Modern JavaScript obfuscators all reach for the same handful of tricks: a string array with an indexed decoder function, control-flow flattening into a switch-state machine, dead-code injection, identifier mangling, and integrity self-checks. Knowing the patterns lets you unwind a 50KB minified blob into something readable in an afternoon.
Pattern 1 — String-array decoder
Obfuscator.io's signature trick. At the top of the file, you'll see something like:
const _0xa1b2 = ['hello', 'world', 'console', 'log'];
(function(arr, n) {
while (--n) arr.push(arr.shift());
})(_0xa1b2, 0x42);
function _0x1234(idx, _) {
idx = idx - 0x0;
return _0xa1b2[idx];
}
console[_0x1234(0x2)](_0x1234(0x0)); // console.log('hello')How to break it
- Run the rotation IIFE in isolation. Copy the array literal and the (function(arr, n) {...})(_0xa1b2, 0x42) call into a Node REPL. The array is now in its post-rotation state.
- Replace every _0x1234(N) call with the literal string at index N. A Babel plugin with a CallExpression visitor matching the decoder name does this in ~20 lines.
- Inline the decoder away — once all callsites are replaced, the decoder function and the string array are dead code and can be removed.
Pattern 2 — Control-flow flattening
Original:
if (a > b) { foo(); } else { bar(); }
return done();After flattening:
let state = 0;
while (true) {
switch (state) {
case 0: state = (a > b) ? 1 : 2; break;
case 1: foo(); state = 3; break;
case 2: bar(); state = 3; break;
case 3: return done();
}
}How to break it
Each case is a basic block. The state transitions form a graph — extract them, and you have your original control flow back. Tools like JStillery or restoring-obfuscated-control-flow can do this automatically when the dispatcher is recognizable. For custom dispatchers, write a Babel pass that builds the case → next-state map manually.
Pattern 3 — Dead-code injection
Obfuscators inject opaque predicates — branches whose condition is constant at compile time but unfortunately not at deobfuscate time without partial evaluation:
if (typeof window !== 'undefined' && Math.PI === 3.14) {
// dead — Math.PI !== 3.14
garbageFunction();
} else {
realCode();
}How to break it
Run the predicate through a constant-folding pass. Babel's @babel/plugin-transform-evaluate or a custom evaluator that handles a whitelist of safe operations (arithmetic, typeof on globals, length checks on string literals) collapses 90% of these.
Pattern 4 — Self-defending code
More aggressive obfuscators inject code that detects whether the script is being beautified (toString of a function tested against an expected hash) and jumps into an infinite loop or alters behavior if it detects tampering. The classic check:
(function check() {
const expected = -0x12345;
const got = check.toString().length;
if (got !== Math.abs(expected) + 0x99) {
while (true) {} // detected — hang
}
setTimeout(check, 4000);
})();How to break it
Either neuter the check by replacing the entire function body with a no-op early in the AST, or run the deobfuscation in a sandbox that doesn't rebuild the function source until you're done. Practical workflow: process the AST, write out a new file, then beautify the new file — never beautify the original.
Putting it together
A real-world deobfuscation pipeline looks like:
- Parse with Babel into AST.
- Run the rotation IIFE to recover the post-rotation string array.
- Replace decoder calls with literals (CallExpression visitor).
- Constant-fold predicates (evaluate visitor).
- Flatten control-flow (case-block reconstruction).
- Rename mangled identifiers using inferred semantics (assignment patterns, well-known API surface).
- Print and prettier-format.
Here's an Obfuscator.io-encoded payload. Walk me through deobfuscating it — bypass the string-array decoder, flatten control-flow, name the recovered functions. [paste payload]Open this in Aether →