1. 程式人生 > >【原創】智慧合約安全事故回顧分析(1):The Dao事件

【原創】智慧合約安全事故回顧分析(1):The Dao事件

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

  1. 自身安全機制的問題,類似智慧合約。

  2. 生態安全問題,交易所,礦池,網站等等。

  3. 使用者安全問題,包括個人賬號密碼的洩露,被釣魚等。

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

  1. 智慧合約本身開發簡單,但是卻能夠儲存幾千萬到幾十億的的資產。

  2. 智慧合約部署的過程是一次共識的過程,如果部署以後發現了安全問題,不能通過傳統的打補丁或者升級的方式來避免。必須在設計和編碼的過程中處理好這些容錯和異常終止邏輯。

  3. 智慧合約的程式碼都是開放的,多任何人可見。這其中就包括了一些不懷好意的黑客,沒有傳統開發過程中的加密,訪問控制。

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

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

事件介紹

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

漏洞原因

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

//使用者選擇分裂出去呼叫的函式
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,讓使用者主動去操作這個介面完成呼叫。因為合約主動呼叫本身就存在安全隱患,合約的許可權大於所有人。