🎬 开场 · 能跑 ≠ 安全
L4 你已经能写出能编译、能部署、能跑的合约——Vault、ERC-20、Proxy、Staker。但能跑只是起点。
去年(2023)DeFi 被盗资金 > $20 亿美元 · 自 2016 以来累计 > $80 亿。几乎每一类合约都有过教科书级的真实事故——而这些事故大多数不是因为 Solidity 语法错了,而是因为开发者没假设代码会被攻击。
今天的检查表 · 6 类最致命的合约漏洞
按”复现难度”排序——从最简单的”忘加 modifier”到最复杂的”闪电贷 + 价格操纵”:
💡 L4 已经预告过两类攻击:
tx.origin 钓鱼(L4 C.2)和ERC-20 approve race condition(L4 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 {}
}
看上去 withdraw 有 onlyOwner 检查、很安全?但任何人都可以先调一次 initWallet(自己的地址) 把 owner 改成自己——然后再调 withdraw 就完全合法。
Playground · 亲手当攻击者
下面这个 Playground 里有两份合约。先 alice 部署 VulnerableWallet 并往里转 5 ETH 作为受害者;然后切到 bob 账户,bob 不用部署任何东西,直接调 alice 部署好的合约的 initWallet(bob 的地址) → bob 现在就是 owner → bob 调 withdraw() → 钱进 bob 钱包。
5 步攻击脚本:
| 步 | 账户 | 合约 | 操作 | 关键观察 |
|---|---|---|---|---|
| 1 | Alice | deploy: VulnerableWallet | 编译 + 部署 | banner 显示合约地址 · 设为 V |
| 2 | Alice | VulnerableWallet (V) | initWallet(alice) —— 自己合法初始化 | owner = alice |
| 3 | Alice | send eth · V · 5 ETH | 把 5 ETH 转进 wallet 作为本金 | V 的余额 = 5 ETH |
| 4 | 切到 Bob | VulnerableWallet (V) | bob 调 同一个 V 的 initWallet(bob) —— 🔥 漏洞触发 | owner 现在 = bob!alice 完全失去控制 |
| 5 | Bob | VulnerableWallet (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):
| 步 | 操作 | 关键观察 |
|---|---|---|
| 1 | Alice 部署 VulnerableBEC · 起始 totalSupply = 10000 | Alice 拥有全部 10000 token |
| 2 | Bob 构造 _value = 2^255 · receivers = [bobAddr, charlieAddr] (cnt=2) | 2^255 * 2 = 2^256 ≡ 0(溢出!)· amount = 0 |
| 3 | Bob 调 batchTransfer([bob,charlie], 2^255) —— 注意 Bob 自己 balance = 0 | require 检查 0 ≥ 0 通过 |
| 4 | Bob 和 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” 是死罪
漏洞合约 VulnerableBank 的 withdraw() 长这样:
// ❌ 漏洞顺序
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 在 external call 之后才置 0 · 重入时 bank 还以为 attacker 余额 = 1
当前调用栈(顶部 = 最深处)
栈空 · 点 ▶ 开始攻击
phase 0 / 5
初始 · Bank 里有 3 ETH(alice + bob + attacker 各存了一些)· attacker 在 bank 里的账本余额 = 1 ETH · attacker 钱包是空的 · 点 ▶ 开始:attacker 调 bank.withdraw()。
ReentrancyGuard 是双保险。Playground · 亲手部署 + 触发重入
下面这个 Playground 同时装着 VulnerableBank、SafeBank、Attacker 三份合约。先用漏洞版触发攻击,再切到安全版看 CEI 怎么挡住。
6 步攻击脚本:
| 步 | 账户 | 切到 | 操作 | 关键观察 |
|---|---|---|---|---|
| 1 | Alice | deploy: VulnerableBank | 编译 + 部署 | 设为 B |
| 2 | Alice | VulnerableBank (B) | deposit() · value = 2 ETH | bankBalance = 2 ETH |
| 3 | 切到 Bob | deploy: Attacker | 构造参数 _bank 粘贴 B 的地址 · 部署 | 设为 X |
| 4 | Bob | Attacker (X) | 调 attack() · value = 1 ETH | 🔥 X 先 deposit 1 进 B · 然后 withdraw · receive 反复重入 |
| 5 | Bob | VulnerableBank (B) | 调 bankBalance() (view) | 🔥 = 0 ETH · alice 的钱也被卷走 |
| 6 | Bob | Attacker (X) | 调 drain() 把 ETH 转回自己钱包 | bob 钱包 +3 ETH · 净赚 2 ETH |
然后换 SafeBank 重跑——第 4 步会发现 attacker 只拿回自己存的 1 ETH,alice 的 2 ETH 安全。
修复要点
| 反模式 | 正确做法 |
|---|---|
| 先 external call · 后改 state | CEI 顺序:Checks · Effects · Interactions |
| 跨多个函数共享状态、没锁 | 用 OpenZeppelin ReentrancyGuard(nonReentrant 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 的设计。
漏洞链路:
- library 合约没人 init —— Parity 当时的 library 部署后没有人调过
initWallet,任何人都能调一次把自己设成 owner(这一步和我们的攻击 1 是同一类漏洞!) - library 合约暴露了
kill()函数 ·kill()调用selfdestruct(...)· 只有 owner 能调 - devops199 先调 library 的
initWallet(自己)→ 成为 library owner - 然后调 library 的
kill()→ library 合约从链上消失 - 所有依赖该 library 的 Wallet 现在
delegatecall到一个已不存在的地址 · 等同变砖 - 链上 $300M 全部冻结 · 至今未解
这里的关键不止 delegatecall · 也是 selfdestruct + 初始化漏洞 · 三个机制叠加才造成灾难。
Playground · 部署 + 自毁 + 看 Wallet 变砖
下面这个 Playground 模拟 Parity 灾难。先部署 library + 两份 wallet,给 wallet 转 ETH;然后用 attacker 账户直接接管 library 并调 kill。再尝试用 wallet —— 直接 revert · 钱出不来。
6 步攻击脚本:
| 步 | 账户 | 切到 | 操作 | 关键观察 |
|---|---|---|---|---|
| 1 | Alice | deploy: WalletLibrary | 部署 library | 设为 L · ⚠️ library 现在没 owner(initialized=false) |
| 2 | Alice | deploy: UserWallet · 构造参数 _library = L | 部署自己的 wallet | 设为 W · alice 是 W 的 owner(在 W 的 storage 里) |
| 3 | Alice | send eth · W · 5 ETH | 给自己 wallet 充值 | W.balance = 5 ETH |
| 4 | 切到 Bob | WalletLibrary (L) | 调 L 自己的 initWallet(bob) —— ⚠️ 不是 W! | L 的 owner = bob(攻击者占领 library) |
| 5 | Bob | WalletLibrary (L) | 调 L 自己的 kill() | library 合约自毁 · 字节码被擦除 |
| 6 | Alice | UserWallet (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 步:
- 闪电贷 · 借 N 万 USDC(同 tx 必还)
- 砸盘 · 大额 swap 进一个小 AMM 池子 → 池子价格被瞬间扭曲(USDC 大量涌入 · ETH 几乎被抽干 · ETH 价格”飙高”)
- 借空 · 在某个依赖该 AMM 当价格源的借贷协议里 · 以”刚才扭曲后的价格”抵押少量 USDC · 借出大量 ETH(协议读到错误价格 · 以为 USDC 极贵)
- 还贷 · 用借出的 ETH 通过另一条路径换回 USDC · 还闪电贷 · 净赚利润 · AMM 价格回归
整个过程一笔 tx 原子完成。下面的 widget 把 4 步演示成可点 next 的动画:
◆ 闪电贷 + 价格操纵 · bZx 风格 · 一笔 tx 内完成
step 1 · ○
闪电贷
step 2 · ○
砸盘扭曲价格
step 3 · ○
借空借贷池
step 4 · ○
还贷 + 利润
🦹 Attacker 钱包
🌊 AMM 池子(决定价格)
🏦 Lending Pool
初始 · AMM 池里 100 USDC + 1 ETH(k=100)· 现价 ETH = 100 USDC · Lending Pool 用这个价 · Attacker 一无所有。点 ▶ 借闪电贷。
Playground · 简化版攻击合约(可选 · 较复杂)
完整复现需要部署 AMM + LendingPool + Attacker 三个合约 · 比较复杂。这里给一个简化的攻击合约骨架 · 学生可以看代码结构 · 课后用 Damn Vulnerable DeFi 完整复现:
修复要点
| 反模式 | 正确做法 |
|---|---|
| 直接读单一 AMM 的 spot price | 用 TWAP(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 个漏洞分别是什么?
.transfer的 2300 gas 限制 —— 退款给旧 king 用payable(king).transfer(msg.value)· 如果旧 king 是个合约且receive()故意 revert 或 gas 超 2300 · 整个becomeKing调用就回滚 · 没人能再上位- state 在 external call 之后才更新 —— 这是 攻击 3 重入的镜像 · transfer 触发 prev king 代码 · 此时
balance还没更新 - 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 后,下面这些资源能让你继续往上爬:
- Ethernaut · ethernaut.openzeppelin.com · 25+ 关 · 每关一个具体漏洞 · 通关后你就能识别 90% 的常见攻击
- Damn Vulnerable DeFi · damnvulnerabledefi.xyz · 进阶 · 每关模拟一种真实 DeFi 攻击(flash loan、oracle、governance)
- Capture the Ether · capturetheether.com · 入门到中级 · 偏密码学和 EVM 细节
🎬 收尾 · 审计心法
这一课的唯一心法:永远假设代码会被攻击。
- 任何 external call 都是一个攻击面
- 任何 public 函数都会被恶意 caller 调用
- 任何依赖外部数据(价格、时间)的逻辑都可能被操纵
- 任何”我相信用户不会这样做”的假设最终都会被打破
推荐资源 · 进阶之路
- 慢雾 SlowMist · 《区块链黑暗森林自救手册》 · 链上用户视角的安全手册
- OpenZeppelin · Security Audits Blog · 顶级审计公司的复盘
- Ethernaut · ethernaut.openzeppelin.com · 25+ 关 CTF · 每关一个漏洞类型
- Damn Vulnerable DeFi · damnvulnerabledefi.xyz · 进阶版 · 模拟真实 DeFi 攻击
- Trail of Bits · Building Secure Contracts · 工程实践指南
- Rekt News · rekt.news · 最新链上事故复盘 · 每月都有新案例
下一课预告
L6 · AI 编程入门 —— 当 AI 能写代码 · 安全审计的角色会怎么变?如何用 AI 辅助找漏洞?AI 写出来的合约能信任吗?