天天看點

技術分析 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合約, 使用者存款和取款的時序圖是這樣的:

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

此時一切運作正常,(經過測試後)使用者在一段時間之後可以贖回 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的轉賬接口, 上面的存款調用時序圖就是這樣的:

技術分析 Lendf.me 被攻擊,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()    
  }

}
           

複制

黑客攻擊的時序圖如下:

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

注意 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