
Reverse engineering toolkit for PerimeterX's bytecode VM, featuring a CFG-based disassembler, 5-layer decryption pipeline, opcode table reconstruction, and stack emulation cleaner for security research on bot detection fingerprinting.
This repository documents the reverse engineering of PerimeterX's auditor.js, a bytecode virtual machine used as a secondary fingerprinting layer in PX's bot detection pipeline. This analysis covers:
Note: This repository covers only one (static) VM version and is intended for security research and analysis purposes. It does not include dynamic solvers or production solver implementations.
On Thursday April 2nd 2026, PerimeterX deployed a new bytecode VM as part of their bot detection pipeline.
auditor.js doesn't look like a normal PX sensor script. Instead of the usual obfuscated property lookups and collector functions:
_fg0 through _fg7), the encrypted VM program split across variables_dp) with a per-site key (_pk) that unpacks the program JSON_0x8df7) that hashes the VM's own source to derive a decryption key, so any modification silently breaks bytecode decryptionThe VM program is split across 8 variables, concatenated, then decrypted via _dp() using a per-site XOR cipher keyed by _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("");
}
The result is a JSON object with obfuscated two-character key names (e.g., "uo" for seed, "dk" for nonce). A mapping table converts them to standard names.
node extractor.js
# -> program.json
| Field | Description |
|---|---|
s | Seed (12755), drives all cryptographic operations |
n | Nonce (1603730985), per-program randomization |
g | Generator flag, enables integrity hash decryption layer |
x | Encrypted flag, constants are XOR-encrypted |
c | Constants pool, 1230 entries |
f | Functions, 112 entries with encrypted bytecode |
e | Entry point, function index 0 |
All 1095 string constants are encrypted with two layers:
Layer 1: Static murmur XOR keyed by 4008000571, position-dependent.
Layer 2: PRNG stream XOR using a glibc LCG, seeded by combining the program seed with each constant's index via Knuth's multiplicative hash.
Before decryption, the seed is XOR'd with an environment fingerprint (_0xaf48), an 8-bit bitmask computed by probing browser APIs:
| Bit | Test | Chrome |
|---|---|---|
| 0 | typeof window.matchMedia === "function" | 1 |
| 1 | document.elementFromPoint exists | 1 |
| 2 | typeof window.requestAnimationFrame === "function" | 1 |
| 3 | typeof window.getComputedStyle === "function" | 1 |
| 4 | CSS.supports exists | 1 |
| 5 | navigator.sendBeacon exists | 1 |
| 6 | document.execCommand exists | 1 |
| 7 | process.versions.node exists (Node.js) | 0 |
For Chrome: _0xaf48 = 0b01111111 = 127, giving effective seed = 12755 ^ 127 = 12716.
This means the same program produces different decryption results in different environments. Running it in Node.js vs Chrome vs Firefox yields different seeds.
node decrypt_constants.js
# -> program_decrypted.json, constants_table.txt
The decrypted strings tell us exactly what the VM fingerprints:
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 cryptography: BigInt, modPow, AQAB (65537 in base64), modulusLength, shiftLeft, shiftRight, getRandomValues
DOM/SVG probing: http://www.w3.org/2000/svg, createElementNS, getBoundingClientRect, getTotalLength, getBBox
PX field names: mtr, tst, mst, enc, sbx, fstec, pdc, prb, wvi, wva, pti, dis, los, cv, sc, jd, ads, enve, init
Endpoint references: https://fst-ec.perimeterx.net/?id=
Anti-debugger: _CMP_RCX_07;_JNZ_0x0A_EB_CC, CC|CD-04|BREAKPOINT-005
107 base opcodes covering the full JavaScript language, plus dynamically generated noise:
40 honey opcodes are alternate implementations of arithmetic/comparison ops using mathematically equivalent but syntactically different expressions. ADD might appear as (a^b) + 2*(a&b) or -((-a)-b) or a-(-b). Each base opcode can have up to 3 variants, generated deterministically from the seed. A simple ADD instruction can appear as 4 different bytecode values within the same program, breaking pattern-matching approaches.
24 padding opcodes are allocated in the permutation but have no handlers and are never emitted. They exist to expand the opcode space and make the shuffle harder to invert.
16 superinstruction groups are the most important anti-analysis feature. When the dispatch loop resolves an opcode to a superinstruction leader, the handler reads one additional byte from the bytecode stream and dispatches to a sub-handler. The sub-handler can be a completely different operation:
| Leader resolves as | Sub-byte | Actually executes |
|---|---|---|
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 |
The opcode table is shuffled via Fisher-Yates seeded by the effective seed, so bytecode values differ per build.
node build_opcodes.js
# -> opcode_table.json, opcode_table.txt
The core of the toolkit. cfg.js builds a control flow graph by following all execution paths from PC=0, decoding each instruction with the correct encryption context.
PX uses overlapping instructions at block boundaries. The same bytes decode as operands on one execution path and as opcodes on another, depending on the block encryption context. A linear scan decodes each byte position once and misses the alternate path. The CFG follows both fall-through and jump edges, decoding each path independently.