
Chrome Renderer 1day RCE durch Type Confusion im Async Stack Trace (v8ctf-Einreichung)
Diese Schwachstelle erlaubte es einem entfernten Angreifer, beliebigen Code im Chrome-Renderer-Prozess auszuführen.
In der Verarbeitung der asynchronen Stack-Traces fehlte eine ausreichende Typprüfung.
Dies führt zu einer Typverwechslung zwischen FunctionContext und NativeContext, die einen unerlaubten Zugriff auf den JSGlobalProxy->hash-Wert verursacht.
Durch Heap-Spraying konnte der Angreifer einen gefälschten asynchronen Stack-Frame einschleusen und die fakeobj-Primitive konstruieren.
Mit der fakeobj-Primitive konnte der Angreifer beliebigen Code im Chrome-Renderer-Prozess ausführen.
Du kannst dir unsere TyphoonCon-2024-Folien ansehen.
Asynchrone Programmierung ist eines der wichtigsten Features in JavaScript. Früher war es schwierig, asynchronen Code mit dem Fehler-Stack zu debuggen, da asynchrone Funktionen nicht im Fehler-Stack erfasst wurden. Suspendierte asynchrone Funktionen werden in der Callback-Queue der Ereignisschleife gespeichert, nicht im Aufrufstapel, daher enthält der Fehler-Stack die asynchrone Funktion nicht. Um dieses Problem zu lösen, bietet V8 die Funktion „async stack trace“ (standardmäßig seit V8 v7.3), um asynchrone Funktionen im Fehler-Stack zu erfassen. (v8-Blog, v8-Dokumentation)
„Promise.all Resolve Element Closure“ ist eine Hilfsfunktion zum Auflösen der eingehenden Promises in der Promise.all-Funktion.
Die Promise.all-Funktion nimmt ein Array von Promises entgegen und gibt ein Promise zurück, das aufgelöst wird, wenn alle eingegebenen Promises aufgelöst sind.
„Promise.all Resolve Element Closure“ ist ein Resolve-Handler für jedes eingehende Promise in der Promise.all-Funktion.
Die Funktion hat die Aufgabe, das eingehende Promise aufzulösen und den Erfüllungswert im Ergebnis-Array zu speichern.
Es gibt 2 Punkte, die man bei dieser Funktion beachten sollte:
FunctionContext, bis sie aufgerufen wurde, und nach dem Aufruf hat sie NativeContext. (v8-Code)Bug-Klasse: Typverwechslung zwischen FunctionContext und NativeContext
Details zur Schwachstelle:
Die Schwachstelle kann ausgelöst werden, indem ein asynchroner Stack-Trace mit der bereits ausgeführten Funktion „Promise.all Resolve Element Closure“ oder ähnlichen intrinsischen Built-in-Funktionen erfasst wird. In diesem Exploit habe ich die Funktion „Promise.all Resolve Element Closure“ als Beispiel verwendet.
Wenn im JavaScript-Code ein Fehler ausgelöst wird, erfasst V8 den Fehler-Stack aus dem Aufrufstapel und hängt die asynchronen Stack-Frames aus der aktuellen Mikrotask an [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);
}
Die Funktion CaptureAsyncStackTrace [2] durchsucht die Promise-Kette und hängt den asynchronen Stack-Frame gemäß dem asynchronen Aufruftyp an (z. B. await, Promise.all, Promise.any).
Unten ist der Ausschnitt der Funktion CaptureAsyncStackTrace, der den Fall Promise.all behandelt:
} 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 (
Wenn beim Durchsuchen der Promise-Kette reaction->fulfill_handler die Built-in-Funktion „Promise.all Resolve Element Closure“ ist, wird der asynchrone Promise-Combinator-Frame an den Fehler-Stack angehängt.
Danach wechselt es zum nächsten Promise, indem es auf function->context->capability->promise zugreift.
Das Problem ist, dass die Funktion davon ausgeht, dass die „Promise.all Resolve Element Closure“-Funktion noch nicht ausgeführt wurde.
Wenn die „Promise.all Resolve Element Closure“-Funktion bereits ausgeführt wurde, ändert sich der Kontext von FunctionContext zu NativeContext.
Das führt zu einer Typverwechslung zwischen FunctionContext und NativeContext in der Funktion CaptureAsyncStackTrace.
Erstellung des PoC:
Die Strategie zum Auslösen der Schwachstelle ist wie folgt:
FunctionContext zu NativeContext zu ändern.Ich habe das synchrone Promise-Auflösungsmuster für Promise.all verwendet, um die Funktion „Promise.all Resolve Element Closure“ auf JS-Skriptebene zu erhalten.
Das Muster habe ich aus den test262-Testfällen übernommen.
Nachdem ich die Funktion explizit aufgerufen hatte, habe ich zum Auslösen der Schwachstelle den Beispielcode aus der Dokumentation zu Zero-Cost-Async-Stack-Traces verwendet, um eine neue Promise-Kette vorzubereiten, und die intrinsische Built-in-Funktion als Fulfill-Handler eines der Promises festgelegt.
Wenn schließlich der Fehler ausgelöst wird, wird der asynchrone Stack-Trace mit der bereits ausgeführten Funktion „Promise.all Resolve Element Closure“ als Fulfill-Handler erfasst, was zu einer Typverwechslung zwischen FunctionContext und NativeContext führt.
Hier ist der PoC-Code: poc.js
(Die Begriffe Exploit-Primitive, Exploit-Strategie, Exploit-Technik und Exploit-Ablauf werden hier definiert.)
Exploit-Primitive: fakeobj-Primitive
Exploit-Strategie: Um aus dem Typverwechslungs-Bug eine fakeobj-Primitive zu bauen, habe ich die folgende Strategie verwendet:
Error.prepareStackTrace mit der getThis-Methode verwenden, um das gefälschte Objekt abzurufen.Der Bug führt zu einer Typverwechslung zwischen FunctionContext und NativeContext in der Funktion CaptureAsyncStackTrace.
Sie greift auf Context->PromiseCapability->JSPromise zu, um den nächsten asynchronen Stack-Frame zu erstellen.
Wenn der Bug ausgelöst wird, greift sie auf NativeContext->JSGlobalProxy->hash zu.
Um den Bug auszunutzen, habe ich den Hash-Wert als JSPromise-Objektzeiger verwendet.
Wir können der folgenden Hash-Erzeugungsfunktion entnehmen, dass der Hash-Wert einen Bereich von (0, 0xfffff) hat:
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
Der Hash-Wert ist SMI-getaggt, daher wird er im Speicher als hash << 1 gespeichert.
Folglich liegt der Wert im Speicher mit einer geraden Zahl im Bereich von (0, 0xfffff << 1).
Um die zufällige Hash-Nummer auf einen gültigen JSPromise-Objektzeiger abzubilden, haben wir 2 Randbedingungen:
Unter Beachtung dieser Randbedingungen habe ich den Heap mit JSPromise-Objekten besprüht, die um 8 Bit nach links verschoben wurden, um die Adresse ungerade zu machen, und kleine for-Schleifen verwendet, um in den Bereich (0, 0xfffff << 1) zu passen.
Das Abgleichen der zufälligen Hash-Nummer mit einem gültigen Objektzeiger scheint hier eine ziemlich geringe Chance zu haben. Um die Zuverlässigkeit zu erhöhen, habe ich die iframe-Technik verwendet. Seiten von verschiedenen Websites laufen aufgrund der Site-Isolation in Chrome in verschiedenen Prozessen. Also habe ich ein iframe mit einer anderen Domain erstellt und den Exploit im iframe ausgeführt, um den Absturz des Hauptprozesses zu vermeiden.
Nach dem Wechsel zum nächsten Promise in der Promise-Kette prüft das Programm die Gültigkeit des Promises und versucht, den asynchronen Stack-Frame gemäß dem asynchronen Aufruftyp anzuhängen.
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);
Wir haben den Fall kAsyncFunctionAwaitResolveClosure gewählt, weil der Parameter der Funktion AppendAsyncFrame, generator_object, vollständig kontrollierbar ist.
Indem wir passende gefälschte Objekte wie PromiseReaction, Function, Context, JSGeneratorObject setzen, um die Bedingungen zu erfüllen, können wir unseren gefälschten asynchronen Frame durch den Aufruf von builder->AppendAsyncFrame(generator_object) injizieren.
Wir können den injizierten gefälschten asynchronen Frame vom Terminal aus überprüfen.
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)
Hier ist der Code von fake_frame.js.
Nach dem Injizieren des gefälschten asynchronen Frames habe ich Error.prepareStackTrace mit der getThis-Methode verwendet, um den receiver des Fehlerobjekts zu erhalten (in diesem Fall ist es JSGeneratorObject).
Mit dem receiver können wir das gefälschte Objekt aus dem Heap abrufen (fakeobj-Primitive).
Exploit-Ablauf: Ich habe den typischen Exploit-Ablauf für V8-Exploits verwendet.
Hier ist der vollständige Exploit-Code: index.html und exploit.html Er wurde auf Chrome 118.0.5993.70 getestet, der Zielversion des v8CTF M118.
Haein Lee vom KAIST Hacking Lab