21

技术分析 Lendf.me 被攻击,ERC777到底该不该用?

 4 years ago
source link: https://learnblockchain.cn/article/893
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

不要因为一次攻击,就拒绝使用新技术。

可重入攻击不是ERC777的错

我在去年 9 月写过一篇ERC科普文章: ERC777 功能型代币(通证)最佳实践 ,文章里我推荐新开发的代币使用 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合约, 用户存款和取款的时序图是这样的:

sequenceDiagram

	Note left of 用户: 授权
	用户->>Token 合约: Approve(Defi, 100)
	Note left of 用户: 存款
	用户->>DeFi合约: deposit(100)
	DeFi合约 ->> Token 合约: transferFrom(用户,Defi,100)


	Note left of 用户: 取款
	用户->>DeFi合约: withdraw()
	DeFi合约 ->> Token 合约: transfer(用户,110)

此时一切运行正常,(经过测试后)用户在一段时间之后可以赎回 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的转账接口, 上面的存款调用时序图就是这样的:

sequenceDiagram
Note left of 用户: 授权
用户->>Token 合约: Approve(Defi, 100)
Note left of 用户: 存款
用户->>DeFi合约: deposit(100)
DeFi合约 ->> Token 合约: transferFrom(用户,Defi,100)
Token 合约 ->> 用户: tokensToSend()
Token 合约 ->> DeFi合约: tokensReceived()

在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()	
  }
  
}

黑客攻击的时序图如下:

sequenceDiagram
Hacker->>Hacker合约: hack()
Hacker合约->>Token 合约: Approve(Defi, 100)

Hacker合约->>DeFi合约: deposit(100)
DeFi合约 ->> Token 合约: transferFrom(Hacker合约,Defi,100)
Token 合约 ->> Hacker合约: tokensToSend()
Hacker合约 ->> DeFi合约: withdraw()
Note left of Hacker合约 : 赎回所有存款
Token 合约 ->> DeFi合约: tokensReceived()

注意 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 度过难关。

转载请注明来自登链社区 Tiny 熊

可重入攻击不是ERC777的错

我在去年 9 月写过一篇ERC科普文章: ERC777 功能型代币(通证)最佳实践 ,文章里我推荐新开发的代币使用 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合约, 用户存款和取款的时序图是这样的:

sequenceDiagram

    Note left of 用户: 授权
    用户->>Token 合约: Approve(Defi, 100)
    Note left of 用户: 存款
    用户->>DeFi合约: deposit(100)
    DeFi合约 ->> Token 合约: transferFrom(用户,Defi,100)

    Note left of 用户: 取款
    用户->>DeFi合约: withdraw()
    DeFi合约 ->> Token 合约: transfer(用户,110)

此时一切运行正常,(经过测试后)用户在一段时间之后可以赎回 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的转账接口, 上面的存款调用时序图就是这样的:

sequenceDiagram
Note left of 用户: 授权
用户->>Token 合约: Approve(Defi, 100)
Note left of 用户: 存款
用户->>DeFi合约: deposit(100)
DeFi合约 ->> Token 合约: transferFrom(用户,Defi,100)
Token 合约 ->> 用户: tokensToSend()
Token 合约 ->> DeFi合约: tokensReceived()

在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() 
  }

}

黑客攻击的时序图如下:

sequenceDiagram
Hacker->>Hacker合约: hack()
Hacker合约->>Token 合约: Approve(Defi, 100)

Hacker合约->>DeFi合约: deposit(100)
DeFi合约 ->> Token 合约: transferFrom(Hacker合约,Defi,100)
Token 合约 ->> Hacker合约: tokensToSend()
Hacker合约 ->> DeFi合约: withdraw()
Note left of Hacker合约 : 赎回所有存款
Token 合约 ->> DeFi合约: tokensReceived()

注意 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 度过难关。

转载请注明来自登链社区 Tiny 熊

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 20分钟前
  • 阅读 ( 92 )
  • 学分 ( 0 )
  • 分类:以太坊

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK