Skip to content
On this page

揭秘“不可阻挡”的漏洞:免费闪电贷背后的巨大风险

在区块链的世界里,安全至上。然而,即使是精心设计的DeFi协议,也可能隐藏着意想不到的“定时炸弹”。今天,我们将带您深入一个名为“Unstoppable”的CTF挑战,揭开一个关于闪电贷的致命漏洞,以及它如何让一个价值百万DVT代币的金库“停止运转”。

“不可阻挡”的诱惑:免费闪电贷的灰乐期

想象一下,一个DVT代币的金库,拥有百万级的代币存款,却慷慨地提供免费的闪电贷!是的,你没有看错。这听起来像是一个绝佳的盈利机会,但开发者们深知“天上不会掉馅饼”,这只是一个“灰乐期”(Grace Period)。在这个时期之后,金库将变得更加严格,不再提供如此优惠的服务。

为了确保在正式完全开放前没有潜在的漏洞,开发者们在测试网上部署了一个“监控合约”(UnstoppableMonitor)。这个合约的任务是持续检测闪电贷功能的“活跃度”(Liveness)。一旦闪电贷功能出现问题,监控合约就会发出警报。

挑战:让“不可阻挡”的金库停摆

我们的任务很简单,但意义重大:证明你可以利用漏洞,让这个原本“不可阻挡”的金库停止提供闪电贷服务。

初看起来,这似乎难以置信。金库拥有巨额资产,闪电贷似乎是其核心功能,怎么可能轻易被“叫停”?而我们的起始资金,仅仅是10个DVT代币

漏洞的根源:ERC4626的“陷阱”

要理解这个漏洞,我们需要深入研究 UnstoppableVault 合约。这个合约是一个遵循ERC4626标准的代币金库,它允许用户存入资产(DVT代币)并获得相应的份额。

关键在于 UnstoppableVaultflashLoan 函数。它在执行闪电贷时,执行了以下操作:

  1. 转出代币:将请求的代币(amount)转给闪电贷的接收者。
  2. 执行回调:调用接收者的 onFlashLoan 函数,让接收者处理代币。
  3. 拉回代币和利息:从接收者那里取回原始借款金额(amount)加上协议的闪电贷利息(fee)。
  4. 支付利息:将利息(fee)支付给指定的 feeRecipient

UnstoppableMonitor 合约,作为闪电贷的接收者,它的 onFlashLoan 函数会执行以下操作:

  1. 授权:对接收到的代币进行 approve 操作,允许金库提取。
  2. 完成回调:返回一个特定的哈希值,表示闪电贷回调成功。

致命的逻辑缺陷

让我们仔细审视 UnstoppableVaultflashLoan 函数中的这段关键逻辑:

solidity
        // pull amount + fee from receiver, then pay the fee to the recipient
        ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
        ERC20(_token).safeTransfer(feeRecipient, fee);

在这里,金库会尝试从 receiver(即我们的 UnstoppableMonitor 合约)中拉回 amount + fee。这其中包含了借款的本金 amount 和利息 fee

然而,问题出在 UnstoppableMonitoronFlashLoan 函数中:

solidity
        ERC20(token).approve(address(vault), amount);

只授权amount,也就是借款的本金,并没有授权提取利息 (fee)!

UnstoppableVault 尝试执行 ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee); 时,由于 UnstoppableMonitorapprove 调用并没有包含 fee 的额度,这笔交易就会失败

如何让金库停摆?

失败的交易意味着什么?根据 UnstoppableVault 合约的逻辑:

solidity
        // callback must return magic value, otherwise assume it failed
        if (
            receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data)
                != keccak256("IERC3156FlashBorrower.onFlashLoan")
        ) {
            revert CallbackFailed();
        }

如果在回调之后,金库未能成功从接收者那里拉回代币和利息,它就会认为整个闪电贷操作失败,并触发 CallbackFailed() 异常。

UnstoppableMonitorcheckFlashLoan 函数,正是利用了这个异常处理机制:

solidity
        try vault.flashLoan(this, asset, amount, bytes("")) {
            emit FlashLoanStatus(true);
        } catch {
            // Something bad happened
            emit FlashLoanStatus(false);

            // Pause the vault
            vault.setPause(true);

            // Transfer ownership to allow review & fixes
            vault.transferOwnership(owner);
        }

vault.flashLoan() 内部的 safeTransferFrom 失败时,就会抛出 CallbackFailed() 异常。checkFlashLoan 函数捕获到这个异常后,会执行 catch 块中的代码:

  1. 暂停金库:调用 vault.setPause(true),让金库停止一切操作。
  2. 转移所有权:调用 vault.transferOwnership(owner),将金库的所有权转移给监控合约的拥有者(也就是我们)。

最终的解决方案

要实现这个目标,我们需要在 UnstoppableMonitorcheckFlashLoan 函数被调用之前,消耗掉金库中除了我们自己拥有的10个DVT之外的所有DVT代币

这样一来,当 UnstoppableVault 尝试从 UnstoppableMonitor 中拉回 amount + fee 时,它会发现 UnstoppableMonitor 的DVT余额不足以支付 amount,进而导致 safeTransferFrom 失败,触发异常,最终让金库停摆。

攻击步骤:

  1. 消耗金库 DVT:我们将我们持有的10个 DVT 代币,全部转移到 UnstoppableVault 合约的地址
  2. 触发闪电贷失败:然后,我们调用 monitorContract.checkFlashLoan(100e18)。此时,金库的 flashLoan 函数会尝试借出100e18个DVT,但由于我们之前消耗了大部分DVT,并且 UnstoppableMonitoronFlashLoan 函数仅授权了 amount 而不是 amount + feesafeTransferFrom 就会失败。

代码实现(在 UnstoppableChallenge 合约中):

solidity
    function test_unstoppable() public checkSolvedByPlayer {
        // Step 1: Transfer our initial DVT balance to the vault.
        // This will drain the vault's total assets to the point where
        // it cannot fulfill the flash loan's repayment (amount + fee).
        token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);

        // Step 2: Trigger the checkFlashLoan.
        // This will attempt a flash loan, which will fail during the repayment phase
        // because the monitor contract hasn't approved enough funds for the fee.
        // This failure will cause the vault to pause and transfer ownership.
        // The assertion checks if the total assets (which are now just the initial 10 DVT)
        // are NOT equal to the shares, indicating a state where repayment failed.
        assertNotEq(vault.totalAssets(), vault.convertToShares(vault.totalSupply()));
    }

结论

“Unstoppable”挑战巧妙地利用了ERC4626标准下的闪电贷机制,结合合约状态(approve 额度不足)和异常处理,成功地让一个本应“不可阻挡”的金库停下了脚步。这个漏洞提醒我们,在DeFi世界里,每一个细节都至关重要,任何一个小小的逻辑疏忽,都可能带来灾难性的后果。


Built with AiAda