漏洞概述 重入攻击是最常见的漏洞之一,重入漏洞的原理是基于递归原理。重入攻击的本质是由于外部调用或是使用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 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; 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挂钩(一个安全机制)是与转账方和接收方帐户绑定(挂钩)的代码段。实质上,它们是智能合约实例:
IERC777Sender接口有一个函数tokensToSend
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版本以上重入变得困难,但是一但失误造成的损失是致命的。检查以及测试合约显得格外重要。