Back to Blog
Guide12 min read

Decompile pkg, nexe & Node SEA Binaries

JSC Decompiler Team

Engineering

You have a single file. It runs like a normal command-line program — ./myapp on Linux, myapp.exe on Windows — but there is no node_modules, no package.json, no .js next to it. It is a self-contained Node.js binary. The application code is inside, and you need to read it.

This is a different problem from a loose .jsc file or an Electron ASAR. Three tools dominate the standalone-binary space: pkg (originally from Vercel), nexe, and Node's official Single Executable Application (SEA) feature. Each embeds a Node runtime plus your app in one file, but each stores the app differently. This guide covers how each one packages code, how to carve the payload back out, and why the final step — turning any embedded V8 bytecode into JavaScript — is the same decompilation problem covered in our guide on how to decompile .jsc files.

Two separate steps. Getting source out of a standalone binary is always two operations: extraction (carving the embedded filesystem or blob out of the executable) and decompilation (turning any V8 bytecode you find into readable JavaScript). JSC Decompiler handles the second step. The first is a manual carving job you do with a hex editor and a few scripts, described below.

First: Identify the Packer

Before carving anything, work out which tool built the binary. The strings inside the file give it away almost immediately:

# Look for packer-specific markers
$ strings -n 8 ./myapp | grep -iE 'pkg|nexe|sea_blob|NODE_SEA|__nexe|PAYLOAD_POSITION'

# pkg leaves references to its bootstrap and a virtual filesystem
pkg/prelude/bootstrap.js
PAYLOAD_POSITION

# nexe leaves a resource sentinel near the tail of the file
<nexe~~sentinel>

# Node SEA leaves the injected blob's fuse marker
NODE_SEA_BLOB
NODE_SEA_FUSE_...

You can also check the tail of the file. All three packers append their payload after the Node executable, so the interesting bytes are near the end, not the ELF/PE/Mach-O header at the start. A quick tail -c or a hex viewer scrolled to the bottom usually shows readable JavaScript, a bundled blob, or a table of offsets.

pkg (vercel/pkg)

pkg was the most widely used Node packer for years. Vercel deprecated the project and it is now community maintained, but the binaries it produced are everywhere and are not going away. It bundles a patched Node runtime, your application files, and any assets you declare into one executable.

How pkg Stores Code

pkg builds a virtual filesystem and appends it to the Node binary. At runtime, a bootstrap layer intercepts filesystem calls so that paths under /snapshot/ resolve into the embedded blob instead of the real disk. The location of that blob is recorded by a sentinel — historically a PAYLOAD_POSITION marker that the loader reads to find where the virtual filesystem begins.

The important detail for reverse engineering: pkg can compile your JavaScript to V8 bytecode before embedding it. When built this way, the virtual filesystem does not contain your source verbatim — it contains the same V8 serialized bytecode family as a bytenode .jsc file. pkg does this precisely so the source is not shipped in the clear. Whether a given build compiled to bytecode depends on the pkg version and build flags, so check what you actually carved out before assuming either way.

Extracting the pkg Payload

The payload is appended data, so extraction means locating the virtual filesystem and pulling files out of it. There are community scripts and tools that automate this, but the manual approach helps you understand what you are looking at:

# 1. Confirm it is a pkg binary and find the sentinel
$ strings -t d ./myapp | grep -i 'PAYLOAD_POSITION'
$ strings ./myapp | grep 'pkg/prelude/bootstrap.js'

# 2. The virtual filesystem is appended after the Node executable.
#    pkg stores a JSON-like index describing each embedded file:
#    its path, offset into the blob, and length.
#    Locate that index, then slice each entry out by offset+length.

# 3. Community tooling automates the carve. General approach:
$ npm install -g pkg-unpacker      # community extractor (names vary)
$ pkg-unpacker ./myapp ./out

# 4. Inspect what came out. Plain JS is readable immediately;
#    bytecode is not:
$ file ./out/snapshot/app/index.js
$ xxd ./out/snapshot/app/index.js | head -2

Tool names and exact sentinel strings drift between pkg versions, so treat the commands above as the shape of the process rather than a fixed recipe. What is stable is the model: a Node binary with an appended, indexed virtual filesystem. Once you have the individual files, sort them into two piles.

  • Readable JavaScript. If the build did not compile to bytecode, the entries are your bundled source (often a single Webpack/esbuild bundle). You are done — read it directly.
  • V8 bytecode. If the entries start with a V8 header rather than readable text, pkg compiled the source. Each of these is now the same decompilation problem as a .jsc file.

nexe

nexe takes a simpler approach than pkg. It bundles your application into a single JavaScript resource and appends that resource to a Node binary. At startup, nexe's bootstrap reads the appended resource and executes it as the entry point.

How nexe Stores Code

By default nexe appends the bundled application as a resource blob and marks its boundary with a sentinel string near the end of the file, along with a small trailer that records the resource length. Critically, the default nexe workflow does not compile your code to V8 bytecode — it embeds a bundled JavaScript source string. That makes nexe binaries the most recoverable of the three in the common case: the source is sitting in the file as text.

Extracting the nexe Resource

Because the resource is usually plain JavaScript, you can often recover it by carving the appended region directly:

# 1. Find the nexe sentinel and its offset
$ strings -t d ./myapp | grep -i 'nexe'
# You are looking for a marker like <nexe~~sentinel>

# 2. The bundled JS resource sits between the Node binary and
#    the trailer. Dump the tail and eyeball where readable code
#    begins:
$ tail -c 5000000 ./myapp > tail.bin
$ strings -n 40 tail.bin | head

# 3. Once you know the start offset and length, slice it out:
$ dd if=./myapp bs=1 skip=<RESOURCE_OFFSET> count=<RESOURCE_LEN> \
     of=recovered.js

# 4. Read it. If it is a minified bundle, run it through a
#    formatter or a deobfuscator to make it workable:
$ npx prettier recovered.js > recovered.pretty.js

If the binary was built with a nexe configuration that compiles to bytecode, or the author ran a separate bytecode step, the recovered resource will be V8 bytecode instead of text. Same rule as pkg: check the first bytes. Readable text means you are finished; a V8 header means it goes to the decompiler.

Offsets are not magic numbers. The exact sentinel text and trailer layout vary across nexe versions. Do not hardcode an offset from one binary and expect it to work on another. Find the sentinel per file, then compute the slice.

Node Single Executable Applications (SEA)

SEA is Node's official answer to standalone binaries, introduced as experimental in Node 20 and stabilizing since. Unlike pkg and nexe, it is a first-party feature: you build a blob from a config file and inject it into a copy of the Node binary using postject.

How SEA Stores Code

The build flow is: write a sea-config.json pointing at your main script, run node --experimental-sea-config sea-config.json to produce a blob, then use postject to inject that blob into a Node binary as a named resource. Node recognizes the injected resource through a fuse — a sentinel it scans for at startup — commonly referenced as NODE_SEA_BLOB with an associated fuse string. If the fuse is present and flipped, Node runs the embedded blob instead of looking for a script argument.

There are two flavors of SEA blob, and they matter a lot for recovery:

  • Plain-script SEA (default). The blob embeds your main script essentially as-is. By default that main script is stored as text, so once you extract the blob you can read the JavaScript directly. This is the common case and the most recoverable.
  • Snapshot SEA. With the snapshot option enabled, the blob contains a V8 startup snapshot built from your script rather than the script text. A snapshot is a serialized V8 heap — the same serialization family as bytecode, not readable source. Recovering logic from a snapshot is closer to the bytecode decompilation problem than to reading a text file.

Extracting the SEA Blob

The blob is a named resource inside the executable, so the cleanest way to pull it is with the same tool used to inject it. postject can read resources back out; you can also locate the blob by its sentinel and carve it:

# 1. Confirm this is a SEA binary
$ strings ./myapp | grep -i 'NODE_SEA'
# NODE_SEA_BLOB / NODE_SEA_FUSE markers confirm SEA

# 2. Locate the blob resource. On ELF you can list sections;
#    the injected resource shows up as its own note/section:
$ readelf -x NODE_SEA_BLOB ./myapp        # Linux, if sectioned
$ objcopy --dump-section NODE_SEA_BLOB=blob.bin ./myapp

# 3. Inspect the blob. The SEA blob has its own small header
#    describing the embedded assets (the main script, plus any
#    files added via useSnapshot / assets in sea-config.json).
$ xxd blob.bin | head

# 4a. Plain-script SEA: the main script is embedded as text.
#     It is right there in the blob once you skip the header.
$ strings -n 40 blob.bin | head

# 4b. Snapshot SEA: the blob holds a V8 snapshot, not text.
#     Treat it as serialized V8 data (see the next section).

Section names, blob header layout, and fuse strings are Node-version specific and the SEA format is still stabilizing, so verify against the exact Node version that built the binary. When section tables are stripped or the platform (PE, Mach-O) hides the resource differently, fall back to scanning for the NODE_SEA_BLOB sentinel and carving from there.

Once You Have V8 Bytecode, It's the Same Problem

All three packers converge on the same fork in the road. Either your carving produced readable JavaScript — and you are finished — or it produced V8 bytecode (a bytenode-style compiled module, or a V8 snapshot from a snapshot SEA). Once you are holding V8 bytecode, the packer is irrelevant. It is now exactly the decompilation problem covered end to end in how to decompile .jsc files.

The one thing you must get right is the V8 version. Bytecode compiled by one V8 version will not parse under another, and a pkg or SEA binary carries whatever V8 shipped with the Node version that built it. You can read the V8 build string out of the binary directly:

# Node/pkg/nexe binaries embed their version strings
$ strings ./myapp | grep -E 'node v[0-9]+|v8_version|[0-9]+\.[0-9]+\.[0-9]+-node'

# Many self-contained binaries still respond to:
$ ./myapp --version        # if the app forwards it
$ ./myapp -e "console.log(process.versions)"   # SEA/pkg may honor this

You do not strictly need to identify the version by hand. JSC Decompiler reads the V8 version out of the bytecode header itself and selects the right parser automatically, which is the whole reason the version-detection step is optional. If you would rather confirm the version before uploading, the V8 version analyzer tells you which V8 build a file was compiled with.

Feed the extracted bytecode to JSC Decompiler and you get readable JavaScript back: reconstructed control flow, string literals, function structure, and module boundaries. Comments and local variable names are gone — V8 discards those at compile time — but the logic is intact. The free tier covers the latest two Node majors; older Node and Electron builds are supported on the paid tiers.

How Recoverable Is Each One?

The effort to get source back depends almost entirely on whether the packer stored text or compiled bytecode, and how exotic the container is.

PackerDefault storageExtractionTypical outcome
nexeBundled JS source (text)Carve appended resource by sentinelSource recovered directly
Node SEA (plain)Main script as text in blobDump named resource, skip blob headerSource recovered directly
pkgVirtual FS, often V8 bytecodeCarve virtual FS, slice by indexBytecode → decompile
Node SEA (snapshot)V8 startup snapshot in blobDump resource, treat as V8 dataSerialized V8 → decompile
pkg (bytecode + obfuscation)Obfuscated JS compiled to bytecodeCarve, decompile, then deobfuscateTwo-layer job

The pattern: nexe and plain-script SEA usually hand you source with nothing more than a careful carve. pkg and snapshot SEA push you into V8 territory, which is where a decompiler earns its keep. If the author also ran an obfuscator before packing, you get a two-layer problem — decompile the bytecode first, then deobfuscate the JavaScript. Recovering the code this way, whether from a stale artifact or a binary with no matching source tree, is the same workflow as our guide on recovering lost Node.js source code.

Legal and Ethical Note

Decompiling a standalone binary is a technical operation, not automatically a legal one. Reverse engineering your own builds, analyzing malware, auditing a vendor binary you are licensed to run, or recovering source you own are ordinary and defensible activities. Pulling apart commercial software to clone it, strip licensing, or redistribute proprietary code is a different matter and may violate the software's license or local law. Know which side of that line you are on before you start, and keep to work you are authorized to do.

Summary

pkg, nexe, and Node SEA all produce the same artifact from the outside — one executable with a Node runtime and your app inside — but they store the app differently. pkg appends an indexed virtual filesystem and frequently compiles to V8 bytecode. nexe appends a bundled JavaScript resource that is usually plain text. SEA injects a blob via postject that is either your script as text or a V8 snapshot, depending on the build.

Recovery is always two steps: carve the payload out by locating the packer's sentinel, then check what you carved. Readable JavaScript means you are done. V8 bytecode or a snapshot means it is the same decompilation problem as any .jsc file — upload it to JSC Decompiler, let it detect the V8 version from the header, and get readable JavaScript back.

Try JSC Decompiler Free

Upload a .jsc file and get readable JavaScript back in seconds. No signup required for the free tier.