
Chrome Renderer RCE 1-day via confusion de type dans la trace de pile asynchrone (soumission v8ctf)
Cette vulnérabilité permettait à un attaquant distant d'exécuter du code arbitraire dans le processus de rendu Chrome.
Il y avait une vérification de type insuffisante dans le code de gestion des traces de pile asynchrones.
Cela conduit à une confusion de type entre FunctionContext et NativeContext, provoquant un accès illégal à la valeur JSGlobalProxy->hash.
Avec du heap spraying, l'attaquant a pu injecter une fausse trame de pile asynchrone et construire le primitif fakeobj.
En utilisant le primitif fakeobj, l'attaquant a pu réaliser une exécution de code arbitraire dans le processus de rendu Chrome.
Vous pouvez consulter nos slides TyphoonCon 2024.
L'asynchrone est l'une des fonctionnalités les plus importantes de JavaScript. Dans le passé, il était difficile de déboguer du code asynchrone avec la pile d'erreur car les fonctions asynchrones ne sont pas capturées dans la pile d'erreur. Les fonctions asynchrones suspendues sont stockées dans la file d'attente de rappels de la boucle d'événements, pas dans la pile d'appels, donc la pile d'erreur ne contient pas la fonction asynchrone. Pour résoudre ce problème, V8 fournit la fonctionnalité "trace de pile asynchrone" (par défaut depuis V8 v7.3) pour capturer les fonctions asynchrones dans la pile d'erreur. (v8 blog, v8 docs)
"Fermeture de résolution d'élément Promise.all" est une fonction auxiliaire pour résoudre les promesses d'entrée dans la fonction Promise.all.
La fonction Promise.all prend un tableau de promesses et retourne une promesse qui se résout lorsque toutes les promesses d'entrée sont résolues.
"Fermeture de résolution d'élément Promise.all" est un gestionnaire de résolution de chaque promesse d'entrée dans la fonction Promise.all.
Son rôle est de résoudre la promesse d'entrée et de stocker la valeur d'accomplissement dans le tableau de résultats.
Il y a 2 points à noter à propos de cette fonction :
FunctionContext jusqu'à ce qu'elle soit appelée, puis elle a NativeContext après avoir été appelée. (code v8)Classe de bogue : Confusion de type entre FunctionContext et NativeContext
Détails de la vulnérabilité :
La vulnérabilité peut être déclenchée en capturant une trace de pile asynchrone avec la fonction "Fermeture de résolution d'élément Promise.all" déjà exécutée ou une fonction native intrinsèque similaire. Dans cet exploit, j'ai utilisé la fonction "Fermeture de résolution d'élément Promise.all" comme exemple.
Lorsqu'une erreur est levée dans le code JavaScript, V8 capture la pile d'erreur depuis la pile et ajoute les trames de pile asynchrones depuis la microtâche courante [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 fonction CaptureAsyncStackTrace [2] parcourt la chaîne de promesses et ajoute la trame de pile asynchrone en fonction du type d'appel asynchrone (par exemple, await, Promise.all, Promise.any).
Voici l'extrait de la fonction CaptureAsyncStackTrace qui gère le cas 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 (
En parcourant la chaîne de promesses, si reaction->fulfill_handler est la fonction native "Fermeture de résolution d'élément Promise.all", elle ajoute la trame de combinaison de promesses asynchrones à la pile d'erreur.
Ensuite, elle passe à la promesse suivante en accédant à function->context->capability->promise.
Le problème est que la fonction suppose que la fonction "Fermeture de résolution d'élément Promise.all" n'a pas encore été exécutée.
Si la fonction "Fermeture de résolution d'élément Promise.all" a déjà été exécutée, le contexte passe de FunctionContext à NativeContext.
Cela conduit à une confusion de type entre FunctionContext et NativeContext dans la fonction CaptureAsyncStackTrace.
Réalisation du PoC :
La stratégie pour déclencher la vulnérabilité est la suivante :
FunctionContext à NativeContext.J'ai utilisé le motif de résolution synchrone de promesses pour Promise.all afin d'obtenir la fonction "Fermeture de résolution d'élément Promise.all" au niveau du script JS.
J'ai emprunté le motif des cas de test test262.
Après avoir appelé explicitement la fonction, pour déclencher la vulnérabilité, j'ai utilisé l'exemple de code dans le document sur la trace de pile asynchrone à coût zéro pour préparer une nouvelle chaîne de promesses et définir la fonction native intrinsèque comme gestionnaire d'accomplissement de l'une des promesses.
Enfin, lorsque l'erreur est levée, la trace de pile asynchrone est capturée avec la fonction "Fermeture de résolution d'élément Promise.all" déjà exécutée comme gestionnaire d'accomplissement, conduisant à une confusion de type entre FunctionContext et NativeContext.
Voici le code du PoC : poc.js
(Les termes primitif d'exploitation, stratégie d'exploitation, technique d'exploitation et flux d'exploitation sont définis ici.)
Primitif d'exploitation : Primitif fakeobj
Stratégie d'exploitation : Pour construire le primitif fakeobj à partir du bogue de confusion de type, j'ai utilisé la stratégie suivante :
Error.prepareStackTrace avec la méthode getThis pour récupérer le faux objet.Le bogue conduit à une confusion de type entre FunctionContext et NativeContext dans la fonction CaptureAsyncStackTrace.
Il accède à Context->PromiseCapability->JSPromise pour construire la trame de pile asynchrone suivante.
Lorsque le bogue est déclenché, il accède à NativeContext->JSGlobalProxy->hash.
Pour exploiter le bogue, j'ai utilisé la valeur de hachage comme pointeur d'objet JSPromise.
Nous pouvons vérifier que la valeur de hachage a une plage de (0, 0xfffff) à partir de la fonction de génération de hachage suivante :
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
La valeur de hachage est marquée SMI, donc en mémoire, elle sera stockée comme hash << 1.
Ainsi, la valeur en mémoire sera dans la plage (0, 0xfffff << 1) avec un nombre pair.
Pour faire correspondre le nombre de hachage aléatoire à un pointeur d'objet JSPromise valide, nous avons 2 contraintes :
En suivant les contraintes, j'ai sprayé le heap avec des objets JSPromise avec un décalage de 8 bits vers la gauche pour rendre l'adresse impaire, et j'ai utilisé de petites boucles for pour rentrer dans la plage (0, 0xfffff << 1).
Ici, faire correspondre le nombre de hachage aléatoire à un pointeur d'objet valide semble avoir une faible probabilité. Pour augmenter la fiabilité, j'ai utilisé la technique iframe. Les pages de différents sites web s'exécutent dans des processus différents grâce à l'isolation de site dans Chrome. J'ai donc créé une iframe avec un domaine différent, et j'ai exécuté l'exploit dans l'iframe pour éviter le crash du processus principal.
Après être passé à la promesse suivante dans la chaîne de promesses, le programme vérifie la validité de la promesse et tente d'ajouter la trame de pile asynchrone selon le type d'appel asynchrone.
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);
Nous avons choisi le cas kAsyncFunctionAwaitResolveClosure car le paramètre de la fonction AppendAsyncFrame, generator_object, est entièrement contrôlable.
En définissant des faux objets appropriés tels que PromiseReaction, Function, Context, JSGeneratorObject pour passer les conditions, nous pouvons injecter notre fausse trame asynchrone en appelant builder->AppendAsyncFrame(generator_object).
Nous pouvons vérifier la fausse trame asynchrone injectée depuis le 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)
Voici le code fake_frame.js.
Après avoir injecté la fausse trame asynchrone, j'ai utilisé Error.prepareStackTrace avec la méthode getThis pour obtenir le receiver de l'objet d'erreur (dans ce cas, c'est JSGeneratorObject).
Avec le receiver, nous pouvons récupérer le faux objet depuis le heap (primitif fakeobj).
Flux d'exploitation : J'ai utilisé le flux d'exploitation typique pour les exploits V8.
Voici le code complet de l'exploit : index.html et exploit.html Il a été testé sur Chrome 118.0.5993.70 qui était la version cible du v8CTF M118.
Haein Lee of KAIST Hacking Lab