Solidity重入攻击开发者指南1️⃣

从 EVM 执行模型出发讲解 Solidity 重入攻击,覆盖经典 ETH 提款漏洞、CEI 修复模式及外部回调风险识别。

· ☕ 19 分钟阅读
Solidity重入攻击开发者指南1️⃣

Solidity 重入攻击开发者指南

参考文章:RareSkills, Where to find solidity reentrancy attacks,原文最后更新于 2024-09-28。

1. 核心结论

Solidity 中的重入攻击并不只发生在经典的 withdraw() + call{value: ...} 场景。只要合约在一次执行流程尚未完成时,把执行权交给了外部合约,外部合约就有机会再次调用当前合约或相关合约,从而在状态未更新、状态不一致或校验条件仍然成立的窗口期内重复获利。

开发者需要记住一句话:

重入的必要条件是:当前合约在关键状态完成更新之前,调用了不可信外部合约或触发了外部回调。

外部调用可能非常明显,例如:

msg.sender.call{value: amount}("");

也可能隐藏在代币标准、框架函数或“safe”函数内部,例如:

_safeMint(to, tokenId);
safeTransferFrom(from, to, tokenId);
_mint(to, id, amount, data); // ERC1155 中会触发接收者回调

因此,审计重入风险时,不能只搜索 call,还要识别所有可能交出控制权的路径。

2. 什么是重入攻击

重入攻击是指:合约 A 在函数执行过程中调用合约 B,合约 B 在回调、fallback、receive 或 token hook 中再次调用合约 A 的函数。由于合约 A 的第一次调用尚未完成,它的状态可能仍处于临时、不一致或尚未记账的阶段。攻击者利用这一点,让某些校验重复通过或让某些资产重复释放。

简化调用链如下:

用户/攻击合约 -> Victim.withdraw()
Victim.withdraw() -> Attacker.receive()
Attacker.receive() -> Victim.withdraw()
Victim.withdraw() -> Attacker.receive()
...

漏洞成立通常需要同时满足以下条件:

  1. Victim 在执行过程中调用了外部地址。
  2. 外部地址可以是攻击者控制的合约。
  3. Victim 的关键状态在外部调用之后才更新。
  4. 重入期间的校验仍然依赖旧状态或不完整状态。
  5. 重入调用可以带来重复提款、重复 mint、重复奖励、价格操纵或权限绕过。

3. 从 EVM 执行模型理解重入

EVM 的合约调用是同步调用。一个合约调用另一个合约时,并不是开启新的异步任务,而是在同一笔交易、同一个调用栈中把执行权交给对方。

例如:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "ETH transfer failed");
    balances[msg.sender] = 0;
}

这里的问题不是 call 本身,而是顺序错误:

  1. 先读取 balances[msg.sender]
  2. 再把 ETH 发给 msg.sender
  3. 如果 msg.sender 是合约,它的 receive()fallback() 会被触发。
  4. 攻击合约在回调里再次调用 withdraw()
  5. 此时 balances[msg.sender] 还没有被清零。
  6. 第二次 withdraw() 仍然可以取到原余额。

这就是典型的“先交互,后更新状态”导致的重入窗口。

4. 经典 ETH 提款重入

4.1 有漏洞的银行合约

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

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

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

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

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

        balances[msg.sender] = 0;
    }
}

问题点:

(bool ok, ) = msg.sender.call{value: amount}("");

这行代码把执行权交给了 msg.sender。如果 msg.sender 是攻击合约,它可以在 receive() 中再次调用 withdraw()。由于 balances[msg.sender] = 0 在外部调用之后执行,攻击者可以重复提款。

4.2 攻击合约示例

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

interface IFaultyBank {
    function deposit() external payable;
    function withdraw() external;
}

contract ReentrancyAttacker {
    IFaultyBank public immutable bank;

    constructor(IFaultyBank _bank) {
        bank = _bank;
    }

    function attack() external payable {
        require(msg.value == 1 ether, "Need 1 ETH");
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }
}

攻击流程:

  1. 攻击者存入 1 ETH。
  2. 调用 withdraw()
  3. Victim 向攻击合约发送 1 ETH。
  4. 攻击合约 receive() 被触发。
  5. 攻击合约再次调用 withdraw()
  6. Victim 的余额记录还没清零,因此继续发送 1 ETH。
  7. 直到 Victim 余额不足或 gas 耗尽。

4.3 修复方式:Checks-Effects-Interactions

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

    balances[msg.sender] = 0;

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

这个版本遵循 CEI 模式:

  1. Checks:校验余额。
  2. Effects:先更新内部状态。
  3. Interactions:最后调用外部合约。

即使攻击合约在 receive() 中再次调用 withdraw(),余额也已经为 0,无法重复提款。

5. 不能只盯着 call

RareSkills 原文强调了一个关键点:你不一定总能从业务代码表面看出“这里调用了外部合约”。一些 OpenZeppelin 或 ERC 标准函数内部会触发接收合约的 hook。

开发者在审计时,需要把以下情况都视为潜在外部调用:

场景是否可能交出控制权原因
address.call{value: ...}("")会触发目标合约 receive() / fallback()
ERC721 safeTransferFrom会调用接收者的 onERC721Received
ERC721 _safeMintmint 给合约时会调用 onERC721Received
ERC1155 safeTransferFrom会调用 onERC1155Received
ERC1155 safeBatchTransferFrom会调用 onERC1155BatchReceived
ERC1155 _mint / _mintBatchERC1155 mint 后也会执行安全接收检查
ERC777 转账会触发 tokensReceived hook
ERC223 / ERC677 / ERC1363这些标准设计上支持转账通知或转账后调用
普通 ERC20 transfer / transferFrom通常否标准 ERC20 本身不要求接收者回调
不可信“ERC20” token不能假设安全对方可能不是纯 ERC20,或实现了额外 hook

6. ERC721 中的重入风险

6.1 风险来源

ERC721 中的 safeTransferFrom_safeMint 含有“安全接收检查”。如果接收地址是合约,ERC721 会调用接收合约的 onERC721Received(),确认对方能正确接收 NFT。

“safe” 的含义是防止 NFT 被转入不支持 ERC721 的合约后永久锁死,并不是防重入。

6.2 有漏洞的 mint 示例

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract VulnerableNFT is ERC721 {
    uint256 public totalSupply;
    mapping(address => bool) public minted;

    constructor() ERC721("VulnerableNFT", "VNFT") {}

    function mint() external payable {
        require(msg.value == 0.1 ether, "Wrong price");
        require(!minted[msg.sender], "Already minted");

        totalSupply++;
        _safeMint(msg.sender, totalSupply);

        minted[msg.sender] = true;
    }
}

问题原因:

  1. minted[msg.sender] = true_safeMint() 之后。
  2. _safeMint() 如果 mint 给合约,会调用 onERC721Received()
  3. 攻击合约可以在 onERC721Received() 中再次调用 mint()
  4. 重入时 minted[msg.sender] 仍然是 false
  5. 攻击者可以绕过“一地址只能 mint 一次”的限制。

6.3 修复示例

function mint() external payable {
    require(msg.value == 0.1 ether, "Wrong price");
    require(!minted[msg.sender], "Already minted");

    minted[msg.sender] = true;

    totalSupply++;
    _safeMint(msg.sender, totalSupply);
}

这里把状态更新提前,重入时校验会失败。

如果函数逻辑复杂,建议同时使用 ReentrancyGuard

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

contract SafeNFT is ERC721, ReentrancyGuard {
    function mint() external payable nonReentrant {
        // checks
        // effects
        // interactions
    }
}

7. ERC1155 中的重入风险

7.1 为什么 ERC1155 更容易被误判

ERC721 中 _mint() 通常不触发接收者回调,_safeMint() 才触发接收者回调。但 ERC1155 的 _mint() 本身就会进行安全接收检查。如果接收者是合约,它会调用:

onERC1155Received(...)

批量 mint 或批量转账时会调用:

onERC1155BatchReceived(...)

因此,在 ERC1155 里看到 _mint() 不应该默认认为它只是内部记账。它可能会把执行权交给接收合约。

7.2 有漏洞的 ERC1155 mint 示例

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract Vulnerable1155 is ERC1155 {
    mapping(address => bool) public minted;
    mapping(uint256 => uint256) public totalSupplyForTokenId;

    constructor() ERC1155("") {}

    function mint(uint256 tokenId) external payable {
        require(msg.value == 0.1 ether, "Wrong price");
        require(!minted[msg.sender], "Already minted");

        totalSupplyForTokenId[tokenId]++;
        _mint(msg.sender, tokenId, 1, "");

        minted[msg.sender] = true;
    }
}

问题原因:

  1. _mint() 触发 onERC1155Received()
  2. 攻击合约可以在回调中再次调用 mint()
  3. minted[msg.sender] 还没设置为 true
  4. 攻击者重复 mint。

7.3 修复方式

function mint(uint256 tokenId) external payable {
    require(msg.value == 0.1 ether, "Wrong price");
    require(!minted[msg.sender], "Already minted");

    minted[msg.sender] = true;
    totalSupplyForTokenId[tokenId]++;

    _mint(msg.sender, tokenId, 1, "");
}

推荐做法:

  1. 所有限制条件先校验。
  2. 所有会影响权限、额度、次数、价格、余额的状态先更新。
  3. 最后再调用 _mint()safeTransferFrom() 或其他可能触发回调的函数。
  4. 对用户可控路径使用 nonReentrant

8. ERC777、ERC223、ERC677、ERC1363 的风险

普通 ERC20 的 transfer()transferFrom() 标准上不会通知接收者,因此一般不会因为接收者回调产生重入。但一些改良标准为了提升合约接收 token 的用户体验,加入了转账 hook。

典型例子:

标准风险点
ERC777接收者可以通过 tokensReceived hook 获得执行权
ERC223转账到合约时可能触发 tokenFallback
ERC677transferAndCall 会转账后调用目标合约
ERC1363transferAndCall / approveAndCall 明确支持回调

设计上的好处是接收合约能知道自己收到 token;安全上的代价是调用方必须把这类 token 交互当作外部调用处理。

开发者常见误区:

token.transferFrom(msg.sender, address(this), amount);

如果 token 是可信、标准、无 hook 的 ERC20,风险较低。但如果协议允许用户传入任意 token 地址,或者资产白名单管理不严格,就不能假设 transferFrom() 一定不会重入。

更稳妥的写法是:

function stake(address token, uint256 amount) external nonReentrant {
    require(isWhitelistedToken[token], "Unsupported token");

    uint256 oldBalance = IERC20(token).balanceOf(address(this));

    userStake[msg.sender][token] += amount;

    IERC20(token).transferFrom(msg.sender, address(this), amount);

    uint256 received = IERC20(token).balanceOf(address(this)) - oldBalance;
    require(received == amount, "Unexpected token behavior");
}

注意:上面只是展示思路,真实项目中还需要考虑 fee-on-transfer、rebasing token、返回值兼容性、SafeERC20、精度和白名单策略。

9. transfer / send 为什么不推荐作为防御手段

历史上,transfer()send() 会限制转发给接收方的 gas,通常是 2300 gas。这个 gas 数量不足以完成复杂逻辑,因此曾经被认为能减少重入风险。

但现在不推荐依赖它们防重入,原因包括:

  1. opcode gas 成本可能变化,硬编码 gas 假设不可靠。
  2. 接收合约可能需要正常执行一些记账逻辑,2300 gas 会导致合法交互失败。
  3. transfer() 失败会 revert,容易造成拒绝服务。
  4. 安全边界不应该依赖“对方 gas 不够”,而应该依赖本合约自己的状态顺序和锁。

推荐方式:

(bool ok, ) = recipient.call{value: amount}("");
require(ok, "ETH transfer failed");

同时配合:

  1. Checks-Effects-Interactions。
  2. ReentrancyGuard
  3. Pull payment 模式。
  4. 明确的资产提款队列或 claim 流程。

10. 跨函数重入

重入不一定重新进入同一个函数。攻击者也可以从函数 A 的外部调用点,进入函数 B,利用两个函数共享的状态变量尚未更新的问题。

10.1 示例场景

假设协议提供两个 swap 函数,并限制用户 24 小时只能 swap 一次:

mapping(address => uint256) public lastSwap;

function swapAForB() external {
    require(block.timestamp - lastSwap[msg.sender] >= 1 days, "Too soon");

    rewardToken.mint(msg.sender, REWARD);
    tokenA.transferFrom(msg.sender, address(this), AMOUNT);
    tokenB.transfer(msg.sender, AMOUNT);

    lastSwap[msg.sender] = block.timestamp;
}

function swapBForA() external {
    require(block.timestamp - lastSwap[msg.sender] >= 1 days, "Too soon");

    rewardToken.mint(msg.sender, REWARD);
    tokenB.transferFrom(msg.sender, address(this), AMOUNT);
    tokenA.transfer(msg.sender, AMOUNT);

    lastSwap[msg.sender] = block.timestamp;
}

如果 tokenAtokenB 是 ERC777 或带 hook 的 token,转账过程可能触发攻击合约回调。攻击者可以在 swapAForB() 尚未更新 lastSwap 时调用 swapBForA(),两个函数相互跳转,从而反复领取 rewardToken

10.2 问题原因

  1. 两个函数共享 lastSwap 作为频率限制。
  2. lastSwap 在函数末尾才更新。
  3. 中间的 token transfer 可能触发外部回调。
  4. 重入目标可以是另一个函数,而不是当前函数。
  5. 单独检查某一个函数可能看不出完整攻击路径。

10.3 修复方案

function swapAForB() external nonReentrant {
    require(block.timestamp - lastSwap[msg.sender] >= 1 days, "Too soon");

    lastSwap[msg.sender] = block.timestamp;

    rewardToken.mint(msg.sender, REWARD);
    tokenA.transferFrom(msg.sender, address(this), AMOUNT);
    tokenB.transfer(msg.sender, AMOUNT);
}

function swapBForA() external nonReentrant {
    require(block.timestamp - lastSwap[msg.sender] >= 1 days, "Too soon");

    lastSwap[msg.sender] = block.timestamp;

    rewardToken.mint(msg.sender, REWARD);
    tokenB.transferFrom(msg.sender, address(this), AMOUNT);
    tokenA.transfer(msg.sender, AMOUNT);
}

更进一步:

  1. 把共享状态的更新提前。
  2. 给所有相关入口加同一个重入锁,而不是只保护某一个函数。
  3. 把领取奖励与 swap 解耦,避免在高风险路径里直接 mint。
  4. 对可交互 token 使用严格白名单。

11. 只读重入 / 跨合约重入

只读重入也称 cross-contract reentrancy。它的核心不是直接重复提款,而是在目标协议处于临时不一致状态时,让另一个协议读取错误价格、错误储备量或错误份额,从而完成套利。

11.1 简化模型

假设合约 A 是 AMM 或资金池,合约 B 依赖 A 的 getPrice() 报价。

攻击流程可能是:

  1. 攻击者从 A 提取流动性。
  2. A 先返还 ETH,再返还 ERC20。
  3. A 返还 ETH 时触发攻击合约 receive()
  4. 攻击合约在回调中调用 B。
  5. B 调用 A 的 getPrice()
  6. 此时 A 的状态处于中间态:某些资产已经转出,某些资产尚未转出。
  7. getPrice() 返回临时错误价格。
  8. B 根据错误价格完成交易。
  9. A 的原始操作继续执行并恢复到正常状态。

11.2 为什么 view 函数也可能成为风险点

很多开发者认为 view 函数不能改状态,因此没有安全问题。但只读重入的问题不是 view 函数改了状态,而是它在错误时刻读取了尚未完成更新的状态。

如果其他协议把这个返回值作为交易决策依据,那么错误读数就会变成可利用的价格预言机漏洞。

11.3 防御方式

协议自身可以:

  1. 在敏感流程中使用公开的重入状态标记。
  2. 对关键 view 函数也检查重入状态。
  3. 在状态完全一致之前,不允许外部读取会影响交易的报价。
  4. 对提款、赎回、多资产转出等流程避免形成可被观察的临时不一致状态。

依赖方协议可以:

  1. 不直接信任单一协议的瞬时 view 报价。
  2. 使用 TWAP、延迟确认、多源价格或预言机。
  3. 检查被依赖协议是否处于锁定/退出/更新中状态。
  4. 对同一交易内的价格突变设置限制。

12. 重入攻击产生的根本原因

从开发者视角看,重入漏洞并不是某个单一 API 的问题,而是状态机设计问题。

常见根因包括:

  1. 状态更新晚于外部调用。
  2. 关键状态跨多个函数共享,但只保护了部分入口。
  3. 把 “safe” 函数误解为“防重入安全”。
  4. 对 token 标准的 hook 行为不熟悉。
  5. 允许任意 token 地址进入核心业务逻辑。
  6. 在一个函数里混合记账、转账、mint、回调和奖励发放。
  7. 依赖外部协议的瞬时状态作为价格或权限依据。
  8. 没有建立明确的状态转换顺序。
  9. 没有用攻击合约测试回调路径。
  10. 审计时只搜索 call,忽略 safeTransferFrom_safeMint、ERC777 hook 等隐式外部调用。

13. 防御方案总览

13.1 Checks-Effects-Interactions

这是最基础、最重要的模式。

function claim() external {
    uint256 reward = rewards[msg.sender];
    require(reward > 0, "No reward");

    rewards[msg.sender] = 0;

    (bool ok, ) = msg.sender.call{value: reward}("");
    require(ok, "ETH transfer failed");
}

适用场景:

  1. 提款。
  2. 奖励领取。
  3. NFT mint 限制。
  4. swap 频率限制。
  5. 押金退还。
  6. 拍卖退款。

13.2 ReentrancyGuard

OpenZeppelin 的 ReentrancyGuard 通过状态锁阻止同一笔交易中的重入调用。

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

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balances;

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

        balances[msg.sender] = 0;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "ETH transfer failed");
    }
}

注意事项:

  1. nonReentrant 不是状态顺序的替代品,应和 CEI 一起使用。
  2. 多个外部入口共享状态时,需要统一保护。
  3. nonReentrant 函数之间不能直接互相调用,常见做法是外部函数加锁,内部逻辑抽成 private 函数。
  4. 只保护 withdraw() 不一定够,攻击者可能重入 claim()swap()mint()depositFor()

13.3 Pull Payment

不要在复杂业务流程中主动推送资产,而是记录可领取余额,让用户单独 claim。

mapping(address => uint256) public pendingWithdrawals;

function settle(address user, uint256 amount) internal {
    pendingWithdrawals[user] += amount;
}

function claim() external nonReentrant {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "Nothing to claim");

    pendingWithdrawals[msg.sender] = 0;

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

优点:

  1. 主流程更简单。
  2. 外部调用集中在 claim 函数。
  3. 更容易加锁和测试。
  4. 可降低部分拒绝服务风险。

13.4 Token 白名单

如果协议与 token 交互,尤其是 DeFi 协议,不应该默认所有 token 都是标准 ERC20。

建议:

  1. 只支持经过审查的 token。
  2. 对 ERC777、ERC1363、带回调 token、fee-on-transfer token、rebasing token 单独设计逻辑。
  3. 不允许用户随意传入 token 地址参与核心会计逻辑。
  4. 使用 SafeERC20 处理不规范返回值,但不要误以为 SafeERC20 能防重入。

13.5 明确状态机

复杂流程应显式建模状态,而不是依赖多个 bool 或隐式顺序。

enum WithdrawalStatus {
    None,
    Pending,
    Processing,
    Completed
}

mapping(bytes32 => WithdrawalStatus) public withdrawalStatus;

当函数涉及多资产转出、多协议调用、回调和价格计算时,状态机比简单的布尔位更容易审计。

13.6 对 view 报价做保护

对于 AMM、借贷、vault、LST、LP token 等协议,view 函数也可能成为攻击路径的一部分。

可以考虑:

bool public liquidityOperationInProgress;

function getPrice() external view returns (uint256) {
    require(!liquidityOperationInProgress, "Price temporarily unavailable");
    return _calculatePrice();
}

这类设计需要权衡可组合性和安全性。关键是不要让依赖方在协议内部状态临时不一致时拿到可用于交易的错误价格。

14. 开发者审计清单

14.1 搜索显式外部调用

重点搜索:

.call(
.delegatecall(
.staticcall(
call{value:
transfer(
send(
safeTransferFrom(
_safeMint(
_mint(
transferAndCall(
approveAndCall(

注意:_mint 是否危险取决于具体标准和具体实现。ERC20 _mint 和 ERC1155 _mint 的风险模型不同。

14.2 检查所有外部调用前后的状态顺序

对每个外部调用点提问:

  1. 调用前是否已经完成余额、额度、次数、权限、锁定状态更新?
  2. 如果目标合约立刻回调当前函数,会发生什么?
  3. 如果目标合约回调另一个共享状态的函数,会发生什么?
  4. 如果目标合约回调依赖方协议,会读取到什么状态?
  5. 如果外部调用失败,状态是否会回滚到一致状态?

14.3 检查 token 标准

对每个 token 交互提问:

  1. token 地址是否可信?
  2. token 是否可能是 ERC777、ERC1363 或自定义 hook token?
  3. 是否允许用户传入任意 token?
  4. 是否存在 fee-on-transfer 或 rebasing 行为?
  5. 余额变化是否通过实际 balance delta 验证?

14.4 检查跨函数共享状态

重点关注这些状态:

  1. balances
  2. minted
  3. claimed
  4. lastAction
  5. lastSwap
  6. totalSupply
  7. userDebt
  8. collateral
  9. rewardDebt
  10. pendingRewards

如果多个函数读写同一个状态,其中任一函数存在外部调用,就要考虑跨函数重入。

14.5 编写攻击合约测试

测试不应只覆盖普通 EOA 调用。至少需要构造恶意接收合约,覆盖:

  1. receive() 重入。
  2. fallback() 重入。
  3. onERC721Received() 重入。
  4. onERC1155Received() 重入。
  5. onERC1155BatchReceived() 重入。
  6. tokensReceived() 重入。
  7. 重入同一函数。
  8. 重入另一个函数。
  9. 重入依赖方协议读取价格。

15. 常见误区

15.1 “我没有写 call,所以没有重入”

错误。_safeMint()safeTransferFrom()、ERC1155 _mint()、ERC777 转账等都可能在内部调用外部合约。

15.2 “safe 函数就是安全的”

错误。ERC721/1155 里的 “safe” 主要表示接收方兼容性检查,不表示防重入。

15.3 “transfer 和 send 可以彻底解决问题”

错误。它们依赖 gas 限制,已经不再是推荐方案。应该使用 CEI、重入锁和清晰状态机。

15.4 “只给 withdraw 加 nonReentrant 就够了”

不一定。攻击者可能从 withdraw() 回调到 claim()mint()swap() 或其他共享状态函数。

15.5 “view 函数没有风险”

错误。view 函数可能在错误时刻返回错误状态,进而让依赖方协议执行错误交易。

16. 推荐修复优先级

当发现疑似重入风险时,建议按以下顺序处理:

  1. 识别所有外部调用,包括隐式 token hook。
  2. 把关键状态更新移动到外部调用之前。
  3. 为用户可控入口增加 nonReentrant
  4. 检查所有共享状态函数是否需要同一个锁。
  5. 将主动推送资产改为 pull payment。
  6. 限制可交互 token 的范围。
  7. 为复杂流程建立显式状态机。
  8. 增加恶意接收合约测试。
  9. 对依赖外部协议价格的逻辑增加预言机与时序保护。
  10. 使用 Slither 等工具扫描外部调用点,但不要只依赖工具结论。

17. 可复用安全模板

17.1 安全提款模板

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

    balances[msg.sender] = 0;

    (bool ok, ) = payable(msg.sender).call{value: amount}("");
    require(ok, "ETH transfer failed");
}

17.2 安全 NFT mint 模板

function mint() external payable nonReentrant {
    require(msg.value == mintPrice, "Wrong price");
    require(!minted[msg.sender], "Already minted");
    require(totalSupply < maxSupply, "Sold out");

    minted[msg.sender] = true;

    uint256 tokenId = ++totalSupply;
    _safeMint(msg.sender, tokenId);
}

17.3 安全 ERC1155 mint 模板

function mint(uint256 id, uint256 amount) external payable nonReentrant {
    require(msg.value == price * amount, "Wrong price");
    require(mintedAmount[msg.sender][id] + amount <= maxPerWallet, "Limit exceeded");

    mintedAmount[msg.sender][id] += amount;
    totalMinted[id] += amount;

    _mint(msg.sender, id, amount, "");
}

17.4 外部 token 交互模板

function deposit(address token, uint256 amount) external nonReentrant {
    require(isSupportedToken[token], "Unsupported token");
    require(amount > 0, "Zero amount");

    uint256 beforeBalance = IERC20(token).balanceOf(address(this));

    deposits[msg.sender][token] += amount;

    SafeERC20.safeTransferFrom(IERC20(token), msg.sender, address(this), amount);

    uint256 received = IERC20(token).balanceOf(address(this)) - beforeBalance;
    require(received == amount, "Unexpected received amount");
}

如果项目需要支持 fee-on-transfer token,则不能直接要求 received == amount,而应按 received 入账。

18. 工具建议

18.1 Slither

RareSkills 原文提到 Slither 可以自动检测外部函数调用。建议把 Slither 用作审计入口,先定位外部调用和重入提示,再人工分析业务状态是否真的可利用。

常见命令:

slither .

注意:工具能发现风险点,但不能完全理解业务约束。最终仍需人工判断:

  1. 是否有攻击收益。
  2. 是否有可控回调。
  3. 是否能绕过业务限制。
  4. 是否影响跨合约依赖方。

18.2 Foundry / Hardhat 攻击测试

建议写专门的攻击合约测试,而不是只写正常用户流程。

测试目标:

  1. 重入后余额不能重复释放。
  2. 重入后 mint 次数不能超过限制。
  3. 重入另一个函数不能绕过共享状态限制。
  4. view 报价在敏感流程中不能被错误使用。
  5. 不可信 token 无法进入核心资产逻辑。

19. 总结

重入攻击的本质不是“某个函数危险”,而是“状态还没稳定时交出了执行权”。Solidity 开发者应该把所有外部调用都看作潜在重入点,包括隐藏在 token 标准和框架函数里的接收者回调。

最稳妥的开发习惯是:

  1. 先检查,再改状态,最后交互。
  2. 对用户可控入口使用统一的重入锁。
  3. 不信任任意 token 的实现。
  4. 把复杂资金流拆成明确的状态机和 claim 流程。
  5. 用恶意接收合约测试真实攻击路径。
  6. 对价格、份额、流动性等 view 读数考虑只读重入风险。

只要合约在中途调用外部合约,就要问自己一个问题:

如果对方现在立刻回调我,当前状态是否仍然安全?

如果答案不是明确的“是”,这个位置就需要重新设计。

20. 参考资料

  1. RareSkills: Where to find solidity reentrancy attacks
  2. Trail of Bits: Slither
  3. OpenZeppelin: Reentrancy After Istanbul
  4. Consensys Diligence: Stop Using Solidity’s transfer() Now
  5. GitHub: Reentrancy attacks in the wild
  6. Ethernaut: Re-entrancy Level

💬 评论