
Kit di reverse engineering per la VM bytecode di PerimeterX, con un disassemblatore basato su CFG, pipeline di decrittazione a 5 livelli, ricostruzione della tabella degli opcode e pulitore di emulazione stack per la ricerca sulla sicurezza nel fingerprinting del rilevamento bot.
Questo repository documenta il reverse engineering di auditor.js di PerimeterX, una macchina virtuale basata su bytecode utilizzata come livello secondario di fingerprinting nella pipeline di rilevamento bot di PX. Questa analisi copre:
Nota: Questo repository copre solo una versione (statica) della VM ed è inteso per scopi di ricerca sulla sicurezza e analisi. Non include solver dinamici o implementazioni di solver per produzione.
Giovedì 2 aprile 2026, PerimeterX ha distribuito una nuova VM basata su bytecode come parte della loro pipeline di rilevamento bot.
auditor.js non assomiglia a un normale script sensore PX. Invece delle solite ricerche di proprietà offuscate e funzioni di raccolta:
_fg0 a _fg7), il programma VM crittato suddiviso tra variabili_dp) con una chiave per sito (_pk) che decomprime il JSON del programma_0x8df7) che esegue l'hash del codice sorgente della VM per derivare una chiave di decrittazione, in modo che qualsiasi modifica rompa silenziosamente la decrittazione del bytecodeIl programma VM è suddiviso in 8 variabili, concatenate e poi decrittate tramite _dp() usando un cifrario XOR per sito con chiave _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("");
}
Il risultato è un oggetto JSON con nomi di chiave offuscati di due caratteri (es. "uo" per seed, "dk" per nonce). Una tabella di mappatura li converte in nomi standard.
node extractor.js
# -> program.json
Tutte le 1095 stringhe costanti sono crittate con due livelli:
Livello 1: Murmur XOR statico con chiave 4008000571, dipendente dalla posizione.
Livello 2: XOR del flusso PRNG utilizzando un LCG di glibc, inizializzato combinando il seed del programma con l'indice di ogni costante tramite l'hash moltiplicativo di Knuth.
Prima della decrittazione, il seed viene XORato con un impronta ambientale (_0xaf48), una maschera di bit a 8 bit calcolata sondando le API del browser:
Per Chrome: _0xaf48 = 0b01111111 = 127, dando seed effettivo = 12755 ^ 127 = 12716.
Ciò significa che lo stesso programma produce risultati di decrittazione diversi in ambienti diversi. Eseguirlo in Node.js vs Chrome vs Firefox produce seed diversi.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
Le stringhe decrittate ci dicono esattamente cosa la VM sta raccogliendo come fingerprint:
Fingerprinting del browser: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Tempi di performance: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
Crittografia RSA: BigInt, modPow, AQAB (65537 in base64), modulusLength, shiftLeft, shiftRight, getRandomValues
Sondaggio DOM/SVG: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
Nomi dei campi PX: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, , ,
Riferimenti endpoint: https://fst-ec.perimeterx.net/?id=
Anti-debugger: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 opcode base che coprono l'intero linguaggio JavaScript, più rumore generato dinamicamente:
40 honey opcode sono implementazioni alternative di operazioni aritmetiche/di confronto che utilizzano espressioni matematicamente equivalenti ma sintatticamente diverse. ADD potrebbe apparire come (a^b) + 2*(a&b) o -((-a)-b) o a-(-b). Ogni opcode base può avere fino a 3 varianti, generate deterministicamente dal seed. Una semplice istruzione ADD può apparire come 4 diversi valori di bytecode all'interno dello stesso programma, rompendo gli approcci di pattern-matching.
24 padding opcode sono allocati nella permutazione ma non hanno gestori e non vengono mai emessi. Esistono per espandere lo spazio degli opcode e rendere più difficile invertire lo shuffle.
16 gruppi di superistruzioni sono la caratteristica anti-analisi più importante. Quando il ciclo di dispatch risolve un opcode in un leader di superistruzione, il gestore legge un byte aggiuntivo dal flusso di bytecode e lo invia a un sotto-gestore. Il sotto-gestore può essere un'operazione completamente diversa:
La tabella degli opcode viene mescolata tramite Fisher-Yates inizializzato con il seed effettivo, quindi i valori del bytecode differiscono per build.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
Il nucleo del toolkit. cfg.js costruisce un grafo di flusso di controllo seguendo tutti i percorsi di esecuzione da PC=0, decodificando ogni istruzione con il corretto contesto di crittazione.
PX utilizza istruzioni sovrapposte ai confini dei blocchi. Gli stessi byte vengono decodificati come operandi su un percorso di esecuzione e come opcode su un altro, a seconda del contesto di crittazione del blocco. Una scansione lineare decodifica ogni posizione di byte una volta e perde il percorso alternativo. Il CFG segue sia i bordi di fall-through che quelli di salto, decodificando ogni percorso indipendentemente.
_0x3ca8): Murmur XOR statico sui byte raw base64, con chiave 4008000571_0xece1): XOR per funzione con due sottolivelli: chiave statica dipendente dalla posizione + chiave dell'hash di integrità del codice_0x427d): XOR rolling per blocco. Ogni blocco di crittazione (definito dai confini fn.bl) ottiene un XOR aggiuntivo derivato dalla chiave della funzione e dall'indice del blocco. Il blocco 0 non è crittato al primo accesso; i blocchi 1+ sono crittati. Questo è il motivo per cui un disassemblatore lineare funziona per il primo blocco ma produce spazzatura per i blocchi successivi.Il CFG applica tutti e cinque i livelli in modo non distruttivo (lo XOR degli operandi viene calcolato al volo, non in-place) in modo che le regioni di istruzioni sovrapposte non si corrompano a vicenda.
Per ogni leader di superistruzione, il CFG legge il sotto-byte, cerca il gestore effettivo in super_groups.json e decodifica l'operando per il vero opcode. I target di salto da opcode di salto fusi (es. ciò che sembra ASSIGN_OP_VAR ma è in realtà JMP) vengono seguiti correttamente.
node cfg.js # tutte le funzioni -> cfg_output/
node cfg.js 79 # singola funzione su stdout
Verificato rispetto a tracce di esecuzione del browser: 600 istruzioni tracciate in 14 funzioni, 0 discrepanze nei delta dello stack. Tutti i 34 opcode unici validati. Il dispatch delle superistruzioni confermato corretto per tutti i 10 gruppi leader eseguiti durante l'inizializzazione.
Prende l'output del CFG ed esegue l'emulazione dello stack per produrre commenti di espressione. Trasforma il bytecode grezzo in pseudo-codice leggibile.
node cleaner.js 79 # fn79 su stdout
Scorre le istruzioni in ordine, tracciando uno stack virtuale. Ogni push/pop/call costruisce una stringa di espressione:
0018 GET_VAR ; 0.0001
001e GET_VAR ; or
0024 PUSH_CONST ; "_0x166"
002b CALL_METHOD_C ; 0.0001._0x88(or, "_0x166")
...
0114 PUSH_CONST ; "fontSize"
011b PUSH_CONST ; "pdc"
0122 CALL_METHOD_C ; _0x18c.getHours("fontSize", "pdc")
Solo il rumore puramente aritmetico/di confronto su uno stack vuoto viene soppresso. Tutto il resto viene mantenuto poiché il CFG ha già filtrato honey e padding.
112 funzioni, 5435 istruzioni mantenute, 207 rumori soppressi. fn79 (il raccoglitore di fingerprint, 1109 istruzioni) ha l'85% di copertura dei commenti di espressione.
VM basata su stack con stack a 256 slot, catena di scope, catena di gestori try/catch e stack di iteratori for-in. Il ciclo di dispatch legge opcode LE a 2 byte, risolve tramite permutazione + XOR di posizione + offset di blocco, decritta gli operandi in-place, esegue il gestore, poi ricritta gli operandi in modo che il bytecode non sia mai completamente decrittato in memoria.
BigInt, modPow, esponente 65537getTotalLength() e getBBox() su percorsi costruitiperformance.timingCC|CD-04|BREAKPOINT-005)L'IA è stata utilizzata per aiutare a documentare il codice, scrivere strumenti e redigere questo readme.
Puramente per scopi educativi/ricerca sulla sicurezza. Nessun solver o bypass, solo documentazione di come funziona la VM perché è genuinamente interessante.
Se qualcuno di PerimeterX/HUMAN Security ha preoccupazioni riguardo a questo repo, non esiti a contattarmi: [email protected]
| Campo | Descrizione |
|---|
s | Seed (12755), guida tutte le operazioni crittografiche |
n | Nonce (1603730985), randomizzazione per programma |
g | Flag generatore, abilita il livello di decrittazione con hash di integrità |
x | Flag crittato, le costanti sono crittate con XOR |
c | Pool di costanti, 1230 voci |
f | Funzioni, 112 voci con bytecode crittato |
e | Punto di ingresso, indice funzione 0 |
| Bit | Test | Chrome |
|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint esiste | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports esiste | 1 |
| 5 | navigator.sendBeacon esiste | 1 |
| 6 | document.execCommand esiste | 1 |
| 7 | process.versions.node esiste (Node.js) | 0 |
adsenveinit| Leader si risolve come | Sotto-byte | Effettivamente esegue |
|---|
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 |