July 18, 2026

JavaScript Deobfuscation for CTF: Hook the Sink, Not the Source

Deobfuscate CTF JavaScript by hooking eval, Function, and atob instead of reading minified code. Includes WebAssembly reversing and a DevTools workflow.

The short answer

Stop reading the obfuscated code. Obfuscated JavaScript has to un-obfuscate itself before the browser can run it, so put yourself at the moment it does. Paste this in the console before the script loads and the payload prints itself:

const _e = window.eval;
window.eval = s => { console.log('[eval]', s); return _e(s); };

Same trick for Function, atob, and WebAssembly.instantiate. The obfuscator does your work for you.

How to read this

  • 60-second skim: the box above, then the hook snippets and the quick reference table.
  • 12-minute read: the full guide, including WebAssembly and the DevTools breakpoint workflow.
  • 40-minute lab: run the hooks against any of the linked picoCTF web challenges before reading the writeup.

What obfuscated JavaScript looks like in CTF

Web challenges hide flags in client-side code constantly, and the author's defense is almost always obfuscation rather than real secrecy. You open view-source: and find a wall of hex escapes, a single 4,000-character line, an array of scrambled strings indexed by a shuffled lookup function, or the classic eval(atob('...')).

The genre breaks into a handful of recognizable shapes:

  • Minification. Not obfuscation at all, just whitespace and short names. A beautifier fully undoes it.
  • String-array indirection. Every literal is moved into one array and referenced through a decoder function, often with the array rotated at load time.
  • Encoding layers. Base64, hex escapes, URL encoding, or character-code arithmetic wrapped around the real source.
  • Runtime construction. The real code never appears in the file; it is assembled at runtime and handed to eval or new Function.
  • Compiled targets. The logic is in WebAssembly and JavaScript is only the loader.

Those last two are where people get stuck, because a beautifier does nothing useful for either. They are also, conveniently, the two where the technique in this guide is strongest.

The sink-hooking principle

Here is the observation the whole guide rests on. Obfuscated code is still code. The browser cannot execute a base64 blob or a rotated string array directly. At some point, the script must convert its hidden payload into something the JavaScript engine accepts, and it must do that in front of you, in your browser, with your debugger attached.

That conversion happens at a small number of sinks: functions that turn data into behavior. If you replace a sink with your own version that logs its argument and then calls the original, the obfuscator hands you clean code voluntarily. You never have to understand the obfuscation at all.

SinkWhat It Reveals
eval(src)The fully assembled source, ready to run
new Function(body)Same, via the other dynamic-code path
atob(s)Every base64 payload as it decodes
String.fromCharCode(...)Character-code arithmetic results
document.write(html)Injected markup and inline scripts
JSON.parse(s)Configuration and embedded data blobs
WebAssembly.instantiate(buf)The raw module bytes before execution
fetch / XMLHttpRequestWhere a second-stage payload comes from
TakeawayBeautifiers fight the obfuscation. Sink hooks make the obfuscation do the work, because the code has to produce a clean payload in order to run at all.

Static passes worth doing first

Before hooking anything, spend two minutes on the cheap passes. A surprising share of challenges end here.

Beautify. In Chrome or Firefox DevTools, open the Sources panel and click the {} pretty-print button at the bottom of the editor. That alone solves pure minification, and it makes every later step readable.

Grep for the flag format. Obvious, routinely skipped, and it works more often than it should.

# Pull every script the page loads, then search them all at once.
wget -r -l1 -H -nd -A.js -e robots=off http://target:port/ -P js/
grep -rniE 'picoctf|flag|secret|token' js/
# Decode any base64-looking blob you find on the way.
grep -roE '[A-Za-z0-9+/]{40,}={0,2}' js/ | cut -d: -f2 | sort -u | while read b; do
echo "$b" | base64 -d 2>/dev/null | strings | grep -i pico
done

Extract the string array. When the file opens with a large literal array and a decoder function, you often do not need to understand the decoder. Copy the array into the console and print it. The flag is frequently sitting in it as plain text, because the author obfuscated the control flow and forgot the data.

// Paste the array literal, then look at it directly.
const _a = ['\x66\x6c\x61\x67', 'aGVsbG8=', '...'];
_a.forEach((s, i) => {
let dec = s;
try { dec = atob(s); } catch (e) { /* not base64, keep original */ }
console.log(i, JSON.stringify(s), '->', JSON.stringify(dec));
});

For layered encodings, Recipe Chain peels one step at a time, and the regex tester is useful for pulling every string literal out of a wall of code without hand-editing.

TakeawayAuthors obfuscate control flow far more carefully than they obfuscate data. Read the string array before you read the logic.

Hooking eval, Function, and atob

When static passes stall, hook the sinks. The pattern is always the same: save the original, replace it with a logging wrapper, call through.

// Paste into the console BEFORE the obfuscated script runs.
// Use a breakpoint on the first line of the page, or a browser extension
// that injects at document-start.
// 1. eval
const _eval = window.eval;
window.eval = function (src) {
console.log('%c[eval]', 'color:#38bdf8', src);
return _eval.call(this, src);
};
// 2. The Function constructor, both call and construct forms.
const _Function = window.Function;
window.Function = new Proxy(_Function, {
apply(target, thisArg, args) {
console.log('%c[Function]', 'color:#a78bfa', args[args.length - 1]);
return Reflect.apply(target, thisArg, args);
},
construct(target, args) {
console.log('%c[new Function]', 'color:#a78bfa', args[args.length - 1]);
return Reflect.construct(target, args);
},
});
// 3. atob, which catches most base64 payloads.
const _atob = window.atob;
window.atob = function (s) {
const out = _atob(s);
console.log('%c[atob]', 'color:#4ade80', s.slice(0, 60), '->', out);
return out;
};
// 4. String.fromCharCode, for char-code arithmetic.
const _fcc = String.fromCharCode;
String.fromCharCode = function (...codes) {
const out = _fcc.apply(String, codes);
if (out.length > 4) console.log('%c[fromCharCode]', 'color:#fbbf24', out);
return out;
};

The proxy matters for Function. A naive reassignment breaks any code that calls new Function(...), because a plain arrow function is not constructible, and a broken hook makes the page throw instead of revealing anything. The proxy preserves both call paths and the prototype chain.

Timing is the hard part. Hooks only catch calls that happen after you install them. If the script runs on page load and you paste into the console afterwards, you see nothing. Fix it by opening DevTools first, setting an event listener breakpoint on Script First Statement under the Sources panel, reloading, pasting your hooks at the pause, then resuming.

Once eval prints the real source, copy it into a file and read it normally. Multi-stage payloads reveal themselves as a sequence of console entries, each one less obfuscated than the last.

unminify is the gentle introduction here. login and the-add-on-trap reward the full hook set.

TakeawayA hook installed after the script ran catches nothing. Break on the first statement, install hooks at the pause, then resume. That single habit solves most of these.

When the flag is already there

A whole category of web challenge puts the check in the browser: the page compares your input against a secret it already holds, then either congratulates you or does not. The secret is present on the client by definition, which means the challenge is not really about obfuscation. It is about knowing that client-side validation is not a security boundary.

Three moves cover almost all of them:

  1. Read the comparison. Find the if that decides success and look at what it compares against. The flag is often the literal on the other side.
  2. Call the success function directly. If the code has function win() { ... }, type win() in the console. You do not need to satisfy the check, you only need to reach its consequence.
  3. Invert the checker. When the comparison is against a transformed value, apply the inverse to the constant rather than searching for an input.
// Typical shape: input is transformed, then compared to a constant.
// function check(s) { return btoa(s.split('').reverse().join('')) === 'Zm...'; }
// Do not brute-force. Invert it.
const target = 'Zm9vYmFy';
console.log(atob(target).split('').reverse().join(''));
// List every function the page defined, in case win() is not obvious.
Object.keys(window).filter(k => typeof window[k] === 'function' && !(k in {}))
.forEach(k => console.log(k, String(window[k]).slice(0, 120)));

dont-use-client-side and client-side-again are the canonical pair, and the second one adds exactly one obfuscation layer over the first.

TakeawayIf the browser can tell you that you are wrong, the browser knows what right looks like. Find where it stores that knowledge instead of guessing at inputs.

WebAssembly challenges: the loader is not the logic

When the JavaScript is short, boring, and ends in WebAssembly.instantiate, the actual challenge is in a .wasm file. Reading the loader gets you nowhere. Get the module bytes and disassemble them.

# Install the WebAssembly Binary Toolkit.
sudo apt install wabt # or: brew install wabt
# Convert the module to readable text format.
wasm2wat challenge.wasm -o challenge.wat
# Strings still work on wasm binaries and often leak the flag outright.
strings -n 6 challenge.wasm | grep -iE 'pico|flag|ctf'
# Look at the data section, where string constants live.
grep -A 5 '(data' challenge.wat | head -40
# Decompile to pseudo-C if wabt's output is hard to follow.
wasm-decompile challenge.wasm -o challenge.dcmp

If the module is fetched at runtime rather than served as a static file, hook the instantiation to capture the bytes:

// Capture wasm bytes and offer them as a download.
const _inst = WebAssembly.instantiate;
WebAssembly.instantiate = function (bytes, imports) {
const buf = bytes instanceof ArrayBuffer ? bytes : bytes.buffer;
const blob = new Blob([buf], { type: 'application/wasm' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'captured.wasm';
a.click();
return _inst.apply(this, arguments);
};

Reading .wat is easier than it looks. It is a stack machine, so i32.const 5 pushes 5 and i32.add pops two and pushes the sum. The pattern to hunt for is a loop that walks a data-section string and compares bytes against your input, which is the WebAssembly version of the client-side check from the previous section, and it inverts the same way.

The some-assembly-required-1 series is built for exactly this workflow, escalating from a plain data-section string in the first to real transformation logic by some-assembly-required-4.

Do not skip strings. A meaningful fraction of WebAssembly challenges store the flag as a literal in the data section. Running strings on the binary takes two seconds and sometimes ends the challenge before you open a disassembler.

The DevTools workflow

Console hooks are one tool. The debugger is the other, and for anything with real control flow it is faster.

  • Break on the first statement. Sources panel, Event Listener Breakpoints, Script, Script First Statement. This is how you get ahead of a script that runs on load.
  • Break on DOM changes. Right-click the element that shows success or failure, then Break on, Subtree modifications. The stack trace at the pause walks you straight back to the deciding function.
  • Use conditional breakpoints as loggers. Set a breakpoint with the condition console.log(x), false. It logs every hit and never actually pauses, which is a printf statement you did not have to edit the file to add.
  • Use Local Overrides. The Sources panel can save a modified copy of any script to disk and serve it on reload. Beautify the file, add your own logging, delete an anti-debugger check, and the page runs your version.
  • Watch the Network tab. Second-stage payloads, flags returned by an endpoint, and the real answer to a check that only pretends to be client-side all show up there.
Anti-debugger loops. Some challenges spam debugger statements to make DevTools unusable. Right-click the line number and choose Never pause here, or use Local Overrides to strip the statement from the file. Do not fight it by closing DevTools.
TakeawayLocal Overrides turns a read-only web challenge into a local one you can edit. It is the single most underused feature in browser DevTools for CTF.

The decision workflow

StepQuestionIf Yes
1Does grep for the flag format hit anything?Done, submit it
2Is it only minified?Pretty-print and read it
3Is there a large string array?Print the array, ignore the decoder
4Does it call eval, Function, or atob?Hook the sink and read the payload
5Is there a success or failure branch?Call the success path directly
6Is the check a transform against a constant?Invert the transform on the constant
7Does it load a .wasm module?strings, then wasm2wat
8Still stuck?Local Overrides plus conditional-breakpoint logging
TakeawaySteps 1 through 3 cost two minutes total and solve a large share of these challenges. Do not open the debugger until they fail.

Quick reference

Console one-liners

GoalSnippet
Log every evalconst _e=eval; window.eval=s==>(console.log(s),_e(s))
Log every atobconst _a=atob; window.atob=s==>(console.log(_a(s)),_a(s))
Dump all page functionsObject.keys(window).filter(k==>typeof window[k]==='function')
Read a script's sourceString(someFunction)
Silent logging breakpointconsole.log(x), false
Find all inline scripts[...document.scripts].map(s==>s.src||s.textContent)

Command-line tools

GoalCommand
Beautify a filenpx js-beautify -f in.js -o out.js
Download all page scriptswget -r -l1 -H -nd -A.js -e robots=off URL
Disassemble WebAssemblywasm2wat mod.wasm -o mod.wat
Decompile WebAssemblywasm-decompile mod.wasm
Strings on a wasm binarystrings -n 6 mod.wasm | grep -i pico

Tools on this site

picoCTF JavaScript and WebAssembly challenges

The thing I like about this category is how badly it rewards patience over cleverness. I have watched people spend forty minutes hand-tracing a rotated string array while the answer sat in the console output of a two-line hook. The obfuscation is designed to make you feel like reading harder is the way through. It almost never is.

Obfuscated JavaScript has to deobfuscate itself before it can run. Stand at that moment and let it hand you the answer.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.