rea-reverse

Deobfuscate javascript-obfuscator (obfuscator.io) Output: Tested

Published by rea-reverseUpdated 8 min read
On this page

javascript-obfuscator (the engine behind obfuscator.io) is the most common reason a JavaScript file is full of _0x4e2a1b names and a0_0x3a51(0x7d) calls. If you ship it in your own product, or you have been asked to review a script that uses it, you need to know two things: which of its options a deobfuscator can undo, and which ones lose information for good.

We tested that directly. We wrote a 38-line script, obfuscated it with the javascript-obfuscator CLI at 14 different option sets, and ran every result through the pipeline of this site’s JavaScript deobfuscator (webcrack 2.16.0 with a custom evaluator that never calls eval). This article reports the numbers, including the cases where it fell short.

Versions: javascript-obfuscator 5.9.0 (CLI, --version prints 5.9.0_2026-10-04T02:32:30.053Z), Node 22.22.3, macOS 27.0.1 on Apple silicon. The obfuscator was installed in a scratch folder with npm i javascript-obfuscator. The deobfuscator was run from the site’s source in Node through vitest, which is the same code the browser page loads in its Web Worker.

What Obfuscation Gives BackSilent walkthrough with English captions.

The test script

The input is our own code: it summarises a reading list and builds an API request. It has 16 distinctive strings (a URL, header names, labels, book titles) so we can count what comes back.

const API_BASE = 'https://books.example.org/api/v2';
const STATUS_LABELS = { todo: 'Not started', reading: 'In progress', done: 'Finished' };

function slugify(title) {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}

function summarise(books) {
  const counts = { todo: 0, reading: 0, done: 0 };
  let pages = 0;
  for (const b of books) {
    if (!(b.status in counts)) throw new Error('unknown status: ' + b.status);
    counts[b.status]++;
    if (b.status === 'done') pages += b.pages;
  }
  return { counts, pages };
}

function buildRequest(user, books) {
  const { counts, pages } = summarise(books);
  return {
    url: API_BASE + '/users/' + encodeURIComponent(user) + '/stats',
    headers: { 'X-Client': 'reading-list/1.4', 'Content-Type': 'application/json' },
    body: JSON.stringify({ counts, pages, slugs: books.map((b) => slugify(b.title)) }),
  };
}
// … four books, then three console.log calls

1,541 bytes. Run with Node, it prints five lines; we hashed that output and checked every obfuscated and deobfuscated version against it.

Obfuscating it

Each variant is one CLI call with a fixed --seed 7, so the runs are repeatable. For example the RC4 variant:

javascript-obfuscator src.js --output out/d-rc4.js --seed 7 \
  --string-array true --string-array-threshold 1 \
  --string-array-rotate true --string-array-shuffle true \
  --string-array-encoding rc4 \
  --string-array-wrappers-count 2 --string-array-wrappers-type function \
  --string-array-wrappers-chained-calls true

--string-array-threshold 1 puts every string into the array; the default is 0.75, which leaves some strings in place at random. All 14 obfuscated files printed the same output as the original except one, which we come back to below.

The string array is the core of obfuscator.io. Here is how it looks at three encodings, cut from the start of each file:

// encoding none
const _0x44280f=['https://books.example.org/api/v2','Not\x20started','In\x20progress', …
// encoding base64
const _0x415402=['CMvHzgLUzW','l3vZzxjZlW','C3rYAw5NAwz5','zg9Uzq','Ahr0Chm6lY9IB29RCY5LEgfTCgXLlM9YzY9HCgKVDJi', …
// encoding rc4
const _0x3a3fd8=['WRjAaSkXurxcNmk1WRDXWQSnWONdRZembK3dG8kXW7G0hSox','s8oVW5KAirVdVuHy', …

Base64 here is not the standard alphabet. Ahr0Chm6 is aHR0cHM6 (“https:”) with upper and lower case swapped, so pasting it into atob gives garbage. RC4 entries need a per-call key that sits in the calling code, not in the array. Add rotation (the array is shifted at start-up until a parseInt checksum matches) and index shifts (a0_0x515e(_0x4fd581- -0x1cc, …)) and no single value in the file maps directly to a string.

Results

Every row was run through deobfuscate() with the tool’s default switches (decode strings, rename, unpack, JSX). “Strings back” counts how many of our 16 marker strings appear in plain text in the output. Times are single runs in Node.

Variant (options) Obfuscated Output Decoder calls inlined Strings back Time
default preset 3,090 B 1,569 B 37 16/16 4 ms
string array, no rotate, none 2,537 B 1,569 B 39 16/16 28 ms
+ rotate, shuffle, index shift 3,262 B 1,569 B 49 16/16 10 ms
+ base64, 2 function wrappers 5,615 B 1,569 B 49 16/16 23 ms
+ rc4, chained wrappers 7,276 B 1,569 B 49 16/16 107 ms
rc4 + stringArrayCallsTransform 8,170 B 1,569 B 49 16/16 92 ms
rc4 + control-flow flattening + dead code + split strings + object keys 10,215 B 1,592 B 129 16/16 484 ms
high-obfuscation preset¹ 30,706 B 1,592 B 231 16/16 223 ms
numbers to expressions + unicode escapes 5,531 B 1,569 B 50 16/16 5 ms
renameGlobals + mangled names 2,245 B 1,469 B 48 16/16 4 ms
renameProperties 3,277 B 1,580 B 49 14/16 4 ms

¹ minus debugProtection and disableConsoleOutput, which would stop the test from running.

For every row except renameProperties, the deobfuscated code printed exactly what the original printed. The first six string-array rows produced byte-identical output files. The two heavier ones came out at 1,592 bytes and differ from those only in placeholder names, plus one const vO2 = {…}; return vO2; left over from transformObjectKeys. 30 KB went back to 1.6 KB.

The output for the high-obfuscation file, trimmed:

const API_BASE = "https://books.example.org/api/v2";
const STATUS_LABELS = {
  todo: "Not started",
  reading: "In progress",
  done: "Finished"
};
function slugify(p) {
  return p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function summarise(p2) {
  const vVO = { todo: 0, reading: 0, done: 0 };
  let vLN0 = 0;
  for (const v5 of p2) {
    if (!(v5.status in vVO)) {
      throw new Error("unknown status: " + v5.status);
    }
    …
function buildRequest(p5, p6) {
  const { counts, pages } = summarise(p6);
  …

Look at what survived and why. API_BASE, slugify and buildRequest kept their names because javascript-obfuscator does not rename top-level names unless you pass renameGlobals. The local names did not: title is p, counts inside summarise is vVO (a variable holding an Object), pages is vLN0 (a Literal Number 0). The destructured { counts, pages } in buildRequest got its names back because the property key carries them. The regular expressions came through untouched; the obfuscator does not encode regex literals.

With renameGlobals the top-level names are gone too: the output starts with const a0c = "https://books.example.org/api/v2" and function a0e(a). There is nothing in the file to recover them from.

Control-flow flattening: the switch only appears with var

We expected --control-flow-flattening to produce the familiar while (!![]) { switch (order[i++]) … } dispatcher. On our script it did not, even at threshold 1. It only added proxy-function objects such as:

const _0x6e32ef = {
  'MJMig': function (_0x5cf084, _0x2540bb) { return _0x5cf084 in _0x2540bb; },
  'cxEnR': function (_0x36f477, _0x1ebb73) { return _0x36f477 + _0x1ebb73; },
  'haepE': 'unknown\x20status:\x20', …

Our functions use const and let and have short bodies. We rewrote one function with var and a longer body (src-var.js), and the dispatcher appeared. We changed both things at once, so we cannot say which one mattered:

}, _0x522111 = _0x505068[_0xc5c14b(0x142)][_0xc5c14b(0x14a)]('|'), _0x4c9118 = 0x0;
while (!![]) {
    switch (_0x522111[_0x4c9118++]) {
    case '0':
        var _0x46a05f = '';
        continue;
    case '1':
        return _0x3b80a3;

The deobfuscator put the statements back in order: 4,971 bytes and 146 lines in, 643 bytes and 28 lines out, no switch left, same output.

The pitfall we did not expect: no string array, no control-flow cleanup

Then we obfuscated with control-flow flattening and no string array (--string-array false). The proxy objects stayed in the output:

for (const v2 of p2) {
  if (!vO.MJMig(v2.status, vO2)) {
    throw new Error(vO.cxEnR(vO.haepE, v2.status));
  }

The cause is in webcrack itself. In the vendored 2.16.0 source, the deobfuscate step returns early when it finds no string array, and the control-flow-object, control-flow-switch and dead-code transforms run at the end of that same step:

if (!stringArray) return;
…
state.changes += applyTransforms(ast,
  [merge_strings_default, dead_code_default, control_flow_object_default, control_flow_switch_default], …

The same happens when you turn off Decode string arrays in the tool: the step needs its sandbox, so nothing after it runs. On the rc4 + control-flow + dead-code file, decoding off gave 10,503 bytes and 198 lines instead of 1,592 bytes and 72 lines. In practice this rarely matters, because obfuscator.io output almost always has a string array (even --dead-code-injection with --string-array false turned it back on in our test: the result had 41 decoder calls). But if a file still shows obj.AbCdE(x, y) calls after a run, that is why.

What cannot come back

renameProperties. This option renames object properties throughout the program. In our script it renamed counts, slugs and even the HTTP header names X-Client and Content-Type:

headers: {
  _0x522f87: "reading-list/1.4",
  _0x3e21ad: "application/json"
},

The obfuscated program itself sent {"_0x31f331":{…},"pages":1009,"_0x25cb26":[…]} instead of {"counts":…}. That is why its output hash differed from the original, and why 2 of the 16 strings are missing after deobfuscation: they no longer exist anywhere in the file. The deobfuscated code behaved exactly like the obfuscated code, which is all a deobfuscator can promise. The javascript-obfuscator README warns that this option “MAY break your code”, and that was with its default safe mode.

Local names, comments, formatting. The obfuscator drops them, and they are not stored anywhere. If you have the source map for your own build, the JS source map restorer gives you the real names, which beats any deobfuscator.

Turning string decoding off on self-defending code hangs it

high-obfuscation enables selfDefending, which checks whether its own functions have been reformatted. With string decoding turned off, the deobfuscator still reformats the code, but the decoders stay. Running that output in Node did not finish: our harness stopped it at its 5-second limit (Script execution timed out after 5000ms). The tool page warns about this. Read such output, do not run it.

A larger file, and the work budget

To see the limits, we obfuscated an open-source library: lodash 4.17.21 (lodash.js, 544,098 bytes, MIT licence). The obfuscator took 0.64 s for the default preset and 3.6 s for high-obfuscation.

  • default preset, 193,916 bytes: deobfuscated in 840 ms. 1,245 decoder calls inlined, distinct _0x names went from 3,253 to 4. The output passed our probe (_.chunk, _.camelCase, _.template and _.VERSION gave the same results as the original). The 4 names left are loop labels (_0x54a173: while (…)), which the renamer does not touch. Some generated names embed old ones, such as vV_0x1209d5.
  • high-obfuscation, 2,921,891 bytes: in Node, with no time limit, the run took 815 seconds and then gave up on the strings: status failed, reason step budget exceeded, 0 strings decoded. The evaluator gives each decoder evaluation a budget of 50 million steps, and the RC4 decoding inside the rotation loop on a string array this large ran past it. The output was formatted and partly renamed, but still encoded, and running it hit our 20-second timeout (self-defending again). In the browser you would not see the 815 seconds: the page stops the worker after 3 minutes.

So for a big file at the heaviest preset, the in-browser tool is not the right tool. The command-line webcrack runs decoders in isolated-vm, which executes them as real JavaScript in a sandbox instead of through this evaluator. We did not time that here. Do that only on code you are allowed to run, on your own machine.

Where REA fits

REA, the open-source project by morluto, approaches front-end code from the bundle side: source maps, webpack chunk recovery and, with a Wakaru binary you supply, unpacking into modules. That workflow is in recover JavaScript source from a bundle. We searched the 6.7.0 npm package (npm pack [email protected]) for anything that targets obfuscator.io string arrays and found none, so for this kind of file decode the strings first with the tool above, then feed the readable output into that workflow or into tracing a website’s API calls.

Deobfuscate only your own code or code you are authorized to review, and respect the licence of what you inspect. See the disclaimer.

Reproducing this

  1. npm i javascript-obfuscator in an empty folder, and save your own script as src.js.
  2. Obfuscate with --seed fixed and --string-array-threshold 1, one option group at a time, and check with node out/x.js that the output did not change.
  3. Paste each file into the deobfuscator and read the summary line: strings decoded, _0x names before and after, size and lines. If it says “found, but not decoded”, the reason in brackets tells you which evaluator limit stopped it.

If the obfuscated script talks to a protobuf or gRPC API, the next step is reading the bodies it sends: see decode protobuf without the .proto.