
Kit de engenharia reversa para a máquina virtual de bytecode da PerimeterX, apresentando um desmontador baseado em CFG, pipeline de descriptografia de 5 camadas, reconstrução da tabela de opcodes e limpador de emulação de pilha para pesquisa de segurança em impressão digital de detecção de bots.
Este repositório documenta a engenharia reversa do auditor.js da PerimeterX, uma máquina virtual bytecode usada como camada secundária de fingerprinting no pipeline de detecção de bots do PX. Esta análise cobre:
Nota: Este repositório cobre apenas uma versão (estática) da VM e é destinado a fins de pesquisa de segurança e análise. Não inclui resolvedores dinâmicos ou implementações de resolvedores de produção.
Na quinta-feira, 2 de abril de 2026, a PerimeterX implantou uma nova VM bytecode como parte de seu pipeline de detecção de bots.
auditor.js não parece um script de sensor PX normal. Em vez das habituais buscas de propriedade ofuscadas e funções coletoras:
_fg0 a _fg7), o programa VM criptografado dividido entre variáveis_dp) com uma chave por site (_pk) que descompacta o JSON do programa_0x8df7) que faz hash do próprio código-fonte da VM para derivar uma chave de descriptografia, de modo que qualquer modificação silenciosamente quebre a descriptografia do bytecodeO programa VM é dividido em 8 variáveis, concatenado e então descriptografado via _dp() usando uma cifra XOR por site chaveada por _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("");
}
O resultado é um objeto JSON com nomes de chave ofuscados de dois caracteres (ex.: "uo" para seed, "dk" para nonce). Uma tabela de mapeamento os converte para nomes padrão.
node extractor.js
# -> program.json
Todas as 1095 constantes string são criptografadas com duas camadas:
Camada 1: Murmur XOR estático chaveado por 4008000571, dependente da posição.
Camada 2: XOR de fluxo PRNG usando um LCG glibc, semeado combinando a seed do programa com o índice de cada constante via hash multiplicativo de Knuth.
Antes da descriptografia, a seed é submetida a XOR com uma impressão digital do ambiente (_0xaf48), uma máscara de bits de 8 bits calculada sondando APIs do navegador:
Para Chrome: _0xaf48 = 0b01111111 = 127, resultando em seed efetiva = 12755 ^ 127 = 12716.
Isso significa que o mesmo programa produz diferentes resultados de descriptografia em diferentes ambientes. Executá-lo no Node.js vs Chrome vs Firefox produz seeds diferentes.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
As strings descriptografadas nos dizem exatamente o que a VM usa como impressão digital:
Fingerprinting de navegador: screenWidth, screenHeight, innerWidth, innerHeight, devicePixelRatio, colorDepth, platform, userAgent, language, timezone, timezoneOffset, forcedColors, highContrast
Temporização de desempenho: navigationStart, domComplete, domLoading, fetchStart, requestStart, responseEnd, secureConnectionStart, serverTiming
Criptografia RSA: BigInt, modPow, AQAB (65537 em base64), modulusLength, shiftLeft, shiftRight, getRandomValues
Sondagem DOM/SVG: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
Nomes de campos PX: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, , ,
Referências de endpoint: https://fst-ec.perimeterx.net/?id=
Anti-debugger: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 opcodes base cobrindo toda a linguagem JavaScript, além de ruído gerado dinamicamente:
40 opcodes honey são implementações alternativas de operações aritméticas/comparação usando expressões matematicamente equivalentes mas sintaticamente diferentes. ADD pode aparecer como (a^b) + 2*(a&b) ou -((-a)-b) ou a-(-b). Cada opcode base pode ter até 3 variantes, geradas deterministicamente a partir da seed. Uma simples instrução ADD pode aparecer como 4 valores bytecode diferentes dentro do mesmo programa, quebrando abordagens de correspondência de padrões.
24 opcodes padding são alocados na permutação mas não possuem manipuladores e nunca são emitidos. Existem para expandir o espaço de opcodes e dificultar a inversão do shuffle.
16 grupos de superinstruções são a característica anti-análise mais importante. Quando o loop de despacho resolve um opcode para um líder de superinstrução, o manipulador lê um byte adicional do fluxo de bytecode e despacha para um sub-manipulador. O sub-manipulador pode ser uma operação completamente diferente:
A tabela de opcodes é embaralhada via Fisher-Yates semeado pela seed efetiva, de modo que os valores bytecode diferem por build.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
O núcleo do kit de ferramentas. cfg.js constrói um grafo de fluxo de controle seguindo todos os caminhos de execução a partir de PC=0, decodificando cada instrução com o contexto de criptografia correto.
PX usa instruções sobrepostas nos limites dos blocos. Os mesmos bytes decodificam como operandos em um caminho de execução e como opcodes em outro, dependendo do contexto de criptografia do bloco. Uma varredura linear decodifica cada posição de byte uma vez e perde o caminho alternativo. O CFG segue arestas de passagem (fall-through) e de salto, decodificando cada caminho independentemente.
_0x3ca8): Murmur XOR estático nos bytes base64 brutos, chaveado por 4008000571_0xece1): XOR por função com duas subcamadas: chave estática dependente da posição + chave hash de integridade de código_0x427d): XOR rotativo por bloco. Cada bloco de criptografia (definido por limites fn.bl) recebe um XOR adicional derivado da chave da função e do índice do bloco. O bloco 0 não é criptografado no primeiro acesso; blocos 1+ são criptografados. É por isso que um desmontador linear funciona para o primeiro bloco mas produz lixo para blocos subsequentes.O CFG aplica todas as cinco camadas de forma não destrutiva (XOR de operando calculado on-the-fly, não in-place) para que regiões de instrução sobrepostas não se corrompam mutuamente.
Para cada líder de superinstrução, o CFG lê o sub-byte, consulta o manipulador real em super_groups.json e decodifica o operando para o opcode real. Destinos de salto de opcodes de salto fundidos (ex.: o que parece ser ASSIGN_OP_VAR mas é na verdade JMP) são seguidos corretamente.
node cfg.js # todas as funções -> cfg_output/
node cfg.js 79 # função única para stdout
Verificado contra traces de execução do navegador: 600 instruções traçadas em 14 funções, 0 divergências nos deltas da pilha. Todos os 34 opcodes únicos validados. Despacho de superinstrução confirmado como correto para todos os 10 grupos líderes que executaram durante a inicialização.
Pega a saída do CFG e executa emulação de pilha para produzir comentários de expressão. Transforma bytecode bruto em pseudo-código legível.
node cleaner.js 79 # fn79 para stdout
Percorre as instruções em ordem, rastreando uma pilha virtual. Cada push/pop/chamada constrói uma string de expressão:
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")
Apenas ruído aritmético/comparação puro em uma pilha vazia é suprimido. Todo o resto é mantido, já que o CFG já filtrou honey e padding.
112 funções, 5435 instruções mantidas, 207 ruídos suprimidos. fn79 (o coletor de impressões digitais, 1109 instruções) tem 85% de cobertura de comentários de expressão.
VM baseada em pilha com pilha de 256 slots, cadeia de escopo, cadeia de manipuladores try/catch e pilha de iterador for-in. O loop de despacho lê opcodes de 2 bytes em little-endian, resolve através de permutação + XOR de posição + deslocamento de bloco, descriptografa operandos in-place, executa o manipulador e, em seguida, re-criptografa operandos para que o bytecode nunca seja totalmente descriptografado na memória.
BigInt, modPow, expoente 65537getTotalLength() e getBBox() em caminhos construídosperformance.timingCC|CD-04|BREAKPOINT-005)IA foi usada para ajudar a documentar o código, escrever ferramentas e redigir este readme.
Puramente para fins educacionais/pesquisa de segurança. Sem resolvedores ou bypasses, apenas documentando como a VM funciona porque é genuinamente interessante.
Se alguém da PerimeterX/HUMAN Security tiver preocupações sobre este repositório, sinta-se à vontade para entrar em contato: [email protected]
| Campo | Descrição |
|---|
s | Seed (12755), direciona todas as operações criptográficas |
n | Nonce (1603730985), randomização por programa |
g | Flag de gerador, ativa a camada de descriptografia por hash de integridade |
x | Flag criptografada, constantes são criptografadas com XOR |
c | Pool de constantes, 1230 entradas |
f | Funções, 112 entradas com bytecode criptografado |
e | Ponto de entrada, índice de função 0 |
| Bit | Teste | 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| Líder resolve como | Sub-byte | Executa na verdade |
|---|
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 |