Skip to content

The eval, Function, and with tricks in obfuscated payloads

· 21 min read
Copyright: MIT
eval, Function, with wordmarks on a dark background with one orange accent

Three language features carry most of the weight in obfuscated JavaScript. eval turns a string into running code. The Function constructor does the same thing with a cleaner exit from strict mode and a fresh scope. And with bends name resolution so that an identifier you read on the page does not have to mean what it appears to mean. None of these is exotic. They have been in the language since the late 1990s, two of them are flagged as hazards in their own MDN pages, and a senior engineer has used all three without thinking of them as weapons. The interesting part is what happens when someone reaches for them deliberately, to make a script that runs fine but reads like noise.

This post is about those three primitives and the supporting trick that almost always travels with them, the rotated string array. The goal here is to explain the mechanism well enough that you can recognise it on sight and reason about how a deobfuscator unwinds it. It is a defensive reference, not a recipe. Where a working example would amount to a bypass, the text stays at the level of structure and pseudocode. The mechanics are the point; the exploit is not.

The walk goes like this. First, why dynamic code construction is the load-bearing trick and how eval and Function differ in practice. Then the with statement and the specific kind of scope confusion it buys an obfuscator. Then string-array rotation, the most common single pattern in production-obfuscated JavaScript, and how the rotator and decoder fit together. After that, how the standard AST-based deobfuscation passes take all of it apart, where they stall, and why the newest tools reach for a sandbox or a language model when static reasoning runs out. The thread running through all of it is the same one that runs through every anti-bot script you will ever read: obfuscation does not hide logic, it raises the cost of recovering it, and that cost is a number an attacker can budget for.

Why dynamic code is the load-bearing trick

A deobfuscator wants to read code. Dynamic code construction takes the code off the page and hides it inside a string until the moment it runs. That is the whole game. If the body of a function only exists as a string assembled at runtime, a tool that reasons about the syntax tree sees an opaque call, not the statements inside it. Static analysis loses its grip exactly where the interesting behaviour lives.

eval is the oldest way to do this. Hand it a string and it parses and runs that string in the current scope, with access to the surrounding variables. Adrian Herrera’s SAFE-DEOBS paper, which built a deobfuscator out of compiler passes for malware triage, puts eval at the centre of the problem and calls it “a known security risk,” citing MDN directly. The paper makes a sharper point in passing. One of the few malware samples its tool could not handle was notable precisely because it did not use eval, which meant a popular contemporary deobfuscation service could not touch it either, since that service worked “due to its reliance on hooking eval.” When your deobfuscator is built around intercepting eval, a payload that avoids eval is invisible to it. That tension, between the convenience of eval for the obfuscator and the predictability it hands the analyst, shapes everything that follows.

The Function constructor is the more disciplined cousin. new Function(argNames, body) compiles the body string into a function object. The difference that matters is scope. Code built with the Function constructor runs in the global scope, not the local one, so it cannot reach the local variables around the call site the way eval can. For an obfuscator that is often a feature, not a limitation: it produces a clean execution context with no accidental references to leak. It also sidesteps a class of eval-specific lint rules and content-security-policy nuances, though CSP’s unsafe-eval covers both. The two share a deeper property. Both are doorways from data into code, and a syntax tree on its own cannot see through a doorway. To know what runs, you either have to evaluate the string or model what it will become.

There is a more theatrical version of the same idea that drops the words eval and Function entirely. You can reach the Function constructor without ever typing its name, by walking from any function’s .constructor property. An empty array’s filter method is a function; its constructor is Function; calling that with a string body gives you the same dynamic compile. JSFuck takes this to its limit, building entire programs out of six characters by leaning on JavaScript’s coercion rules. An empty array plus a unary operator becomes a number; chains of negation and addition build up integers; those index into the string forms of true, false, undefined and the like to harvest individual letters; the letters spell constructor and call; and at the end of the chain sits a Function call with a string body the analyst never saw spelled out. One ReverseJS walkthrough takes a 41-line JSFuck expression and shows Babel collapsing it back to the literal -1 it always was. The lesson is that “uses the Function constructor” and “contains the token Function” are different statements, and a grep-based detector that only knows the second one will miss the first.

A string of source text crosses into running code three ways "return a+b" opaque to the parser compiled, then runs eval(s) local scope Function(s) global scope []["fill"] ["constructor"](s) no literal "Function" token A syntax tree cannot see through the doorway. To know what runs you must evaluate the string or model it. *The three routes from a string into running code. The constructor-walk path on the bottom reaches the same compiler without ever naming it, which is why token-matching misses it.*

The with statement and scope confusion

with is the strangest of the three because it does not run new code at all. It changes the rules for resolving names in the code you already wrote. A with (obj) { ... } block pushes obj onto the front of the scope chain, so every bare identifier inside the block is first looked up as a property of obj before the surrounding scopes are consulted. The feature was meant as a convenience for repeated property access. Its side effect is that you can no longer tell, by reading the block, what any name in it refers to.

MDN says this in almost those words. The with statement, it notes, “makes it hard for a human reader or JavaScript compiler to decide whether an unqualified name will be found along the scope chain, and if so, in which object.” It gives a two-line example, a function that takes x and an object o and logs x inside with (o), and observes that “if you look just at the definition of f, it’s impossible to tell what the x in the with body refers to.” That impossibility is the entire appeal to an obfuscator. The same documentation calls the statement deprecated, says it “may be the source of confusing bugs and compatibility issues, makes optimization impossible,” and notes that it “is forbidden in strict mode.” Three properties an obfuscator can read as a checklist: it confuses readers, it defeats optimisers, and it forces the surrounding code out of strict mode, which has its own consequences for how other tricks behave.

Concretely, an obfuscator can stash a dispatch table or a bag of decoder functions in an object, wrap a region in with (thatObject), and then call its members as if they were free-standing globals. Reading the wrapped block, a(b(c)) could be three local variables, three globals, or three properties of the with object, and you cannot decide which without tracking what the object held at that point in execution. Pair this with names that are already meaningless hex identifiers and the analyst has lost the one anchor that usually survives obfuscation, which is the ability to follow a name to its declaration. The lookup itself is also slow, because, as MDN puts it, “the optimizer cannot make any assumptions about what each unqualified identifier refers to, so it must repeat the same property lookup every time.” Obfuscators that care about performance use with sparingly for that reason, but anti-bot scripts that run once per page load and want to be hostile to analysis can afford it.

The good news for the analyst is that with is the most mechanically reversible of the three primitives, because it does not actually hide any code. SAFE-DEOBS handles it with a dedicated pass it calls the WithRewriter, which the paper describes as conservatively eliminating with statements “such that lexical scoping remains valid.” Once you know which object the block was scoped to and what that object contained, every bare name in the block can be rewritten to an explicit property access or a normal variable reference, and the scope chain goes back to being readable. The rewrite is only as good as your knowledge of the object’s contents, which is why it runs after the passes that resolve constants and string values, not before.

with(o) pushes o to the front of the lookup chain with (o) { a(b) } resolving the bare name "a": o.a ? checked first local a ? global a ? The reader cannot decide which box wins without knowing what o held at runtime. The WithRewriter pass replaces the chain with an explicit o.a once o's contents are known. *The with statement does not hide code, it makes name resolution depend on a runtime object. That is what a deobfuscator's rewrite pass undoes once it knows the object's contents.*

String-array rotation, the most common pattern you will meet

If you read one obfuscated bundle this year it will almost certainly have a string array. The technique pulls every string literal out of the program, packs them into one array at the top of the file, and rewrites each original use site as an index into that array through a small accessor function. On its own this is shallow. The value of _0x42a1(7) is whatever sits at the rotated index, and a single pass of the program reveals all of them. So the pattern almost never travels alone. It comes rotated, often encoded, and frequently wrapped behind layers of indirection.

The reference implementation is the open-source javascript-obfuscator, the tool behind obfuscator.io, and its options read like a menu of exactly these tricks. stringArray (default true) does the extraction. stringArrayRotate (default true) shifts the array by a fixed offset chosen at obfuscation time, so the indices in the code do not line up with array positions until a small bootstrap runs. stringArrayShuffle (default true) randomises the order on top of that. stringArrayEncoding can be set to base64 or rc4, in which case each entry is stored encoded and the accessor decodes it on the way out. And stringArrayWrappersType chooses how the index is mediated: 'variable' by default, or 'function', which the docs recommend “for higher obfuscation when performance loss doesn’t have a high impact” and which inserts wrapper functions at random positions inside each scope so that one logical lookup becomes a chain of calls through several intermediaries.

The rotation deserves a closer look because it is the part people find clever the first time. The array is not stored pre-rotated. Instead a small immediately-invoked function runs at startup, takes the array, and rotates it in place until a checksum derived from a few of its entries matches a hardcoded number. In practice the loop repeatedly does the array equivalent of pushing the shifted-off front element onto the back, the push(shift()) idiom you will see in every obfuscator.io output, parsing some entries as integers and summing them until the magic constant is hit. The effect is that the array exists in the source in one order and is silently permuted into its working order before any real code runs. The accessor function on top of it usually looks like a tiny indirection that returns array[index - offset], with the offset and the array reference themselves often hoisted through another wrapper. None of this changes behaviour. All of it makes a static reader chase the value of every call instead of seeing the string.

String array: extract, rotate at startup, decode on access 1. the array ["bG9n", "Y2FsbA", "dG9TdHJpbmc", ...] stored permuted + encoded 2. rotator IIFE while(sum != magic) arr.push(arr.shift()) permutes to working order 3. accessor _0x42a1(7) -> decode(arr[7-off]) returns "toString" Call sites read _0x42a1(7) instead of "toString". Behaviour is identical; the string is gone from view. A deobfuscator that can run the rotator once and evaluate the accessor recovers the whole table at once. stringArrayWrappersType: 'function' inserts extra call layers between the site and the accessor. Field names and defaults from the javascript-obfuscator option docs. *The standard obfuscator.io string-array layout. The rotator runs once at startup; recover its result and evaluate the accessor and you have every string in the program.*

Two more options in the same tool change the difficulty meaningfully. selfDefending (default false) wraps the code in a guard that, in the docs’ words, “breaks if beautified,” so that running the output through a formatter, the natural first move of any analyst, trips a check and corrupts execution. The guard is typically a function that inspects its own source via Function.prototype.toString, often using a regular expression against itself, and changes behaviour if the whitespace does not match what it expects. This is where dynamic primitives and string arrays meet anti-tamper: the self-defending check is itself a small dynamic-evaluation trick aimed at the analyst’s tooling rather than at any runtime input. controlFlowFlattening (default false, and per the docs up to “1.5x slower runtime speed”) rewrites straight-line code into a dispatcher loop driven by a state variable, so the order of operations is no longer visible in the source layout. deadCodeInjection (default false) pads the program with plausible but never-executed branches that “dramatically increases size of obfuscated code (up to 200%).” Each of these is orthogonal to the string array and stacks on top of it.

This same toolbox is what most commercial anti-bot scripts draw from, with heavier customisation and, increasingly, per-session variation so that no two delivered scripts are byte-identical. The honest framing comes from the vendors themselves. Kasada’s own write-up on the subject opens by saying that in “the adversarial game of bot detection and mitigation, obfuscation plays a key role in delivering long-term efficacy.” Long-term efficacy, not permanent secrecy. The Datadome engineer Antoine Vastel, who has written extensively about building obfuscators for bot detection, has been consistent on the same point over the years: obfuscation buys time against reverse engineering, it does not stop it. The detection logic these scripts protect is documented in more depth across the Crawlex posts on the Akamai sensor_data payload, the Datadome JS tag, and Kasada’s KPSDK token VM. The obfuscation discussed here is the wrapping; those posts are about the contents.

How deobfuscators take it apart

The standard answer to all of this is to stop reading the text and start rewriting the syntax tree. Parse the program into an AST with a tool like Babel, run a set of transformation passes until the tree stops changing, then print it back out. SAFE-DEOBS describes exactly this shape, a fixed-point loop over passes it lists as constant folding, constant propagation, dead-branch removal, function inlining, string decoding, and variable renaming, the whole engine coming to “2474 LOC of Scala.” The first four are ordinary compiler optimisations pointed at a hostile target; the last two are specific to deobfuscation. Webcrack, the most widely used current tool for obfuscator.io output, advertises the same priorities in plainer language: “Safety - Considers variable references and scope,” “Auto-detection - Finds code patterns without needing a config,” and “Readability - Removes obfuscator/bundler artifacts.”

Constant folding is the workhorse. Obfuscators love to split a string like "undefined" into 'und' + 'efin' + 'ed' and a number like 400 into 6688/88+0, because the pieces carry no meaning individually. Folding evaluates any subtree all of whose inputs are known, replacing it with the literal it computes. In Babel this is one call: path.evaluate() returns an object with a confident flag, and when that flag is set and the result converts cleanly to a literal, you swap the whole expression for a NumericLiteral or StringLiteral and move on. Constant propagation feeds it: when a variable is assigned a known value and never reassigned, every reference to it can be replaced by that value, which then unlocks more folding. The Babel mechanics here matter for safety. You do not match on names, you ask the scope for the binding with path.scope.getBinding(name) and check that it is constant before substituting, because a name-based replacement would silently corrupt any program that shadows or reassigns the identifier. This is the difference webcrack means by “considers variable references and scope.”

The string array falls to a combination of these passes plus one capability the pure-static approach lacks. To recover the array’s working order you have to know what the rotator produced, and the rotator is real code with a real loop. A deobfuscator can sometimes reason about it statically, but the reliable move is to run it. Webcrack exposes exactly this through a sandbox option, described in its docs as “an optionally async function that takes a code parameter and returns the evaluated value,” so the rotator and the accessor can be executed in a controlled context and their outputs folded back into the tree as plain literals. Once the array is in its true order and the accessor is evaluated, every _0x42a1(7) becomes its string, the wrapper chains collapse, and the program that was unreadable five minutes ago has its method names and its control flow back. The with rewrite then runs cleanly because the objects it scoped over now hold known values, and variable renaming gives the hex identifiers human names where it can infer them.

That sandbox step is also the soft spot, and a careful obfuscator aims at it. If the rotator or any wrapped function checks its environment, looks for the presence of a debugger, inspects its own toString, or refuses to run outside a real browser, then naively evaluating it either fails or produces a poisoned result. This is the selfDefending idea generalised, and it is why running unknown obfuscated code to deobfuscate it is done in a throwaway, instrumented context, never against your own session. The same instinct that makes you treat a malware sample as live makes you treat a hostile anti-bot script as live. The deeper detection tricks, anti-instrumentation and environment probing, get their own treatment in the Deobfuscating anti-bot JavaScript workflow post and the JavaScript VM obfuscation post, which picks up where this one stops, at the point a script abandons readable JavaScript entirely and runs its logic inside a bytecode interpreter.

The frontier has moved in the last couple of years toward mixing static rewriting with a language model. Google’s CASCADE, presented at ICSE-SEIP in 2026 by a team of four from Google, pairs Gemini with Babel on the premise that neither alone is enough: the model is good at the parts that need judgement, like guessing what an opaque function probably does and what to name it, while the compiler infrastructure handles the parts that need correctness, like the actual tree rewrites. The split is telling. Folding a constant or running a rotator is mechanical and a transpiler does it exactly; recovering a meaningful name for _0x3b8ba1 is a guess, and a model that has read a lot of code guesses better than a heuristic. Several benchmarks have appeared alongside it to measure how well language models recover semantics from obfuscated JavaScript, which is itself a sign the problem is now treated as a recovery task with a measurable success rate rather than a binary of solved or unsolved.

What the three primitives have in common

Strip away the variety and eval, Function, and with are doing one thing each, and the three things rhyme. eval and Function move code into data so the parser cannot read it until the moment it becomes code again. with moves the meaning of a name into a runtime object so the reader cannot resolve it without executing the program. The string array does the same move at the level of literals, hiding every string behind an index that only the running program can dereference. In all three cases the obfuscator has not deleted information. It has relocated the information from where a static reader looks, the source text, to where only a running program looks, the execution state. Recovering it means becoming, partially, the running program. That is why every serious deobfuscator ends up wanting to evaluate something, and why every serious obfuscator ends up wanting to detect that evaluation.

The economics fall out of that observation cleanly. None of these tricks is uncrackable, and the people who write them know it. SAFE-DEOBS reduced one real malware sample by 97 percent, from hundreds of lines to twelve, and the only residue was a reference to the DOM it deliberately did not model. Webcrack will unpack a stock obfuscator.io bundle in seconds. What the tricks buy is time and breadth: a generic deobfuscator handles the common shapes, but every layer of custom self-defence, every environment check, every per-session permutation forces the analyst back into the loop for that specific target, and analyst time is the expensive input. For an anti-bot vendor that is the whole point, because the script only has to outlast the value of the thing behind it on the timescale of a rotation. For a malware author it is the same calculation pointed at a different defender. The arms race is not about whether the code can be read. It is about how many analyst-hours the next read will cost, and that number is something both sides can put on a spreadsheet.

There is a quieter consequence worth ending on. Because all three primitives turn on the boundary between data and code, the cleanest long-term defence against analysis is to make sure no readable code crosses that boundary at all, which is exactly the direction the field has taken. Move the logic into a bytecode interpreter and the eval is structural, not a string you can catch. Move it into WebAssembly and there is no JavaScript to deobfuscate in the first place. The eval, Function, and with tricks are the first generation of that idea, the version that still lives inside readable JavaScript and can still, with enough patience, be read back into readable JavaScript. The newer generations have noticed that the most reliable way to beat a deobfuscator is to give it nothing in its own language to work on.


Sources & further reading

Further reading