hunghy93-pixel/audit-reps
GitHub: hunghy93-pixel/audit-reps
基于 Foundry 的智能合约审计训练项目,通过重现借贷与清算领域的真实漏洞 PoC 帮助开发者练习安全审计技能。
Stars: 0 | Forks: 0
# audit-reps
针对智能合约安全审查的刻意练习,专注于
**借贷 / 清算偿付能力** 垂直领域。每次练习重现一个真实的、
有文档记录的 bug *类型*,作为一个最小化、完全本地化的 Foundry PoC —— 一个攻击成功的
绿色通过测试,外加另一个修复后消除该漏洞的测试。
目标不是构建工具。而是为了锻炼 **审计肌肉**:阅读
份额 / 会计 / 偿付能力数学,并发现其破坏的不变性。
**状态:** 已发布 6 次练习,20 个测试通过 (`forge test`)。
| 练习 | Bug 类型 | 文件 | 测试 |
|-----|-----------|-------|-------|
| #1 | 空市场舍入 / 首个存款者通胀 | `VulnerableVault` · `SafeVault` | `InflationAttack.t.sol` (2) |
| #1b | 幼稚的 `require(shares>0)` “修复”无效 | `GuardedVault` | `GuardedVault.t.sol` (2) |
| #2 | `donateToReserves` 缺少健康检查 (Euler 类型) | `EulerStyleVault` · `SafeEulerVault` | `EulerDonateExploit.t.sol` (2) |
| #3 | 预言机 (现货价格) 操纵 + 过时价格清算 | `MockAMM` · `MockOracle` · `VulnerableLendingMarket` · `SafeLendingMarket` | `OracleManipulation.t.sol` (4) |
| #4 | `liquidityIndex` 舍入漂移 (Radiant 类型) | `VulnerableScaledMarket` · `SafeScaledMarket` | `IndexRoundingExploit.t.sol` (6) |
| #5 | 注资防御 (复利指数通胀) | `SeedableScaledMarket` | `SeedingDefense.t.sol` (4) |
## 练习 #1 — 空市场舍入 / 首个存款者通胀
`src/VulnerableVault.sol` · `src/SafeVault.sol` · `test/InflationAttack.t.sol`
### Bug 类型
基于份额的存款市场铸造 `shares = assets * totalShares / totalAssets`
并读取 `totalAssets` 作为其**实时代币余额**。这使得任何人都可以通过*直接转账*(一种“捐赠”)来膨胀
每股价格,而这种操作不会铸造份额。
当市场刚刚激活时(totalShares 极小),攻击者:
1. 存入 `1 wei` → 拥有 100% 的总供应量 (1 share)
2. 捐赠大量资金 → 每股价格暴涨
3. 受害者存入资金 → 其份额数量**向下舍入为 0**
4. 攻击者赎回 → 抽干受害者被舍入扣除的资产
`test/InflationAttack.t.sol` 证明了这一点:攻击者利润 = **受害者的全部
存款** (`10000e18`),受害者可挽回资金 = `0`。这是在 Sherlock / Code4rena /
CodeHawks 竞赛中反复出现的典型 **ERC-4626 通胀** 发现。 — 已对照 OpenZeppelin ERC-4626 文档 / EIP-4626 确认。
### 同源真实事件 — Radiant Capital, 2024-01-02, ~$4.5M (1900 ETH)
属于同一*家族*(在注资不足、新激活的市场上进行舍入盗窃),但是
一种**不同的原语**:Radiant 的根本原因是由在 Arbitrum 上一个新的原生 USDC 市场上的重复 `deposit()` / `withdraw()` 驱动的半进一法 `liquidityIndex`(利息指数)舍入,在 WETH 市场中累积了坏账(约 1.3% TVL) —
*而不是* 本 PoC 展示的份额价格捐赠通胀。该具体机制已在
**练习 #4** 中忠实重现;本练习是其同源兄弟(实时余额份额价格对比 利息指数舍入)。 — 已确认 (Radiant 官方推文;Beosin;
QuillAudits)。
### 修复方案 — 虚拟份额 + 虚拟资产
`SafeVault` 针对 `totalShares + 10**OFFSET` 和
`totalAssets + 1` 计算价格。虚拟份额(不属于任何人)瓜分了任何捐赠,因此攻击者永远无法挽回超过其捐赠的金额 → 该攻击变为
**负 EV (负期望值)**。测试证明:相同的攻击使攻击者变穷约 `5000e18`
(回归防护断言损失 ≥40% 的捐赠,锁定了 OFFSET 量级)。虚拟偏移量中和了*利润*;一个坚决的恶意破坏者仍然可以以自己更大的损失为代价使受害者损失一些价值,因此
双保险修复方案还会为市场**注资**(参见*稍后深入*)。
## 练习 #1b — 幼稚的 `require(shares > 0)` 防护无效
`src/GuardedVault.sol` · `test/GuardedVault.t.sol`
开发人员首先想到的修复方案是 `require(mintedShares > 0)`。它无法
消除该类型漏洞 —— 它只改变了*结果*,而且永远不会带来安全:
- **部分盗窃** (`test_naiveGuard_stillAllowsPartialTheft`):通过调整捐赠大小,
使受害者铸造一个*非零但不公平的小额*份额数量通过防护检查
— 攻击者仍然获利 (`+25e18`),受害者仍然损失 (`~25e18`)。
- **恶意破坏 / DoS** (`test_naiveGuard_onlyConvertsTotalTheftToGriefing`):更大额的
捐赠将受害者的份额舍入为零,因此防护*触发*并且受害者的
存款交易**回退 (revert)** — 导致拒绝服务,而不是保护。
教训:验证**经济**属性(是否有人能以牺牲其他用户为代价让自己过得更好?),而不是表面上的不变性(例如“份额非零”)。
## 练习 #2 — `donateToReserves` 缺少健康检查 (Euler 类型)
`src/EulerStyleVault.sol` · `src/SafeEulerVault.sol` · `test/EulerDonateExploit.t.sol`
### 真实事件 — Euler Finance, 2023-03, ~$197M
`donateToReserves`(在 EIP-14 中添加)减少了用户的 eToken 抵押品**而没有
进行健康检查**。攻击者利用闪电贷杠杆化一个头寸,进行捐赠将自己
推至资不抵债,然后**从第二个账户自我清算**;Euler
针对深度资不抵债头寸的动态软清算折扣,让清算者得以远低于偿还债务的价格获取抵押品,而闪电贷
经济性使得净利润超过了攻击成本 — 从而抽干了其他存款者。 —
已确认 (BlockSec, Cyfrin, Hacken, Omniscia;存在公开 PoC)。
### 最小化模型展示了什么
该 PoC 使用故意设定的小额数字重现了*机制类别*:
1. `mint` 自我杠杆化(相等的抵押品 + 由空头支票支撑的债务 — Euler 的
eToken/dToken 自我借贷)。HF 保持 ≥ 1;头寸看起来很健康。
2. `donateToReserves` 减少抵押品**没有健康检查** → 账户将
*自身*推入资不抵债状态(实际的 bug)。
3. `liquidate` 给予一种折扣,违规者资不抵债的程度越深,折扣就越大,因此
捐赠膨胀的头寸被廉价地夺取。
4. 攻击者同时控制违规者 (A1) 和清算者 (A2);被夺取的
幻影抵押品被赎回为**属于其他存款者(LP 池)的真实资产**。
`test_vulnerable_donateToReserves_drainsLPs`:攻击者 (A1+A2) 净利润 **`+100e18`,
完全来源于 LP 池**(金库 `1000e18 → 900e18`)。注意:在*本
模型*中,利润来自于**应用于幻影膨胀抵押品的折扣**;捐赠本身是沉没成本(它不会被“收回”,且
净利润小于捐赠)。“清算奖金超过捐赠”的描述属于*真实*黑客攻击的闪电贷经济性,而不是这些
玩具数字。
### 修复方案 — 在任何减少余额的操作后重新断言偿付能力
`SafeEulerVault.donateToReserves` 增加了一行代码 — `require(collateral >= debt)` —
这正是 Euler 发布的检查。有了它,没有任何账户能让*自身*变得可清算,
因此折扣化的自我清算永远不会被触发。
`test_safe_donateToReserves_revertsAndProtectsLPs` 证明了捐赠会回退,并且
LP 资金保持完好。(已验证:在 `withdraw`、`donate` 都受到限制且 `mint`
无法将 HF 推至 <1 的情况下,`collateral >= debt` 在任何操作顺序下都是不变式 —
`liquidate` 是永远无法到达的。)
## 练习 #3 — 预言机 (现货价格) 操纵 + 过时价格清算
`src/MockAMM.sol` · `src/MockOracle.sol` · `src/VulnerableLendingMarket.sol` · `src/SafeLendingMarket.sol` · `test/OracleManipulation.t.sol`
### Bug 类型
`VulnerableLendingMarket` 从 `MockAMM.spotPrice()` 获取抵押品价值 — 这是一个攻击者可以在单笔交易内(通过闪电贷进行交换)操纵的链上
现货价格。测试了两个方向:
- **砸盘 → 清算** (`test_vulnerable_spotManipulation_drainsViaLiquidation`):
将抵押品倾销到 AMM 中,将现货价格从 100 砸到 51,将**健康的**受害者转变为
可清算状态,以折扣价夺取其抵押品,然后反向交换。按
真实价格计算净赚 **+5000e18** = 受害者被夺取的抵押品减去偿还的债务(已验证是真实的转账,而不是 AMM 往返造成的假象 — 无手续费的往返净利润约为 0)。
- **拉盘 → 过度借贷** (`test_vulnerable_spotManipulation_pumpToOverBorrow`):将现货价格拉高,利用幻影膨胀的抵押品进行借贷,恢复价格并离开,给协议留下
**坏账** (债务 > 抵押品价值)。这是 Mango
Markets ($114M) 的方向。
该家族的真实案例(它们在子变体上有所不同):Harvest ($34M, 2020-10,
闪电贷操纵 Curve 现货 — 最接近的匹配),Mango Markets ($114M, 2022-10,
拉盘过度借贷),Cream ($130M, 2021-10, 份额价格预言机 — 更接近练习 #1)。 —
数字/日期已确认。
### 修复方案 — 及其故意未覆盖的内容
`SafeLendingMarket` 读取抗操纵的数据源 (`MockOracle`,一个 TWAP /
Chainlink **替代品**),因此攻击者的交换无法移动价格,**并**强制执行
过时界限 (`MAX_STALENESS = 1h`)。测试证明相同的攻击使得
受害者保持健康 (`HEALTHY` 回退),且过时的数据源会回退 (`STALE`)。
## 练习 #4 — `liquidityIndex` 舍入漂移 (Radiant 类型)
`src/VulnerableScaledMarket.sol` · `src/SafeScaledMarket.sol` · `test/IndexRoundingExploit.t.sol`
### Bug 类型
一个 Aave 风格的市场存储 `scaledBalance` 和不断增长的 `liquidityIndex`;你的
基础余额是 `rayMul(scaledBalance, index)`。Aave 的 `rayMul`/`rayDiv` 使用
**半进一法** 舍入,这违反了往返不变式
`rayMul(rayDiv(amount, index), index) <= amount`:在 `index = 2·RAY` 时,存入
`1 wei` 会获得 **2** 的余额。提取盈余并重复
存款/提款循环就会抽干池子 — 这正是字面意义上的 Radiant 2024-01 攻击。再高一点 (`index = 3·RAY`),相同的存款会*向下舍入为 0*,存款者损失了
该 wei (`test_vulnerable_highIndex_depositRoundsToZero`) — 相同的原语也会让存款者损失。
### 修复方案 — 双向采用有利于协议的舍入 (+ 注资市场)
`SafeScaledMarket` 将铸造的 scaled 余额**向下**舍入 (`rayDivDown`),将销毁的 scaled 余额**向上**舍入
(`rayDivUp`),因此没有存款会被超额记录,也没有提款可以
零销毁。`testFuzz_safe_roundTripNeverProfits` 在模糊测试的
`(amount, index)` 范围内锁定了该不变式。
## 练习 #5 — 注资防御 (复利 `liquidityIndex` 舍)
`src/SeedableScaledMarket.sol` · `test/SeedingDefense.t.sol`
### Radiant 漏洞影响巨大的原因,以及 Aave 发布的防御方案
练习 #4 的泄漏约为 1 wei/操作。本次练习增加了真实的利息计算 —
`accrueInterest(x)` 执行 `liquidityIndex += x*RAY/totalScaled` — 因此**膨胀指数的成本与 `totalScaled` 成正比**:
- **未注资** (totalScaled = dust = 1):约 2 个代币的利息将指数爆炸性提升至
`2e18·RAY`,半进一法 `deposit(1e18)/withdraw(2e18)` 循环每轮净赚 `+1e18` —
`test_unseeded_cheapIndexInflation_drainsPool` 从一个 100 个代币的池子中抽干了 **10 个代币**。
- **已注资** (`seed()` 铸造了大量死亡的 `totalScaled`):*相同*的利息只能将指数移动
`< 10·RAY`,泄漏保持在灰尘级别,收割循环变成绝对损失
(`test_seeded_blocksCheapIndexInflation`)。这是 Aave 有文档记载的修复方案 — *“在创建新市场时包含一笔初始存款。”*
### 修复必须是原子的
`seed()` 受到严格保护,仅限**原始**市场 (`totalScaled == 0 && index == RAY`),因此抢先接触该市场的攻击者会使操作者的 `seed()` 回退
(`test_seed_revertsAfterFrontRun`) — 成功注资的市场保证在注资时处于原始状态。
## 审计威胁模型清单 — 借贷 / 清算垂直领域
在任何借贷/清算协议上都值得过一遍的类型,大致按
真实世界的发现频率排列。上面的练习 #1/#1b/#2/#3/#4/#5 涵盖了**通胀**、
**偿付能力检查**、**预言机**、**指数舍入** 和 **注资** 行;其余部分是下一步要构建的空白。
**预言机 (最常见的借贷漏洞利用类型)**
- [ ] 在需要抗操纵 TWAP 的地方使用了现货价格 (AMM 储备 / `getReserves`)?
可以通过闪电贷移动?
- [ ] Chainlink:过时检查 (`updatedAt`/heartbeat),`answeredInRound`,最小/最大
边界,L2 sequencer-uptime 是否都检查了?过时的轮次 → 错误价格清算。
**份额 / 指数会计**
- [ ] `totalAssets` 是否来自攻击者可以通过转账膨胀的**实时余额**?
- [ ] 任何**在不铸造份额的情况下增加资产**的路径(捐赠,空投,rebase,
费用累计)?在 `totalSupply == 0 / ~dust` 时的份额计算?存款舍入为 0?
- [x] 新市场在公开使用前是否已**注资** (死份额 / 最小流动性)? — *练习 #5*。
注资成本 ∝ totalScaled;**原子地**注资 (受原始状态保护) 且相对于攻击者的利息预算注资量要**大**。
- [x] 注资不足市场上累积的**利息 / `liquidityIndex` 舍入漂移** (实际的 Radiant 机制) — *练习 #4*。往返计算
`rayMul(rayDiv(a,idx),idx) <= a`?销毁舍入 UP (无零销毁退出)?已注资?
**偿付能力 / 清算**
- [ ] 是否**每个减少余额的操作** (提款,捐赠,转账,赎回)
在之后重新断言 `collateral >= debt`? (Euler bug,泛化)
- [ ] 清算**奖金 / close-factor** 计算:奖金能否将头寸推至*进一步*
资不抵债?部分清算舍入?自我清算利润?
- [ ] **坏账社会化** / 资不抵债金库的赎回竞赛 — 先到先得?
**重入**
- [ ] 抵押品/清算**钩子** (ERC777/ERC677 `onTokenTransfer`),
在读取 HF/价格时的只读重入,存款/借贷的 CEI 排序。
**市场状态**
- [ ] 冻结/暂停市场绕过,借款上限 / 隔离模式 / LTV 绕过。
**通用 — 尾部低危机械式扫描 (对每个合约运行)**
*在一次盲审转账测试 (参见 `TRANSFER-TESTS.md`) 漏掉了大约一半的 Low 尾部后添加。
这些方法成本低且召回率高 — 每次都机械地执行它们:*
- [ ] 未检查的 ERC20 `transfer` / `transferFrom` / `approve` 返回值 (使用 SafeERC20 — 有些代币返回 false 而不是回退)。
- [ ] 每个 constructor / initializer / setter 中的零地址 / 零值验证。
- [ ] 授权清理 — 使用后将配额重置为 0;对外部 router 没有悬空的/无限的授权。
- [ ] 两步所有权转移 (`Ownable2Step`) 对比容易误操作的单步 `transferOwnership`。
- [ ] 在任何可升级/实现合约的 constructor 中使用 `_disableInitializers()`。
- [ ] 每个所有者设置的配置参数都有合理的 min/max 边界 (无无限制的费用/限制/持续时间)。
- [ ] 每个改变状态的 setter 都会发出 Event。
- [ ] 未使用/废弃的 helper 函数暗示存在空白 (例如,一个未使用的 `getRoundData()` 返回了主逻辑路径所忽略的过时数据)。
**通用 — 严重性校准 (像评委一样进行评级)**
*在同一个测试将 3 个发现中的 1 个评级为 High 而实际上只有 1 个是 High 后添加。严重性受上下文限制,
而不是由 bug 的名字决定的:*
- 谁**触发**了它?无权限的攻击者 → 较高;`onlyOwner` / 所有者自我伤害 → 较低。
- 是否有**第三方受害者**,还是只有所有者自己的混合资金? — 仅限所有者的漏洞给予
折扣,**但当** bug 造成坏账 / 资不抵债 / 第三方损失(借贷池,LP,其他存款者)时**则不然**:即使是由*所有者*触发的,协议资不抵债也是 **High**。*(校准训练教训 — 评分标准最初低估了一个在 Aave 上留下坏账的 unwind bug。)*
- 缺陷是在**真实的执行路径**上,还是仅仅在主入口点从不调用的 helper / `view` 中?
- 是否有**事后兜底** (原子回退,健康因子检查) 限制了已实现的损失?
- 它是否需要**外部前提条件** (数据源冻结,sequencer 宕机)?通常是 Medium,而不是 High。
- **直接资金损失 / 资不抵债 = High。** 恶意破坏 / 有条件的 / 仅限所有者的 / 需要前提条件的 = Medium–Low。
## 下一次练习 (按最高价值优先)
1. **加固练习 #3 的预言机修复** — 将清单行中的其余内容添加到
`SafeLendingMarket`:`answeredInRound`,min/max 边界,L2 sequencer-uptime;外加一个
真实的多区块 TWAP 模拟,以展示为什么同区块操纵会失败。
2. **清算 close-factor / 部分清算 + 只读重入** — 剩余的
`Solvency/liquidation` 和 `Reentrancy` 清单行。
3. **忠实的 Euler fork** — 预黑客攻击主网区块的真实 Euler 模块。需要
Ethereum **archive RPC** (在环境中设置为 `ETH_RPC_URL`;切勿提交它)。
较重 (多模块代理);最小化的练习 #2 是入门阶梯。
## 运行
```
forge test -vv
```
## 来源
- Radiant 官方事后分析推文 — https://x.com/RDNTCapital/status/1742638364933714112
- QuillAudits — Radiant Capital 黑客分析 — https://www.quillaudits.com/blog/hack-analysis/radiant-capital-hack
- Cyfrin — Euler Finance 黑客分析 — https://www.cyfrin.io/blog/how-did-the-euler-finance-hack-happen-hack-analysis
- BlockSec — Euler Finance 事件 — https://blocksec.com/blog/euler-finance-incident-the-largest-hack-of-2023
- OpenZeppelin ERC-4626 / EIP-4626 通胀攻击缓解 (虚拟份额)
- 预言机价格操纵类 — Mango Markets (~$114M, 2022-10, SEC/CFTC 文件), Cream Finance (~$130M, 2021-10, Halborn), Harvest (~$34M, 2020-10, SlowMist)
标签:Foundry, Maven, 以太坊, 区块链安全, 安全培训, 智能合约审计, 漏洞验证