
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
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 :
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, , ,
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 :
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.
PX utilise des instructions qui se chevauchent aux limites des blocs. Les mêmes octets se décodent comme des opérandes sur un chemin d'exécution et comme des opcodes sur un autre, selon le contexte de chiffrement du bloc. Un parcours linéaire décode chaque position d'octet une fois et manque le chemin alternatif. Le CFG suit à la fois les arêtes de passage et de saut, décodant chaque chemin indépendamment.
_0x3ca8) : XOR statique murmur sur les octets bruts base64, avec la clé 4008000571_0xece1) : XOR par fonction avec deux sous-couches : clé statique dépendante de la position + clé de hachage d'intégrité du code_0x427d) : XOR roulant par bloc. Chaque bloc de chiffrement (défini par les limites fn.bl) reçoit un XOR supplémentaire dérivé de la clé de la fonction et de l'index du bloc. Le bloc 0 n'est pas chiffré lors du premier accès ; les blocs 1+ sont chiffrés. C'est pourquoi un désassembleur linéaire fonctionne pour le premier bloc mais produit du charabia pour les blocs suivants.Le CFG applique les cinq couches de manière non destructive (le XOR des opérandes est calculé à la volée, pas sur place), de sorte que les régions d'instructions qui se chevauchent ne se corrompent pas mutuellement.
Pour chaque leader de super-instruction, le CFG lit le sous-octet, cherche le gestionnaire réel dans super_groups.json, et décode l'opérande pour le véritable opcode. Les cibles de saut provenant d'opcodes de saut fusionnés (par ex., ce qui ressemble à ASSIGN_OP_VAR mais est en fait JMP) sont correctement suivies.
node cfg.js # toutes les fonctions -> cfg_output/
node cfg.js 79 # une seule fonction vers stdout
Vérifié par rapport aux traces d'exécution du navigateur : 600 instructions tracées dans 14 fonctions, 0 divergences dans les deltas de pile. Les 34 opcodes uniques validés. Le dispatch des super-instructions confirmé correct pour les 10 groupes leaders qui se sont exécutés lors de l'initialisation.
Prend la sortie du CFG et exécute une émulation de pile pour produire des commentaires d'expression. Transforme le bytecode brut en pseudo-code lisible.
node cleaner.js 79 # fn79 vers stdout
Parcourt les instructions dans l'ordre, en suivant une pile virtuelle. Chaque push/pop/appel construit une chaîne d'expression :
0018 GET_VAR ; 0.0001
001e GET_VAR ; ou
0024 PUSH_CONST ; "_0x166"
002b CALL_METHOD_C ; 0.0001._0x88(ou, "_0x166")
...
0114 PUSH_CONST ; "fontSize"
011b PUSH_CONST ; "pdc"
0122 CALL_METHOD_C ; _0x18c.getHours("fontSize", "pdc")
Seul le bruit arithmétique/de comparaison pur sur une pile vide est supprimé. Tout le reste est conservé car le CFG a déjà filtré les leurres et le remplissage.
112 fonctions, 5435 instructions conservées, 207 bruits supprimés. fn79 (le collecteur d'empreintes, 1109 instructions) a une couverture de commentaires d'expression de 85%.
VM à base de pile avec une pile de 256 emplacements, une chaîne de portée, une chaîne de gestionnaires try/catch, et une pile d'itérateurs for-in. La boucle de dispatch lit des opcodes LE de 2 octets, les résout via permutation + XOR de position + décalage de bloc, déchiffre les opérandes sur place, exécute le gestionnaire, puis rechiffre les opérandes afin que le bytecode ne soit jamais complètement déchiffré en mémoire.
BigInt, modPow, exposant 65537getTotalLength() et getBBox() sur des chemins construitsperformance.timingCC|CD-04|BREAKPOINT-005)L'IA a été utilisée pour aider à documenter le code, écrire les outils et rédiger ce README.
À des fins purement éducatives / de recherche en sécurité. Pas de solveurs ni de contournements, juste une documentation du fonctionnement de la VM car c'est réellement intéressant.
Si quelqu'un de PerimeterX/HUMAN Security a des préoccupations concernant ce dépôt, n'hésitez pas à me contacter : [email protected]
| 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 |
| 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 |
adsenveinit| 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 |