上个月,一个做合约测试的同事跟我抱怨:功能测试、接口测试、回归测试全做了,为什么链上资产还是出事?我问了一句:你有没有想过,攻击者根本不看你的需求文档,他们只看状态机里那些没人走到位的分叉口。智能合约安全审计和传统软件测试最大的不同就在这里,它不是把“需求测完”,而是把“所有不应该发生的路径”都当作正经路径去走一遍。做这行越久,我越觉得链路安全不是靠某一款工具或某一个天才审计师,而是由从代码到经济模型层层叠出来的七道防线撑住的。这篇内容是给测试工程师的实战复盘,我把自己的攻防推演流程、踩过的坑、以及每道防线怎么落地的细节,一次性整理出来。
1. 为什么测试工程师能啃下智能合约安全审计这块硬骨头
1.1 攻防视角与传统测试视角的差异
传统测试工程师的习惯是“需求覆盖”,先看产品文档,再画用例矩阵,把正常流程和异常流程都跑一遍。但在链上,需求本身只是“系统应该做什么”的很小一部分,审计真正要回答的是“系统不该做什么,以及如果有人强行让它做,它扛不扛得住”。
举例来说,一个借贷协议的业务需求可能是“用户存入资产后可以借出资产”,这听起来很清晰。但审计师的视野里,这句需求会迅速扩展成一系列问题:存入和借出的状态更新顺序是否安全?价格源取自哪里,如果价格瞬间偏离 20% 会怎样?清算函数是不是任何人都能触发,触发者能拿到多少奖励?这些在需求文档里往往只字未提,但恰恰是链上资产安全的核心。
所以测试工程师要转攻智能合约,第一步不是去堆积 Solidity 语法,而是把“需求覆盖”心智切换成“攻击路径覆盖”。你的用例设计能力、边界值敏感度、异常注入经验,全都是可直接迁移的底子。我见过很多安全背景出身的人反而容易只盯着重入、整数溢出这些经典漏洞,忽略了一个更致命的东西——业务逻辑在黑箱条件下组合出来的“非预期状态”。后者恰恰是测试工程师最擅长的:你有很强的场景拆分能力。
1.2 测试工程师做审计的三个先天优势
从我带团队的经验看,有软件测试背景的人进入链上审计,有三个别人很难短期追上的优势。
第一个是用例设计能力。审计报告里的每一条风险,本质上都是一条反向用例。你怎么设计“用户跌到清算线以下但还差 1 wei 才触发清算”这种边界场景?怎么设计“两个账户同时反复调用同一函数”的并发场景?这是测试思维的基本功。
第二个是工具链操作能力。很多原生开发者写合约很顺,但对静态扫描工具、模糊测试器、变异测试框架的掌握反而不如专业测试。你会发现像 Foundry、Echidna 这类工具,本质上就长着一张“测试框架的脸”,有 setup、test、invariant、report,习惯了 pytest 或 JUnit 的人上手极快。
第三个是异常敏感度。传统测试里有一句话叫“凡是没被验证过的成功,都是事故现场”。在合约审计里同样成立:外部调用有没有检查返回值?转账失败时状态是否回滚?权限变更后旧角色是否仍然生效?这些“不正常也要让它正常”的思路,就是测试工程师刻在骨子里的东西。
也正因为如此,智能合约安全审计不应该是少数安全专家闭门造车的领域。一个有五年测试经验、半个月吃透 Foundry 的人,在这个战场上能产生的产出,常常不亚于培训班里速成的“安全研究员”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先画靶子:七道防线到底在防什么,靠什么防
2.1 先从攻击路径倒推:什么情况下会损失资产
我一开始做审计时,上来就想找漏洞,结果在合约里翻了半天,什么也没找到。后来带我的老师傅点醒了一句话:你连攻击目标是啥都不知道,你在找什么?
从那以后,我拿到合约的第一件事永远是画“资产流向图”:哪些资产进了协议、存在哪里、谁能动它、动它的前提条件是什么。这个动作类似测试需求分析阶段做的影响范围评估,只不过对象从页面按钮变成了资金操作。
把攻击路径倒推下来,我会整理成一张“损失模型清单”。通常链上资产损失绕不开几类:第一类是合约逻辑缺陷,比如重入、权限绕过、账本不一致,攻击者可以直接把池子掏空;第二类是外部依赖被污染,比如价格预言机被操控、依赖的合约被升级成恶意实现,导致协议按错误数据结算;第三类是经济模型失衡,比如滑点设计不合理、清算奖励算错,攻击者通过闪电贷在短时间内操纵价格完成套利;第四类是管理权限风险,比如 owner 私钥泄露、时间锁形同虚设、多签阈值过低,治理地址转手就能把用户资产全部提走;第五类是上线后的外部风险,比如依赖的公链节点异常、RPC 被篡改、抢跑机器人故意抬高 gas,这些和合约代码没直接关系,但最终伤害的还是链上资产。
七道防线就是按这五类损失倒推出来的。不是我要凑七条防线,而是当你逐层追问“代码有没有问题、状态会不会乱、外部数据能不能信、权限会不会失控、上线后能不能第一时间发现异常”时,防线会自己长出来。
2.2 七道防线全景表
我把整套防线用一张总表固定下来,每次接审计项目都直接按这张表推进,防止漏项。
| 防线 | 核心任务 | 主要手段 | 典型对应风险 |
|---|---|---|---|
| 第一道防线:静态扫描 | 先用机器规则排查常见代码缺陷 | Slither、Aderyn、Semgrep | 重入、未检查返回值、危险的 tx.origin |
| 第二道防线:状态模糊与不变量验证 | 通过大量随机交互试探合约状态一致性 | Foundry fuzz、Echidna | 账本不一致、边界条件错误、异常中断 |
| 第三道防线:行为差分与变异分析 | 用代码变更反推测试套件是否真的有效 | 差分测试、变异测试 | 回归漏洞、修复无效、测试盲区 |
| 第四道防线:人工逻辑审计 | 对业务状态机、函数权限、账本逻辑做逐行推演 | 状态机建模、权限矩阵、代码走查 | 业务逻辑缺陷、权限绕过、设计缺陷 |
| 第五道防线:经济模型压力推演 | 模拟攻击者动用大额资金的攻击路径 | 闪电贷模拟、价格操纵建模、滑点测算 | 预言机操纵、清算套利、不健康头寸 |
| 第六道防线:治理与升级专项审计 | 审查权限分配、时间锁、代理升级链路 | 治理流程分析、多签审查、升级演练 | 后门、私钥泄露、恶意升级 |
| 第七道防线:上线监控与应急预案验证 | 把监控、熔断、暂停开关当作“生产环境测试” | 链上监控脚本、事件告警、应急演练 | 延迟响应、抢跑、黑天鹅事件 |
这张表是我们项目组内部从十几个案例里提炼出来的。可以看到,前三条防线更多依赖工具和自动化,第四到第六条防线重心在人,第七条防线一直延伸到上线之后。测试工程师如果能按这个顺序形成肌肉记忆,做审计时就不容易只盯着一两个具体漏洞,而是会自然形成一套纵深防御的评估思路。
2.3 对号入座:哪些损失类型对应哪条防线
总表看完可能会觉得比较空,我再给一个对应关系,方便以后拿到项目快速定位重点。
如果攻击路径是“攻击者通过反复调用提款函数,在余额扣除前反复取走资产”,这个命中的是“合约逻辑缺陷”,主要防线是静态扫描里的重入检测和人工逻辑审计中的调用顺序检查。如果路径是“攻击者利用闪电贷借出大量资金,在某 DEX 里把某个币的价格推高,再用这个虚高价格从借贷协议借走超额资产”,它命中的是“经济模型失衡”,靠第四道防线里面的业务逻辑推演和第五道防线的压力测试。如果路径是“项目的 owner 地址把代理合约直接升级成恶意实现,然后抽干资金”,它命中的是“管理权限风险”,第六道防线就是专门为它准备的。如果路径是“合约上线三个月后,预言机合约被攻击者篡改,协议瞬间按错误价格清算用户”,这属于“外部依赖与线上异常”,虽然代码审计时能发现依赖风险,但真正兜底的是第七道防线的实时监控和应急响应。
说白了,七道防线不是七种高深技术,而是七层“假设上一道防线失效了怎么办”的冗余设计。哪怕你在前六道防线里漏掉了一条,监控和预案还在后面兜底。这种思路非常像测试金字塔:底层自动化扫面越广,上层人工审查越能集中在刀刃上。
3. 前三道防线:让代码先自己打自己一遍
3.1 防线一:静态扫描,机器先把常见病筛出来
每接一个新合约,我第一件事就是跑 Slither。这条命令值得所有测试工程师背下来:
bash复制slither . --solc-remaps @openzeppelin/=lib/openzeppelin-contracts/ \
--filter-paths lib/ \
--exclude-dependencies
跑完之后,我习惯先看 High 级别的告警,但绝不直接照单全收。Slither 是一个基于数据流和污点分析的静态工具,它的强项是能快速扫出重入、未检查的外部调用返回值、危险的 tx.origin、错误的权限修饰符,这些模式是机器最容易学会的。但它也会产生很多误报,比如它会把“外部调用后再更新状态”都标成重入风险,可如果函数加了 nonReentrant 修饰符,实际风险已经被控制住了,需要人工确认。
我记得刚上手时,一份报告里 Slither 弹了 8 条 High,我当时紧张得不行。逐条去看之后,6 条是因为外部调用后没有立即更新状态而触发的,但合约外层都加了防重入锁,实际不可利用;剩下 2 条才是真问题:一条是 withdraw 函数里没有检查底层代币转账返回值,另一条是某个只读函数被声明成 public,导致任何人都能调用内部记账逻辑来消耗 gas。如果我把工具输出直接当成结论,审计报告就会变成一份“狼来了”清单。
这里有一个测试工程师非常熟悉的处理方法:把静态扫描结果当作“疑似用例”,而不是“确认缺陷”。每一条告警都要回到代码里建立完整的调用链,确认可达性和可利用性,确认后才能进入报告。Slither 的输出格式很适合做归档,无论误报还是确认风险,我都会保留现场,方便后续回归对比。
很多团队会再加一条 Aderyn 或 Semgrep 作为第二扫描器,这种交叉验证思路我很支持。不同工具底层使用的检测规则不同,有的偏污点分析,有的偏模式匹配,多跑一个往往能补上另一边的盲区。不过要注意,静态扫描的意义是压低人工审计的工作量,不能本末倒置,把调参和消误报当成项目主体。
3.2 防线二:让状态自己爆炸——不变量模糊测试
静态扫描之后,我会立刻进入不变量模糊测试。这一步很多人会忽略,认为智能合约没有 UI、没有接口,没法做模糊。但实际上合约本身就是公开接口,甚至比传统 Web 服务更开放,因为所有函数对所有人可见。
我以之前写的一个简易 Vault 合约为例,它的问题是提款顺序设计不合理。审计中我们关心的不变量是:任何时候,用户账面上的总余额都应该等于合约持有的真实资产量。如果合约实际转出了资产,但账本没扣除,这个不变量就会被破坏。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract Vault {
mapping(address => uint256) public balanceOf;
uint256 public totalLocked;
bool private _locked;
modifier nonReentrant() {
require(!_locked, "reentrant");
_locked = true;
_;
_locked = false;
}
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
totalLocked += msg.value;
}
// 这里故意把余额更新放在外部调用之后,模拟一种脏状态
function withdraw(uint256 amount) external nonReentrant {
require(balanceOf[msg.sender] >= amount, "insufficient");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount;
totalLocked -= amount;
}
receive() external payable {}
}
让测试框架自动跑,靠随机调用很难发现这个漏洞,因为攻击者必须是一个能在收到 ETH 后回调 withdraw 的合约,普通随机地址没有这种能力。这正好说明:不变量模糊测试不是银弹,它更适合验证“任何随机交互序列都不会打破状态一致性”,而不是用来寻找特定攻击者模式。重入这种攻击,更需要静态扫描配合人工确认。
真正有效的 fuzz 用例得引入“恶意代理调用者”,我用 Foundry 的 startHoax 和自建攻击合约可以把重入场景串起来。这时候你会发现,测试工程师平时写 mock、写 handler 的经验全部派上用场:我们需要主动设计一个会回调的地址,把这些恶意行为喂给状态机,让合约“自己打自己”。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract Attacker {
Vault public vault;
uint256 public times;
constructor(Vault _vault) {
vault = _vault;
}
receive() external payable {
if (times < 3) {
times++;
vault.withdraw(msg.value);
}
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
}
contract VaultTest is Test {
Vault public vault;
Attacker public attacker;
function setUp() public {
vault = new Vault();
attacker = new Attacker(vault);
}
function testReentrancy() public {
attacker.attack{value: 1 ether}();
// 漏洞触发后,攻击合约实际拿到了超过一次提款的资金
assertLt(address(vault).balance, vault.totalLocked());
}
}
测试跑失败,你就能在 trace 里看到重入路径,这正是报告里最有说服力的证据。这个案例是我想特别强调的:工具不会替你思考,但工具能帮你把“你认为可能出问题的地方”快速变成可复现的证据,这条思路贯穿整个审计流程。
3.3 防线三:差分与变异——不改变不知道哪里脆
前两条防线做完,项目组内部通常已经有一个初步风险清单。我再往前走一步,做行为差分测试和基础变异测试。
差分测试在智能合约里特别适合解决“升级后业务行为是否一致”的问题。比如项目方给了一个旧版本合约和一份准备升级的新版本源码,传统做法是人工读 diff,但 diff 很长时效率很低。我的做法是把两个合约分别部署到本地测试网,喂同一组随机交易,对比两个合约的状态和关键函数返回值,任何不一致的地方都需要解释:这个差异是预期的修复,还是意外引入的回归。这个思路和传统测试里的“新旧版本兼容测试”完全一致。
变异测试则用来检验审计用例的质量。我会把合约里的 >= 改成 >,把 msg.sender 改成 tx.origin,把加减法顺序调换,然后重新跑一遍已有测试套件。如果测试用例集合完全没有反应,说明当前用例根本覆盖不住这类错误,测试套件的“杀伤力”不足,需要补充用例。
比如在某个分红合约里,我做过一个变异实验:把分红计算中的“取用户最小余额”改成“取当前调用者余额”,结果整套原有用例全部通过。当时我就知道,测试没有涉及多用户分红场景。补齐这个用例之后,我们很快发现真实代码在单人分红提前调用时存在“自分红放大”的逻辑漏洞。这种漏洞如果你只靠读代码,很容易因为思维惯性滑过去,但变异测试能用自己的“尸体”提醒你:这里真的不够坚固。
4. 中段防线:工具看不到的逻辑陷阱,需要靠人一层层盘
4.1 第四道防线:人工逻辑审计的基本盘其实还是状态机
自动化跑完,真正的硬仗才刚开始。人工逻辑审计是最难标准化、但价值最高的部分,它考验的是审计人员能不能把合约的整套业务逻辑抽象成状态机,再逐一找状态迁移的错误。
我的习惯是拿到合约先画三张图:第一张是“用户状态图”,记录用户可以处于哪些状态,比如未存款、已存款、正在借款、已被清算;第二张是“资金流向图”,记录资金在哪个合约、哪个账户之间流转;第三张是“函数关系矩阵”,把每个函数会读取哪些状态、修改哪些状态、调用哪些外部合约列出来。这三张图画完,绝大多数逻辑漏洞其实已经浮出水面了。
很多审计新手喜欢直接逐行读代码,这非常容易迷路。我知道效率最高的方式是从函数入口出发,倒着问:这个函数的执行路径会不会让资金流向和用户状态不一致?比如某个借贷协议在清算函数里扣减了用户的抵押品,但没有同步修改用户的借款总额,那么用户在 UI 界面上可能已经显示被清算,但链上数据仍然保留着借款额度,这个脏状态就可能被二次利用。
人工逻辑审计里最常见的坑集中在几类:一是状态更新顺序,尤其是“先发币后入账”这类问题;二是账本口径不一致,比如有的地方用 balanceOf,有的地方用 totalSupply,两个口径在特殊场景下会打架;三是循环边界和精度处理,比如分红循环里若用户数量太多会耗尽 gas,或者除法截断导致部分用户永远拿不到最后一 wei;四是重入之外的跨函数重入,就是一个函数调用了另一个内部函数,而内部函数里又有外部调用,防重入锁没有覆盖到整条链路。这些都是机器扫不出来、但人工在状态机上能推出来的东西。
4.2 第五道防线:经济模型里的博弈,要模拟攻击者的资金流水
如果说前四道防线还停留在“合约自身是否正确”,第五道防线看的是“这套系统在真实市场里能不能被操纵”。这部分是测试工程师最陌生的领域,也恰恰是链上资产安全审计和传统安全测试最大的分水岭。
典型的经济模型攻击不依赖任何代码 bug,所有函数都按预期工作,但攻击者可以尝试通过操纵价格源、操纵滑点、抢跑交易,从而实现获利。
举一个非常常见的例子:某个借贷协议如果使用 DEX 的实时价格作为抵押品价格源,而 DEX 的流动性池只有 200 万美金,攻击者就可以先用闪电贷借出 1000 万美金,分成多笔买入某个代币,把该代币的价格从 10 美金推高到 30 美金。价格短时间虚高之后,他立刻用自己原来持有的少量抵押品借出超出实际价值的稳定币,再把借来的稳定币换回原始代币,归还闪电贷。整个过程中合约代码完全正确,但协议因为信任了可以被操纵的价格,亏掉了用户抵押品与实际价值的差价。
这个风险,第五道防线要解决的问题是:当攻击者掌握巨额资金,我们的协议是否还成立?我会在测试环境里用 Foundry 的 deal 给一个模拟攻击者地址打上足够的测试币,再模拟一连串交易:闪电贷借出、买入目标代币、价格偏移检测、抵押贷款、清算抵押品、归还闪电贷。通过这一步,可以直接观察协议在最坏情况下会损失多少。
另外我会盯几个关键指标:协议依赖的价格源有没有时间加权?池子深度和可借金额的比例是否健康?预言机的基础价格和链上实时价格之间有没有设置偏差上限?这些不写进合约代码,但它们对链上资产安全的影响远高于一行 require 的缺失。
4.3 第六道防线:权限像呼吸一样重要,治理与升级链路要专项审
第六道防线经常被低估,因为很多项目把目光放在业务逻辑上,默认 owner、admin 是“自己人”。但你没见过因为私钥保管不当导致资金被抽干的案例,就不会理解权限审计有多重要。
我在权限审计时不会只看合约代码,还会把部署脚本、管理后台、多签钱包、时间锁配置一起拉进来。核心问题只有几个:谁能调用管理函数?管理函数能影响多少资金?这个操作需要几步审批?如果审批地址被攻破,多久之内资金是安全的?
一个合格的治理设计应该满足:修改关键参数至少要经过多签审批和时间锁延迟;升级代理合约的逻辑实现时要有足够长的观察期;暂停功能只能暂停新增存款,不能暂停用户取款。审查时我会专门建一张权限矩阵表:
| 关键函数 | 调用角色 | 是否需时间锁 | 影响范围 | 风险等级 |
|---|---|---|---|---|
| setOracle | owner | 是 | 所有借贷资产价格 | 高 |
| upgradeTo | owner | 是 | 合约实现整体替换 | 严重 |
| setFeeRate | 多签 | 是 | 平台手续费 | 中 |
| pause | 安全多签 | 否,但需快速生效 | 暂停全部存取款 | 中 |
看完矩阵之后,我一般会再做一次“丢失私钥推演”:假设 owner 私钥完全泄露,攻击者接下来能做什么,需要多长时间才能提走所有资金。如果答案是“一条交易就能改价格源,再一条交易就能借空资产”,那无论业务代码多安全,这个项目的整体风险评级都必须判为高危。
5. 上线之后的那道防线:监控、巡检与应急预案的真实操作
5.1 第七道防线为什么容易被忽略
很多团队在安全审计报告出来后就觉得松了口气,代码改了、测试过了、合约部署了,事情结束。但链上攻击有一个传统软件世界不太常见的特征:攻击是持续发生的,攻击者一直在寻找新合约、新漏洞,而你的协议不可能只运行一次就不再变化。
第七道防线的核心是:把线上系统当成一个“永远在测试中的环境”。你需要用监控探针、交易模拟、告警规则持续验证系统是否还处于健康状态,这些本质上就是测试工程师最熟悉的环境巡检和冒烟测试,只不过对象从服务器变成了链上合约。
我在团队里推动过一个观点:安全审计的产出不应该只是一份 PDF 报告,还应该包括一套预先设计好的上线监控方案。审计过程中发现的风险点,反过来就是监控要重点关注的地方。如果审计发现协议依赖预言机价格,那么监控里必须加一条“价格源与链上聚合价格的偏差监控”;如果审计发现提款函数是重灾区,监控里就必须有“单地址短时间高频提款”的告警规则。
5.2 用测试工程思路搭一套链上巡检哨兵
为了让监控可落地,我通常会把智能合约里需要关注的核心参数读出来,写成一个循环脚本,放到定时任务里。不需要一开始就做很重的图形可视化,先保证信息能拿得到、告警能触达。
bash复制#!/usr/bin/env bash
RPC="https://your-rpc-endpoint"
VAULT="0xYourVaultAddress"
while true; do
totalLocked=$(cast call "$VAULT" "totalLocked()(uint256)" --rpc-url "$RPC")
etherBalance=$(cast balance "$VAULT" --rpc-url "$RPC")
echo "$(date -u +%FT%TZ) totalLocked=$totalLocked ethBalance=$etherBalance"
if [ "$totalLocked" != "$etherBalance" ]; then
echo "ALERT: totalLocked and ethBalance mismatch"
fi
sleep 30
done
这是一个最原始的监控框架,但已经能解决很多实际问题。脚本本身不是重点,重点是你要把审计阶段画的那张“资金流向图”里所有关键节点,全部变成可查的状态。我会为每个协议维护一份监控清单,每次新版本上线前,用审计阶段沉淀的关键参数把脚本参数化,避免每个项目都从零开始。
除了主动巡检,还要订阅链上事件和交易日志。很多攻击在事先会有前兆,比如管理地址出现异常的大额授权、关键函数在一小时内被高频调用。这些事件的检测用 The Graph 或者直接监听合约日志都能做,核心是你要提前定义“什么是异常”。这个定义能力,本质上就是测试里的预期结果设计。
5.3 应急预案验证:熔断开关与时间锁不是摆设
监控只是发现问题,真正要扛住攻击还得靠设计好的应急响应。我每次审计都会在报告里专门列一节“应急响应建议”,但这部分经常被当成非必选项。
真实世界告诉我,应急预案如果不做至少一次完整演练,等于不存在。链上的暂停开关是很多协议的救命设计:当监控发现异常攻击模式时,立刻调用暂停函数,把所有外部交互冻结,为后续分析争取时间。但前提是你真的会调用这个函数,而且调用后不会把协议带进一个更糟糕的状态。
我会在审计阶段拿到合约里所有暂停函数、恢复函数和对应角色的完整清单,然后和项目方一起在测试网做一次“红蓝对抗”。蓝方扮演正常用户进行存取款,红方模拟攻击者进行高频提款和价格操纵,中间安全多签触发暂停,验证用户的资产是否能安全取回、攻击者的下一步是否会被阻断。这个演练做一次,项目团队的应急手感会完全不同。
另外要注意时间锁的配置。很多项目把时间锁设成 48 小时,但应急预案里又要求“管理员能在 5 分钟内修改价格源参数”,这两个设定是冲突的。审计时如果发现这种冲突,必须让项目方明确一个优先级:到底是防止管理员短期作恶更重,还是保证应急响应速度更重要。没有绝对正确答案,但模棱两可一定会出事。
6. 一次完整的七道防线串联复盘:我在目标梳理时差点漏掉的那匹马
6.1 从接收合约到完成推演的完整过程
前面讲了很多方法论,这里用一个复盘把全过程串起来。这个案例高度模拟我真实做过的一个借贷协议审计项目,业务逻辑不复杂,但覆盖面足够广。
项目方提供了核心借贷合约、价格源合约、代理升级合约,外加一份说明文档。接到合约后,我先快速跑 Slither,输出里有一条 High:LendingPool.withdraw() 在调用外部代币合约之前没有更新用户余额,工具提示可能存在“跨函数重入”。我人工确认后,发现函数已有 nonReentrant 修饰符,但修饰符只锁在外部调用那一层,合约内部还调用了另一个 _updateCollateral() 函数,这个内部函数里又有一个外部调用,所以整条调用链并没有被完全锁住。这条最终被确认为可利用的重入风险。
接着进入模糊测试,我用 Foundry 写了一个带恶意外部调用的攻击合约,把用户从“存款”到“提款”的状态切换跑了几百轮。由于重入路径已经存在,测试直接触发了 totalLocked 与合约实际代币余额的偏差。到了人工逻辑审计阶段,我们在权限矩阵里又发现一个问题:清算函数的调用角色是“任何人”,但它读取的抵押品价格来自一个没有时间加权并且流动性很浅的 DEX 池。这条漏洞不是代码可以直接查出来的,必须靠经济模型压力推演才能发现。
我在测试环境里给攻击地址打了一笔巨额测试代币,模拟“闪电贷买入—推高价格—超额借出”的路径。过程非常顺利,攻击者在单笔交易内就实现了盈利,而合约层面没有任何一步报错。最终我在报告里把这条标记为“严重”,同时给出修复建议:价格源换成时间加权平均价,并设置价格偏离警告上限。
6.2 报告怎么写,代码怎么改,回归怎么验证
审计报告的写作是很多测试工程师容易忽略的“最后一公里”。我写报告时遵循一套比较固定的格式,每条风险都包含:风险描述、触发条件、影响范围、复现路径(尽量附带测试代码或交易序列)、严重等级、修复建议。修复建议部分不能只写“建议使用安全预言机”,而要具体到函数层级。
项目方按照报告修改后,我要做的第一件事不是直接看改得对不对,而是把审计时写的那组失败用例原封不动地重跑一遍。如果用例仍然失败,说明修复没有堵住路径;如果通过了,我还会把变异测试再跑一轮,确认修复本身没有引入新的边界问题。
这个流程走完之后,我会把这一次审计中发现的所有失败用例、攻击脚本、监控规则一起打包交给项目方,保证他们后续每一次合约迭代都能复用这套测试资产。这也是我一直推荐测试工程师做审计的原因:你产出的不只是报告,而是一整套可以持续回归的测试基建。
6.3 测试资产沉淀之后,团队会强在哪
上面这个案例做完之后,我们团队把这次的重入攻击合约和价格操纵模拟脚本保留了下来,之后接到同类型借贷协议时,基础用例半个小时就能套上去。监控脚本也沉淀成了模板库,新项目上线前只需要改地址、改阈值,不用从零开始搭建。
我在实际项目里的体会是,安全审计这个活儿没有终点,链上攻击手法更新太快,过去只会打重入,后来学会了价格操纵,现在还要防治理攻击、跨链桥组合攻击。但反过来看,这也是测试工程师最好的学习场景,
