Skip to content

Deobfuscating anti-bot JavaScript: the workflow for VM-based protections

· 20 min read
Copyright: MIT
The word DEVIRTUALIZE in monospace over a dark background with an orange opcode arrow

Open the JavaScript that an anti-bot vendor ships to your browser and the first thing you notice is that it does not want to be read. Identifiers are two-letter soup. Strings are gone, replaced by index lookups into an array you cannot find. The control flow is a single giant switch inside a while(true), and the case labels are computed at runtime. Somewhere in those forty thousand lines is the code that decides whether your session is a human or a script, and the vendor has spent real engineering effort making sure you cannot point at the line that does it.

The question this post answers is narrow and practical. Given a blob like that, how do you actually make it readable, what does the tooling do under the hood, and at what point does the standard workflow stop working? That last part matters more than the rest, because the answer for the hardest protections is “it stops working at the bytecode VM,” and understanding why is more useful than any single transform. This is a defensive and educational walkthrough. It explains how these systems are built and how analysts study them. It does not hand you a bypass.

The road map: first, what obfuscation actually is as a set of transforms, so you know what you are undoing. Then the cheap first pass, beautifying and scope analysis. Then the core technique, rewriting the abstract syntax tree with Babel to recover strings and unflatten control flow. Then runtime hooking, for when static analysis runs out of road, and the cat-and-mouse of anti-instrumentation that anti-bot code uses to notice you hooking it. Finally the wall: virtualization, where the logic you want is compiled to custom bytecode, and what the academic literature says about getting it back.

What obfuscation is, as a pipeline

Obfuscation is a chain of source-to-source transforms that preserve behaviour while destroying readability. The open-source reference implementation, javascript-obfuscator (the engine behind obfuscator.io), is worth studying precisely because it is documented. Commercial anti-bot tags are not documented, but they reuse the same primitives, so the public tool is a faithful model of the easy two-thirds of what you will meet in the wild.

The transforms stack in a rough order. String literals get pulled out of the code into a single array, then that array gets rotated by a fixed offset at load time so even the array order lies to you. Accesses go through a wrapper function, so "submit" becomes something like _0xa1(0x1f). Numbers turn into arithmetic. Identifiers get renamed to hex. Dead code gets injected to pad the surface. And then the structural one, the transform that does the most damage to a human reader: control flow flattening, which takes ordinary nested blocks and re-expresses them as a state machine. The obfuscator.io documentation describes it as converting code into a flattened switch-in-while structure where a dispatcher variable decides which block runs next. Linear logic becomes a lookup table.

readable source if (isBot(x)) block(x) string array + rotation + rename control-flow flattening _0xa1(0x1f) while(!0){...} the analyst walks this chain backwards, one transform at a time: beautify → rename by scope → inline string array → unflatten → read each step is reversible until the logic is compiled to bytecode — then it is not *The obfuscation pipeline and the reverse walk. Every layer above the bytecode line is a mechanical transform with a known inverse; virtualization breaks that property.*

The important property of everything above is that it is structure-preserving and reversible. The string array still holds the real strings. The flattened state machine still encodes the original blocks. Nothing has been thrown away, only scrambled, and a scramble has an inverse. That is the entire reason AST-based deobfuscation works, and it is exactly the property that virtualization gives up.

The cheap first pass: beautify and scope

Before any clever transform, you reformat. An obfuscated payload ships as one line, or close to it, and a pretty-printer like Prettier or js-beautify turns it back into indented, line-broken code. This does nothing semantic. It only makes the rest of the work survivable by a human, and it is the difference between scrolling a wall of text and reading a function.

Next is scope analysis, and this is where you stop treating the file as text and start treating it as a tree. The renamed identifiers are not random globally; they are consistent within their scope. _0x3f1a inside one function is a specific binding, and a scope-aware tool can give every binding a fresh, unique, readable name without breaking anything, because it knows which _0x3f1a refers to which declaration. This is the job ESLint’s scope analyzer and Babel’s own binding tracker do. They build the map of declarations to references so that a rename touches exactly the right identifiers and no others. A naive find-and-replace cannot do this. Two different functions can both use _0x3f1a for unrelated locals, and a text replace corrupts one of them.

That distinction, text versus tree, is the whole reason the serious tooling moved to ASTs. A regular expression sees characters. It cannot tell a string literal from an identifier that happens to share its spelling, cannot respect scope, and cannot evaluate an expression. Everything past this point operates on the parsed tree.

Rewriting the tree with Babel

The standard toolkit for this is Babel, used in reverse. Babel is a JavaScript compiler, and like any compiler it runs in three phases: parse the source into an abstract syntax tree with @babel/parser, traverse and mutate that tree with @babel/traverse, then print it back to source with @babel/generator. The @babel/types package builds and validates nodes. For deobfuscation you write a custom transform that runs in the middle phase, and the rest is free.

The traversal model is a visitor. You hand traverse an object whose keys are AST node types and whose values are functions, and Babel calls your function once for every matching node, passing a path object that wraps the node and knows its parent, its scope, and how to replace itself. A transform that flips every + to * is a five-line visitor on BinaryExpression. The same machinery, pointed at the right node types, undoes obfuscation.

The methodology has three steps, and it is the same loop every time. Visualize the structure, usually by pasting a snippet into AST Explorer to see exactly what node types the obfuscator emitted. Work out the transform as pseudo-code against those nodes. Then implement it as a visitor. The skill is not Babel; it is recognizing the pattern in the tree and knowing its inverse.

Recovering the strings

The single most valuable transform is string recovery, because strings are where the meaning lives. Function names being called, property accesses, URL fragments, the names of the very browser APIs the script probes. Get the strings back and a dead file starts talking.

There are a few common concealments, each with a clean inverse. Hex and unicode escapes (\x73\x75\x62\x6d\x69\x74) are the easiest: Babel already decodes them into the node’s value while the original escaped form sits in a separate extra field used only for round-tripping. Delete extra and the generator prints the plain decoded string. Split-and-concatenate strings ("sub" + "mit") fold away with path.evaluate(), Babel’s built-in partial evaluator, which returns a confident flag and the computed value when the expression is statically constant; you replace the node with a single string literal. The string-array pattern, where literals live in one array and every use is an indexed call, is recovered by finding the array binding through scope, reading its elements, and rewriting each _0xa1(0x1f) call to the literal it resolves to.

string-array dereferencing _0xa1(0x1f) wrapper adds rotation offset idx = 0x1f - 0x17 "toDataURL" the array, after de-rotation: [0]"length" [1]"call" ... [8]"toDataURL" [9]"getImageData" ... rotated at load time so the raw declaration order is wrong recovering one string at a time turns "_0xa1(0x1f)" into a named API probe "toDataURL" and "getImageData" together are a canvas fingerprint read *Resolving a single string-array call. The accessor applies a load-time rotation offset, so the inverse has to replay that rotation before indexing. Recovered names like toDataURL are how you spot a canvas read.*

Encrypted string arrays add one twist. The accessor runs a decoder, often base64 or RC4, before returning the value. You cannot statically read those out, so analysts isolate the decoder function and run it in a sandboxed Node vm context, feeding it indices and collecting the plaintext, then bake the results back into the tree. This is the one place in static deobfuscation where you genuinely execute attacker code, and it is why you do it inside an isolated VM context rather than the host interpreter. The mature open-source tools wire this up for you. webcrack depends on isolated-vm for exactly this reason, sandboxing the obfuscator’s own decoder so it can recover strings without giving the payload a real runtime.

Get all of this right and a recent result is striking. Google’s CASCADE system, published in 2025 by researchers from Google and UT Austin, pairs the Gemini model with a compiler-style intermediate representation to attack exactly this string layer in obfuscator.io output. It reports detecting the decoder “prelude” functions at 99.56 percent accuracy and recovering strings at a 98.93 percent success rate, averaging 945 literals per file in around 2.3 seconds. The interesting part is less the LLM than what the numbers imply: string recovery on a structure-preserving obfuscator is now essentially a solved problem, accurate enough to automate at scale, because the strings were never actually destroyed.

Unflattening control flow

With strings back, the remaining wall is usually control flow flattening. The code is a dispatcher loop: a state variable, a while, a switch whose cases are the original basic blocks, and assignments to the state variable at the end of each case that encode “go to block N next.” The original program is fully present, just expressed as edges in a state machine instead of nested syntax.

Unflattening reconstructs the graph. You read the dispatcher to learn the initial state, then walk each case, recording which block it runs and which state it sets next, building the control-flow graph by hand or by transform. Once you have the edges you re-emit ordinary if/else and loops. When the next-state values are plain constants this is mechanical and the open-source unflatteners handle it. When the state is computed at runtime, from an opaque predicate or a value the dispatcher derives on the fly, static reconstruction stalls, and that is the deliberate seam between obfuscation a tool can undo and obfuscation built to resist tools.

When static stops: runtime hooking

Sometimes you do not need to fully understand the code. You need to know what it observed about the browser and what it sent back. That is a runtime question, and the answer is instrumentation: let the script run in a real browser and watch it from the outside.

The technique is hooking. You replace the functions the script depends on with wrappers that record arguments and return values before delegating to the original. Hook JSON.stringify and XMLHttpRequest.prototype.send and you capture the exact payload the script POSTs to the vendor, the assembled bundle of signals, without reading a line of the collection logic. Hook CanvasRenderingContext2D.prototype.toDataURL, navigator property getters, Function.prototype.toString, and you log every fingerprinting surface the script touches and in what order. The script tells you what it cares about by the act of reading it. This same telemetry is what the vendor’s own payload carries; for one worked example of what ends up in such a bundle, see the breakdown of the DataDome client payload.

Hooking is also how you study the demand side of the market, not just the defense. The signal collection a script performs is the same collection a fraud operator has to satisfy, whether the end goal is carding, scalping inventory, or fake-account creation at scale. Watching the script enumerate its checks is a direct read of what those operations are graded against.

The catch is that the anti-bot script knows hooking is the obvious move, and it looks for you.

Anti-instrumentation: the script watching the watcher

A wrapped function is not the function it replaced, and JavaScript leaks that fact in several ways. The longest-standing tell is Function.prototype.toString. Call it on a genuine built-in and you get the body collapsed to function toDataURL() { [native code] }. Call it on your wrapper and you get your actual source, or, if you tried to hide by faking the string, you get a different inconsistency. The defensive trick is documented and old: redefine toString to lie for your hooks. The counter is older still. The lie has to live somewhere, so the script calls toString on toString itself, and a native toString.toString() reports native code while a patched one does not. The check recurses on the very function you used to hide.

There are more seams. A genuine native method has no own prototype property, so 'prototype' in fn separates many naive wrappers from the real thing. Property descriptors expose redefinition: a getter you installed has a different configurable or enumerable shape, or simply appears as an own property where the platform puts it on the prototype. And cross-realm comparison defeats local patching entirely, because a script can pull a pristine reference out of a fresh <iframe>’s contentWindow and call iframe.contentWindow.Function.prototype.toString.call(target) to bypass whatever you patched in the main realm. Patch the iframe too and it makes a new one.

the toString integrity check, recursing analyst hooks toDataURL script reads fn.toString() no [native code] → flag analyst patches toString to lie: toString → returns "[native code]" script calls toString.toString() — caught cross-realm: pull a clean reference from a fresh iframe and re-check iframe.contentWindow.Function.prototype.toString.call(target) *The integrity check follows the hook wherever it hides. Patch the lie and the script inspects the patch; patch the realm and it spawns a clean one. Each defense has a counter, and the counter has a counter.*

The only place to truly hide a hook is below JavaScript, in the C++ of the engine itself, where a patched method still reports as native because there is no JS-level wrapper to inspect. That moves the whole contest out of the script’s reach, which is why serious anti-detect work has migrated toward patched browser engines rather than runtime JS overrides. But notice what has happened to the deobfuscation goal. You are no longer reading the script. You are running it and fighting its self-defense, and the script can still simply decline to produce a valid result when it senses interference. Hooking buys you observation, not understanding, and against a script that hides its logic well, observation is where the readable workflow ends.

The wall: virtualization

The hardest protections do not obfuscate the logic. They replace it. Virtualization, also called VM-based obfuscation, compiles the sensitive code to bytecode for a custom interpreter and ships the interpreter plus the bytecode instead of the code. There is no flattened switch to unflatten and no string array to inline, because the original syntax tree no longer exists in the file. What ships is a dispatcher loop reading an opcode stream, and a number like 0x15 means whatever this particular VM’s handler table says it means.

This is not theoretical for anti-bot. Kasada’s client script, delivered as a file usually called p.js, is built around a custom interpreter running Kasada-specific bytecode, and the bytecode rotates frequently enough that anything pinned to a fixed instruction set goes stale fast. The public write-up of one such VM, on Nike’s Kasada-protected site, is the clearest real example available. The analyst found a dispatcher function running a state machine over an array of roughly sixty handler functions, an instruction pointer indexing into a bytecode array of about 386,000 characters, and a separate strings section base-62 encoded with a fixed alphabet and spliced out for separate decoding. The opcodes covered the arithmetic and logic you would expect of any small machine. Crucially, the author noted that understanding the dispatcher “from a strictly static analysis perspective is no easy task” and leaned on debugger breakpoints instead.

In February 2026, DataDome shipped VM-based obfuscation for its Device Check and Slider components, describing a build-time compiler that translates detection logic into bytecode where each instruction is an opcode followed by operands, with the original source never reaching the browser. They frame it as a third layer on top of their existing dynamic and WebAssembly protections, and pitch it explicitly against automated, AI-assisted deobfuscation. The exact opcode encoding and handler semantics are not public, and this post does not infer them; what is public is the architecture, and the architecture is the point. The same generation of tooling that solved string recovery does not touch this. CASCADE’s own authors list VM-based obfuscation, alongside heavy control-flow flattening, as out of scope. Moving detection logic into a bytecode VM, or further out into WebAssembly, is a deliberate answer to deobfuscation, not an accident of complexity.

Why the AST workflow cannot follow

The reason is the property from the first section. AST transforms work because obfuscation is structure-preserving: the strings, the blocks, the original tree are all still there, scrambled by an invertible map. Virtualization throws that away. Compilation to bytecode is lossy in the directions you care about, variable names and high-level structure, and the bytecode’s meaning lives in the interpreter, not in any grammar Babel can parse. Parse p.js and you get a clean AST of the interpreter. The thing you actually want is encoded in the data the interpreter eats, and a parser does not read data as logic.

So the workflow changes shape entirely. To get the logic back you have to recover the instruction set: locate the dispatch loop, enumerate the handlers, and work out what each opcode does to the VM’s registers and stack, which is itself a small reverse-engineering project per protection and one that resets every time the vendor rotates the bytecode or the handler table. This is devirtualization, and it is closer to reversing a CPU than to running a Babel plugin.

The academic news, and it genuinely is good news for the analyst, is that the VM’s apparent complexity is mostly a bluff. Dynamic symbolic execution attacks virtualization by separating the two kinds of instructions in an execution trace, the ones that are the VM doing its dispatch bookkeeping and the ones that are the original program computing its result. The 2018 DIMVA work out of Quarkslab on symbolic deobfuscation demonstrated the key result on virtualized binaries: the syntactic complexity a dispatcher adds does not raise the semantic complexity of what the program actually computes. They showed that no matter which dispatcher variant protected a function, their analysis recovered the same instruction count for the underlying logic. The VM is a thick wrapper around a thin computation, and symbolic execution peels the wrapper by following data dependencies rather than control flow.

That result has hard limits, and they are honest ones. The same line of work could not handle JIT compilation, multithreading, floating-point arithmetic, system calls, or programs whose memory access depends on user input, and two-level virtualization, a VM inside a VM, was solvable but expensive. Those limits matter because a browser anti-bot VM lives in exactly the environment that triggers them. It calls into Web APIs constantly, its behaviour depends on the runtime it finds, and the vendor can rotate the bytecode faster than a per-target devirtualizer can be rebuilt. Symbolic execution says virtualization is not unbreakable in principle. Operational reality says breaking it on a moving target, every few days, on a script designed to detect the very instrumentation a trace needs, is a different proposition from breaking it once in a paper.

What the workflow actually buys you

Lay the pieces end to end and the shape is clear. Beautifying and scope-aware renaming are free and always worth doing. String recovery is largely solved, to the point of being automatable with good accuracy, because the strings were only ever hidden, never removed. Control-flow unflattening works until the next-state values stop being constants. Runtime hooking gets you the payload and the list of signals without reading the logic, at the cost of a fight with anti-instrumentation that the script is designed to win on its own turf. And virtualization breaks the assumption all of that rests on, forcing a per-target devirtualization effort that symbolic execution can win in theory and that vendors rotate specifically to make uneconomical in practice.

The honest summary is that the difficulty curve is not smooth. It is a cliff, and the cliff is the bytecode VM. Everything below it is an exercise in inverting known transforms, the kind of work where better tooling keeps winning, and the 2025 string-recovery numbers show the ceiling rising. Everything above it is a different discipline, one where the question stops being “what does this code say” and becomes “what does this machine compute,” answered by trace and constraint rather than by reading. The vendors moving their detection logic into custom VMs and into WebAssembly understand this distinction precisely, which is why they are spending the engineering to cross the cliff. They are not trying to make their code unreadable. They already lost that fight years ago. They are trying to make it not be code at all.


Sources & further reading

Further reading