前言
近年來,各個大型CTF(Capture The Flag,中文一般譯作奪旗賽,在網絡安全領域中指的是網絡安全技術人員之間進行技術競技的一種比賽形式)比賽中都有了區塊鏈攻防的身影,而且出現的題目絕大多數都是區塊鏈智能合約攻防。此系列文章我們主要以智能合約攻防為中心,來剖析智能合約攻防的要點,前兩篇我們分享了合約反編譯,反彙編的基礎內容。後續的文章中,我們會繼續分享CTF比賽中智能合約常見題型(重入,整數溢出,空投,隨機數可控等)及解題思路,相信會給讀者帶來不一樣的收穫。
上篇文章中我們分享了CTF比賽中常考的重入漏洞題型,本篇繼續來分享CTF比賽中的整數溢出題型,也是比較常見的一類題型,當然多數CTF智能合約題目並不僅僅考察單個漏洞的攻防,可能涉及多個漏洞的組合。
本篇我們以2018年WCTF上BelluminarBank題目為例,給大家分享智能合約整數溢出的題型。解出這道題不僅需要整數溢出攻擊,也需用到變量覆蓋,權限設置等多個攻擊技巧。
題目地址:
由於WCTF智能合約比賽沒有在以太坊測試網(ropsten)進行,沒有在線的攻防場景,合約具體題目介紹及合約源碼已在GitHub給出:https://github.com/beched/ctf/tree/master/2018/wctf-belluminar
題目分析
題目提示
團隊需要對字節碼進行反向工程,並使用以下攻擊:
整數溢出繞過存款期限限制;存儲溢出以覆蓋銀行所有者;存儲訪問權限以洩露私有屬性;部署自殺合同以強制將eth發送到目標合同(以解決餘額差異)不一定需要意外的以太攻擊,如果使用withdraw()和invest()調用,則可以適當平衡。可能是由於導致錯誤解決方案的巨大錯誤所致:withdraw()函數不會更改balances數組。但是仍然需要事先利用整數溢出。
合約說明
Belluminar Bank非常小而特別。其工作方式如下:
任何人都可以投資任何金額,並應指定存款期限(在此之前存款將被鎖定);存款期限必須比先前客戶的存款期限至少長1年;每個存款分配一個帳號;帳戶0包含31337 wei,由銀行所有者(合同創建者)鎖定多年;存款期限滿一年(如果您不提款),銀行所有者可以沒收您的存款。目標是破解這家銀行並清空其餘額。如果成功,該機器人將向您發送交易數據中的標誌。
合約源碼
pragma solidity ^0.4.23;contract BelluminarBank { struct Investment { uint256 amount; uint256 deposit_term; address owner; } //全局變量 Investment[] balances; uint256 head; address private owner; bytes16 private secret; //secret可讀取 function BelluminarBank(bytes16 _secret, uint256 deposit_term) public { secret = _secret; owner = msg.sender; if(msg.value > 0) { balances.push(Investment(msg.value, deposit_term, msg.sender)); } } function bankBalance() public view returns (uint256) { return address(this).balance; } //局部變量覆蓋全局變量 function invest(uint256 account, uint256 deposit_term) public payable { if (account >= head && account < balances.length) { Investment storage investment = balances[account]; investment.amount += msg.value; } else { if(balances.length > 0) { //存在整數溢出 require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years); } //局部變量 investment.amount = msg.value; investment.deposit_term = deposit_term; investment.owner = msg.sender; balances.push(investment); } } function withdraw(uint256 account) public { require(now >= balances[account].deposit_term); require(msg.sender == balances[account].owner); msg.sender.transfer(balances[account].amount); } function confiscate(uint256 account, bytes16 _secret) public { require(msg.sender == owner); require(secret == _secret); require(now >= balances[account].deposit_term + 1 years); uint256 total = 0; for (uint256 i = head; i <= account; i++) { total += balances[i].amount; delete balances[i]; } head = account + 1; msg.sender.transfer(total); }}
合約分析
從題目提示可以得出,本次攻擊的目的是拿到合約中的所有餘額。並且需要多個漏洞攻擊手法。
先來分析合約中的存在轉帳功能函數withdraw()和confiscate():
function withdraw(uint256 account) public { require(now >= balances[account].deposit_term); require(msg.sender == balances[account].owner); msg.sender.transfer(balances[account].amount);}
withdraw()函數中,會判斷現在的時間是否大於存款的期限,第二句判斷調用者地址是否是存款者地址,如果條件滿足,就會轉出當前合約調用者的存款餘額,可以得出該函數並不能轉出合約所有餘額。
繼續來看第二個函數confiscate():
function confiscate(uint256 account, bytes16 _secret) public { require(msg.sender == owner); require(secret == _secret); require(now >= balances[account].deposit_term + 1 years); uint256 total = 0; for (uint256 i = head; i <= account; i++) { total += balances[i].amount; delete balances[i]; } head = account + 1; msg.sender.transfer(total);}
confiscate()函數中會依次判斷所有者地址,secret,存款期限,如果條件滿足,之後會對存款數組進行遍歷,將得到的資金amount全都賦予total,最終通過transfer()將所有餘額轉出,也就是說合約所有者可以將之前存款記錄的餘額全部取出。很明顯,該confiscate()函數就是我們最終需要利用的轉帳函數。
如果要使用confiscate()函數進行轉帳,我們需要解決該函數中的前三行代碼判斷條件,由於題目給出了提示-存儲溢出以覆蓋銀行所有者。我們繼續分析該合約變量覆蓋問題。
從合約源碼可看到,該合約中有四個全局變量(balances,head,owner,secret),在solidity中,全局變量存儲在storage當中,對於複雜的數據類型,比如array(數組)和struct(結構體),在函數中作為局部變量時,也會默認儲存在storage當中。並且solidity的狀態變量存儲時,都是按照狀態在合約中的先後順序進行依次存儲。
也就是說目前合約的四個全局變量存儲如下:
storage[0] : balancesstorage[1] : headstorage[2] : ownerstorage[3] : secret
在invest()函數中,通過Investment storage investment = balances[account];可得到一個結構體變量investment,該變量有三個成員均存在賦值操作,如下:
investment.amount = msg.value; investment.deposit_term = deposit_term;investment.owner = msg.sender;
在函數中作為局部變量時,也會默認儲存在storage當中,由於結構體變量investment並未對三個成員進行初始化,所以當變量存儲時依然會按照順序存儲在storage[0,1,2]中,那麼目前storage中的存儲為數據為:
這裡局部變量覆蓋全局變量時要特別注意一點:局部變量amount覆蓋全局變量balances時,由於balances變量是數組的長度(目前數組中有合約部署者傳入的一組數據,故balances為1),當其他調用者也傳入的一組數據,比如傳入的msg.value值為1(也就是amount的值為1時),之後變量覆蓋後的balances值也為1,但是由於傳入了一組數據後,數組的長度balances變為2,由於變量覆蓋相互影響的關係,balances的值為2後,amount的值也變為2,也就是說雖然傳入的msg.value值為1,但最終amount的值為2。
我們繼續來分析合約漏洞,由於上圖invest()函數中賦值的三個變量(msg.value,deposit_term,msg.sender)都可控:
第一個變量msg.value,是調用者傳入的資金;
第二個變量deposit_term本身的含義是存款期限,調用者可根據自己情況輸入需要存款的時間,在合約中發生變量覆蓋後則代表head值(存款數據的索引)。並且在invest()函數中存在和deposit_term變量相關聯的判斷條件require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years);我們將它和confiscate()函數中的判斷條件require(now >= balances[account].deposit_term + 1 years);進行對比。
由於我們最終需要利用confiscate()函數中的transfer函數進行轉帳,如果按照正常邏輯運算,我們存錢後至少需要一年時間才能取出,所以該判斷條件(require(now >= balances[account].deposit_term + 1 years);)必須設法繞過。同時我們還需要invest()函數中的判斷條件(require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years);)也正常執行。
可以看到這兩行代碼的條件判斷中加減操作並沒有做安全防護,這裡假如我們使balances[balances.length - 1].deposit_term + 1 years值等於2256,由於solidity的存儲關係,這裡會發生整數上溢出,最終結果為0,就可以繞過該判斷條件。還需要注意的一點為:confiscate()函數中的for循環需要head(由於變量覆蓋的關係,head值為deposit_term傳入的值)從0開始才能將所有的資金取出,所以需要我們對deposit_term進行兩次賦值:第一次賦值為2256 - 1 years(solidity中默認時間單位為秒,故這裡的賦值為:2^256 - 3153600 = 115792089237316195423570985008687907853269984665640564039457584007913098103936 ),第二次賦值為0(賦值為0,判斷條件也恆成立)。
第三個變量msg.sender,從上圖可以看出,該變量傳入後覆蓋全局變量owner,當前調用者地址就會變為合約所有者,從而就可繞過confiscate()函數中msg.sender == owner判斷條件。
由於secret存儲在storage(storage變量是指永久存儲在區塊鏈中的變量),所以我們可以調用storage索引獲取裡面的值。
至此confiscate()函數中的前三句判斷條件均已滿足。
解題思路
通過分析BelluminarBank合約漏洞,我們可以利用整數溢出,變量覆蓋,訪問權限等漏洞攻擊轉出合約所有餘額。具體解題思路如下:
通過調用invest()函數傳入account為1,deposit_term為115792089237316195423570985008687907853269984665640564039457584007913098103936,攜帶的msg.value為1wei。account始終根據第一句的判斷條件進行賦值。msg.value賦值amount,再進行balances變量覆蓋(由於變量循環賦值的關係),最終結果balances=amount=2;deposit_term值變量覆蓋也成為head值;調用者地址msg.sender最終變量覆蓋後會成為合約所有者owner。調用之後balances[balances.length - 1].deposit_term + 1 years發生整數溢出,繞過判斷條件。繼續調用invest()函數傳入account為2,deposit_term為0,攜帶的msg.value為2wei,msg.value賦值amount,再進行balances變量覆蓋(由於變量循環賦值的關係),最終結果balances=amount=3;deposit_term值變量覆蓋也成為head值為0,相當於還原head原始的值。由於balances變量的循環覆蓋的關係,最終的合約餘額會有差別,可通過合約自毀或者withdraw()函數調整合約餘額。調用confiscate()函數傳入兩個參數:account為1,secret值為我們之後通過storage獲取的密碼值,最終取走合約所有的餘額。攻擊演示
本次攻擊演示在ropsten測試網進行,使用工具為Remix+Matemask+myetherwallet
Remix在線編輯器:http://remix.ethereum.org/
MetaMask錢包插件:https://metamask.io/
MyEtherWallet在線錢包:https://www.myetherwallet.com/
1.首先部署BelluminarBank漏洞合約
使用在線編輯器Remix通過Meta Mask在線錢包A地址部署BelluminarBank合約,部署時給合約傳入參數為:
value:31337 wei,deposit_term:0x00000000000000000000000000000001,_secret:1000
(為了方便查看數據,我們將合約源碼中的一部分內容進行了可見性修改)
部署完成後目前合約中變量值為以下
2.使用myetherwallet在線錢包調用BelluminarBank合約
在remix中獲取api並複製部署的合約地址,填入myetherwallet錢包中。
連接成功
3.調用invest()函數修改合約所有者owner,存款期限數值deposit_term,變量重複覆蓋值amount
傳入參數為:
value:0.000000000000000001 ETH,account:1,deposit_term:115792089237316195423570985008687907853269984665640564039457584007913098103936。
完成後目前合約中變量值為以下
4.調用invest()函數修改存款期限數值deposit_term(修改head為0),變量重複覆蓋值amount
傳入參數為:
value:0.000000000000000002 ETH,account:2,deposit_term:0。
完成後目前合約中變量值為以下
雖然上圖中顯示的合約全部餘額為31340 wei,但調用過程中出現循環變量覆蓋,導致數組中的餘額為31337+2+3 =31342 wei,如下圖所示:
為了使合約本身餘額與數組中的amount匹配,這裡我們選擇強制給該轉幣。
5.通過c地址部署合約並調用taijie()函數自毀合約給BelluminarBank合約轉2 wei,平衡合約數組中的餘額。
自毀成功後,BelluminarBank合約餘額變為31342 wei。
6.調用confiscate()函數最終取走合約所有的餘額
傳入兩個參數:account:2,secret:0x00000000000000000000000000000001
調用完成後,BelluminarBank合約餘額變為0。
至此完成攻擊演示
總結
本篇文章中,我們通過2018WCTF比賽中的BelluminarBank智能合約題目,詳細分析了合約存在的漏洞問題,提供了解題思路並進行了攻擊演示,其中使用的相關工具已在文中給出連結,希望對智能合約初學者及愛好者有所幫助,下一篇我們會繼續分享CTF智能合約經典題目,請大家持續關注。