../

Client-Side Virtual Machines in Modern Bot Defense (Part 1)

Introduction

Every year, companies pour serious money into stopping bots from draining APIs, sniping inventory, stuffing credentials, and inflating hosting bills.

And at the center of a lot of that defense? Client-side anti-bot scripts, the ones your browser downloads, runs, and then quietly posts “I’m human” signals to the edge.

But here’s what a lot of people still get wrong: the hard part is no longer just “deobfuscate the JavaScript and rebuild the sensor.” High-end vendors moved the interesting logic into a custom virtual machine that runs private bytecode. The outer file is just a shell. The program rotates. A reverse that worked last week can be dead after a script refresh.

This is Part 1 of a series on how those VM-style antibots work, why they got so hard, and how to think about them if you’re into reverse engineering or building defenses. I’ll keep it architecture-first and product-agnostic. No drop-in solvers, no vendor-specific bypass writeups, no live header dumps.

In simpler terms: we’re going to map the pattern, not ship a playbook for any one antibot.

Old readable sensor pipeline vs modern VM pipeline
Old path: reverse a sensor program once. VM path: reverse a machine, then pay again when the program image rotates.

What you actually see in the browser

If you’ve ever opened a modern challenge script hoping for a clean collectFingerprint() function… yeah. That’s not what you get.

What you wish you saw looks something like this:

Idealized old-school sensor
// NOT real vendor code, just the mental model
async function protect() {
  const signals = {
    webdriver: navigator.webdriver,
    ua: navigator.userAgent,
    webgl: getGpuInfo(),
  };
  await fetch("/_sensor", {
    method: "POST",
    body: btoa(JSON.stringify(signals)),
  });
}

What you often get instead looks more like this (shape only, invented names):

Outer shell + hidden program
// Outer file is JS. The real program is the decoded blob.
// Invented names only, not a real product API.
SDK.scriptStart = SDK.now();
(function (blob, alphabet, thr) {
  const bc = decode(blob, alphabet, thr); // huge stream of ints
  const handlers = buildHandlers();       // opcode table at runtime
  runVM(bc, handlers);                    // private instruction set
})( /* giant string */, /* alphabet */, /* threshold */ );

That second one is the point. Vendors spent years moving critical client logic out of normal JavaScript and into private VMs: custom instruction sets, weird memory models, and program images that change session to session.

The old model (and why it used to work)

For a long time, antibot reverse engineering mostly meant:

  • Find the sensor script
  • Deobfuscate string arrays and control-flow mess
  • Figure out the field order / encoding
  • Rebuild a payload that looked close enough
  • Pair it with a browser-like TLS stack and sticky cookies

Here’s the mental model in code form:

Classic sensor path
const fields = {
  f1: canvasHash(),
  f2: webglVendor(),
  f3: timingSample(),
  // ... dozens more
};
const sensor = encodeFields(fields); // this format reverse was the hard part
setCookie("_abck", await postSensor(sensor));
// later requests ride the cookie + transport identity

That work still matters. Fingerprint quality, timing, header order, HTTP/2 settings, IP reputation, and edge policy still kill a ton of automation at scale.

But the client program itself was often still JavaScript you could eventually read. Ugly JavaScript, sure, but JavaScript. If you were patient, you could rebuild the story: collect signals → encode → post → update cookie.

The big assumption was simple: understand the program once, reuse it forever.

What changed

Vendors noticed something obvious: once the program was recovered, that reverse amortized forever. So they changed the cost structure.

Instead of shipping “obfuscated application code,” modern high-end clients often ship four pieces:

  1. Bytecode: the real program (sometimes time-locked or seed-bound)
  2. A dispatcher: private opcodes / handler table
  3. A host bridge: calls into window, DOM, crypto, canvas, etc.
  4. Rotation: yesterday’s script body is not today’s
Conceptual packaging (not a real API)
const client = {
  bytecode: decode(scriptBody),
  handlers: buildOpcodeTable(),
  host: { window, document, crypto, canvas },
  rotateEvery: "session | seed | time-window",
};
run(client);
// fingerprinting + crypto become VM *programs*, not normal modules

So the product isn’t mainly “hide the algorithm.” It’s: force you to re-lift a private instruction set over and over, while the edge still scores the whole identity bundle: transport, cookies, timing, reputation, challenge history.

Modern antibots aren’t just smarter fingerprints. A lot of them are client-side VMs whose job is to make the program expensive to re-acquire every time it rotates.

A quick map of modern bot defense

Not every product is a full custom VM. Mixing categories is how people talk past each other. Here’s the map I use:

LayerWhat it isWhat “advanced” looks like
Sensor / cookieBrowser posts telemetry; edge updates trust cookiesHigh signal quality + session coherence
Challenge / PoWExtra work before acceptLive config; answers tied to the session
Custom JS VMPrivate bytecode runs the client programRotating images, host-bridged ops
WASM / nativeHard parts leave pure JSAnother language boundary for reverse engineers
Edge policyWho gets soft green vs hard blockPass-once ≠ “I fully reversed it”

Classic web Bot Manager stacks (Akamai-class and peers) still live here. A versioned browser script collects environment/behavior signals, posts an encoded sensor, and the edge updates cookies.

The long pole is often not one magic field. It’s signal quality + transport + scale. These systems can be extremely hard, and they are not always “a full custom VM.” Treating them like one leads to bad reverse plans.

Challenges and proof of work

A lot of products layer challenges on top of sensors: secondary scripts, proof-of-work headers, timing camouflage, interactive gates.

PoW isn’t there to “prove humanity” in a philosophical sense. It’s there to raise the cost of industrial automation once the payload format is already understood.

Custom JavaScript VMs

This is the focus of the series.

Several commercial bot defenses ship a private interpreter that runs proprietary bytecode in the browser. I’m not going to turn this series into a Kasada or Shape/F5 solution guide. Those names show up in public research because they’re good examples of the pattern, not because I’m going to hand you a reverse of either one.

Public writeups (for example the nullpt.rs Nike VM series) already established the broad idea years ago: outer JS is a shell, the real program is bytecode, and the stack keeps rotating. That idea has only gotten denser over time.

One packaging pattern you’ll see discussed in public RE is a split between a short-lived “launcher” and a heavier “kernel” seed. Names below are reverse-engineering nicknames, not official vendor APIs, and the snippet is only a mental model:

Conceptual packaging (not a product reverse)
// conceptual only, not a named product recipe
const launcher = {
  inject(kernelSrc) { /* load the seed */ },
  config: { keys: "…", alphabet: "…", initEvent: "init" },
};

const kernel = {
  // seed-bound data: bytecode, ops, signal order
  // interpreter machinery changes less often than the data
  bytecode: /* … */,
  ops: /* … */,
  signals: /* fingerprint program */,
};
// seed lifetimes vary by product and pin; never treat public claims as frozen truth

In systems like this, fingerprinting and crypto are often programs running on the VM, not free-standing readable modules.

WASM and native boundaries

Some vendors move dispatcher pieces, control flow, or crypto into WebAssembly or mobile SDKs. That doesn’t replace the VM idea. It just relocates the expensive parts behind another boundary so pure-JS recovery is incomplete.

Edge policy

Everything client-side still ends at an edge decision. Soft targets accept noisy sessions. Hard retail and ticketing stacks do not.

Important: one green cookie on a soft site is not proof you reverse engineered the whole system. It means policy was lenient enough for that attempt.

Anatomy of a VM antibot

Strip the marketing and the public header names. Structurally, a modern client-side VM antibot usually has four parts.

Bytecode, dispatch, host bridge, and rotation inside an outer JS shell
The outer file is a shell. The program lives in bytecode, runs through a private dispatcher, talks to the browser through a host bridge, and rotates.

1. Bytecode instead of source

The “real program” is not the outer JavaScript file. The outer file is a loader + decoder + interpreter. The program is a large encoded stream, often recovered offline as tens or hundreds of thousands of integers, that only becomes meaningful once you understand the instruction set.

Here’s a tiny toy decoder so the idea is concrete (names invented; real alphabets and knobs are version-bound):

Toy decoder (pedagogical only)
// NOT a real product decoder
function decode(blob, alphabet, threshold) {
  const base = alphabet.length - threshold;
  const out = [];
  for (const ch of blob) {
    const v = alphabet.indexOf(ch);
    if (v < 0) continue;
    out.push(v < threshold ? v : v - threshold + base);
  }
  // real systems also add integrity checks, time windows, host seeds…
  return out; // e.g. 200k+ ints on a heavy script
}

That encoding may be alphabet/radix packed, integrity-checked mid-stream, time-windowed so the same body is only valid in a seed interval, or bound to host-specific constants.

From a defender’s view, static “read the business logic” audits of the outer script miss the point. From a reverse engineer’s view, the first milestone isn’t “pretty print.” It’s recover a stable decode path for this specific pin.

2. Dispatch and handlers

At the center is a loop: read an opcode, run a handler, maybe jump. In JavaScript-shaped terms (still conceptual, real loops are mangled):

Conceptual VM loop
function runVM(bc, handlers) {
  const st = { ip: 0, reg: new Array(16).fill(0) };
  while (st.ip < bc.length) {
    const op = bc[st.ip++];
    const h = handlers[op]; // often: table[bc[ip++]](state)
    if (!h) throw new Error("unknown op " + op);
    h(st, bc);              // mutates ip / regs / may call host
  }
}

In practice the loop is messier. Program counters live in nested state objects. Handler tables are Proxy-wrapped or built at runtime. Opcode indices may rotate even when the outer product version string does not.

Two questions that must stay separate:
1) Did the dispatch shape change? (cheap gate: rebuild tools or not)
2) Did the program image change? (expected every rotation: re-decode and re-map)

QuestionWhat it tells you
Where is the loop?You found the machine
How many handlers / what shapes?You found the ISA surface
Shape unchanged across different SHAs?Your tools may still apply
Body / knobs changed?Re-extract. Don’t freeze constants.

Those are different questions. Mixing them is how people thrash for weeks.

3. The host bridge

A pure closed VM would be useless for fingerprinting. The interesting ops reach out into the browser: property gets on window, DOM probes, timers, WebCrypto, canvas/WebGL, network primitives.

What host ops feel like (illustrative)
const hostOps = {
  PROP_GET(obj, key) { return obj[key]; },
  CALL(fn, args)     { return fn.apply(null, args); },
  NEW(C, args)       { return new C(...args); },
  // recovered call targets often look like:
  // navigator.userAgent, canvas webgl, crypto.subtle, …
};
// bytecode rarely says "webdriver" in cleartext

That bridge is also where reverse engineers get leverage again. Host calls are observable. String pools leak header names and environment probes. Create-function / invoke patterns show where bytecode “functions” cross into real JS callables.

It’s also where static analysis gets dishonest. Multi-path joins, incomplete register models, and fake “crypto islands” show up if you densify traces without validating them. Serious reverse work keeps negative results. “This TEA-looking window collapsed under stricter control” is more valuable than a screenshot of bitops.

4. Rotation tax

This is the economic heart of the design.

Same version string, different script bodies and knobs over sessions
A stable version label does not mean a frozen program. Bodies, alphabets, and knobs rotate.
How I’d track a pin (generic placeholders)
const pin = {
  version: "vX.Y.Z",           // sometimes stable across a day
  sessions: [
    { sha: "aaa…", mult: 2, thr: 38 },
    { sha: "bbb…", mult: 5, thr: 40 },
    { sha: "ccc…", mult: 2, thr: 38 },
  ],
  dispatchShape: "UNCHANGED",  // separate signal from SHA / knobs
};
// wrong: freeze mult from session A onto session C
// right: re-extract knobs per body

Across products you see combinations of:

  • Per-request or per-session script bodies
  • Seed-bound kernels (short seed lifetimes)
  • Time-lock windows on encoded images
  • Stable product version strings with unstable SHAs and knobs
  • Opcode / handler index remaps that break frozen tables

The attacker’s cost shifts from one hard reverse to continuous re-acquisition. The defender’s win condition isn’t “unbreakable forever.” It’s “expensive enough, often enough, under real edge policy.”

Why this is harder than “just deobfuscation”

People still say “the script is obfuscated.” That undersells the change.

Then (classic obfuscation)Now (VM antibots)
Ugly but still a JS ASTPrivate ISA; JS is only the host
One reverse can last monthsBodies / seeds rotate on short cycles
String decoder unlocks readabilityDecoder unlocks a bytecode stream, not intent
Opcode maps are stable folkloreHandler indices and knobs rematerialize per body
Crypto is a function you can nameCrypto may be a region of VM program you must prove
Soft site green ≈ success storySoft greens are common; hard targets and scale are not

The hard parts become scientific: locate dispatch, classify handlers, recover control flow, bind host seeds, and only then talk about encryption or payload assembly, with proof, not vibes.

What’s next in this series

  1. Part 1 (this post). Why VM antibots exist, taxonomy, anatomy, rotation tax.
  2. Part 2. Finding a dispatch loop in the abstract, and what “stable shape vs rotating program” means.
  3. Part 3. Rotation tax in practice: version labels that stay still, knobs that move, how to instrument a pin without turning it into a product solver.

Only Part 1 is published today. There is no planned “break Kasada / Shape / F5” case-study arc. Named products show up only as public pattern references. The rest of the series stays architecture and methodology.

Takeaways

The headline is pretty simple:

Advanced antibots moved the program into a private virtual machine and put that machine on a rotation schedule. The browser still looks normal. The reverse engineering problem does not.

In Part 2, we’ll stay generic: how to think about locating a dispatch loop, and what “unchanged” means when a script body SHA just flipped. Still not a recipe for any specific vendor.

Hope this map helps. Part 2 will get more technical, not more product-specific.


Series: Client-Side Virtual Machines in Modern Bot Defense · Part 1 of 3