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
evalornew 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.
Related guides on this site
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.
| Sink | What 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 / XMLHttpRequest | Where a second-stage payload comes from |
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; doecho "$b" | base64 -d 2>/dev/null | strings | grep -i picodone
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.
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. evalconst _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.
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.
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:
- Read the comparison. Find the
ifthat decides success and look at what it compares against. The flag is often the literal on the other side. - Call the success function directly. If the code has
function win() { ... }, typewin()in the console. You do not need to satisfy the check, you only need to reach its consequence. - 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.
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.
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.
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.The decision workflow
| Step | Question | If Yes |
|---|---|---|
| 1 | Does grep for the flag format hit anything? | Done, submit it |
| 2 | Is it only minified? | Pretty-print and read it |
| 3 | Is there a large string array? | Print the array, ignore the decoder |
| 4 | Does it call eval, Function, or atob? | Hook the sink and read the payload |
| 5 | Is there a success or failure branch? | Call the success path directly |
| 6 | Is the check a transform against a constant? | Invert the transform on the constant |
| 7 | Does it load a .wasm module? | strings, then wasm2wat |
| 8 | Still stuck? | Local Overrides plus conditional-breakpoint logging |
Quick reference
Console one-liners
| Goal | Snippet |
|---|---|
| Log every eval | const _e=eval; window.eval=s==>(console.log(s),_e(s)) |
| Log every atob | const _a=atob; window.atob=s==>(console.log(_a(s)),_a(s)) |
| Dump all page functions | Object.keys(window).filter(k==>typeof window[k]==='function') |
| Read a script's source | String(someFunction) |
| Silent logging breakpoint | console.log(x), false |
| Find all inline scripts | [...document.scripts].map(s==>s.src||s.textContent) |
Command-line tools
| Goal | Command |
|---|---|
| Beautify a file | npx js-beautify -f in.js -o out.js |
| Download all page scripts | wget -r -l1 -H -nd -A.js -e robots=off URL |
| Disassemble WebAssembly | wasm2wat mod.wasm -o mod.wat |
| Decompile WebAssembly | wasm-decompile mod.wasm |
| Strings on a wasm binary | strings -n 6 mod.wasm | grep -i pico |
Tools on this site
picoCTF JavaScript and WebAssembly challenges
Client-side checks
Obfuscation and minification
WebAssembly
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.