
Chrome Renderer 1day RCE via Type Confusion em Async Stack Trace (submissão v8ctf)
Esta vulnerabilidade permitiu que um atacante remoto executasse código arbitrário dentro do processo renderizador do Chrome.
Havia uma verificação de tipo insuficiente no código de manipulação de rastreamento de pilha assíncrono.
Isso leva a uma confusão de tipo entre FunctionContext e NativeContext, causando acesso ilegal ao valor JSGlobalProxy->hash.
Com heap spraying, o atacante conseguiu injetar um frame de pilha assíncrono falso e construir a primitiva fakeobj.
Usando a primitiva fakeobj, o atacante conseguiu alcançar execução de código arbitrário no processo renderizador do Chrome.
Você pode verificar nossos slides do TyphoonCon 2024.
Assíncrono é uma das características mais importantes em JavaScript. No passado, era difícil depurar código assíncrono com pilha de erros porque funções assíncronas não eram capturadas na pilha de erros. Funções assíncronas suspensas são armazenadas na fila de callbacks do loop de eventos, não na pilha de chamadas, portanto a pilha de erros não contém a função assíncrona. Para resolver esse problema, o V8 fornece o recurso "async stack trace" (por padrão desde V8 v7.3) para capturar funções assíncronas na pilha de erros. (v8 blog, v8 docs)
"Promise.all Resolve Element Closure" é uma função auxiliar para resolver as promessas de entrada na função Promise.all.
A função Promise.all recebe um array de promessas e retorna uma promessa que resolve quando todas as promessas de entrada são resolvidas.
O papel da função é resolver a promessa de entrada e armazenar o valor de cumprimento no array de resultados.
Há 2 pontos a serem observados sobre a função:
FunctionContext até ser chamada, e então tem NativeContext depois de ser chamada. (v8 code)Classe do bug: Confusão de tipo entre FunctionContext e NativeContext
Detalhes da vulnerabilidade:
A vulnerabilidade pode ser desencadeada ao capturar um rastreamento de pilha assíncrono com a função "Promise.all Resolve Element Closure" já executada ou funções intrínsecas semelhantes. Neste exploit, usei a função "Promise.all Resolve Element Closure" como exemplo.
Quando um erro é lançado no código JavaScript, o V8 captura a pilha de erros da pilha e anexa os frames de pilha assíncronos da microtarefa atual [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);
}
A função CaptureAsyncStackTrace [2](https://source.chromium.org/chromium/chromium/src/+/refs/tags/118.0.5993.70:v8/src/execution/isolate.cc;drc=b29ad8be0d7d9798243e1c1230dc21201c6bec8e;l=967) percorre a cadeia de promessas e anexa o frame de pilha assíncrono de acordo com o tipo de chamada assíncrona (ex.: await, Promise.all, Promise.any).
Abaixo está o trecho da função CaptureAsyncStackTrace que lida com o 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 (
Ao percorrer a cadeia de promessas, se reaction->fulfill_handler for a função interna "Promise.all Resolve Element Closure", ele anexa o frame de combinador de promessa assíncrona à pilha de erros.
Em seguida, move-se para a próxima promessa acessando function->context->capability->promise.
O problema é que a função assume que a função "Promise.all Resolve Element Closure" ainda não foi executada.
Se a função "Promise.all Resolve Element Closure" já foi executada, o contexto é alterado de FunctionContext para NativeContext.
Isso leva a uma confusão de tipo entre FunctionContext e NativeContext na função CaptureAsyncStackTrace.
Criando o PoC:
A estratégia para acionar a vulnerabilidade é a seguinte:
FunctionContext para NativeContext.Usei o padrão de resolução síncrona de promessas para Promise.all para obter a função "Promise.all Resolve Element Closure" no nível do script JS.
Peguei o padrão dos casos de teste test262.
Após chamar explicitamente a função, para acionar a vulnerabilidade, usei o código de exemplo no documento de rastreamento de pilha assíncrono de custo zero para preparar uma nova cadeia de promessas e definir a função intrínseca interna como um manipulador de cumprimento de uma das promessas.
Finalmente, quando o erro é lançado, o rastreamento de pilha assíncrono é capturado com a função "Promise.all Resolve Element Closure" já executada como manipulador de cumprimento, levando a uma confusão de tipo entre FunctionContext e NativeContext.
Aqui está o código PoC: poc.js
(Os termos primitiva de exploit, estratégia de exploit, técnica de exploit e fluxo de exploit são definidos aqui.)
Primitiva de exploit: primitiva fakeobj
Estratégia de exploit: Para construir a primitiva fakeobj a partir do bug de confusão de tipo, usei a seguinte estratégia:
Error.prepareStackTrace com o método getThis para recuperar o objeto falso.O bug leva a uma confusão de tipo entre FunctionContext e NativeContext na função CaptureAsyncStackTrace.
Ele acessa Context->PromiseCapability->JSPromise para construir o próximo frame de pilha assíncrono.
Quando o bug é acionado, ele acessa NativeContext->JSGlobalProxy->hash.
Para explorar o bug, usei o valor hash como um ponteiro de objeto JSPromise.
Podemos verificar que o valor hash tem um intervalo de (0, 0xfffff) a partir da seguinte função geradora 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
O valor hash é marcado como SMI, portanto na memória será armazenado como hash << 1.
Portanto, o valor na memória estará no intervalo de (0, 0xfffff << 1) com número par.
Para corresponder o número hash aleatório a um ponteiro de objeto JSPromise válido, temos 2 restrições:
Seguindo as restrições, fiz heap spray com objetos JSPromise com deslocamento para a esquerda de 8 bits para tornar o endereço ímpar, e usei pequenos loops for para caber no intervalo (0, 0xfffff << 1).
Aqui, corresponder o número hash aleatório a um ponteiro de objeto válido parece ter uma chance bastante baixa. Para aumentar a confiabilidade, usei a técnica de iframe. Páginas de diferentes sites são executadas em processos diferentes devido ao isolamento de sites no Chrome. Então, criei um iframe com domínio diferente e executei o exploit no iframe para evitar a falha do processo principal.
Após passar para a próxima promessa na cadeia de promessas, o programa verifica a validade da promessa e tenta anexar o frame de pilha assíncrono de acordo com o tipo de chamada assíncrona.
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);
Escolhemos o caso kAsyncFunctionAwaitResolveClosure porque o parâmetro da função AppendAsyncFrame, generator_object, é totalmente controlável.
Ao definir objetos falsos apropriados, como PromiseReaction, Function, Context, JSGeneratorObject, para passar nas condições, podemos injetar nosso frame assíncrono falso chamando builder->AppendAsyncFrame(generator_object).
Podemos verificar o frame assíncrono falso injetado a partir do terminal.
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)
Aqui está o código fake_frame.js.
Após injetar o frame assíncrono falso, usei Error.prepareStackTrace com o método getThis para obter o receiver do objeto de erro (neste caso, é JSGeneratorObject).
Com o receiver, podemos recuperar o objeto falso do heap (primitiva fakeobj).
Fluxo do exploit: Usei o fluxo de exploração típico para exploits V8.
Aqui está o código completo do exploit: index.html e exploit.html Foi testado no Chrome 118.0.5993.70 que era a versão alvo do v8CTF M118.
Haein Lee do KAIST Hacking Lab