Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Breaking the Seal: Static Deobfuscation of Comp...

Breaking the Seal: Static Deobfuscation of Compiled V8 JavaScript Bytecode Malware

Avatar for hasherezade

hasherezade

August 05, 2026

More Decks by hasherezade

Other Decks in Technology

Transcript

  1. MALWARE / BRIEFINGS · 2026 REVERSE ENGINEERING COMPILED V8 JAVASCRIPT

    BYTECODE MALWARE BREAKING THE SEAL Static deobfuscation of compiled V8 JavaScript bytecode malware Aleksandra “Hasherezade” Doniec · Check Point . J S C → R E A D A B L E CODE Research
  2. WHOAMI ABOUT ME Aleksandra “Hasherezade” Doniec @hasherezade — Malware researcher

    & engineer at Check Point Research — I reverse malware & build open-source tools — Author of PE-sieve/HollowsHunter · Tiny Tracer · PE-bear — Today: deobfuscating a V8 cache payload & publishing the toolkit hasherezade.net @hasherezade tools · write-ups
  3. INTRODUCTION WHEN THE TOOLS FIT Analysis is easier when the

    payload arrives in a form our tools already understand. 01 02 03 A readable script An intermediate language we can decompile A native binary for IDA & mature frameworks The tools aren’t magic — but they give us a place to start.
  4. INTRODUCTION THE USUAL OBSTACLES Malware authors do anything they can

    to disrupt our workflow. THE USUAL OBSTACLES EVASION OBFUSCATION Example via github.com/DosX-dev/obfus.h
  5. INTRODUCTION FRICTION BY FORMAT A quieter strategy: choose a format

    where the ecosystem is weak. If the payload lands where our tools don’t quite fit, the attacker gets friction almost for free. WE’VE SEEN THIS BEFORE GO RUST AOT .NET Each created its own reversing friction. The tools caught up — slowly. Rust binary disassembly · via cxiao.net/posts/2025-08-17not-so-simple-rust-loader
  6. WHY THIS FORMAT MATTERS WHY JAVASCRIPT, WHY NODE Born to

    enhance web pages browser language → a general-purpose platform via Node.js npm ≈ pip — pull in existing modules. Build capable software quickly, in a high-level language, from parts that already exist. $ npm install <module>
  7. WHY THIS FORMAT MATTERS THE COMPILATION TRICK source JavaScript →

    V8 engine parse + compile Ignition → bytecode code cache → .jsc Bytecode is not a stable public format — V8-internal & version-specific. TurboFan later JITs hot paths to native, but the cache stores Ignition bytecode — and that’s what ships to us.
  8. WHY THIS FORMAT MATTERS CODE CACHING IN PRACTICE COMPILE →

    CACHE · MAKE_CACHE.JS const vm = require("vm"); const fs = require("fs"); const source = fs.readFileSync("hello.js"); // compile, then serialize the bytecode const script = new vm.Script (source); const cache = script. createCachedData (); fs.writeFileSync("hello.js.cache", cache); LOAD CACHE → RUN · RUN_CACHE.JS const vm = require("vm"); const fs = require("fs"); const source = fs.readFileSync("hello.js", "utf8"); const cache = fs.readFileSync("hello.js.cache"); // reuse the cache — skip parse + compile const script = new vm.Script (source, { cachedData : cache }); if (script.cachedDataRejected) throw new Error("V8 rejected the cache"); script.runInThisContext();
  9. WHY THIS FORMAT MATTERS THE ATTACKER'S RECIPE write JS →

    obfuscate → compile → V8 cache → bundle runtime → ship compiled WHAT THEY KEEP WHAT THEY GAIN The productivity of JavaScript + npm — build fast, from parts that already exist. A new layer of defense — as a bonus, for free. THE ANALYST RECEIVES ONLY COMPILED V8 BYTECODE
  10. THE CASE STUDY MEET JSCEAL Named for the final payload’s

    extension — .jsc A cryptocurrency-focused stealer with surveillance & interception Tracked by Check Point since early 2025 — campaigns already published Today: the untold part — how we reverseengineered it downloading of the final payload research.checkpoint.com/2025/jsceal-targets-crypto-apps
  11. THE CASE STUDY THE STARTING POINT — NOT FRIENDLY Brotli-compressed

    blob THE USUAL GOAL V8 code cache — run by a bundled Node.js Recover code well enough to understand what it does obfuscated JavaScript (before compilation) Track how the family evolves Compare samples
  12. THE QUESTION HOW DO YOU ANALYZE IT WHEN THE SOURCE

    IS GONE? browser + crypto theft Telegram sessions keylogging screenshots local HTTPS MITM proxy
  13. FIRST ATTEMPT THE BUNDLE UNPACKED I recreated what the deployment

    script does — download, unpack, run. RUN IT THE WAY THE LOADER WOULD # bring the payload's own runtime, load preflight first .\node.exe -r .\preflight.js .\app.jsc the bundle unpacked — app.jsc · preflight.js · node.exe · .node modules
  14. RUN IT · WHAT COMES OUT THE SERVER WAS GONE

    Running it exposes only the surface. real run Windows PowerShell — jsceal_last\build – □ ✕ The one thing it sends events: [ { name: 'session_start', domain: 'node' } ] A single Faro telemetry beacon — one session_start event, nothing more. Then it dies here reason: getaddrinfo ENOTFOUND far o.vertical-scaling.com The endpoint is already gone — ENOTFOUND. So we tried to bring it back ourselves.
  15. WE BROUGHT THE ENDPOINT BACK OURSELVES WE BROUGHT IT BACK

    Even a live server doesn't expose the logic. sinkhole receives the only request · answers 204 Windows PowerShell — jsceal_last\build 1 Redirect the host — hosts → 127.0.0.1 2 Forge trust — self-signed cert via NODE_EXTRA_CA_CERTS 3 Serve — a small HTTPS sinkhole on :443 It connects with status 204 — yet still exposes only the same session_start beacon. – □ ✕
  16. FIRST ATTEMPT BLACK BOX ANALYSIS IS NOT ENOUGH DYNAMIC GIVES

    US Observed behavior — from one execution. WE NEEDED A repeatable way to recover full logic To compare samples To map the complete capability set So I decided to recover the program from the bytecode. this path is not for the fainthearted
  17. GETTING TO CLEAN BYTECODE TOOLING FOR .JSC IS THIN decompiler

    decompiler decompiler · chosen JSC Decompiler ghidra_nodejs View8 online · needs upload OSS plugin · unmaintained OSS Python · author at CPR commercial · per .jsc file Standard — up to 500 KB · $200 Large — up to 2 MB · $500 LLM on raw disassembly? Up to ~500 MB per payload — and the wrong abstraction level. Abandoned. * * A decision made at a particular moment in time — LLM context windows were still too short to process files this large.
  18. GETTING TO CLEAN BYTECODE WHY VIEW8 Open-source Python decompiler By

    Moshe Marelus, Check Point Research Already used on compiled V8 JavaScript malware Didn’t solve the whole problem — but a strong foundation I could extend VIEW8 .jsc disassembly → readable pseudocode github.com/suleram/View8 research.checkpoint.com — release announcement
  19. GETTING TO CLEAN BYTECODE THE NAIVE PATH FAILS View8 needs

    disassembly — not the .jsc file. 1 Strip the Brotli compression layer — simple & mechanical 2 Ask the bundled Node to print the bytecode… ✗ noisy — runtime & loader bytecode mixed in ✗ Node treats the cache blob as a script — syntax error ACTUAL OUTPUT > node.exe --print-bytecode .\app.jsc.unp > listing.txt C:\Users\tester\Desktop\test\build\app.jsc.unp:1 b♣����i5P"*☺�s►}y☻'ux�☺∟S☺�9�`�r☺A$L`♫ ☺9&L`� ☺♀Qa�5M~☻cJ☺♀Qa�♣�v☻EQ☺♀QafE�#☻sJ☺♀Qa�9‫☻ކ‬dJ ☺♀Qa*�K�☻xJ☺♀Qa�♀K☻☻AJ☺♀Qa�]☻ZA☺♀Qa�↑_?☻lJ ^ SyntaxError: Invalid or unexpected token at wrapSafe (node:internal/modules/cjs/loader:1804:18) at Module._compile (node:internal/modules/cjs/loader:1845:20) at Object..js (node:internal/modules/cjs/loader:2002:10) at Module.load (node:internal/modules/cjs/loader:1594:32) at Module._load (node:internal/modules/cjs/loader:1396:12) at wrapModuleLoad (node:internal/modules/cjs/loader:255:19) at Module.executeUserEntryPoint (node:internal/main/run_main:154:5) at node:internal/main/run_main_module:33:47 Node.js v24.18.0
  20. GETTING TO CLEAN BYTECODE GETTING THE DISASSEMBLY THE FIX —

    A DISASSEMBLER ON THE V8 API Skip Node entirely — build a disassembler on the V8 API itself. → build the matching version of V8 → apply the author's patches, link as a static library V8::SetFlagsFromString("--no-lazy --no-flush-bytecode"); V8::Initialize(); isolate = Isolate::New(create_params); auto* cached = new ScriptCompiler::CachedData(buf, len); ScriptOrigin origin = CreateScriptOrigin( String::NewFromUtf8Literal(isolate, "code.jsc")); ScriptCompiler::Source source(dummySource, origin, cached); → consume the code cache directly repo github.com/j4k0xb/View8 ScriptCompiler::CompileUnboundScript( isolate, &source, ScriptCompiler::kConsumeCodeCache);
  21. GETTING TO CLEAN BYTECODE DEBUGGING V8 ITSELF A string-printing bug

    in the V8 disassembly path It passed a 16-bit code unit to std::isprint(), then narrowed printable values to char — undefined for non-byte inputs, and capable of garbling wide characters Result: raw unprintable bytes injected mid-string — corrupt disassembly that broke View8 downstream No workaround — I debugged V8 and fixed it at the root cause THE ROOT-CAUSE PATCH + + + + accumulator->Add("\\r"); } else if (c == '\\') { accumulator->Add("\\\\"); } else if (!std::isprint(c)) { } else if (c >= 0x20 && c <= 0x7e) { accumulator->Put(static_cast<char>(c)); } else if (c <= 0xff) { accumulator->Add("\\x%02x", c); } else { accumulator->Put(static_cast<char>(c)); accumulator->Add("\\u%04x", c); } Released: a ready-made disassembler you can reuse Details: github.com/hasherezade/jsc_deobfuscator/wiki/Building-V8-Disasm
  22. READING THE OUTPUT THE FALSE SUMMIT DECOMPILED OUTPUT · WHAT

    YOU ACTUALLY OPEN 34– 330– 55 MB 500 MB cache disassembly ~50 MB decompiled — Too big to read linearly — tens of thousands of functions, most of it bundled dependency code. — The JavaScript was obfuscated before it was compiled — the malware logic stayed hidden. A milestone — not yet the summit. // … thousands of functions like this … function func_Mz_0x10000000f(a0) { r5 = Scope[0] r2 = func_r_0x10000000b r0 = new {"w": 126853, "d": "741^", "J": 166834, "m": "!Rgf", "H": 187083, "U": "*LaG", "F": 1182, "r": "Zj4P", "I": 37217, "x": " Scope[6705][2] = new {"w": 1342} r6 = new {"jGBGz": null, "hBPBb": null, "qbyOP": null, "ykkYm": null, "SeAyf": null, "yHrsY": null, "umIdy": null, "RBgqe": null} r7 = func_r_0x10000000b(r0["w"], r0["d"]) r6["jGBGz"] = (r7 + func_r_0x10000000b(r0["J"], r0["m"])) r6["hBPBb"] = func_hBPBb_0x10000000c r6["qbyOP"] = func_r_0x10000000b(r0["H"], r0["U"]) r6["ykkYm"] = func_ykkYm_0x10000000d r6["SeAyf"] = func_SeAyf_0x10000000e r6["yHrsY"] = func_r_0x10000000b(r0["F"], r0["r"]) r6["umIdy"] = func_r_0x10000000b(r0["I"], r0["x"]) r7 = func_r_0x10000000b(r0["X"], r0["p"]) r7 = (r7 + func_r_0x10000000b(r0["a"], r0["g"])) r7 = (r7 + func_r_0x10000000b(r0["h"], r0["p"])) r6["RBgqe"] = (r7 + "l") r1 = r6 r7 = r1[func_r_0x10000000b(r0["k"], r0["z"])] r6 = r7[func_r_0x10000000b(r0["b"], r0["c"])] r3 = r6("|") r4 = 0 while (true) { r7 = Number(r4) r4 = (Number(r4) + 1) r6 = r3[r7] if (!r6 === "0") { if (!r6 === "1") { if (!r6 === "2") { if (! 6 "3")
  23. COMPILED CACHE → PSEUDOCODE VIEW8 · A WORKED EXAMPLE A

    V8 cache is a compiled artifact — names & formatting are already gone. What View8 recovers is lower-level: constants, registers, scopes, function boundaries, control flow. VIEW8 RECONSTRUCTION · NAMES SHORTENED FOR DISPLAY ORIGINAL SOURCE function greet(name) { return "Hello " + name; } console.log(greet("BlackHat")); → function func_start() { ACCU = DeclareGlobals([func_greet, 0], <closure>) r3 = func_greet("BlackHat") r0 = console["log"](r3) return r0 } function func_greet(a0) { return ("Hello " + a0) } Not original JavaScript. Not directly runnable. But parseable and understandable. First milestone — a reliable path from .jsc to pseudocode.
  24. WHAT VIEW8 GIVES US REGISTERS, SCOPES, DECLARERS Not named locals

    — virtual registers r0 r1 r2 + accumulator ACCU Functions declare other functions & pass data through Scope DECOMPILED INTERNALS · FUNC_START DECOMPILED INTERNALS · FUNC_GREET # func_start_0x1d1b0e89dcd9 # Declarer: None # Const Pool [func_greet_0x1d1b0e89ddc9, 0], "console", "log", "greet", "BlackHat" # Arguments: 1 # Registers: 5 # Code { ACCU = DeclareGlobals([func_greet_0x1d1b0e89ddc9, 0], <closure>) r3 = func_greet_0x1d1b0e89ddc9("BlackHat") r0 = console["log"](r3) return r0 } # func_greet_0x1d1b0e89ddc9 # Declarer: func_start_0x1d1b0e89dcd9 # Const Pool "Hello " # Arguments: 2 # Registers: 1 # Code { return ("Hello " + a0) }
  25. TRACE IT UP THE DECLARER TREE SCOPE RESOLUTION declarer function

    — Some values are propagated through Scope. — They can be set by functions higher in the declarer hierarchy and referenced by functions below them. Scope[10083][2] = new { "c" : 742 } ↓ referenced below by the child function child function r1 = (a1 - (- Scope[10083][2] [ "c" ])) ↓ resolves to r1 = (a1 - (- 742 ))
  26. IDENTIFYING THE OBFUSCATION KNOW YOUR OBFUSCATOR OBFUSCATED SOURCE · PREFLIGHT.JS

    // fragment — one of the preflight.js scripts // (may or may not share the same obfuscator) var _0x3f=['c2NyaXB0','c3Jj','YXBwZW5k','aGVhZA==']; var s=_0x1d(0x0); // _0x1d decodes → "script" var e=d[_0x1d(0x4)](s); // → createElement("script") var _0x2a='2|0|3|1'; // block-order string while(!![]){ switch(_0x2a[_0x1d(0x7)]()){ case '0': e[_0x1d(0x1)]=u; // → e.src = u continue; case '1': h[_0x1d(0x2)](e); // → head.append(e) continue; // cases '2','3' … } break; } THE OBFUSCATOR javascriptobfuscator open-source · documented · configurable Every real string is fetched through _0x1d('0xN') from an encoded array; the block order hides behind a while / switch dispatcher. github.com/javascript-obfuscator/javascript-o bfuscator
  27. IDENTIFYING THE OBFUSCATION FOUR LAYERS, STACKED 1 String obfuscation 2

    Control-flow flattening The important strings are hidden. Functions become state machines behind a dispatcher. 3 Proxy indirection 4 Operation wrappers Chained forwarding layers, not the real call. Even add / subtract / compare become functions. None surprising alone. The hard part: they’re stacked and depend on each other. ⚠️ D I S C L A I M E R For the sake of brevity, some details here are omitted or simplified. A more comprehensive description will be available soon on our blog.
  28. THE ARCHITECTURE THE DEOBFUSCATION PIPELINE DEOBFUSCATE — ORDER DICTATED BY

    DEPENDENCIES value / scope propagation → strings → unflatten → proxies & wrappers → global prop. + visibility → LLM renaming → analysis & comparison Strings must come first — every later layer depends on them. We’ll follow one small example function through each pass.
  29. THE ARCHITECTURE CHANGES IN VIEW8 DECOMPILE Brotli .jsc → decompress

    → V8 code cache → patched V8 disasm → View8 pseudocode → serialized IR NEW OUTPUT (AND INPUT): SERIALIZED IR EACH FILTER CONSUMES THE PICKLE load (serialized IR) → transform → save (serialized IR) Filters see the decompiler’s real objects & function hierarchy The deobfuscator stays a separate tool — not a tightly coupled fork.
  30. THE ARCHITECTURE CHANGES IN VIEW8 HIDING FUNCTIONS ADDING METADATA OUTPUT

    SPLITTING After a pass removes a layer, it hides its helpers — once strings are recovered, the thousands of string decoders disappear from the output. Annotate every function and every line with custom metadata — e.g. which obfuscation types were found — which makes later processing much easier. Instead of one huge file, print a selected function subtree: follow declared-by, called-by and referenced-by from a chosen root, to a chosen depth. …plus more changes and bug fixes — all in the repository changelog.
  31. PASS 1 · STRINGS STRINGS COME FIRST They unlock every

    later filter — APIs, paths, keys, commands, URLs. straight from View8 · before any pass r5 = func_n_0x34d57d25f3b9(60787, "Bz&S") r4 = global_xF[(r5 + "lt")] r5 = func_n_0x34d57d25f3b9(58819, "5C8Q") r5 = (r5 + func_n_0x34d57d25f3b9(17159, "SldQ")) return r4[(r5 + "t")] A function called with a number and a string, its result concatenated with the next — a good guess that it performs string deobfuscation.
  32. PASS 1 · STRINGS SAME FRAGMENT, DECODED The filter resolves

    every chunk back to plain text. before — straight from View8 (comments mine) r5 = func_n_0x34d57d25f3b9(60787, "Bz&S") // "defau" r4 = global_xF[(r5 + "lt")] r5 = func_n_0x34d57d25f3b9(58819, "5C8Q") // "globa" r5 = (r5 + func_n_0x34d57d25f3b9(17159, "SldQ")) // "lAgen" return r4[(r5 + "t")] after — our filter → r4 = global_xF["default"] return r4["globalAgent"]
  33. PASS 1 · STRINGS HOW THE STRINGS UNLOCK chunk the

    strings → RC4 per-chunk key → Base64 → one big array // the function that holds the chunked strings function func_KV_0x18c3e8c9a1c1() { Scope[10824][2] = new ["s8ohWR3dRx8", "ffddSSo6sW", … thousands more …] } // a call to the deobfuscating function r2 = func_xt_0x274f42c4e909( 71692 , "%]hf" ) ↳ RC4 key ↳ index (to be transformed) We know the algorithm & keys. The hard part is finding the correct chunk.
  34. PASS 1 · STRINGS WHY THE INDEX IS HARD //

    the call // what's underneath the function r2 = func_xt_0x274f42c4e909(71692, "%]hf") function func_xt_0x274f42c4e909(a0, a1) { r1 = (a0 + Scope[1][8]["K"]) return func_r_0x24543eceeb91(a1, r1) } → The index doesn’t point straight to the chunk — it goes through an arithmetic transformation. index → +/− → +/− → +/− → real chunk chained decoders, each adds or subtracts Some values aren’t local constants — they come via scope. Repeat every operation, but populate the scopes first.
  35. PASS 1 · STRINGS CRACKING THE PARENT BOUNDED SEARCH Each

    chain ends in one heavily obfuscated parent that adds a final constant. Don’t deobfuscate it — treat it as a black box and crack the value. Only one such parent per file needs cracking. 0 array size Pick a candidate → decrypt the chunk → is it plausible, printable text? ×3 Readable ≠ correct. Accept a value only if it works in at least three test cases.
  36. PASS 1 · STRINGS STRINGS AT SCALE ~130k encoded chunks

    (avg) min 19k · max 217k ~10k decoder configurations (avg) min 2.2k · max 13k DATASET github.com/hasherezade/jsceal_datasets/tree/main/logs/sessions_23_samples ~2→~1.5 min average runtime, without vs with CSV cache no cache with cache MINIMUM MEDIAN MAXIMUM 0.6 1.7 4.6 0.5 1.2 3.0
  37. PASS 1 · STRINGS THE FIRST REAL ARTIFACTS HIDDEN POWERSHELL

    EXECUTION "powershell.exe" "-NoProfile -WindowStyle Hidden" "-ExecutionPolicy Bypass -Command" "Add-MpPreference -ExclusionPath" BROWSER SECRETS, COOKIES & OAUTH "iterInstalledBrowsers" "getCookies" "getPasswords" "--user-data-dir=" "--profile-directory=" "oauth_token" "saveOAuthToken" consolidate chunks → substitute back → hide decoders + a listing of every decoded string, written as its own file CRYPTOCURRENCY BALANCES ".phantom-labs.vault." "binance" "totalBalanceInUSDT" "free_margin_usd" "customer_account_USDT_balance_available" THE ATTACKER'S EMBEDDED PUBLIC KEY -----BEGIN PUBLIC KEY----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtRdWl/ucoH +ZnVuxHrx2cTbwEY2LucyUqEJVl6trmNYaJTFX9qDYA8Z4VOaFO86M Hg0cY1mJ8NALzTqDt20C … -----END PUBLIC KEY-----
  38. PASS 2 · CONTROL FLOW UNFLATTENING THE DISPATCHER LOOP The

    most important functions are flattened: code split into numbered blocks, run by a state machine. Their intended order is stored as a string — for example "3|2|1|0|4". r6["jGBGz"] = "3|2|1|0|4" r1 = r6 r7 = r1["jGBGz"] r6 = r7["split"] r3 = r6("|") while (true) { r7 = Number(r4) r4 = (Number(r4) + 1) r6 = r3[r7] if (!r6 === "0") { [block0]; continue; } if (!r6 === "1") { [block1]; continue; } if (!r6 === "2") { [block2]; continue; } if (!r6 === "3") { [block3]; continue; } if (!r6 === "4") { [block4]; continue; } }
  39. PASS 2 · CONTROL FLOW PROPAGATING THE ORDER RECOVERING THE

    ORDER · PASS 1 func_1: The order string is unreadable until Pass 1 — then it must reach the flattened function. It is decoded upstream, in the declarer, and passed downstream via scope into place. Scope[843][9]["K"] = func_xt_0x274f42c4e909(99278, "h^gm") func_2: r6["jGBGz"] = Scope[843][9]["K"] ↓ func_1: Scope[843][9]["K"] = "3|2|1|0|4" func_2: r6["jGBGz"] = "3|2|1|0|4"
  40. PASS 2 · CONTROL FLOW RESTORING THE ORDER while (true)

    { r7 = Number(r4) r4 = (Number(r4) + 1) r6 = r3[r7] if (!r6 === "0") { [block0]; continue; } if (!r6 === "1") { [block1]; continue; } if (!r6 === "2") { [block2]; continue; } if (!r6 === "3") { [block3]; continue; } if (!r6 === "4") { [block4]; continue; } } the dispatcher loop
  41. PASS 2 · CONTROL FLOW RESTORING THE ORDER while (true)

    { r7 = Number(r4) r4 = (Number(r4) + 1) r6 = r3[r7] if ( !r6 === "0" ) { [block0]; continue; } if (!r6 === "1") { [block1]; continue; } if (!r6 === "2") { [block2]; continue; } if (!r6 === "3") { [block3]; continue; } if (!r6 === "4") { [block4]; continue; } } mind the notation CUSTOM NOTATION !r6 === "0" means r6 !== "0" View8 pseudocode, not literal JS — the ! negates the whole comparison.
  42. PASS 2 · CONTROL FLOW RESTORING THE ORDER while (true)

    { r7 = Number(r4) r4 = (Number(r4) + 1) r6 = r3[r7] if (!r6 === "0") { [block0]; continue; } if (!r6 === "1") { [block1]; continue; } if (!r6 === "2") { [block2]; continue; } if (!r6 === "3") { [block3]; continue; } if (!r6 === "4") { [block4]; continue; } } dispatcher → linear block order ORDER STRING · FROM PASS 1 "3|2|1|0|4" run blocks 3 · 2 · 1 · 0 · 4 RESTORED · LINEAR ORDER → // "3|2|1|0|4" [block3]; [block2]; [block1]; [block0]; [block4];
  43. PASS 2 · CONTROL FLOW · THE CAVEAT REBUILDING THE

    LOGIC one block, multiple conditional continues → rebuild as nested if / else FLATTENED · MULTIPLE CONTINUES REBUILT · NESTED IF / ELSE while (true) // the dispatcher loop { r15 = Number(r4) r4 = (Number(r4) + 1) r14 = r3[r15] if (!r14 === "0") { // other chunks... [...] } // Chunk 0: r15 = r2["Uugef"] if (r15(r11, r12)) { ACCU = 0 } else // added else statement { r15 = r2["PgJCU"] r17 = r2["LqFvW"] r17 = r17(r11, r12) if (r15(r17, r5)) { ACCU = 1 } else // added else statement { return -1 } } CHUNK 0 // Chunk 0: r15 = r2["Uugef"] if (r15(r11, r12)) { ACCU = 0 continue // not the end of the chunk... } r15 = r2["PgJCU"] r17 = r2["LqFvW"] r17 = r17(r11, r12) if (r15(r17, r5)) { ACCU = 1 continue // not the end of the chunk... } return -1 break →
  44. PASS 2 · CONTROL FLOW · AFTER THE FUNCTION, LINEAR

    AGAIN real func_Mz — nested dispatch peeled into a straight line FLATTENED · NESTED DISPATCH if (!r6 === "1") { if (!r6 === "2") { if (!r6 === "3") { // ... } if (r1["ykkYm"](a0, "ws")) { return global_Nb["default"]["globalAgent"] } continue } if (r1["SeAyf"](a0, r1["yHrsY"])) { return global_Tb["default"]["globalAgent"] } continue a0 = a0["split"](":")[0] } LINEAR AGAIN: //... if (r1["SeAyf"](a0, r1["yHrsY"])) { return global_Tb["default"]["globalAgent"] } if (r1["ykkYm"](a0, "ws")) { return global_Nb["default"]["globalAgent"] } //... After unflattening the function starts to reveal its shape.
  45. PASS 3 · INDIRECTION PROXIES COLLAPSING A PROXY CALL a

    proxy — calls indirectly Its only role is to forward a call. Proxies chain into rabbit holes that bury the real call tree. Filter: mark → resolve target → replace → hide. - function func_INBzN_0x16abcdb5cc69(a0, a1, a2, a3) - { - return a0(a1, a2, a3) - } @@ -22213,7 +21565,7 @@ function func_J_0x16abcdb59891() } else { ACCU = func_INBzN_0x16abcdb5cc69(func_k_0x16abcdb5a2d9, <this>, null, null) + ACCU = func_k_0x16abcdb5a2d9(<this>, null, null) }
  46. PASS 3 · INDIRECTION OPERATION WRAPPERS FOUR WRAPPERS, ONE OPERATION

    EACH even a single operator becomes a function A subtraction, a division, an in check — each hidden behind its own function, repeated under many names and layered. ~18,500 wrapper functions hidden in one run — noise, not features function func_wcmWN_0x35459f2fab89(a0, a1) { return a0 in a1 } function func_eBvDY_0x35459f2fa789(a0, a1) { return (a0 - a1) } function func_wNPyv_0x35459f2fa689(a0, a1) { return (a0 / a1) } function func_oEEDc_0x35459f2faa89(a0, a1) { return a0(a1) }
  47. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r10 = Scope[846][21][r5(Scope[846][3]["N", Scope[846][3]["M")] r13 = r5(Scope[846][3]["k", Scope[846][3]["Q") r12 = r0[(r13 + "h")] r10 = r10(r12, a0) 01 Initial decompiled code 1 / 6
  48. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r10 = Scope[846][21][func_me_0x24149a8e4421(12568, "%]hf")] //"qQNNx" r13 = func_me_0x24149a8e4421(34408, "[Jy3") //"lengt" r12 = r0[(r13 + "h")] r10 = r10(r12, a0) 02 Resolve the string-deobfuscation calls 2 / 6
  49. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r10 = Scope[846][21]["qQNNx"] // func_qQNNx_0x24149a8dfe99 r12 = r0["length"] r10 = r10(r12, a0) 03 A revealed string is a dictionary key → a function 3 / 6
  50. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r12 = r0["length"] r10 = func_qQNNx_0x24149a8dfe99(r12, a0) // -> func_PQxQy_0x24149a8dd581 04 Inline the resolved function at its call site 4 / 6
  51. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r12 = r0["length"] r10 = func_PQxQy_0x24149a8dd581(r12, a0) 05 Substitute the proxy with its real target 5 / 6
  52. PASS 3 · PEELING THE LAYERS PEELING THE LAYERS One

    line, filled with its literal meaning — layer by layer. function func_value_0x24149a8e3d19(a0) { [...] r12 = r0["length"] r10 = (r12 - a0) 06 The wrapper collapses into one atomic operation 6 / 6
  53. PASS 3 · INDIRECTION PEELING THE LAYERS before — proxied

    wrappers each pass feeds the next a recovered string ↓ reveals a field name → a dictionary entry ↓ points to a proxy → a wrapper //... if (r1["SeAyf"](a0, r1["yHrsY"])) { return global_Tb["default"]["globalAgent"] } if (r1["ykkYm"](a0, "ws")) { return global_Nb["default"]["globalAgent"] } //... ↓ resolved //... if (a0 === "https") { return global_Tb["default"]["globalAgent"] } if (a0 === "ws") { return global_Nb["default"]["globalAgent"] } //... ykkYm, SeAyf were comparison wrappers; yHrsY held "https". ↓ becomes one operation Scan for the full before & after of the running function
  54. SCAN FOR THE FULL BEFORE & AFTER OF THE RUNNING

    FUNCTION gist.github.com/hasherezade/c14985f37f8512f2c3c7b7cf8807541e
  55. MILESTONE THE CLEANED OUTPUT ✓ strings are present ✓ important

    flows linear again ONE PROBLEM REMAINS ✓ many proxies & wrappers gone Names are gibberish — and unrecoverable. ✓ decoders & helpers hidden Manual renaming is too difficult at scale. ✓ splittable into logical branches
  56. PASS 4 · COMPREHENSION LLM-ASSISTED NAMING LEAF-FIRST 1 Build a

    dependency graph from the entry point 2 Modes: basic (direct calls) vs greedy (every reference) 3 Leaf-first — fewest unresolved deps first 4 Propagate each new name into remaining bodies Red leaves named first → propagate up to start
  57. PASS 4 · COMPREHENSION BULK CSV MAPPING RENAME_MAP.CSV Bulk mode

    returns a CSV mapping each old identifier to a proposed name. The same file doubles as a resumable cache — an interrupted run picks up where it stopped. func_Fl_0x39fbdd9eda9,func_callSuper_0x39fbdd9eda9 func_unknown_0x26b1a1babb39,func_escapeChar_0x26b1a1babb39 func_unknown_0x20b39019e6d1,func_setValue_0x20b39019e6d1 func_unknown_0xc4a9532d969,func_emitResponseData_0xc4a9532d969 func_de_0x3615a23b6001,func_bitwiseAnd_0x3615a23b6001 func_unknown_0x16d39d6a9309,func_isFulfilled_0x16d39d6a9309 func__write_0x37aee3f919d9,func_write_0x37aee3f919d9 func_ee_0x95e5d24cb31,func_flushStreams_0x95e5d24cb31 func_unknown_0x32b5d60b0cf1,func_unregister_0x32b5d60b0cf1 func_unknown_0x30be66b9bf99,func_createEncoderStream_0x30be66b9bf99 func_unknown_0x1c3c9cc0ec01,func_matchesAttribute_0x1c3c9cc0ec01 func_unknown_0x31a18b0ddf19,func_parseJsonResponse_0x31a18b0ddf19 func_zu_0x3cc839129751,func_cloneBody_0x3cc839129751 func__err_0x119b51f94589,func_onError_0x119b51f94589 func_m_0xbd9c56cc7a9,func_HttpParserError_0xbd9c56cc7a9 func_Pe_0x15e5e5ffbc01,func_addSchemas_0x15e5e5ffbc01 func_unknown_0x141d9a386581,func_readPayloadLength_0x141d9a386581 func_unknown_0x3615a2389cb9,func_decryptBlock_0x3615a2389cb9
  58. THE RUNNING EXAMPLE — REAL OUTPUT getGlobalAgent() REAL DEOBFUSCATED OUTPUT

    function func_Mz_0x10000000f(a0) { r7 = a0["split"] r7 = r7(":") a0 = r7[0] if (a0 === "http") { return global_Nb["default"]["globalAgent"] } if (a0 === "https") { return global_Tb["default"]["globalAgent"] } if (a0 === "ws") { return global_Nb["default"]["globalAgent"] } if (a0 === "wss") { return global_Tb["default"]["globalAgent"] } r8 = "Invalid protocol" ACCU = Error(r8) return undefined } decoder calls → flattened → linear → named LLM'S GUESS AT THE ORIGINAL function getGlobalAgent(url) { const protocol = url.split(":")[0]; if (protocol === "http") { return http.default.globalAgent; } if (protocol === "https") { return https.default.globalAgent; } if (protocol === "ws") { return http.default.globalAgent; } if (protocol === "wss") { return https.default.globalAgent; } throw new Error("Invalid protocol"); } Left: the real decompiled, deobfuscated body — register slots and global references intact. Right: the LLM's reconstruction of how the original source likely read.
  59. PASS 4 · VALIDATING THE NAMES IS THE NAME TRUE?

    WHAT THE FILTER CAN REJECT empty too short malformed unknown no answer at all a letter or two not a valid identifier the model gave up — this protects the format, not the truth WHETHER A NAME IS TRUE HAS TO BE TESTED SEPARATELY
  60. PASS 4 · VALIDATING THE NAMES SONNET VS GPT Claude

    Sonnet 4.6 vs GPT-5.4-mini The same cleaned payload through both models. These were the models I used while conducting the research — not perfectly matched vendor tiers, but practical configurations for processing a payload this large. DATASET FOR THE EXPERIMENT github.com/hasherezade/jsceal_datasets/tree/ main/e27ae/ai_labels/session1 21,154 functions named by both models 9.3% chose the exact same name (1,971) Exact wording ≠ meaning. Different names may denote the same functionality.
  61. PASS 4 · VALIDATING THE NAMES JUDGING IN CONTEXT renamed

    pickle → View8 → SEMANTIC SENSE ACROSS 142 HIGH-CONTEXT ROOTS function subtree from a root 117 both sensible Output splitting makes portions small enough to read — or to hand back to an LLM for another round. 22 Sonnet only 3 GPT only Started at the entry point, followed the branches that set up the malware logic. Accurate & informative: Sonnet 128/142 · GPT 30 (+90 right general concept). The gap was mainly precision.
  62. PASS 4 · VALIDATING THE NAMES A NAME IS A

    HYPOTHESIS GPT bootKey Sonnet initBootKeyModule Both useful — Sonnet says what it does (inits a module, exports getBootKey). GPT findCertificate Sonnet removeCertificate Not cosmetic — the body deletes/splices an entry from the cert store. Sonnet decryptLocalStateFile Confident & wrong — it actually parses a DPAPI master-key file (Protect dir), decrypts, verifies HMAC. The model helps us find and navigate the logic. The code remains the evidence.
  63. THE PAYOFF — ALL FROM DEOBFUSCATED CODE WHAT WE RECOVERED

    one selected payload · MD5 e27ae65977287bdfb7b0e15fd3603f85
  64. WHAT WE RECOVERED CRYPTOCURRENCY THEFT, AT SCALE 50+ 30+ platform-specific

    handlers pure crypto exchanges Binance Bybit OKX Kraken + many more Names surfaced the handler cluster; targets verified through hostnames, routes, response fields, and the data each body saves. Payment & P2P platforms in there too.
  65. WHAT WE RECOVERED WALLETS & EXTENSIONS 10 wallet-extension initializers +

    a hardware-wallet path MetaMask Phantom Rabby Trust Wallet + more Evidence is unusually direct — the bodies carry extension types, extension IDs, popup paths, and patch configuration.
  66. WHAT WE RECOVERED BROWSER & CREDENTIAL THEFT 100+ cookie-related functions

    — bundled parsers plus JSCeal-specific read/extract paths, including Facebook and OAuth Windows creds — DPAPI structures + master-key files, LSA registry, derive keys, decrypt Windows Hello + passkey collection Crypto seed phrases Puppeteer + stealth plugins — replay stolen cookies, automate Google login challenges, grab Android OAuth tokens
  67. WHAT WE RECOVERED TELEGRAM · KEYLOG · SCREENSHOTS TELEGRAM KEYLOGGING

    SCREENSHOTS Find Telegram Desktop → enter tdata → read session files → hand to storage handlers Start/stop a keyboard-capture component; a keydown subscription — not a Windows hook Screen capture + window enumeration and control Buried before deobfuscation. Verifiable modules after.
  68. WHAT WE RECOVERED — THE ONE I SLOW DOWN FOR

    THE LOCAL MITM PROXY leg 1 leg 2 leg 3 proxy setup certificate generation certificate installation generation stack: generateKeyPair → createCertificate (subject + issuer) → signCertificate certutil -addstore -f root <certificate path> Installs an attacker-controlled certificate into the trusted root store — now the local proxy can intercept HTTPS.
  69. STATIC INTENT, MEET RUNTIME PROOF THE RECOVERED ENTRY POINT module

    entry — malware init, after deobfuscation Process Monitor — the DNS query it triggers ACCU = func_copyProperties_0x93e23cefe71(global_QU, r3) global_s7e = {} global_G7e = {} ACCU = func_requireCluster_0x93e23cf3479() r1 = (require("dns"))["setServers"] r3 = new [0, 0] r3[0] = "1.1.1.1" r3[1] = "8.8.8.8" ACCU = r1(r3) ACCU = func_setupWorker_0x3fa27d771a51(__filename) if (func_setupWorker_0x3fa27d771a51(__filename)) { ACCU = func_initializeApplication_0x217bb6195779() ACCU = func_exportModule_0x93e23cf1a31(global_s7e) } else { ACCU = func_initializeApplication_0x7b2a97682c9() ACCU = func_exportModule_0x93e23cf1a31(global_G7e) } r0 = ACCU return ACCU A run shows one query — the recovered code shows the whole entry point that produced it. Static code shows the intent. The runtime trace confirms behavior.
  70. BEYOND ONE SAMPLE TESTED ACROSS 23 SAMPLES 23 / 23

    FINISHED WITH ANALYZABLE OUTPUT 23 232k 1.78M 479k 4.6 min string-decoder configurations resolved final cleanup rewrites unique payloads · several months decoded string entries exported avg per sample, no cache shortest 1.4 · longest 7.6 Strings, flattened functions, capability clusters — all recovered unattended. DATASET github.com/hasherezade/jsceal_datasets/tree/main/logs/sessions_23_samples
  71. HONEST ABOUT IT LIMITS V8 disassembly is version-sensitive — a

    different Node version will need a matching disassembler Filters are pattern-based — may need tuning for other settings or output Native .node modules remain outside the pipeline LLM names are navigation aids, not proof Not perfect source recovery — but a practical path to code you can read, validate, and compare.
  72. DATED BY DESIGN A MOVING TARGET WHAT HAS MOVED WHAT

    DOESN’T MOVE Context windows and storage expanded dramatically over the year of this work — some constraints here are already less severe. The layers still come off by computation, not inference — a model with a code interpreter can decrypt the chunks, but then it is implementing its own version of the analogous pass. The model comparison was a snapshot, not a ranking. Nearly 3 million encoded chunks across 23 samples, decoded the same way every time — inspectable and verifiable. The passes give us control over the transformation and over the output. The model-specific parts are dated by design — that control is the durable part.
  73. TAKE IT WITH YOU RELEASED TOOLKIT OPEN-SOURCE › View8 integration

    › output splitting › deobfuscation filters › dependency-aware LLM renamer github.com/hasherezade/jsc_deobfuscator SCAN TO ACCESS github.com/hasherezade/ jsc_deobfuscator CREDIT View8 is a separate project by Moshe Marelus — I contributed improvements and hope it keeps growing. github.com/suleram/View8
  74. CONCLUSIONS FROM SEALED BOX TO READABLE CODE .jsc → bytecode

    → pseudocode → Compiled V8 bytecode doesn’t have to be a sealed box. — Crossed the gap with a matching disassembler, View8, dedicated passes, and LLM-assisted navigation. — Not the original JavaScript — but sufficient for practical threat analysis, and for tracking the family as it evolves. deobfuscated output
  75. BREAKING THE SEAL THANK YOU Aleksandra “Hasherezade” Doniec Check Point

    Research hasherezade.net speakerdeck.com/hshrzd tools & slides .JSC → BYTECODE → PSEUDOCODE → DEOBFUSCATED OUTPUT → ANALYZE IT LIKE CODE AGAIN
  76. APPENDIX SAMPLE SET · 23 PAYLOADS 01 02 03 04

    05 06 07 08 09 10 11 12 03f4e47b9c2283c32bb8f8f042ce6e41 0b8015cbb1ffdc6efe6a306ff5b1115f 1026743185dfa10e9ddc21b5a4c578d5 201f28b5e62e52e269757930f941c774 2fe27eb8c99626e8c02e4bfd02aca962 376ec4dbc3363fa7131367e4c6327a46 462195f7f8033df7371e899fe9bc51de 499184635d56a9827d2059256a35e530 533d0b93ea03cd5bab4eec0f0ebadd03 68ac84a8470d1f365f0bb2f37b6256d5 6e023b9b3097a2dba311cb06a91fe259 7b659fa5c93af29c4e11d8c8be437058 13 14 15 16 17 18 19 20 21 22 23 MD5 · JSCeal .jsc payloads analyzed 2026 8fb3e6acb2024601eba0ba484091ff3d af105a6d4dc10b2bfefd75e917245523 b2dad3f88b7f6870f83eb1ad852b7f7e d064dfaaef30c057b832c79996c35e89 d5b4137135cf121e3ea07b1c81fe1108 e26687982d924ffebef6fbf2d9d43350 e27ae65977287bdfb7b0e15fd3603f85 e711a90b5ece5380e1acaed56827e8d5 e81b35b76b4d97751c0724bc0c7f3b83 e8b5448b4f7b013e8c6191b20d3f8291 fd4494c555adda2eb54b88f5c9c08801