
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_fg7_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.
_0x3ca8): Static murmur XOR on raw base64 bytes, keyed by 4008000571_0xece1): Per-function XOR with two sublayers: position-dependent static key + code integrity hash key_0x427d): Per-block rolling XOR. Each encryption block (defined by fn.bl boundaries) gets an additional XOR derived from the function key and block index. Block 0 is unencrypted on first access; blocks 1+ are encrypted. This is why a linear disassembler works for the first block but produces garbage for subsequent blocks.The CFG applies all five layers non-destructively (operand XOR computed on-the-fly, not in-place) so overlapping instruction regions don't corrupt each other.
For each superinstruction leader, the CFG reads the sub-byte, looks up the actual handler in super_groups.json, and decodes the operand for the real opcode. Jump targets from fused jump opcodes (e.g., what looks like ASSIGN_OP_VAR but is actually JMP) are followed correctly.
node cfg.js # all functions -> cfg_output/
node cfg.js 79 # single function to stdout
Verified against browser execution traces: 600 instructions traced across 14 functions, 0 mismatches in stack deltas. All 34 unique opcodes validated. Superinstruction dispatch confirmed correct for all 10 leader groups that executed during init.
Takes CFG output and runs stack emulation to produce expression comments. Turns raw bytecode into readable pseudo-code.
node cleaner.js 79 # fn79 to stdout
Walks instructions in order, tracking a virtual stack. Each push/pop/call builds an expression string:
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")
Only pure arithmetic/comparison noise on an empty stack is suppressed. Everything else is kept since the CFG already filtered honey and padding.
112 functions, 5435 instructions kept, 207 noise suppressed. fn79 (the fingerprint collector, 1109 instructions) has 85% expression comment coverage.
Stack-based VM with 256-slot stack, scope chain, try/catch handler chain, and for-in iterator stack. The dispatch loop reads 2-byte LE opcodes, resolves through permutation + position XOR + block offset, decrypts operands in-place, executes the handler, then re-encrypts operands so bytecode is never fully decrypted in memory.
BigInt, modPow, exponent 65537getTotalLength() and getBBox() on constructed pathsperformance.timing waterfall collectionCC|CD-04|BREAKPOINT-005)AI has been used to help document the code, write tooling, and draft this readme.
Purely for educational/security research. No solvers or bypasses, just documenting how the VM works because it's genuinely interesting.
If anyone from PerimeterX/HUMAN Security has concerns about this repo, feel free to reach out: [email protected]