Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
Tools/GitHubGitHub/b9ph0met/px-vm
Dynamic Analysis (Sandboxing)IDS/IPS EvasionReverse EngineeringWeb SecurityMalware AnalysisCryptographyBinary AnalysisPapers & ResearchLearning & EducationAnti-BotFingerprint Spoofing
551465 months agoReviewed by Kitploit

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
GitHub
b9ph0met/px-vm

px-vm

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.

View Repository
Share

PerimeterX Auditor VM Analysis

Summary

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:

  • Bytecode extraction and 5-layer decryption pipeline
  • Opcode table reconstruction (107 base + 40 honey + 24 padding + 16 superinstruction groups)
  • Constants pool decryption (1230 entries, 1095 encrypted strings)
  • CFG-based disassembler with superinstruction sub-dispatch resolution
  • Stack emulation cleaner producing readable pseudo-code
  • Anti-analysis techniques: honey opcodes, overlapping instructions, code integrity hashing

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.

Background

On Thursday April 2nd 2026, PerimeterX deployed a new bytecode VM as part of their bot detection pipeline.

What's Inside

auditor.js doesn't look like a normal PX sensor script. Instead of the usual obfuscated property lookups and collector functions:

  • ( through ), the encrypted VM program split across variables
8 massive base64 strings
_fg0
_fg7
  • A XOR decryption function (_dp) with a per-site key (_pk) that unpacks the program JSON
  • A Fisher-Yates shuffle that permutes the opcode table so bytecode values differ per build
  • A dispatch loop with 107+ case handlers, the VM interpreter
  • BigInt arithmetic for RSA encryption of the fingerprint output
  • A code integrity hash (_0x8df7) that hashes the VM's own source to derive a decryption key, so any modification silently breaks bytecode decryption
  • Step 1: Bytecode Extraction

    The VM program is split across 8 variables, concatenated, then decrypted via _dp() using a per-site XOR cipher keyed by _pk:

    root@kitploit:~
    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.

    root@kitploit:~
    node extractor.js
    # -> program.json
    

    Program Structure

    FieldDescription
    sSeed (12755), drives all cryptographic operations
    nNonce (1603730985), per-program randomization
    gGenerator flag, enables integrity hash decryption layer
    xEncrypted flag, constants are XOR-encrypted
    cConstants pool, 1230 entries
    fFunctions, 112 entries with encrypted bytecode
    eEntry point, function index 0

    Step 2: Constants Decryption

    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:

    BitTestChrome
    0typeof window.matchMedia === "function"1
    1document.elementFromPoint exists1
    2typeof window.requestAnimationFrame === "function"1
    3typeof window.getComputedStyle === "function"1
    4CSS.supports exists1
    5navigator.sendBeacon exists1
    6document.execCommand exists1
    7process.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.

    root@kitploit:~
    node decrypt_constants.js
    # -> program_decrypted.json, constants_table.txt
    

    What the Constants Reveal

    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

    Step 3: Opcode Table

    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 asSub-byteActually executes
    FOR_IN_NEXT74FOR_IN_NEXT
    FOR_IN_NEXT100MAKE_CLOSURE
    ASSIGN_OP_VAR165ASSIGN_OP_VAR
    ASSIGN_OP_VAR37JMP
    GET_VAR_PROP_C143SET_VAR_POP
    GET_VAR_PROP_C23JMP_NULLISH

    The opcode table is shuffled via Fisher-Yates seeded by the effective seed, so bytecode values differ per build.

    root@kitploit:~
    node build_opcodes.js
    # -> opcode_table.json, opcode_table.txt
    

    Step 4: CFG Builder

    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.

    Why Not a Linear Disassembler

    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.

    Five-Layer Bytecode Encryption

    1. Layer 1 (_0x3ca8): Static murmur XOR on raw base64 bytes, keyed by 4008000571
    2. Layer 2 (_0xece1): Per-function XOR with two sublayers: position-dependent static key + code integrity hash key
    3. Layer 3 (_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.
    4. Opcode permutation: Fisher-Yates shuffle + position-dependent offset + block-dependent offset
    5. Per-instruction operand XOR: Operand bytes XOR'd with a key derived from instruction start position

    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.

    Superinstruction Resolution

    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.

    root@kitploit:~
    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.

    Step 5: Cleaner

    Takes CFG output and runs stack emulation to produce expression comments. Turns raw bytecode into readable pseudo-code.

    root@kitploit:~
    node cleaner.js 79     # fn79 to stdout
    

    Walks instructions in order, tracking a virtual stack. Each push/pop/call builds an expression string:

    root@kitploit:~
      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.

    VM Architecture

    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.

    Key Findings

    • RSA encryption of fingerprint output using BigInt, modPow, exponent 65537
    • SVG rendering fingerprinting via getTotalLength() and getBBox() on constructed paths
    • Full performance.timing waterfall collection
    • Anti-debugger detection markers (CC|CD-04|BREAKPOINT-005)
    • Function 79 is the main fingerprint collector (8361 bytes, ~1200 instructions)

    Notes

    AI has been used to help document the code, write tooling, and draft this readme.

    Disclaimer

    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]

    Download Tool