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.
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 pathconst 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:
- Bytecode: the real program (sometimes time-locked or seed-bound)
- A dispatcher: private opcodes / handler table
- A host bridge: calls into
window, DOM, crypto, canvas, etc. - Rotation: yesterday’s script body is not today’s
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:
| Layer | What it is | What “advanced” looks like |
|---|---|---|
| Sensor / cookie | Browser posts telemetry; edge updates trust cookies | High signal quality + session coherence |
| Challenge / PoW | Extra work before accept | Live config; answers tied to the session |
| Custom JS VM | Private bytecode runs the client program | Rotating images, host-bridged ops |
| WASM / native | Hard parts leave pure JS | Another language boundary for reverse engineers |
| Edge policy | Who gets soft green vs hard block | Pass-once ≠ “I fully reversed it” |
Sensor and cookie systems
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.
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 loopfunction 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)
| Question | What 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.
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.
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 AST | Private ISA; JS is only the host |
| One reverse can last months | Bodies / seeds rotate on short cycles |
| String decoder unlocks readability | Decoder unlocks a bytecode stream, not intent |
| Opcode maps are stable folklore | Handler indices and knobs rematerialize per body |
| Crypto is a function you can name | Crypto may be a region of VM program you must prove |
| Soft site green ≈ success story | Soft 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
- Part 1 (this post). Why VM antibots exist, taxonomy, anatomy, rotation tax.
- Part 2. Finding a dispatch loop in the abstract, and what “stable shape vs rotating program” means.
- 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