◢ Lesson 5 ⏱ 105 min

智能合约安全 · 攻击与防御

🎬 开场 · 能跑 ≠ 安全

L4 你已经能写出能编译、能部署、能跑的合约——Vault、ERC-20、Proxy、Staker。但能跑只是起点

去年(2023)DeFi 被盗资金 > $20 亿美元 · 自 2016 以来累计 > $80 亿。几乎每一类合约都有过教科书级的真实事故——而这些事故大多数不是因为 Solidity 语法错了,而是因为开发者没假设代码会被攻击


今天的检查表 · 6 类最致命的合约漏洞

按”复现难度”排序——从最简单的”忘加 modifier”到最复杂的”闪电贷 + 价格操纵”:

攻击 1 · 难度 ★🔓访问控制漏洞Parity Wallet · $30M
攻击 2 · 难度 ★🔢整数溢出BEC Token · 价格归零
攻击 3 · 难度 ★★🔁重入攻击The DAO · $60M · ETH 分叉
攻击 4 · 难度 ★★★💣delegatecall + 自毁Parity Multisig · $300M 冻结
攻击 5 · 难度 ★★★★闪电贷 + 价格操纵bZx / Mango · 数百次复发
挑战 · 综合🎯期末通关Ethernaut 风格

💡 L4 已经预告过两类攻击tx.origin 钓鱼L4 C.2)和 ERC-20 approve race conditionL4 B.3)—— 这两个 L5 不再重讲,但都是同一类思路:用户授权了你不该授权的东西


攻击 1 · 访问控制漏洞 · 你忘加了一个 modifier

真实事故 · Parity Multisig Wallet 黑客 1 号(2017.7.19) · 攻击者扫描以太坊上所有未初始化的 Parity multisig wallet · 直接调它们暴露的 initWallet(...) 函数 · 把自己设成 owner · 抽走资金 · 总损失 ≈ $30M / 153,037 ETH

整个攻击只用了 3 笔交易。慢雾后来复盘说:漏洞代码只有 1 行——init 函数没加任何权限检查

原理 · “默认 public” 的诅咒

Solidity 函数的可见性有 4 档:public / external / internal / private。Solidity 0.5 之前,函数不写可见性就默认 public——任何人都能调用。Parity 的 initWallet 函数就是这种情况。

漏洞合约长这样:

// ❌ 漏洞版本 · 模仿 Parity Multisig 的简化
contract VulnerableWallet {
    address public owner;
    bool public initialized;

    // ⚠️ 没 onlyOwner · 没 initializer · 任何人都能调
    function initWallet(address _owner) public {
        owner = _owner;
        initialized = true;
    }

    function withdraw() public {
        require(msg.sender == owner, "not owner");
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {}
}

看上去 withdrawonlyOwner 检查、很安全?但任何人都可以先调一次 initWallet(自己的地址) 把 owner 改成自己——然后再调 withdraw 就完全合法。

Playground · 亲手当攻击者

下面这个 Playground 里有两份合约。先 alice 部署 VulnerableWallet 并往里转 5 ETH 作为受害者;然后切到 bob 账户,bob 不用部署任何东西,直接调 alice 部署好的合约initWallet(bob 的地址) → bob 现在就是 owner → bob 调 withdraw() → 钱进 bob 钱包。

5 步攻击脚本

账户合约操作关键观察
1Alicedeploy: VulnerableWallet编译 + 部署banner 显示合约地址 · 设为 V
2AliceVulnerableWallet (V)initWallet(alice) —— 自己合法初始化owner = alice
3Alicesend eth · V · 5 ETH把 5 ETH 转进 wallet 作为本金V 的余额 = 5 ETH
4切到 BobVulnerableWallet (V)bob 调 同一个 V 的 initWallet(bob) —— 🔥 漏洞触发owner 现在 = bob!alice 完全失去控制
5BobVulnerableWallet (V)withdraw()bob 钱包 +5 ETH · alice 颗粒无收

然后用相同步骤试 SafeWallet —— 在第 4 步会撞到 already initialized revert · 攻击失败。

修复要点

反模式正确做法
不写可见性(默认 public)永远显式声明 external / public / internal / private
初始化函数没限制重入initializer modifier(OpenZeppelin Initializable)· 或自己用 require(!initialized)
危险函数只靠”没人会调”避险任何可被外部触发的状态修改都必须 onlyOwner / onlyRole
部署后忘了初始化在 constructor 里初始化、或者用 factory 部署+初始化同一笔 tx

⚠️ OpenZeppelin Initializable 是行业标准 · 它保证 initialize 只能被调用一次 · 且和 proxy 模式配合无缝。任何上主网的项目都应该用它,不要自己写 bool initialized


攻击 2 · 整数溢出 · 当 uint256 + 1 等于 0

真实事故 · BEC(Beauty Chain)Token 黑客(2018.4.22) · 攻击者构造一笔 batchTransfer 调用 · 让函数内的乘法溢出 · 单笔交易铸出 2²⁵⁵ 个 BEC · 价格秒间归零 · 项目方紧急暂停交易所提币 · BEC 从此一蹶不振。

漏洞代码就 1 行:uint256 amount = _value * cnt; —— 没溢出检查。

原理 · 数字会绕回来

EVM 里 uint256 的合法范围是 [0, 2²⁵⁶-1]。超出范围会”绕回来”——像时钟从 23:59 跳到 00:00。

uint8 (256 个值演示版):
  255 + 1 = 0      ← overflow
  0   - 1 = 255    ← underflow

uint256:
  (2^256 - 1) + 1 = 0
  0 - 1 = 2^256 - 1 (a HUGE number)

Solidity 0.8 开始默认所有算术都有 overflow check · 自动 revert。unchecked { } block 关掉检查——为了 gas 优化、或者你确定不会溢出时用。BEC 的代码是 0.4 写的、那时没有自动检查。

Playground · 复现 BEC

下面这份合约用 unchecked 复现 0.4 时代的行为。注意:现代合约应该几乎不写 unchecked(除非你能用形式化方法证明它安全)。

攻击步骤(用 VulnerableBEC):

操作关键观察
1Alice 部署 VulnerableBEC · 起始 totalSupply = 10000Alice 拥有全部 10000 token
2Bob 构造 _value = 2^255 · receivers = [bobAddr, charlieAddr] (cnt=2)2^255 * 2 = 2^256 ≡ 0(溢出!)· amount = 0
3Bob 调 batchTransfer([bob,charlie], 2^255) —— 注意 Bob 自己 balance = 0require 检查 0 ≥ 0 通过
4Bob 和 Charlie 各得到 2^255 BEC凭空铸出天文数字 · 总供应崩盘

实际操作时 2^255 在 Playground 输入框就是 57896044618658097711785492504343953926634992332820282019728792003956564819968

试试 SafeBEC —— 步骤 3 直接 revert with arithmetic underflow/overflow · 攻击失败。

修复要点

反模式正确做法
Solidity < 0.8 · 没 SafeMath升级到 0.8+ · 或全程用 SafeMath
不必要的 unchecked { }默认不写 unchecked · 只在能证明安全的高频循环里用
没考虑大数 * 大数任何 a * b 之前都要想一遍上界
数组长度无界require(arr.length <= MAX) 永远不要假设数组短

攻击 3 · 重入攻击 · The DAO 经典中的经典

真实事故 · The DAO 黑客(2016.6.17) · 当时 ETH 上最大众筹项目 · 攻击者用一份精心构造的合约 · 通过重入漏洞从 The DAO 提走了 360 万 ETH(约 $60M) · 占当时 ETH 总流通量的 5% · 以太坊社区被迫硬分叉回滚 · 衍生出 ETC(Ethereum Classic)。

重入是智能合约最经典、最教科书级的漏洞——也是至今仍在反复出现的漏洞(2023 年 Curve、2022 年 Fei Protocol 都栽过)。

原理 · “external call 早于 state update” 是死罪

漏洞合约 VulnerableBankwithdraw() 长这样:

// ❌ 漏洞顺序
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "no balance");

    (bool ok,) = msg.sender.call{value: amount}("");  // ← 先 send
    require(ok, "send failed");

    balances[msg.sender] = 0;  // ← 后 zero
}

关键观察:第 5 行的 call{value: amount}("") 会触发 msg.sender 这个地址上任意代码——如果 msg.sender 是个合约,它的 receive()fallback() 会被调用。攻击者完全可以在 receive 里再次调 bank.withdraw()——这时第 8 行还没执行,balances[attacker] 仍然 = 1,require 通过,再发一次 ETH。循环直到 bank 空

下面的 widget 把整个调用栈摊开 · 点 ▶ 推一步 一帧一帧看栈怎么深下去、bank 余额怎么被掏空:

◆ 重入攻击 · 调用栈反复进入未更新的 state

链上余额

🏦 Bank.balance (合约 ETH)3 ETH
🦹 Attacker 钱包0 ETH

Bank 内部账本

balances[attacker]1 ETH

⚠️ 漏洞版 · balance 在 external call 之后才置 0 · 重入时 bank 还以为 attacker 余额 = 1

当前调用栈(顶部 = 最深处)

栈空 · 点 ▶ 开始攻击

phase 0 / 5

初始 · Bank 里有 3 ETH(alice + bob + attacker 各存了一些)· attacker 在 bank 里的账本余额 = 1 ETH · attacker 钱包是空的 · 点 ▶ 开始:attacker 调 bank.withdraw()

// Checks-Effects-Interactions(CEI): 先 require · 再改 state · 最后 external call。 external call 是攻击者控制的代码——把它放最后、且 state 已落定 · 重入也打不进来。 OZ 的 ReentrancyGuard 是双保险。

Playground · 亲手部署 + 触发重入

下面这个 Playground 同时装着 VulnerableBankSafeBankAttacker 三份合约。先用漏洞版触发攻击,再切到安全版看 CEI 怎么挡住。

6 步攻击脚本

账户切到操作关键观察
1Alicedeploy: VulnerableBank编译 + 部署设为 B
2AliceVulnerableBank (B)deposit() · value = 2 ETHbankBalance = 2 ETH
3切到 Bobdeploy: Attacker构造参数 _bank 粘贴 B 的地址 · 部署设为 X
4BobAttacker (X)attack() · value = 1 ETH🔥 X 先 deposit 1 进 B · 然后 withdraw · receive 反复重入
5BobVulnerableBank (B)bankBalance() (view)🔥 = 0 ETH · alice 的钱也被卷走
6BobAttacker (X)drain() 把 ETH 转回自己钱包bob 钱包 +3 ETH · 净赚 2 ETH

然后换 SafeBank 重跑——第 4 步会发现 attacker 只拿回自己存的 1 ETH,alice 的 2 ETH 安全。

修复要点

反模式正确做法
先 external call · 后改 stateCEI 顺序:Checks · Effects · Interactions
跨多个函数共享状态、没锁用 OpenZeppelin ReentrancyGuardnonReentrant modifier)
假设 .transfer 是安全的2300 gas 限制曾经能挡 · EIP-1884 后失效 · 不要依赖它
跨合约的 view 函数也重入read-only reentrancy · 2022 年 Curve 案 · view 函数返回的也可能是中间态

⚠️ ReentrancyGuard 不是万能 · 它只保护 single-function 重入。跨合约重入(A 调 B,B 在某个状态下 call 回 A 的另一个函数)需要更细的设计。审计师必看的是”任何 external call 之后,哪些状态可能被假设是稳定的”。


攻击 4 · delegatecall + 自毁 · $300M 永久冻结

真实事故 · Parity Multisig Wallet 黑客 2 号(2017.11.6) · 一个名叫 devops199 的用户”意外”成为了 Parity wallet library 合约的 owner · 然后调用了它的 kill() 函数 · 触发 selfdestruct · 所有依赖这份 library 的 Multisig wallet 同时变砖 · 包括 Polkadot 团队的 ICO 资金 · 总冻结金额约 $300M / 513,774 ETH · 钱不是被偷走 · 是永久取不出来

原理 · 一份合约自毁 · 千份钱包变砖

回看 L4 D.1 的 proxy 升级模式——Proxy 的 fallback 用 delegatecall 把所有调用转发给 Logic 合约。所有 Multisig wallet 都共享同一份 Logic library——这是 Parity 当时为了节省 gas 的设计。

漏洞链路:

  1. library 合约没人 init —— Parity 当时的 library 部署后没有人调过 initWallet任何人都能调一次把自己设成 owner(这一步和我们的攻击 1 是同一类漏洞!)
  2. library 合约暴露了 kill() 函数 · kill() 调用 selfdestruct(...) · 只有 owner 能调
  3. devops199 先调 library 的 initWallet(自己) → 成为 library owner
  4. 然后调 library 的 kill() → library 合约从链上消失
  5. 所有依赖该 library 的 Wallet 现在 delegatecall 到一个已不存在的地址 · 等同变砖
  6. 链上 $300M 全部冻结 · 至今未解

这里的关键不止 delegatecall · 也是 selfdestruct + 初始化漏洞 · 三个机制叠加才造成灾难。

Playground · 部署 + 自毁 + 看 Wallet 变砖

下面这个 Playground 模拟 Parity 灾难。先部署 library + 两份 wallet,给 wallet 转 ETH;然后用 attacker 账户直接接管 library 并调 kill。再尝试用 wallet —— 直接 revert · 钱出不来

6 步攻击脚本

账户切到操作关键观察
1Alicedeploy: WalletLibrary部署 library设为 L · ⚠️ library 现在没 owner(initialized=false)
2Alicedeploy: UserWallet · 构造参数 _library = L部署自己的 wallet设为 W · alice 是 W 的 owner(在 W 的 storage 里)
3Alicesend eth · W · 5 ETH给自己 wallet 充值W.balance = 5 ETH
4切到 BobWalletLibrary (L)L 自己initWallet(bob) —— ⚠️ 不是 W!L 的 owner = bob(攻击者占领 library)
5BobWalletLibrary (L)L 自己kill()library 合约自毁 · 字节码被擦除
6AliceUserWallet (W)试 withdraw 任何金额🔥 W 的 fallback delegatecall 到 L (已不存在) · revert · alice 的 5 ETH 永远拿不出来

这就是 $300M 永久冻结的物理机制。注意第 4-5 步攻击的不是 alice 的 Wallet · 而是所有 Wallet 共享的 library。一份 library 倒下、千份 wallet 同时变砖。

修复要点

反模式正确做法
library 没在部署时 init部署 + initialize 同一笔 tx 完成(用 factory)
selfdestruct 留在生产合约里从 Cancun 升级 (2024) 起,selfdestruct 在新创建合约里已基本失效(保留地址)· 新代码绝不要用
Logic 合约可被外部 init在 constructor 里调用 _disableInitializers()(OpenZeppelin)
delegatecall 目标可被改用 immutable 锁定 · 或用 UUPS 模式(OZ Upgradeable 系列)

⚠️ Parity 灾难的根本教训:你 L4 学的每一块乐高——继承、interface、library、ERC-20、proxy、access control——单独都是好东西。但叠加在一起时,每一块的漏洞会被放大。审计的核心思维是:每个 external call 之后,组合起来还能保持什么?


攻击 5 · 闪电贷 + 价格操纵 · 现代 DeFi 标配灾难

真实事故 · bZx 黑客(2020.2.15 + 2.18) · 攻击者一笔交易内借出大量 ETH → 在小池子里 swap 把价格扭曲 → 在 bZx 用扭曲的价格借出超额资产 → 还闪电贷 + 拿走利润 · 两次共损失 $1M+ · 此后这套手法在 DeFi 里复发数十次(Harvest, Cream, Mango, Beanstalk)· bZx 模式 成为攻击套路名词。

原理 · “原子无风险套利”在链上的新形态

闪电贷是 Aave / dYdX / Uniswap 提供的特殊能力:你可以在同一笔 tx 内借出几乎无限的资金 · 只要这笔 tx 结束前还回去就行 · 还不上 tx 自动 revert · 无任何资金风险。这本身不是漏洞——但它放大了任何”依赖链上 spot price”的弱点。

bZx 风格攻击的 4 步:

  1. 闪电贷 · 借 N 万 USDC(同 tx 必还)
  2. 砸盘 · 大额 swap 进一个小 AMM 池子 → 池子价格被瞬间扭曲(USDC 大量涌入 · ETH 几乎被抽干 · ETH 价格”飙高”)
  3. 借空 · 在某个依赖该 AMM 当价格源的借贷协议里 · 以”刚才扭曲后的价格”抵押少量 USDC · 借出大量 ETH(协议读到错误价格 · 以为 USDC 极贵)
  4. 还贷 · 用借出的 ETH 通过另一条路径换回 USDC · 还闪电贷 · 净赚利润 · AMM 价格回归

整个过程一笔 tx 原子完成。下面的 widget 把 4 步演示成可点 next 的动画:

◆ 闪电贷 + 价格操纵 · bZx 风格 · 一笔 tx 内完成

phase 0 / 4

step 1 ·

闪电贷

step 2 ·

砸盘扭曲价格

step 3 ·

借空借贷池

step 4 ·

还贷 + 利润

🦹 Attacker 钱包

USDC0
ETH0.00
抵押在 Lending0 USDC
借出来的0.00 ETH

🌊 AMM 池子(决定价格)

USDC 储备100
ETH 储备1.0000
K 不变100
ETH 价格100.0 USDC

🏦 Lending Pool

可借 ETH10.00
读价从AMM spot ⚠️
LTV 限额75%
闪电贷未还0

初始 · AMM 池里 100 USDC + 1 ETH(k=100)· 现价 ETH = 100 USDC · Lending Pool 用这个价 · Attacker 一无所有。点 ▶ 借闪电贷。

// 闪电贷攻击的本质:同一笔 tx 内借出几百万美元等价资产 · 在不留资金缺口的前提下扭曲 spot price · 让另一个协议读到错误价格 · 修复唯一办法:不要直接用 AMM spot price 当价格源 · 用 TWAP / Chainlink · 多源校验。

Playground · 简化版攻击合约(可选 · 较复杂)

完整复现需要部署 AMM + LendingPool + Attacker 三个合约 · 比较复杂。这里给一个简化的攻击合约骨架 · 学生可以看代码结构 · 课后用 Damn Vulnerable DeFi 完整复现:

修复要点

反模式正确做法
直接读单一 AMM 的 spot priceTWAP(Time-Weighted Average Price · 几个区块内的均价)
信任一个预言机多源校验 · Chainlink + Pyth + 自己的合理性检查
池子小、流动性低价格源池子需要深度 · 攻击者扭曲它的成本必须高于潜在收益
单笔 tx 内可同时影响多个协议关键操作加 commit-reveal / 延迟出价(DEX MEV 防御)

⚠️ 闪电贷不是漏洞 · 是放大器。任何单笔 tx 内可被操纵的 invariant 都会被它放大。bZx、Mango、Beanstalk、Cream、Harvest——这些项目都不是不知道 DeFi 安全模型 · 而是低估了”单笔 tx 内可改变多少状态”。审计 DeFi 协议时 · 永远问一句:“如果攻击者有无限的瞬时资金,他能扭曲什么?”


🎯 期末通关 · Ethernaut 风格挑战

下面这份 KingOfTheHill 合约长得像个简单的”出价当王”游戏——任何人发 ETH 超过当前 king 余额就成为新 king。游戏暗藏至少 3 个漏洞——你能在 Playground 里写一份 Attacker 合约把它打死吗?

解答提示(先不看,先自己想):

3 个漏洞分别是什么?
  1. .transfer 的 2300 gas 限制 —— 退款给旧 king 用 payable(king).transfer(msg.value) · 如果旧 king 是个合约且 receive() 故意 revert 或 gas 超 2300 · 整个 becomeKing 调用就回滚 · 没人能再上位
  2. state 在 external call 之后才更新 —— 这是 攻击 3 重入的镜像 · transfer 触发 prev king 代码 · 此时 balance 还没更新
  3. balance 字段被 attacker 控制 —— 一旦攻击者上位 · 他可以让 balance 设成任意高的值(要么用大额、要么用更复杂的 attacker 逻辑)· 后续别人无法匹配
攻击者代码示例
contract Attacker {
    KingOfTheHill public target;

    constructor(address _target) payable {
        target = KingOfTheHill(_target);
    }

    function attack() external payable {
        target.becomeKing{value: msg.value}();
    }

    // 关键:没有 receive 函数 · 或者 receive 故意 revert
    receive() external payable {
        revert("I refuse refunds");
    }
}

部署 Attacker · 调 attack() 让自己成为 king · 之后任何人再调 target.becomeKing() 时 · payable(king).transfer(msg.value) 这一行会失败(因为 Attacker 的 receive revert)· 整个 becomeKing 回滚 · Attacker 永远是 king

这个挑战示范了一个核心规律:合约里的”任何 external call”都可能是攻击面——哪怕看起来无害(“我就只是退款而已”)。修复:用 pull payment 模式——不要在 becomeKing 里 push refund · 而是让 prev king 自己来 withdraw。


📚 课后练习 · 系统训练

通关 KingOfTheHill 后,下面这些资源能让你继续往上爬:


🎬 收尾 · 审计心法

这一课的唯一心法永远假设代码会被攻击

推荐资源 · 进阶之路

下一课预告

L6 · AI 编程入门 —— 当 AI 能写代码 · 安全审计的角色会怎么变?如何用 AI 辅助找漏洞?AI 写出来的合约能信任吗?