1. 从一桩"看似稳赚"的攻击说起
智能合约重入攻击防护验证,这个名字听起来很学术,但几乎所有接触过 DeFi 安全的人,都绕不开这道坎。重入攻击在区块链安全领域的地位,相当于 Web 世界里 SQL 注入在数据库安全里的位置——经典、致命、而且远比你想象的更容易被绕过。做测试的人最怕的不是漏洞本身,而是测试方案设计得不完整,防护机制明明有漏洞却测不出来。
我见过不少测试从业者听到"重入攻击"就条件反射想到要写一个恶意合约去反复调用目标合约的提款函数,跑通一个 case 就宣告验证通过。这种验证方式,说难听点,是在给代码做体检的时候只量了体温,没拍片子。真正有效的重入防护验证,需要考虑重入的方向、入口函数的枚举、外部调用的位置、状态变量的更新时序、以及锁的粒度。这篇文章就是我基于大量实战经验整理的一份关键细节笔记,适合有一定 Solidity 基础、正在做合约安全测试或者审计验证的朋友参考,哪怕你是刚入行的测试新手,按照里面的思路一步步来,也能设计出相当靠谱的验证方案。
任何重入攻击的测试,本质上都是在回答同一个问题:当某个函数的外部调用还没结束、状态还没最终落定时,能不能有人再次进入同一个合约的敏感逻辑,并且从中得利?如果你能把这个问题拆解清楚,整个验证体系就通了一半。
2. 重入攻击的本质测试视角
2.1 先用生活类比搞清楚"重入"到底是什么
想象你在一家银行柜台办理转账,你把钱递给柜员,柜员说"好的,我先给对方打个电话确认账户",然后放下你的业务去接电话。这时候你突然对另一个柜员说"我也要办理转账,把我卡里的钱都转走"。第二个柜员查看你的卡余额,发现钱还在(因为第一笔业务还没入账),于是又把钱转走了。等你第一笔业务真正入账时,系统发现余额不足,但此时不该发生的第二笔转账已经完成。这就是重入攻击的核心逻辑。
智能合约的世界里,外部调用(比如转账 trigger 对方合约的 fallback 或 receive 函数)就是"柜员去打电话"的时刻。如果合约在发起这个外部调用之前,没有先把自己的余额状态更新掉,那么攻击者就能利用回调的时机,重新进入合约的提款函数,把本不该再提取的金额再提一遍。
测试从业者看到这里应该立刻意识到一个问题:验证重入防护是否有效,不能只看"能不能重复调用同一个函数",而要关注"外部调用发生在哪个时机"。如果状态更新在外部调用之前已经完成,重入即使发生,也无法造成额外损失;如果状态更新在外部调用之后,那就是典型的漏洞模型。
2.2 单函数重入与跨函数重入的差异
很多人对重入的理解停留在"同一个函数反复调用",这在最早的案例里确实是这样。但后来攻击手法进化了,出现了跨函数重入。
单函数重入:A 函数发起外部调用,在调用期间攻击者的回调里重新调用 A 函数。最经典的场景就是提款函数的 ETH 转账,攻击者在 receive 回调里再次调用提款函数。
跨函数重入:A 函数发起外部调用,在回调期间,攻击者调用的是 B 函数,而 B 函数和 A 函数共享同一个关键状态变量。比如 A 函数负责减少用户的锁仓余额,B 函数负责根据锁仓余额发放奖励。如果 A 函数在扣减状态之前就把控制权交出去了,攻击者可以在回调里调用 B 函数,此时 B 函数读到的还是未被扣减的旧余额,就能超额领取奖励。
还有一种更隐蔽的,是跨合约重入。同一个交易里,多个合约之间相互调用,攻击者可以在某个中间调用点上重新进入另一个合约的敏感逻辑。我最开始做测试的时候,吃过跨合约重入的亏——单合约测试全部通过,但组合起来就出问题。这就是为什么验证方案里必须包含组合场景。
2.3 从测试设计角度理解攻击成立的条件
从我个人的测试经验看,判断一个场景是否需要重入攻击验证,只需要看三个条件是否同时成立:
第一,合约存在外部调用,而且这个调用发生在某个状态变量的更新之前。第二,被调用的外部地址是可以被攻击者影响的,哪怕是通过合约注册表、回调钩子、代理地址间接影响。第三,外部调用之后,合约的后续逻辑依赖于前面那些尚未更新的状态变量来做出资金或权限相关决策。
这三个条件同时满足,你基本上可以确定这是个重入风险点。测试用例的设计,其实就是围绕这三个条件去构造场景,验证防护机制能否阻断攻击路径。记住这个判断框架,比背一百个攻击案例都管用。因为攻击形式千变万化,但底层的条件模型是稳定的。
3. 主流防护机制的测试验证重点
3.1 checks-effects-interactions 模式验证什么
这是最经典也最容易被误判的防护手段。它的核心思想是:先做条件检查(checks),然后更新合约状态(effects),最后才进行外部交互(interactions)。顺序一旦调整,防护就不成立。
测试从业者验证这个模式时,最容易踩的坑是只检查代码行文顺序,而忽略了状态变量的更新粒度。我举个例子:某个合约在函数开头就把balances[msg.sender] = 0了,看起来符合 effects 先行的原则,但合约另外还有一个totalLocked或者rewardDebt之类的关联变量没有同步更新。攻击者虽然无法重复提取余额,但可以通过重入影响其他关联逻辑。
所以验证这个模式,不能只盯着单个状态变量。要梳理清楚:外部调用之前,所有与该函数相关的状态是否都已经更新到位?关联状态之间是否存在时间差?建议的做法是画一张函数状态变量读写表,把函数名、读取的变量、写入的变量、外部调用点分别列出来,然后检查读写顺序。测试用例则要覆盖"外部调用后读取的每一个变量是否为最新值"这个断言。
3.2 重入锁的粒度陷阱
重入锁(mutex)是现在最常见的防护手段,OpenZeppelin 的 ReentrancyGuard 就是个典型实现。原理非常简单:用一个_status状态变量标记当前是否在执行中,函数入口检查状态,退出时恢复状态。
但锁的粒度有很大讲究,这是测试中特别容易漏掉的地方。按粒度从细到粗可以分三个层次:
锁只保护单个函数。这种方式防护面最窄,跨函数重入如果共享状态,攻击者可以绕过单个函数的锁,从另一个函数进入。
锁保护同一合约内的所有函数,比如 ReentrancyGuard 的 nonReentrant 修饰符加在多个函数上。这是最常见的做法,但要注意:如果攻击者从合约 A 的未加锁函数进入,在外部调用时回调合约 A 的加锁函数,保护依然有效;可如果合约之间存在相互调用的关系,锁的状态是保存在各自合约内的,跨合约的锁就需要额外的协议级设计。
锁的保护范围横跨多个合约的整个操作流程。这种多见于复杂的借贷协议或金库系统,需要把多个合约的锁串联起来。这种场景下,测试要特别注意锁是否会被某些异常路径卡死,比如函数被回滚后锁的状态是否可能停留在"已锁定",导致后续调用全部被吞掉。我在测试中实际遇到过这个问题:锁状态和函数的正常流程绑定得太紧,一旦遇到某种边缘情况的回滚,锁就永远卡住了。
3.3 转账机制差异带来的验证盲点
很多人天然认为"我用 transfer 或者 send 限制了 gas,就不会有重入问题"。这种想法在测试场景里非常危险。transfer 和 send 给回调的 gas 限制是 2300,这确实能阻止大部分复杂的重入逻辑,因为攻击者的回调代码稍微复杂一点就会 out of gas。但有几个例外必须验证:
如果目标函数不依赖 ETH 转账触发回调,而是通过 ERC777 之类的支持 hook 的代币转账触发回调,gas 限制根本不适用,回调会正常执行,重入攻击依然可行。
如果合约通过 call 进行 ETH 转账,比如addr.call{value: amount}(""),gas 是全部转交的,攻击者可以尽情执行复杂逻辑。
更隐蔽的是:多个跨链桥接或托管合约中,提款不一定发生在当前交易内,而是先记录一个待提款状态,由攻击者后续手动触发。这种场景下"gas 限制防重入"的说法完全不成立,因为提款动作本身就是攻击者主动发起的,重入点变成攻击者自己的托管合约调用。
所以测试方案里,我建议把转账方式作为一个独立的测试维度。同一套逻辑分别用 transfer、call、hook 代币来触发回调和重入尝试,观察结果是否一致。
3.4 防护机制对比速查表
- | 防护机制 | 防单函数重入 | 防跨函数重入 | 防跨合约重入 | 主要验证关注点 | |---|---|---|---|---| | checks-effects-interactions | 有效 | 需检查关联状态更新时序 | 需组合场景验证 | 外部调用前所有相关状态是否已更新完成 | | 单函数重入锁 | 有效 | 无效 | 无效 | 锁只保护一个函数时的绕过路径 | | 合约级重入锁 | 有效 | 有效 | 需看锁的保存位置 | 回滚是否卡死锁状态、锁状态的作用域 | | 跨合约协议级锁 | 有效 | 有效 | 有效 | 锁状态同步机制和异常路径恢复 | | transfer/send 限 gas | 基本有效 | 受限于回调复杂度 | 受限于回调目标 | hook 代币、call 转账、延迟提款的绕过场景 |
这个表格里每一行都有对应要重点测试的边界场景,后面我会逐个展开讲怎么测。
4. 防护验证测试方案的设计全流程
4.1 第一步:信息收集与入口函数清单
做重入测试之前,先别急着写代码。我习惯先对目标合约做一轮完整的静态梳理,重点收集三类信息:
所有标记为 external 或 public 的函数都要拉出来,逐一判断它们是否可能成为重入入口。入口的定义是:这个函数内部存在外部调用,并且在调用点之前有状态更新逻辑。如果一个函数没有任何外部调用,它理论上是没法直接发起重入回调的,但要注意内部调用链上可能间接调用其他含外部调用的函数。
所有 external 调用点都在什么地方,顺序如何。包括 ETH 转账、代币转账、对注册表合约的调用、对回调钩子的调用,都要一一列出。
更关键的是,每个函数的状态变量更新顺序。哪些变量在外部调用之前更新,哪些在之后,我需要画成一张表,因为后面断言都要靠这张表来判断预期结果。
在梳理过程中,我强烈建议用伪代码把每个敏感函数的核心逻辑写下来,不用管编译能不能过,目的是看清调用顺序。这一步做得细致,后面测试用例的设计会轻松很多。
4.2 第二步:动态验证环境搭建
重入测试最适合用 Foundry 来做,这个框架允许你把恶意合约写在测试里,直接发起高仿真攻击。我这里默认你已经有一个 Foundry 测试项目,如果没有,用forge init初始化一个就行。
测试环境搭建的核心,是把目标合约部署到位,设定好初始状态和攻击者的初始持仓。下面是一个最简单的 setUp 模板,我用一个模拟的银行合约来做演示:
contract MockBank { mapping(address => uint256) public balances; bool private locked; constructor() {} function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw(uint256 amount) external { require(amount <= balances[msg.sender], "insufficient"); balances[msg.sender] -= amount; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "call failed"); } }这个合约逻辑上是有问题的:它先更新余额,再发送 ETH。从代码顺序看似乎遵守了"先更新后交互",但这个例子的问题在于withdraw中balances[msg.sender] -= amount确实在 call 之前,看起来没问题。那我换个例子,把顺序颠倒过来,模拟典型漏洞:
contract MockBankVulnerable { mapping(address => uint256) public balances; bool private locked; constructor() {} function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw(uint256 amount) external { require(amount <= balances[msg.sender], "insufficient"); (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "call failed"); balances[msg.sender] -= amount; } }这个才是典型的漏洞模型。测试恶意合约的写法是:
contract Attacker { MockBankVulnerable private target; uint256 private attackCount; address private owner; constructor(address _target) { target = MockBankVulnerable(_target); owner = msg.sender; } receive() external payable { if (address(target).balance > 0) { target.withdraw(address(this).balance); } } function attack() external payable { target.deposit{value: msg.value}(); target.withdraw(msg.value); } function collect() external { payable(owner).transfer(address(this).balance); } }然后在测试里验证:攻击者最初有 1 ETH,完成 attack 之后,攻击者合约地址上应该持有合约里的全部余额,目标合约余额归零。
function test_ReentrancyAttack() public { Attacker attacker = new Attacker(address(bank)); vm.deal(address(this), 1 ether); bank.deposit{value: 1 ether}(); vm.deal(address(attacker), 1 ether); attacker.attack{value: 1 ether}(); assertEq(address(bank).balance, 0); assertEq(address(attacker).balance, 2 ether); }这里有个细节值得强调:攻击者合约的 receive 回调函数里,循环终止条件要设置为address(target).balance > 0。不然的话,递归会一直执行,直到耗尽 gas。测试的核心断言不是"攻击是否成功",而是"合约的余额是否被抽空",两个维度都要看。
4.3 第三步:验证矩阵与断言设计
单一的攻击测试跑通,在整个验证工作里大概只占三成。真正完整的验证要做覆盖矩阵,我通常会把以下维度交叉排列:
防护机制的覆盖范围。先搞清楚目标合约用的是什么防护手段,是 reentrancy lock 还是 checks-effects-interactions 顺序,还是 combo 双保险。每种防护都要单独测。
重入的入口函数列表。无论目标合约对外暴露了多少个可能被重入的入口函数,每个入口都要作为一次重入尝试的目标来设计用例。
重入的调用路径,包括直接调用同一函数、跨函数跳转、跨合约组合调用。
状态变量的时序差异。同一个函数里,如果状态更新在外部调用后,要测典型的重复取值;如果状态更新在外部调用前,要测攻击者能否通过回掉影响后续逻辑。
按照这个矩阵,我的经验是一个同时有存取款、借贷、奖励领取功能的合约,很容易就会设计出 20 到 30 个重入测试用例。有些看起来是重复的,但实际跑起来会发现不少隐患,比如某个入口函数漏加锁,或者某个关联变量没有同步更新。
断言设计这里有个特别重要的原则:断言不能只依赖"攻击者余额是否增加"。有些重入攻击的目的不是直接掏空合约,而是篡改状态、影响其他用户的资产计算。所以每一条断言都要对应到具体的状态变量,既能验证资金增减,也要验证账面值和实际值的偏差。还有,断言必须同时检查目标合约的余额、攻击者合约的余额、状态映射变量,三个缺一不可。
4.4 第四步:跨函数重入的测试构造
跨函数重入是很多人验证时容易漏掉的场景。构造方式其实不复杂:在恶意合约的 receive 回调里,不要调用同一个 withdraw 函数,而是调用另一个和它共享状态变量的函数。
继续拿模拟场景举例,假设目标合约还有一个claimReward函数,会根据用户的balances映射计算奖励。如果withdraw的 balance 扣减发生在转移之后,那么攻击者在回调里调用claimReward时,读到的 balance 还是扣减前的旧值,这样就能重复领取奖励。
receive() external payable { if (attackCount == 0) { attackCount++; target.claimReward(); } }跨函数重入的测试难点不在写恶意合约,而是在找到两个函数之间共享的状态变量。这个必须回到第 4.1 步的状态变量读写表里去找线索。共享变量越多的合约,跨函数重入的测试用例越要多设计几个方向。
4.5 模拟工具帮助扩展边界
除了手写的精确测试用例,我还会用 Foundry 的模糊测试功能来扩展边界。思路是这样的:写一个通用的重入测试函数,入参是重入次数、重入位置、回调函数的选择器等,然后让 fuzzer 自动生成大量组合。这个做法的价值在于,它很容易发现你之前没考虑到的重入位置组合。
一个简化版的做法是定义枚举类型表示重入位置,在 receive 回调里根据不同位置执行不同攻击动作:
enum ReentryPoint { AfterTransfer, AfterBalanceUpdate, DuringExternalCallInLoop } receive() external payable { if (reentryPoint == ReentryPoint.AfterTransfer) { target.withdraw(address(this).balance); } else if (reentryPoint == ReentryPoint.AfterBalanceUpdate) { // 尝试再调用一次,看防护是否拦截 } }然后用 fuzzer 把每种组合跑几百遍,观察是否有任何一次攻击成功。模糊测试跑出来的"意外成功",往往是真实审计中最有价值的发现。
5. 测试过程中容易踩的坑与排查思路
5.1 坑一:过度依赖 gas 限制来预期攻击结果
我在多个项目里发现,测试者只要看到 transfer 或者 send,就自动认为重入攻击不可能成功,直接略过了攻击路径的构造。前面分析过,transfer 的 2300 gas 限制确实限制了大部分回调逻辑,但如果代币是 ERC777 这种带 hook 的,或者提款目标是另一个会主动回调的代理合约,情况就完全不同。
我的建议是:不管目标合约用的是 transfer 还是 call,都把重入测试写起来。如果回调因为 out of gas 失败,测试会自然告诉你攻击无效。但如果因为你的疏忽没写用例,你根本不知道自己的假设是否成立。测试这个行业,最忌把假设当结论。
5.2 坑二:恶意合约回调里缺少终止条件
这个问题几乎每个新手都会遇到。恶意合约的 receive 回调如果写成无限循环调用提款函数,没有设置终止条件,最终反馈回来的不是攻击成功,而是 out of gas。然后测试者很困惑,把整个攻击链怀疑一遍,就是没怀疑自己的恶意合约写得有问题。
正确的做法是设置明确的终止条件。最直观的是检查目标合约余额是否已经被抽空,一旦余额为零立即停止。还有一种做法是限制最大重入次数,用attackCount < N作为条件。第二种做法有个好处,你能控制重入深度,特别适合测试不同深度的防护表现。
5.3 坑三:只测正向攻击,不测防护死锁
重入锁测试有个双向维度:一个维度是攻击者的重入尝试是否会被拦截,另一个维度是防护本身是否会在异常路径下被卡死。
所谓死锁,就是锁的置位和复位跟函数执行路径耦合得很紧。比如函数在外部调用之后抛出了一个require失败,如果锁复位代码在require之后,本次交易回滚时,状态会一起回滚。但若外部调用已经更新了另一个合约的状态,而当前合约的回滚没有同步回滚对方的状态,可能造成跨合约的资源饥饿。
测试死锁的方法是:设计一个人为让外部调用失败或者触发 require 失败的场景,然后尝试再次调用同一个加锁函数,观察锁是否还能正常工作。我实际遇到过锁状态被永久卡住的案例,那次要不是在测试里多写了一条"失败后重试"的用例,上线之后整个合约的资金进出都会瘫痪。
5.4 坑四:忽略 view/pure 函数的"假重入"
有一个很反直觉的现象:如果一个提款流程中间调用了某个 view 函数来读取状态,而这个 view 函数被攻击者用恶意实现替换了,那它也可能成为间接重入的载体。虽然 view 函数不能直接改状态,但如果它读取的是外部合约的数据,而攻击者控制了外部数据源,就可能影响提款流程的后续分支。
这类问题通常不会单靠重入测试暴露出来,但它确实是重入测试中要留意的一种变体。我的做法是,在梳理外部调用点时,把 read 类型的跨合约调用也列进来,判断它们是否可能被恶意数据源影响。
5.5 测试搭环境时的高频问题速查
- | 现象 | 可能原因 | 排查方向 | |---|---|---| | 攻击合约调用直接 revert | 目标函数加了重入锁,二次进入被拦截 | 先确认锁是否生效,再确认自己构造的是单函数还是跨函数重入 | | 攻击交易显示 out of gas | 恶意合约回调缺少终止条件,无限递归 | 检查 receive 回调中的循环退出条件 | | 攻击成功但余额断言失败 | 手续费、gas 补偿、初始余额计算有误 | 把攻击前后所有相关地址的余额都打印出来对比 | | 测试单个函数通过,组合场景失败 | 跨合约/跨函数重入路径未覆盖 | 检查共享状态变量和外部调用点位置 | | 锁状态被卡死,后续交易全部 revert | 异常路径未正确复位锁状态 | 检查 require 失败路径上的锁复位顺序 | | 重入在测试环境可行,主网模拟不可行 | gas 估算差异或链上手续费导致回调 gas 不足 | 使用高 gas 上限和真实主网 RPC 做 fork 测试 |
排查的时候最忌讳"一个一个变量去试"。我的经验是先把整个攻击路径的事件顺序打印出来,从攻击者发起调度,到目标合约进入外部调用,再到恶意合约回调入口,每一步都可能有拦截点。定位到具体拦截点之后,再针对性地调整测试,效率会高很多。
6. 一份经过验证的重入攻击排查清单
6.1 代码层面自查清单
每次做重入测试,我都会拿着下面这份清单过一遍代码:
- 所有涉及资金转移或外部调用的函数,转账之前的余额扣减是否同步执行。这里说的同步,不仅仅是某个映射变量的更新,还包括所有关联的累计变量。
- 外部调用的目标地址是否完全可控,如果可控,是否调用了恶意合约的回调。
- 合约内多个函数是否共享状态变量,共享的方式是否给了跨函数重入的空间。两个函数如果都读取
user.balance并且先后被同一个交易触发,就容易出问题。 - 是否存在回调触发的 token 转账逻辑,包括代币合约中的 hook 机制。很多标准的 ERC20 没有 hook,但是 ERC777、ERC1155 甚至某些 ERC721 的转账逻辑都值得警惕。
- 重入锁的置位是否覆盖了所有敏感入口。非敏感入口的边界,也要确认是否可能被利用来绕过锁的检查。
- 外部调用失败后,函数是否继续执行关键状态变更。标准做法是外部调用失败必须 revert,除非有明确的替代路径设计。
这份清单不只是说说而已,我建议把它转成可执行的 grep 检测规则和代码走读检查表,每次审计测试都过一遍。
6.2 测试用例覆盖度检查表
测试执行完之后,还要回头检查用例本身是否覆盖到位:
- 直接重入:同一函数、同一路径的重复调用,覆盖了吗。
- 间接重入:外部调用触发恶意合约,恶意合约再调用本合约其他函数,覆盖了吗。
- 跨合约重入:攻击者能否通过另一个合约进入本合约的敏感逻辑,覆盖了吗。
- 反向重入:目标合约调用外部合约时,外部合约反过来调用目标合约的另一个函数,覆盖了吗。
- 极端时序:外部调用在状态更新前、状态更新中、状态更新后的重入,分别覆盖了吗。
- 锁的复位:一次正常的攻击完成后,锁状态是否正常恢复,覆盖了吗。一次失败的操作后,锁状态是否可能被卡死,覆盖了吗。
我总是跟团队里的人说,重入测试不是"验证一下有没有 bug",而是一个对抗性思维训练的过程,你要像攻击者一样去思考怎么绕过防护,而不是像开发一样去欣赏防护。
7. 从个人经验里提炼的几个核心认知
做重入攻击防护验证这几年,我最大的一个体会是:单纯依赖某一种防护机制都不够稳。checks-effects-interactions 模式能挡住大多数基本重入,但它的前提是开发者完整理解所有状态变量之间的依赖关系;重入锁粒度防住了本合约,但跨合约场景需要更全局的设计。真正稳健的方案,是机制组合加完备测试矩阵。
第二个体会是,测试用例的质量远比数量重要。我见过有人写了二十几条重入测试用例,但全是同一条路径换不同的金额,真正的跨函数场景一条都没覆盖。测试设计最核心的动作是花时间把状态变量读写表和外部调用点梳理清楚,这个步骤做扎实了,哪怕你只写五条用例,都比盲目写三十条要有效得多。
最后再分享一个小技巧:在测试里把每一次重入尝试的目标函数和触发顺序都打上事件日志,这样攻击链一旦被拦截,你能立刻看到是哪个环节干的活。我自己的项目里,恶意合约和测试合约都会专门加事件输出,排查效率直接翻倍。重入攻击测起来确实烧脑,但它也是最能锻炼测试功底的题型之一,把思路和方法沉淀下来,后面做其他安全测试都会顺手很多。