
Ingénierie inverse de la nouvelle VM Datadome 🔥
Ce dépôt documente la première version publique de la machine virtuelle (VM) JavaScript intégrée au navigateur de DataDome, utilisée dans leur flux CAPTCHA/interstitiel. Cette analyse couvre :
Remarque : Ce dépôt ne couvre qu'une seule version (statique) de la VM et est destiné à des fins de recherche et d'analyse en sécurité. Il n'inclut pas de solveurs dynamiques ni d'implémentations de solveurs en production.
Le 14 janvier 2026, DataDome a commencé à intégrer un nouveau composant basé sur une VM dans leur tag client.
Le code de la VM a été extrait du défi captcha dans vm.js (disponible dans ce dépôt).
La première étape a consisté à désobfusquer le script :
L'obfuscation est simple : évaluer chaque variable et la remplacer par sa valeur réelle. Un script de désobfuscation est disponible dans deobf.js.
L'exécution du code désobfusqué (out.js) dans DevTools révèle la sortie attendue de la VM :
La sortie est un objet JSON contenant deux nombres et une chaîne. Plongeons maintenant dans l'implémentation réelle de la VM.
Au début de la fonction Q.exports, on peut voir comment le bytecode est décodé :
B(), un générateur pseudo-aléatoire)
-> D contient le bytecode décodé avec un certain « bruit » aléatoire
En descendant, on trouve le point d'entrée de la VM : une fonction avec deux paramètres A (le bytecode) et Q (un dictionnaire vide utilisé pour la gestion des erreurs).
L'aspect le plus intéressant de cette VM est son architecture : tout vit dans un seul tableau (A). Ce tableau contient :
Cette conception reflète une architecture informatique réelle avec des régions mémoire distinctes. L'étape suivante consiste à cartographier chaque décalage pour comprendre ce qui est stocké où :
var stack_pointer = 4593
var instruction_pointer = 4635
var frame_base_pointer = 4674
var last_result = 4633
var exit_flag = 4656
var current_opcode_handler = 4685
var current_opcode_id = 4675
var stack_offset = 124482
var vm_start = 5258
Avec ces décalages cartographiés, la structure de la VM devient claire.
La VM commence par un ensemble de fonctions auxiliaires qui gèrent :
Après les fonctions auxiliaires, la VM initialise les valeurs de base :
En dessous de l'initialisation se trouvent tous les gestionnaires d'instructions.
Le répartiteur est la boucle principale de la VM qui s'exécute jusqu'à ce que `exit_flag` soit défini :
I représente l'instruction couranteP est le décalage réel dans le tableau (en tenant compte de l'obfuscation)current_opcode_handler et met à jour current_opcode_id
Voici un exemple basique d'un gestionnaire d'opcode :
%= ou ^=)fetch() à la finUn des opcodes les plus complexes crée des fermetures/fonctions :
A[4919] = function () {
var Q = readUint8(); // Nombre d'arguments attendus
var B = [];
for (var E = readUint8(), D = 0; D < E; D++) {
var g = readUint8();
var a = A[A[frame_base_pointer] + g];
B.push(a); // Capture des variables depuis la portée courante
}
var h = A[instruction_pointer] + 3; // Sauvegarde de l'adresse du corps de la fonction
A[A[stack_pointer]++] = function (E) {
// Configuration d'un nouveau cadre de pile lors de l'appel
var e = A[stack_pointer] - E;
while (E < Q) {
A[e + E++] = undefined; // Remplir les arguments manquants avec undefined
}
A[stack_pointer] = e + Q;
for (var D = 0; D < B.length; D++) {
var g = B[D];
A[A[stack_pointer]++] = g; // Pousser les variables capturées
}
A[e - 2] = A[frame_base_pointer]; // Sauvegarder l'ancien pointeur de cadre
A[e - 1] = A[instruction_pointer]; // Sauvegarder l'adresse de retour
A[frame_base_pointer] = e;
A[instruction_pointer] = h; // Sauter au corps de la fonction
};
fetch();
};
Cet opcode :
undefinedCet opcode crée un wrapper pour les appels de fonction qui gère à la fois les appels réguliers et les appels constructeurs :
A[5003] = function () {
var Q = A[--A[stack_pointer]]; // POP fonction
var B = A[--A[stack_pointer]]; // POP contexte 'this'
function E(e) { // e = nombre d'arguments
var D = A[stack_pointer];
var g = A.slice(D - e, D); // Récupérer les arguments depuis la pile
if (this instanceof E) {
// Appel constructeur (new E(...))
g.unshift(null);
var h = Function.prototype.bind.apply(Q, g);
A[stack_pointer] -= e;
try {
a = new h();
} catch (A) {
a = A.message;
}
A[A[stack_pointer]++] = a;
} else {
// Appel de fonction régulier
var t;
try {
t = Q.apply(B, g);
} catch (A) {
t = A.message;
}
A[stack_pointer] -= e + 2;
A[A[stack_pointer]++] = t;
}
}
A[A[stack_pointer]++] = E;
fetch();
};
Cet opcode :
new) ou d'un appel régulierA[4961] = function () {
var Q = {};
for (var E = readUint16(), e = 0; e < E; e++) {
var D = A[--A[stack_pointer]]; // Premier POP
var g = A[--A[stack_pointer]]; // Deuxième POP
Q[D] = g;
}
A[A[stack_pointer]++] = Q;
fetch();
};
Cet opcode construit des littéraux objet en :
objet[premierDepilé] = deuxièmeDepiléChaque instruction se termine par l'appel de fetch(), qui prépare l'instruction suivante :
function fetch() {
var Q = A[instruction_pointer];
var B = A[vm_start + Q];
A[instruction_pointer] = Q + 1;
var E = A[4783 + B];
A[current_opcode_handler] = E;
A[current_opcode_id] = B;
}
Cette fonction :
current_opcode_handler et current_opcode_idCela reflète la logique du répartiteur, créant un cycle fetch-decode-exécuter typique des architectures de VM.
Pour faciliter l'analyse, un désassembleur de preuve de concept (disasm.js) a été développé pour convertir le bytecode de la VM en assembleur lisible par l'homme.
Le désassembleur fonctionne en deux passes :
La première passe scanne le bytecode pour identifier toutes les cibles de saut. Cela inclut :
JMP_FWD, JMP_BACK)JZ, JNZ_KEEP, JZ_KEEP)Chaque adresse cible est marquée avec un label (par exemple L_0042) pour faciliter le suivi du flux de contrôle.
La deuxième passe convertit chaque instruction en sortie pseudo-assembleur :
000042: fa 00 0a PUSH_IMM 10
000045: 19 00 19 PUSH_REG 25
000048: eb ADD
Chaque ligne comprend :
L'un des aspects les plus complexes est le décodage des valeurs immédiates intégrées dans le bytecode. La VM utilise des marqueurs de type pour indiquer comment interpréter les octets suivants :
Types simples (aucune donnée supplémentaire) :
0x28 → true0x7D → false0x4C → null0x3D → undefinedPetits entiers (0-127) : Encodés avec le bit de poids fort
0x85 → 5 (0x80 | 5)Chaînes : Encodées par XOR et terminées par null
0x67, clé XOR commençant à 1830x27, clé XOR commençant à 46Types numériques :
0x6F + 1 octet0x61 + 2 octets (big-endian)0x65 + 3 octets (big-endian)0x54 + 4 octets (big-endian)0x05 + 8 octetsL'encodage XOR pour les chaînes est simple mais empêche une inspection occasionnelle :
let str = '';
let xorKey = 183; // Clé initiale pour les chaînes ASCII
let ch;
while ((ch = readByte() ^ (xorKey++ & 0xFF)) !== 0) {
str += String.fromCharCode(ch);
}
Certains opcodes nécessitent un traitement personnalisé :
CLOSURE (opcode 136) : Crée des fonctions/fermetures avec des variables capturées
Format : CLOSURE locales, nombre_captures, [indices_capture...], décalage_saut
Le décalage de saut pointe après le corps de la fonction, permettant à la VM de sauter par-dessus la définition de fonction lors de l'exécution linéaire.
PUSH_MULTI_IMM (opcode 96) : Pousse plusieurs valeurs à la fois
Format : PUSH_MULTI_IMM nombre, val1, val2, ...
# Désassembler depuis un fichier
node disasm.js bytecode.txt
; Désassemblage VM DataDome
; Taille du bytecode : 5428 octets
; Constantes VM : VM_START=5258, OPCODE_BASE=4783
000000: 88 01 00 04 CLOSURE locals=1, captures=[0, 4], corps=L_0006, fin=L_0a3f
L_0006:
000006: fa 67 ... PUSH_IMM "window"
00001f: 2b PUSH_WINDOW
000020: 02 SET
000021: fa 67 ... PUSH_IMM "navigator"
00003a: 19 00 00 PUSH_REG 0
00003d: fa 67 ... PUSH_IMM "navigator"
000056: ee GET
000057: 02 SET
...
Ce format de sortie permet de :
Je suis encore assez novice en matière de VM, donc prenez tout avec des pincettes. L'IA a été utilisée pour aider à documenter le code et à rédiger certaines parties de ce readme (parce que la rédaction de documentation est pénible).
Ceci est purement à des fins éducatives / de recherche en sécurité. Aucun solveur ni contournement inclus – juste documenter le fonctionnement de la VM parce que c'est vraiment intéressant.
DataDome : si vous lisez ceci, bonjour !!! C'est juste moi qui essaie d'obtenir une bourse 🙏. Ne me poursuivez pas en justice, je suis fauché comme pas possible. Si vous avez un problème avec ce dépôt, faites-le moi savoir et on peut en discuter :)
Désolé pour ceux qui pensaient que j'allais parler de l'intérieur de la VM... ça n'arrivera pas
NE ME DM PAS POUR ME DEMANDER UNE API DATADOME, JE NE VOUS AIDERAI PAS