Boîte à outils de rétro-ingénierie pour la machine virtuelle bytecode de PerimeterX, comprenant un désassembleur basé sur CFG, un pipeline de déchiffrement à 5 couches, une reconstruction de table d'opcodes, et un nettoyeur d'émulation de pile pour la recherche en sécurité sur la détection des bots par empreinte numérique.
Ce dépôt documente le rétro-ingénierie de auditor.js de PerimeterX, une machine virtuelle à bytecode utilisée comme couche secondaire d'empreinte digitale dans la chaîne de détection de bots de PX. Cette analyse couvre :
Note : Ce dépôt ne couvre qu'une seule version (statique) de la VM et est destiné à la recherche en sécurité et à des fins d'analyse. Il n'inclut pas de solveurs dynamiques ni d'implémentations de solveurs de production.
Le jeudi 2 avril 2026, PerimeterX a déployé une nouvelle VM à bytecode dans le cadre de leur pipeline de détection de bots.
auditor.js ne ressemble pas à un script de capteur PX normal. Au lieu des habituelles recherches de propriétés obfusquées et des fonctions de collecte :
_fg0 à _fg7), le programme VM chiffré réparti entre des variables_dp) avec une clé par site (_pk) qui décompresse le JSON du programme_0x8df7) qui hache le propre code source de la VM pour dériver une clé de déchiffrement, de sorte que toute modification interrompt silencieusement le déchiffrement du bytecodeLe programme de la VM est réparti sur 8 variables, concaténées, puis déchiffrées via _dp() à l'aide d'un chiffrement XOR par site avec la clé _pk :
var _pk = 893686289;
function _dp(_b) {
var _r = atob(_b), _o = new Array(_r.length);
for (var _i = 0; _i < _r.length; _i++) {
_o[_i] = String.fromCharCode(
_r.charCodeAt(_i) ^ (((_pk >>> (8 * (_i % 4))) ^ Math.imul(_i + 1, 0x6B8B4567)) & 0xFF)
);
}
return _o.join("");
}
Le résultat est un objet JSON avec des noms de clés obfusqués de deux caractères (par ex., "uo" pour la graine, "dk" pour le nonce). Une table de correspondance les convertit en noms standards.
node extractor.js
# -> program.json
| Champ | Description |
|---|---|
s | Graine (12755), pilote toutes les opérations cryptographiques |
n | Nonce (1603730985), randomisation par programme |
g | Drapeau générateur, active la couche de déchiffrement par hachage d'intégrité |
x | Drapeau chiffré, les constantes sont chiffrées par XOR |
c | Pool de constantes, 1230 entrées |
f | Fonctions, 112 entrées avec bytecode chiffré |
e | Point d'entrée, index de fonction 0 |
Les 1095 chaînes constantes sont toutes chiffrées avec deux couches :
Couche 1 : XOR statique murmur avec la clé 4008000571, dépendante de la position.
Couche 2 : XOR de flux PRNG utilisant un LCG glibc, initialisé en combinant la graine du programme avec l'index de chaque constante via le hachage multiplicatif de Knuth.
Avant le déchiffrement, la graine est XORée avec une empreinte environnementale (_0xaf48), un masque de 8 bits calculé en interrogeant les API du navigateur :
| Bit | Test | Chrome |
|---|---|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint existe | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports existe | 1 |
| 5 | navigator.sendBeacon existe | 1 |
| 6 | document.execCommand existe | 1 |
| 7 | process.versions.node existe (Node.js) | 0 |
Pour Chrome : _0xaf48 = 0b01111111 = 127, soit une graine effective = 12755 ^ 127 = 12716.
Cela signifie que le même programme produit des résultats de déchiffrement différents selon l'environnement. L'exécuter sous Node.js vs Chrome vs Firefox donne des graines différentes.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
Les chaînes déchiffrées nous indiquent exactement ce que la VM prend comme empreinte :
Empreinte du navigateur : screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Chronométrage des performances : navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
Cryptographie RSA : BigInt, modPow, AQAB (65537 en base64), modulusLength, shiftLeft, shiftRight, getRandomValues
Sondage DOM/SVG : http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
Noms de champs PX : mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, ads, enve, init
Références de points de terminaison : https://fst-ec.perimeterx.net/?id=
Anti-débogueur : _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 opcodes de base couvrant l'ensemble du langage JavaScript, plus du bruit généré dynamiquement :
40 opcodes leurres sont des implémentations alternatives des opérations arithmétiques/comparaisons utilisant des expressions mathématiquement équivalentes mais syntaxiquement différentes. ADD pourrait apparaître comme (a^b) + 2*(a&b) ou -((-a)-b) ou a-(-b). Chaque opcode de base peut avoir jusqu'à 3 variantes, générées de manière déterministe à partir de la graine. Une simple instruction ADD peut apparaître sous 4 valeurs de bytecode différentes au sein du même programme, brisant les approches par reconnaissance de motifs.
24 opcodes de remplissage sont alloués dans la permutation mais n'ont pas de gestionnaires et ne sont jamais émis. Ils existent pour agrandir l'espace des opcodes et rendre le mélange plus difficile à inverser.
16 groupes de super-instructions sont la fonctionnalité anti-analyse la plus importante. Lorsque la boucle de dispatch résout un opcode en un leader de super-instruction, le gestionnaire lit un octet supplémentaire dans le flux de bytecode et se dirige vers un sous-gestionnaire. Le sous-gestionnaire peut être une opération complètement différente :
| Leader résolu en | Sous-octet | Exécute réellement |
|---|---|---|
FOR_IN_NEXT | 74 | FOR_IN_NEXT |
FOR_IN_NEXT | 100 | MAKE_CLOSURE |
ASSIGN_OP_VAR | 165 | ASSIGN_OP_VAR |
ASSIGN_OP_VAR | 37 | JMP |
GET_VAR_PROP_C | 143 | SET_VAR_POP |
GET_VAR_PROP_C | 23 | JMP_NULLISH |
La table d'opcodes est mélangée via Fisher-Yates avec une graine effective, de sorte que les valeurs de bytecode diffèrent d'une build à l'autre.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
Le cœur de la boîte à outils. cfg.js construit un graphe de flot de contrôle en suivant tous les chemins d'exécution à partir de PC=0, en décodant chaque instruction avec le contexte de chiffrement correct.