區(qū)塊鏈安全—詳談合約攻擊(一)

一、合約何以智能?

在前文中,我們詳細(xì)的講述了Pos、DPos、BFT等常用的落地項(xiàng)目中的一些共識機(jī)制。而讀者在了解了共識機(jī)制的具體流程后也應(yīng)該會向我一樣驚共識的協(xié)議之美。在區(qū)塊鏈中,除了共識機(jī)制以外,還有另外一種富含魅力的技術(shù),那就是“智能合約”。智能合約的引入增強(qiáng)的區(qū)塊鏈的發(fā)展軌跡,也為區(qū)塊鏈技術(shù)帶來了更多生機(jī)。

而智能合約的重要性到底是如何呢?我們應(yīng)該如何看待智能合約?

提及智能合約,我們首先要說明的是在早期的時(shí)候,智能合約與區(qū)塊鏈本是兩個(gè)獨(dú)立的技術(shù)。而區(qū)塊鏈誕生要晚于智能合約。也就是說,區(qū)塊鏈1.0出世的時(shí)候智能合約還沒有被采納入?yún)^(qū)塊鏈技術(shù)。而隨著區(qū)塊鏈的發(fā)展,人民發(fā)現(xiàn)區(qū)塊鏈在價(jià)值傳遞的過程中需要一套規(guī)則來描述價(jià)值傳遞的方式,這套規(guī)則應(yīng)該令機(jī)器進(jìn)行識別和執(zhí)行而不是人為。在最早的比特幣中還沒有出現(xiàn)這種方法,而隨著以太坊的出現(xiàn),這種假設(shè)在智能合約的幫助下成為了可能。

按照歷史的發(fā)展,智能合約最早出現(xiàn)在了1995年,也就是說幾乎與互聯(lián)網(wǎng)同時(shí)代出現(xiàn)的。從本質(zhì)上講,只能合約類似于計(jì)算機(jī)語言中的if-then語句。智能合約通過如下方式與真實(shí)世界進(jìn)行交互:當(dāng)一個(gè)預(yù)先編好的條件被觸發(fā)時(shí),智能合約執(zhí)行相應(yīng)的條款,而系統(tǒng)通過相應(yīng)的條款進(jìn)行交易的執(zhí)行。

在區(qū)塊鏈2.0時(shí)代到來后,區(qū)塊鏈正式與智能合約相結(jié)合。這也使區(qū)塊鏈技術(shù)真正的脫離了數(shù)字貨幣的枷鎖,成為一門獨(dú)立的技術(shù)。由于智能合約的引入,區(qū)塊鏈的應(yīng)用場景一下子廣泛了起來。現(xiàn)在在許多行業(yè)中都可以看到區(qū)塊鏈的身影。

那么智能合約是什么呢?智能合約的本質(zhì)其實(shí)就是一段使用計(jì)算機(jī)語言而編程的程序,這段程序可以運(yùn)行在區(qū)塊鏈系統(tǒng)所提供的容器中,同時(shí)這個(gè)程序也可以在某種外在、內(nèi)在的條件下被激活。這種特性與區(qū)塊鏈技術(shù)相融合不僅避免了人為對規(guī)則的篡改,而且發(fā)揮了智能合約在效率和成本方面的優(yōu)勢。

在安全方面,由于智能合約代碼放在了區(qū)塊鏈中并且在區(qū)塊鏈系統(tǒng)提供的容器中運(yùn)行的,在結(jié)合密碼學(xué)技術(shù)的前提下,區(qū)塊鏈具有了天然的防篡改以及防偽造的特性。

二、以太坊第二次Parity安全事件

1 Solidity 的三種調(diào)用函數(shù)

在講解第二次Parity安全事件之前,我們要對一些相關(guān)的安全函數(shù)進(jìn)行研究分析。我們在之前的稿件中曾經(jīng)對delegatecall()函數(shù)進(jìn)行過詳細(xì)的講述。而今我們對其他三種函數(shù)進(jìn)行更多的分析。

delegatecall()函數(shù)的濫用

在Solidity中我們需要知道幾個(gè)函數(shù):call()、delegatecall()、callcode()。在合約中使用此類函數(shù)可以實(shí)現(xiàn)合約之間相互調(diào)用及交互。而兩次Parity安全事件都是由于類似的幾個(gè)函數(shù)出現(xiàn)了問題而導(dǎo)致以太幣被盜。所以掌握此類調(diào)用函數(shù)的正確用法也是分析區(qū)塊鏈安全所必不可少的。

而我們知道,msg中保存了許多關(guān)于調(diào)用方的一些信息,例如交易的金額數(shù)量、調(diào)用函數(shù)字符的序列以及調(diào)用發(fā)起人的地址信息等。然而當(dāng)上述三種函數(shù)在調(diào)用的過程中, Solidity 中的內(nèi)置變量 msg 會隨著調(diào)用的發(fā)起而改變。

下面我們就詳細(xì)的講解一下此類三種函數(shù)的異同點(diǎn)以及安全隱患。

contract D {
  uint public n;
  address public sender;

  function callSetN(address _e, uint _n) {
    _e.call(bytes4(sha3("setN(uint256)")), _n); // E's storage is set, D is not modified 
  }

  function callcodeSetN(address _e, uint _n) {
    _e.callcode(bytes4(sha3("setN(uint256)")), _n); // D's storage is set, E is not modified 
  }

  function delegatecallSetN(address _e, uint _n) {
    _e.delegatecall(bytes4(sha3("setN(uint256)")), _n); // D's storage is set, E is not modified 
  }
}

contract E {
  uint public n;
  address public sender;

  function setN(uint _n) {
    n = _n;
    sender = msg.sender;
    // msg.sender is D if invoked by D's callcodeSetN. None of E's storage is updated
    // msg.sender is C if invoked by C.foo(). None of E's storage is updated

    // the value of "this" is D, when invoked by either D's callcodeSetN or C.foo()
  }
}

contract C {
    function foo(D _d, E _e, uint _n) {
        _d.delegatecallSetN(_e, _n);
    }
}

delegatecall: 對于msg方面,其函數(shù)被調(diào)用后值不會修改為調(diào)用者,但是其執(zhí)行在調(diào)用者的運(yùn)行環(huán)境中。這個(gè)函數(shù)也經(jīng)常爆出很嚴(yán)重的漏洞,例如我曾經(jīng)講述的第一次Parity的安全漏洞就是因?yàn)榇撕瘮?shù)將調(diào)用者環(huán)境中的函數(shù)跨合約執(zhí)行。

call: 此函數(shù)為最常用的調(diào)用方式,與delegatecall不同的是,而此時(shí)msg的值將修改為調(diào)用者,執(zhí)行環(huán)境為被調(diào)用者的運(yùn)行環(huán)境(合約的 storage)。

callcode: 同call函數(shù)一樣,調(diào)用后內(nèi)置變量 msg 的值會修改為調(diào)用者,但執(zhí)行環(huán)境為調(diào)用者的運(yùn)行環(huán)境。

pragma solidity ^0.4.0;

contract A {
    address public temp1;
    uint256 public temp2;

    function three_call(address addr) public {
        addr.call(bytes4(keccak256("test()")));                 // call函數(shù)
        addr.delegatecall(bytes4(keccak256("test()")));       // delegatecall函數(shù)
        addr.callcode(bytes4(keccak256("test()")));           // callcode函數(shù)
    }
}

contract B {
    address public temp1;
    uint256 public temp2;

    function test() public  {
        temp1 = msg.sender;
        temp2 = 100;
    }
}

在實(shí)驗(yàn)開始前,部署合約后查看合約A、B中的變量均為temp1 = 0, temp2 = 0。

現(xiàn)在調(diào)用語句1 call 方式,觀察變量的值發(fā)現(xiàn)合約 A 中變量值為0,而被調(diào)用者合約 B 中的 temp1 = address(A), temp2 = 100。即msg中的地址為調(diào)用者(address(A)),而環(huán)境為被調(diào)用者B(temp2 = 100)。

下面使用調(diào)用語句2 delegatecall 方式,觀察變量的值發(fā)現(xiàn)合約 B 中變量值為 0,而調(diào)用者合約 A中 temp2 = 100。即調(diào)用函數(shù)后內(nèi)置變量 msg 的值不會修改為調(diào)用者,但執(zhí)行環(huán)境為調(diào)用者的運(yùn)行環(huán)境。

現(xiàn)在調(diào)用語句3 callcode 方式,觀察變量的值發(fā)現(xiàn)合約 B 中變量值為 0,而調(diào)用者合約 A 中的temp1 = address(A), temp2 = 100。即調(diào)用后內(nèi)置變量 msg 的值會修改為調(diào)用者,但執(zhí)行環(huán)境為調(diào)用者的運(yùn)行環(huán)境。

之后我們就可以分析第二次Parity攻擊事件了。

2 事件分析

在Parity錢包中為了方便用戶的使用提供了多簽合約模板,而用戶使用此模板可以生產(chǎn)自己的多方簽名合約并且不需要很大的代碼量。而在Parity錢包的實(shí)際業(yè)務(wù)中都會通過delegatecall函數(shù)內(nèi)嵌式地交給庫合約。相當(dāng)于我的關(guān)機(jī)核心代碼部署在服務(wù)器方,不用用戶自行部署。由于多簽合約的主邏輯(代碼量較大),所以合約部署一次即可,不然用戶全部都要在本地部署是一個(gè)很不理智的行為。除此之外,這還可以為用戶節(jié)省部署多簽合約所耗費(fèi)的大量Gas。

下面我們看一下問題代碼:代碼

Parity 多簽名錢包第二次被黑事件是一個(gè)例子,說明了如果在非預(yù)期的環(huán)境中運(yùn)行,良好的庫代碼也可以被利用。我們來看看這個(gè)合約的相關(guān)方面。這里有兩個(gè)包含利益的合約,庫合約和錢包合約。

contract WalletLibrary is WalletEvents {
  
  ...
 // constructor - stores initial daily limit and records the present day's index.
  function initDaylimit(uint _limit) internal {
    m_dailyLimit = _limit;
    m_lastDay = today();
  }
  // (re)sets the daily limit. needs many of the owners to confirm. doesn't alter the amount already spent today.
  function setDailyLimit(uint _newLimit) onlymanyowners(sha3(msg.data)) external {
    m_dailyLimit = _newLimit;
  }
  // resets the amount already spent today. needs many of the owners to confirm.
  function resetSpentToday() onlymanyowners(sha3(msg.data)) external {
    m_spentToday = 0;
  }

  // throw unless the contract is not yet initialized.
  modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

  // constructor - just pass on the owner array to the multiowned and
  // the limit to daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }

  // kills the contract sending everything to `_to`.
  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }
...
  
}

再看錢包合約,

contract Wallet is WalletEvents {

  ...

  // METHODS

  // gets called when no other function matches
  function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }
  
  ...  

  // FIELDS
  address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe;
}
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
}

// constructor is given number of sigs required to do protected "onlymanyowners" transactions
// as well as the selection of addresses capable of confirming them.
function initMultiowned(address[] _owners, uint _required) only_uninitialized {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i) {
        m_owners[2 + i] = uint(_owners[i]);
        m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
}

// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

根據(jù)上述代碼我們知道,此時(shí)為了防止第一次的Parity中的問題,這里的幾段函數(shù)都增加了only_uninitialized來限制簽名人的數(shù)量。

Wallet 合約基本上會通過 delegate call 將所有調(diào)用傳遞給 WalletLibrary。此代碼段中的常量地址 _walletLibrary,即是實(shí)際部署的 WalletLibrary 合約的占位符。

而我們可以使用WalletLibrary 合約可以初始化,并被用戶擁有。

function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }

倘若我們能夠執(zhí)行上述合約中的_walletLibrary.delegatecall(msg.data);,此時(shí),我們通過往這個(gè)合約地址轉(zhuǎn)賬一個(gè)value = 0, msg.data.length > 0的交易,以執(zhí)行_walletLibrary.delegatecall分支。并將msg.data中傳入我們要執(zhí)行的initWallet ()函數(shù)。而此類函數(shù)的特性也就幫助我們將錢包進(jìn)行了初始化。

function initMultiowned(address[] _owners, uint _required) only_uninitialized {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i) {
        m_owners[2 + i] = uint(_owners[i]);
        m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
}

這個(gè)函數(shù)假定創(chuàng)建者會調(diào)用initWallet函數(shù),但是initWallet的only_uninitialized ()函數(shù)在內(nèi)部被執(zhí)行,所以攻擊者成為了所謂的owner(可以控制系統(tǒng)運(yùn)行相應(yīng)函數(shù))。

第一次調(diào)用initWallet交易結(jié)果:

Function: initWallet(address[] _owners, uint256 _required, uint256 _daylimit)
MethodID: 0xe46dcfeb
[0]:0000000000000000000000000000000000000000000000000000000000000060
[1]:0000000000000000000000000000000000000000000000000000000000000000
[2]:0000000000000000000000000000000000000000000000000000000000000000
[3]:0000000000000000000000000000000000000000000000000000000000000001
[4]:000000000000000000000000ae7168deb525862f4fee37d987a971b385b96952

之后攻擊者拿到了系統(tǒng)的控制權(quán)限,調(diào)用了kill函數(shù):

/ kills the contract sending everything to `_to`.
  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }

隨后調(diào)用 kill() 功能。因?yàn)橛脩羰?Library 合約的所有者,所以修改傳入、Library 合約自毀。因?yàn)樗鞋F(xiàn)存的 Wallet 合約都引用該 Library 合約,并且不包含更改引用的方法,因此其所有功能(包括取回 Ether 的功能)都會隨 WalletLibrary 合約一起丟失。

自殺之后,唯一可以用的函數(shù)只有:

// gets called when no other function matches
  function() payable {
    // just being sent some cash?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
  }

這種類型的 Parity 多簽名錢包中的所有以太都會立即丟失或者說永久不可恢復(fù)。

流程圖如下:

image.png

3防御措施

這檔次的Parity事件有幾種預(yù)防的方式,一是智能合約摒棄自殺函數(shù),這樣的話即使黑客獲得了高級權(quán)限也無法將合約移除。第二是可以進(jìn)一步對initWallet、initDaylimit及initMultiowned添加internal限定類型,以禁止外部調(diào)用:

// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) internal only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
}

// constructor - stores initial daily limit and records the present day's index.
function initDaylimit(uint _limit) internal only_uninitialized {
    m_dailyLimit = _limit;
    m_lastDay = today();
}

// constructor is given number of sigs required to do protected "onlymanyowners" transactions
// as well as the selection of addresses capable of confirming them.
function initMultiowned(address[] _owners, uint _required) internal only_uninitialized {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i) {
        m_owners[2 + i] = uint(_owners[i]);
        m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
}

可以增加internal()函數(shù)來增強(qiáng)限制函數(shù)的作用。

三、總結(jié)

本次事件是在第一次Parity事件的基礎(chǔ)之上衍生出來的。攻擊者屬于無意識的誤操作。

image.png

而針對此次事件,我認(rèn)為官方需要引起相應(yīng)的思考,其實(shí)在問題曝光之前就有網(wǎng)友提到過此類問題,但是并沒有引起相關(guān)的關(guān)注。所以Parity管理者應(yīng)該對此進(jìn)行關(guān)注。其次,在漏洞修補(bǔ)方面,我認(rèn)為應(yīng)該更加嚴(yán)格的賦給權(quán)限,做到完全禁止外部陌生用戶的訪問。在合約設(shè)計(jì)方面,我認(rèn)為類似于KILL這樣的危險(xiǎn)函數(shù)就盡量不要出現(xiàn)。

四、參考資料

本稿為原創(chuàng)稿件,轉(zhuǎn)載請標(biāo)明出處。謝謝。

首發(fā)于:https://xz.aliyun.com/t/3299
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容