在互動過程中,存在 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