A wall of Base64 isn’t always the easy kind
A giant Base64 string at the top of a script is usually one of two things: a trivial “decode-and-read” challenge, or a virtual machine wearing that disguise. A well-known example of the second kind is the reese84 interrogator shipped by Imperva / Incapsula — we reference it here only as a concrete, public example of the pattern, not as a target. The skeleton looks deceptively simple:
(function () {
var A = window.atob("b64_string");
var E = new window.Uint8Array(A.length);
for (var B = 0; B < A.length; B++) E[B] = A.charCodeAt(B);
E = new window.Uint16Array(E.buffer); // the bytecode
var Q = [ null, null, [], /* ... ~120 curried functions ... */ ];
Q[0] = Q;
var B = 0;
while (B < E.length) {
Q[E[B++]] = Q[E[B++]](Q[E[B++]]); // the triplet loop
}
})();Decode some Base64, reinterpret the bytes as 16-bit values, and run a loop three slots at a time. The catch is in Q: none of those functions do anything on their own. Each takes one argument and returns another function. Nothing fires until a function has been handed enough arguments to be fully saturated. They are curried thunks, and the real behaviour lives in how the triplet loop composes them — not in any single body. So you cannot just read the opcodes; you have to reconstruct what their composition computes.
The IIFE-fold trap
Before folding these curried bodies you have to respect one detail, because getting it wrong silently corrupts everything downstream. Consider an opcode like this:
function (A) {
return function (E) {
return (function (A) {
return function () { return A(arguments); };
})(A(E));
};
}A naive IIFE folder substitutes the body as return function () { return A(E)(arguments); }. That is wrong. The inner IIFE forces A(E) once, at the moment the closure is built. The naive fold re-defers it, so A(E) re-runs on every call — with a different arguments each time. The correct fold binds A(E) once and evaluates it at depth two:
function (v7) {
return function (v8) {
var v9 = v7(v8); // forced once, here
return function () { return v9(arguments); };
};
}This is the kind of bug that does not throw — it just produces an IR that disagrees with the real VM in ways you will not notice until the recovered constants come out as garbage. Build a small unary solver (evaluate single-argument calls to a concrete value where you can) and an IIFE folder that gets this right, and the bodies become readable.
There’s no constant pool — until there is
Once you fold the bodies and resolve the type coercion, you notice the VM has no constant pool. That does not mean it has no constants — it means they are synthesized on the fly:
function (v66) {
return function (v67) {
return "false[object Window]"[v66()];
};
}The string "false[object Window]" is just false + window coerced; indexing it with v66() extracts a single character, and that index is the constant. Many opcodes do the same with different coerced primitives. So the constants are real — they are simply manufactured from coerced JavaScript values one character at a time. That tells you what the opening stretch of the program is doing: it is assembling a character lookup table. You could confirm that by emulating the triplet loop, but a curried VM means an instruction stream in the megabytes, so brute emulation does not scale. There is a faithful, static path instead.
Lowering opcodes to an expression IR
Rather than hand-writing a bespoke matcher for each of the ~120 opcode indices, lower them generically: walk each function’s AST and translate every expression into your own small IR — one that only knows the handful of things these opcodes actually do. The pool collapses fast. A BinaryExpression becomes a BinOp; an indexed coercion becomes an Index that folds to a Const; the rest are small combinations of Call, Force, Index, and Cond.
The real work the translator does is not the lowering — it is counting. Because every opcode is curried, its index means nothing until it has been handed enough arguments and called enough times to fire. As you lower a body you count two things: the arity (how many curried arguments it takes) and whether the fully-applied body still returns one more thunk that needs a final forcing call. Their sum is exactly how many cycles the opcode needs before it is saturated and ready to emit. That number is the linchpin of the whole approach.
Classifying shapes (roles)
A tree of BinOp / Index / Call nodes still does not tell you what an opcode is. Two opcodes can lower to structurally similar trees and mean completely different things to the VM. So a second, coarser pass — the classifier — matches each lowered body against a fixed catalogue of roles. Not one matcher per opcode index, but one matcher per kind of thing the VM does:
- Leaves — literals (
LiteralNumber,LiteralBool,LiteralNull,LiteralWindow), slot/argument/window reads and writes (SlotRead,ArgsRead,IndexWrite),BinOp/UnaryOp/Ternary, and call shapes (CallN,NewN,MethodCallN,Apply). - Forcing glue —
Force,DoubleForce,PassthroughArg0: the no-op-looking opcodes whose entire job is to pull a value out of a thunk. - Combinators —
Compose,Pipe,Seq,Iife,With: the higher-order plumbing that wires other opcodes together. - Frame ops —
NewFrame,FramePush,Link,Body: the calling-convention machinery around the VM’sslot[2]activation record. - Control flow —
While,TryCatch: recognised by their thunk-wrapped shape.
Every match is purely structural. Ternary is “a Cond whose test forces argument 2, consequent forces argument 0, alternate forces argument 1.” Compose is “apply argument 0 to the result of applying argument 1 to argument 2.” Nothing is keyed to an opcode’s numeric index, which is exactly why the same matchers survive the polymorphism between samples. The classifier also records one extra bit per opcode: whether the body needs a frame (it invokes an argument with arguments, or touches slot[2][0]). That boolean decides whether the saturator may freely inline a body or must treat it as an opaque, effectful call.
Flattening by shadow-execution
Now use that cycle count. Parse the flat Uint16Array into (dest, op, operand) triplets — JavaScript evaluates the assignment target before the right-hand side, so Q[E[B++]] = Q[E[B++]](Q[E[B++]]) reads in exactly that order. Each triplet means “apply whatever is in the operator slot to whatever is in the operand slot, write the result to the destination slot.”
Instead of running that, you shadow it. Build a frame mirroring Q, but the slots hold abstract values, not live functions: an opcode, a partially-applied opcode with some arguments collected, or a finished IR expression. Walk the triplets in the exact order the VM would. For each one, look at the operator slot:
- an opcode → start a fresh partial application with one argument;
- an already-partial opcode → apply another argument;
- an already-finished value → it is a real call, so emit it.
This is where the megabytes evaporate. The overwhelming majority of triplets exist only to thread one more argument into a partial — they do nothing on their own, mirroring how the curried thunks stay inert until saturated. Only when a partial’s argument count reaches its cycle count does it fire: you lower the opcode body to a concrete IR expression, and the slot graduates from partial to finished value. Cheap results (a literal, a bare slot reference) go straight into the slot; anything heavier is bound to a fresh temporary. Either way you record the slot’s current definition, so later reads pick up the latest expression — an SSA-flavoured value numbering over the VM’s register file. One more detail: opcode bodies force the thunks they are handed (the VM passes thunks, not finished values), so you must track when an argument is called inside a body too. As the opening stretch fires, the synthesized-constant bodies fold to single characters and the character lookup table assembles itself, exactly as predicted.
Saturation and convergence
The linear walk does not finish the job. Plenty of opcodes never reach their cycle count during the walk because they are handed to other opcodes as values rather than called directly — a combinator decides when (or whether) they fire. After flattening they show up as residual partials: correct, but opaque (v3[op](x)(y)). So run a second phase that does to the IR what the walk did to the bytecode — a fixpoint loop of three passes:
- saturate — find any residual partial whose collected argument count has reached its cycle count and inline the opcode body, replacing each
Arg(n)with the supplied argument (hoisting any argument used more than once into a temporary so effects are not duplicated). - fold — constant-fold and simplify: string-method folding, coercion collapse, unary/const arithmetic.
- inline — SSA-style cleanup: a slot or temporary written once and read once gets propagated into its single use; a pure definition with zero remaining reads is dropped, an effectful one demoted to a bare effect.
Each round feeds the next. Count residual partials between rounds; the moment a round fails to reduce the count, you have converged (cap it at, say, six rounds). For partials that are genuinely under-applied and can never saturate, an optional lambda pass eta-expands them into explicit arrows with fresh parameters, so even the leftover plumbing reads as real JavaScript. This is the line between “we emulated the loop” and “we devirtualized it”: the flatten walk transcribes the trace, and saturation collapses the composition that trace was threading together, so a deeply-curried combinator nest becomes a single readable expression.
Recovering control flow
Pure expressions are not enough — the program loops and catches exceptions, and a faithful transcription has to show that. The VM has no jump opcodes; control flow is encoded the same way as everything else, as opcodes whose bodies happen to have a particular shape. A loop opcode is a thunk wrapping a while whose test and body are both forced arguments. A try/catch opcode is a thunk wrapping a try whose handler re-invokes its argument with the caught value. The classifier tags exactly these as the While and TryCatch roles, and the lowering keeps while, for…in and try/catch as first-class IR nodes (WhileStmt, ForInStmt, TryCatchStmt) instead of flattening them into calls. When such an opcode saturates you emit a real while (…) { … } or try { … } catch (__err) { … }.
Loops force one bit of extra care. Value propagation normally threads a slot’s known value forward to its next read — but inside a loop body a slot written on one iteration is read on the next, so propagating a single-pass value would be wrong. Before propagating into a while, scan the loop for every slot it writes and mark those off-limits, so the loop’s own state never gets constant-folded out from under it.
Bootstrapping and synthesized constants
The VM could trivially manufacture any ASCII character with a String.fromCharCode opcode — but that would be too telling, so it builds one itself. From coerced primitives it already has the characters c o n s t r u f m C h a d e, which is just enough to spell two words: constructor and fromCharCode. Indexing a string with ["constructor"] hands you the String constructor; a second index, ["fromCharCode"], reaches the static method. Where does an uppercase C come from? From a neat use of window.atob: encode something like "128false" and harvest characters out of the decoded bytes.
With fromCharCode in hand the VM builds more vocabulary — and the bulk of it does not come character by character. It comes from encoded Base64 blob pools assembled during bootstrap. If they were plain Base64 you would atob them and read the words, so instead each blob is Base64-decoded and then run through a short chain of byte transformations. Those chains are polymorphic between samples — one pool might be SwapAdjacent, RotateRight, XorChain, XorChain. You do not guess the chain: you read it straight out of the IR, because by now the fold opcodes are named and their primitives, order, immediates and key lengths are sitting in the decode routine. You recognise each pass by what its loop body does: a loop combining |, <<, >>, & with 255 and 7 is a bit-rotate; a loop whose only real operator is ^ against a key byte is an XorChain; one writing to both i and i+1 is SwapAdjacent. Re-derive the chain per pool, pull the immediates and keystreams from the IR, replay the primitives, and out falls the real vocabulary: getImageData, createOscillator, __SENTRY__, webdriver, MAX_VERTEX_TEXTURE_IMAGE_UNITS, OfflineAudioContext, userAgent. So the VM does have a constant pool — it is just encoded. Once you substitute the decoded strings back in, an opaque signal read becomes window["webdriver"], canvas["getContext"], or "__SENTRY__" in window.
Side effects, cleanup, and checking the work
Everything folded so far has been pure — takes thunks, returns a value. But the program eventually has to do something, and the first thing it does is install itself with a saturated three-argument opcode of the shape window[v24()] = v25(). There is no special “install” instruction; the side effect is just an opcode that fired. When you transcribe it, an Assign / Call / New cannot be treated like a value — dropping or reordering it changes the program. So the rule is: the moment an opcode body produces an effect, pin it into the instruction stream in execution order, and let only its result flow onward. Value propagation may thread constants through pure slots, but it may never cross an effect it cannot prove pure — the instant you hit an opaque call or a window write, forget what you knew about the affected slot. IIFEs follow the same model: an Iife-tagged body is decomposed back into a sequence of instructions and re-emitted as (function(){…})() rather than inlined.
Faithfulness leaves litter. The bootstrap writes a constant into a slot[2] frame cell, reads it back a few instructions later, writes the next one, hundreds of times over. A dedicated slot[2] propagation pass walks the stream tracking each frame cell’s known value and substitutes reads, invalidating on any full replacement, frame push, or opaque call; a dead-store pass then removes pure writes that are never read before the next write. Finally, verify. It is easy to be confidently wrong about a decode chain, so run the recovered IR through a small abstract interpreter that speaks the same value universe — numbers, byte strings, ArrayBuffer/Uint8Array views, objects, arrays, atob, String.fromCharCode, and the frame model. If the chain is right, the interpreter reproduces the same readable vocabulary you derived statically; if you misread a pass or an immediate, the bytes come out as garbage and you know immediately. That is the difference between “this looks like a SwapAdjacent” and “running it produces webdriver.”
Zoom out and the whole pipeline is: lower each curried body to a tiny expression IR, classify every body into a role, shadow-execute the triplet stream to transcribe the trace, then saturate and clean until the composition collapses into readable JavaScript — all statically, without ever running the original sample. What started as a wall of Base64 ends as a program you can read: the cipher it builds, the pools it unpacks, the global it installs, and the list of every automation-framework, WebGL and DOM probe it goes looking for. That readability is the prize — the moment signals stop being opaque slot indices, the script stops being a black box and becomes something you can reason about. This write-up distills the excellent walkthrough at debug.cat — Imperva Reversal, which is well worth reading in full.
