
Chrome Renderer 1day RCE tramite Type Confusion nello Stack Trace Asincrono (v8ctf submission)
Questa vulnerabilità permetteva a un attaccante remoto di eseguire codice arbitrario all'interno del processo renderer di Chrome.
C'era un controllo di tipo insufficiente nel codice di gestione dello stack trace asincrono.
Porta a una confusione di tipo tra FunctionContext e NativeContext, causando un accesso illegale al valore JSGlobalProxy->hash.
Con l'heap spraying, l'attaccante è stato in grado di iniettare un finto frame dello stack asincrono e costruire la primitiva fakeobj.
Usando la primitiva fakeobj, l'attaccante è riuscito a ottenere l'esecuzione di codice arbitrario nel processo renderer di Chrome.
Puoi consultare le nostre slide di TyphoonCon 2024.
L'asincronia è una delle caratteristiche più importanti in JavaScript. In passato, era difficile eseguire il debug del codice asincrono con lo stack di errori perché le funzioni asincrone non venivano catturate nello stack di errori. Le funzioni asincrone sospese sono memorizzate nella coda di callback del ciclo di eventi, non nello stack di chiamate, quindi lo stack di errori non contiene la funzione asincrona. Per risolvere questo problema, V8 fornisce la funzionalità "async stack trace" (per impostazione predefinita da V8 v7.3) per catturare la funzione asincrona nello stack di errori. (v8 blog, v8 docs)
"Promise.all Resolve Element Closure" è una funzione helper per risolvere le promise di input nella funzione Promise.all.
La funzione Promise.all prende un array di promise e restituisce una promise che si risolve quando tutte le promise di input sono risolte.
"Promise.all Resolve Element Closure" è un gestore di risoluzione per ogni promise di input nella funzione Promise.all.
Il ruolo della funzione è risolvere la promise di input e memorizzare il valore di completamento nell'array dei risultati.
Ci sono 2 punti da notare riguardo alla funzione:
FunctionContext fino a quando non viene chiamata, e poi ha NativeContext dopo essere stata chiamata. (v8 code)Classe di bug: Confusione di tipo tra FunctionContext e NativeContext
Dettagli della vulnerabilità:
La vulnerabilità può essere attivata catturando uno stack trace asincrono con la funzione "Promise.all Resolve Element Closure" già eseguita o funzioni builtin intrinseche simili. In questo exploit, ho usato la funzione "Promise.all Resolve Element Closure" come esempio.
Quando un errore viene lanciato nel codice JavaScript, V8 cattura lo stack di errori dallo stack e aggiunge i frame dello stack asincrono dal microtask corrente [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);
}
La funzione CaptureAsyncStackTrace [2] cerca nella catena di promise e aggiunge il frame dello stack asincrono in base al tipo di chiamata asincrona (es., await, Promise.all, Promise.any).
Di seguito è riportato lo snippet della funzione CaptureAsyncStackTrace che gestisce il caso Promise.all:
} 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 (
Durante la ricerca nella catena di promise, se reaction->fulfill_handler è la funzione builtin "Promise.all Resolve Element Closure", aggiunge il frame combinatorio della promise asincrona allo stack di errori.
Poi, passa alla promise successiva accedendo a function->context->capability->promise.
Il problema è che la funzione presuppone che la funzione "Promise.all Resolve Element Closure" non sia stata ancora eseguita.
Se la funzione "Promise.all Resolve Element Closure" è già stata eseguita, il contesto viene cambiato da FunctionContext a NativeContext.
Porta a una confusione di tipo tra FunctionContext e NativeContext nella funzione CaptureAsyncStackTrace.
Realizzazione del PoC:
La strategia per attivare la vulnerabilità è la seguente:
FunctionContext a NativeContext.Ho usato il pattern di risoluzione sincrona delle promise per Promise.all per ottenere la funzione "Promise.all Resolve Element Closure" a livello di script JS.
Ho preso in prestito il pattern dai casi di test test262.
Dopo aver chiamato esplicitamente la funzione, per attivare la vulnerabilità, ho usato il codice di esempio nel documento sullo stack trace asincrono a costo zero per preparare una nuova catena di promise e impostare la funzione builtin intrinseca come gestore di completamento di una delle promise.
Infine, quando l'errore viene lanciato, lo stack trace asincrono viene catturato con la funzione "Promise.all Resolve Element Closure" già eseguita come gestore di completamento, portando a una confusione di tipo tra FunctionContext e NativeContext.
Ecco il codice del PoC: poc.js
(I termini primitiva di exploit, strategia di exploit, tecnica di exploit e flusso di exploit sono definiti qui.)
Primitiva di exploit: primitiva fakeobj
Strategia di exploit: Per costruire la primitiva fakeobj dal bug di confusione di tipo, ho usato la seguente strategia:
Error.prepareStackTrace con il metodo getThis per recuperare l'oggetto finto.Il bug porta a una confusione di tipo tra FunctionContext e NativeContext nella funzione CaptureAsyncStackTrace.
Accede a Context->PromiseCapability->JSPromise per costruire il prossimo frame dello stack asincrono.
Quando il bug viene attivato, accede a NativeContext->JSGlobalProxy->hash.
Per sfruttare il bug, ho usato il valore hash come puntatore di oggetto JSPromise.
Possiamo verificare che il valore hash ha un intervallo di (0, 0xfffff) dalla seguente funzione di generazione hash:
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
Il valore hash è con tag SMI, quindi in memoria, sarà memorizzato come hash << 1.
Quindi, il valore in memoria sarà nell'intervallo di (0, 0xfffff << 1) con numeri pari.
Per far corrispondere il numero hash casuale a un puntatore valido di oggetto JSPromise, abbiamo 2 vincoli:
Seguendo i vincoli, ho fatto heap spray con oggetti JSPromise con uno shift a sinistra di 8 bit per rendere l'indirizzo dispari, e ho usato piccoli cicli for per rientrare nell'intervallo (0, 0xfffff << 1).
Qui far corrispondere il numero hash casuale a un puntatore valido di oggetto sembra avere una probabilità piuttosto bassa. Per aumentare l'affidabilità, ho usato la tecnica iframe. Le pagine di siti web diversi vengono eseguite in processi diversi a causa dell'isolamento dei siti in Chrome. Così, ho creato un iframe con un dominio diverso e ho eseguito l'exploit nell'iframe per evitare il crash del processo principale.
Dopo essere passato alla promise successiva nella catena di promise, il programma verifica la validità della promise e tenta di aggiungere il frame dello stack asincrono in base al tipo di chiamata asincrona.
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);
Abbiamo scelto il caso kAsyncFunctionAwaitResolveClosure perché il parametro della funzione AppendAsyncFrame, generator_object, è completamente controllabile.
Impostando oggetti finti appropriati come PromiseReaction, Function, Context, JSGeneratorObject per superare le condizioni, possiamo iniettare il nostro finto frame asincrono chiamando builder->AppendAsyncFrame(generator_object).
Possiamo verificare il finto frame asincrono iniettato dal terminale.
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)
Ecco il codice fake_frame.js.
Dopo aver iniettato il finto frame asincrono, ho usato Error.prepareStackTrace con il metodo getThis per ottenere il receiver dell'oggetto errore (in questo caso, è JSGeneratorObject).
Con il receiver, possiamo recuperare l'oggetto finto dall'heap (primitiva fakeobj).
Flusso dell'exploit: Ho usato il flusso tipico di exploit per gli exploit V8.
Ecco il codice completo dell'exploit: index.html e exploit.html È stato testato su Chrome 118.0.5993.70 che era la versione target del v8CTF M118.
Haein Lee del KAIST Hacking Lab