solidity&blockchain初探

collectcrop Lv3

solidity这个语言广泛运用与智能合约的编写,想要入门区块链就得先了解这个语言。该语言其实与c语言用法类似。在solidity语言中,标识执行账户是用地址实现的,比如我们MetaMask中创建的Account的地址或是某个合约的地址。

1.一些基本概念与工具站

1)账户

外部账户

外部账户是由人创建的,可以存储以太币,是由公钥和私钥控制的账户。每个外部账户拥有一对公私钥,这对密钥用于签署交易,它的地址由公钥决定。外部账户不能包含以太坊虚拟机(EVM)代码。

一个外部账户具有以下特性

  • 拥有一定的 Ether
  • 可以发送交易、通过私钥控制
  • 没有相关联的代码

合约账户

合约账户是由外部账户创建的账户,包含合约代码。合约账户的地址是由合约创建时合约创建者的地址,以及该地址发出的交易共同计算得出的。

一个合约账户具有以下特性

  • 拥有一定的 Ether
  • 有相关联的代码,代码通过交易或者其他合约发送的调用来激活
  • 当合约被执行时,只能操作合约账户拥有的特定存储

2)合约

在区块链和智能合约的上下文中,合约通常是指一种程序或协议,能够在区块链上自动执行、控制或文档化法律事件和行动。以下是合约的一些关键特征:

  1. 智能合约:智能合约是一种自执行的合约,其中协议的条款以代码形式写入,运行在区块链上。它们能够自动执行合约条款,减少对中介的依赖。
  2. 去中心化:合约在区块链上运行,没有单一控制点,这使得合约更加透明和安全。
  3. 不可篡改性:一旦合约部署到区块链上,其内容就无法更改,这提供了强有力的防篡改保障。
  4. 透明性:合约的代码和执行是公开的,任何人都可以查看,从而提高了信任度。
  5. 自动执行:合约可以根据预设条件自动执行,省去人工干预的需要。例如,当某个条件被满足时,合约会自动转移资产。
  6. 多种用途:合约可以用于多种场景,如金融交易、身份验证、供应链管理、投票系统等。

3)常用网站及插件

  • MetaMask插件:可以创建属于自己的以太网账户,拥有一个地址,其相当于一个钱包,存着你不同网络中的以太币。
  • Remix:一个在线IDE,用于编辑合约以及与合约交互。
  • Fauctes:水龙头,可以用来免费获取测试网络中免费的货币。
  • infura:可以获取apikey,用于web3开发测试。

2.存储类型

Solidity 主要有三种存储类型:

  • storage:永久存储,存储在区块链上,所有合约状态变量(如 uint256 totalSupply;)都是存储在这里。每次修改都会消耗 gas。
  • memory:临时存储,存储在内存中,生命周期仅在当前调用期间。函数调用结束后,数据会被清除。适用于需要临时使用的数据,如函数内部的计算结果。
  • calldata:用于函数参数的只读数据存储位置,数据存在于外部调用时的输入中,通常用于优化 gas 使用。

3.修饰符

1)访问修饰符

  • public:函数或变量可以被任何合约或外部账户访问。
  • private:函数或变量只能在定义它的合约内部访问,其他合约无法访问。
  • internal:函数或变量只能在当前合约及其子合约中访问,外部合约无法访问。
  • external:函数只能被外部账户或其他合约调用,不能在合约内部调用。

2)状态修饰符

  • view:函数不会修改区块链状态,且可以读取合约的状态变量。调用此函数不会消耗 gas。
  • pure:函数不读取或修改任何状态变量,也不访问任何合约的状态。它只能使用传入的参数。调用此函数同样不会消耗 gas。
  • payable:函数可以接收 ETH。用于处理涉及资金转移的功能。

3)其他常用修饰符

  • require:用于验证条件是否为真,如果条件不满足,则抛出异常并撤销交易。常用于输入验证和权限检查。
  • assert:用于检查不应发生的条件,如果条件不满足,则抛出异常并撤销交易。通常用于内部错误和不变性验证。
  • revert:显式撤销交易,并可以返回错误消息。与 require 类似,但可以用于更复杂的条件检查。

4)自定义修饰符

常见的是用于权限控制。

1
2
3
4
5
6
7
8
modifier onlyOwner {
require(msg.sender == owner, "Not the contract owner");
_; //_;用于替换实际的执行逻辑
}

function restrictedFunction() public onlyOwner {
// 只有合约的拥有者可以执行此函数
}

5)Fallback 函数

特殊的函数,当合约接收到 ETH 但没有匹配的函数调用时会被执行。可以用于接收资金。

4.常用内置函数以及全局变量

  • abi.encodePacked 是 Solidity 中的一个内置函数,用于将多个参数编码为一个字节数组。它在处理数据时非常有用,特别是在需要进行哈希计算、合约交互或其他数据处理时。
  • Keccak256: 将输入数据(无论大小)转换为固定长度的输出(256 位),即 32 字节的哈希值。
  • msg.sender:指向当前运行合约账户的地址
  • tx.origin:存着整个调用链最原始的调用者的地址,及交易的原始发起方

5.从示例看基础语法

example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// WelcomeSHCTF2024.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract WelcomeSHCTF2024 {

string public storedFlag;

constructor(string memory flag) {
string memory xorResult = xorWithSHCTF(flag);
storedFlag = xorResult;
}

function xorWithSHCTF(string memory flag) internal pure returns (string memory) {
bytes memory flagBytes = bytes(flag);
bytes memory xorKey = bytes("shctf");
bytes memory result = new bytes(flagBytes.length);

for (uint256 i = 0; i < flagBytes.length; i++) {
result[i] = bytes1(uint8(flagBytes[i]) ^ uint8(xorKey[i % xorKey.length]));
}

return string(result);
}

function verifyXORedFlag(string memory inputFlag) public view returns (bool) {
return keccak256(abi.encodePacked(storedFlag)) == keccak256(abi.encodePacked(xorWithSHCTF(inputFlag)));
}
}
  • pragma solidity ^0.8.0;:指明合约是用 Solidity 编写的,并且要求编译器版本为 0.8.0 或更高。
  • contract:中文译为合约,类似与class,实际上就是声明一个对象。
  • string private storedFlag:这个就很熟悉了,就是类型+访问修饰符+变量名的组合,声明一个变量
  • constructor(params){}:这个就是该合约的构造函数,在创建时会接受参数并初始化
  • function:声明一个方法,参数可以带上修饰符,后面也可以跟上若干修饰符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


interface IWelcomeSHCTF2024 {
function verifyXORedFlag(string memory inputFlag) external view returns (bool);
function storedFlag() external view returns (string memory);
}

contract exp{
IWelcomeSHCTF2024 public tar;

constructor(address contractAddress) {
// 使用给定的地址初始化合约实例
tar = IWelcomeSHCTF2024(contractAddress);
}

function xorWithSHCTF(string memory flag) internal pure returns (string memory) {
bytes memory flagBytes = bytes(flag);
bytes memory xorKey = bytes("shctf");
bytes memory result = new bytes(flagBytes.length);

for (uint256 i = 0; i < flagBytes.length; i++) {
result[i] = bytes1(uint8(flagBytes[i]) ^ uint8(xorKey[i % xorKey.length]));
}

return string(result);
}

function getFlag() public view returns (string memory) {
// 调用 WelcomeSHCTF2024 合约的 verifyXORedFlag 函数
return xorWithSHCTF(tar.storedFlag());
}
}

我们也可以写一个与上述实例相交互的脚本,这里我们假设上面的storedFlag是个public变量,其中要先定义一个interface接口,里面要写这个接口中能被外部调用的方法,也就是有external或public修饰的方法,其具体定义可以直接复制源码中方法的定义。这样定义之后,我们就可以通过传入合约实例的地址,来创建这么一个接口实例,然后就能调用该实例对外公开的方法。要访问属性的话要通过getter方法,也就是多的一句function storedFlag() external view returns (string memory);来实现属性的接口调用。我们可以在本地做实验以验证。

remix合约部署

可以先创建一个新的工作区,选择default project就行。然后在contracts目录下新建自己合约文件,如WelcomeSHCTF2024.sol以及exp.sol,然后选择编译器版本后进行编译,之后转到Deploy & run transactions界面。

在环境上选Remix VM(与实际的测试网交互要选WalletConnect来连接到自己的账户),然后我们可以在deploy部署前输入一个flag字符串,作为该合约的constructor的参数。点击deploy进行合约的部署。

部署成功后底下Deployed Contracts会显示出内容,我们可以通过点击按钮来调用各个接口,有些接口的调用我们需要传参。可以发现我们的public变量也可以作为接口调用,点击storedFlag就能获取到原合约异或加密后的storedFlag的值

然后我们复制一下这个自己部署的合约的地址,在编译完exp.sol后,在CONTRACT中选择exp.sol,传入刚才部署的合约地址用以接口调用。

成功以后,用我们之前编写的getFlag外部方法,就能直接获取到我们之前部署的flag了,这样能够获取到public存储的flag密文。

6.存储层面

这个感觉在ctf解题中是很重要的,感觉ctf-wiki中已经讲的很好了,这里我再整理一遍吧。

插槽

以太坊数据存储会为合约的每项数据指定一个可计算的存储位置,存放在一个容量为 2^256 的超级数组中,数组中每个元素称为插槽,其初始值为 0。虽然数组容量的上限很高,但实际上存储是稀疏的,只有非零 (空值) 数据才会被真正写入存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 插槽式数组存储
----------------------------------
| 0 | # slot 0
----------------------------------
| 1 | # slot 1
----------------------------------
| 2 | # slot 2
----------------------------------
| ... | # ...
----------------------------------
| ... | # 每个插槽 32 字节
----------------------------------
| ... | # ...
----------------------------------
| 2^256-1 | # slot 2^256-1
----------------------------------

当数据长度是已知时,其具体的存储位置将在编译时指定,而对于长度不确定的类型(如动态数组、映射),则会按一定规则计算存储位置。以下是对不同类型变量的储存模型的具体分析。

存储规则

  • 存储插槽以低位对齐方式存储,在图上直观表示就是右对齐
  • 每个基本类型只占存储它们所需字节
  • 一个插槽内能存多个类型
  • 如果存储插槽中的剩余空间不足以储存一个基本类型,那么它会被移入下一个存储插槽
  • 结构和数组数据总是会占用一整个新插槽(但结构或数组中的各项,都会以这些规则进行打包)
一般存法

如以下合约:

1
2
3
4
5
6
7
8
pragma solidity ^0.4.0;

contract C {
address a; // 0
uint8 b; // 0
uint256 c; // 1
bytes24 d; // 2
}

其存储布局如下:

1
2
3
4
5
6
7
-----------------------------------------------------
| unused (11) | b (1) | a (20) | <- slot 0
-----------------------------------------------------
| c (32) | <- slot 1
-----------------------------------------------------
| unused (8) | d (24) | <- slot 2
-----------------------------------------------------
动态数组存法

会占用对应位置 p 处的插槽,用以储存数组的长度,而数组真正的起始点会位于 keccak256(p)

如以下合约:

1
2
3
4
5
6
7
pragma solidity ^0.4.0;

contract C {
uint256 a; // 0
uint[] b; // 1
uint256 c; // 2
}

其存储布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-----------------------------------------------------
| a (32) | <- slot 0
-----------------------------------------------------
| b.length (32) | <- slot 1
-----------------------------------------------------
| c (32) | <- slot 2
-----------------------------------------------------
| ... | ......
-----------------------------------------------------
| b[0] (32) | <- slot `keccak256(1)`
-----------------------------------------------------
| b[1] (32) | <- slot `keccak256(1) + 1`
-----------------------------------------------------
| ... | ......
-----------------------------------------------------
字节数组和字符串存法

如果 bytesstring 的数据很短,那么它们的长度也会和数据一起存储到同一个插槽。具体地说:如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2。如果数据长度超出 31 字节,则在主插槽存储 length * 2 + 1, 数据照常存储在 keccak256(slot) 中。

映射存法

对于映射,其会占据位置 p 处的一个插槽,但该插槽不会被真正使用。映射中的键 k 所对应的值会位于 keccak256(k . p), 其中 . 是连接符。如果该值同时是一个非基本类型,则将 keccak256(k . p) 作为偏移量来找到具体的位置。

如以下合约:

1
2
3
4
5
6
pragma solidity ^0.4.0;

contract C {
mapping(address => uint) a; // 0
uint256 b; // 1
}

其存储布局如下:

1
2
3
4
5
6
7
8
9
10
11
-----------------------------------------------------
| reserved (a) | <- slot 0
-----------------------------------------------------
| b (32) | <- slot 1
-----------------------------------------------------
| ... | ......
-----------------------------------------------------
| a[addr] (32) | <- slot `keccak256(addr . 0)`
-----------------------------------------------------
| ... | ......
-----------------------------------------------------

不同类型所占字节数

X={8,16,24,32,40,48,56,64,128,256} N={x|1<=x<=16,x=32}

表中{x}代表X集合中某个元素,{n}表示N集合中某个元素,???为动态类型数据

类型 大小(字节)
address 20
address payable 20
bool 1
uint{x} {x}/8
int{x} {x}/8
bytes{n} {n}
bytes(动态字节数组) ???
string(动态字符串) ???
结构体、数组、映射 ???

7.题目分析

[SHCTF] just Signin

先看题目提供的合约,其中有存了一个flag,然后我们可以从外部调用verifyXORedFlag来验证我们输入的flag是否正确,但显然我们不可能直接去爆破flag的值,这时候我们参考ctf-wiki中关于Ethereum Storage的介绍。

由于以太坊上的所有信息都是公开的,所以即使一个变量被声明为 private,我们仍能读到变量的具体值。

利用 web3 提供的 web3.eth.getStorageAt() 方法,可以读取一个以太坊地址上指定位置的存储内容。所以只要计算出了一个变量对应的插槽位置,就可以通过调用该函数来获得该变量的具体值。`

那么我们就可以去用web3这个js库编写脚本来分析插槽内容。根据内存存储的规则,string类型的变量是动态分配内存的,由于存的内容大于31字节,所以该位置slot0会存大小,而keccak256(0)中会存实际内容。而且由于内容大于32字节,所以我们要连续读几个插槽,这里读两个就能读到全部内容了。

在写脚本过程中还遇到了几个坑:

  • 直接keccak256(0)出来的插槽位置中是全空的,实际上我们要得到的插槽位置在keccak256(abi.encodePacked(0))中,也就是在keccak256('0x0000000000000000000000000000000000000000000000000000000000000000')中。
  • 其中用npm装web3库时后面测试运行时会报错,原因是我拿apt装的nodejs版本较低,解决方法是拿nvm重装高版本nodejs。
  • const web3 = new Web3("https://sepolia.infura.io/v3/your_api");这个创建实例一开始我后面的url不知道填什么,从ChainList找了几个url填进去,然后会发现对应地址处的插槽是全空的,显然是找错链了。后面在infura注册后用里面的测试网络sepolia能够正确找到对应合约。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// const Web3 = require('web3');
import Web3 from 'web3';
// 连接到 Sepolia 测试网络(你需要替换成合适的提供商 URL)
const web3 = new Web3("https://sepolia.infura.io/v3/your_api");

// 合约地址(在题目中提供的地址)
const contractAddress = "0x3948DF4C50B1671eaa6b22876Ea746899a6916C1";

// 获取存储的 private 变量 storedFlag
async function getPrivateVariable() {
try {
// 读取存储插槽的数据
const data = await web3.eth.getStorageAt(contractAddress, 0);
console.log(`Stored len in slot 0:`, data);

var startSlot = await BigInt(web3.utils.keccak256('0x0000000000000000000000000000000000000000000000000000000000000000'));
// 要读取的起始插槽
const numSlots = 2; // 要读取的插槽数量

for (let i = 0; i < numSlots; i++) {
const slot = startSlot + BigInt(i);
const storageData = await web3.eth.getStorageAt(contractAddress, slot);
console.log(`Data at slot ${slot}:`, storageData);
}

} catch (error) {
console.error(`Error reading slot:`, error);
}

}

getPrivateVariable();

然后我们就能得到经过异或加密的密文,简单解密回去后就能得到flag

python解密exp

1
2
3
4
5
6
7
8
9
enc = [32, 32, 32, 32, 32, 8, 31, 6, 69, 5, 28, 5, 6, 43, 18, 28, 55, 23, 28, 85, 44, 10, 82, 27, 5, 24, 43, 11, 21, 15, 29, 55, 20, 68, 20, 31, 12, 30]
key = 'shctf'

flag = ""
for i in range(len(enc)):
ch = enc[i] ^ ord(key[i%5])
flag += chr(ch)

print("".join(flag))
  • 标题: solidity&blockchain初探
  • 作者: collectcrop
  • 创建于 : 2024-10-08 00:04:22
  • 更新于 : 2024-10-08 00:09:20
  • 链接: https://collectcrop.github.io/2024/10/08/solidity-blockchain初探/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。