# Bundled-JS / Embedded-Source Binaries (Bun SEA, Node SEA, Deno compile, pkg, Electron, PyInstaller) A growing class of "binaries" are not stripped C/C++ at all — they are a runtime VM glued onto a high-level-language bundle. The bundle is **plaintext or trivially-decodable** inside the binary. If you reach for `native-binary.md` workflow on these (Ghidra → pwndbg → hex), you will waste hours decompiling a runtime you don't care about while the actual logic sits exposed three megabytes away. **This reference exists because the workflow is fundamentally different from stripped C.** --- ## When to use this reference instead of `native-binary.md` Open this if `file ./target` shows a generic Mach-O / ELF / PE BUT any of: - Size is suspiciously large (50 MB+ for a "simple CLI") - `strings -n 8 ./target | rg -i "node_modules|webpack|esbuild|bun|pkg/lib|electron|pyinstaller"` returns hits - The binary's CLI flags include things like `--inspect`, `--unhandled-rejections`, npm-style help text - Vendor docs say it's built with Bun / pkg / nexe / Deno compile / PyInstaller / Electron / Tauri (UI shell) - `head -c 4 ./target | xxd` shows a known runtime magic for an embedded archive section If yes → **stop following `native-binary.md` and follow this**. Triage and dynamic tracing are the same. Static analysis is completely different. --- ## The workflow ``` [1] Triage → identify the bundler (Bun? pkg? Deno? Electron? PyInstaller?) [2] Locate the bundle → find where the embedded source archive starts [3] Extract → dump source to disk so you can grep / read it [4] Source-level static analysis (rg + Read, NOT Ghidra) [5] Runtime verification → debug logs, --inspect, partial-evidence patterns [6] Fix / report ``` Step 3 is the unlock — once you have plaintext source on disk, the rest is normal codebase exploration. --- ## [1] Identify the bundler — 30-second fingerprint ```bash # Look for runtime-specific markers in plaintext strings strings -n 12 ./target 2>/dev/null | rg -iE 'bun|node_modules|webpack|esbuild|deno|pkg/lib|electron|pyinstaller|nexe|NODE_SEA_FUSE|NODE_SEA_BLOB|tauri|ESZIP_V2|denort' | head -20 ``` | Marker pattern | Bundler | Source format | |---|---|---| | `@oven/bun-darwin`, `bun-lockfile-format-v`, `// @bun` | **Bun SEA** (compiled via `bun build --compile`) | Plaintext JS, single big bundle | | `NODE_SEA_BLOB` + `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1` | **Node SEA** (`node --build-sea` or `--experimental-sea-config`) | Plaintext JS, or V8 code cache (when `useCodeCache: true`), or startup snapshot (when `useSnapshot: true`) — the latter two are NOT plaintext | | `pkg/lib/bootstrap.js`, `pkg/prelude`, `PAYLOAD_POSITION` | **pkg** (vercel/pkg) | Plaintext or v8 cached data | | `ESZIP_V2`, `denort`, `deno_runtime` | **Deno compile** (`deno compile`) | TS/JS in eszip archive — readable but needs `eszip` crate to walk; not pure plaintext | | `Electron`, `app.asar`, `chrome.dll`, `Squirrel.Mac` | **Electron** | `app.asar` archive (TAR-like with JSON header). Source is plaintext JS once extracted | | `PyInstaller`, `pyz`, `_MEIPASS`, `pyi-os-utils` | **PyInstaller** | Compressed `.pyc` bytecode — needs `pyinstxtractor` + `decompyle3` to recover Python source | | `nexe-`, `nexe_compile`, `:::nexe::` | **nexe** | Plaintext JS appended to node binary | | `Tauri`, `tao`, `wry`, `tauri::generate_context` | **Tauri** (Rust shell + JS UI) | **Two worlds**: JS frontend in resource section is extractable here; Rust commands / core logic are native and require [native-binary.md](native-binary.md) | If multiple match (e.g. Tauri + Bun): the outer shell is the first one (Tauri/Electron). The inner JS is the second one's format. **For Tauri specifically, expect to use both this reference (for the UI bundle) and `native-binary.md` (for the Rust binary side).** > **Source-format reality check**: only Bun SEA, pkg (when not using `--public-packages`), nexe, and Electron `.asar` are reliably plaintext. Node SEA with code-cache or snapshot, PyInstaller `.pyc`, and Deno eszip require additional tooling. Don't assume `strings` will find readable code — verify the bundler first. --- ## [2] Locate the bundle ### Bun SEA — JS is just embedded plaintext The JS source is concatenated into the binary as a giant template literal / string. No decoding needed. ```bash # Verify by searching for typical JS bundle markers strings -n 8 ./target | rg "function|var |let |const |async function" | head -5 # Find where the bundle starts (look for "use strict" or banner comment) LC_ALL=C grep -aob '"use strict"' ./target | head -5 LC_ALL=C grep -aob '#!/usr/bin/env bun' ./target | head -5 ``` ### Node SEA — `NODE_SEA_BLOB` resource/segment + activated fuse Per the [Node.js SEA docs](https://nodejs.org/api/single-executable-applications.html), a Node-built SEA contains: - A resource (PE), section in `NODE_SEA` segment (Mach-O), or note (ELF) named `NODE_SEA_BLOB` - The fuse string `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1` (with trailing `:1` indicating injected; `:0` means a copy of the node binary that has not yet had a blob injected) ```bash # Confirm it is a SEA at all LC_ALL=C grep -aob 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1' ./target | head -1 # Find the blob resource/section LC_ALL=C grep -aob 'NODE_SEA_BLOB' ./target | head # On Mach-O, inspect the segment directly otool -l ./target | grep -A4 'NODE_SEA' ``` The blob format is documented but non-trivial to walk by hand. For extraction, **use postject in reverse** (carve the section bytes) or read the blob via `node:sea` API from inside a debug build of the same binary. Plain `strings` will get you the embedded JS only when the SEA was built without `useCodeCache` and without `useSnapshot` — both of those replace plaintext with V8 cache data or startup snapshot bytes. `node --build-sea sea-config.json` and `node --experimental-sea-config sea-config.json` *generate* SEA blobs; neither inspects an existing executable. ### Deno compile — eszip archive section ```bash # Deno-compile binaries embed an eszip v2 archive LC_ALL=C grep -aob 'ESZIP_V2' ./target | head -3 # Also confirm the runtime LC_ALL=C grep -aob 'denort' ./target | head -1 ``` To extract, use the `eszip` Rust crate (or the `@deno/eszip` JS port) to parse the archive after carving it out at the offset above. There is no stable Deno CLI flag that inspects compiled-executable eszip contents as of 2026-04 — `deno info` only works on source files. ### pkg — `PAYLOAD_POSITION` marker ```bash LC_ALL=C grep -aob 'PAYLOAD_POSITION' ./target | head LC_ALL=C grep -aob 'pkg/prelude' ./target | head ``` For source extraction, use the `pkg-extract` tooling community projects or carve based on the offset reported by the `PAYLOAD_POSITION:` value. ### Electron — `app.asar` is usually a separate file Most Electron apps ship `app.asar` next to the binary, not embedded inside. Extract it with the official tool: ```bash # macOS layout ls -la /Applications/MyApp.app/Contents/Resources/app.asar npx @electron/asar extract app.asar ./extracted/ # or older: npx asar extract app.asar ./extracted/ ``` For single-file builds where the asar is embedded inside the executable, **do not pattern-match arbitrary 4-byte sequences** (the asar format starts with a Pickle-encoded uint32 header size + JSON metadata, and the same bytes appear elsewhere in any binary). Instead, use a Pickle-aware extractor that validates the JSON header before claiming a match — the `asar` npm package's programmatic `extractAll()` API does this. Carve the asar bytes by scanning for a candidate Pickle header (4-byte size + 4-byte payload size + `{"files":` prefix), validate the JSON parses, then feed the carved buffer to `extractAll()`. ### PyInstaller — use `pyinstxtractor`, NOT runtime self-extraction ```bash # Recover the embedded archive without running the binary python3 pyinstxtractor.py ./target # Output: ./target_extracted/ with .pyc files # Decompile the .pyc files back to Python source decompyle3 ./target_extracted/main.pyc # Python 3.7+ uncompyle6 ./target_extracted/main.pyc # older Python ``` If `pyinstxtractor` cannot read the archive (e.g. non-standard PyInstaller version), use the official `pyi-archive_viewer` tool that ships with PyInstaller. Avoid the "run-the-binary-and-snoop-`/tmp/_MEI*`" approach: it only catches what runs in the time window between `_MEIPASS` extraction and cleanup, and it executes potentially untrusted code. --- ## [3] Extract source to disk — DO NOT skip this **The single biggest mistake** with bundled-JS reverse engineering is trying to read the source out of `strings` output or `xxd` dumps. You will lose data. See "Gotchas" below. ### For Bun SEA / nexe / single-string-blob bundlers Read the binary as bytes, find the JS section, save to a `.js` file: ```python # extract_bundled_js.py import sys if len(sys.argv) < 2: raise SystemExit("usage: extract_bundled_js.py ") with open(sys.argv[1], 'rb') as f: data = f.read() markers = [b'// @bun', b'"use strict"', b"'use strict'", b'#!/usr/bin/env'] start = -1 for m in markers: p = data.find(m) if p != -1 and (start == -1 or p < start): start = p if start == -1: raise SystemExit( "no bundle marker found — binary may not be Bun/nexe, " "or markers were stripped. Try strings(1) for hints." ) # Heuristic end: look for a long null run AFTER start. # This is a heuristic, NOT a guarantee. Verify the tail of the output # looks like JS (closing braces, EOF) before trusting it. end = data.find(b'\x00' * 1024, start) if end == -1: end = len(data) bundle = data[start:end] print(f'Extracted {len(bundle)} bytes from offset {start} to {end}', file=sys.stderr) sys.stdout.buffer.write(bundle) ``` ```bash python3 extract_bundled_js.py ./target > extracted-bundle.js wc -c extracted-bundle.js # Sanity check the tail is JS, not random binary tail -c 200 extracted-bundle.js ``` ### For PyInstaller Use `pyinstxtractor` then `uncompyle6` / `decompyle3` on the `.pyc` files. ### For Electron .asar ```bash npx asar extract app.asar ./extracted/ # Now ./extracted/ has a normal node_modules + your source layout ``` ### For Deno compile Use the `eszip` Rust crate or the `@deno/eszip` JS port to walk the archive after carving the eszip section out at the offset reported by the `ESZIP_V2` magic search. There is no stable Deno CLI as of 2026-04 that inspects compiled-binary eszip contents directly. --- ## [4] Source-level static analysis — `rg` + `Read`, not Ghidra Once you have the source on disk, treat it as a normal codebase: ```bash # Find function definitions rg -n "^function |^const \w+ = (function|\(.*\) =>)" extracted-bundle.js | head # Find specific behavior rg -n "claude-opus-4-7|reasoning_effort|api_key" extracted-bundle.js # Resolve minified identifiers — they show up as `var XYZ="value"` rg -aoP 'var \w+="[^"]+"' extracted-bundle.js | head -50 ``` For minified bundles, use a template-literal-aware parser to extract specific functions or template strings. Example skeleton: ```python def find_template_end(data, start): """Walk a JS template literal preserving ${...} interpolation depth. Returns position of closing backtick.""" i = start while i < len(data): c = data[i:i+1] if c == b'\\': i += 2; continue if c == b'$' and data[i+1:i+2] == b'{': depth = 1; i += 2 while i < len(data) and depth > 0: cc = data[i:i+1] if cc == b'\\': i += 2; continue if cc == b'`': j = find_template_end(data, i+1) i = j + 1; continue if cc == b'{': depth += 1 elif cc == b'}': depth -= 1 elif cc in (b'"', b"'"): q = cc; i += 1 while i < len(data) and data[i:i+1] != q: if data[i:i+1] == b'\\': i += 2 else: i += 1 i += 1; continue i += 1 continue if c == b'`': return i i += 1 return -1 ``` For function-body extraction, **track the parameter list separately** before tracking body braces. The naive approach mis-counts destructuring `function f({a, b, ...c})` as the body `{` and exits early. --- ## [5] Runtime verification You usually cannot single-step JS inside a Bun-compiled binary the way you would with `node --inspect`. Workarounds: ### Bun-compiled Bun's inspector takes `--inspect[=:[/]]` on the command line. For env-var control of compiled binaries, the form is the same minus the leading `--`: ```bash # Default port (6499) auto-prefix ./target --inspect # → ws://localhost:6499/ (paste into https://debug.bun.sh) # Explicit host:port[/prefix] ./target --inspect=localhost:9229/dbg # Or via env var (if --inspect cannot be passed) BUN_INSPECT=localhost:9229/dbg ./target ``` **For HTTP request tracing without an interactive debugger** (highest-value Bun-specific runtime evidence): ```bash # Print every fetch() / node:http request as a curl command + full headers/body BUN_CONFIG_VERBOSE_FETCH=curl ./target ... # Or just print the request/response without curl-format BUN_CONFIG_VERBOSE_FETCH=true ./target ... ``` Plus generic env-var-based debug logging if the app supports it: ```bash APP_DEBUG=1 APP_LOG_LEVEL=debug APP_LOG_FILE=/tmp/trace.log ./target ``` ### Node SEA / pkg / nexe ```bash # These usually accept --inspect since they are real Node ./target --inspect # Then chrome://inspect or node --inspect-brk ``` ### Electron ```bash ./target.app/Contents/MacOS/target --inspect=9229 --remote-debugging-port=9223 # Renderer process is at chrome://inspect, main process via the inspector port ``` ### When you cannot make a real call The target's API may require credentials, network access, or paid quota you don't have. **You are not stuck** — see [methodology/partial-runtime-evidence.md](../methodology/partial-runtime-evidence.md) for the fallback patterns. --- ## ⚠️ Gotchas — read these before extracting ### G1. `strings -n N` silently drops short identifier interpolations `strings` outputs runs of printable characters of length **≥ N**. Default is 4 on most systems; many references (including older versions of `native-binary.md`) recommend `-n 8` for less noise. **With `-n 8`, short template-literal interpolations like `${x}`, `${i}`, `${R}` are silently dropped** because they are 4 chars surrounded by non-printable bytes (newlines or section padding). The result looks like: ```text expected: \n${x}\n strings: \n ← ${x} is gone, no warning ``` A consumer reading the strings output would conclude the template is empty. **Mitigation**: 1. Use `strings` only for **fingerprinting** (Phase 1 triage), never as the source of extracted text. 2. For actual extraction, **read the binary as bytes** with `python3 -c "open('./target','rb').read()"` and grep / parse from there. 3. If you must use `strings`, try `strings -n 1 -t x ./target` and post-filter — but byte-level reads are still more reliable. ### G2. Stale cached binary ≠ latest features Bundled-app installers often check a remote version and skip download if a cached binary exists. If you reverse-engineered an old version and the user reports behavior you don't see in the source, **re-run the installer** (or fetch the version manifest manually) before assuming the source is current. ```bash # Example pattern - varies by tool curl -fsSL https://example.com/install.sh | head -50 # find version-fetch URL curl -fsSL https://static.example.com/cli/cli-version.txt ./your-tool --version # Compare. If different, re-install. ``` ### G3. APFS / NTFS case-insensitivity silently overwrites files When extracting many minified function bodies (`cVR`, `CVR`, `dpr`, `DPR`, …) and saving each to its own file, **macOS APFS and Windows NTFS treat `cVR.txt` and `CVR.txt` as the same file**. The second write silently overwrites the first. **Mitigation**: prefix filenames with something case-distinguishing, e.g. `mode-cVR.txt`, `mode-CVR.txt`, or use a hash suffix. ### G4. Bun's runtime adds 30-50 MB of unrelated symbols A 70 MB Bun-compiled binary is **mostly Bun runtime** (~50 MB) plus your app (~20 MB). When fingerprinting, you will see thousands of strings like `tree-sitter-typescript`, `react-native-stylex` etc. that the user's actual app doesn't use — these are package names baked into Bun's package-resolution data. **Mitigation**: when grepping for "what does this app do?", filter out runtime noise: ```bash strings -n 8 ./target | rg -v 'node_modules|@oven/bun|package-lock|tree-sitter|ffmpeg-installer' | head ``` ### G5. Source maps usually NOT shipped Bundled apps strip source maps for production. Variable names are minified to `T`, `R`, `a`, `r`, etc. Treat the bundle like an obfuscated codebase: identify constants by tracing assignments (`var T="actual-name"`) and resolve interpolations manually. ### G6. The "extract" file is not legally redistributable If reverse-engineering proprietary software, the extracted source is the vendor's IP. Use it for understanding behavior, **never commit it to git**, never post snippets in public issues. Cleanup your `extracted-bundle.js` files in Phase 9. --- ## Silent-failure patterns specific to bundled JS | Pattern | Why it's silent | |---|---| | Bundle includes unreachable dead code from tree-shaking failures | You read code that never runs — verify with runtime trace | | `process.env.X` resolved at BUILD time, not RUNTIME | Setting the env var at runtime has no effect; the value is baked in | | `import.meta.url` in compiled binary returns `bun://...` not a real path | File-relative resolution silently breaks | | Worker threads spawn from embedded code, look for sub-bundle inside main bundle | Workers may have their own copy of dependencies | | Minified identifiers with case variants used in same module | Easy to confuse `cVR` with `CVR` when reading fast | --- ## Phase 9 cleanup specifics for bundled-JS work ```bash # Remove extracted bundles — they may contain proprietary source rm -f /tmp/extracted-bundle.js /tmp/extracted-*.js rm -rf /tmp/asar-extracted/ rm -rf /tmp/_MEI* # Remove strings dumps rm -f /tmp/*-strings.txt /tmp/*-strings-v*.txt # Remove Python helper scripts created for parsing rm -f /tmp/extract_bundled_js.py /tmp/parse_template.py # Verify the extraction directory is gone (if you used a workspace dir) ls /Users/$USER/local-workspaces/*-extracted/ 2>/dev/null # rm -rf only after journal review confirms nothing important is there ``` --- ## When to escalate back to `native-binary.md` If extraction reveals the "bundle" is actually compiled to v8 cached data (pkg with `--public-packages` or PyInstaller with bytecode-only mode), and decompilation is non-trivial, **switch back to `native-binary.md` workflow** (Ghidra against the runtime + careful tracing). Bundled-JS workflow only helps when the high-level source is recoverable as readable text.