漏洞概述

重入攻击是最常见的漏洞之一,重入漏洞的原理是基于递归原理。重入攻击的本质是由于外部调用或是使用transfer,send等转账时,导致合约的执行权落入攻击者手中,而此时如果一些重要的状态变量没有更新,攻击者就可以重入到该合约进行攻击。0.8.0版本后重入攻击受到了一定限制,但是仍然存在重入的风险。

漏洞示例

例一

1
2
3
4
5
6
function withdraw(uint _amount) external payable {
require (balances[msg.sender] >= _amount,"balance is insufficient");
(bool sent,) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
balances[msg.sender] -= _amount;//漏洞!!1
}

这是一个很简单的重入漏洞,在withdraw函数中它是先给呼叫合约转了帐,而后才修改变量

1
2
(bool sent,) = msg.sender.call{value: _amount}("");步骤一
balances[msg.sender] -= _amount;后修改变量 步骤二

那么,如果msg.sender是一个合约,同时在收到目标合约发送的代币之后进行第二次调用withdraw,

第二次withdraw是在第一次调用的步骤一中进行,而第三次调用会在第二次的步骤一中进行,然后一二三次的步骤二会连续的执行,因此如果你输入的amount是5,那么你可以获取远远大于5的代币。这就像你银行账户有10块,当你取5块的时候,银行还未减少你账户余额的情况下,你又开始了第二次第三次的取款,最终钱到手了,管他银行怎么减少你账户的钱。

问题来了,如何在bool sent,) = msg.sender.call{value: _amount}(“”);中实现这个功能呢?看代码

1
2
3
4
5
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}

当攻击合约接受到代币时他就会执行withdraw,那么我们一直在接受代币,一直在withdraw,那么钱不就到手了吗?需要注意的是你需要设定一个停止条件,就比如if (address(bank).balance >= 1 ether);否则你会陷入死循环,然后交易失败。另一点需要注意的是,你有没有想过call的转账方式可以被重入,那么transfer,send的转账方式可以被重入吗?这就需要我们理解三种转账的区别了

· transfer 2300 gas,reverts · send 2300 gas returns bool · call - all gas,returns bool and data

观察可知,transfer与send只有2300gas的燃料,因此他们只能干一些简单是事情,这也告诉我们call转账比较危险,用的时候一定要检查是否有风险,因为它的操作空间可是很大的。

重入思想的拓展

例二

1
2
3
4
5
6
7
8
9
function deleteUser()public{     
uint256 len = users[msg.sender].length;
User memory user = users[msg.sender][len-1];
bool success =msg.sender.call.value(user.balance)();
require(success,"transfer error");

users[msg.sender].length--;

}

目的:如何调用一次deleteUser而让length减少2

解题概述:我们可以看到在函数内正常的情况下我们只能让让length–,根本无法满足我们的目的,但是有call,那么我们就可以用刚才的重入思想攻击他,完成目标(在攻击合约的fallback函数中在调用一次deleteUser)。

题目很简单,和上一步的重入几乎一样,但是这题的目的可不是为了代币,而是为了达到另一种目的,因此我想表达的是:不要局限在重入攻击对代币的窃取,重入攻击可以用于对数据的多次操作(既一次调用多次改变),从而造成重大漏洞,当然重入漏洞最早发生在对代币的窃取。但也不可忽视其对数据状态的多次操作。

解决漏洞方案

用transfer,send转账函数

因为两者都有gas限制,因此无法完成重入这样复杂的多次调用,可以有效避免重入攻击。

先修改状态变量后调用函数

以例一为例

如果我们将目标合约代码改写为如下合约,那么我们还会被攻击吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Bank {
mapping(address => uint) public balances;
function withdraw(uint _amount) external payable {
require (balances[msg.sender] >= _amount,"balance is insufficient");
balances[msg.sender] -= _amount;
(bool sent,) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function getBalance() external view returns (uint) {
return address(this).balance;
}
constructor() payable{}
}

我们只是把状态变量的修改提前到调用外部函数之前就预防了重入。因为状态变量修改之后,你再次调用外部函数,就有可能会被require (balances[msg.sender] >= _amount,”balance is insufficient”);这一步阻挡(除非你钱多多)。

给函数上锁

1
2
3
4
5
6
7
8
9
10
bool Lock=false;
function withdraw(uint _amount) external payable {
require(!Lock,"Failed to call");
Lock=true;
require (balances[msg.sender] >= _amount,"balance is insufficient");
balances[msg.sender] -= _amount;
(bool sent,) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
Lock=false;
}

这样,我已经上锁了,你重入的时候直接被阻挡在require(!Lock,”Failed to call”);指的一提的是,0.8.0版本以上也是采用了加锁的原理来防止重入的。

设置gas费用

给call函数设置限定的gas可以在一定程度上防止重入,但这并不是很好的办法,应该从根源上杜绝,设置gas费只能是无奈之举

ERC223的重入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    //被攻击的ERC223合约
bool flag=true
function airdrop(address getAirdrop) public {
require(flag);
require(token.transfer(getAirdrop, 100 * 10**18));
balanceOf[getAirdrop] += 100 * 10**18;
balanceOf[address(this)] -= 100 * 10**18;
console.log("balanceOf[getAirdrop]",balanceOf[getAirdrop]);
console.log("balanceOf[this]",balanceOf[address(this)]);
flag = false;
}
function _transfer(address from,address to,uint256 amount,bytes memory data) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");

unchecked {
balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
balances[to] += amount;
}
if (Address.isContract(to)) {
IERC223Recipient(to).tokenReceived(msg.sender, amount, data);
}
emit Transfer(from, to, amount);

_afterTokenTransfer(from, to, amount);
}

可以看到这个转账合约是无法触发fallback的,但我们注意,ERC223相对于ERC20增加了

1
2
3
if (Address.isContract(to)) {
IERC223Recipient(to).tokenReceived(msg.sender, amount, data);
}

原本是为了防止意外发送的代币被合约接受,但增加了重入的风险。我们看到airdrop只能操作一次(flag=false)。但是我们发现他是在函数最后才修改了变量,那我们可不可以在进行airdrop的时候再次airdrop呢?。而transfer中又调用了外部合约的tokenReceived,那么如果我们在tokenReceived中再次调用airdrop是不是就可以免费获取更多钱了?

ERC777重入

ERC777挂钩(一个安全机制)是与转账方和接收方帐户绑定(挂钩)的代码段。实质上,它们是智能合约实例:

  1. IERC777Sender接口有一个函数tokensToSend
  2. IERC777Recipient接口有一个函数tokensReceived

这两个接口的智能合约的地址实例存储在ERC1820注册表中,与它们“挂钩”的地址配对。

这两个合约的的地址是储存在ERC1820注册表中。两个函数的功能不细讲,但是这两个函数对于ERC777而言是一个外部函数。

ERC777挂钩的引入使我们能够增强ERC777代币的转账功能,即使在部署代币之后也是如此。它还提供了取消交易的可能性,但是正因如此也引起了不小的漏洞。

下面两个函数是ERC777里面调用外部的函数的源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _callTokensToSend(address operator,address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData) private {
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(from, _TOKENS_SENDER_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
}
}
function _callTokensReceived(address operator,address from,address to,uint256 amount,bytes memory userData,bytes memory operatorData,bool requireReceptionAck) private {
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
require(!to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}

可以看到在tokensToSend与tokensReceived的操作空间是很大的,因此如果攻击合约自己写一个攻击合约并且实现了这两个函数,那么攻击合约就可以在这两个函数中实现重入的操纵,从而造成危险。

总结

重入攻击是一种递归思想,在状态变量未及时修改时,攻击者利用各种方法展开递归,从而造成损失,因此要特别注意状态变量的及时修改,以及调用外部函数的安全性,虽然0.8.0版本以上重入变得困难,但是一但失误造成的损失是致命的。检查以及测试合约显得格外重要。