2
月
在社区成长 2 月
LEVEL 1
1 XP
一、背景
重入漏洞是智能合约审计中必须优先排查的问题。它并不局限于“重复提款”这一种表现:只要合约在关键状态尚未完成更新时,把控制权交给外部地址,外部地址就可能通过回调再次进入当前函数或其他共享状态的函数,破坏原有业务假设。
从安全边界看,任何外部调用都应被视为不可信,包括普通 ETH 转账、低级 call、ERC777 hooks、ERC721/ERC1155 的安全转账回调、预言机回调、跨链消息接收函数,以及对未知实现合约的接口调用。即使前端或移动端限制了按钮点击次数,也无法阻止攻击者绕过 APP,直接构造链上交易。因此,重入防护必须落实在合约端。
本文仅使用本地 Foundry 环境进行验证,不涉及任何真实协议或未授权目标。
二、漏洞原理
典型提款逻辑可以抽象为三个步骤:
1. 检查调用者是否有余额;
2. 向调用者发送 ETH;
3. 将调用者余额清零。
问题出在第二步。Solidity 的低级 call 会把执行权交给接收方。如果接收方是合约,其 receive 或 fallback 函数可以再次调用提款函数。由于第三步还没有执行,原合约看到的余额仍然有效,于是会再次付款。
存在风险的简化代码如下:
pragma solidity ^0.8.24;
contract VulnerableVault {
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, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
}
这里的核心缺陷不是使用 call 本身,而是“先外部交互、后更新状态”。在 call 执行期间,balances[msg.sender] 尚未归零。
三、漏洞成立的前置条件
并非看到外部调用就一定可以利用。审计时至少需要确认以下条件:
1. 调用目标可由攻击者控制,或者目标合约存在可触发的回调机制;
2. 外部调用发生在关键状态更新之前;
3. 重入后仍能通过权限、余额、时间或状态机检查;
4. 合约内存在足够的可转移资产,或重入能造成份额、奖励、投票权等账务异常;
5. 没有有效的互斥锁,也没有在所有相关入口执行 Checks-Effects-Interactions;
6. 重入路径没有因为 gas、调用深度、暂停状态等因素自然终止。
需要特别注意:攻击者第一次存入的金额可以很小,但如果提款金额根据内部余额计算,而合约中同时保存了其他用户资金,就可能出现用同一份余额记录多次提款的结果。
四、搭建本地审计环境
建议在隔离目录内使用 Foundry:
forge init reentrancy-lab
cd reentrancy-lab
forge build
将待测合约放入 src/VulnerableVault.sol。随后编写仅用于本地验证的测试攻击合约:
pragma solidity ^0.8.24;
import "../src/VulnerableVault.sol";
contract LocalReentryProbe {
VulnerableVault public immutable vault;
uint256 public callbackCount;
constructor(address target) {
vault = VulnerableVault(target);
}
function run() external payable {
require(msg.value == 1 ether, "send 1 ether");
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
callbackCount++;
if (address(vault).balance >= 1 ether && callbackCount < 5) {
vault.withdraw();
}
}
}
这个探针设置了明确的回调次数上限,避免本地测试因为无限递归而难以定位问题。它只用于证明控制流能够在余额清零前重新进入 withdraw。
五、分步骤验证
步骤 1:建立初始资产状态
在 Foundry 测试中,先让一个普通测试账户向金库存入 10 ether,再让探针合约存入 1 ether。这样可以验证漏洞影响的不只是攻击者自己的存款。
步骤 2:记录关键状态
调用前至少记录:
address(vault).balance
vault.balances(address(probe))
address(probe).balance
步骤 3:触发本地探针
使用 Foundry 的 vm.deal 为测试地址分配本地测试余额,然后调用 probe.run{value: 1 ether}()。执行:
forge test -vvvv
-vvvv 可以显示更详细的调用轨迹。审计时重点观察是否出现如下嵌套结构:
VulnerableVault.withdraw
→ LocalReentryProbe.receive
→ VulnerableVault.withdraw
→ LocalReentryProbe.receive
如果同一笔交易中 withdraw 被重复进入,并且每次读取到的 balances[probe] 仍为 1 ether,就证明状态更新顺序存在问题。
步骤 4:断言安全属性
不要只断言“攻击合约余额增加”,还应验证业务不变量:
1. 单个账户累计提款不得超过其已确认存款;
2. 所有用户可申领余额总和不得大于金库可用资产;
3. 一次提款执行后,用户内部余额应立即减少;
4. 任意外部回调期间,不应观察到可再次消费的旧状态。
可以进一步运行模糊测试:
forge test --fuzz-runs 10000
forge coverage
模糊测试适合覆盖不同存款金额、提款金额和调用顺序,但不能代替人工检查调用链。
六、修复方案
推荐同时使用“状态先更新”和“重入锁”,形成纵深防御。修复示例如下:
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
uint256 current = balances[msg.sender];
require(amount > 0 && amount <= current, "invalid amount");
balances[msg.sender] = current - amount;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed");
}
}
这里采用了 Checks-Effects-Interactions:先检查参数,再减少余额,最后执行外部转账。若转账失败,require 会回滚整笔交易,余额扣减也会一并回滚。nonReentrant 则用于阻止同一重入保护域内的嵌套调用。
如果业务允许,更稳健的设计是 Pull Payment:合约只记录用户可领取金额,由用户主动领取;同时将记账、结算和外部交互拆分清楚。需要注意,Pull Payment 只能降低复杂调用链风险,本身仍要正确更新领取状态。
七、系统化审计方法
1. 资产梳理:列出 ETH、ERC20、NFT、份额、质押凭证、奖励和跨链映射资产。
2. 入口梳理:检查 external、public、receive、fallback,以及代理合约转发后的实际入口。
3. 外部调用清单:搜索 call、delegatecall、staticcall、transfer、send、safeTransfer、safeMint 和第三方接口调用。
4. 状态顺序检查:确认余额、nonce、订单状态、已领取标记和份额是否在外部调用前更新。
5. 交叉函数检查:withdraw 加锁并不代表安全。如果 claim、redeem、borrow 等函数共享同一状态且未加锁,仍可能发生跨函数重入。
6. 权限检查:确认管理员、升级者、暂停者、预言机更新者和跨链消息验证者的权限边界。
7. 代理检查:对于可升级合约,还要检查初始化函数是否可重复调用、存储布局是否冲突,以及修复是否实际部署到了代理正在使用的实现。
8. 不变量测试:围绕总资产、总份额、用户余额、债务与抵押率建立可执行断言,而不是只测试正常流程。
可使用 Slither 进行辅助扫描:
pipx install slither-analyzer
slither . --filter-paths "lib|test"
静态工具的告警只能作为线索。某些外部调用已经正确更新状态,会产生误报;而跨函数重入、经济状态重入和依赖业务语义的漏洞,也可能不被工具发现。
八、容易遗漏的重入变体
1. 跨函数重入:函数 A 外部调用期间进入函数 B,B 读取了 A 尚未更新的共享状态。
2. 跨合约重入:金库、策略、奖励和价格模块分散在多个合约中,单个合约看似安全,但整体调用链存在旧状态窗口。
3. 只读重入:回调期间调用 view 函数读取临时不一致的数据,其他协议再依据该数据进行定价或结算。view 不修改状态,并不表示其返回值在任意调用阶段都安全。
4. Token Hook 重入:ERC777 的发送/接收 hook,以及 ERC721/ERC1155 的安全接收回调,都可能把控制权交给接收方。
5. 闪电贷放大:闪电贷通常不是根因,但可能临时放大资金规模,使会计或价格计算缺陷造成更严重后果。
九、风险与注意事项
不要简单把 call 替换为 transfer 并认为问题已经解决。依赖固定 gas 补贴容易产生兼容性和可用性问题,正确方向仍是安全的状态更新顺序、明确的失败处理和必要的重入锁。
不要只在 withdraw 上添加 nonReentrant 后停止审计。需要检查所有共享同一账本的入口。OpenZeppelin ReentrancyGuard 的 nonReentrant 函数也不能直接互相调用;常见做法是让外部入口加锁,再调用 private 或 internal 的内部实现。
不要用 address(this).balance 直接代表全部可分配余额。合约可能收到强制转入的 ETH,内部负债也不一定等于链上余额。应维护独立、可验证的会计状态。
移动端或网页端的交易确认页面只能改善用户知情程度,不能构成合约安全控制。合约不能假设所有调用都来自官方 APP,也不能依赖前端隐藏按钮、限制频率或校验输入。
十、常见问题
问:Solidity 0.8 已有整数溢出检查,是否能防止重入?
答:不能。溢出检查解决的是算术越界,重入属于外部调用期间的控制流和状态一致性问题。
问:只有转账 ETH 才会重入吗?
答:不是。代币 hooks、NFT 安全转账、外部策略、预言机回调和自定义接口调用都可能触发回调。
问:使用 nonReentrant 后是否可以忽略 CEI?
答:不建议。CEI 能缩短不一致状态窗口,重入锁则提供额外保护。两者结合更便于审计,也能降低后续新增入口时引入缺陷的概率。
问:静态扫描没有告警是否代表安全?
答:不代表。工具难以完整理解业务状态机、跨合约会计关系和经济不变量,必须结合人工审计、单元测试、模糊测试与不变量测试。
十一、总结
重入漏洞的本质是:合约在关键状态尚未落定时,将执行权交给了不可信外部实体。有效审计不能只搜索某个函数名,而应沿着“资产—状态—外部调用—回调入口—共享状态”完整建模。实践中应优先执行 CEI,按业务边界使用 ReentrancyGuard,检查跨函数与跨合约重入,并通过 Foundry 调用轨迹、模糊测试和不变量断言验证修复。前端限制不能替代链上安全控制,所有关键约束都必须由合约自身强制执行。
重入漏洞是智能合约审计中必须优先排查的问题。它并不局限于“重复提款”这一种表现:只要合约在关键状态尚未完成更新时,把控制权交给外部地址,外部地址就可能通过回调再次进入当前函数或其他共享状态的函数,破坏原有业务假设。
从安全边界看,任何外部调用都应被视为不可信,包括普通 ETH 转账、低级 call、ERC777 hooks、ERC721/ERC1155 的安全转账回调、预言机回调、跨链消息接收函数,以及对未知实现合约的接口调用。即使前端或移动端限制了按钮点击次数,也无法阻止攻击者绕过 APP,直接构造链上交易。因此,重入防护必须落实在合约端。
本文仅使用本地 Foundry 环境进行验证,不涉及任何真实协议或未授权目标。
二、漏洞原理
典型提款逻辑可以抽象为三个步骤:
1. 检查调用者是否有余额;
2. 向调用者发送 ETH;
3. 将调用者余额清零。
问题出在第二步。Solidity 的低级 call 会把执行权交给接收方。如果接收方是合约,其 receive 或 fallback 函数可以再次调用提款函数。由于第三步还没有执行,原合约看到的余额仍然有效,于是会再次付款。
存在风险的简化代码如下:
pragma solidity ^0.8.24;
contract VulnerableVault {
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, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
}
这里的核心缺陷不是使用 call 本身,而是“先外部交互、后更新状态”。在 call 执行期间,balances[msg.sender] 尚未归零。
三、漏洞成立的前置条件
并非看到外部调用就一定可以利用。审计时至少需要确认以下条件:
1. 调用目标可由攻击者控制,或者目标合约存在可触发的回调机制;
2. 外部调用发生在关键状态更新之前;
3. 重入后仍能通过权限、余额、时间或状态机检查;
4. 合约内存在足够的可转移资产,或重入能造成份额、奖励、投票权等账务异常;
5. 没有有效的互斥锁,也没有在所有相关入口执行 Checks-Effects-Interactions;
6. 重入路径没有因为 gas、调用深度、暂停状态等因素自然终止。
需要特别注意:攻击者第一次存入的金额可以很小,但如果提款金额根据内部余额计算,而合约中同时保存了其他用户资金,就可能出现用同一份余额记录多次提款的结果。
四、搭建本地审计环境
建议在隔离目录内使用 Foundry:
forge init reentrancy-lab
cd reentrancy-lab
forge build
将待测合约放入 src/VulnerableVault.sol。随后编写仅用于本地验证的测试攻击合约:
pragma solidity ^0.8.24;
import "../src/VulnerableVault.sol";
contract LocalReentryProbe {
VulnerableVault public immutable vault;
uint256 public callbackCount;
constructor(address target) {
vault = VulnerableVault(target);
}
function run() external payable {
require(msg.value == 1 ether, "send 1 ether");
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
callbackCount++;
if (address(vault).balance >= 1 ether && callbackCount < 5) {
vault.withdraw();
}
}
}
这个探针设置了明确的回调次数上限,避免本地测试因为无限递归而难以定位问题。它只用于证明控制流能够在余额清零前重新进入 withdraw。
五、分步骤验证
步骤 1:建立初始资产状态
在 Foundry 测试中,先让一个普通测试账户向金库存入 10 ether,再让探针合约存入 1 ether。这样可以验证漏洞影响的不只是攻击者自己的存款。
步骤 2:记录关键状态
调用前至少记录:
address(vault).balance
vault.balances(address(probe))
address(probe).balance
步骤 3:触发本地探针
使用 Foundry 的 vm.deal 为测试地址分配本地测试余额,然后调用 probe.run{value: 1 ether}()。执行:
forge test -vvvv
-vvvv 可以显示更详细的调用轨迹。审计时重点观察是否出现如下嵌套结构:
VulnerableVault.withdraw
→ LocalReentryProbe.receive
→ VulnerableVault.withdraw
→ LocalReentryProbe.receive
如果同一笔交易中 withdraw 被重复进入,并且每次读取到的 balances[probe] 仍为 1 ether,就证明状态更新顺序存在问题。
步骤 4:断言安全属性
不要只断言“攻击合约余额增加”,还应验证业务不变量:
1. 单个账户累计提款不得超过其已确认存款;
2. 所有用户可申领余额总和不得大于金库可用资产;
3. 一次提款执行后,用户内部余额应立即减少;
4. 任意外部回调期间,不应观察到可再次消费的旧状态。
可以进一步运行模糊测试:
forge test --fuzz-runs 10000
forge coverage
模糊测试适合覆盖不同存款金额、提款金额和调用顺序,但不能代替人工检查调用链。
六、修复方案
推荐同时使用“状态先更新”和“重入锁”,形成纵深防御。修复示例如下:
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
uint256 current = balances[msg.sender];
require(amount > 0 && amount <= current, "invalid amount");
balances[msg.sender] = current - amount;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed");
}
}
这里采用了 Checks-Effects-Interactions:先检查参数,再减少余额,最后执行外部转账。若转账失败,require 会回滚整笔交易,余额扣减也会一并回滚。nonReentrant 则用于阻止同一重入保护域内的嵌套调用。
如果业务允许,更稳健的设计是 Pull Payment:合约只记录用户可领取金额,由用户主动领取;同时将记账、结算和外部交互拆分清楚。需要注意,Pull Payment 只能降低复杂调用链风险,本身仍要正确更新领取状态。
七、系统化审计方法
1. 资产梳理:列出 ETH、ERC20、NFT、份额、质押凭证、奖励和跨链映射资产。
2. 入口梳理:检查 external、public、receive、fallback,以及代理合约转发后的实际入口。
3. 外部调用清单:搜索 call、delegatecall、staticcall、transfer、send、safeTransfer、safeMint 和第三方接口调用。
4. 状态顺序检查:确认余额、nonce、订单状态、已领取标记和份额是否在外部调用前更新。
5. 交叉函数检查:withdraw 加锁并不代表安全。如果 claim、redeem、borrow 等函数共享同一状态且未加锁,仍可能发生跨函数重入。
6. 权限检查:确认管理员、升级者、暂停者、预言机更新者和跨链消息验证者的权限边界。
7. 代理检查:对于可升级合约,还要检查初始化函数是否可重复调用、存储布局是否冲突,以及修复是否实际部署到了代理正在使用的实现。
8. 不变量测试:围绕总资产、总份额、用户余额、债务与抵押率建立可执行断言,而不是只测试正常流程。
可使用 Slither 进行辅助扫描:
pipx install slither-analyzer
slither . --filter-paths "lib|test"
静态工具的告警只能作为线索。某些外部调用已经正确更新状态,会产生误报;而跨函数重入、经济状态重入和依赖业务语义的漏洞,也可能不被工具发现。
八、容易遗漏的重入变体
1. 跨函数重入:函数 A 外部调用期间进入函数 B,B 读取了 A 尚未更新的共享状态。
2. 跨合约重入:金库、策略、奖励和价格模块分散在多个合约中,单个合约看似安全,但整体调用链存在旧状态窗口。
3. 只读重入:回调期间调用 view 函数读取临时不一致的数据,其他协议再依据该数据进行定价或结算。view 不修改状态,并不表示其返回值在任意调用阶段都安全。
4. Token Hook 重入:ERC777 的发送/接收 hook,以及 ERC721/ERC1155 的安全接收回调,都可能把控制权交给接收方。
5. 闪电贷放大:闪电贷通常不是根因,但可能临时放大资金规模,使会计或价格计算缺陷造成更严重后果。
九、风险与注意事项
不要简单把 call 替换为 transfer 并认为问题已经解决。依赖固定 gas 补贴容易产生兼容性和可用性问题,正确方向仍是安全的状态更新顺序、明确的失败处理和必要的重入锁。
不要只在 withdraw 上添加 nonReentrant 后停止审计。需要检查所有共享同一账本的入口。OpenZeppelin ReentrancyGuard 的 nonReentrant 函数也不能直接互相调用;常见做法是让外部入口加锁,再调用 private 或 internal 的内部实现。
不要用 address(this).balance 直接代表全部可分配余额。合约可能收到强制转入的 ETH,内部负债也不一定等于链上余额。应维护独立、可验证的会计状态。
移动端或网页端的交易确认页面只能改善用户知情程度,不能构成合约安全控制。合约不能假设所有调用都来自官方 APP,也不能依赖前端隐藏按钮、限制频率或校验输入。
十、常见问题
问:Solidity 0.8 已有整数溢出检查,是否能防止重入?
答:不能。溢出检查解决的是算术越界,重入属于外部调用期间的控制流和状态一致性问题。
问:只有转账 ETH 才会重入吗?
答:不是。代币 hooks、NFT 安全转账、外部策略、预言机回调和自定义接口调用都可能触发回调。
问:使用 nonReentrant 后是否可以忽略 CEI?
答:不建议。CEI 能缩短不一致状态窗口,重入锁则提供额外保护。两者结合更便于审计,也能降低后续新增入口时引入缺陷的概率。
问:静态扫描没有告警是否代表安全?
答:不代表。工具难以完整理解业务状态机、跨合约会计关系和经济不变量,必须结合人工审计、单元测试、模糊测试与不变量测试。
十一、总结
重入漏洞的本质是:合约在关键状态尚未落定时,将执行权交给了不可信外部实体。有效审计不能只搜索某个函数名,而应沿着“资产—状态—外部调用—回调入口—共享状态”完整建模。实践中应优先执行 CEI,按业务边界使用 ReentrancyGuard,检查跨函数与跨合约重入,并通过 Foundry 调用轨迹、模糊测试和不变量断言验证修复。前端限制不能替代链上安全控制,所有关键约束都必须由合约自身强制执行。