T28 · Smart Contract Security
代码没写错,为什么还是被偷了?
- 练习的能力
- Builder
- 动手
- 写一个有重入漏洞的合约,自己写攻击合约把钱取走,再修好它。
- AI Lab
- 让 AI 审你的合约,把它的每一条结论分为真问题、误报与漏报三类并统计比例。
一个现实问题
一个金库合约,二十行。存进去,取出来。
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}你逐行读一遍:
第一行,读出这个人的余额。对的。 第二行,没有余额就拒绝。对的。 第三行,把这笔钱转给他。对的。 第四行,转账失败就整笔回滚。对的。 第五行,把他的余额清零。对的。
五行,每一行单独拎出来都完全正确。编译零警告,单元测试全绿,分支覆盖率 100%。
上线第二天,金库被搬空了。不是被搬走一个人的钱,是所有人的钱。
你把那笔交易的调用轨迹拉出来,看到的东西很反常:一次交易里,withdraw 被进入了几十次,每一次都成功,每一次都转出了同样的金额。而发起的那个账户,一开始只存了一笔很小的钱。
到这里,问题已经不是「哪一行写错了」,因为没有一行写错。
问题是:这五行之间发生了什么,是我逐行读代码时没有看见的?
思想实验
把这段代码翻译成柜台业务。
你去银行取钱,柜员做三件事:
1. 翻开账本,看你名下有多少钱
2. 从抽屉里数出这么多现金,递给你
3. 在账本上把你的余额划掉三个动作,顺序看起来天经地义。绝大多数时候它也确实没问题。
现在加一个条件:在第 2 步「递给你」的那一瞬间,你有机会立刻再提一次要求,而柜员必须先处理这个新要求,才能回来做第 3 步。
于是:
柜员翻账本:你有 100 → 数 100 给你
在这一刻又收到一次请求 → 柜员翻账本:账上还是 100(还没划!)
→ 又数 100 出去
再收到一次请求 → 账本上还是 100
→ 又数 100 出去
...
抽屉空了,柜员终于回过头来,把账本上的 100 划掉账本最后只被划了一次,现金出去了几十次。
柜员一笔账都没算错。 每一次他都认真翻了账本,每一次给出的金额都和账本一致。他做错的只有一件事:
在「钱已经出去、账还没记」的那个瞬间,他允许了一次新的请求插进来。
这就是开头那五行代码的全部问题。第三行 msg.sender.call 不只是「把钱转过去」——它把执行权交给了对方。对方的代码在那一刻开始运行,而此时账本还停在旧值上。
再做第二个思想实验,它对应这一章的另一半。
一栋办公楼,前门有保安查证件,一次都没查错,楼里的人很放心。问题在侧门:设计的时候大家觉得「侧门只有员工知道」,就没派保安。
保安的检查从来没出过错,楼还是被进去了。 因为安全性不取决于你检查得多严,而取决于有没有一条路绕过了检查。
两个实验合起来,就是这一章的全部:
漏洞不在某一行代码里,它在两行之间的顺序里,和没被把守的那条路径上。
你来决定
给开头那个金库修一刀。只能先改一处,你改哪一处?
观察结果
四刀的对照:
| 是不是根治 | 挡不住什么 | 会不会过期 | 代价 | |
|---|---|---|---|---|
| 互斥锁 | 否,是保险 | 跨函数、只读路径 | 不会 | 少量 Gas |
| 调换顺序 | 是 | 跨合约的状态不一致 | 不会 | 无 |
| 限制 Gas | 否 | 一切,只要定价变了 | 会 | 合法合约收不到钱 |
| 改成拉模式 | 是,且顺带解决别的问题 | 无(但改变了交互形态) | 不会 | 两笔交易 |
第一条结论:
顺序是根治,锁是保险。两个都要。
不要因为加了锁就不管顺序——锁有盲区。也不要因为顺序对了就不加锁——顺序会在后续迭代里被别人改回去,而锁会在那一刻救你一次。
第二条结论更重要,它是这一章真正的主轴:
任何一次对外部地址的调用,都是把执行权交了出去。在交还执行权之前,你的状态必须已经是自洽的。
这句话的威力在于它不只适用于转原生代币。把「执行权会被交出去」的位置列全,你会发现它比想象的多得多——而这正是 T7、T9、T12 一路预告到这里的那份清单。
第三条结论来自第二个思想实验:
安全性不由「检查得多严」决定,由「有没有一条路绕过了检查」决定。
所以审合约的方式不是逐行读代码,而是逐条路径地问:这条路上谁在把关。
建立模型
执行权会在哪些地方被交出去
先把清单列全。这是这一章最该打印出来贴在显示器上的一张表。
| 位置 | 为什么 |
|---|---|
转原生代币(call) | 接收方的 receive 或 fallback 会执行 |
任何对外部地址的 call / delegatecall / staticcall | 定义如此 |
| 转 NFT 给合约地址 | 标准要求调用接收方的回调函数确认 |
| 多代币标准的单笔与批量转账 | 同上,而且批量版本的回调只触发一次,容易漏判 |
| 带转账钩子的代币 | 代币标准允许在转账前后通知双方 |
| 任何「回调式」的设计 | 闪电贷、闪电兑换、拍卖出价通知 |
| 由用户传进来的地址参数 | 它可能是一份合约,而不是一个人 |
最后一行是整张表的总结:
任何一个地址参数,都可能是一份合约。
T9 讲 NFT 标准时留过一句话:那个「转给合约时会回调确认」的设计,是为了防止 NFT 被转进取不出来的地址,但它同时意味着转账过程中会把执行权交给接收方。现在把它说完整:
// 一个看起来很常规的铸造函数
function mint(uint256 qty) external payable {
require(qty <= MAX_PER_TX, "too many");
require(minted[msg.sender] + qty <= MAX_PER_WALLET, "wallet limit");
require(msg.value == qty * PRICE, "wrong value");
for (uint256 i = 0; i < qty; i++) {
_safeMint(msg.sender, nextId++); // 这一行会回调接收方
}
minted[msg.sender] += qty; // 计数在回调之后才更新
}接收方在回调里再次进入 mint,此时 minted[msg.sender] 还是旧值,钱包上限检查形同虚设。每一行都对,顺序错了。
同一个形状的四种形态
大多数人只认识第一种,而真正出事的常常是第三种。
| 形态 | 长什么样 | 互斥锁管用吗 |
|---|---|---|
| 同函数 | 回调里再进入同一个函数 | 管用 |
| 跨函数 | 回调里进入另一个共享同一份状态的函数 | 只有两个函数都加了锁才管用 |
| 只读 | 回调期间,外部合约读你的 view 函数,读到中间状态 | 完全不管用 |
| 跨交易 | 不是回调,但同样是「状态更新晚于使用」 | 不适用 |
第三种值得单独写一段,因为它是近年来出事最多的一类,而且它伤害的往往不是你,是集成你的人。
// 一个按「总资产 除以 总份额」报价的金库
function pricePerShare() external view returns (uint256) {
return (address(this).balance * 1e18) / totalShares;
}
function redeem(uint256 shares) external nonReentrant {
uint256 amount = (address(this).balance * shares) / totalShares;
sharesOf[msg.sender] -= shares;
(bool ok, ) = msg.sender.call{value: amount}(""); // 执行权在这里交出去
require(ok, "transfer failed");
totalShares -= shares; // 分母在这之后才更新
}redeem 上有锁,所以没人能重新进入它。但是在那次 call 期间:
分子(合约余额):已经减少了
分母(totalShares):还没减少
→ pricePerShare 返回一个比真实值偏低的数在这段时间里,外部合约不去碰这个金库,而是去调用另一个把 pricePerShare 当预言机用的借贷协议。那个协议读到偏低的价格,于是 T21 讲过的那条预言机操纵的路就通了。
三条教训:
view函数没有锁,也不可能有锁。 保护它的唯一方式是保证它读到的状态永远自洽。- CEI 的「状态」包括所有会被外部读取的派生量,不只是你在这个函数里显式改的那几个变量。
- 集成方要问一句:我读的这个数,在被读的那一刻是不是中间状态。 这是上一章那句「上游不可信时,防线画在自己这一侧」的又一次应用。
CEI:三个字母的顺序
Checks 先做全部检查与参数校验
Effects 再把本合约的状态改成「这件事已经发生了」
Interactions 最后才和外部世界交互对照着看:
| 有问题的写法 | 正确的写法 | |
|---|---|---|
| 取款 | 转账 → 清零 | 清零 → 转账 |
| 铸造 | 回调式铸造 → 累加计数 | 累加计数 → 回调式铸造 |
| 兑换 | 转出代币 → 更新储备 | 更新储备 → 转出代币 |
| 清算 | 把抵押品给清算人 → 减债务 | 减债务 → 把抵押品给清算人 |
T19 提过一个例外:真实的 AMM 实现常常是「先转出,再检查不变量」,这样才能支持闪电兑换。那不是违反 CEI,而是把 Effects 换成了「交互之后校验不变量」。 这条路走得通,但它要求你能明确写出那条不变量,并且在交互返回后逐一验证。
允许在交互之后再收口,前提是你写得出那条不变量。写不出来,就老老实实按 CEI 的顺序来。
权限边界
上一节是顺序问题,这一节是边界问题。两者加起来占了漏洞的大多数。
| 坑 | 具体形态 |
|---|---|
| 少一个修饰符 | 某个管理函数忘了加权限检查。最朴素,也最常发生 |
| 用发起人而不是调用者做鉴权 | 用 tx.origin 判断身份,用户被钓鱼到一份恶意合约上就等于授权 |
| 初始化没保护 | 部署与初始化之间有窗口,任何人都能抢先成为管理员 |
| 逻辑合约可被直接调用 | 代理背后的实现合约本身也是一个地址,T8 的案例正是如此 |
| 权限的传递性 | A 只信任 B,但 B 接受任何人的调用,于是 A 实际上信任所有人 |
| 默认允许 | 新加的函数忘了加检查就等于对所有人开放 |
第五行最隐蔽,因为它跨了两份合约,单独审任何一份都看不出问题:
// 金库:只信任策略合约
function pull(uint256 amount) external {
require(msg.sender == strategy, "only strategy");
token.transfer(strategy, amount);
}
// 策略:一个「任何人都能触发」的再平衡函数
function rebalance(address to, uint256 amount) external { // 没有任何检查
vault.pull(amount);
token.transfer(to, amount);
}金库的检查完全正确。策略是它信任的地址。而策略把这份信任无条件转给了所有人。
审权限的正确方式不是读修饰符,是画一张图:谁能调到这个函数,经过几跳。 只要有一跳是「任何人」,整条链就是「任何人」。
最后一条实践规则:
默认拒绝。 新增一个外部函数时,先假设它需要权限,再去论证为什么可以公开;而不是反过来。
精度与取整方向
这一类问题的特征是:它不需要任何攻击技巧,只需要有人比你更仔细地算了一遍账。
整数除法永远向下取整,这不是 bug。问题在于误差落在谁那一边。
一条可以直接抄的规则:
每一次取整的误差,都必须落在协议这一边,不能落在用户那一边。
展开成表:
| 在算什么 | 往哪边取整 | 理由 |
|---|---|---|
| 存入资产换份额 | 向下 | 少给用户份额 |
| 赎回份额换资产 | 向下 | 少给用户资产 |
| 用户欠的债 | 向上 | 多算一点债 |
| 用户的抵押价值 | 向下 | 少算一点抵押 |
| 手续费 | 向上 | 协议多收一点 |
| 清算能拿走的抵押 | 向下 | 别多给清算人 |
还有一条硬规则:先乘后除。 写成「先除再乘」会先损失精度再放大它,这是最常见的一种自伤。
现在看这两件事结合起来的经典后果——第一个存款人问题:
初始状态:totalShares = 0,金库里没有钱
1. 首个存款人存入 1 wei
首存通常按 1:1 → 拿到 1 份额,totalShares = 1
2. 同一个人【不走 deposit】,直接给合约转 10000 个代币
合约余额 = 10000e18 + 1,而 totalShares 仍然是 1
3. 下一个存款人存入 10000 个代币
份额 = 10000e18 * totalShares / 合约余额
= 10000e18 * 1 / (10000e18 + 1)
= 0 ← 整数除法向下取整
4. 这个人拿到 0 份额。他的 10000 个代币成了金库资产,
而金库的全部份额(1 份)在第一个存款人手里这个问题需要三个条件同时成立,每一个单独看都很无辜:
- 份额按「资产 乘以 总份额 除以 总资产」计算,且向下取整
- 总资产是直接读合约余额,因此可以被「直接转账」污染
- 允许极小的首次存款,使 totalShares 可以是 1对应三种防御,通常一起用:
- 内部记账:总资产用一个自己维护的变量,而不是直接读余额。直接转进来的钱不计入。
- 虚拟份额与虚拟资产:在分子分母上各加一个固定偏移,让「份额算出来是 0」这件事在经济上不划算。
- 死份额:初始化时铸一笔份额给一个无法取出的地址,让
totalShares永远不会小到危险的量级。
同时加一条兜底:算出来的份额是 0 就直接 revert。 一行代码,堵住这一类问题的最后一步。
升级风险
T8 推出了代理模式的三条铁律,这里逐条兑现,并补上第四条。
第一,存储布局只能尾部追加。 不能插入、不能改类型、不能换顺序、不能删除。
这条规则靠 review 守不住,要靠 CI:把每个版本的存储布局导出成一份文件提交进仓库,升级时用脚本比对,只允许新增。这是一个可以完全自动化的检查,没有理由不做。
第二,代理自己的变量放在伪随机的固定槽里,不要从 slot 0 开始声明,否则一定会和逻辑合约撞车。
第三,逻辑合约自己也是一个可以被直接调用的地址。 它的构造函数里就应该把自己标记成「已初始化」,让任何人都无法再初始化它;它里面也不能有能把自己销毁的路径。
第四,升级本身是一个需要被审计的动作,而不只是一段代码。 要回答的问题是:
- 谁能触发升级?是一个人、一个多签,还是一次治理投票?
- 有没有时间锁?多长?
- 升级会不会发出事件?外部监控能不能看见?
- 升级之后有没有一个「初始化新版本」的步骤?它有没有保护?
- 有没有办法在升级出错之后回滚?第 24 章 说过同一句话,值得再说一遍:一个没有时间锁的可升级合约,等于把所有资金交给了持有升级权限的那把钥匙。 代码审得再干净,这一条不成立,前面的努力全部归零。
不变量:把上面所有东西收口
到这里你已经见了四类问题。它们看起来很不一样,但有一个共同点:
每一类问题,都对应一条本该永远成立、却在某个瞬间不成立的命题。
| 问题 | 被破坏的命题 |
|---|---|
| 顺序错误导致的重复取款 | 合约持有的资产,不少于所有用户余额之和 |
| 只读路径读到中间态 | 任何外部可读的派生量,读到的都是自洽状态 |
| 铸造超额 | 每个地址的已铸数量,不超过上限 |
| 份额通胀 | 存入正数金额的人,拿到的份额必须为正 |
| 权限传递 | 只有授权地址能调到这个函数 |
这些命题就叫不变量。它们的价值不在于「写下来看起来很专业」,而在于:
不变量是唯一能被机器反复检验的安全性表述。
「这个函数我觉得没问题」不能被检验。「合约余额永远不少于所有用户余额之和」可以——你可以让程序随机生成几百万种操作序列,每一次操作之后都检查它一遍。
这正是下一个阶段要做的事。先在这一章养成一个习惯:每写一个有状态的合约,先写出它的不变量,再写功能。 完整的写法与自动检验在 T30。
它叫什么
在一次外部调用把执行权交出去之后、本合约状态更新之前,控制流重新进入本合约。
它的本质不是「被调用了两次」,而是状态更新晚于状态使用。四种形态里,只读的那种最容易被忽略,而且互斥锁对它完全无效。
函数体的标准顺序:先做全部检查,再改本合约状态,最后才和外部交互。
它是重入的根治手段,而不是缓解手段。例外只有一种:把状态收口换成「交互之后校验不变量」,前提是你写得出那条不变量。
一个状态位,函数执行期间禁止再次进入。
它是保险,不是根治。两个盲区:不共享同一把锁的函数之间仍然可以互相进入;view 函数永远不受它保护。
谁能调用哪个函数。
审它的方式不是读修饰符,是画一张可达性的图:谁能调到这里,经过几跳。只要有一跳是「任何人」,整条链的答案就是「任何人」。原则是默认拒绝。
整数除法必然产生误差,问题只在误差落在谁那一边。
一条规则:误差永远落在协议这一边。 给用户的算少一点,用户欠的算多一点。再加一条:先乘后除。
可升级合约带来的一整类风险:存储布局错位、初始化被抢、以及最根本的那一条——能升级的人可以一次性改掉全部逻辑。
布局比对要放进 CI;升级权限要有时间锁和事件。这两件事不做,代码审计的价值会被大幅抵消。
一条在任何时刻、任何操作序列之后都必须为真的命题。
它的价值在于可被机器反复检验。写不出不变量的合约,只能靠人去看;写得出的,可以让程序跑几百万次去撞它。
动手
这个练习只在你自己的本地环境里做。 目的是让你亲眼看见「每一行都对、整体却不对」这件事,从而永远记住修复方式。不要把这里的代码部署到任何公链,更不要针对任何真实部署的合约做任何尝试——本地节点上的一切都是你自己的,这个练习也只需要本地节点。
这是这一阶段最重要的三十分钟。亲手让一份自己写的金库在测试里失守一次,比读十篇事故分析都深刻。
验收标准三条,每一条都是一个会红会绿的测试:
验收 1:一份测试能证明,一个只存了小额的账户最终取回的钱超过它存入的
验收 2:把顺序修好之后,同一个测试必须从通过变成失败
验收 3:说清互斥锁和顺序修复各自挡住了什么、各自的盲区在哪写下这个有漏洞的金库,一个字都不要改。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
// 这一行把执行权交给了 msg.sender
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// 等控制流回到这一行时,上面那段可能已经被重新进入过很多次
balances[msg.sender] = 0;
}
function totalAssets() external view returns (uint256) {
return address(this).balance;
}
}部署到本地网络,用两三个普通地址各存一笔钱进去。这几笔钱扮演的是「其他诚实用户」,它们是这个练习里会流失的那部分。记下 totalAssets() 的值。
写一份「探针」合约来复现问题。
它不是用来攻击谁的,它是你的测试夹具——一份能在收到转账时再次触发 withdraw 的合约,作用是把「顺序错误会导致重复取款」这件事变成一个可断言的事实。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
interface IVault {
function deposit() external payable;
function withdraw() external;
}
// 测试夹具:只在本地用来证明漏洞存在
contract ReentrancyProbe {
IVault public immutable vault;
uint256 public unit;
constructor(address v) {
vault = IVault(v);
}
function start() external payable {
unit = msg.value;
vault.deposit{value: msg.value}();
vault.withdraw();
}
// 金库把钱打进来时会触发这里,此时金库的账本还没清零
receive() external payable {
if (address(vault).balance >= unit) {
vault.withdraw();
}
}
}部署之前,先在纸上推演一遍。 写出前三次进入时 balances[probe] 和金库余额分别是多少。推演对了再跑,你会对发生的事有完全不同的理解——这一步是这个练习的重点,不要跳过。
用一份测试把它断言出来。
在测试里:先用诚实地址存入若干;再用一个很小的金额调探针的 start();最后断言两件事:
- 金库的 totalAssets() 掉到了 0 或接近 0
- 探针取回的总额,明显超过它当初存入的那一小笔这份测试现在应该通过——也就是它成功证明了钱会流失。这是唯一能让你确信漏洞真实存在的东西,比任何口头描述都硬。这一点从 T8 起就是同一条标准:一个能跑的测试,胜过一段解释。
用调换顺序来修复,让上一步那份测试变红。
把 withdraw 改成 CEI 的顺序:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // 先记账
(bool ok, ) = msg.sender.call{value: amount}(""); // 再交互
require(ok, "transfer failed");
}同一份测试,现在必须失败——探针第二次进入时读到的余额已经是 0,require 把它拦下。
编译通过不算修好,测试从绿变红才算。这是整个练习的核心验收。
再单独加一把互斥锁,理解它和顺序修复的分工。
把金库回退到有漏洞的版本,这次只加锁、不改顺序,观察那份测试也会变红。
然后回答一个问题:如果金库有两个函数都会转账、共享同一份 balances,而你只给其中一个加了锁,会怎样? 自己写一份测试验证跨函数的路径——这就是锁的第一个盲区。
复现只读路径的问题,理解锁的第二个盲区。
用本章那个「按余额除以份额报价」的例子:给 redeem 加锁,但把 totalShares 的更新放在转账之后。再写一份合约,在收到转账的回调里去读金库的 pricePerShare()。
你会看到:redeem 上明明有锁,读到的价格却是错的。 因为锁保护不了 view 函数。
修复方式是把 totalShares -= shares 挪到转账之前——又回到那条主轴:交出执行权之前,所有会被外部读到的量都必须自洽。
做一次小结。 把这个练习里出现过的每一种修复列成一张表:
| 修复方式 | 挡住了什么 | 盲区是什么 |
|---|---|---|
| 调换顺序(CEI) | 同函数、跨函数、只读,全挡 | 跨合约的状态不一致要另算 |
| 互斥锁 | 加了锁的函数被重新进入 | 跨函数需都加锁;view 完全不管 |
| 拉模式 | 把转账从有状态逻辑里移除 | 改变了交互形态 |
填完这张表,你就真正理解了这一章的第一条结论:顺序是根治,锁是保险,两个都要。
AI Lab
这个 Lab 的产出不是「AI 帮我找到了几个 bug」,而是一份你对 AI 审计能力的信任边界报告。分三步。
第一步:
这是我的合约(把你在动手环节写的金库、带只读路径的份额合约、
以及一个你自己写的、带一处权限传递问题的双合约结构一起给它)。
请找出所有安全问题,每一个都要给出:
- 具体的函数和行
- 触发它需要的前置条件与调用顺序
- 造成的后果
- 修复方式
不要给出「建议做一次审计」「注意重入风险」这类没有落点的话。
第二步:
把你上面报告的每一个问题,写成一份【在当前代码上会失败】的测试。
如果你写不出这样的测试,说明这一条你并不确定,请标注出来。
第三步:
我在代码里【故意】留了至少两处问题没有告诉你。
它们分别属于「只读路径」和「跨合约权限传递」两类。
请重新审一遍,专门找这两类。第一步会暴露这个工具最典型的行为:它擅长报出教科书式的、有名字的问题(同函数重入、缺修饰符),但对需要跨函数、跨合约推理的问题命中率明显下降。 它也会产生噪声——把一堆「理论上可能」但在你的上下文里根本不成立的东西列出来充数。这正是你要分类的原因。
第二步是这一章最硬的一条筛子,和 T8 的 delegatecall Lab 用的是同一把:一个「在坏代码上会失败」的测试,是唯一能证明问题真实存在的东西。 让它为每一条结论写测试,它编造的那些会当场露馅——测试要么写不出来,要么在坏代码上居然通过了。凡是配不出失败测试的结论,一律降级为误报。
第三步测的是漏报,而漏报比误报危险得多:误报浪费你的时间,漏报让你以为安全。 你在动手环节亲手复现过的那个只读路径问题,是检验它的绝佳素材——如果它在第一步没报、第三步专门找也没找到,你就得到了一条非常具体的信任边界:这个工具不能替你把关只读路径。
最后必须你自己做:统计真问题、误报、漏报三个比例,写成一句结论。 类似「它能帮我扫掉大部分有名字的问题,但只读重入和权限传递这两类必须我自己审」。这句话,就是你以后能不能把它放进流程、放在哪个位置的依据。
一条底线:AI 可以用来生成审计的线索,绝不能用来代替你出审计的结论。这和 T32 那条「不要用模型去校验模型的输出」是同一条规则——真正的把关必须是确定性的:一份会红会绿的测试。
AI 说完之后,你必须自己验证
- 它报的每一个「真问题」,你都写了一份会失败的测试来证明;证不出来的,降级为误报
- 它报的每一个问题,是不是给了具体的函数、具体的行、具体的触发路径;只说「可能有重入风险」而指不出位置的,算噪声不算发现
- 把你在动手环节里【故意留下的】那个只读路径问题藏进代码,看它能不能报出来;报不出来就是一次漏报
- 它有没有把「加个互斥锁就安全了」当成结论,却说不清顺序问题是否已经根治
- 它报的取整方向,你自己按「误差落在协议一侧」的规则复核过,方向对不对
- 它引用的库、修饰符名、标准编号是不是真实存在,任何一个都要自己查而不是相信
- 最后你亲手统计三个比例:真问题、误报、漏报各占多少,并写下对这个工具的信任边界
真实案例
一个募集了巨额资金的合约,取款函数正是开头那个形状:先转账、后清零。有人利用这个顺序,在一次交易里反复进入取款流程,把远超自己份额的资金转了出去。
它的影响之大,直接导致了一次链的分叉。而根因简单到令人难以置信:两行代码的顺序。
这个案例是整个行业「先记账、后交互」这条肌肉记忆的起点。它也说明了这一章开头那句话:没有一行是错的,错在两行之间。
一个金库对外提供「每份额值多少钱」的 view 函数,本身加了重入锁,看起来无懈可击。但它在转账之后才更新份额总数,于是在那段回调时间里,这个报价是偏的。
另一个借贷协议把这个报价当成价格来源。在回调期间,那个协议读到偏低的价格,据此放出了不该放的贷款。
两个协议各自都通过了审计,问题出在它们的组合处。教训有两条:view 函数也是攻击面;以及 T21 那句话——把另一个协议的即时状态当预言机,是最常见的攻击入口。
T27 讲过一次,这里从合约侧再看一遍:一个只该用于某种操作的权限,因为没有限制「能调哪个函数」,被用去调用了一次授权类的函数,把额度授给了一个受控地址,之后的转移根本不经过原来的检查。
整个过程里,金额上限一次都没被触发,因为授权操作本身的金额是零。
规则是这一阶段反复出现的那一条:任何会改变资金控制权的动作都要被覆盖,不只是转账。 授权、委托、改管理员、升级,一个都不能漏。
T8 详细讲过这个案例,放在这里是因为它同时命中了这一章的两个模型:初始化路径没保护(权限边界),以及逻辑合约自己也是一个可被直接调用的地址(升级风险)。
有人直接对库合约本身调用了它的初始化函数,成为它的所有者,然后触发了自毁。所有依赖它的钱包在同一秒变成空壳。
它提醒你:审一份可升级合约时,逻辑合约要和代理分开审,并且要假设它会被任何人直接调用。
改一个变量
你挡住了同函数和跨函数的重新进入,代价是每个函数多一点 Gas。看起来很安全。
但你有两个敞口原封不动:只读路径(锁保护不了 view),以及跨合约的状态不一致(你转账时暴露的中间态,会被集成你的人读到)。
更长期的问题是:锁让你放松了对顺序的警惕。下一个迭代里,某个新函数忘了加锁,或者某个人为了省 Gas 去掉了锁,你的防线就只剩顺序了——而你从来没维护过顺序。 这就是为什么两个都要。
重入这一整类问题基本消失了,批量操作的 Gas 上限、单个接收方失败拖垮全批,也一起解决了。
代价是交互形态变了:几乎每件事都变成两笔交易,用户体验和产品复杂度都上升。而且它引入了一个新的状态——「待领」——这个状态自己也要被纳入你的不变量:待领总额加上已发放,必须等于应发放。
所以拉模式不是万能药,它是一次权衡:用体验换掉一整类顺序风险,适合对即时性不敏感的场景。
第一存款人那类「算出 0 份额」的问题几乎消失,很多精度自伤也随之消失。
但取整方向的规则一条都不能少。误差再小也不是零,一旦某次取整的方向反了,攻击者只要把操作重复足够多次,微小的误差就会被累积放大——这正是很多「薅羊毛」型攻击的形状。
而且高精度不解决「总资产直接读余额」这个根因。份额通胀的三个条件里,取整只是其中一个;内部记账和虚拟份额那两条防御,换成高精度之后照样要做。
升级这一整类风险确实消失了:没有人能改逻辑,也就没有存储错位、没有初始化劫持、没有「一把钥匙改掉一切」。
但你把风险换到了另一头:发现漏洞时你无法修复。 一个不可升级的合约里如果有一个已经被公开的漏洞,你能做的往往只有「引导用户尽快撤资」,然后看着它被慢慢掏空。
所以这不是「安不安全」的选择,是「你更怕哪一种失控」的选择:可升级怕的是钥匙被滥用,不可升级怕的是漏洞无法补。 成熟的做法通常是折中——可升级,但升级权限交给带时间锁的治理,让「改逻辑」这件事变得慢、透明、可被用户提前看见并退出。
带走的问题
谁承担风险?这一章给出的答案很冷酷:部署合约的人,以及信任它的用户。 合约的漏洞不像服务器 bug 那样可以热修,它一旦上线就是公开的、不可逆的、全天候被人盯着的。所以这一章的每一条修复,衡量标准都不是「看起来对」,而是「有没有一份测试证明它对」。
如果 Token 价格归零,产品还能运行吗?换一个问法:如果一个你依赖的外部合约行为异常,你的合约会怎样? 这一章的主轴就是在回答它——任何外部调用都可能返回你不期望的东西、在回调里做你不期望的事。把每一个外部依赖都当成潜在的异常源,你的合约才有韧性。
AI 错误时谁承担损失?这一章的 AI Lab 里,AI 审计最危险的错误不是误报,是漏报——它让你以为安全。只读路径和跨合约权限传递这两类,是它最常漏的。责任始终在署名部署这份合约的人:AI 可以生成线索,不能代替你出结论,而结论的形态永远是一份会红会绿的测试。
本章自测
因为漏洞通常不在某一行里,而在两行之间的顺序和没被把守的那条路径上。
开头那个金库每一行都对,问题在于它在「钱已经出去、账还没记」的瞬间把执行权交给了对方。这不是语法错误,是状态顺序错误。
这一章的四类问题——顺序、权限、精度、升级——没有一类是「写错了一行代码」,全都是「结构上留了一个缝」。
CEI(先检查、再改状态、最后交互)是根治:只要交出执行权之前状态已经自洽,对方在回调里做什么都没用,因为它读到的已经是最终状态。它同时覆盖同函数、跨函数、只读三种形态。
互斥锁是保险:它挡住「加了锁的函数被重新进入」,但有两个盲区——不共享同一把锁的函数之间照样能互相进入,以及 view 函数永远不受锁保护。
所以两个都要:顺序是根治,但会在迭代中被改回去;锁在那一刻能救你一次。
因为它伤害的往往不是你,是集成你的人,而且互斥锁对它完全无效。
场景是这样:你的某个 view 函数返回一个派生量(比如份额价格),而这个派生量的分子和分母在一次交互的前后不是同步更新的。在交互那段时间里,别的合约来读这个 view,读到的是中间态。锁保护不了 view,所以它拦不住这条路。
唯一的防线是把「状态自洽」的范围扩大到所有会被外部读取的派生量,而不只是你在当前函数里显式改的变量。
口诀:误差永远落在协议这一边。
给用户的东西向下取整(份额、赎回的资产、抵押价值算少一点),用户欠协议的东西向上取整(债务、手续费算多一点)。再加一条硬规则:先乘后除,避免先损失精度再放大。
它防的不是某一次的小误差,而是同一个方向的误差被重复放大——很多薅羊毛型攻击就是把一个方向反了的取整重复几十万次。
没有标准答案,检查这几件事:
- 有没有一条「资产守恒」类的不变量?比如合约余额不少于所有用户余额之和。这是最基础、也最能挡住顺序问题的一条。
- 有没有一条覆盖「外部可读派生量」的不变量?比如任何时刻读到的份额价格都在合理区间内。这一条专门防只读路径。
- 有没有一条「权限可达性」的不变量?比如除了授权地址,没有任何调用路径能改到某个关键状态。
- 每一条不变量,能不能写成一个函数,让程序随机生成几百万种操作序列、每步之后都检查它一遍?写不成,说明它还是一句感觉,不是一条不变量。
- 你能不能故意制造一次违反,确认检验会失败?检验不出违反的不变量,等于没有。
能把感觉变成可被机器反复撞的命题,就是这一章通向 T30 的桥。
一句话带走
多数漏洞出在状态顺序与权限边界,而不是语法错误。