
Chrome Renderer 1day RCE mediante Type Confusion en Async Stack Trace (v8ctf submission)
Esta vulnerabilidad permitía a un atacante remoto ejecutar código arbitrario dentro del proceso renderizador de Chrome.
Había una comprobación de tipo insuficiente en el código de manejo de trazas de pila asíncronas. Conduce a una confusión de tipos entre FunctionContext y NativeContext, causando un acceso ilegal al valor JSGlobalProxy->hash. Mediante heap spraying, el atacante pudo inyectar un marco de pila asíncrono falso y construir la primitiva fakeobj. Usando la primitiva fakeobj, el atacante pudo lograr la ejecución de código arbitrario en el proceso renderizador de Chrome.
Puedes consultar nuestras diapositivas de TyphoonCon 2024.
La asincronía es una de las características más importantes de JavaScript. En el pasado, era difícil depurar código asíncrono con la pila de errores porque las funciones asíncronas no se capturaban en la pila de errores. Las funciones asíncronas suspendidas se almacenan en la cola de callbacks del bucle de eventos, no en la pila de llamadas, por lo que la pila de errores no contenía la función asíncrona. Para resolver este problema, V8 ofrece la funcionalidad de "traza de pila asíncrona" (por defecto desde V8 v7.3) para capturar funciones asíncronas en la pila de errores. (v8 blog, v8 docs)
El "Closure de resolución de elementos de Promise.all" es una función auxiliar para resolver las promesas de entrada en la función Promise.all. La función Promise.all toma un array de promesas y devuelve una promesa que se resuelve cuando todas las promesas de entrada se han resuelto. El "Closure de resolución de elementos de Promise.all" es un manejador de resolución de cada promesa de entrada en la función Promise.all. Su función es resolver la promesa de entrada y almacenar el valor de cumplimiento en el array de resultados.
Hay 2 puntos a tener en cuenta sobre la función:
FunctionContext hasta que se llama, y luego tiene NativeContext después de ser llamada. (código v8)Clase de bug: Confusión de tipos entre FunctionContext y NativeContext
Detalles de la vulnerabilidad:
La vulnerabilidad se puede desencadenar capturando una traza de pila asíncrona con la función "Closure de resolución de elementos de Promise.all" ya ejecutada o funciones integradas intrínsecas similares. En este exploit, utilicé la función "Closure de resolución de elementos de Promise.all" como ejemplo.
Cuando se lanza un error en el código JavaScript, V8 captura la pila de errores de la pila y añade los marcos de pila asíncronos de la microtarea actual [1].
CallSiteBuilder builder(isolate, mode, limit, caller);
VisitStack(isolate, &builder);
// Si --async-stack-traces está habilitado y la "microtarea actual" es un
// PromiseReactionJobTask, intentamos enriquecer la traza de pila con marcos
// asíncronos.
if (v8_flags.async_stack_traces) {
CaptureAsyncStackTrace(isolate, &builder);
}
La función CaptureAsyncStackTrace [2] recorre la cadena de promesas y añade el marco de pila asíncrono según el tipo de llamada asíncrona (p. ej., await, Promise.all, Promise.any).
A continuación se muestra el fragmento de la función CaptureAsyncStackTrace que maneja el caso de 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);
// Ahora inspeccionamos el contexto del elemento de resolución de Promise.all()
// para encontrar la capacidad de promesa que se está resolviendo cuando todas
// las promesas concurrentes se resuelven.
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 (
Mientras recorre la cadena de promesas, si reaction->fulfill_handler es la función integrada "Closure de resolución de elementos de Promise.all", añade el marco de combinador de promesas asíncrono a la pila de errores. Luego, pasa a la siguiente promesa accediendo a function->context->capability->promise.
El problema es que la función asume que la función "Closure de resolución de elementos de Promise.all" no se ha ejecutado todavía. Si la función "Closure de resolución de elementos de Promise.all" ya se ha ejecutado, el contexto cambia de FunctionContext a NativeContext. Esto lleva a una confusión de tipos entre FunctionContext y NativeContext en la función CaptureAsyncStackTrace.
Creación del PoC:
La estrategia para desencadenar la vulnerabilidad es la siguiente:
FunctionContext a NativeContext.Usé el patrón de resolución de promesas síncrono para Promise.all con el fin de obtener la función "Closure de resolución de elementos de Promise.all" a nivel de script JS. Tomé prestado el patrón de los casos de prueba de test262.
Después de llamar explícitamente a la función, para desencadenar la vulnerabilidad, usé el código de ejemplo del documento de traza de pila asíncrona de costo cero para preparar una nueva cadena de promesas y establecer la función intrínseca integrada como manejador de cumplimiento de una de las promesas.
Finalmente, cuando se lanza el error, la traza de pila asíncrona se captura con la función "Closure de resolución de elementos de Promise.all" ya ejecutada como manejador de cumplimiento, lo que lleva a una confusión de tipos entre FunctionContext y NativeContext.
Aquí está el código del PoC: poc.js
(Los términos primitiva de exploit, estrategia de exploit, técnica de exploit y flujo de exploit están definidos aquí.)
Primitiva de exploit: primitiva fakeobj
Estrategia de exploit: Para construir la primitiva fakeobj a partir del bug de confusión de tipos, usé la siguiente estrategia:
Error.prepareStackTrace con el método getThis para recuperar el objeto falso.El bug lleva a una confusión de tipos entre FunctionContext y NativeContext en la función CaptureAsyncStackTrace. Accede a Context->PromiseCapability->JSPromise para construir el siguiente marco de pila asíncrono. Cuando se desencadena el bug, accede a NativeContext->JSGlobalProxy->hash. Para explotar el bug, usé el valor hash como puntero de objeto JSPromise.
Podemos verificar que el valor hash tiene un rango de (0, 0xfffff) a partir de la siguiente función generadora de 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
El valor hash tiene la etiqueta SMI, por lo que en la memoria se almacenará como hash << 1. Por lo tanto, el valor en la memoria estará en el rango de (0, 0xfffff << 1) con números pares.
Para hacer coincidir el número hash aleatorio con un puntero de objeto JSPromise válido, tenemos 2 restricciones:
Siguiendo las restricciones, hice spray en el heap con objetos JSPromise desplazados a la izquierda 8 bits para que la dirección sea impar, y usé bucles for pequeños para encajar en el rango (0, 0xfffff << 1).
Hacer coincidir el número hash aleatorio con un puntero de objeto válido parece tener una probabilidad bastante baja. Para aumentar la fiabilidad, utilicé la técnica de iframe. Las páginas de diferentes sitios web se ejecutan en procesos diferentes debido al aislamiento de sitios en Chrome. Así que creé un iframe con un dominio diferente y ejecuté el exploit en el iframe para evitar el bloqueo del proceso principal.
Después de pasar a la siguiente promesa en la cadena de promesas, el programa verifica la validez de la promesa e intenta añadir el marco de pila asíncrono según el tipo de llamada asíncrona.
while (!builder->Full()) {
// Verificar que la {promesa} no esté resuelta.
if (promise->status() != Promise::kPending) return;
// Verificar que tengamos exactamente una PromiseReaction en la {promesa}.
if (!IsPromiseReaction(promise->reactions())) return;
Handle<PromiseReaction> reaction(
PromiseReaction::cast(promise->reactions()), isolate);
if (!IsSmi(reaction->next())) return;
// Verificar si la {reacción} tiene una de las conocidas continuaciones de
// función asíncrona o generador asíncrono como su manejador de cumplimiento.
if (IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncFunctionAwaitResolveClosure) ||
IsBuiltinFunction(isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorAwaitResolveClosure) ||
IsBuiltinFunction(
isolate, reaction->fulfill_handler(),
Builtin::kAsyncGeneratorYieldWithAwaitResolveClosure)) {
// Ahora inspeccionamos el AwaitContext de los manejadores para llegar
// al JSGeneratorObject de la función asíncrona.
Handle<Context> context(
JSFunction::cast(reaction->fulfill_handler())->context(), isolate);
Handle<JSGeneratorObject> generator_object(
JSGeneratorObject::cast(context->extension()), isolate);
CHECK(generator_object->is_suspended());
// Añadir marco asíncrono correspondiente al {generator_object}.
builder->AppendAsyncFrame(generator_object);
Elegimos el caso kAsyncFunctionAwaitResolveClosure porque el parámetro de la función AppendAsyncFrame, generator_object, es totalmente controlable.
Al establecer objetos falsos apropiados como PromiseReaction, Function, Context, JSGeneratorObject para pasar las condiciones, podemos inyectar nuestro marco asíncrono falso llamando a builder->AppendAsyncFrame(generator_object). Podemos verificar el marco asíncrono falso inyectado desde la terminal.
Error: Vamos a echar un vistazo...
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)
Aquí está el código de fake_frame.js.
Después de inyectar el marco asíncrono falso, usé Error.prepareStackTrace con el método getThis para obtener el receiver del objeto de error (en este caso, es JSGeneratorObject). Con el receiver, podemos recuperar el objeto falso del heap (primitiva fakeobj).
Flujo de exploit: Usé el flujo de explotación típico para exploits de V8.
Aquí está el código completo del exploit: index.html y exploit.html Fue probado en Chrome 118.0.5993.70, que era la versión objetivo de v8CTF M118.
Haein Lee de KAIST Hacking Lab