作者:啟明星辰ADLab

1 概述

隨著區塊鏈、以太坊技術的興起和不斷成熟,安全問題也隨之而來,今年智能合約漏洞已經讓多個區塊鏈項目價值瞬間歸零。智能合約的開發語言、設計模式、運行機制都與傳統應用有較大差異,它既有傳統的安全風險(如整數溢出等),又有獨特的新型風險(如私有變量不“私有”和特殊類型變量覆蓋等)。研發人員如果不能深刻理解這些核心原理,則很容易編寫出存在漏洞的智能合約;惡意合約也可以通過這種方法留下隱蔽漏洞,欺騙合約投資人并暗地里收割。本文以WCTF2018的一道智能合約漏洞賽題[1]為例,從solidity語言特性出發,深度解讀以太坊智能合約漏洞原理和攻擊利用。

2 漏洞合約分析

該合約是一個銀行類合約,用戶可以存入eth到該合約,并在存入到期之后取出。原題對該合約描述如下:

該合約中存在漏洞,攻擊者利用漏洞可以盜取合約中的所有余額。漏洞涉及到整數溢出、變量覆蓋以及由變量覆蓋導致的變量相互影響。

合約源碼如下:

要提取合約的全部合約余額,confiscate 函數是關鍵,但該函數調用成功必須滿足:

  • msg.sender == owner

  • secret == _secret

  • now >= balances[account].deposit_term + 1 years

攻擊者可以通過合約存儲訪問、整數溢出和變量覆蓋來依次構造上述條件。

2.1 solidity全局變量存儲

在BelluminarBank合約中,一共有4個全局變量,分別是balances、head、owner、secrete。它們的默認訪問屬性是private,看上去只有合約自己能夠訪問這些變量。事實上,合約的所有變量數據都是公開存儲在鏈上的區塊中,任何人都可以通過訪問存儲數據來獲得這些變量的值[2]。在solidity語言中,全局變量都存儲在storage中,根據solidity的變量存儲規則,定長的變量在storage中是順序存儲的,數組變量在storage中其索引位置存放的是其數組長度(參見[3])。該合約storage中的變量存儲布局如下:

對于在公鏈部署的合約,可通過以太坊web3接口web3.eth.getStorageAt(co ntractAddress, index)獲取某個合約指定storage索引的數據。

因此,secrete并不是一個不可獲取的私有數據,攻擊者只需要訪問該合約storage中的數據就可以構造confiscate 函數的secret == _secret條件。

2.2 solidity全局變量覆蓋

BelluminarBank合約中的confiscate函數要求調用者必須是合約擁有者才可以進行余額提取操作,看上去攻擊者是無法提取的。然而,由于solidity語言的局部變量存儲特性,導致本合約的owner變量可以被修改,覆蓋問題出現在 invest 函數中。

首先來看solidity局部變量覆蓋全局storage的問題。solidity語言的變量存儲有一個特性,即數組、映射、結構體類型的局部變量默認是引用合約的storage [4],而全局變量默認存儲在storage中。因此,如果這些局部變量未被初始化,則它們將直接指向storage,修改這些變量就是在修改全局變量。

以如下的簡單合約test為例,函數test1中定義了一個局部結構體變量x,但是沒有對其進行初始化。根據solidity的變量存儲規則,這時候x是存儲在storage中的,而且是從索引0開始,那么對其成員變量x,y賦值之后,剛好覆蓋了全局變量a和b。有興趣可以在 remix 中在線對本合約進行調試。

pragma solidity 0.4.24;

contract test {

    struct aa{

        uint x;

        uint y;

    }

    uint public a = 4;

    uint public b = 6;

    function test1() returns (uint){

        aa x;

        x.x = 9;

        x.y = 7;

    }

}

在invest函數的else分支中,使用了一個局部結構變量investment。該局部變量在當前執行分支中并沒有被初始化,默認指向合約的storage。執行中對該變量的成員賦值就會直接覆蓋全局變量,覆蓋關系為:

同時,在變量覆蓋之前必須滿足如下條件,即存款期限是最末一個存款記錄的期限后一年:deposit_term >= balances[balances.length - 1].deposit_term + 1 years。由于deposit_term是用戶提供的,輕松就可以滿足。

所以,通過精心構造invest函數的參數就可以覆蓋stroage中的sender,從而改變該合約的擁有者為攻擊者,突破confiscate 函數的msg.sender == owner限制。

2.3 整數溢出

在BelluminarBank合約源碼的confiscate函數還有另外一個如下的時間限制,即必須在存款滿一年后才能提取,now >= balances[account].deposit_term + 1 years

上一節用于全局變量覆蓋的存款操作使得balances中最末一個存儲記錄的期限已經是1年后,即攻擊者至少在2年后才能調用confiscate函數進行提款。與此同時,deposit_term在賦值給局部變量的時候會把全局變量head覆蓋為超大的數,這也使得后續的for (uint256 i = head; i <= account; i++)循環處理無法提取全部的存款,因為head不為0。

顯然,必須把head覆蓋為0才能提取全部的存款,即invest函數的deposit_term參數必須為0。但如果該參數為0,又無法滿足invest函數的全局變量覆蓋執行的條件deposit_term >= balances[balances.length - 1].deposit_term + 1 years

仔細分析可發現,如果balances[balances.length - 1].deposit_term+ 1 years恰好等于0,則上述的條件恒為真。顯然,balances[balances.length - 1].deposit_term只要取值為(uint256_max – 1 years + 1),就會導致相加后的值為uint256_max+1。這個結果會超過uint256的表達空間,產生溢出導致最后的值為0。

因此,攻擊者先做第一次存款,把balances最后一項的deposit_term設置為特殊值;然后做第二次存款,deposit_term傳入0值,就能觸發整數溢出,繞過變量覆蓋條件限制并修改head為0值。

2.4 “變量糾纏”的副作用

在全局變量覆蓋中,很容易產生“變量糾纏”現象,從而觸發一些容易被忽視的副作用。這里以一個簡單合約test為例,函數testArray中依然存在結構體局部變量a覆蓋全局變量x的情況。但由于x是數組變量,其直接索引的storage存儲位置僅存儲其數組長度,也就是a.x只會覆蓋x的數據長度,而a.y將覆蓋變量num。

在testArray函數中,賦值操作a.x = 5時,因為x.length與變量a.x處于同一存儲位置,賦值后數組x的長度變成了5。接下來,賦值a.y,并將變量a加入到數組x。所以變量a實際上加入到了數組x索引為5的位置。如果調試testArray函數執行,會發現在函數執行完畢之后,x[5].x = 6, x[5].y = 7。

這是為什么呢?明明代碼中賦值寫的是 a.x = 5,a.y = 7。這就是全局變量x和局部變量a形成了“糾纏”,首先是局部變量a修改導致全局變量x改變,然后是全局變量x修改導致了局部變量修改,最后把修改后的局部變量又存儲到修改后的全局變量。這里即是,賦值操作a.x = 5時,把數組x的長度變成了5; 接下來x.push操作,實際上是先將該數組x的長度加1,此時a.x = 6; 最后再把a.x = 6, a.y=7加入到x[5]。所以,存入數據的x就是新數組的長度6。

pragma solidity 0.4.24;

contract test {

    struct aa{

        uint x;

        uint y;

    }

    aa [] x;

    uint public num = 4;



    function testArray() returns (uint){

        aa a;

        a.x = 5;

        a.y = 7;

        x.push(a);

    }

}

3 漏洞利用方式

在第2節中對合約 BelluminarBank存在的幾個漏洞進行了分析,下面將說明如何利用這個漏洞提取合約的全部余額,這里在Remix在線編譯環境中部署該合約,并演示其利用方式。

首先部署合約,在部署參數中設置secrete 為“0x01”,deposit_term為1000,msg.value為 31337 wei。

部署合約后,合約的全局變量如下圖所示:

這樣,合約目前的余額是 31337 wei,合約擁有者的地址為:0xca35b7d915458ef54 0ade6068dfe2f44e8fa733c。

下面開始需要構造條件使得攻擊者可以成功調用confiscate函數。

步驟1: 覆蓋owner并構造整數溢出條件

要想轉走合約余額,首先必須修改合約的owner。利用局部結構體 investment 修改合約owner,需滿足條件:

  1. account < head or account >= balances.length

  2. deposit_term >= balances[balances.length – 1].deposit_term + 1 years

設置攻擊者(0x1472…160C)的invest調用參數如下:

  • msg.value = 1 wei (因為在合約初始化時owner已經存入一筆金額,所以此時balances數組長度為1,為了不改變balances數組長度,這里依然將其設置為1 we i

  • depositsit_term = 2^256 - 1 years = 115792089237316195423570985008687907853269984665640564039457584007913098103936 (在步驟2中需要利用這個數值構造溢出,同時這個值可以使源碼中 require 條件得到滿足)

  • account = 1 (滿足條件 account >= balances.length)

調用之后,新的存款記錄數據將存放在balances數組索引為1的位置。此時的balances數組情況和全局storage變量情況如下圖所示。

可以發現,owner已經修改為攻擊者地址,同時head被傳入的deposit_term覆蓋為一個超大值。

而提取余額是從balances數組中head索引開始的存款記錄開始計算數額的。顯然,為了提取到合約owner的余額,即balances[0]賬戶的余額,head必須被覆蓋為0。因此,需要進行第二次storage變量覆蓋,修改head。

步驟2: 恢復head并繞過deposit_term限制

繼續設置攻擊者調用invest的參數:

  • msg.value = 2wei (同樣保證balances的長度覆蓋后不出現錯誤)

  • deposit_term = 0: 恢復head

  • account = 2 (滿足條件 account >= balances.length 即可)

因為在步驟 1 中,已經將balances[1].deposit_term 設置為 2^256 -1 years,因此在第二次調用 invest 函數時,由于balances[balances.length - 1].deposit_term + 1 years”溢出為0滿足了require條件,所以可以成功進行第二次覆蓋。

這樣即滿足了調用confiscate函數的條件msg.sender == owner,通過讀取storage很容易獲得secrete,條件secret == _secret 也可以滿足,同時還重新覆蓋了head使之變為0 。

覆蓋之后全局storage變量和balances數組如下圖所示:

可以發現head已經修改為0了。

現在來看看第三個條件:

now >= balances[account].deposit_term + 1 years

account是傳入的數據,目前合約中account數量為3。在前面的invest調用后, balances[2].deposit_term = 0。 顯然條件 now >= balances[2].deposit_term + 1 years 成立,所以在恢復head數據的同時,也繞過了confiscate函數中對于存款期限的判定。接下來只要調用函數confiscate時,設置account 為 2,便可使時間判斷條件滿足,同時也能提取所有賬戶的余額。

步驟3: 增加合約余額

經過步驟1和步驟2,仿佛攻擊者已經可以調用confiscate函數提取所有余額了,然而實際上是不行的。交易會發生回滾,這是為什么呢?

仔細分析前面的數據就會發現,步驟1中msg.value為 1 wei,但是最后balances數組中的balances[1].amount 卻變成了 2 wei。這是因為變量覆蓋過程中產生了“糾纏”副作用,由于msg.value覆蓋balances數組的長度,balances更新前增加了數組長度,數組長度又改變了msg.value,最后導致存入的amount變成了新的數組長度,即2。

所以,每次調用invest函數進行變量覆蓋,存款記錄的賬目金額都比調用者實際支付的msg.value大。下圖是兩次調用invest之后的balances數組情況。

從圖中可以看出,存款記錄中的賬面值會比實際交易的msg.value多 1 wei。通過confiscate函數計算得到的所有賬戶總額為31342 wei,而實際的合約賬戶總余額為 31340 wei。

為了能夠將合約中所有余額提取出來,需要增加合約的真實余額,使其同存款記錄中的余額相等。然而,通過invest方式增加的余額都會被計入賬面余額,那么怎么在不通過invest函數的情況下增加合約的真實余額呢?

答案是selfdestruct函數。

selfdestruct函數會將該合約的余額轉到指定賬戶,然后從區塊鏈中銷毀該合約的代碼和storage。該函數的官方文檔說明[5]如下:

因此,可以構造一個合約,然后在合約中調用selfdestruct函數將合約的余額轉給BelluminarBank合約。為此,構造如下合約:

contract donar{

    function donar() public payable{

        selfdestruct(contractAddr);

    }

}

該合約創建后馬上銷毀,同時將自己的余額轉給銀行合約。

在 remix 中 編譯該合約,同時將 contractAddr替換為銀行合約地址。然后 在deploy該合約時,設置 msg.value 為2 wei。當合約創建又銷毀之后,其余額(2wei)將轉給銀行賬戶,使銀行合約的賬面余額和實際余額一致,這樣confiscate函數調用就能夠正確執行。

Donar合約部署設置如下:

合約部署完之后,BelluminarBank 合約余額如下圖:

步驟4:調用confiscate提取合約余額

經過上面的操作之后,設置confiscate函數的參數為[2,“0x01”]即可將合約的全部余額轉走。

參考鏈接

  1. https://github.com/beched/ctf/tree/master/2018/wctf-belluminar

  2. https://solidity.readthedocs.io/en/v0.4.24/security-considerations.html#private-information-and-randomness

  3. https://medium.com/aigang-network/how-to-read-ethereum-contract-storage-44252c8af925

  4. http://solidity.readthedocs.io/en/v0.4.24/frequently-asked-questions.html

  5. https://solidity.readthedocs.io/en/v0.4.24/introduction-to-smart-contracts.html?highlight=selfdestruct


啟明星辰積極防御實驗室(ADLab)

ADLab成立于1999年,是中國安全行業最早成立的攻防技術研究實驗室之一,微軟MAPP計劃核心成員。截止目前,ADLab通過CVE發布Windows、Linux、Unix等操作系統安全或軟件漏洞近400個,持續保持國際網絡安全領域一流水準。實驗室研究方向涵蓋操作系統與應用系統安全研究、移動智能終端安全研究、物聯網智能設備安全研究、Web安全研究、工控系統安全研究、云安全研究。研究成果應用于產品核心技術研究、國家重點科技項目攻關、專業安全服務等。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/661/