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

面向 Solidity 开发者通俗讲解重入攻击的执行模型、常见漏洞入口、修复方案、自查清单与攻击测试思路。

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

Solidity 重入攻击通俗详解

本文根据 RareSkills 文章 Where to find solidity reentrancy attacks 整理扩展,面向 Solidity 开发者,重点解释重入攻击为什么会发生、在哪里容易发生、如何修复和如何自查。
原文最后更新日期:2024-09-28。

目录

  1. 重入攻击一句话解释
  2. 为什么 Solidity 会有重入问题
  3. 重入攻击的必要条件
  4. EVM 调用流程中的“执行权交出”
  5. 经典 ETH 提款重入
  6. transfersend 为什么不推荐
  7. ERC721 中的重入风险
  8. ERC1155 中的重入风险
  9. ERC223、ERC677、ERC777、ERC1363 中的 token hook 风险
  10. 普通 ERC20 是否会重入
  11. 跨函数重入
  12. 只读重入 / 跨合约重入
  13. 重入漏洞产生的根本原因
  14. 常见修复方案
  15. 开发和审计检查清单
  16. 攻击测试思路
  17. 实战中的安全编码模板
  18. 总结
  19. 参考资料

1. 重入攻击一句话解释

重入攻击就是:你的合约在事情还没处理完的时候,先调用了外部合约;外部合约趁这个机会又反过来调用你的合约,利用你还没更新好的状态重复执行某些操作。

用生活中的例子理解:

假设银行柜台有一条规则:

  1. 先查你账户还有 100 元。
  2. 把 100 元给你。
  3. 再把系统里的余额改成 0。

如果你在第 2 步拿到钱的瞬间,又让柜员重新执行一次“取款”,此时系统还没来得及把余额改成 0,柜员又查到你还有 100 元,于是又给你 100 元。

这就是重入攻击的直觉模型。

在 Solidity 里,类似问题经常出现在:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    msg.sender.call{value: amount}("");
    balances[msg.sender] = 0;
}

问题不只是“发 ETH”这一行,而是:

先把钱发出去 -> 外部合约获得执行权 -> 自己的余额还没清零

2. 为什么 Solidity 会有重入问题

Solidity 合约之间的调用是同步调用。一个合约调用另一个合约时,会把当前执行权交给被调用方。被调用方执行完以后,控制权才回到原合约。

关键点是:被调用方可以是攻击者写的合约。

攻击者写的合约可以在以下函数中放入恶意逻辑:

receive() external payable {}
fallback() external payable {}
onERC721Received(...) external returns (bytes4) {}
onERC1155Received(...) external returns (bytes4) {}
onERC1155BatchReceived(...) external returns (bytes4) {}
tokensReceived(...) external {}

所以,只要你的合约在关键状态更新之前调用了攻击者合约,攻击者就可以在回调中再次调用你。

3. 重入攻击的必要条件

重入不是凭空发生的。通常需要满足这些条件:

  1. 当前合约调用了外部合约,或者触发了外部合约的接收回调。
  2. 外部合约是攻击者可控的,或者至少是不可信的。
  3. 当前合约的关键状态还没有更新完成。
  4. 重入时,校验逻辑仍然能通过。
  5. 重复调用能产生收益,例如重复提款、重复 mint、重复领取奖励、绕过限购、操纵价格。

换成代码层面的判断:

外部调用之前,有没有把 balances / claimed / minted / lastSwap / totalSupply 等状态更新好?

如果没有,就要高度警惕。

4. EVM 调用流程中的“执行权交出”

RareSkills 原文强调:重入只会在你的合约调用其他合约或发送 ETH 时发生。因为只有这样,你才会把执行权交给别人。

明显的外部调用包括:

target.call(data);
target.call{value: amount}("");
target.delegatecall(data);
target.staticcall(data);
IERC20(token).transfer(to, amount);
IERC20(token).transferFrom(from, to, amount);

但更容易漏掉的是隐藏在库函数或标准函数里的外部回调,例如:

_safeMint(msg.sender, tokenId);        // ERC721
safeTransferFrom(from, to, tokenId);   // ERC721
_mint(msg.sender, id, 1, "");          // ERC1155
safeBatchTransferFrom(...);            // ERC1155

这些函数表面看起来像“内部操作”,但它们可能会在内部调用接收合约的 hook。

审计重入风险时,不能只搜索 .call,还要理解所用标准和库函数的内部行为。

5. 经典 ETH 提款重入

5.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, "ETH transfer failed");

        balances[msg.sender] = 0;
    }
}

这段代码的问题在于:

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

这行代码会给 msg.sender 发送 ETH。如果 msg.sender 是普通用户地址,没有代码,通常不会发生重入。如果 msg.sender 是攻击合约,攻击合约的 receive()fallback() 会被执行。

而此时:

balances[msg.sender] = 0;

还没有执行。

所以攻击合约可以在收到 ETH 的瞬间再次调用 withdraw()

5.2 攻击合约

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

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

contract AttackBank {
    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();
        }
    }
}

5.3 攻击流程

  1. 攻击者部署 AttackBank
  2. 攻击者向 AttackBank.attack() 发送 1 ETH。
  3. AttackBank 把 1 ETH 存入 FaultyBank
  4. AttackBank 调用 FaultyBank.withdraw()
  5. FaultyBank 读取攻击者余额为 1 ETH。
  6. FaultyBank 给攻击合约发送 1 ETH。
  7. 攻击合约的 receive() 被触发。
  8. 攻击合约再次调用 FaultyBank.withdraw()
  9. FaultyBank 还没把攻击者余额清零,因此又发送 1 ETH。
  10. 重复执行,直到银行合约余额不足或 gas 耗尽。

5.4 这个漏洞为什么严重

攻击者只存入 1 ETH,却可以拿走合约里其他用户的 ETH。问题不是攻击者账户余额变成负数,而是合约重复根据同一份旧余额进行支付。

6. transfersend 为什么不推荐

Solidity 里还有两种发送 ETH 的方式:

payable(to).transfer(amount);
bool ok = payable(to).send(amount);

它们会限制转发给接收方的 gas,历史上常被认为可以阻止重入,因为接收方拿不到足够 gas 去做复杂操作。

但现在不推荐把它们作为防重入方案,原因是:

  1. 依赖 gas 限制不是稳固的安全边界。
  2. EVM opcode 的 gas 成本会变化,过去够用的 2300 gas 将来可能不够或行为变化。
  3. 有些合法合约收到 ETH 后需要执行记账逻辑,transfer / send 可能导致它们失败。
  4. 安全应该靠状态顺序和重入锁,而不是靠“对方 gas 不够”。

推荐做法是继续使用:

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

但必须配合:

  1. Checks-Effects-Interactions。
  2. ReentrancyGuard
  3. Pull payment 模式。

7. ERC721 中的重入风险

7.1 “safe” 不是防重入安全

ERC721 中有这些常见函数:

safeTransferFrom(...)
_safeMint(...)
transferFrom(...)
_mint(...)

其中 safeTransferFrom_safeMint 会检查接收方是不是合约。如果接收方是合约,就会调用:

onERC721Received(...)

这个检查的目的,是防止 NFT 被转到一个不支持 ERC721 的合约里后无法取出。

所以这里的 safe 是“接收兼容性安全”,不是“防重入安全”。

7.2 有漏洞的 ERC721 mint

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

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

contract VulnerableERC721 is ERC721 {
    uint256 public totalSupply;
    mapping(address => bool) public alreadyMinted;

    constructor() ERC721("Demo NFT", "DNFT") {}

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

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

        alreadyMinted[msg.sender] = true;
    }
}

表面规则是:

每个地址只能 mint 一次

但问题是 alreadyMinted[msg.sender] = true_safeMint() 之后。

如果 msg.sender 是攻击合约,_safeMint() 会触发它的 onERC721Received()。攻击合约可以在这个回调里再次调用 mint()。由于 alreadyMinted[msg.sender] 还没变成 true,第二次调用仍然能通过。

7.3 攻击合约思路

contract ERC721MintAttacker {
    VulnerableERC721 public nft;
    uint256 public count;

    constructor(VulnerableERC721 _nft) {
        nft = _nft;
    }

    function attack() external payable {
        nft.mint{value: 0.1 ether}();
    }

    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external returns (bytes4) {
        if (count < 5) {
            count++;
            nft.mint{value: 0.1 ether}();
        }

        return this.onERC721Received.selector;
    }
}

真实攻击中,攻击者会根据价格、最大供应量和 gas 限制调整次数。

7.4 修复方式

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

    alreadyMinted[msg.sender] = true;

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

更稳妥:

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

    alreadyMinted[msg.sender] = true;

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

8. ERC1155 中的重入风险

8.1 ERC1155 的 _mint 也可能触发回调

这是 RareSkills 原文里非常重要的一点。

在 ERC721 中,_mint()_safeMint() 的行为不同。_safeMint() 会触发接收方检查,而 _mint() 通常不会。

但 ERC1155 不一样。ERC1155 的 _mint() 在 mint 给合约地址时,也会做接收检查,最终触发:

onERC1155Received(...)

批量操作会触发:

onERC1155BatchReceived(...)

所以在 ERC1155 中,不能看到 _mint() 就以为它只是内部记账。

8.2 有漏洞的 ERC1155 mint

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

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

contract VulnerableERC1155 is ERC1155 {
    mapping(address => bool) public alreadyMinted;
    mapping(uint256 => uint256) public totalSupplyForTokenId;

    constructor() ERC1155("") {}

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

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

        alreadyMinted[msg.sender] = true;
    }
}

问题流程:

  1. 攻击合约调用 mint()
  2. 合约检查 alreadyMinted[msg.sender] == false
  3. 合约执行 _mint()
  4. _mint() 调用攻击合约的 onERC1155Received()
  5. 攻击合约在回调中再次调用 mint()
  6. alreadyMinted[msg.sender] 仍然是 false
  7. 攻击者重复 mint。

8.3 修复方式

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

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

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

修复原则:

  1. 限制条件先检查。
  2. 限购、领取、mint 状态先更新。
  3. 最后再 _mint()safeTransferFrom()
  4. 使用 nonReentrant 保护入口。

9. ERC223、ERC677、ERC777、ERC1363 中的 token hook 风险

普通 ERC20 有一个用户体验问题:合约收到 ERC20 后,不能自动知道自己收到了 token。

一些标准为了解决这个问题,引入了“收到 token 后通知接收方”的机制。

常见标准:

标准典型行为重入风险
ERC223转账到合约时可能调用接收方函数
ERC677transferAndCall 转账后调用目标合约
ERC777接收方可实现 tokensReceived hook
ERC1363transferAndCall / approveAndCall

这些 hook 的好处是接收方能自动处理 token,但风险是接收方获得了执行权。

因此,当协议支持这些 token 或允许用户传入任意 token 地址时,必须把 token 转账也当成潜在外部调用。

10. 普通 ERC20 是否会重入

标准 ERC20 的 transfer()transferFrom() 不要求调用接收方合约的 hook。所以如果你使用的是可信的标准 ERC20,一般不会因为接收者回调导致重入。

但开发中不能简单写成:

ERC20 transferFrom 一定不会重入

更准确的说法是:

可信标准 ERC20 的 transfer/transferFrom 通常不会触发接收者回调。
但不可信 token、非标准 token、包装 token、ERC777 或伪装成 ERC20 的 token 可能触发外部逻辑。

危险场景:

function deposit(address token, uint256 amount) external {
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    balances[msg.sender][token] += amount;
}

如果 token 是用户随便传入的,就不能确认它的行为。攻击者可以传入一个恶意 token,在 transferFrom() 中回调你的合约。

更安全:

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

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

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

如果支持 fee-on-transfer token,应按实际收到数量入账:

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

    uint256 beforeBalance = IERC20(token).balanceOf(address(this));
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    uint256 received = IERC20(token).balanceOf(address(this)) - beforeBalance;

    require(received > 0, "Nothing received");
    balances[msg.sender][token] += received;
}

这段代码把入账放在外部调用之后,是为了按实际收到数量记账,因此必须结合 nonReentrant、严格 token 白名单和业务约束。对于不同 token 类型,安全顺序需要结合会计模型具体设计。

11. 跨函数重入

重入不一定是“再次进入同一个函数”。攻击者也可以从函数 A 的回调中进入函数 B。

这叫跨函数重入。

11.1 示例

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

这两个函数都依赖 lastSwap 限制用户一天只能 swap 一次。

问题是:

  1. 奖励先发了。
  2. token 转账可能触发外部 hook。
  3. lastSwap 最后才更新。
  4. 攻击者可以在 token 回调中从 swapAForB() 进入 swapBForA()
  5. 两个函数都认为用户还没有 swap。

11.2 修复

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

注意:如果两个函数共享状态,最好使用同一个重入锁保护所有相关入口。

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

只读重入听起来比较反直觉,因为 view 函数不能修改状态。它的风险不在于 view 函数改了状态,而在于它可能在错误的时间读到了错误的中间状态。

12.1 通俗解释

假设一个资金池里有 ETH 和 token,价格由池子里的资产比例决定。

用户退出池子时,合约需要返还:

  1. ETH
  2. token

如果合约先返还 ETH,后返还 token,那么在“ETH 已经返还、token 还没返还”的短暂窗口里,池子的资产比例是错误的。

如果攻击者在收到 ETH 的回调中调用另一个协议,而另一个协议又读取这个池子的价格,就可能拿到错误价格。

12.2 攻击流程

  1. 攻击者在池子中存入资产。
  2. 攻击者发起退出。
  3. 池子先给攻击者返还 ETH。
  4. 攻击者合约收到 ETH,receive() 被触发。
  5. 攻击者在回调中调用另一个 DeFi 协议。
  6. 另一个协议读取池子的 getPrice()
  7. 此时池子的状态处于中间态,价格不准确。
  8. 攻击者利用错误价格交易。
  9. 原池子的退出流程继续,token 也被返还,池子状态恢复正常。

12.3 防御

协议自身可以:

  1. 避免在多资产转出过程中暴露错误报价。
  2. 在敏感操作期间设置重入状态。
  3. 让关键 view 函数在重入状态下 revert。
  4. 公开重入锁状态,让依赖方可以检查。
  5. 优先让状态在外部交互前保持一致。

依赖方协议可以:

  1. 不依赖单个协议的瞬时价格。
  2. 使用 TWAP 或外部预言机。
  3. 对价格变化设置上下限。
  4. 检查被依赖协议是否处于操作中状态。
  5. 对同一交易内的价格读数保持谨慎。

13. 重入漏洞产生的根本原因

从代码层面看,重入漏洞是外部调用顺序错误。

从系统设计层面看,重入漏洞是状态机设计错误。

常见根因:

  1. 外部调用发生在状态更新之前。
  2. 合约假设外部地址是普通用户,而不是合约。
  3. 合约假设某些库函数不会调用外部合约。
  4. 把 ERC721/1155 的 safe 误解成防重入。
  5. 对 ERC1155 _mint() 的接收回调不了解。
  6. 允许任意 token 进入核心业务逻辑。
  7. 多个函数共享状态,但只保护了其中一个函数。
  8. 在一个函数中混合提款、转账、mint、奖励、价格计算等多个动作。
  9. view 函数返回了中间态数据。
  10. 没有写恶意回调合约测试。

14. 常见修复方案

14.1 Checks-Effects-Interactions

简称 CEI。

顺序是:

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

错误:

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

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

    balances[msg.sender] = 0;
}

正确:

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, "ETH transfer failed");
}

14.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 不是万能药。
  2. 它不能替代正确的状态更新顺序。
  3. 多个共享状态的外部入口要一起考虑。
  4. nonReentrant 函数之间不适合互相外部调用,常见做法是外部函数加锁,内部逻辑拆成 private

14.3 Pull Payment

Pull payment 的思想是:不要在复杂业务流程里直接给用户转钱,而是记录用户可以领取多少钱,让用户单独调用 claim。

mapping(address => uint256) public pendingWithdrawals;

function _addPendingWithdrawal(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, "ETH transfer failed");
}

优点:

  1. 外部调用集中,容易审计。
  2. 主业务流程更少被外部合约打断。
  3. 用户提款失败不会阻塞整个业务流程。
  4. 更适合拍卖退款、奖励领取、结算提款。

14.4 Token 白名单

如果协议允许任意 token 地址,风险会明显增加。

建议:

  1. 核心资产使用白名单。
  2. 对 ERC777、ERC1363、fee-on-transfer、rebasing token 分别设计。
  3. 使用 SafeERC20 处理返回值不规范问题。
  4. 不要以为 SafeERC20 可以防重入,它主要处理 ERC20 返回值兼容性。

14.5 明确状态机

复杂业务不要只靠几个 bool 拼逻辑。可以显式定义状态:

enum WithdrawalState {
    None,
    Requested,
    Processing,
    Completed
}

mapping(bytes32 => WithdrawalState) public withdrawalStates;

好处:

  1. 状态转换更清晰。
  2. 哪些状态允许外部调用更容易判断。
  3. 审计人员更容易发现中间态。
  4. 多资产提款和跨协议交互更可控。

15. 开发和审计检查清单

15.1 搜索这些关键字

.call
.delegatecall
.staticcall
call{value:
.transfer
.send
safeTransferFrom
safeBatchTransferFrom
_safeMint
_mint
transferAndCall
approveAndCall
onERC721Received
onERC1155Received
onERC1155BatchReceived
tokensReceived

15.2 每个外部调用点都问这些问题

  1. 外部调用之前,余额是否已经更新?
  2. 外部调用之前,领取状态是否已经更新?
  3. 外部调用之前,mint 限制是否已经更新?
  4. 外部调用之前,频率限制是否已经更新?
  5. 如果对方立刻回调同一个函数,会怎样?
  6. 如果对方回调另一个函数,会怎样?
  7. 如果对方在回调中调用依赖方协议读取价格,会怎样?
  8. 这个外部地址是否可信?
  9. 这个 token 是否真的是标准 ERC20?
  10. 失败时状态是否能完整回滚?

15.3 重点关注这些状态变量

balances
claimed
minted
alreadyMinted
lastSwap
lastClaim
totalSupply
totalMinted
rewardDebt
pendingRewards
userDebt
collateral
shares
reserves

这些状态如果在外部调用之后才更新,就要重点分析。

16. 攻击测试思路

只用普通用户地址测试是不够的。必须写恶意合约测试回调。

16.1 测试 receive 重入

receive() external payable {
    victim.withdraw();
}

适合测试 ETH 提款、退款、claim。

16.2 测试 ERC721 回调重入

function onERC721Received(
    address,
    address,
    uint256,
    bytes calldata
) external returns (bytes4) {
    victim.mint{value: 0.1 ether}();
    return this.onERC721Received.selector;
}

适合测试 _safeMint()safeTransferFrom()

16.3 测试 ERC1155 回调重入

function onERC1155Received(
    address,
    address,
    uint256,
    uint256,
    bytes calldata
) external returns (bytes4) {
    victim.mint(1);
    return this.onERC1155Received.selector;
}

适合测试 ERC1155 _mint()safeTransferFrom()

16.4 测试跨函数重入

在回调中不要调用原函数,而是调用另一个共享状态函数:

function onERC1155Received(...) external returns (bytes4) {
    victim.claimReward();
    return this.onERC1155Received.selector;
}

16.5 测试只读重入

在回调中调用依赖协议:

receive() external payable {
    dependentProtocol.tradeUsingVictimPrice();
}

重点观察依赖协议是否读取了中间态价格。

17. 实战中的安全编码模板

17.1 安全 ETH 提款

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 安全 ERC721 mint

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

    alreadyMinted[msg.sender] = true;

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

17.3 安全 ERC1155 mint

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

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

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

17.4 安全 claim

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

    pendingRewards[msg.sender] = 0;

    rewardToken.transfer(msg.sender, reward);
}

如果 rewardToken 不是可信标准 ERC20,这里的 transfer 也需要按外部调用处理。

17.5 安全 token deposit

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));
    SafeERC20.safeTransferFrom(IERC20(token), msg.sender, address(this), amount);
    uint256 received = IERC20(token).balanceOf(address(this)) - beforeBalance;

    require(received > 0, "Nothing received");
    balances[msg.sender][token] += received;
}

这个模板适合需要兼容 fee-on-transfer 的场景。如果项目只支持标准 token,可以进一步要求 received == amount

18. 总结

重入攻击的本质不是某个 API 危险,而是:

合约状态还没有稳定时,把执行权交给了外部合约。

最容易出问题的位置:

  1. ETH call 转账。
  2. ERC721 _safeMint / safeTransferFrom
  3. ERC1155 _mint / _mintBatch / safeTransferFrom
  4. ERC777 tokensReceived
  5. ERC1363 transferAndCall / approveAndCall
  6. 任意不可信 token。
  7. 多函数共享状态。
  8. 多资产提款中的 view 报价。

最重要的防御习惯:

  1. 先校验,再改状态,最后外部交互。
  2. 外部入口使用 nonReentrant
  3. 共享状态的多个函数一起保护。
  4. 不信任任意 token。
  5. 对复杂资金流使用 pull payment 和状态机。
  6. 写恶意回调合约测试。
  7. 对 view 报价考虑中间态问题。

开发时可以反复问自己:

如果对方在这个外部调用点立刻回调我,我的状态是否已经安全?

如果答案不确定,就说明这里需要重新设计。

19. 参考资料

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

💬 评论