
Chrome Renderer の1day RCE:非同期スタックトレースの型の混乱による(v8ctf submission)
この脆弱性により、リモートの攻撃者がChromeレンダラプロセス内で任意のコードを実行できるようになりました。
非同期スタックトレース処理コードにおいて、型チェックが不十分でした。
これにより、FunctionContext と NativeContext の間で型混乱が発生し、JSGlobalProxy->hash 値への不正アクセスが引き起こされます。
ヒープスプレーにより、攻撃者は偽の非同期スタックフレームを注入し、fakeobj プリミティブを構築することができました。
fakeobj プリミティブを使用して、攻撃者はChromeレンダラプロセスで任意のコード実行を達成しました。
詳細はTyphoonCon 2024のスライドをご覧ください。
非同期処理はJavaScriptの最も重要な機能の一つです。 以前は、非同期関数がエラースタックにキャプチャされないため、エラースタックを使用した非同期コードのデバッグが困難でした。 中断された非同期関数はコールスタックではなくイベントループのコールバックキューに保存されるため、エラースタックには非同期関数が含まれません。 この問題を解決するために、V8はエラースタックに非同期関数をキャプチャする「非同期スタックトレース」機能を提供しています(V8 v7.3以降デフォルトで有効)。(v8 blog、v8 docs)
「Promise.all Resolve Element Closure」は、Promise.all 関数内で入力されたPromiseを解決するためのヘルパー関数です。
Promise.all 関数はPromiseの配列を受け取り、すべての入力Promiseが解決されたときに解決されるPromiseを返します。
「Promise.all Resolve Element Closure」は、Promise.all 関数内の各入力Promiseの解決ハンドラです。
この関数の役割は、入力Promiseを解決し、その履行値を結果の配列に格納することです。
この関数について注意すべき点が2つあります:
FunctionContext を持ち、呼び出された後は NativeContext を持ちます。(v8 code)バグクラス: FunctionContext と NativeContext の間の型混乱
脆弱性の詳細:
この脆弱性は、既に実行された「Promise.all Resolve Element Closure」関数または同様の内部ビルトイン関数を使用して非同期スタックトレースをキャプチャすることでトリガーできます。 このエクスプロイトでは、例として「Promise.all Resolve Element Closure」関数を使用しました。
JavaScriptコードでエラーがスローされると、V8はスタックからエラースタックをキャプチャし、現在のマイクロタスクから非同期スタックフレームを追加します [1].
CallSiteBuilder builder(isolate, mode, limit, caller);
VisitStack(isolate, &builder);
// If --async-stack-traces are enabled and the "current microtask" is a
// PromiseReactionJobTask, we try to enrich the stack trace with async
// frames.
if (v8_flags.async_stack_traces) {
CaptureAsyncStackTrace(isolate, &builder);
}
CaptureAsyncStackTrace 関数 [2] はPromiseチェーンを検索し、非同期呼び出しの種類(例:await、Promise.all、Promise.any)に応じて非同期スタックフレームを追加します。
以下は、Promise.all ケースを処理する CaptureAsyncStackTrace 関数のスニペットです:
} else if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kPromiseAllResolveElementClosure)) {
Handle<JSFunction> function(JSFunction::cast(reaction->fulfill_handler()),
isolate);
Handle<Context> context(function->context(), isolate);
Handle<JSFunction> combinator(context->native_context()->promise_all(),
isolate);
builder->AppendPromiseCombinatorFrame(function, combinator);
// Now peak into the Promise.all() resolve element context to
// find the promise capability that's being resolved when all
// the concurrent promises resolve.
int const index =
PromiseBuiltins::kPromiseAllResolveElementCapabilitySlot;
Handle<PromiseCapability> capability(
PromiseCapability::cast(context->get(index)), isolate);
if (!IsJSPromise(capability->promise())) return;
promise = handle(JSPromise::cast(capability->promise()), isolate);
} else if (
Promiseチェーンを検索する際、reaction->fulfill_handler が「Promise.all Resolve Element Closure」ビルトイン関数である場合、非同期プロミスコンビネータフレームをエラースタックに追加します。
次に、function->context->capability->promise にアクセスして次のPromiseに移動します。
問題は、関数が「Promise.all Resolve Element Closure」関数がまだ実行されていないと仮定していることです。
もし「Promise.all Resolve Element Closure」関数が既に実行されている場合、コンテキストが FunctionContext から NativeContext に変更されます。
これにより、CaptureAsyncStackTrace 関数内で FunctionContext と NativeContext の間の型混乱が発生します。
PoCの作成:
脆弱性をトリガーする戦略は次のとおりです:
FunctionContext から NativeContext に変更します。JSスクリプトレベルで「Promise.all Resolve Element Closure」関数を取得するために、Promise.all の同期プロミス解決パターンを使用しました。
このパターンはtest262のテストケースから借用しました。
関数を明示的に呼び出した後、脆弱性をトリガーするために、zero-cost async stack trace document のサンプルコードを使用して新しいPromiseチェーンを準備し、内部ビルトイン関数をそのうちの1つのPromiseの履行ハンドラとして設定しました。
最後に、エラーがスローされると、既に実行された「Promise.all Resolve Element Closure」関数を履行ハンドラとして非同期スタックトレースがキャプチャされ、FunctionContext と NativeContext の間の型混乱が発生します。
PoCコードはこちら: poc.js
(エクスプロイトプリミティブ、エクスプロイト戦略、エクスプロイト手法、エクスプロイトフローの用語はこちらで定義されています。)
エクスプロイトプリミティブ: fakeobj プリミティブ
エクスプロイト戦略: 型混乱バグから fakeobj プリミティブを構築するために、次の戦略を使用しました:
Error.prepareStackTrace と getThis メソッドを使用して偽のオブジェクトを取得します。このバグにより、CaptureAsyncStackTrace 関数内で FunctionContext と NativeContext の間の型混乱が発生します。
次の非同期スタックフレームを構築するために、Context->PromiseCapability->JSPromise にアクセスします。
バグがトリガーされると、NativeContext->JSGlobalProxy->hash にアクセスします。
バグを悪用するために、ハッシュ値をJSPromiseオブジェクトポインタとして使用しました。
以下のハッシュ生成関数から、ハッシュ値が (0, 0xfffff) の範囲であることがわかります:
int Isolate::GenerateIdentityHash(uint32_t mask) {
int hash;
int attempts = 0;
do {
hash = random_number_generator()->NextInt() & mask;
} while (hash == 0 && attempts++ < 30);
return hash != 0 ? hash : 1;
}
pwndbg> p/x mask
$1 = 0xfffff
ハッシュ値はSMIタグ付けされているため、メモリ上では hash << 1 として格納されます。
したがって、メモリ上の値は偶数で (0, 0xfffff << 1) の範囲になります。
ランダムなハッシュ番号を有効なJSPromiseオブジェクトポインタに一致させるために、2つの制約があります:
これらの制約に従い、アドレスを奇数にするために8ビット左シフトしたJSPromiseオブジェクトでヒープをスプレーし、範囲 (0, 0xfffff << 1) に収まるように小さなforループを使用しました。
ここでランダムなハッシュ番号を有効なオブジェクトポインタに一致させるのは、かなり確率が低いように見えます。 信頼性を高めるために、iframeテクニック を使用しました。 Chromeのサイト分離により、異なるウェブサイトのページは異なるプロセスで実行されます。 そのため、異なるドメインのiframeを作成し、メインプロセスのクラッシュを避けるためにiframe内でエクスプロイトを実行しました。
Promiseチェーンの次のPromiseに移動した後、プログラムはPromiseの有効性をチェックし、非同期呼び出しの種類に応じて非同期スタックフレームを追加しようとします。
while (!builder->Full()) {
// Check that the {promise} is not settled.
if (promise->status() != Promise::kPending) return;
// Check that we have exactly one PromiseReaction on the {promise}.
if (!IsPromiseReaction(promise->reactions())) return;
Handle<PromiseReaction> reaction(
PromiseReaction::cast(promise->reactions()), isolate);
if (!IsSmi(reaction->next())) return;
// Check if the {reaction} has one of the known async function or
// async generator continuations as its fulfill handler.
if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncFunctionAwaitResolveClosure) ||
IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorAwaitResolveClosure) ||
IsBuiltinFunction(
isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorYieldWithAwaitResolveClosure)) {
// Now peek into the handlers' AwaitContext to get to
// the JSGeneratorObject for the async function.
Handle<Context> context(
JSFunction::cast(reaction->fulfill_handler())->context(), isolate);
Handle<JSGeneratorObject> generator_object(
JSGeneratorObject::cast(context->extension()), isolate);
CHECK(generator_object->is_suspended());
// Append async frame corresponding to the {generator_object}.
builder->AppendAsyncFrame(generator_object);
パラメータである AppendAsyncFrame 関数の generator_object が完全に制御可能であるため、kAsyncFunctionAwaitResolveClosure ケースを選択しました。
条件を通過するためにPromiseReaction、Function、Context、JSGeneratorObjectなどの適切な偽のオブジェクトを設定することで、builder->AppendAsyncFrame(generator_object) を呼び出して偽の非同期フレームを注入できます。
注入された偽の非同期フレームは端末から確認できます。
Error: Let's have a look...
at bar (../../../../fake_frame.js:168:15)
at async foo (../../../../fake_frame.js:163:9)
at async Promise.all (index 0)
at async Array.sloppy_func (../../../../fake_frame.js:1:1)
fake_frame.js コードはこちらです。
偽の非同期フレームを注入した後、Error.prepareStackTrace と getThis メソッドを使用してエラーオブジェクトの receiver(この場合は JSGeneratorObject)を取得しました。
receiver を使用して、ヒープから偽のオブジェクトを取得できます(fakeobj プリミティブ)。
エクスプロイトフロー: V8エクスプロイトの典型的なエクスプロイトフローを使用しました。
完全なエクスプロイトコードはこちら: index.html と exploit.html これはv8CTF M118のターゲットバージョンであるChrome 118.0.5993.70でテストされています。
KAIST Hacking Lab の Haein Lee