技術分析 Lendf.me 被攻擊,ERC777到底該不該用?

買賣虛擬貨幣
可重入攻擊不是ERC777的錯我在去年 9 月寫過一篇ERC科普文章:ERC777 功能型代幣(通證)最佳實踐[1] ,文章裡我推薦新開發的代幣使用 ERC777 標準。Imtoken 使用 ERC777 發行 imbtc 其實是非常值得稱讚的,典型的反面是 USDT (transfer不返回值)坑了多少專案。
週末兩天Uniswap 和 Lendf.me 都發生了駭客攻擊事件,都是Defi 應用與 ERC777 組合應用導致可重入漏洞,其中導致 Lendf.me 損失抵押資產千萬美元。發生這樣的事情,相信是所有從業者不願意看到的,本文也無意針對Lendf.me,你們也是受害者,只是看到有人甩鍋給 ERC777 ,不忍從技術角度說幾句公道話。要把鍋全甩給 ERC777 ,是特朗普壞(甩鍋給你,只因你太優秀)。ERC777 是一個好的Token標準, 可以極大的提高Defi 應用的使用者體驗,透過使用的 Hook 回撥機制,在 ERC20 中需要二筆或多筆完成的交易(當然還有其他的特性),而使用ERC777單筆交易就可以完成。
對行業的發展我一直是樂觀派, 如果因為本次攻擊,拒絕使用ERC777,那一定在開歷史倒車。這次事件挫敗了大家對 Defi的信心, 從長遠看,我相信會讓行業更健康。可重入攻擊是怎麼發生的?下面我用一段簡潔的程式碼說明可重入攻擊是如何發生的(警告,以下是程式碼請勿使用),下面是 Defi 應用最常見的邏輯,deposit 函式用來存款,存款時會記錄下使用者的存款金額,withdraw 函式用來取款,取款在餘額的基礎上加上一個利率。
interface IToken {  function transfer(address recipient, uint256 amount) external returns (bool);  function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
}contract Defi {  IToken token;
  mapping(address => uint) balances;  function deposit(uint256 amount) external {    uint balance = balances[msg.sender] + amount;
      if(token.transferFrom(msg.sender, this, amount)){      balances[msg.sender] = balance;    }
  }  function withdraw() external {      if(token.transfer(msg.sender, balances[msg.sender] + 利息)) {
      // 取回後餘額設定為 0      balances[msg.sender] = 0;    }
  }}

在互動過程中,存在 3 個角色,使用者、Defi合約、Token合約, 使用者存款和取款的時序圖是這樣的:

此時一切執行正常,(經過測試後)使用者在一段時間之後可以贖回 110 個 token,開開心心釋出上線了。

後來上線了一個 ERC777 代幣, ERC777 定義了以下兩個hook 介面:

interface ERC777TokensSender {
    function tokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external;
}

interface ERC777TokensRecipient {
    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata data,
        bytes calldata operatorData
    ) external;
}

用來同時傳送者和接收者進行相應的響應,當然傳送者和接收者也可以選擇不響應(不實現介面)。

ERC777 的轉賬實現一般類似下面這樣:(transfer 和 transferFrom 實現差不多,下面用transfer舉例)

function transfer(address to, uint256 amount) public returns (bool) {

  if (有傳送者介面實現) {
      傳送者.tokensToSend(operator, from, to, amount, userData, operatorData);
  }

    _move(from, from, to, amount, "", "");

  if (有接收者介面實現) {
      接收者.tokensReceived(operator, from, to, amount, userData, operatorData);
  }
  return true;
}

簡單來說,就是在更改 傳送者 和 接收者餘額的前後檢視是否需要通知傳送者和接收者,大部分情況下,普通賬號對普通賬號的轉賬(因為普通一般不會實現介面)和 ERC20 效果上一樣的。

如果傳送者和接收者實現了ERC777的轉賬介面, 上面的存款呼叫時序圖就是這樣的:

在Defi合約呼叫Token 的transferFrom 時,Token合約會呼叫 tokensToSend 和 tokenReceived 以便傳送者和接收者進行相應的相應。注意這裡tokensToSend 由使用者實現,tokenReceived 由 Defi 合約實現。

這個回撥能力做很多有趣的事情,比如:可以把授權和存款合併為一筆交易,使用者直接呼叫 token 合約的轉賬,Defi 合約收到轉賬後,在tokenReceived中完成使用者的存款操作。

ERC777 協議沒有對使用者如何實現tokensToSend 及 tokenReceived 做出規定,Defi合約開發者也不應該對參與方的實現進行任何的假定。在 Lendf.me 的攻擊案例中,駭客使用者就是在tokensToSend的實現中,呼叫了 Defi 合約的 withdraw ,駭客使用者合約的程式碼大概是這樣的:

contract Hacker {

  IToken token;
  IDefi  defi;


  function hack() external  {
      token.approve(defi, 100);
      defi.deposit(100)
  }

  function tokensToSend() external {
      defi.withdraw()    
  }

}

駭客攻擊的時序圖如下:

注意 tokensToSend() 、 withdraw()和tokensReceived() 函式都是在 transferFrom()中執行的,根據deposit的程式碼:

  function deposit(uint256 amount) external {
    uint balance = balances[msg.sender] + amount;
      if(token.transferFrom(msg.sender, this, amount)){
      balances[msg.sender] = balance;
    }
  }

只要前面 3 個函式沒有出錯,transferFrom執行成功之後,就重置使用者餘額(駭客合約)為 100(存款金額)。而實際上駭客已經把所有存款全部取出,從而實現了一次對 Defi 合約的攻擊。

大家都沒方法控制合約的實現,但是甩鍋到 ERC777 對嗎?那麼對於 Defi 開發者,如何避免攻擊呢?

避免 ERC777 重入攻擊

其實可重入攻擊一直都存在,OpenZeppelin 也給過解決方案,給 Defi 合約加上重入限制即可。

contract Defi {
  bool private _notEntered;
  IToken token;
  mapping(address => uint) balances;

  modifier nonReentrant() {
    require(_notEntered, "ReentrancyGuard: reentrant call");
    _notEntered = false;
    _;
    _notEntered = true;
  }

  function deposit(uint256 amount) external nonReentrant {
      if(token.transferFrom(msg.sender, this, amount)){
      balances[msg.sender] = balances[msg.sender] + amount;
    }
  }

  function withdraw() external nonReentrant {
      if(token.transfer(msg.sender, balances[msg.sender] + 利息)) {
      // 取回後餘額設定為 0
      balances[msg.sender] = 0;
    }
  }  
}

給deposit 和 withdraw 函式加入重入限制後,此時如果在 tokensToSend中呼叫withdraw就會敗而回退交易。很明顯在 Defi 合約中可以避免重入攻擊。

最後希望 Lendf.me 度過難關。

References

[1] ERC777 功能型代幣(通證)最佳實踐: https://learnblockchain.cn/2019/09/27/erc777

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;