
Reverse-Engineering-Toolkit für den Bytecode-VM von PerimeterX, mit einem CFG-basierten Disassembler, einer 5-stufigen Entschlüsselungspipeline, Opcode-Tabellen-Rekonstruktion und Stack-Emulations-Bereinigung für Sicherheitsforschung zur Erkennung von Bot-Detection-Fingerprinting.
Dieses Repository dokumentiert das Reverse Engineering von PerimeterX auditor.js, einer Bytecode-Virtual Machine, die als sekundäre Fingerprinting-Ebene in der Bot-Erkennungspipeline von PX verwendet wird. Diese Analyse umfasst:
Hinweis: Dieses Repository behandelt nur eine (statische) VM-Version und dient der Sicherheitsforschung und Analyse. Es enthält keine dynamischen Löser oder Produktionslöser-Implementierungen.
Am Donnerstag, den 2. April 2026, hat PerimeterX eine neue Bytecode-VM als Teil seiner Bot-Erkennungspipeline eingesetzt.
auditor.js sieht nicht wie ein normales PX-Sensor-Skript aus. Statt der üblichen obfuskierten Eigenschaftszugriffe und Collector-Funktionen:
_fg0 bis _fg7), das verschlüsselte VM-Programm, aufgeteilt auf Variablen_dp) mit einem pro-Site-Schlüssel (_pk), die das Programm-JSON entpackt_0x8df7), der den eigenen Quellcode der VM hasht, um einen Entschlüsselungsschlüssel abzuleiten, sodass jede Modifikation die Bytecode-Entschlüsselung stillschweigend brichtDas VM-Programm ist auf 8 Variablen aufgeteilt, verkettet und dann über _dp() mit einem pro-Site-XOR-Chiffre, das durch _pk geschlüsselt wird, entschlüsselt:
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("");
}
Das Ergebnis ist ein JSON-Objekt mit obfuskierten Zweizeichen-Schlüsselnamen (z.B. "uo" für Seed, "dk" für Nonce). Eine Mapping-Tabelle wandelt sie in Standardnamen um.
node extractor.js
# -> program.json
| Feld | Beschreibung |
|---|---|
s | Seed (12755), steuert alle kryptografischen Operationen |
n | Nonce (1603730985), pro-Programm-Randomisierung |
g | Generator-Flag, aktiviert die Integritäts-Hash-Entschlüsselungsebene |
x | Verschlüsseltes Flag, Konstanten sind XOR-verschlüsselt |
c | Konstanten-Pool, 1230 Einträge |
f | Funktionen, 112 Einträge mit verschlüsseltem Bytecode |
e | Einstiegspunkt, Funktionsindex 0 |
Alle 1095 String-Konstanten sind mit zwei Ebenen verschlüsselt:
Ebene 1: Statischer Murmur-XOR, geschlüsselt mit 4008000571, positionsabhängig.
Ebene 2: PRNG-Stream-XOR mittels eines glibc-LCG, gesät durch Kombination des Programm-Seeds mit dem Index jeder Konstante mittels Knuths multiplikativem Hash.
Vor der Entschlüsselung wird der Seed mit einem Umgebungs-Fingerabdruck (_0xaf48) XOR-verknüpft, einer 8-Bit-Bitmaske, die durch Abfragen von Browser-APIs berechnet wird:
| Bit | Test | Chrome |
|---|---|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint existiert | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports existiert | 1 |
| 5 | navigator.sendBeacon existiert | 1 |
| 6 | document.execCommand existiert | 1 |
| 7 | process.versions.node existiert (Node.js) | 0 |
Für Chrome: _0xaf48 = 0b01111111 = 127, ergibt effektiven Seed = 12755 ^ 127 = 12716.
Das bedeutet, dass dasselbe Programm in verschiedenen Umgebungen unterschiedliche Entschlüsselungsergebnisse liefert. Die Ausführung in Node.js vs. Chrome vs. Firefox ergibt unterschiedliche Seeds.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
Die entschlüsselten Strings zeigen uns genau, was die VM fingerabdruckt:
Browser-Fingerprinting: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Performance-Timing: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
RSA-Kryptografie: BigInt, modPow, AQAB (65537 in Base64), modulusLength, shiftLeft, shiftRight, getRandomValues
DOM/SVG-Abfrage: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
PX-Feldnamen: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, ads, enve, init
Endpunkt-Referenzen: https://fst-ec.perimeterx.net/?id=
Anti-Debugger: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 Basis-Opcodes, die die gesamte JavaScript-Sprache abdecken, plus dynamisch generiertes Rauschen:
40 Honig-Opcodes sind alternative Implementierungen von arithmetischen/Vergleichsoperationen mit mathematisch äquivalenten, aber syntaktisch unterschiedlichen Ausdrücken. ADD könnte als (a^b) + 2*(a&b) oder -((-a)-b) oder a-(-b) erscheinen. Jeder Basis-Opcode kann bis zu 3 Varianten haben, die deterministisch aus dem Seed generiert werden. Eine einfache ADD-Instruktion kann als 4 verschiedene Bytecode-Werte innerhalb desselben Programms erscheinen und bricht Mustervergleichsansätze.
24 Padding-Opcodes werden in der Permutation zugewiesen, haben aber keine Handler und werden nie ausgegeben. Sie existieren, um den Opcode-Raum zu erweitern und das Shuffle schwerer umkehrbar zu machen.
16 Superinstruction-Gruppen sind das wichtigste Anti-Analyse-Merkmal. Wenn die Dispatch-Schleife einen Opcode als Superinstruction-Leader auflöst, liest der Handler ein zusätzliches Byte aus dem Bytecode-Stream und dispatcht an einen Sub-Handler. Der Sub-Handler kann eine völlig andere Operation sein:
| Leader löst auf als | Sub-Byte | Führt tatsächlich aus |
|---|---|---|
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 |
Die Opcode-Tabelle wird mittels Fisher-Yates-Shuffle durch den effektiven Seed gemischt, sodass Bytecode-Werte pro Build unterschiedlich sind.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
Das Kernstück des Toolkits. cfg.js erstellt einen Kontrollflussgraphen, indem es allen Ausführungspfaden ab PC=0 folgt, jede Instruktion mit dem richtigen Verschlüsselungskontext dekodiert.
PX verwendet überlappende Instruktionen an Blockgrenzen. Dieselben Bytes werden auf einem Ausführungspfad als Operanden und auf einem anderen als Opcodes dekodiert, abhängig vom Blockverschlüsselungskontext. Ein linearer Scan dekodiert jede Byteposition nur einmal und übersieht den alternativen Pfad. Der CFG folgt sowohl Fall-Through- als auch Sprungkanten und dekodiert jeden Pfad unabhängig.
_0x3ca8): Statischer Murmur-XOR auf rohen Base64-Bytes, geschlüsselt mit 4008000571_0xece1): Pro-Funktion-XOR mit zwei Unterebenen: positionsabhängiger statischer Schlüssel + Code-Integritäts-Hash-Schlüssel_0x427d): Pro-Block-rollendes XOR. Jeder Verschlüsselungsblock (definiert durch fn.bl-Grenzen) erhält ein zusätzliches XOR, das vom Funktionsschlüssel und Blockindex abgeleitet wird. Block 0 ist beim ersten Zugriff unverschlüsselt; Blöcke 1+ sind verschlüsselt. Daher funktioniert ein linearer Disassembler für den ersten Block, erzeugt aber für nachfolgende Blöcke Müll.Der CFG wendet alle fünf Ebenen nicht-destruktiv an (Operanden-XOR wird dynamisch berechnet, nicht in-place), sodass sich überlappende Instruktionsregionen nicht gegenseitig korrumpieren.
Für jeden Superinstruction-Leader liest der CFG das Sub-Byte, schlägt den tatsächlichen Handler in super_groups.json nach und dekodiert den Operanden für den echten Opcode. Sprungziele von fusionierten Sprung-Opcodes (z.B. was wie ASSIGN_OP_VAR aussieht, aber tatsächlich JMP ist) werden korrekt verfolgt.
node cfg.js # alle Funktionen -> cfg_output/
node cfg.js 79 # einzelne Funktion nach stdout
Verifiziert gegen Browser-Ausführungsspuren: 600 Instruktionen über 14 Funktionen verfolgt, 0 Abweichungen in Stack-Deltas. Alle 34 eindeutigen Opcodes validiert. Superinstruction-Dispatch für alle 10 Leader-Gruppen bestätigt, die während der Initialisierung ausgeführt wurden.
Nimmt CFG-Ausgabe und führt Stack-Emulation durch, um Ausdruckskommentare zu erzeugen. Wandelt rohen Bytecode in lesbaren Pseudocode um.
node cleaner.js 79 # fn79 nach stdout
Geht Instruktionen in der Reihenfolge durch und verfolgt einen virtuellen Stack. Jeder Push/Pop/Aufruf baut einen Ausdrucksstring auf:
0018 GET_VAR ; 0.0001
001e GET_VAR ; oder
0024 PUSH_CONST ; "_0x166"
002b CALL_METHOD_C ; 0.0001._0x88(oder, "_0x166")
...
0114 PUSH_CONST ; "fontSize"
011b PUSH_CONST ; "pdc"
0122 CALL_METHOD_C ; _0x18c.getHours("fontSize", "pdc")
Nur reines arithmetisches/Vergleichsrauschen auf einem leeren Stack wird unterdrückt. Alles andere wird beibehalten, da der CFG bereits Honig- und Padding-Instruktionen gefiltert hat.
112 Funktionen, 5435 Instruktionen behalten, 207 Rauschen unterdrückt. fn79 (der Fingerprint-Collector, 1109 Instruktionen) hat 85% Ausdruckskommentar-Abdeckung.
Stack-basierte VM mit 256-Slot-Stack, Scope-Chain, Try/Catch-Handler-Chain und For-In-Iterator-Stack. Die Dispatch-Schleife liest 2-Byte-LE-Opcodes, löst sie durch Permutation + Positions-XOR + Block-Offset auf, entschlüsselt Operanden in-place, führt den Handler aus und verschlüsselt dann Operanden wieder, sodass Bytecode nie vollständig entschlüsselt im Speicher liegt.
BigInt, modPow, Exponent 65537getTotalLength() und getBBox() auf konstruierten Pfadenperformance.timing-WasserfallsammlungCC|CD-04|BREAKPOINT-005)KI wurde verwendet, um das Dokumentieren des Codes, das Schreiben von Werkzeugen und das Verfassen dieser Readme zu unterstützen.
Rein zu Bildungs-/Sicherheitsforschungszwecken. Keine Löser oder Umgehungen, nur die Dokumentation der Funktionsweise der VM, weil sie wirklich interessant ist.
Falls jemand von PerimeterX/HUMAN Security Bedenken bezüglich dieses Repos hat, kann er sich gerne melden: [email protected]