Crypto OS
Technical Crypto OS第五阶段 · Infrastructure & Security

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一切,只要定价变了合法合约收不到钱
改成拉模式是,且顺带解决别的问题无(但改变了交互形态)不会两笔交易

第一条结论:

顺序是根治,锁是保险。两个都要。

不要因为加了锁就不管顺序——锁有盲区。也不要因为顺序对了就不加锁——顺序会在后续迭代里被别人改回去,而锁会在那一刻救你一次。

第二条结论更重要,它是这一章真正的主轴:

任何一次对外部地址的调用,都是把执行权交了出去。在交还执行权之前,你的状态必须已经是自洽的。

这句话的威力在于它不只适用于转原生代币。把「执行权会被交出去」的位置列全,你会发现它比想象的多得多——而这正是 T7T9T12 一路预告到这里的那份清单。

第三条结论来自第二个思想实验:

安全性不由「检查得多严」决定,由「有没有一条路绕过了检查」决定。

所以审合约的方式不是逐行读代码,而是逐条路径地问:这条路上谁在把关。

建立模型

执行权会在哪些地方被交出去

先把清单列全。这是这一章最该打印出来贴在显示器上的一张表。

位置为什么
转原生代币(call接收方的 receivefallback 会执行
任何对外部地址的 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 讲过的那条预言机操纵的路就通了。

三条教训:

  1. view 函数没有锁,也不可能有锁。 保护它的唯一方式是保证它读到的状态永远自洽。
  2. CEI 的「状态」包括所有会被外部读取的派生量,不只是你在这个函数里显式改的那几个变量。
  3. 集成方要问一句:我读的这个数,在被读的那一刻是不是中间状态。 这是上一章那句「上游不可信时,防线画在自己这一侧」的又一次应用。

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

对应三种防御,通常一起用:

  1. 内部记账:总资产用一个自己维护的变量,而不是直接读余额。直接转进来的钱不计入。
  2. 虚拟份额与虚拟资产:在分子分母上各加一个固定偏移,让「份额算出来是 0」这件事在经济上不划算。
  3. 死份额:初始化时铸一笔份额给一个无法取出的地址,让 totalShares 永远不会小到危险的量级。

同时加一条兜底:算出来的份额是 0 就直接 revert。 一行代码,堵住这一类问题的最后一步。

升级风险

T8 推出了代理模式的三条铁律,这里逐条兑现,并补上第四条。

第一,存储布局只能尾部追加。 不能插入、不能改类型、不能换顺序、不能删除。

这条规则靠 review 守不住,要靠 CI:把每个版本的存储布局导出成一份文件提交进仓库,升级时用脚本比对,只允许新增。这是一个可以完全自动化的检查,没有理由不做。

第二,代理自己的变量放在伪随机的固定槽里,不要从 slot 0 开始声明,否则一定会和逻辑合约撞车。

第三,逻辑合约自己也是一个可以被直接调用的地址。 它的构造函数里就应该把自己标记成「已初始化」,让任何人都无法再初始化它;它里面也不能有能把自己销毁的路径。

第四,升级本身是一个需要被审计的动作,而不只是一段代码。 要回答的问题是:

- 谁能触发升级?是一个人、一个多签,还是一次治理投票?
- 有没有时间锁?多长?
- 升级会不会发出事件?外部监控能不能看见?
- 升级之后有没有一个「初始化新版本」的步骤?它有没有保护?
- 有没有办法在升级出错之后回滚?

第 24 章 说过同一句话,值得再说一遍:一个没有时间锁的可升级合约,等于把所有资金交给了持有升级权限的那把钥匙。 代码审得再干净,这一条不成立,前面的努力全部归零。

不变量:把上面所有东西收口

到这里你已经见了四类问题。它们看起来很不一样,但有一个共同点:

每一类问题,都对应一条本该永远成立、却在某个瞬间不成立的命题。

问题被破坏的命题
顺序错误导致的重复取款合约持有的资产,不少于所有用户余额之和
只读路径读到中间态任何外部可读的派生量,读到的都是自洽状态
铸造超额每个地址的已铸数量,不超过上限
份额通胀存入正数金额的人,拿到的份额必须为正
权限传递只有授权地址能调到这个函数

这些命题就叫不变量。它们的价值不在于「写下来看起来很专业」,而在于:

不变量是唯一能被机器反复检验的安全性表述。

「这个函数我觉得没问题」不能被检验。「合约余额永远不少于所有用户余额之和」可以——你可以让程序随机生成几百万种操作序列,每一次操作之后都检查它一遍。

这正是下一个阶段要做的事。先在这一章养成一个习惯:每写一个有状态的合约,先写出它的不变量,再写功能。 完整的写法与自动检验在 T30。

它叫什么

重入Reentrancy

在一次外部调用把执行权交出去之后、本合约状态更新之前,控制流重新进入本合约。

它的本质不是「被调用了两次」,而是状态更新晚于状态使用。四种形态里,只读的那种最容易被忽略,而且互斥锁对它完全无效。

检查—生效—交互Checks-Effects-Interactions

函数体的标准顺序:先做全部检查,再改本合约状态,最后才和外部交互。

它是重入的根治手段,而不是缓解手段。例外只有一种:把状态收口换成「交互之后校验不变量」,前提是你写得出那条不变量。

互斥锁Reentrancy Guard

一个状态位,函数执行期间禁止再次进入。

它是保险,不是根治。两个盲区:不共享同一把锁的函数之间仍然可以互相进入;view 函数永远不受它保护。

访问控制Access Control

谁能调用哪个函数。

审它的方式不是读修饰符,是画一张可达性的图:谁能调到这里,经过几跳。只要有一跳是「任何人」,整条链的答案就是「任何人」。原则是默认拒绝。

取整方向Rounding Direction

整数除法必然产生误差,问题只在误差落在谁那一边。

一条规则:误差永远落在协议这一边。 给用户的算少一点,用户欠的算多一点。再加一条:先乘后除。

升级风险Upgrade Risk

可升级合约带来的一整类风险:存储布局错位、初始化被抢、以及最根本的那一条——能升级的人可以一次性改掉全部逻辑

布局比对要放进 CI;升级权限要有时间锁和事件。这两件事不做,代码审计的价值会被大幅抵消。

不变量Invariant

一条在任何时刻、任何操作序列之后都必须为真的命题。

它的价值在于可被机器反复检验。写不出不变量的合约,只能靠人去看;写得出的,可以让程序跑几百万次去撞它。

动手

动手写一个有漏洞的金库,写一份测试证明钱会流失,再修好它任意 Solidity 框架 + 本地节点0 元。全程在你自己的本地网络上进行

这个练习只在你自己的本地环境里做。 目的是让你亲眼看见「每一行都对、整体却不对」这件事,从而永远记住修复方式。不要把这里的代码部署到任何公链,更不要针对任何真实部署的合约做任何尝试——本地节点上的一切都是你自己的,这个练习也只需要本地节点。

这是这一阶段最重要的三十分钟。亲手让一份自己写的金库在测试里失守一次,比读十篇事故分析都深刻。

验收标准三条,每一条都是一个会红会绿的测试:

验收 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

AI Lab让 AI 审你的合约,把它的每一条结论分成真问题、误报、漏报三类并统计比例Level 2 · AI Copilot

这个 Lab 的产出不是「AI 帮我找到了几个 bug」,而是一份你对 AI 审计能力的信任边界报告。分三步。

第一步:
这是我的合约(把你在动手环节写的金库、带只读路径的份额合约、
以及一个你自己写的、带一处权限传递问题的双合约结构一起给它)。
请找出所有安全问题,每一个都要给出:
- 具体的函数和行
- 触发它需要的前置条件与调用顺序
- 造成的后果
- 修复方式
不要给出「建议做一次审计」「注意重入风险」这类没有落点的话。

第二步:
把你上面报告的每一个问题,写成一份【在当前代码上会失败】的测试。
如果你写不出这样的测试,说明这一条你并不确定,请标注出来。

第三步:
我在代码里【故意】留了至少两处问题没有告诉你。
它们分别属于「只读路径」和「跨合约权限传递」两类。
请重新审一遍,专门找这两类。

第一步会暴露这个工具最典型的行为:它擅长报出教科书式的、有名字的问题(同函数重入、缺修饰符),但对需要跨函数、跨合约推理的问题命中率明显下降。 它也会产生噪声——把一堆「理论上可能」但在你的上下文里根本不成立的东西列出来充数。这正是你要分类的原因。

第二步是这一章最硬的一条筛子,和 T8 的 delegatecall Lab 用的是同一把:一个「在坏代码上会失败」的测试,是唯一能证明问题真实存在的东西。 让它为每一条结论写测试,它编造的那些会当场露馅——测试要么写不出来,要么在坏代码上居然通过了。凡是配不出失败测试的结论,一律降级为误报。

第三步测的是漏报,而漏报比误报危险得多:误报浪费你的时间,漏报让你以为安全。 你在动手环节亲手复现过的那个只读路径问题,是检验它的绝佳素材——如果它在第一步没报、第三步专门找也没找到,你就得到了一条非常具体的信任边界:这个工具不能替你把关只读路径。

最后必须你自己做:统计真问题、误报、漏报三个比例,写成一句结论。 类似「它能帮我扫掉大部分有名字的问题,但只读重入和权限传递这两类必须我自己审」。这句话,就是你以后能不能把它放进流程、放在哪个位置的依据。

一条底线:AI 可以用来生成审计的线索,绝不能用来代替你出审计的结论。这和 T32 那条「不要用模型去校验模型的输出」是同一条规则——真正的把关必须是确定性的:一份会红会绿的测试。

AI 说完之后,你必须自己验证

  • 它报的每一个「真问题」,你都写了一份会失败的测试来证明;证不出来的,降级为误报
  • 它报的每一个问题,是不是给了具体的函数、具体的行、具体的触发路径;只说「可能有重入风险」而指不出位置的,算噪声不算发现
  • 把你在动手环节里【故意留下的】那个只读路径问题藏进代码,看它能不能报出来;报不出来就是一次漏报
  • 它有没有把「加个互斥锁就安全了」当成结论,却说不清顺序问题是否已经根治
  • 它报的取整方向,你自己按「误差落在协议一侧」的规则复核过,方向对不对
  • 它引用的库、修饰符名、标准编号是不是真实存在,任何一个都要自己查而不是相信
  • 最后你亲手统计三个比例:真问题、误报、漏报各占多少,并写下对这个工具的信任边界

真实案例

每一行都对、顺序错了,金库被反复取款2016 年,一个知名的 DAO

一个募集了巨额资金的合约,取款函数正是开头那个形状:先转账、后清零。有人利用这个顺序,在一次交易里反复进入取款流程,把远超自己份额的资金转了出去。

它的影响之大,直接导致了一次链的分叉。而根因简单到令人难以置信:两行代码的顺序。

这个案例是整个行业「先记账、后交互」这条肌肉记忆的起点。它也说明了这一章开头那句话:没有一行是错的,错在两行之间。

只读报价被当预言机,借贷协议被掏空近年多次重演

一个金库对外提供「每份额值多少钱」的 view 函数,本身加了重入锁,看起来无懈可击。但它在转账之后才更新份额总数,于是在那段回调时间里,这个报价是偏的。

另一个借贷协议把这个报价当成价格来源。在回调期间,那个协议读到偏低的价格,据此放出了不该放的贷款。

两个协议各自都通过了审计,问题出在它们的组合处。教训有两条:view 函数也是攻击面;以及 T21 那句话——把另一个协议的即时状态当预言机,是最常见的攻击入口。

一个 approve 没限制函数,额度被授给攻击者账户与授权场景反复出现

T27 讲过一次,这里从合约侧再看一遍:一个只该用于某种操作的权限,因为没有限制「能调哪个函数」,被用去调用了一次授权类的函数,把额度授给了一个受控地址,之后的转移根本不经过原来的检查。

整个过程里,金额上限一次都没被触发,因为授权操作本身的金额是零

规则是这一阶段反复出现的那一条:任何会改变资金控制权的动作都要被覆盖,不只是转账。 授权、委托、改管理员、升级,一个都不能漏。

初始化没保护,逻辑合约被人接管后销毁2017 年,某多签钱包库

T8 详细讲过这个案例,放在这里是因为它同时命中了这一章的两个模型:初始化路径没保护(权限边界),以及逻辑合约自己也是一个可被直接调用的地址(升级风险)。

有人直接对库合约本身调用了它的初始化函数,成为它的所有者,然后触发了自毁。所有依赖它的钱包在同一秒变成空壳。

它提醒你:审一份可升级合约时,逻辑合约要和代理分开审,并且要假设它会被任何人直接调用。

改一个变量

如果给所有函数都加上互斥锁,但从不检查 CEI 顺序

你挡住了同函数和跨函数的重新进入,代价是每个函数多一点 Gas。看起来很安全。

但你有两个敞口原封不动:只读路径(锁保护不了 view),以及跨合约的状态不一致(你转账时暴露的中间态,会被集成你的人读到)。

更长期的问题是:锁让你放松了对顺序的警惕。下一个迭代里,某个新函数忘了加锁,或者某个人为了省 Gas 去掉了锁,你的防线就只剩顺序了——而你从来没维护过顺序。 这就是为什么两个都要。

如果把所有 external call 都换成拉模式

重入这一整类问题基本消失了,批量操作的 Gas 上限、单个接收方失败拖垮全批,也一起解决了。

代价是交互形态变了:几乎每件事都变成两笔交易,用户体验和产品复杂度都上升。而且它引入了一个新的状态——「待领」——这个状态自己也要被纳入你的不变量:待领总额加上已发放,必须等于应发放。

所以拉模式不是万能药,它是一次权衡:用体验换掉一整类顺序风险,适合对即时性不敏感的场景。

如果所有金额计算改用高精度定点数,取整误差趋近于零

第一存款人那类「算出 0 份额」的问题几乎消失,很多精度自伤也随之消失。

取整方向的规则一条都不能少。误差再小也不是零,一旦某次取整的方向反了,攻击者只要把操作重复足够多次,微小的误差就会被累积放大——这正是很多「薅羊毛」型攻击的形状。

而且高精度不解决「总资产直接读余额」这个根因。份额通胀的三个条件里,取整只是其中一个;内部记账和虚拟份额那两条防御,换成高精度之后照样要做。

如果合约设成不可升级,彻底消灭升级风险

升级这一整类风险确实消失了:没有人能改逻辑,也就没有存储错位、没有初始化劫持、没有「一把钥匙改掉一切」。

但你把风险换到了另一头:发现漏洞时你无法修复。 一个不可升级的合约里如果有一个已经被公开的漏洞,你能做的往往只有「引导用户尽快撤资」,然后看着它被慢慢掏空。

所以这不是「安不安全」的选择,是「你更怕哪一种失控」的选择:可升级怕的是钥匙被滥用,不可升级怕的是漏洞无法补。 成熟的做法通常是折中——可升级,但升级权限交给带时间锁的治理,让「改逻辑」这件事变得慢、透明、可被用户提前看见并退出。

带走的问题

9
谁承担风险?

谁承担风险?这一章给出的答案很冷酷:部署合约的人,以及信任它的用户。 合约的漏洞不像服务器 bug 那样可以热修,它一旦上线就是公开的、不可逆的、全天候被人盯着的。所以这一章的每一条修复,衡量标准都不是「看起来对」,而是「有没有一份测试证明它对」。

11
如果 Token 价格归零,产品还能运行吗?

如果 Token 价格归零,产品还能运行吗?换一个问法:如果一个你依赖的外部合约行为异常,你的合约会怎样? 这一章的主轴就是在回答它——任何外部调用都可能返回你不期望的东西、在回调里做你不期望的事。把每一个外部依赖都当成潜在的异常源,你的合约才有韧性。

17
AI 错误时谁承担损失?

AI 错误时谁承担损失?这一章的 AI Lab 里,AI 审计最危险的错误不是误报,是漏报——它让你以为安全。只读路径和跨合约权限传递这两类,是它最常漏的。责任始终在署名部署这份合约的人:AI 可以生成线索,不能代替你出结论,而结论的形态永远是一份会红会绿的测试。

本章自测

一句话带走

多数漏洞出在状态顺序与权限边界,而不是语法错误。

做完这一章的动手环节了?勾上它查看全部进度

本页目录