3. 重入攻击实战与修复

重入攻击,说白了就是合约在转账或调用外部合约时,被对方反过来又调用了自己。我最早在2016年的The DAO事件里见识到它的威力——那次直接导致了以太坊分叉。嗯,咱们今天就来彻底搞懂它。

3.1 重入攻击的原理

先看一个典型的漏洞合约。我见过不少新手写的NFT合约,都有类似的问题:

// 有漏洞的NFT合约
contract VulnerableNFT is ERC721 {
    mapping(uint256 => address) private _owners;
    
    function safeMint(address to, uint256 tokenId) public payable {
        require(msg.value >= 0.1 ether, "不够钱");
        
        // 先转账,再铸造
        (bool sent, ) = to.call{value: msg.value}("");
        require(sent, "转账失败");
        
        _safeMint(to, tokenId);  // 这里会被重入
    }
    
    function withdraw() public {
        uint256 balance = address(this).balance;
        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "提款失败");
    }
}

为什么会这样?因为to.call会触发接收合约的fallback函数。如果接收合约是个恶意合约,它可以在fallback里再次调用safeMint。这时候合约的状态还没更新,_safeMint会重复铸造NFT。

我经历过一个真实案例:有个项目方在mint函数里先退多余的ETH,再更新状态。结果被机器人抢跑,同一个地址mint了上百个NFT,项目直接崩了。

3.2 三种修复方案

方案一:检查-效果-交互模式

这是最基础的防御思路。核心原则就一句话:先检查条件,再更新状态,最后做外部调用

// 修复后的合约
contract SafeNFT is ERC721 {
    mapping(uint256 => address) private _owners;
    
    function safeMint(address to, uint256 tokenId) public payable {
        // 第一步:检查
        require(msg.value >= 0.1 ether, "不够钱");
        require(_owners[tokenId] == address(0), "已存在");
        
        // 第二步:效果(更新状态)
        _owners[tokenId] = to;
        
        // 第三步:交互
        (bool sent, ) = to.call{value: msg.value}("");
        require(sent, "转账失败");
        
        _safeMint(to, tokenId);
    }
}

你看,我把_owners[tokenId] = to移到了转账之前。这样即使被重入,第二次调用时require就会失败。

我的习惯:写任何涉及外部调用的函数,我都会先问自己三个问题:状态更新了吗?检查做完了吗?外部调用能省则省吗?

方案二:使用OpenZeppelin ReentrancyGuard

OpenZeppelin提供了现成的重入锁。我个人非常推荐这个方案,因为它简单、可靠,而且经过了大量项目的验证。

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract GuardedNFT is ERC721, ReentrancyGuard {
    using ReentrancyGuard for uint256;
    
    function safeMint(address to, uint256 tokenId) 
        public 
        payable 
        nonReentrant   // 加上这个修饰符
    {
        require(msg.value >= 0.1 ether, "不够钱");
        
        (bool sent, ) = to.call{value: msg.value}("");
        require(sent, "转账失败");
        
        _safeMint(to, tokenId);
    }
    
    function withdraw() public nonReentrant {
        uint256 balance = address(this).balance;
        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "提款失败");
    }
}

nonReentrant修饰符会在函数入口设置一个锁,函数执行完再释放。如果重入发生,锁还在,第二次调用就会直接revert。

注意:ReentrancyGuard不是万能的。它只能防止同一合约内的重入。如果攻击者通过跨合约调用绕开锁,还是会有风险。我曾经审计过一个项目,他们把锁加在了A合约,但攻击者通过B合约的delegatecall绕过去了。

方案三:在NFT合约中正确放置锁

对于NFT合约,有几个关键位置必须加锁:

  • mint函数:尤其是涉及ETH退款的mint
  • batchMint函数:批量铸造时更容易被重入
  • withdraw函数:提款函数是重入攻击的重灾区
  • setApprovalForAll:虽然不直接涉及转账,但状态变更也可能被利用

我见过一个项目,他们在mint函数加了锁,但batchMint忘了加。攻击者通过批量铸造,每次只付一个NFT的钱,却拿到了多个NFT。嗯,这种低级错误其实挺常见的。

3.3 我经历过的重入漏洞审计案例

分享一个我印象深刻的案例。去年审计一个NFT市场合约,他们的挂单取消功能是这样的:

function cancelOrder(uint256 orderId) public {
    Order storage order = orders[orderId];
    require(order.seller == msg.sender, "不是你的单");
    
    // 先退NFT
    IERC721(order.nft).safeTransferFrom(
        address(this), 
        msg.sender, 
        order.tokenId
    );
    
    // 再删除订单
    delete orders[orderId];
}

问题出在哪?safeTransferFrom会触发接收合约的onERC721Received回调。攻击者可以在回调里再次调用cancelOrder,因为订单还没删除,第二次调用会再次转出同一个NFT。

修复方案很简单:先删除订单,再转NFT。

function cancelOrder(uint256 orderId) public nonReentrant {
    Order storage order = orders[orderId];
    require(order.seller == msg.sender, "不是你的单");
    
    // 先删除
    address nft = order.nft;
    uint256 tokenId = order.tokenId;
    delete orders[orderId];
    
    // 再转账
    IERC721(nft).safeTransferFrom(
        address(this), 
        msg.sender, 
        tokenId
    );
}
核心要点:
  • 重入攻击的本质是状态更新和外部调用的顺序问题
  • 检查-效果-交互模式是最基础的防御
  • ReentrancyGuard是可靠的现成方案
  • NFT合约中,mint、batchMint、withdraw、cancelOrder都是高危函数
  • 跨合约调用时要注意锁的覆盖范围

最后说一句:重入攻击的变种很多,但防御思路其实很统一。你只要记住「先更新状态,再做外部调用」这个原则,就能防住90%的攻击。剩下的10%,靠ReentrancyGuard和代码审计来补。