上链ON-CHAIN BLOCK 16 / 26
卷三 · 合约工程BLOCK 16

合约安全


在这个没有 undo、没有客服、代码即法律的世界,一个 bug 不是崩溃一次,而是资金瞬间被搬空且永不追回。历史上单个漏洞被盗数亿美元的事件反复上演。这一章过一遍最经典的攻击类型,并让你在下面亲手运行一次重入攻击,看金库怎么被一次调用掏干。

头号杀手:重入(Reentrancy)

它是 2016 年 The DAO 事件(直接导致以太坊分叉)的元凶,至今仍高居事故榜首。原理:当你的合约给外部地址转 ETH 时,控制权交给了对方;如果你在更新自己账本之前就转账,对方能在回调里反过来再次调用你的提款函数——此时你的账本还没扣减,于是它能重复提款,直到掏空。

Solidity · 有漏洞的金库
mapping(address => uint256) public balance;

function withdraw() public {
    uint256 amt = balance[msg.sender];
    // ✗ 危险:先转账,后清账本
    (bool ok, ) = msg.sender.call{value: amt}(""); // 交出控制权!
    require(ok);
    balance[msg.sender] = 0;   // ← 攻击者在这行之前又重入了 withdraw
}

攻击合约的 receive() 里写着"收到钱就再调一次 withdraw"。因为 balance[攻击者] 直到最后才清零,每次重入读到的余额都还在,于是循环提款。运行下面这个模拟器看它发生:

重入攻击模拟器DEMO
攻击者只存了 1 ETH,却想取走整个金库。点上面按钮看两种实现的差别。
纯 JS 模拟 EVM 调用栈的重入过程,直观展示"先转账后记账"如何被利用。

三种防御

权限缺失 / 错配

朴素但致命:某个改关键状态的函数忘了加权限检查,任何人都能调。历史上有合约的初始化函数没保护,被人抢先调用夺走 owner 权限,直接卷走全部资金(著名的 Parity 多签事件)。

Solidity · 权限对错
function setOwner(address o) public { owner = o; }            // ✗ 谁都能改
function setOwner(address o) public onlyOwner { owner = o; } // ✓ 加修饰器
风险记账

写下任何一个能改状态的函数时,第一反应就该问:"谁应该能调这个?"——然后加上对应的 onlyOwner / 角色检查。别依赖"没人会发现这个函数",链上字节码全公开,机器人 7×24 扫描每一个新合约的每一个 public 函数。默认全世界都在盯着。

预言机操纵

合约常需要"外部价格"(如某代币兑 USD)。如果它天真地用某个 DEX 池子的即时价格做依据,攻击者就能用闪电贷在一笔交易内瞬间砸盘/拉盘、扭曲价格、骗过你的合约、再还款获利。这类"闪电贷 + 预言机操纵"是 DeFi 被盗的重灾区。防御:用时间加权平均价(TWAP)或去中心化预言机(如 Chainlink)聚合多源价格,让单笔交易无法操纵。第 24 章讲 DeFi 会再展开。

其他常见坑

漏洞要点防御
整数下溢/溢出0.8 前会绕回;unchecked 块内仍会用 0.8+;unchecked 要谨慎
tx.origin 鉴权用 tx.origin 判身份可被钓鱼中转用 msg.sender
链上"随机数"block 值可预测/被矿工操纵用 VRF 等可验证随机
gas 耗尽 DoS循环可被撑爆导致函数瘫痪避免无界循环;拉取模式
未检查返回值低层 call 失败不自动 revert检查 ok 或用 SafeERC20
Android 类比

你在 Android 安全里养成的直觉在这里加倍适用:永远不信任外部输入、永远做权限校验、最小权限原则。区别是后果被放大到极致——移动端漏洞可能被打补丁热修,链上合约一旦部署往往改不动(除非专门设计升级,下一章),损失也是真金白银、不可逆。所以流程上要:严格测试(第 13 章 fuzzing)、第三方审计、慢慢放量、设紧急暂停开关。

要点入账

安全不是最后加的一道工序,而是贯穿始终的思维方式。记住高频杀手:重入(用 CEI + 重入锁)、权限缺失(每个写函数都问"谁能调")、预言机操纵(别信即时单源价格)。核心心法只有一句:先改自己的账,再和外面打交道;默认全世界都是攻击者。下一章我们解决"部署后发现 bug 怎么办"——可升级合约,它本身又是一个巨大的安全话题。

本章小结

◆ 返回目录