Appearance
揭秘“不可阻挡”的漏洞:免费闪电贷背后的巨大风险
在区块链的世界里,安全至上。然而,即使是精心设计的DeFi协议,也可能隐藏着意想不到的“定时炸弹”。今天,我们将带您深入一个名为“Unstoppable”的CTF挑战,揭开一个关于闪电贷的致命漏洞,以及它如何让一个价值百万DVT代币的金库“停止运转”。
“不可阻挡”的诱惑:免费闪电贷的灰乐期
想象一下,一个DVT代币的金库,拥有百万级的代币存款,却慷慨地提供免费的闪电贷!是的,你没有看错。这听起来像是一个绝佳的盈利机会,但开发者们深知“天上不会掉馅饼”,这只是一个“灰乐期”(Grace Period)。在这个时期之后,金库将变得更加严格,不再提供如此优惠的服务。
为了确保在正式完全开放前没有潜在的漏洞,开发者们在测试网上部署了一个“监控合约”(UnstoppableMonitor)。这个合约的任务是持续检测闪电贷功能的“活跃度”(Liveness)。一旦闪电贷功能出现问题,监控合约就会发出警报。
挑战:让“不可阻挡”的金库停摆
我们的任务很简单,但意义重大:证明你可以利用漏洞,让这个原本“不可阻挡”的金库停止提供闪电贷服务。
初看起来,这似乎难以置信。金库拥有巨额资产,闪电贷似乎是其核心功能,怎么可能轻易被“叫停”?而我们的起始资金,仅仅是10个DVT代币。
漏洞的根源:ERC4626的“陷阱”
要理解这个漏洞,我们需要深入研究 UnstoppableVault 合约。这个合约是一个遵循ERC4626标准的代币金库,它允许用户存入资产(DVT代币)并获得相应的份额。
关键在于 UnstoppableVault 的 flashLoan 函数。它在执行闪电贷时,执行了以下操作:
- 转出代币:将请求的代币(amount)转给闪电贷的接收者。
- 执行回调:调用接收者的
onFlashLoan函数,让接收者处理代币。 - 拉回代币和利息:从接收者那里取回原始借款金额(amount)加上协议的闪电贷利息(fee)。
- 支付利息:将利息(fee)支付给指定的
feeRecipient。
而 UnstoppableMonitor 合约,作为闪电贷的接收者,它的 onFlashLoan 函数会执行以下操作:
- 授权:对接收到的代币进行
approve操作,允许金库提取。 - 完成回调:返回一个特定的哈希值,表示闪电贷回调成功。
致命的逻辑缺陷
让我们仔细审视 UnstoppableVault 的 flashLoan 函数中的这段关键逻辑:
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。
然而,问题出在 UnstoppableMonitor 的 onFlashLoan 函数中:
solidity
ERC20(token).approve(address(vault), amount);
它只授权了 amount,也就是借款的本金,并没有授权提取利息 (fee)!
当 UnstoppableVault 尝试执行 ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee); 时,由于 UnstoppableMonitor 的 approve 调用并没有包含 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() 异常。
而 UnstoppableMonitor 的 checkFlashLoan 函数,正是利用了这个异常处理机制:
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 块中的代码:
- 暂停金库:调用
vault.setPause(true),让金库停止一切操作。 - 转移所有权:调用
vault.transferOwnership(owner),将金库的所有权转移给监控合约的拥有者(也就是我们)。
最终的解决方案
要实现这个目标,我们需要在 UnstoppableMonitor 的 checkFlashLoan 函数被调用之前,消耗掉金库中除了我们自己拥有的10个DVT之外的所有DVT代币。
这样一来,当 UnstoppableVault 尝试从 UnstoppableMonitor 中拉回 amount + fee 时,它会发现 UnstoppableMonitor 的DVT余额不足以支付 amount,进而导致 safeTransferFrom 失败,触发异常,最终让金库停摆。
攻击步骤:
- 消耗金库 DVT:我们将我们持有的10个 DVT 代币,全部转移到
UnstoppableVault合约的地址。 - 触发闪电贷失败:然后,我们调用
monitorContract.checkFlashLoan(100e18)。此时,金库的flashLoan函数会尝试借出100e18个DVT,但由于我们之前消耗了大部分DVT,并且UnstoppableMonitor的onFlashLoan函数仅授权了amount而不是amount + fee,safeTransferFrom就会失败。
代码实现(在 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世界里,每一个细节都至关重要,任何一个小小的逻辑疏忽,都可能带来灾难性的后果。