1. 为什么想做这个系统
说起智能合约安全,我这两年最大的感受是:审计成本高、人工依赖重、误报率高。一条链上的合约动辄几百行代码,随便一个权限控制疏漏或者重入漏洞,被黑客盯上就是几百万美元的损失。公链项目方不敢不上线审计,但排队排一两个月、单次审计报价六位数起步,很多中小型项目根本扛不住。
于是,很多团队开始思考一件事:能不能做一个基于规则的智能合约漏洞检测系统,把审计这一步往前挪、往自动化挪?
这个思路并不新鲜,传统Web安全里早就有人这么干——比如用正则特征库扫SQL注入、用AST匹配找危险函数调用。智能合约本质上也是一段代码,只要它能被解析成结构化语法树,理论上就能用规则去匹配漏洞模式。
但我们实际动手做的时候发现,合约安全检测远没有想象中那么简单。规则写浅了,漏报率高得吓人;规则写深了,误报率又失控。一个真正能跑起来的检测系统,需要拿捏“规则覆盖范围”、“语义抽象程度”和“误报率”这三者之间的平衡。
我自己做过一版完整的落地实现,参考了很多开源工具的思路,也踩了不少坑。这篇就把整个系统的设计思路、技术选型、规则编写方法、检测流程和典型问题排查经验完整捋一遍,希望能给正在做合约安全自动化、或者准备在区块链安全方向立项的同学一个可复用的参考。
先说明一下,这里聊的“基于规则”,不是那种拿正则硬匹配源码的玩具级方案,而是建立在数据流分析、AST模式识别、状态变量追踪和调用栈模拟基础上的半自动化审计引擎。它能检测的漏洞类型非常多,但我不打算堆一份漏洞清单,而是挑核心几类讲清楚规则是怎么落地的。
整个系统我在设计时定了五个核心目标:
- 可解释性:每条告警必须能回溯到具体的规则文件和代码位置,审计师拿到告警后能快速确认。
- 规则可扩展:团队里非专职安全研发的合约开发者也能照着模板写新规则,不用改主引擎代码。
- 误报可控:跑真实数据集时,误报率要压到40%以下,否则人工复核成本比全人工审计还高。
- 性能够用:处理一条普通ERC20合约的时间控制在1秒以内,不能比市面上主流工具慢太多。
- 输出结构化:漏洞名称、风险等级、触发路径、修复建议、代码片段、置信度评分字段齐全,方便对接工单系统或CI流水线。
下面从整体设计开始拆。
2. 整体设计思路与框架选型
2.1 三层流水线架构:解析层、分析层和规则层
我最终采用的架构是经典的三层流水线,这个结构在传统静态分析工具里非常成熟,移植到合约检测上也很自然。
解析层负责把Solidity源码“读”进来,生成抽象语法树。这里有个关键决策点:用现成的编译工具链,还是自己写编译器前端。
市面上的开源方案里有几种路线。第一种是调用官方编译器拿到AST,比如通过solc的JSON接口直接输出AST,再用JSON解析器处理,这种方式获取的AST结构最权威、最完整,和编译器版本强绑定,升级语言版本时容易踩坑。第二种是使用现成的解析库,某知名静态分析框架把AST处理封装成了遍历框架,规则开发阶段很方便,但框架本身迭代快、API变动大。第三种是自己用词法/语法分析工具生成AST,可控性最强,但写Solidity这种带继承、修饰器、自定义类型的语言,工作量非常大,维护成本高。
我最终选了第一种路线为核心、第二种路线做辅助——主检测引擎直接消费编译器的标准AST,不依赖第三方框架的中间表示,保证底层结构稳定。辅助工具层面再引入遍历辅助库来快速定位节点,提升开发效率。这样既拿到了语言级别的精确语法信息,又不至于把整个系统压在一个快速迭代的第三方框架上。
分析层是整个系统的大脑。AST到手后,分析层要完成这几件大事:
- 构建合约的信息模型:把合约、函数、修饰器、事件、结构体、状态变量、继承关系、接口实现等都整理成内部对象。
- 生成函数调用图:搞清楚哪个函数能调用哪个函数,这是路径分析的基础。
- 数据流分析:对关键的变量进行赋值追踪、传播来源分析,判断一个危险的输入值是否经过了干净的过滤。
- 状态变异追踪:标记哪些函数会修改合约的存储状态,哪些函数是只读的,这直接影响很多漏洞判定逻辑。
这一层我建议做成依赖注入式的核心服务,不把业务规则直接耦合到分析逻辑里,这样后续想加新的分析能力时不用推倒重来。
规则层则是业务逻辑的集中地。每一条规则是一个独立模块,输入是分析层提供的统一信息模型,输出是告警对象。
规则层里我划分了几类不同粒度的规则:
- 语法级规则:直接做AST节点模式匹配,适合检测简单的危险写法,比如“在循环里调用外部合约”。
- 语义级规则:需要结合函数调用图和状态变更情况做判断,比如“构造函数之外对owner地址做权限变更但缺少事件声明”。
- 流敏感规则:追踪变量传播路径,适合识别“输入参数未经过滤就流入call/value转账场景”这类问题。
- 污点追踪规则:定义污点源(比如外部传入参数、链上喂价数据)和污点汇(比如底层call、delegatecall、transfer),分析数据是否从源流到了汇。
值得一提的是,规则层不该只有检测规则,我同时还加了一层“抑制规则”,用来处理“审计师确认过且修复过的已知项”,避免同一个缺陷反复告警。这是降低重复劳动的关键设计。
2.2 为什么不用机器学习方案
做这个项目时,团队里不止一次有人提过:现在AI这么火,要不要用模型来做检测?
这个问题我认真考虑过。机器学习方案的核心卖点是“能发现未知模式”,不用一条条手写规则,理论上检出率上限更高。但实际落地时有几个硬伤:
- 训练数据极度匮乏:公开的带标签漏洞合约数据集,加起来也就几万个样本,还严重不均衡——重入漏洞样本多,逻辑漏洞样本少。
- 可解释性不足:模型告诉你有问题,但它说不清为什么。安全审计场景里,工程师很难直接把模型的判词贴进审计报告里交差。
- 误报的修复成本高:规则引擎的误报,工程师能顺着规则逻辑排查;模型的误报,往往要重新分析完整数据流,人力成本反而更高。
- 对抗鲁棒性差:攻击者稍微改一下漏洞代码的结构(比如换个函数名、拆成两步调用),模型的判定就可能会剧烈变化,规则引擎却只要模式匹配命中就会稳定告警。
所以我最终的结论是:当前阶段,规则引擎是合约安全检测的基本盘,机器学习只能在“辅助排序告警优先级”这个环节做锦上添花,不能承担直接判定漏洞的责任。
这里有个实用技巧:规则产出的结构化告警,可以把“漏洞类型+代码特征+触发路径”编码成特征向量,喂给一个轻量排序模型,用来给告警做风险评级,帮审计师优先看高危项。这个方案我们实测过,能把人工复核效率提升30%左右,性价比很高。
2.3 性能优化思路
性能问题是很多静态分析工具的死穴。合约规模一大,函数调用图爆炸,分析器跑几分钟都出不了结果。我在这版系统里做了三件比较有效的事:
第一,AST预筛机制。大量规则其实只关心特定类型的节点,比如“内部交易类函数”“权限修饰器”“高危函数名”。我在AST遍历阶段先做一次粗筛,把不可能触发规则的合约直接跳过细粒度分析,只对候选合约跑完整流水线。
第二,分析结果缓存。同一个合约仓库的多个版本之间,往往只有少量代码变更。我把每个合约文件的信息模型、函数调用图、数据流结果做了增量缓存,只有变更过的文件需要重新分析,其余直接读缓存。这个优化对多轮迭代开发场景帮助特别大。
第三,规则并行评估。规则之间多数是相互独立的,我按漏洞类别分了四五个评估组,用并行队列跑,组间互不阻塞。实测下来,把一条普通ERC20合约的处理时间从3.8秒压到了0.9秒左右。
3. 核心规则设计与落地实操
3.1 漏洞类型库的构建:从最痛的几类开始
规则引擎的覆盖能力,取决于漏洞类型库的设计。我第一版没有贪多,只挑了六类在真实攻击事件中频率最高、后果最严重的漏洞类型:
- 重入漏洞:外部调用发生在状态更新之前,攻击者利用回退函数重入。
- 算术溢出/精度丢失:Solidity 0.8.x 前版本没有内置溢出检查,加减乘除都可能出问题。
- 权限控制缺失:危险函数缺少onlyOwner等修饰器,或者修饰器实现有缺陷。
- 未校验的外部调用返回值:低级call/transfer的返回值被忽略,调用失败后合约继续执行。
- 整型转换截断:uint256强制转成uint128或uint64,导致数值溢出或逻辑错误。
- 拒绝服务风险:循环依赖外部调用结果、无限循环、强制转账等模式。
每类漏洞我都会建立对应的规则族,而不是只写一条规则。以重入漏洞为例,我拆分成了三个细分规则:
规则A:检测“在修改状态变量之后,是否还有外部调用”。这种属于标准的检查-效果-交互模式的变体,主逻辑是确认状态更新是否发生在外部交互之前。
规则B:检测“外部调用之后,是否在同一个函数内再次写入了可重入相关状态变量”。这种能捕获部分逻辑写得比较绕的重入场景。
规则C:检测“是否有强制转账手法绕过余额检查直接转移资产”,用于识别某些特殊重入变体。
做规则拆分的收益非常明显:单一规则检测率可能只有50%,拆成多条互补规则后,综合检出率能提升到85%以上。代价是误报可能上升,需要靠后面的“置信度评分”机制来压。
3.2 从AST到规则对象:一条规则的生命周期
每条规则在系统里都是一个独立模块,但它们的生命周期是统一的。我总结下来,一条规则从想法到上线要经过四个阶段:
发现阶段:分析某次真实攻击事件或者审计案例,提炼出漏洞的代码级特征。比如某次某知名黑客事件,本质上就是“合约中调用了外部合约且未对返回值校验,同时存在资金转移动作”,把这个特征抽象成规则候选。
规则建模阶段:把特征转成形式化描述,明确三点:触发条件、上下文约束、排除条件。
实现阶段:基于分析层的API写规则代码。这里我一直强调要用声明式规则语法加少量命令式逻辑,因为团队里写规则的人不一定是资深安全专家,声明式配置更容易上手和维护。
验证阶段:把规则跑在已知漏洞样本集上,计算检出率和误报率,然后决定是合并进主规则库、调到候选库还是直接砍掉。
这个流程走下来,规则库的质量基本是可控的。我也踩过坑:早期我嫌流程麻烦,规则写完直接上生产,结果一条“高危权限规则”因为漏写了排除条件,把大量普通项目的管理员函数全报了一遍,审计师半夜打电话问我是不是系统崩了。从那之后,所有规则必须过验证阶段,质量红线不能松。
一个具体规则的实现逻辑,大致长这样:
class ReentrancyRule(Rule): version = "1.0" severity = "HIGH" name = "Reentrancy-ExternalCallBeforeStateUpdate" def check(self, contract): for func in contract.functions: # 检测函数体是否存在外部调用节点 external_calls = find_external_call_nodes(func) # 检测函数体中是否存在状态变量写入节点 state_writes = find_state_write_nodes(func) for call in external_calls: # 外部调用发生在状态写入之前 -> 触发告警 if not has_state_write_before(call, state_writes): self.report( msg="外部调用发生在状态更新前,存在重入风险", line=call.line_no, confidence=compute_confidence(call, func) )当然这是伪代码,实际工程里要处理的东西多得多:函数继承链上的声明、修饰器展开、映射和数组变量的别名分析、构造函数与初始化函数的特殊处理等等。
3.3 危险函数标记与链上行为模拟
规则系统里有一块非常实用但容易被忽略的能力:危险函数标记和链上行为模拟。
危险函数标记解决的是“某些操作在合约里本身就不该出现”的问题。我维护了一个危险函数库,分成了几类:
| 危险操作 | 典型函数 | 风险说明 |
|---|---|---|
| 低层调用 | call,delegatecall,callcode | 参数可控性高,容易引入重入或代理风险 |
| 强制转账 | selfdestruct,transfer到合约地址 | 可能导致资金卡死或合约自毁 |
| 危险数值操作 | unchecked块内的算术运算 | 关闭了内置溢出检查,需要人工确认 |
| 外部合约交互 | 调用地址是变量而非常量 | 无法提前知晓交互目标,风险较高 |
| 阻塞性循环 | for(uint i = 0; i < users.length; i++) | 数组过长时可能超Gas导致DoS |
链上行为模拟则是更进一步:针对某个函数的调用流程,模拟它在不同状态下的执行路径。这里不追求完整的EVM语义模拟,那样太重了,我采用的是轻量级抽象模拟——只关注状态变量的读、写、外部调用的先后关系,以及关键参数是否被污染。这套抽象精度在绝大多数真实场景下够用,不会对性能造成过大负担。
一个比较有用的模拟场景是“检测权限判断的绕过”。比如某个合约:
function withdrawAll(address payable to) external { require(owner == msg.sender, "not owner"); require(!paused, "paused"); uint amount = balanceOf[msg.sender]; balanceOf[msg.sender] = 0; (bool ok, ) = to.call{value: amount}(""); require(ok, "transfer failed"); }从纯规则匹配看,这个函数在状态写入之后才执行外部调用,不触发基础重入规则。但仔细看,它对外部接收地址to没有约束,谁能调用、谁就能设置提款地址。如果to是一个恶意合约,回退函数里再次调用了withdrawAll,虽然余额已经清零,但攻击者仍可能利用代理或指定地址的方式做更深层次的资金转移。这种问题单靠AST是不够的,需要把“调用上下文”和“资金流向量”加入到模拟里。
我们在这类场景上的做法是:在模拟过程中给每个外部调用关联一个“资金敏感度”标签,如果该调用的value参数源自状态变量、且接收地址来自外部参数,就标记为“高敏资金流”,规则可以专门针对这类模式再单独审查。实际效果是增加了一些三元告警,但对复杂攻击模式的检出确实有了提升。
3.4 状态变量的读写分析与权限修饰器验证
权限问题在合约安全里占比非常高,但规则写起来很讲究。如果只做“危险函数缺少onlyOwner修饰器”这种表面检查,误报率会非常高——因为很多项目的权限控制是用构造函数参数、链下签名、多签地址列表等不同方式实现的,未必都要用修饰器。
我的权限规则做了三层递进:
第一层:检查函数是否有显式权限修饰器声明。有,则基本放过。没有,进入第二层。
第二层:检查函数体的前置校验逻辑——“msg.sender是否在函数开头被require或if语句判断”“判断条件是否涉及调用者地址”。有校验则标记为“疑似低风险”,没有则进入第三层。
第三层:查看函数是否对高风险状态变量做了写操作(比如余额、授权额度、管理员地址)。如果函数既无显式权限,也无前置地址校验,还要改这些状态,直接报“高危权限缺失”。
这套三层递进逻辑让权限类规则的误报率大幅下降。我印象很深的一个案例:某项目合约里有个函数叫emergencyPause(),没有显式修饰器,但函数体开头用require(authority[msg.sender])做了“白名单签名地址”校验。单纯AST规则直接把它报成高危漏洞,但三层递进逻辑正确识别了它是有防护的,省掉了一次人工复核。这对用户信任系统是很大的加分项。
状态变量读写分析也是权限规则的核心基础设施。我在分析层维护了一张“状态变量访问矩阵”,记录每个状态变量被哪些函数读、哪些函数写。结合函数可见性(public/external/internal/private)和调用链,可以快速判断一个危险操作是否被暴露给了外部用户。这个矩阵在安全审计报告里也很好用,可以直接生成为“权限矩阵图”给客户看。
4. 系统实现的关键环节
4.1 编译流程与AST获取的坑
系统的输入是Solidity源码工程,第一步要把它编译成带AST的输出。这一步踩过的坑,估计每一个做合约静态分析的人都遇到过。
版本管理是最大的痛点。Solidity 0.4到0.8之间的语法差异非常大,不同版本编译出来的AST结构也完全不同。同一个合约项目,锁定的编译器是0.6.12,但如果你用最新版的编译器去解析,语法错误一片,连AST都拿不到。我的解决方案是:
- 解析项目的
solc版本声明,自动下载对应版本的编译器。 - 维护一个“版本兼容映射表”,每个AST访问器都要标注它支持的solidity版本范围。
- 对于不支持的版本,宁可跳过分析,也不能用错的AST硬分析——分析结果只会误导人。
多文件编译问题。真实项目往往是几十个合约文件互相import,有些import的路径是本地的,有些来自网络包。编译时必须正确解析这些依赖。我的做法是先跑一遍标准编译命令,拿到完整的编译上下文,再把每个文件的AST单独导出。如果某个依赖文件缺失,就标记为“部分分析”而不是直接放弃整个项目。
编译器优化开关的影响。有些项目开启了optimizer: true,这会导致某些中间代码被优化掉,AST里的节点可能与源码不是严格一一对应。强依赖AST行号的规则,需要额外做一次“源码映射校准”。这个坑比较隐蔽,我第一次遇到时因为告警行号对不上源码,排查了好久,最后才发现是优化器导致的节点折叠。
4.2 数据流分析引擎的轻量实现
数据流分析是规则引擎里技术含量最高的部分,但我不建议从零实现一套完整的抽象解释器,那工程量太大了。更现实的做法是:结合AST做一个轻量级污点数据流,覆盖绝大多数规则需求即可。
我的实现思路是这样的:
- 污点源定义:函数参数、
msg.sender、msg.value、外部调用返回值、合约内外部读取的存储值。 - 污点传播规则:
target = source直接把污点标签复制;target = source + constant则继续传播但加一个“弱污点”标记;条件判断语句里的变量传播要区分“真分支”和“假分支”,别盲目全路径传播。 - 污点汇定义:底层
call的地址或数额参数、delegatecall的调用地址、mint/transfer的接收地址、approve的授权对象。 - 判定逻辑:污点从源流到汇,且中间没有经过白名单校验或者数值约束,则触发告警。如果中间有
require或if对污点变量的范围做了限制,则降低置信度。
举一个真实的检测示例,某NFT合约里有这样一段:
function setFeePercentage(uint256 newFee) external { require(newFee <= 100, "fee cannot exceed 100"); feePercentage = newFee; }看似有数值约束,但如果合约里还有个函数可以读取feePercentage去计算提现金额,且percentage被用于除法分子、可能导致精度归零,那么“<=100”这个限制在业务上可能还不够安全。污点分析会把newFee标记为“经过数值约束的弱污点”,规则再做一次业务语义判断,需要看使用场景来决定是否升级告警等级。这种场景就需要规则作者有实际的合约审计经验,纯从代码语法上是判断不出来的。
数据流引擎里,还有一个容易忽视的细节:需要考虑Solidity的引用类型语义。地址、数组、映射是引用类型,赋值传的是引用而不是拷贝。如果不做别名分析,一个函数里对balances[user]的修改,可能在另一个函数里已经改变了balances[admin]的同一块存储。我在早期版本里就是忽略了这个,导致几条权限规则的判定结果跟真实漏洞对不上。后来花了不少时间在分析层实现了简单的存储槽位别名追踪,这个痛点才算解决。
4.3 误报压减与置信度评分模型
规则引擎最被人诟病的就是误报。误报率高,审计师就不爱用。我在设计置信度评分模型时,给每条告警算一个0到100的分数,再根据分数划分等级:
- 80-100:高危,建议立即人工复核
- 60-79:中危,建议结合上下文再确认
- 40-59:低危,可批量探查
- 0-39:信息性提示,不进入告警列表,只存到报告附注里
置信度分数由以下因素加权计算:
触发模式匹配度(权重40%):规则是否命中了关键AST模式、是否满足所有触发条件。如果还触发了辅助模式,加分。
数据流传播深度(权重25%):污点源到汇经过了几次赋值跳转?如果途中经过了多个模块的传递,说明风险传播路径更复杂,加分。只在直达场景中出现的,相对可信度低一些,扣分。
上下文合规检查(权重20%):危险操作附近是否有缓解措施,如白名单校验、数值边界约束、重入锁修饰器。有,扣分。
真实数据集统计先验(权重15%):这类告警在该类型合约里的历史命中率。比如“transfer返回值未检查”在旧版本合约里非常普遍但危害不一定大,先验分数就会压一压;而“delegatecall参数来自外部输入”几乎都是高危,先验分数拉高。
这套评分模型跑起来后,我对比过没有评分机制之前的结果:人工复核率从47%降到了23%,但关键漏洞一个没漏。审计师的时间花得更值了。
4.4 输出层:报告生成与CI对接
系统的最终输出不只是“告警列表”,而是一份结构化的安全分析报告。我这里设计的报告格式参考了传统渗透测试报告的习惯,每个漏洞项包含:
- 漏洞名称与唯一编号
- 风险等级(高危/中危/低危/信息)
- 所在文件与代码行号区间
- 代码片段预览(带高亮)
- 漏洞触发路径(从入口函数到危险操作的调用链描述)
- 修复建议(针对不同代码结构的推荐改法)
- 置信度评分与评分理由
- 参考链接(对应漏洞库条目、历史攻击案例)
生成报告的同时,也同步输出一份JSON机器可读格式,方便对接CI流水线。在CI场景里,我通常会把“高危漏洞数量大于0”设为发布阻断条件,中危则作为非阻断提醒。这个阈值可以根据项目安全等级灵活配置。
一个我在对接CI时学到的教训:不要把整份报告直接塞进PR评论,PR评论长度有限制,而且纯文本渲染体验很差。正确做法是CI脚本上传报告产物,在PR评论里只贴摘要和可点击链接。这样既保证了信息可达性,也不影响CI执行速度。
5. 典型漏洞规则速查表
做这套系统时我把很多规则经验沉淀成了一张速查表,现在分享出来,对自建规则引擎或人工审计都有参考价值。
| 漏洞类别 | 核心触发条件 | 关键排除条件 | 置信度参考 |
|---|---|---|---|
| 重入漏洞 | 外部调用节点出现且在状态写入之前 | 状态写入先于外部调用,或重入锁生效 | 85-95 |
| 算术溢出 | 加减乘除节点在unchecked块内,或编译版本低于0.8 | 操作数有明确的边界约束,或版本0.8+ | 60-75 |
| 权限缺失 | 写状态变量但无显式权限控制 | 函数体内有msg.sender校验或白名单机制 | 80-92 |
| 返回值忽略 | 低级call/transfer结果未检查 | 调用后有条件判断或require检查 | 65-80 |
| 整数截断 | 显式类型转换或隐式窄化赋值 | 操作数值范围在目标类型范围内 | 55-70 |
| 拒绝服务 | 循环内依赖外部调用,或转移存在硬编码地址 | 循环上限有常量约束或外部调用有超时 | 50-75 |
| 危险函数泄露 | selfdestruct/低层call出现在内部函数 | 函数私有且调用链安全 | 70-85 |
| 事件缺失 | 高价值状态变更没有emit事件 | 变更值非关键、非资产相关 | 40-60 |
| 精度丢失 | 除法先于乘法,或除法结果参与后续乘法 | 结果精度已提升(如乘1e18) | 55-70 |
| 前置条件缺失 | 循环修改存储、引用外部数据未验证 | 有状态一致性验证逻辑 | 50-65 |
这张表也是规则库的索引表,每条规则在代码里都会引用这些分类、置信度和排除条件,保证新开发者上手时有统一语言。
6. 常见问题与排查技巧实录
6.1 为什么规则明明匹配到了,却一直不触发告警
这个问题在开发阶段天天遇到。排查顺序我总结成了四步:
第一步:确认规则加载是否成功。很多框架类项目,规则是插件方式加载的,插件路径写错、注册表没更新,规则根本没进内存,自然不触发。我在系统里加了“规则状态面板”,启动时自动列出所有已加载规则及版本,排查效率大幅提升。
第二步:确认AST树种是否真的是你预期的那棵树。拿一条最简单的语法规则去树上跑,打印节点路径,看是否命中。很多规则不触发的原因是AST版本不同、节点类型差异,比如Solidity 0.8里require可能被解析成FunctionCall,但在某些编译器版本里又包了一层ExpressionStatement。
第三步:确认上下文约束条件是否过度收紧。我见过很多规则作者写的排除条件比触发条件还严格,导致实际生产环境里永远进不了判定分支。比如“重入漏洞排除条件里写了‘函数有非public可见性’,但目标合约的所有函数都是public,于是全被排除了”。这种情况要做规则自测,把排除条件逐一注释掉,看哪一步导致告警消失。
第四步:确认输入合约是否符合产品质量门槛。我的系统里有个“可分析性评分”,如果合约复杂度超标、源码映射缺失或者编译警告过多,系统会主动降低分析深度,有些规则就不跑了。这种情况下不是规则有问题,是输入质量差,要先修合约侧的编译问题。
6.2 误报率压不下去,应该优先调哪里
如果误报率长期居高不下,不要急着去删规则,先从这三处入手:
第一处:数据流传播过深。很多规则我在最初写时把传播深度设到无穷大,导致一个污点绕了一大圈后到达汇点,实际上中间隔了太多业务逻辑,早已不是原始风险。把传播深度限制在3跳以内,误报率能肉眼可见地下降。
第二处:修饰器的展开逻辑。Solidity的修饰器本质上是把代码块插入函数体,但这会导致权限校验代码被展开到函数体中间,AST结构变得复杂。如果不做标准化处理,规则可能会把“先执行后校验”和“先校验后执行”当成同样的问题,误报就会膨胀。优化方案是把修饰器处理成独立对象,在规则层单独读取,而不是展开后跟函数体混在一起。
第三处:跨函数状态监控。有些规则只检查单个函数体,漏掉了合约整体的状态一致性。比如某个合约在A函数里设置了paused = true,B函数里却没有检查这个状态位就直接执行了外部调用。单函数规则不会报,但人来看,这确实是个问题。这类跨函数问题误报低但漏报高,如果误报率太高,反而说明规则库的“聚焦度”不够,该收敛一下规则范围,先把单函数级别的规则做到极致再扩展。
6.3 规则库越来越大,如何管理规则质量
规则库膨胀后,一定会出现“新规则与旧规则冲突、某条规则覆盖另一种规则、规则版本混乱”的情况。我用的管理办法是:
- 每条规则有独立版本号和变更历史,发布时记录上一个版本与变更原因。
- 定期(我一般按季度)跑一次规则回归测试:用固定样本集(包含最近半年新增的真实漏洞代码)回归所有规则的检出率、误报率,任何一条规则的效果退化超过10%就必须修正或下线。
- 新规则的贡献必须附带至少三个样本验证(两个正样本、一个负样本),没有验证记录的规则不能进主规则库。
- 规则之间按照“漏洞类别”分组管理,当一个漏洞类别下出现了超过十条规则时,强制做一次规则合并与精炼,避免碎片化规则互相干扰。
这套办法短期内看起来增加了管理成本,但从长期收益看非常值。我见过有些开源项目规则库乱到“同一漏洞类型有十几个规则互相覆盖,优先级都不知道按谁算”,最后只能把整个规则库推翻重写。那才是最大的浪费。
6.4 检测出来的问题,修复后如何验证
检测系统的最后一环,是帮助团队验证“修复是否真的有效”。我在系统里增加了“修复确认”流程:
- 修复代码提交后,增量解析变更文件,重新跑相关规则。
- 如果高危告警消失了,记录修复验证时间与验证人。
- 如果高危告警依然存在,或者新引入了其他告警,自动打回并附上对比报告。
这个流程在CI里是自动闭环的。有一次某团队修了一个重入漏洞,给函数加了重入锁,但锁的放置位置不对(放在了外部调用之后),我们的规则依然能报出问题的变体,于是提交被CI拦截。开发者通过报告里给出的“修复建议”重新改了位置,代码合入后高危告警归零。这算是这套系统最有成就感的使用场景之一。
我个人在实际使用中的体会是,**自动化检测系统不是替代审计师的,而是把审计师从繁琐的“穷举查询”中解放出来,让他们把精力集中在需要业务判断和创造力的地方。**规则引擎的边界决定了它无法理解完整的业务意图,但它能把那些高度确定性的漏洞类型快速、可靠地呈现在你面前,这就是它最大的价值。
如果你也在做类似的合约安全自动化项目,我建议不要一上来就追求“检测所有漏洞”,先从自己最熟悉的、在真实攻击事件里最常见的那三类规则做起,跑通了一个闭环,再慢慢扩张。这套系统的扩展性足够好——只要规则层和数据流层解耦,新漏洞类型永远只是“多写一条规则”的事。