Solidity 重入攻击通俗详解
本文根据 RareSkills 文章 Where to find solidity reentrancy attacks 整理扩展,面向 Solidity 开发者,重点解释重入攻击为什么会发生、在哪里容易发生、如何修复和如何自查。
原文最后更新日期:2024-09-28。
目录
- 重入攻击一句话解释
- 为什么 Solidity 会有重入问题
- 重入攻击的必要条件
- EVM 调用流程中的“执行权交出”
- 经典 ETH 提款重入
transfer和send为什么不推荐- ERC721 中的重入风险
- ERC1155 中的重入风险
- ERC223、ERC677、ERC777、ERC1363 中的 token hook 风险
- 普通 ERC20 是否会重入
- 跨函数重入
- 只读重入 / 跨合约重入
- 重入漏洞产生的根本原因
- 常见修复方案
- 开发和审计检查清单
- 攻击测试思路
- 实战中的安全编码模板
- 总结
- 参考资料
1. 重入攻击一句话解释
重入攻击就是:你的合约在事情还没处理完的时候,先调用了外部合约;外部合约趁这个机会又反过来调用你的合约,利用你还没更新好的状态重复执行某些操作。
用生活中的例子理解:
假设银行柜台有一条规则:
- 先查你账户还有 100 元。
- 把 100 元给你。
- 再把系统里的余额改成 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. 重入攻击的必要条件
重入不是凭空发生的。通常需要满足这些条件:
- 当前合约调用了外部合约,或者触发了外部合约的接收回调。
- 外部合约是攻击者可控的,或者至少是不可信的。
- 当前合约的关键状态还没有更新完成。
- 重入时,校验逻辑仍然能通过。
- 重复调用能产生收益,例如重复提款、重复 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 攻击流程
- 攻击者部署
AttackBank。 - 攻击者向
AttackBank.attack()发送 1 ETH。 AttackBank把 1 ETH 存入FaultyBank。AttackBank调用FaultyBank.withdraw()。FaultyBank读取攻击者余额为 1 ETH。FaultyBank给攻击合约发送 1 ETH。- 攻击合约的
receive()被触发。 - 攻击合约再次调用
FaultyBank.withdraw()。 FaultyBank还没把攻击者余额清零,因此又发送 1 ETH。- 重复执行,直到银行合约余额不足或 gas 耗尽。
5.4 这个漏洞为什么严重
攻击者只存入 1 ETH,却可以拿走合约里其他用户的 ETH。问题不是攻击者账户余额变成负数,而是合约重复根据同一份旧余额进行支付。
6. transfer 和 send 为什么不推荐
Solidity 里还有两种发送 ETH 的方式:
payable(to).transfer(amount);
bool ok = payable(to).send(amount);
它们会限制转发给接收方的 gas,历史上常被认为可以阻止重入,因为接收方拿不到足够 gas 去做复杂操作。
但现在不推荐把它们作为防重入方案,原因是:
- 依赖 gas 限制不是稳固的安全边界。
- EVM opcode 的 gas 成本会变化,过去够用的 2300 gas 将来可能不够或行为变化。
- 有些合法合约收到 ETH 后需要执行记账逻辑,
transfer/send可能导致它们失败。 - 安全应该靠状态顺序和重入锁,而不是靠“对方 gas 不够”。
推荐做法是继续使用:
(bool ok, ) = to.call{value: amount}("");
require(ok, "ETH transfer failed");
但必须配合:
- Checks-Effects-Interactions。
ReentrancyGuard。- 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;
}
}
问题流程:
- 攻击合约调用
mint()。 - 合约检查
alreadyMinted[msg.sender] == false。 - 合约执行
_mint()。 _mint()调用攻击合约的onERC1155Received()。- 攻击合约在回调中再次调用
mint()。 alreadyMinted[msg.sender]仍然是false。- 攻击者重复 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, "");
}
修复原则:
- 限制条件先检查。
- 限购、领取、mint 状态先更新。
- 最后再
_mint()或safeTransferFrom()。 - 使用
nonReentrant保护入口。
9. ERC223、ERC677、ERC777、ERC1363 中的 token hook 风险
普通 ERC20 有一个用户体验问题:合约收到 ERC20 后,不能自动知道自己收到了 token。
一些标准为了解决这个问题,引入了“收到 token 后通知接收方”的机制。
常见标准:
| 标准 | 典型行为 | 重入风险 |
|---|---|---|
| ERC223 | 转账到合约时可能调用接收方函数 | 有 |
| ERC677 | transferAndCall 转账后调用目标合约 | 有 |
| ERC777 | 接收方可实现 tokensReceived hook | 有 |
| ERC1363 | transferAndCall / 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 一次。
问题是:
- 奖励先发了。
- token 转账可能触发外部 hook。
lastSwap最后才更新。- 攻击者可以在 token 回调中从
swapAForB()进入swapBForA()。 - 两个函数都认为用户还没有 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,价格由池子里的资产比例决定。
用户退出池子时,合约需要返还:
- ETH
- token
如果合约先返还 ETH,后返还 token,那么在“ETH 已经返还、token 还没返还”的短暂窗口里,池子的资产比例是错误的。
如果攻击者在收到 ETH 的回调中调用另一个协议,而另一个协议又读取这个池子的价格,就可能拿到错误价格。
12.2 攻击流程
- 攻击者在池子中存入资产。
- 攻击者发起退出。
- 池子先给攻击者返还 ETH。
- 攻击者合约收到 ETH,
receive()被触发。 - 攻击者在回调中调用另一个 DeFi 协议。
- 另一个协议读取池子的
getPrice()。 - 此时池子的状态处于中间态,价格不准确。
- 攻击者利用错误价格交易。
- 原池子的退出流程继续,token 也被返还,池子状态恢复正常。
12.3 防御
协议自身可以:
- 避免在多资产转出过程中暴露错误报价。
- 在敏感操作期间设置重入状态。
- 让关键 view 函数在重入状态下 revert。
- 公开重入锁状态,让依赖方可以检查。
- 优先让状态在外部交互前保持一致。
依赖方协议可以:
- 不依赖单个协议的瞬时价格。
- 使用 TWAP 或外部预言机。
- 对价格变化设置上下限。
- 检查被依赖协议是否处于操作中状态。
- 对同一交易内的价格读数保持谨慎。
13. 重入漏洞产生的根本原因
从代码层面看,重入漏洞是外部调用顺序错误。
从系统设计层面看,重入漏洞是状态机设计错误。
常见根因:
- 外部调用发生在状态更新之前。
- 合约假设外部地址是普通用户,而不是合约。
- 合约假设某些库函数不会调用外部合约。
- 把 ERC721/1155 的
safe误解成防重入。 - 对 ERC1155
_mint()的接收回调不了解。 - 允许任意 token 进入核心业务逻辑。
- 多个函数共享状态,但只保护了其中一个函数。
- 在一个函数中混合提款、转账、mint、奖励、价格计算等多个动作。
- view 函数返回了中间态数据。
- 没有写恶意回调合约测试。
14. 常见修复方案
14.1 Checks-Effects-Interactions
简称 CEI。
顺序是:
- Checks:先做校验。
- Effects:再更新本合约状态。
- 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");
}
}
注意:
nonReentrant不是万能药。- 它不能替代正确的状态更新顺序。
- 多个共享状态的外部入口要一起考虑。
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");
}
优点:
- 外部调用集中,容易审计。
- 主业务流程更少被外部合约打断。
- 用户提款失败不会阻塞整个业务流程。
- 更适合拍卖退款、奖励领取、结算提款。
14.4 Token 白名单
如果协议允许任意 token 地址,风险会明显增加。
建议:
- 核心资产使用白名单。
- 对 ERC777、ERC1363、fee-on-transfer、rebasing token 分别设计。
- 使用
SafeERC20处理返回值不规范问题。 - 不要以为
SafeERC20可以防重入,它主要处理 ERC20 返回值兼容性。
14.5 明确状态机
复杂业务不要只靠几个 bool 拼逻辑。可以显式定义状态:
enum WithdrawalState {
None,
Requested,
Processing,
Completed
}
mapping(bytes32 => WithdrawalState) public withdrawalStates;
好处:
- 状态转换更清晰。
- 哪些状态允许外部调用更容易判断。
- 审计人员更容易发现中间态。
- 多资产提款和跨协议交互更可控。
15. 开发和审计检查清单
15.1 搜索这些关键字
.call
.delegatecall
.staticcall
call{value:
.transfer
.send
safeTransferFrom
safeBatchTransferFrom
_safeMint
_mint
transferAndCall
approveAndCall
onERC721Received
onERC1155Received
onERC1155BatchReceived
tokensReceived
15.2 每个外部调用点都问这些问题
- 外部调用之前,余额是否已经更新?
- 外部调用之前,领取状态是否已经更新?
- 外部调用之前,mint 限制是否已经更新?
- 外部调用之前,频率限制是否已经更新?
- 如果对方立刻回调同一个函数,会怎样?
- 如果对方回调另一个函数,会怎样?
- 如果对方在回调中调用依赖方协议读取价格,会怎样?
- 这个外部地址是否可信?
- 这个 token 是否真的是标准 ERC20?
- 失败时状态是否能完整回滚?
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 危险,而是:
合约状态还没有稳定时,把执行权交给了外部合约。
最容易出问题的位置:
- ETH
call转账。 - ERC721
_safeMint/safeTransferFrom。 - ERC1155
_mint/_mintBatch/safeTransferFrom。 - ERC777
tokensReceived。 - ERC1363
transferAndCall/approveAndCall。 - 任意不可信 token。
- 多函数共享状态。
- 多资产提款中的 view 报价。
最重要的防御习惯:
- 先校验,再改状态,最后外部交互。
- 外部入口使用
nonReentrant。 - 共享状态的多个函数一起保护。
- 不信任任意 token。
- 对复杂资金流使用 pull payment 和状态机。
- 写恶意回调合约测试。
- 对 view 报价考虑中间态问题。
开发时可以反复问自己:
如果对方在这个外部调用点立刻回调我,我的状态是否已经安全?
如果答案不确定,就说明这里需要重新设计。
19. 参考资料
- RareSkills: Where to find solidity reentrancy attacks
- OpenZeppelin: Reentrancy After Istanbul
- Consensys: Stop Using Solidity’s transfer() Now
- Trail of Bits: Slither
- GitHub: Reentrancy attacks in the wild
- Ethernaut: Re-entrancy Level