
Kit de ingeniería inversa para la máquina virtual de bytecode de PerimeterX, que incluye un desensamblador basado en CFG, un pipeline de descifrado de 5 capas, reconstrucción de tabla de códigos de operación y un limpiador de emulación de pila para la investigación de seguridad sobre la huella digital de detección de bots.
Este repositorio documenta la ingeniería inversa de auditor.js de PerimeterX, una máquina virtual de bytecode utilizada como capa secundaria de huellas digitales en el pipeline de detección de bots de PX. Este análisis cubre:
Nota: Este repositorio cubre solo una versión (estática) de la VM y está destinado a fines de investigación y análisis de seguridad. No incluye solucionadores dinámicos ni implementaciones de solucionadores de producción.
El jueves 2 de abril de 2026, PerimeterX implementó una nueva VM de bytecode como parte de su pipeline de detección de bots.
auditor.js no parece un script de sensor PX normal. En lugar de las habituales búsquedas de propiedades ofuscadas y funciones de recolección:
_fg0 a _fg7), el programa VM cifrado dividido en variables_dp) con una clave por sitio (_pk) que desempaqueta el JSON del programa_0x8df7) que hashea la propia fuente de la VM para derivar una clave de descifrado, de modo que cualquier modificación rompe silenciosamente el descifrado del bytecodeEl programa VM está dividido en 8 variables, concatenado y luego descifrado mediante _dp() usando un cifrado XOR por sitio con clave _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("");
}
El resultado es un objeto JSON con nombres de clave de dos caracteres ofuscados (por ejemplo, "uo" para seed, "dk" para nonce). Una tabla de mapeo los convierte a nombres estándar.
node extractor.js
# -> program.json
Todas las 1095 constantes de cadena están cifradas con dos capas:
Capa 1: XOR murmur estático con clave 4008000571, dependiente de la posición.
Capa 2: XOR de flujo PRNG usando un LCG glibc, sembrado combinando la semilla del programa con el índice de cada constante mediante el hash multiplicativo de Knuth.
Antes del descifrado, la semilla se XOR con una huella digital del entorno (_0xaf48), una máscara de bits de 8 bits calculada probando APIs del navegador:
Para Chrome: _0xaf48 = 0b01111111 = 127, lo que da una semilla efectiva = 12755 ^ 127 = 12716.
Esto significa que el mismo programa produce diferentes resultados de descifrado en diferentes entornos. Ejecutarlo en Node.js vs Chrome vs Firefox produce diferentes semillas.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
Las cadenas descifradas nos dicen exactamente qué huellas digitales recoge la VM:
Huellas digitales del navegador: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Temporización de rendimiento: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
Criptografía RSA: BigInt, modPow, AQAB (65537 en base64), modulusLength, shiftLeft, shiftRight, getRandomValues
Sondeo DOM/SVG: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
Nombres de campos PX: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, , ,
Referencias de endpoints: https://fst-ec.perimeterx.net/?id=
Anti-depurador: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 opcodes base que cubren todo el lenguaje JavaScript, más ruido generado dinámicamente:
40 opcodes honey son implementaciones alternativas de operaciones aritméticas/de comparación usando expresiones matemáticamente equivalentes pero sintácticamente diferentes. ADD puede aparecer como (a^b) + 2*(a&b) o -((-a)-b) o a-(-b). Cada opcode base puede tener hasta 3 variantes, generadas deterministicamente a partir de la semilla. Una simple instrucción ADD puede aparecer como 4 valores diferentes de bytecode dentro del mismo programa, rompiendo enfoques de coincidencia de patrones.
24 opcodes de relleno se asignan en la permutación pero no tienen manejadores y nunca se emiten. Existen para expandir el espacio de opcodes y hacer que el barajado sea más difícil de invertir.
16 grupos de superinstrucciones son la característica anti-análisis más importante. Cuando el bucle de despacho resuelve un opcode a un líder de superinstrucción, el manejador lee un byte adicional del flujo de bytecode y despacha a un sub-manejador. El sub-manejador puede ser una operación completamente diferente:
La tabla de opcodes se baraja mediante Fisher-Yates con semilla efectiva, por lo que los valores de bytecode difieren por compilación.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
El núcleo del kit de herramientas. cfg.js construye un grafo de flujo de control siguiendo todas las rutas de ejecución desde PC=0, decodificando cada instrucción con el contexto de cifrado correcto.
PX usa instrucciones superpuestas en los límites de los bloques. Los mismos bytes se decodifican como operandos en una ruta de ejecución y como opcodes en otra, dependiendo del contexto de cifrado del bloque. Un escaneo lineal decodifica cada posición de byte una vez y pierde la ruta alternativa. El CFG sigue tanto los bordes de caída como los de salto, decodificando cada ruta de forma independiente.
_0x3ca8): XOR murmur estático sobre bytes base64 crudos, con clave 4008000571_0xece1): XOR por función con dos subcapas: clave estática dependiente de posición + clave de hash de integridad de código_0x427d): XOR rodante por bloque. Cada bloque de cifrado (definido por los límites de fn.bl) obtiene un XOR adicional derivado de la clave de la función y el índice del bloque. El bloque 0 no está cifrado en el primer acceso; los bloques 1+ están cifrados. Por eso un desensamblador lineal funciona para el primer bloque pero produce basura para los bloques siguientes.El CFG aplica las cinco capas de forma no destructiva (el XOR de operandos se calcula sobre la marcha, no en el lugar) para que las regiones de instrucciones superpuestas no se corrompan entre sí.
Para cada líder de superinstrucción, el CFG lee el sub-byte, busca el manejador real en super_groups.json y decodifica el operando para el opcode real. Los destinos de salto de opcodes de salto fusionados (por ejemplo, lo que parece ASSIGN_OP_VAR pero en realidad es JMP) se siguen correctamente.
node cfg.js # all functions -> cfg_output/
node cfg.js 79 # single function to stdout
Verificado contra trazas de ejecución del navegador: 600 instrucciones trazadas en 14 funciones, 0 discrepancias en deltas de pila. Los 34 opcodes únicos validados. El despacho de superinstrucciones confirmado correcto para los 10 grupos líderes que se ejecutaron durante la inicialización.
Toma la salida del CFG y ejecuta emulación de pila para producir comentarios de expresiones. Convierte el bytecode crudo en pseudocódigo legible.
node cleaner.js 79 # fn79 to stdout
Recorre las instrucciones en orden, rastreando una pila virtual. Cada push/pop/call construye una cadena de expresión:
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 se suprime el ruido puramente aritmético/de comparación en una pila vacía. Todo lo demás se mantiene ya que el CFG ya filtró honey y padding.
112 funciones, 5435 instrucciones mantenidas, 207 ruidos suprimidos. fn79 (el recolector de huellas digitales, 1109 instrucciones) tiene un 85% de cobertura de comentarios de expresiones.
VM basada en pila con pila de 256 ranuras, cadena de ámbito, cadena de manejadores try/catch y pila de iteradores for-in. El bucle de despacho lee opcodes de 2 bytes en little-endian, resuelve mediante permutación + XOR de posición + desplazamiento de bloque, descifra operandos en el lugar, ejecuta el manejador y luego vuelve a cifrar operandos para que el bytecode nunca se descifre completamente en memoria.
BigInt, modPow, exponente 65537getTotalLength() y getBBox() en rutas construidasperformance.timingCC|CD-04|BREAKPOINT-005)Se ha utilizado IA para ayudar a documentar el código, escribir herramientas y redactar este readme.
Puramente con fines educativos/de investigación en seguridad. Sin solucionadores ni bypasses, solo documentando cómo funciona la VM porque es genuinamente interesante.
Si alguien de PerimeterX/HUMAN Security tiene inquietudes sobre este repositorio, no dude en contactar: [email protected]
| Campo | Descripción |
|---|
s | Semilla (12755), impulsa todas las operaciones criptográficas |
n | Nonce (1603730985), aleatorización por programa |
g | Indicador de generador, habilita la capa de descifrado de hash de integridad |
x | Indicador cifrado, las constantes están cifradas con XOR |
c | Pool de constantes, 1230 entradas |
f | Funciones, 112 entradas con bytecode cifrado |
e | Punto de entrada, índice de función 0 |
| Bit | Prueba | 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| El líder se resuelve como | Sub-byte | Realmente ejecuta |
|---|
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 |