How to Read JavaScript That Was Written Not to Be Read

· Practice · 6 min

How to Read JavaScript That Was Written Not to Be Read

Open the source of a phishing page, a malicious ad or a skimmer, and you’ll often find a wall of JavaScript that looks like someone fell asleep on the keyboard. Variable names like _0x3c1e. Strings that make no sense. One line that scrolls for a minute.

That’s obfuscation. The author wants the code to run, but not to be read.

You don’t need to understand every line. You need to answer two questions: what does this script do, and who does it talk to? Most obfuscation is a stack of simple tricks you can peel off one layer at a time.

Before you touch anything

Don’t run unknown JavaScript on your daily machine. Not in your normal browser, not with your accounts signed in.

  • Use a throwaway virtual machine with a snapshot, no personal data and no saved logins.
  • Read first, run later. Most of this post is static: you only look at text and decode it.
  • Your tools can have bugs too. The deobfuscator webcrack once let a malicious bundle write files anywhere on Windows. Good tool, but run it in the VM as well.
  • Keep the original file untouched. Work on copies.

Four words that get mixed up

Minification removes whitespace and shortens names to make files smaller. It isn’t meant to hide anything, but it does make code hard to read.

Obfuscation is meant to hide what the code does. It renames things, hides strings and wraps code in layers.

Encoding (base64, hex, rot13) changes how data is written. Anyone can reverse it. There’s no key.

Encryption needs a key. If the key is in the script, as it usually is on a web page, it’s encoding with extra steps.

That’s why client-side secrets never work: whatever the browser can run, you can read.

The sample

To keep this safe, I wrote a small script that behaves like a real one but does nothing harmful. It hides a web address and “contacts” it, except it only prints a line. Here it is, as you’d find it:

var _0x3c1e=['dmFyIHBhcnRzPVsnZXhhbScsJ3BsZS5pbicsJ3ZhbGlkJ107dmFyIGhvc3Q9cGFydHMuam9pbignJyk7Y29uc29sZS5sb2coJ3dvdWxkIGNvbnRhY3QgaHR0cHM6Ly8nK2hvc3QrJy9jb2xsZWN0Jyk7'];(function(_0xa){var _0xd=globalThis['\x61\x74\x6f\x62'](_0xa);eval(_0xd);})(_0x3c1e[0]);

To follow along, save it as sample.js inside a VM. Nothing in it touches the network. The sample and scripts to decode and verify it are on GitHub.

Step 1: make it readable

Run it through a beautifier: Prettier’s playground, beautifier.io, js-beautify or the pretty print button in your browser’s developer tools. This changes layout, not behaviour.

var _0x3c1e = ['dmFyIHBhcnRzPVsnZXhhbScsJ3BsZS5pbicsJ3ZhbGlkJ107dmFyIGhvc3Q9...'];
(function (_0xa) {
  var _0xd = globalThis['\x61\x74\x6f\x62'](_0xa);
  eval(_0xd);
})(_0x3c1e[0]);

Now the structure shows. An array holds one long string. A function passes that string through something and hands the result to eval, which runs whatever text it gets. Remember this: the real code is built at runtime and handed to eval.

Step 2: recognise what you’re looking at

You don’t need a tool to identify most encodings. You need to know what they look like.

What you seeProbablyNotes
Letters and digits, sometimes + and /, often ending in =Base64Very common. The = padding gives it away
Only 0-9 and a-fHexTwo characters per byte
\x61\x74\x6f\x62 inside quotesHex escapesThe browser turns them into plain text when it runs
Normal-looking text that doesn’t read right, with the same shape as the originalROT13 or CaesarStructure survives, letters shift
eval(function(p,a,c,k,e,d)A packerA classic way to wrap code. An unpacker handles it
Huge arrays of strings plus a function that fetches items by numberString array obfuscationTypical for tool-generated output

In our sample, two things stand out: the function name is written in hex escapes, and the long string looks like base64.

Step 3: decode by hand

Start with the hex escapes. Your terminal can read them:

printf '\x61\x74\x6f\x62\n'
atob

atob is the browser’s built-in base64 decoder. The script calls it to decode its own string, and hides the name so a quick search for atob fails. Now decode the string:

echo 'dmFyIHBhcnRzPVsnZXhhbScsJ3BsZS5pbicsJ3ZhbGlkJ107dmFyIGhvc3Q9cGFydHMuam9pbignJyk7Y29uc29sZS5sb2coJ3dvdWxkIGNvbnRhY3QgaHR0cHM6Ly8nK2hvc3QrJy9jb2xsZWN0Jyk7' | base64 -d
var parts=['exam','ple.in','valid'];var host=parts.join('');console.log('would contact https://'+host+'/collect');

That’s the next layer. Real samples often stack several, so repeat: look, identify, decode.

Step 4: let eval tell you instead

With many layers, decoding by hand gets slow. The shortcut: the script ends by handing text to eval, so replace eval with something that only prints it.

In the throwaway VM, with Node.js:

node -e "globalThis.eval = console.log; require('./sample.js')"
var parts=['exam','ple.in','valid'];var host=parts.join('');console.log('would contact https://'+host+'/collect');

Same result, but the hidden code was printed instead of executed. In a browser console the idea is the same: overwrite eval before the script runs.

eval isn’t the only exit. Also look for new Function(...), setTimeout or setInterval with a string, and document.write. Some obfuscators also add self-defending code that breaks the script once it’s reformatted, another reason to keep your original.

Step 5: read what came out

The hidden code is now plain. It splits a domain into three pieces, so a search for the full name misses it, then glues them together and builds the URL:

exam + ple.in + valid  ->  example.invalid

That answers question two. In a real investigation this is the indicator you write down, defanged as example[.]invalid so nobody clicks it by accident. Then check it against your logs and share it with your team.

(.invalid is a reserved name that never resolves, which is why I used it.)

When to reach for a tool

Hand decoding teaches you the patterns. Tools save time once you know what you’re looking at.

SituationTry
Minified, but otherwise plainA beautifier: Prettier, beautifier.io, js-beautify
Packed codeAn unpacker, or js-beautify, which handles common packers
Output of obfuscator.io style tools, webpack bundleswebcrack, which deobfuscates, unminifies and unpacks
Old-style encoders such as JSFuck or JJEncodede4js. Archived since 2021, so don’t expect it to handle newer obfuscators
Unknown or customManual work, or your own script on top of a parser such as Babel

A practical order: beautify, try an automatic tool, then go manual on what remains. Save every intermediate version so you can compare.

Cheat sheet

Keep this one.

  1. Save the original. Never edit it.
  2. Work in a throwaway VM.
  3. Beautify. Read the structure.
  4. Find the exit: eval, Function, timers with strings, document.write.
  5. Identify the encoding by its look.
  6. Decode one layer. Repeat.
  7. Print instead of executing: replace the exit with console.log.
  8. Write down domains, URLs and file names. Defang them.
  9. Verify: does your reading match what the script does in the VM?
  10. Document what you did, so someone else can repeat it.

Handy commands:

printf 'text' | base64          # encode
echo 'dGV4dA==' | base64 -d     # decode
printf 'text' | xxd -p          # hex encode
echo '74657874' | xxd -p -r     # hex decode
echo 'text' | tr 'A-Za-z' 'N-ZA-Mn-za-m'   # rot13, same command both ways

Wrapping up

Automatic tools do most of the work, but they don’t understand intent. When they fail, knowing why each layer exists gets you to the end.

Next time you meet a wall of _0x names, don’t read it. Make it readable, find the exit, and let the script show you what it’s hiding.

Sources

I learned the fundamentals in the Hack The Box Academy module on JavaScript deobfuscation. The sample and the text in this post are my own.

Sample and scripts for this post: https://github.com/sbroekhoven/javascript-blogpost

webcrack, project page: https://github.com/j4k0xb/webcrack

webcrack, advisory GHSA-ccqh-278p-xq6w (arbitrary file write on Windows): https://github.com/j4k0xb/webcrack/security/advisories/GHSA-ccqh-278p-xq6w

de4js, project page, archived by the owner on 7 December 2021: https://github.com/lelinhtinh/de4js

Found this useful? Leave kudos: