Balsn CTF 2019 - Bank & 第一届钓鱼城杯 strictmathematician

 

前言

这两题一题是由Balsn战队举办的比赛中出现的,另一题则是在钓鱼城杯中利用相似的原理出的。总的来说是非常的有趣,最近抽出了一段时间好好感受了一下题目。

首先鸣谢两位师傅:
Pikachu师傅
Ainevsia师傅
在很大的程度上帮助我理解了题目。


题目考点主要在于理解动态数组、结构体以及mapping的存储方式,还有结构体中函数的存储。
首先我们给出钓鱼城这题的源码

pragma solidity ^0.4.23;

contract StrictMathematician {
    address owner;
    string private constant welcome = "Oh, fantansitic baby! I am a strict mathematician";
    uint randomNumber = 0;
    uint createtime = now;

    constructor() public payable{
        owner = msg.sender;
    }

    struct Target {
        function() internal callback;
        uint32 value;
        address origin;
        address sender;
        bytes12 hash;
        uint time;
    }
    Target[] Targets;

    struct FailLog {
        uint idx;
        address origin;
        uint time;
        bytes12 guessnum;
        address sender;
    }
    mapping(address => FailLog[]) FailLogs;
    event SendFlag(address addr);

    function start(bytes12 hash) public payable {
        Target target;
        target.origin = tx.origin;
        target.sender = msg.sender;
        target.hash = hash;
        require(msg.value == 1 ether);
        target.value += 1;
        Targets.push(target);
    }

    function guess(uint idx, bytes12 num) public {
        if (bytes12(keccak256(abi.encodePacked(num))) != Targets[idx].hash) {
            FailLog faillog;
            faillog.idx = idx;
            faillog.time = now;
            faillog.origin = tx.origin;
            faillog.sender = msg.sender;
            faillog.guessnum = num;
            FailLogs[msg.sender].push(faillog);
        } else {
            Target target = Targets[idx];
            target.value += 1;
        }
    }

    function check(uint idx, uint tmp) public {
        uint maxlen = check_len(address(msg.sender)) + tmp * 3 / 4 ;
        require(uint(read_slot(uint(cal_mapaddr(uint(msg.sender),4)))) <= maxlen);
        require(tmp != 0);
        Target target = Targets[idx+tmp];
        require(uint32(target.value+1)==0);
        target.callback();
    }

    function payforflag() public payable {
        require(address(this).balance == 0);
        emit SendFlag(msg.sender);
        selfdestruct(msg.sender);
    }

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

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

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

    function check_len(address addr) internal pure returns(uint maxlen){

        uint res = uint(cal_arrayaddr(uint(cal_mapaddr(uint(addr),4))));
        uint sum = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
        uint begin = uint(keccak256(abi.encodePacked(uint(3))));
        uint distance;
        uint remainder;

        if (res>begin) {
            distance = res - begin;
        } else{
            distance = sum - begin + res + 1;
        }

        remainder = distance % 3;
        if (remainder==0) {
            maxlen = 1;
        } else if (remainder==1) {
            maxlen = 3;
        } else {
            maxlen = 2;
        }
    }
}

Solidity在0.5以下都有因为结构体未初始化覆盖存储的漏洞。那么这里首先我们就可以看到两个结构体都会造成不同程度的覆盖,首先我们可以画出如下的storage地址结构。
若造成覆盖那么

我们可以看到这里是比较有趣的。 他里面的内存排布是非常重要的。所以最开始我有一个点没有看懂,后来在pikachu师傅的指点下才明白过来。

上面是进行赋值前的 slot0 和 赋值后的
在变化的时候是

通过这个就可以比较明显的看出来了,原来我一直以为是0初始值(x

然后我们通过审计源码可以发现,触发flag的要素是需要我们的合约清空,但是没有任何可以转账出去的函数,这就很迷奇了。但是我们可以发现,因为变量覆盖的原因。

它可以做到覆盖我们的Target结构体的长度。那么这样我们相当于实现了一个Target数组的任意写(实质上并不是得计算排布)。然后还有一个比较奇妙的点就是我们结构体中的callback()函数,这里我引用Ainevsia师傅博客里的一段

这种function类型的变量占据8个字节,就像C语言里的函数指针一样,调用这个变量所指向的函数的时候会使用JUMP指令跳转到该变量所表示的地址上。

这里提及一个其他知识点,Solidity中如果想Jump目标地址后面必须有Jumpdest,否则就会停止。这个考点在JOP类型题中比较关键。用于构造类似pwn中rop链的一种exp。题目中事件后是跟着一个Jumpdest无须多虑
那我们可以想到通过把event 那个事件的指针覆盖到我们的callback指针上,通过ida-evm插件的观察可以看到他的地址是0x0153 , 也就是需要将他覆盖上,然后唯一能对结构体做一定手脚的就是check函数,

有一个这样的条件,我们可以考虑让 FailLog和一段Target互相重叠达成如下图所示的效果。

那么这样我们可以传一个 0xffffffff0000000000000153 这样就可以把这个部署上去了。
然后就是经典的对需要写的目标地址进行计算
target = kecaak256(keccak256(abi.encode(addr,4)))+3
base = keccak256(3)
distance = (2^256-base+target) % (2^256), idx = distance // 3
然后就是要慢慢调整我们的结构达成上一个图的模式,最后调用check他callback的时候就会成功调用我们所覆写的地址了。从而成功触发event,得到flag。


接下来简要说明下 BalsnCTF 2019

pragma solidity ^0.4.24;

contract Bank {
    event SendEther(address addr);
    event SendFlag(address addr);

    address public owner; // 0
    uint randomNumber = RN; // 1

    constructor() public {
        owner = msg.sender;
    }

    struct SafeBox {
        bool done;  // 0_0_1
        function(uint, bytes12) internal callback;  // 0_1_9
        bytes12 hash;   // 0_9_21
        uint value; // 1
    }
    SafeBox[] safeboxes; // 2

    struct FailedAttempt {
        uint idx; // 0 
        uint time; // 1
        bytes12 triedPass; // 2_0_12
        address origin; // 2_12_32
    }
    mapping(address => FailedAttempt[]) failedLogs; // 3

    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);
    }

}

可以发现这个结构体以及整体的布局结构和上面的是非常相似的。
只是function的结构体他只有两个storage占用,所以到时候调整的时候应该是除以2来调整以及计算。

致谢

  • Ainevsia
  • Pikachu

结语

感觉这里已经有一点pwn的意思了,后续想继续看看JOP相关的题目,感觉会很有意思分别是 Rw3rd的Re:Montagy以及qwb2020线下的EGM。因为这类题目的opcode 都需要精心的排布才能成功做成一个链。到时候也想去尝试下。

(完)