lucifer

还记得《我是一个区块链黑客(第一篇)》结尾那份防范清单吗?最后一条写着”定期审计合约代码”——一句话打发了价值几十万美元的行业服务,堪称全文最省钱的一条建议。无独有偶,聊 DeFi 保险的《DeFi 保险:谁来保护你的链上资产?》更直接,把”审计次数”列进了协议风险评分表。

那么问题来了:审计到底是什么?谁在审?报告怎么读?普通人能不能自己跑两下?本文就回答这四个问题。

先把心态立正:审计不是保险单。这话怎么理解?想想体检。体检报告只能告诉你”抽血那天的你”状态如何,不能保证你一年不生病;车过了年检,上了高速照样可能爆胎。被多家审计公司轮番体检过的协议也一样——Euler Finance 审过不止一轮,2023 年 3 月还是被闪电贷攻击卷走 1.97 亿美元(后来黑客退还了大部分,堪称行业奇观)。原因并不神秘:审计是时间盒(time-boxed)内的抽检,不是数学证明。几个工程师花一两周,对着几万行代码找毛病,找到的是”他们能找到的”,而不是”不存在的”。

本文按四个问题展开:漏洞长什么样、审计怎么跑、报告怎么读、以及你自己怎么动手。读不懂 Solidity 没关系——审计报告本来就不全是写给程序员的,普通用户才是最该会读它的人。看完至少能看懂报告;看得懂代码更好,文末有个一百来行的玩具扫描器等你复现。

一、审计不是保险单,是什么?

先给审计一个准确定位:在有限时间内,用工具加人力,对特定版本的合约代码做一次系统性检查。三个关键词全是限定词,一个都不能丢,每个都值得用体检来翻译一遍:

  • “有限时间”(一到四周):医生再厉害,也不可能给你查一整年。抽检就是抽检;
  • “特定版本”(审的是哪个 commit 就是哪个 commit):体检报告查的是抽血那天的你。你下周开始熬夜蹦迪,报告管不着;
  • “系统性检查”(覆盖已知模式,但不承诺穷尽):套餐查得出脂肪肝,查不出你有没有罕见的食物过敏。

不信的话,看看两份”体检报告”当众失效的著名案例:

  • Euler Finance(2023 年 3 月,1.97 亿美元):这个借贷协议审过不止一轮(还办过审计竞赛),但出问题的 donateToReserves 函数是最后一轮审计之后新加的,缺了偿付能力检查。体检完第二天就去蹦极,报告救不了你——审计的是 A 版本,上线的是 B 版本,审计结论不随代码演进而自动续期。
  • Wormhole(2022 年 2 月,约 3.26 亿美元):这个跨链桥的 Solana 侧合约被安全公司 Neodyme 审过,漏洞出在”补丁提交到了 GitHub、线上程序没更新”。医生开了药,你把处方裱起来挂在墙上,药一粒没吃——代码修了、部署没跟上,等于没修,攻击者伪造守护人签名搬走了约 3.26 亿美元。有意思的是,同一个协议后来通过 Immunefi 给一位白帽付了 1000 万美元赏金——当年创下纪录。钱花在哪、什么时候花,是门学问。

还要补一句:审计审的是合约代码,管不了合约之外的世界。《我是一个区块链黑客(第一篇)》开头聊的 Bybit 事件,14.6 亿美元的损失,Safe 的合约本身没有任何漏洞,问题出在某位开发者的电脑被入侵。体检报告一切正常,不代表你家厨房的煤气阀没漏——审计管不到你同事的电脑,也管不到前端和运维。

所以正确的预期是:审计降低风险,不消灭风险。它和锁门、监控、保险一起构成防御纵深,单独拎出来谁都不是护身符。

二、漏洞都长什么样:六大类

审计公司在找什么?绝大多数被利用的漏洞,逃不出这六类。不用背定义,每类记住一个画面就够:

  1. 重入攻击:外部转账先于状态更新——余额还没扣,钱已经递出去了,调用者借着转账时的回调”人又回来了”,再取一次。像你把货交给快递员,才想起签收单还没签,而对方已经拿着货去办”下一单”了。历史第一名就是 The DAO 事件(2016 年,约 360 万枚 ETH),《我是一个区块链黑客(第一篇)》用整段代码拆解过。
  2. 权限控制缺失:该设权限的函数裸奔,或者用 tx.origin 做鉴权——钓鱼合约能冒充”最初的交易发起人”通过校验,等于门锁用的是一把人人能配的假钥匙。反过来,onlyOwner 滥用是另一个极端:全楼的门只有物业经理一把钥匙,他想开哪间开哪间,去中心化名存实亡(授权模型的细节见《Web3 中的授权与撤销授权》)。
  3. 预言机操纵:合约自身逻辑没错,但喂给它的价格输入可以被人一手遮天。像体检前有人偷偷调了你的体重秤——你照着秤上的数字填表,表填得再工整也是错的。抵押品被高估,然后凭空贷出真金白银,这是当代 DeFi 被黑的第一大类(详见《预言机:DeFi 里的价格到底是从哪来的?》)。
  4. 整数溢出:里程表跑过头归零的老问题。Solidity 0.8 起编译器内置了溢出检查,新合约基本免疫;但链上还跑着大量 0.8 之前部署的老合约。2018 年 BEC 代币因为一个整数溢出,把余额直接刷成了天文数字,项目几乎当场归零——那是老一辈”踩坑人”的学费。
  5. 逻辑与经济模型漏洞:最难查的一类——每一行代码单独看都”对”,但规则组合起来能被玩死。像下棋,对手每一步都合规,连起来就将死了你。2022 年 10 月,Solana 上的 Mango Markets 被人用互相挂单的方式,在低流动性的 MNGO 永续市场里把价格拉到远高于市场的位置,再凭虚高的抵押品贷走约 1.15 亿美元——事后当事人还公开认领,声称这是”合法交易策略”。
  6. 精度与舍入:Solidity 的整数除法向下取整,单次误差小到可以忽略?配合循环批量操作和闪电贷放大,舍入误差就是利润。超市抹零,一次抹一分没人当回事;可有人一秒钟”抹”你一万次,把零头全扫进自己口袋——这种”蚂蚁搬家”式攻击在收益聚合器里尤其常见。

汇总成一张分类表,方便你以后对着审计报告”按图索骥”:

漏洞类型 一句话诊断 典型事故 本博客对应文章
重入攻击 转账在前、记账在后 The DAO(约 360 万枚 ETH) 《我是一个区块链黑客(第一篇)》
权限控制缺失 该锁的门没锁,或锁用了假钥匙 tx.origin 钓鱼链 《Web3 中的授权与撤销授权》
预言机操纵 价格输入被污染 Mango Markets(约 1.15 亿美元) 《预言机:DeFi 里的价格到底是从哪来的?》
整数溢出 0.8 以前的时代病,老合约仍有 BEC 代币刷成天量 —
逻辑与经济模型 代码都对,规则能被玩死 Euler(1.97 亿美元) —
精度与舍入 向下取整的误差被薅成利润 收益聚合器多见 —

一个真实实例:Wormhole 的 19 天

第一节那句”药一粒没吃”,值得单独拉出来算一遍,因为它把”修复没部署”的代价标到了小数点后。时间线如下(数字按公开报道口径整理):

时间 发生了什么
2022 年 1 月 14 日 签名验证漏洞的修复补丁提交到 Wormhole 的 GitHub 仓库——代码修好了,但没有部署到 Solana 链上合约
2022 年 2 月 2 日 链上跑的还是旧版。攻击者伪造守护人签名,无抵押铸出 120,000 枚 wETH,时值约 3.26 亿美元——正是第一节那个数字
同日稍后 其中 93,750 枚经桥转到以太坊主网(时值约 2.5 亿美元),开始套现
2022 年 2 月 3 日 幕后支持方 Jump Crypto 注入 120,000 ETH(约 3.25 亿美元)补上窟窿,用户资金完好

漏洞的真实名字不是”签名验证写错了”——那个 1 月 14 日就修完了——而是”修复没有跑到链上“。从补丁提交到攻击发生,中间隔着约 19 天:够做三轮审计、发两版公告、开十次复盘会,唯独没有做那一件最要紧的事。攻击者甚至不需要挖掘新漏洞,他只是发现药方开了 19 天,药还摆在药房货架上没人取。再算一下三方账单:攻击者试图赚走约 3.26 亿,用户一分未损,真正掏钱的是 Jump——用约 3.25 亿美元买回一个教训。

一句话点题:漏洞报告和修复只是纸面动作,补丁部署到链上才算闭环——审了、修了、没上线,等于零。这也是为什么下文的读报告清单,第一步就是拿报告里的 commit 与链上合约地址对表。

三、一次审计是怎么跑的

正规审计大体四步,一步比一步贵。整个流程你可以想象成一次大体检:先上仪器,再见医生,最后拿报告、遵医嘱。

一次审计的四步流水线:静态分析是 CT 扫描,人工审查是老医生问诊,报告按五级分级,最后复诊确认修复

第一步:静态分析——机器粗筛,相当于 CT 扫描。 用 Slither 这类工具把代码推进舱里扫一遍,已知病灶(漏洞模式)秒级到分钟级现形:重入的形状、tx.origin 的写法、没人接的返回值。CT 的好处是快、便宜、不知疲倦;局限也摆在明面上——它只认得”影像上见过的病”。后文我们自己也会跑一遍。

第二步:人工审查——老医生问诊,费用的大头,也是价值的大头。 业务逻辑的怪病,机器看不出来:审业务逻辑有没有死循环、权限模型合不合理、经济模型能不能被闪电贷撬动,以及组合场景——A 协议的闪电贷加上 B 协议的定价机制再遇上你这个合约。攻击者从来不会按模块出牌,也不按科室挂号。Mango 那类漏洞,CT 扫一百遍也扫不出来,全靠老医生望闻问切——这正是审计费按周计价的原因。

第三步:出报告——分级,像体检报告上的”立刻住院、尽快就诊、定期复查”。 每条发现(finding)带一个严重度标签,行业通行的五级如下:

等级 官方含义 相当于体检报告上的
Critical 资金可被直接盗取,几乎无需前置条件 “立刻住院”——门没锁,路人都能搬东西
High 资金损失概率高,触发条件现实可行 “尽快就诊”——锁了,但钥匙挂在门上
Medium 特定条件下造成有限损失或功能受限 “定期复查”——窗户没关,得费点劲爬进来
Low 偏离最佳实践,存在间接风险 “注意生活习惯”——猫眼装反了,暂时出不了大事
Informational 建议与风格提示 医生的唠叨——“建议把花盆从窗台上挪开”

severity 五级金字塔:Critical 在塔尖,意味着资金现在就能被偷;越往下越是生活习惯问题

注意分级不是拍照——同一条发现,不同公司可能给差一档的定级,就像两家医院看同一个结节,一家说观察、一家说切了。看分布比抠单个标签更有意义。

第四步:修复确认(mitigation review)——复诊。 医生开的药,吃了没有?项目方修复后,审计方复核补丁,把对应条目标成 mitigated(已缓解)。没修的条目会带着”未修复”或”已确认但不修”的标签留在报告里——相当于报告里写着”建议立刻住院(患者拒绝)”。这才是读报告时最该盯的地方,下一节展开。

四、怎么读一份审计报告:四步实操清单

拿到一份报告,普通人按这个顺序看,五分钟内能建立基本判断:

  1. 先看 Scope(审计范围)——先看体检套餐里都有什么项目。 套餐没包含的器官,就等于没查。审了哪些合约、对应哪个版本的 commit、部署地址是什么,全写在 Scope 里,范围之外的部分等于裸奔——Euler 就死在这:审的是旧版,出事的是新加的函数。拿到报告第一件事,就是拿它和项目当前公开的合约地址对表:审的和跑在链上的,是不是同一套代码。

    审计范围示意:Scope 之内是查过的安全区,Scope 之外是没排过雷的雷区

  2. 搜有没有未修复的 Critical / High——先翻”立刻住院”那一页。 每条发现都有状态:已修复(mitigated)、已确认(acknowledged)、未修复(unresolved)。只要存在未修复的 Critical,这份报告的其他部分不用看了——项目方明知道门没锁还不锁,剩下全是细节。就像体检报告写着”建议立刻住院”,你却把报告塞进抽屉照常上班。

  3. 看发现数量的同时看严重度分布。 50 条 Informational 不如 1 条 Critical 值得关注。医生说五十句”多喝水少熬夜”,不如说一句”血管堵了九成”。数量多但全是低级别,说明代码有点草但骨架还行;数量少却藏着 Critical,那才是地雷——密度和烈度是两个维度。
  4. 核实报告是真的。 伪造审计报告是 rug 项目的常规操作,套路和号贩子如出一辙:抄一份知名审计公司的排版,贴个 logo,一份唬人的 PDF 就出炉了——好比黄牛把”知名专家”四个字印在小卡片上卖你三百块,医院官网根本查不到这位专家今天出诊。正确姿势是去审计公司官网核对(多数公司有已发布报告的列表),核对项目名、报告编号和日期;项目方转贴的 PDF 在官网找不到对应条目,直接按 rug 处理。用户视角的快速排查清单,见《上新币之前,先做这五项检查:Rug Pull 与貔貅盘识别》。

顺带一提,”审计次数”为什么会被 DeFi 保险协议当评分项:单次审计有遗漏概率,多轮独立审计叠加能显著压低漏网风险——就像拿不准的病灶,多挂一个专家号总踏实些。这和《DeFi 保险:谁来保护你的链上资产?》里”多轮审计 + 赏金计划的协议评分更高”是同一个逻辑。不是审得越多越安全的迷信,而是独立抽样次数越多、覆盖越广的统计常识。

五、自己动手:跑一次 Slither

说了这么多,不如亲手跑一遍。别人的体检套餐再贵也轮不到你,但 CT 机有开源的,可以搬回自己家。

Slither 是 Trail of Bits 开源的静态分析器,Solidity 审计的行业标配,pip 一行装好,支持对源码文件、乃至区块浏览器上验证过源码的合约地址,直接一条命令出结果。

1
2
3
4
5
6
# 安装 Slither(solc-select 是它的依赖,随装附带,用来管理 solc 版本)
pip install slither-analyzer

# 安装并切换到某个 solc 编译器版本
solc-select install 0.8.20
solc-select use 0.8.20

诚实记录一下沙箱翻车:我在 macOS 上第一次安装时 cbor2 编译失败——本机的 Rust 工具链太老,认不了新版 cbor2 的 Cargo 配置。先执行 pip install cbor2==5.6.5 绕过(或者用 rustup update 升级工具链)再装,就顺了。工具链依赖是静态分析工具的常见门槛,这也是为什么很多团队直接把 Slither 挂进 CI,让机器替人记环境。

接着造一个教学用漏洞合约,三个函数各埋一颗雷,看看这台 CT 机能不能全扫出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// 教学用漏洞合约:请勿部署到任何真实网络
contract VulnerableBank {
mapping(address => uint256) public balances;
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 漏洞一:先转账、后记账(重入)
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No funds");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}

// 漏洞二:用 tx.origin 做鉴权
function emergencyWithdraw() public {
require(tx.origin == owner, "Not owner");
payable(msg.sender).transfer(address(this).balance);
}

// 漏洞三:不检查底层调用的返回值
function tip(address payable to, uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
to.send(amount);
}
}

跑起来只要一条命令(在合约文件所在目录执行):

1
slither VulnerableBank.sol

以下是我沙箱里的真实输出(同一个文件、同一条命令,篇幅原因节选了三颗雷对应的发现,另有 solc 编译警告和 arbitrary-send-eth、missing-zero-check、low-level-calls 等条目略去):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
INFO:Detectors:
Detector: reentrancy-eth
Reentrancy in VulnerableBank.withdraw() (VulnerableBank.sol#18-24):
External calls:
- (success,None) = msg.sender.call{value: amount}() (VulnerableBank.sol#21)
State variables written after the call(s):
- balances[msg.sender] = 0 (VulnerableBank.sol#23)
VulnerableBank.balances (VulnerableBank.sol#6) can be used in cross function reentrancies:
- VulnerableBank.balances (VulnerableBank.sol#6)
- VulnerableBank.deposit() (VulnerableBank.sol#13-15)
- VulnerableBank.tip(address,uint256) (VulnerableBank.sol#33-37)
- VulnerableBank.withdraw() (VulnerableBank.sol#18-24)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#reentrancy-vulnerabilities-1
INFO:Detectors:
Detector: tx-origin
VulnerableBank.emergencyWithdraw() (VulnerableBank.sol#27-30) uses tx.origin for authorization: require(bool,string)(tx.origin == owner,Not owner) (VulnerableBank.sol#28)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#dangerous-usage-of-txorigin
INFO:Detectors:
Detector: unchecked-send
VulnerableBank.tip(address,uint256) (VulnerableBank.sol#33-37) ignores return value by to.send(amount) (VulnerableBank.sol#36)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#unchecked-send
INFO:Slither:VulnerableBank.sol analyzed (1 contracts with 102 detectors), 8 result(s) found

对着输出读一遍:第一颗雷 reentrancy-eth,Slither 不光报了名字,连”外部调用在第 21 行、状态写入在第 23 行”都给你圈出来了——相当于 CT 不光说”有结节”,连片子第几层都标好了。第二颗 tx-origin、第三颗 unchecked-send 也全中,还附赠了几个我没打算埋的(比如 tip 收款地址没做零地址检查)。注意两点:一是 Slither 内置了 102 个检测器,输出末尾那行就是汇总;二是它报的是”可疑模式”,算不算真漏洞、能不能被实际利用,仍需要人判断——CT 报出结节,良恶性还得医生说了算。这就是静态分析的天花板,也是人工审查收费的原因。

六、自研扫描器:一百行看懂静态分析的骨架

CT 机贵,原理却不神秘:把身体切成一层层图像,再用规则找异常。Slither 的骨架一模一样:把代码变成结构化数据,然后用一堆检测器做模式匹配。

来,做一个玩具版 mini_auditor。它不解析真正的 Solidity,而是输入一份”合约特征描述”(函数列表、修饰符、语句序列),用正则检测三类模式:重入嫌疑(外部转账发生在状态更新之前)、tx.origin 鉴权、未检查返回值的转账调用,并输出带 severity 的报告。

先说直觉,最有意思的是怎么用正则抓重入:看语句的先后顺序——外部转账出现的位置,后面还有没有”记账”语句。有,就是”先转账后记账”,报 Critical。就这么朴素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#!/usr/bin/env python3
# mini_auditor —— 漏洞模式扫描器(教学版)
# 它不解析真正的 Solidity,而是吃一份"合约特征描述":函数列表、修饰符、语句序列。
# 用正则做模式匹配,演示静态分析的思路——Slither 内部也是同一个方向,只是它更全面。
# 检测三类模式:① 重入嫌疑 -> Critical ② tx.origin 鉴权 -> High ③ 转账返回值未检查 -> Medium

import re

EXTERNAL_CALL_RE = re.compile(r"\.(call\{|send\(|transfer\(|delegatecall\{)")
STATE_WRITE_RE = re.compile(r"^[A-Za-z_]\w*\[[^\]]+\]\s*(?:\+|-)?=") # balances[x] = / -= / +=
TX_ORIGIN_RE = re.compile(r"tx\.origin")
BOOL_CAPTURE_RE = re.compile(r"^\s*\(\s*bool\s") # (bool ok, ) = ... 接住了返回值
SEVERITY_ORDER = ["Critical", "High", "Medium", "Low", "Informational"]


def is_unchecked(stmt):
"""返回值有没有被接住?(bool ...)=、require/if 包裹、== / != 都算接住了"""
return not (BOOL_CAPTURE_RE.match(stmt) or "require" in stmt
or stmt.startswith("if") or "==" in stmt or "!=" in stmt)


def scan_function(contract, func):
findings = []
guarded = any("nonReentrant" in m for m in func["modifiers"])
calls = [i for i, s in enumerate(func["statements"]) if EXTERNAL_CALL_RE.search(s)]
writes = [i for i, s in enumerate(func["statements"]) if STATE_WRITE_RE.match(s)]

# 模式①:外部转账在前、状态写入在后 = 教科书式重入;有 nonReentrant 修饰符就跳过
if guarded:
findings.append({
"severity": "Informational",
"title": f"{contract}.{func['name']}: 使用了 nonReentrant 修饰符",
"detail": "外部调用受重入锁保护,模式①未触发",
"fix": "无需处理",
})
else:
for ci in calls:
later = [w for w in writes if w > ci]
if later:
findings.append({
"severity": "Critical",
"title": f"重入嫌疑:{contract}.{func['name']}",
"detail": (f"第 {ci + 1} 条语句发起外部转账"
f"「{func['statements'][ci]}」,状态更新却排在"
f"第 {later[0] + 1} 条「{func['statements'][later[0]]}」"),
"fix": "按 检查-效果-交互 模式重排:先更新状态,再做外部调用;或加 nonReentrant",
})

for i, s in enumerate(func["statements"]):
# 模式②:tx.origin 鉴权
if TX_ORIGIN_RE.search(s):
findings.append({
"severity": "High",
"title": f"tx.origin 鉴权:{contract}.{func['name']}",
"detail": f"第 {i + 1} 条语句「{s}」用 tx.origin 校验身份,钓鱼合约可冒充调用者",
"fix": "改用 msg.sender;多步操作场景改用 nonce 或多签校验",
})
# 模式③只查 send 和 call:send 失败返回 false、call 失败返回 (false,),都不会自动回滚;
# transfer 失败会自动 revert,无需人工检查
if (".send(" in s or ".call{" in s) and is_unchecked(s):
findings.append({
"severity": "Medium",
"title": f"未检查转账返回值:{contract}.{func['name']}",
"detail": f"第 {i + 1} 条语句「{s}」忽略了返回值,转账失败会被无声吞掉,账目错位",
"fix": "接住返回值并 require 校验,或改用失败即回滚的 transfer",
})
return findings


def scan(contract):
findings = []
for func in contract["functions"]:
findings.extend(scan_function(contract["name"], func))
return findings


def report(contract, findings):
n_stmt = sum(len(f["statements"]) for f in contract["functions"])
lines = [
"=" * 62,
f"mini_auditor 扫描报告 目标合约: {contract['name']}"
f"({len(contract['functions'])} 个函数,{n_stmt} 条语句)",
"=" * 62,
]
for f in findings:
lines += [
f"[{f['severity']:>13}] {f['title']}",
f" 详情: {f['detail']}",
f" 建议: {f['fix']}",
]
counts = {s: 0 for s in SEVERITY_ORDER}
for f in findings:
counts[f["severity"]] += 1
summary = "、".join(f"{counts[s]} 个 {s}" for s in SEVERITY_ORDER if counts[s]) or "未发现命中模式"
lines += ["-" * 62, f"扫描完成: {summary}",
"免责声明: 基于特征描述的模式粗筛,仅用于教学,不能替代真实审计"]
return "\n".join(lines)


if __name__ == "__main__":
# 样本一:带病合约——三个漏洞各埋一个
VULNERABLE_BANK = {
"name": "VulnerableBank",
"functions": [
{"name": "withdraw", "modifiers": [], "statements": [
"uint256 amount = balances[msg.sender]",
'require(amount > 0, "No funds")',
'(bool ok, ) = msg.sender.call{value: amount}("")', # 外部转账
"balances[msg.sender] = 0", # 记账排在了后面
]},
{"name": "emergencyWithdraw", "modifiers": [], "statements": [
'require(tx.origin == owner, "Not owner")', # tx.origin 鉴权
"payable(msg.sender).transfer(address(this).balance)",
]},
{"name": "tip", "modifiers": [], "statements": [
"balances[msg.sender] -= amount",
"to.send(amount)", # 返回值没人接
]},
],
}

# 样本二:同一业务的"整改后"版本——检查-效果-交互 + 重入锁 + 返回值检查
SAFE_BANK = {
"name": "SafeBank",
"functions": [
{"name": "withdraw", "modifiers": ["nonReentrant"], "statements": [
"uint256 amount = balances[msg.sender]",
'require(amount > 0, "No funds")',
"balances[msg.sender] = 0", # 先记账
'(bool ok, ) = msg.sender.call{value: amount}("")', # 后转账
'require(ok, "Transfer failed")', # 返回值接住
]},
],
}

print(report(VULNERABLE_BANK, scan(VULNERABLE_BANK)))
print()
print(report(SAFE_BANK, scan(SAFE_BANK)))

用 python3 mini_auditor.py 跑它,以下是真实输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
==============================================================
mini_auditor 扫描报告 目标合约: VulnerableBank(3 个函数,8 条语句)
==============================================================
[ Critical] 重入嫌疑:VulnerableBank.withdraw
详情: 第 3 条语句发起外部转账「(bool ok, ) = msg.sender.call{value: amount}("")」,状态更新却排在第 4 条「balances[msg.sender] = 0」
建议: 按 检查-效果-交互 模式重排:先更新状态,再做外部调用;或加 nonReentrant
[ High] tx.origin 鉴权:VulnerableBank.emergencyWithdraw
详情: 第 1 条语句「require(tx.origin == owner, "Not owner")」用 tx.origin 校验身份,钓鱼合约可冒充调用者
建议: 改用 msg.sender;多步操作场景改用 nonce 或多签校验
[ Medium] 未检查转账返回值:VulnerableBank.tip
详情: 第 2 条语句「to.send(amount)」忽略了返回值,转账失败会被无声吞掉,账目错位
建议: 接住返回值并 require 校验,或改用失败即回滚的 transfer
--------------------------------------------------------------
扫描完成: 1 个 Critical、1 个 High、1 个 Medium
免责声明: 基于特征描述的模式粗筛,仅用于教学,不能替代真实审计

==============================================================
mini_auditor 扫描报告 目标合约: SafeBank(1 个函数,5 条语句)
==============================================================
[Informational] SafeBank.withdraw: 使用了 nonReentrant 修饰符
详情: 外部调用受重入锁保护,模式①未触发
建议: 无需处理
--------------------------------------------------------------
扫描完成: 1 个 Informational
免责声明: 基于特征描述的模式粗筛,仅用于教学,不能替代真实审计

对着输出看:带病的 VulnerableBank,三颗雷各报一条,等级、位置、修复建议一样不缺;整改后的 SafeBank——先记账后转账、加了 nonReentrant 锁、返回值也接住了——只剩一条”报平安”的 Informational。一病一愈,对照着读,检测器的脾气一目了然。

再和上一节 Slither 的结果对照一下:同一个 withdraw,Slither 报 reentrancy-eth,我们报 Critical 重入嫌疑;同一个 tx.origin,两边都抓到了。区别只在覆盖面——它有 102 个检测器,我们有 3 个正则。但思路完全一致:把”人逐行读”变成”机器先筛、人再判断”。真实的审计公司在这之上还有更重的武器:MythX 这类深度分析平台、Echidna 这类模糊测试工具、Certora 这类形式化验证——至于经济模型能不能被玩死,目前还是纯人工科目,机器帮不上忙,还得老医生坐诊。

七、漏洞赏金:白帽的正当获利路径

审计之外的另一道防线是漏洞赏金,代表平台是 Immunefi。逻辑很简单:白帽发现漏洞,通过平台负责任地披露给项目方,换取赏金;项目方花钱买”雷还没炸”的时间。像旧时官府贴悬赏告示:捉拿要犯,赏银百两——江湖高手有了正路,就不必落草为寇。

规模感受一下:Wormhole 曾在这里付出过 1000 万美元的单笔赏金(史上最大之一);LayerZero 挂出过 1500 万美元的赏金上限;整个平台累计发放的赏金已上亿美元。顶级协议赏金上百万美元是常态——对有能力找到漏洞的人,这是一条完全正当、且收入可观的职业路径。

这也解释了《DeFi 保险:谁来保护你的链上资产?》里那句”Immunefi 赏金 + 多轮审计的协议更安全”:赏金计划本质上是把攻击者的期望收益翻转成正收益——同样发现一个 Critical,偷偷利用可能吃官司、赚一次,走 Immunefi 报告则合法、拿钱、还能上排行榜。悬赏令一贴,小偷的最优解就变成了领赏的守门员,这是除审计之外性价比最高的安全投资。

边界也要说清:没授权的”先斩后奏”不叫白帽,叫犯罪。Mango 那位当事人事后声称自己是”合法利用市场规则”,结果吃了 CFTC、SEC、DOJ 三家连诉——刑事定罪虽然在 2025 年被推翻,但这场耗时数年的官司本身就说明:绕过赏金计划直接搬走资金,法律不会自动站在你这一边。发现漏洞,先找 Immunefi 或项目方的官方渠道,别急着表演。

总结

回答开头四个问题:

  • 审计是什么:时间盒内的抽检,是防御纵深的一环,不是保险单——体检报告不能保证你一年不生病,Euler 和 Wormhole 的账单就是证据。
  • 谁在审:Slither 这类工具做秒级 CT 粗筛,审计工程师做老医生式问诊(业务逻辑、经济模型、组合场景占大头),最后复诊确认修复、按 Critical 到 Informational 五级出报告。
  • 报告怎么读:看 Scope 对不对表(套餐外等于没查)、搜未修复的 Critical/High(”建议立刻住院”却拒绝住院)、看严重度分布而不只是数量、去审计公司官网验真(当心挂名专家号)——伪造审计是 rug 项目的常规操作。
  • 普通人能不能自己跑:能。Slither 一条命令扫源码或地址;一百行 Python 也能写个玩具扫描器,抓出教科书级的重入。

如果只记一句话:读不懂 Solidity,至少要学会看懂审计报告——就像看不懂化验单,也该认得”建议住院”四个字;看得懂代码,就更没有理由不看。

相关阅读


 评论


博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议

本站使用 Material X 作为主题 。
载入天数...载入时分秒...