Solidity的三種合約間的調用方式 call、delegatecall 和 callcode


0x00 前言

Solidity(http://solidity.readthedocs.io/en/v0.4.24/) 是一種用與編寫以太坊智能合約的高級語言,語法類似於 JavaScript。

Solidity 編寫的智能合約可被編譯成為字節碼在以太坊虛擬機上運行。Solidity 中的合約與面向對象編程語言中的類(Class)非常類似,在一個合約中同樣可以聲明:狀態變量、函數、事件等。同時,一個合約可以調用/繼承另外一個合約。

在 Solidity 中提供了 call、delegatecall、callcode 三個函數來實現合約之間相互調用及交互。正是因為這些靈活各種調用,也導致了這些函數被合約開發者“濫用”,甚至“肆無忌憚”提供任意調用“功能”,導致了各種安全漏洞及風險:

2017.7.20,Parity Multisig電子錢包版本 1.5+ 的漏洞被發現,使得攻擊者從三個高安全的多重簽名合約中竊取到超過 15 萬 ETH(https://blog.zeppelin.solutions/on-the-parity-wallet-multisig-hack-405a8c12e8f7) ,其事件原因是由於未做限制的 delegatecall 函數調用了合約初始化函數導致合約擁有者被修改。

2018.6.16,「隱形人真忙」在先知大會上演講了「智能合約消息調用攻防」(https://paper.seebug.org/625/)的議題,其中提到了一種新的攻擊場景—— call 注⼊,主要介紹了利用對 call 調用處理不當,配合一定的應用場景的一種攻擊手段。接着於 2018.6.20,ATN 代幣團隊發布「ATN抵御黑客攻擊的報告」(https://paper.seebug.org/621/),報告指出黑客利用 call 注入攻擊漏洞修改合約擁有者,然后給自己發行代幣,從而造成 ATN 代幣增發。

由此本文主要是針對 Solidity 合約調用函數call、delegatecall、callcode 三種調用方式的異同、濫用導致的漏洞模型並結合實際案例進行分析介紹。

0x01 Solidity 的三種調用函數

在 Solidity 中,call 函數簇可以實現跨合約的函數調用功能,其中包括 call、delegatecall 和 callcode 三種方式。

以下是 Solidity 中 call 函數簇的調用模型:

<address>.call(...) returns (bool) <address>.callcode(...) returns (bool) <address>.delegatecall(...) returns (bool)

這些函數提供了靈活的方式與合約進行交互,並且可以接受任何長度、任何類型的參數,其傳入的參數會被填充至 32 字節最后拼接為一個字符串序列,由 EVM 解析執行。

在函數調用的過程中, Solidity 中的內置變量 msg 會隨着調用的發起而改變,msg 保存了調用方的信息包括:調用發起的地址,交易金額,被調用函數字符序列等。

三種調用方式的異同點

  • call: 最常用的調用方式,調用后內置變量 msg 的值會修改為調用者,執行環境為被調用者的運行環境(合約的 storage)。
  • delegatecall: 調用后內置變量 msg 的值不會修改為調用者,但執行環境為調用者的運行環境。
  • callcode: 調用后內置變量 msg 的值會修改為調用者,但執行環境為調用者的運行環境。

通過下面的例子對比三種調用方式,在 remix 部署調試,部署地址為 0xca35b7d915458ef540ade6068dfe2f44e8fa733c:

pragma solidity ^0.4.0; contract A {    address public temp1;    uint256 public temp2;    function three_call(address addr) public {        addr.call(bytes4(keccak256("test()")));                 // 1        //addr.delegatecall(bytes4(keccak256("test()")));       // 2        //addr.callcode(bytes4(keccak256("test()")));           // 3    } } contract B {    address public temp1;    uint256 public temp2;    function test() public  {        temp1 = msg.sender;        temp2 = 100;    } }

在部署后可以看到合約 A 的變量值: temp1 = 0x0, temp2 = 0x0,同樣合約 B 的變量值也是: temp1 = 0x0, temp2 = 0x0。

現在調用語句1 call 方式,觀察變量的值發現合約 A 中變量值為 0x0,而被調用者合約 B 中的 temp1 = address(A), temp2 = 100:

現在調用語句2 delegatecall 方式,觀察變量的值發現合約 B 中變量值為 0x0,而調用者合約 A 中的 temp1 = 0xca35b7d915458ef540ade6068dfe2f44e8fa733c, temp2 = 100:

現在調用語句3 callcode 方式,觀察變量的值發現合約 B 中變量值為 0x0,而調用者合約 A 中的 temp1 = address(A), temp2 = 100:

0x02 delegatecall 「濫用」問題

delegatecall: 調用后內置變量 msg 的值不會修改為調用者,但執行環境為調用者的運行環境。

原理

在智能合約的開發過程中,合約的相互調用是經常發生的。開發者為了實現某些功能會調用另一個合約的函數。比如下面的例子,調用一個合約 A 的 test() 函數,這是一個正常安全的調用。

function test(uint256 a) public {    // codes } function callFunc() public {    <A.address>.delegatecall(bytes4(keccak256("test(uint256)")), 10); }

但是在實際開發過程中,開發者為了兼顧代碼的靈活性,往往會有下面這種寫法:

function callFunc(address addr, bytes data) public {    addr.delegatecall(data); }

這將引起任意 public 函數調用的問題:合約中的 delegatecall 的調用地址和調用的字符序列都由用戶傳入,那么完全可以調用任意地址的函數。

除此之外,由於 delegatecall 的執行環境為調用者環境,當調用者和被調用者有相同變量時,如果被調用的函數對變量值進行修改,那么修改的是調用者中的變量。

利用模型

下面的例子中 B 合約是業務邏輯合約,其中存在一個任意地址的 delegatecall 調用。

contract B {    address owner;    function callFunc(address addr, bytes data) public {        addr.delegatecall(data);        //address(Attack).delegatecall(bytes4(keccak256("foo()")));  //利用代碼示意    } }

攻擊者對應這種合約可以編寫一個 Attack 合約,然后精心構造字節序列(將注釋部分的攻擊代碼轉換為字節序列),通過調用合約 B 的 delegatecall,最終調用 Attack 合約中的函數,下面是 Attack 合約的例子:

contract Attack {    address owner;    function foo() public {        // any codes    } }

對於 delegatecall 「濫用」的問題,實際的漏洞效果取決於 Attack 合約中的攻擊代碼,可能造成的安全問題包括:

  1. 攻擊者編寫一個轉賬的函數,竊取合約 B 的貨幣
  2. 攻擊者編寫設置合約擁有者的函數,修改合約 B 的擁有者

delegatecall 安全問題案例

Parity MultiSig錢包事件

2017.7.20,Parity Multisig電子錢包版本 1.5+ 的漏洞被發現,使得攻擊者從三個高安全的多重簽名合約中竊取到超過 15 萬 ETH ,按照當時的 ETH 價格來算,大約為 3000 萬美元。

其事件原因是由於未做限制的 delegatecall 可以調用 WalletLibrary 合約的任意函數,並且其錢包初始化函數未做校驗,導致初始化函數可以重復調用。攻擊者利用這兩個條件,通過 delegatecall 調用 initWallet() 函數,最終修改了合約擁有者,並將合約中的以太幣轉到自己的賬戶下。

下面是存在安全問題的代碼片段:

(Github/parity: https://github.com/paritytech/parity/blob/4d08e7b0aec46443bf26547b17d10cb302672835/js/src/contracts/snippets/enhanced-wallet.sol)

a. delegatecall 調用代碼: (contract Wallet is WalletEvents)

// 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);  }

b. initWallet() 與 initMultiowned() 代碼片段: (contract WalletLibrary is WalletEvents)

function initWallet(address[] _owners, uint _required, uint _daylimit) {    initDaylimit(_daylimit);    initMultiowned(_owners, _required); } ... function initMultiowned(address[] _owners, uint _required) {    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; }

其中錢包初始化函數 initMultiowned() 未做校驗,可以被多次調用,存在安全隱患,但由於其位於 WalletLibrary 合約下,是不能直接調用的。黑客利用 Wallet 合約中的 delegatecall 調用 WalletLibrary 合約的 initWallet() 函數,初始化整個錢包,將合約擁有者修改為僅黑客一人,隨后進行轉賬操作。

黑客攻擊鏈:

除了上述 delegatecall 濫用的案例,在分析研究的過程中,發現有部分蜜罐合約利用 delegatecall的特性(拷貝目標到自己的運行空間中執行),在代碼中暗藏后門,暗中修改轉賬地址,導致用戶丟失貨幣。有關 delegatecall 蜜罐的詳情請參考「以太坊蜜罐智能合約分析」,其中的 「4.2 偷梁換柱的地址(訪問控制):firstTest」小節。

0x03 call 安全問題

call: 最常用的調用方式,調用后內置變量 msg 的值會修改為調用者,執行環境為被調用者的運行環境。

call 注入是一種新的攻擊場景,由「隱形人真忙」在先知大會上演講「智能合約消息調用攻防」議題上提出,原因是對 call 調用處理不當,配合一定的應用場景的一種攻擊手段。

call 注入原理

call 調用修改 msg.sender 值

通常情況下合約通過 call 來執行來相互調用執行,由於 call 在相互調用過程中內置變量 msg 會隨着調用方的改變而改變,這就成為了一個安全隱患,在特定的應用場景下將引發安全問題。

外部用戶通過 call 函數再調用合約函數:

高度自由的 call 調用

在某些應用場景下,調用函數可以由用戶指定;下面是 call 函數的調用方式:

<address>.call(function_selector, arg1, arg2, ...) <address>.call(bytes)

從上面可以看出,call 函數擁有極大的自由度:

  1. 對於一個指定合約地址的 call 調用,可以調用該合約下的任意函數
  2. 如果 call 調用的合約地址由用戶指定,那么可以調用任意合約的任意函數

為了便於理解,可以將智能合約中的 call 函數類比為其他語言中的 eval 函數,call 函數相當於給用戶提供了隨意調用合約函數的入口,如果合約中有函數以 msg.sender 作為關鍵變量,那么就會引發安全問題。

call 函數簇調用自動忽略多余參數

call 函數簇在調用函數的過程中,會自動忽略多余的參數,這又額外增加了 call 函數簇調用的自由度。下面的例子演示 call 自動忽略多余參數:

pragma solidity ^0.4.0; contract A {    uint256 public aa = 0;    function test(uint256 a) public {        aa = a;    }    function callFunc() public {        this.call(bytes4(keccak256("test(uint256)")), 10, 11, 12);    } }

例子中 test() 函數僅接收一個 uint256 的參數,但在 callFunc() 中傳入了三個參數,由於 call 自動忽略多余參數,所以成功調用了 test() 函數。

call 注入模型

call 注入引起的最根本的原因就是 call 在調用過程中,會將 msg.sender 的值轉換為發起調用方的地址,下面的例子描述了 call 注入的攻擊模型。

contract B {    function info(bytes data){        this.call(data);        //this.call(bytes4(keccak256("secret()"))); //利用代碼示意    }    function secret() public{        require(this == msg.sender);        // secret operations    } }

在合約 B 中存在 info() 和 secret() 函數,其中 secret() 函數只能由合約自己調用,在 info() 中有用戶可以控制的 call 調用,用戶精心構造傳入的數據(將注釋轉為字節序列),即可繞過 require()的限制,成功執行下面的代碼。

對於 call 注入的問題,實際造成的漏洞影響取決於被調用的函數,那么可能的安全問題包括:

1.權限繞過

如同上面的例子,合約將合約本身的地址作為權限認證的條件之一,但由於 call 的調用會導致 msg.sender 變量值更新為調用方的值,所以就會引起權限繞過的問題。

function callFunc(bytes data) public {    this.call(data);    //this.call(bytes4(keccak256("withdraw(address)")), target); //利用代碼示意 } function withdraw(address addr) public {    require(isAuth(msg.sender));    addr.transfer(this.balance); } function isAuth(address src) internal view returns (bool) {    if (src == address(this)) {        return true;    }    else if (src == owner) {        return true;    }    else {        return false;    } }

上述例子表示了權限繞過導致的任意用戶提取貨幣。,withdraw() 函數設計的初衷為只能有合約擁有者和合約本身可以發起取款的操作;但由於 call 的問題,只要用戶精心拼接字符序列調用 call,從而調用 withdraw() 函數,就可以繞過 isAuth() 並取款。

2.竊取代幣

在代幣合約中,往往會加入一個 call 回調函數,用於通知接收方以完成后續的操作。但由於 call調用的特性,用戶可以向 call 傳入 transfer() 函數調用,即可竊取合約地址下代幣。

下面的例子表示了用戶傳入 transfer() 函數導致竊取代幣。

function transfer(address _to, uint256 _value) public {    require(_value <= balances[msg.sender]);    balances[msg.sender] -= _value;    balances[_to] += _value; } function callFunc(bytes data) public {    this.call(data);    //this.call(bytes4(keccak256("transfer(address,uint256)")), target, value); //利用代碼示意 }

該例子是代幣合約的代碼片段,用戶傳入精心構造的字符序列以通過 call 來調用 transfer() 函數,並傳入 transfer() 的參數 _to 為自己的地址;通過 call 調用后, transfer() 函數執行時的 msg.sender 的值已經是合約地址了,_to 地址是用戶自己的地址,那么用戶就成功竊取了合約地址下的代幣。

call 注入案例

1.ATN代幣增發

2018.5.11,ATN 技術人員收到異常監控報告,顯示 ATN Token 供應量出現異常,通過分析發現 Token 合約由於存在漏洞受到攻擊。該事件對應了上文中的第一種利用模型,由於 ATN 代幣的合約中的疏漏,該事件中 call 注入不但繞過了權限認證,同時還可以更新合約擁有者。

在 ATN 項目中使用到了 ERC223 和 ds-auth 庫,兩個庫在單獨使用的情況下沒有問題,同時使用時就會出現安全問題,以下是存在安全問題的代碼片段。 (Github/ATN: https://github.com/ATNIO/atn-contracts)

a. ERC223 標准中的自定義回調函數: (Github/ERC223: https://github.com/Dexaran/ERC223-token-standard)

function transferFrom(address _from, address _to, uint256 _amount, bytes _data, string _custom_fallback) public returns (bool success) {    ...    if (isContract(_to)) {        ERC223ReceivingContract receiver = ERC223ReceivingContract(_to);        receiver.call.value(0)(bytes4(keccak256(_custom_fallback)), _from, _amount, _data);    }    ... }

b. ds-auth 權限認證和更新合約擁有者函數: (Github/ds-auth: https://github.com/dapphub/ds-auth)

... function setOwner(address owner_) public auth {    owner = owner_;    emit LogSetOwner(owner); } ... modifier auth {    require(isAuthorized(msg.sender, msg.sig));    _; } function isAuthorized(address src, bytes4 sig) internal view returns (bool) {    if (src == address(this)) {        return true;    } else if (src == owner) {        return true;    } else if (authority == DSAuthority(0)) {        return false;    } else {        return authority.canCall(src, this, sig);    } }

黑客通過調用 transferFrom() 函數,並傳入黑客自己的地址作為 _from 參數, ATN 合約的地址作為 _to 參數,並傳入 setOwner() 作為回調函數;在執行過程中,由於 call 調用自動忽略多余的參數,黑客的地址將作為 setOwner() 的參數成功執行到函數內部,與此同時,call 調用已經將 msg.sender轉換為了合約本身的地址,也就繞過了 isAuthorized() 的權限認證,黑客成功將合約的擁有者改為了自己;隨后調用 Mint() 函數為自己發行代幣,最后黑客再次調用 setOwner() 將權限還原,企圖銷毀作案現場。

黑客攻擊鏈:

得力於 ATN 代幣團隊及時發現問題,並高效的解決問題,此次事件並未對 ATN 代幣造成較大的波動;ATN 代幣團隊封鎖了黑客賬戶,也銷毀了由黑客發行的 1100W 個代幣,最后在交易所的配合下追蹤黑客。

2.大量代幣使用不安全代碼

對於第二種利用模型,在目前公開的智能合約中,仍有不少合約使用這種不安全的代碼,為了實現通知接收方以完成后續的操作,加入了一個高度自由的回調函數方法。以下是存在安全隱患的代碼片段:

(etherscan: https://etherscan.io/address/0xbe803e33c0bbd4b672b97158ce21f80c0b6f3aa6#code)

... function transfer(address _to, uint256 _value) public returns (bool success) {    require(_to != address(0));    require(_value <= balances[msg.sender]);    require(balances[_to] + _value > balances[_to]);    balances[msg.sender] -= _value;    balances[_to] += _value;    Transfer(msg.sender, _to, _value);    return true; } ... function approveAndCallcode(address _spender, uint256 _value, bytes _extraData) public returns (bool success) {    allowed[msg.sender][_spender] = _value;    Approval(msg.sender, _spender, _value);    if(!_spender.call(_extraData)) { revert(); }    return true; } ...

黑客通過調用 approveAndCallcode() 函數,將合約地址作為 _spender 參數,並將 transfer() 的調用轉換為字節序列作為 _extraData 參數,最終調用 transfer() 函數。在 transfer() 函數中,_to 參數為黑客的地址,而此時 msg.sender 的值已經是合約本身的地址了,黑客通過這種方式,成功竊取了合約地址中的代幣。

黑客攻擊鏈:

對於上述所描述的安全問題目前還不能造成直接的經濟損失。在對這類智能合約的審計過程中,發現目前大量的代幣合約不會使用到合約本身的地址作為存儲單元,也就是說 合約地址所對應的代幣量為 0 (balances[address(this)] == 0)。但這種不安全的代碼很難猜測到在后續的發展中,會引起什么樣的問題,應該保持關注並避免這種不安全的代碼。

0x04 callcode 安全問題

callcode: 調用后內置變量 msg 的值會修改為調用者,但執行環境為調用者的運行環境。

由於 callcode 同時包含了 call 和 delegatecall 的特性,通過上文對 call 和 delegatecall 的安全問題進行了分析和舉例,可以得出的結論是 call 和 delegatecall 存在的安全問題將同時存在於 callcode 中,這里不再進行詳細的分析。

0x05 總結

目前,區塊鏈技術極高的熱度促使該技術不斷的投入到了生產環境中,但還沒有完整的技術流水線,也沒有統一的行業規范,同時 Solidity 語言現在版本為 0.4.25,還沒有發布第一個正式版本,導致基於區塊鏈技術的產品出現各種安全漏洞,部分漏洞可以直接造成經濟損失。

針對文中所提到的安全隱患,這里給開發者幾個建議:

  1. call、callcode、delegatecall調用的自由度極大,並且 call 會發生 msg 值的改變,需要謹慎的使用這些底層的函數;同時在使用時,需要對調用的合約地址、可調用的函數做嚴格的限制。
  2. call 與 callcode 調用會改變 msg 的值,會修改 msg.sender 為調用者合約的地址,所以在合約中不能輕易將合約本身的地址作為可信地址。
  3. delegatecall 與 callcode 會拷貝目標代碼到自己的環境中執行,所以調用的函數應該做嚴格的限制,避開調用任意函數的隱患。
  4. 智能合約在部署前必須通過嚴格的審計和測試。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM