天天看點

【轉載】智能合約安全事故回顧分析(1):The Dao事件

首先需要說明的一點是,這個世界上沒有絕對安全的技術。在區塊鍊發展的十年裡,各種基于區塊鍊的數字貨币引發的安全事故層出不窮,這些安全威脅主要來源有三個方面:

  1. 自身安全機制的問題,類似智能合約。
  2. 生态安全問題,交易所,礦池,網站等等。
  3. 使用者安全問題,包括個人賬号密碼的洩露,被釣魚等。

作為普通的開發人員或者有一定程式設計知識的從業人員,我們首先應該確定的是自身安全機制沒有問題,當然這個“沒有問題”是一個相對的概念。智能合約的安全為什麼這麼重要,這很大原因在于智能合約程式設計和傳統程式設計的巨大差別:

  1. 智能合約本身開發簡單,但是卻能夠存儲幾千萬到幾十億的的資産。
  2. 智能合約部署的過程是一次共識的過程,如果部署以後發現了安全問題,不能通過傳統的打更新檔或者更新的方式來避免。必須在設計和編碼的過程中處理好這些容錯和異常終止邏輯。
  3. 智能合約的代碼都是開放的,多任何人可見。這其中就包括了一些不懷好意的黑客,沒有傳統開發過程中的加密,通路控制。

本系列希望通過對過往發生的一些安全事故的回顧,來提醒或者說警醒各位開發者,在開發的過程中,即便不能做到百分百安全,那麼起碼能做到“吸取前人的教訓”,避免已經發生過的安全事故再次發生。

本文介紹的是對以太坊影響深遠的The Dao 智能合約漏洞事件。

事件介紹

The Dao 是一個去中心化的自治風險投資基金,通過釋出的智能合約來募集資金,參與者可以通過投票的方式來投資以太坊上的應用,如果盈利,參與者就能獲得回報。2016年6月17日,一名黑客發現了The Dao募資合約的漏洞,使得他可以無限的從合約中轉出資金,短短幾小時,360萬的以太币被轉出。這件事對以太坊的發展産生了巨大的影響,最後為了彌補使用者的損失V神智能采用軟分叉的方式,即所有通過這個The Dao的合約來減少新增使用者餘額的方式都被視為無效。

漏洞原因

首先請讀者看一下合約中的代碼,這端代碼的業務邏輯是:如果使用者不同意其他使用者的投票,可以選擇分裂出去。簡單的說就是使用者拿錢給基金會投資,中間使用者如果反悔可以随時退錢。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

//使用者選擇分裂出去調用的函數

function splitDAO(

uint

_proposalID, address _newCurator) noEther onlyTokenholders returns (

bool

_success) {

// ...

//利用平衡數組計算應該轉移多少代币 p是提案對象

uint

fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance) / p.splitData[0].totalSupply;

if

(p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == 

false

)

throw

;

// ...

// Burn DAO Tokens

Transfer(msg.sender, 0, balances[msg.sender]);

withdrawRewardFor(msg.sender);  

// 轉移對應的金額給使用者

// XXXXX Notice the preceding line is critically before the next few

totalSupply -= balances[msg.sender];    

// 相應變量更新

balances[msg.sender] = 0;   

// 餘額置為0

paidOut[msg.sender] = 0;

return

true

;

}

function withdrawRewardFor(address _account) noEther 

internal

returns(

bool

_success) {

if

((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])

throw

;

uint

reward = (balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];

if

(!rewardAccount.payOut(_account, reward))    

// XXXXX vulnerable

throw

;

paidOut[_account] += reward;

return

true

;

}

function payOut(address _recipient, 

uint

_amount) returns (

bool

) {

if

(msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))

throw

;

if

(_recipient.call.value(_amount)()) { 

// XXXXX vulnerable

PayOut(_recipient, _amount);

return

true

;

else

{

return

false

;

}

}

  

上面的代碼在了解業務很容易明白:

使用者提出分裂--》合約計算應該退給使用者的金額--》調用call函數發送金額給使用者--》使用者的賬戶餘額歸為0,即先是調用splitDAO,splitDao中調用withdrawRewardFor,withdrawRewardFor中調用payOut執行轉賬。

乍一看沒什麼問題,講述黑客的攻擊手段之前,回顧一下solidity程式設計中的知識點:如果call函數的調用結果是true就一定是執行成功的嗎?答案是NO,因為有可能是執行了回調函數。當調用call.value的時候,會把所有的gas發送到合約位址上并執行預設函數。是以這個預設函數将會有足夠的gas執行任何操作,包括重新調用原合約的接口。本次攻擊的黑客正式利用了這一點。

攻擊手段

  1. 黑客先是通過自己建立了一個合約Child Dao,這個合約擁有一個回調函數,這個函數的作用就是去調用The Dao中的splitDao。
  2. 黑客送出了splitDao,位址是Child Dao的位址,當然在此之前的操作都是合法的操作,滿足The Dao定義的調用splitDao的條件。
  3. 結合上面的代碼,你會發現,開發者的代碼先是在函數withdrawRewardFor中把金額退還給了使用者,然後在退出函數之後将使用者的餘額置為0。那麼如果攻擊者在withdrawRewardFor和餘額置空之間在此調用withdrawRewardFor,将會再次向攻擊者送出的位址轉移賬戶金額。結合剛才介紹的call函數知識點,聰明的讀者應該能夠想到攻擊的原理了。黑客利用了call函數的機制,在合約中再次調用轉賬申請,由于上一次轉賬申請的餘額還沒有更新,是以第二次也會成功。相當于在循環中的重複調用自己,程式設計中的遞歸。

如何防範

其實The Dao的開發者的漏洞代碼在傳統的程式設計中沒有任何問題,傳統程式設計為了應對事務處理的結果,往往在轉賬之後進行餘額的更新,因為有可能因為網絡等原因導緻轉賬不成功,如果程式提前把使用者的賬戶餘額置為0則容易引發資料丢失的問題。本次The Dao事件的代碼修複可以從多方面來考慮:

  1. 調整代碼順序,在轉賬之前執行餘額減扣。
  2. 避免不可控的函數調用,黑客利用call函數fallback的調用機制來攻擊,這個場景其實在很多别的攻擊事件中也可能發生,後面介紹的DOS攻擊中黑客也利用了這一點。一方面應該避免這種方式調用,其實還應該避免在合約中直接使用轉賬操作,可以在設計的時候提供一個轉賬mapping,每個使用者可以提現金額的多少對應其中的key value,讓使用者主動去操作這個接口完成調用。因為合約主動調用本身就存在安全隐患,合約的權限大于所有人。

//使用者選擇分裂出去調用的函數

function splitDAO(

uint

_proposalID, address _newCurator) noEther onlyTokenholders returns (

bool

_success) {

// ...

//利用平衡數組計算應該轉移多少代币 p是提案對象

uint

fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance) / p.splitData[0].totalSupply;

if

(p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == 

false

)

throw

;

// ...

// Burn DAO Tokens

Transfer(msg.sender, 0, balances[msg.sender]);

withdrawRewardFor(msg.sender);  

// 轉移對應的金額給使用者

// XXXXX Notice the preceding line is critically before the next few

totalSupply -= balances[msg.sender];    

// 相應變量更新

balances[msg.sender] = 0;   

// 餘額置為0

paidOut[msg.sender] = 0;

return

true

;

}

function withdrawRewardFor(address _account) noEther 

internal

returns(

bool

_success) {

if

((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])

throw

;

uint

reward = (balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];

if

(!rewardAccount.payOut(_account, reward))    

// XXXXX vulnerable

throw

;

paidOut[_account] += reward;

return

true

;

}

function payOut(address _recipient, 

uint

_amount) returns (

bool

) {

if

(msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))

throw

;

if

(_recipient.call.value(_amount)()) { 

// XXXXX vulnerable

PayOut(_recipient, _amount);

return

true

;

else

{

return

false

;

}

}