可重入攻擊不是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