Appearance
止まらない?いや、止められる!:Unstoppable Vault 攻略ガイド
Damn Vulnerable DeFi v4 の CTF チャレンジ「Unstoppable」に挑戦しましょう!このチャレンジでは、100万 DVT トークンが預けられた、一見安全に見える「Unstoppable Vault」のフラッシュローン機能を停止させることを目指します。
チャレンジの概要
「Unstoppable Vault」は、猶予期間中、無料のフラッシュローンを提供しています。しかし、開発者は完全なパーミッションレス化の前に、テストネットでライブベータを実施することにしました。そのために、フラッシュローン機能の稼働状況を監視するための「UnstoppableMonitor」コントラクトが用意されています。
プレイヤーは、初期残高10 DVT トークンからスタートし、このフラッシュローン機能を停止させる必要があります。つまり、Vault がフラッシュローンを提供できなくなるように仕向けるのです。
Vulnerability の探求
「UnstoppableVault.sol」のコードを見てみましょう。
solidity
/**
* @inheritdoc ERC4626
*/
function totalAssets() public view override nonReadReentrant returns (uint256) {
return asset.balanceOf(address(this));
}
/**
* @inheritdoc IERC3156FlashLender
*/
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;
}
注目すべきは flashLoan 関数内のこの部分です。
solidity
uint256 balanceBefore = totalAssets();
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
ここで、flashLoan の実行前に、totalAssets() (Vault が実際に保有しているトークン量) と convertToShares(totalSupply) (Vault の発行済みシェア数から換算されるべきトークン量) が一致するかどうかをチェックしています。ERC4626 標準に準拠していれば、通常この二つは一致するはずです。
しかし、もし何らかの方法で totalAssets() の値だけが convertToShares(totalSupply) よりも少なくなる 状況を作り出せれば、この revert InvalidBalance() が発動し、フラッシュローンの実行が阻止されます。
では、どうすれば totalAssets() だけを減らすことができるでしょうか?
「UnstoppableMonitor.sol」には、checkFlashLoan という関数があります。
solidity
function checkFlashLoan(uint256 amount) external onlyOwner {
require(amount > 0);
address asset = address(vault.asset());
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);
}
}
この checkFlashLoan 関数は、フラッシュローンが失敗した場合、Vault を一時停止し、所有権をコントラクトのオーナー(この場合はデプロイアー)に移譲します。
ここで、UnstoppableMonitor コントラクトの onFlashLoan 関数を見てみましょう。
solidity
function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata)
external
returns (bytes32)
{
if (initiator != address(this) || msg.sender != address(vault) || token != address(vault.asset()) || fee != 0) {
revert UnexpectedFlashLoan();
}
ERC20(token).approve(address(vault), amount); // <-- ここに注目!
return keccak256("IERC3156FlashBorrower.onFlashLoan");
}
onFlashLoan 関数内で、ERC20(token).approve(address(vault), amount); が実行されています。これは、モニターコントラクトが受け取ったフラッシュローン額を Vault に承認する操作です。
攻略法
totalAssets()を減らす: Vault が保有するトークン (asset) を、UnstoppableMonitor側でフラッシュローンを受け取った直後に、Vault 自身に送り返すのではなく、Vault の外部に転送する 必要があります。 しかし、onFlashLoanのコールバック内で、ERC20(token).approve(address(vault), amount);が実行されると、Vault はSafeTransferLibを使用して receiver (この場合はモニターコントラクト) からトークンを引き出し、その処理の中でtotalAssets()が更新されます。ここで鍵となるのは、
UnstoppableVaultのflashLoan関数に戻り、convertToShares(totalSupply)のチェックの直前にtotalAssets()が取得されている点です。このギャップを突くために、プレイヤーは
INITIAL_PLAYER_TOKEN_BALANCE(10 DVT) を Vault に預け直す だけで十分です。UnstoppableVaultのdeposit関数は、afterDeposit内部でwhenNotPausedをチェックします。- しかし、
deposit関数の内部で、afterDepositが呼び出されるのは、_mintの後です。 UnstoppableVaultの_mint関数は、_updateを呼び出しますが、_updateの中でafterDepositが呼び出されるわけではありません。
しかし、
deposit関数では、asset.transferFrom(msg.sender, address(this), amount);が実行され、Vault のtotalAssets()が更新されます。deposit関数が実行された後、Vault に預けられたINITIAL_PLAYER_TOKEN_BALANCEは Vault のtotalAssetsに計上されます。ところが、
UnstoppableVaultのdeposit関数は、_update関数を呼び出します。そして_update関数は、afterDepositを呼び出しますが、afterDepositはnonReentrantであり、whenNotPausedをチェックします。ここで重要なのは、
UnstoppableVaultのdeposit関数が、_updateを呼び出す前にasset.transferFrom(msg.sender, address(this), amount);を実行し、totalAssets()を更新する点です。この
totalAssets()の更新と、convertToShares(totalSupply)のチェックの間のわずかな時間差、もしくはdeposit関数自体のロジックの隙間を突くことができれば、totalAssets()の値がconvertToShares(totalSupply)よりも大きくなってしまう状況を作り出せます。テストケースのコード:
solidityfunction test_unstoppable() public checkSolvedByPlayer { // プレイヤーが保持する初期トークンを Vault に預け直す token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE); // これにより、totalAssets() が totalSupply() から換算される値よりも大きくなる assertNotEq(vault.totalAssets(), vault.convertToShares(vault.totalSupply())); }このコードでは、プレイヤーが初期に持っていた10 DVT トークンを Vault に預け直します。これにより、Vault の
totalAssets()は増加しますが、convertToShares(totalSupply)の計算結果は、この追加の預け入れを直接反映しない(あるいは、deposit関数内のロジックの隙間により、一時的に不整合が生じる)ため、二つの値に差が生じます。この差が生じると、
UnstoppableVaultのflashLoan関数内でif (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();が発動し、フラッシュローンが失敗します。フラッシュローンの失敗と監視: フラッシュローンが失敗すると、「UnstoppableMonitor」コントラクトの
checkFlashLoan関数内のcatchブロックが実行されます。これにより、Vault は一時停止 (setPause(true)) され、所有権はデプロイアーに移譲されます。
まとめ
「Unstoppable」チャレンジは、ERC4626 標準の挙動と、フラッシュローンにおけるコールバック処理のタイミングを理解することが鍵となります。プレイヤーが持つ少量のトークンを戦略的に Vault に預け直すことで、Vault の内部状態に一時的な不整合を作り出し、フラッシュローンの実行を阻止し、最終的に Vault を停止させることに成功します。
これであなたも、Unstoppable Vault を止めることができるはずです!