
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.