CTF思路
点击放大

<di="center">
<img src="assets/unbuned.png" alt="unbuned logo" width="200"/>
Extract Ja from Bun-compiled executables
The easiest Bun decompiler-style Ja extractor for reverse engineering, malware analysis, security research, and code recovery.
![Python]()

![Release]()
![Platforms]()
![Dependencies]()
![Stars]()
![Forks]()
Why It Exists | Quick Start | Features | Real-World Samples | How It Works
</div>
---
unbuned is a zero-dependency Python extractor for Bun-compiled executables. It locates the embedded Bun bundle, strips the binary noise around it, and writes clean Ja back to disk.
If you want to reverse engineer a Bun CLI, inspect a suspicious Bun-packed binary, recover lost app logic, or study how a production Bun app is bundled, this is the tool.
I built unbuned for the exact moment where a Bun executable lands on disk and you do not want a full re project just to see the application logic. Most of the time, the thing you actually need is the Ja bundle, fast, with as little friction as possible.
That is the whole point of this repo: one Python file, no dependencies, no install ceremony, and output you can immediately grep, diff, beautify, or audit.
If this tool saves you time, star the repo. That is what helps it reach more re, researchers, and malware analysts.
---
No dependencies required. Just Python 3.6+.
---
Examples:
Output lands here:
| Flag | Effect |
|---|---|
| -o, --output DIR | Write to DIR instead of output/<name> |
| --format | Reformat the extracted Ja for reading |
| --indent N | Spaces per indentation level with --format (default 2) |
| --wrap-at N | Column at which --format wraps long lines (default 100, 0 disables) |
| -a, --all | Dump e: modules, sources, assets, manifests and bytecode |
| -m, --modules | Also write one file per compiled module plus manifest.json |
| --sources | Also write the original sources recovered from the source maps |
| --assets | Also extract the non-Ja files the executable embeds |
| --bytecode | Also dump the bytecode regions that surround the Ja |
| --inspect | Report on the executable without writing anything |
| --json | Emit the inspection report as JSON |
| --arch CPU | Pick a slice from a uni Mach-O by CPU type |
| --chunk-size N | Boundary scan granularity (default 1000) |
| --threshold F | Non-text ratio that ends the Ja (default 0.3) |
| --skip-existing | Ne an existing output file |
| -q, --quiet | Suppress progress output |
--all walks the whole bundle and writes a tree:
On claude.exe that is 2,299 files in about three seconds.
---
.bun sections directly__BUN,__bun sections directly, thin or uni.bun sections directly, 32-bit or 64-bit, either endianness--alloutput/<name>/<name>.jsunbuned currently handles:
A Bun bundle is not one file. It is a NUL-delimited list of compiled modules,
each introduced by a // @bun header line. --modules writes them out
separately so you can grep, diff, and open them indi:
Module names come from Bun's own module graph, so they are the real embedded
paths rather than guesses (see Real File Names). When a
binary ships no readable graph, the name falls back to the module's first
import specifier. CommonJS modules get a .cjs extension, ES modules .js.
Bun does not just concatenate your source. It compiles the whole program into
a single blob and, before that blob, writes a table describing every file it
embedded: the original path, the source text, any source map, the JSC bytecode,
and the loader and format it was compiled with.
That table is a StandaloneModuleGraph, and it is the reason unbuned can put
real names on things. Bun's build step appends it to the end of the .bun
section in a fixed order: the file records, a 32 byte Offsets struct, then
the literal marker \n---- Bun! ----\n.
Finding the trailer from the end of the section locates the header, the header
points at the table, and the table is 52 bytes per file:
One detail matters more than it looks: every one of those offsets is relative
to the start of the section *plus* the section's own eight byte length header.
Missing that +8 makes every pointer land eight bytes early, which is enough
to turn a whole table into garbage.
Records are written in load order, entry point first, then its static imports,
then dynamic imports breadth first. So a module's contents offset is exactly
where that module's text begins in the bundle, and matching a module to its
name is a dict lookup rather than a guess:
The graph also answers questions the bundle text cannot. For claude.exe:
entry_point_id and the startup count come from the same header, and the
remaining flags decode to the compiler's own feature set (has_source_hashes,has_builtin_bytecode, has_bytecode_string_table, and so on). All of it is
written to manifest.json under module_graph, and every module entry gains
the path the graph recorded for it.
What the graph cannot give you is the original import graph, or a name better
than the one the bundler chose. Bun has already hashed chunks tochunk-9fxe9jf7.js by the time it writes the table. unbuned reports what the
compiler recorded and does not pretend to recover more.
Bun ships minified code, and minified code is one enormous line per module.--format runs the bundle through a built-in beautifier so the output is
readable without installing anything:
Prettier, js-beautify and Babel are Node packages, and they all build a full
syntax tree before printing anything. That is the wrong shape for a Python tool
that has to stay one file with no dependencies, and it is the wrong shape for a
40 MB bundle. unbuned instead ships a single-pass lexer: it scans for the
constructs that change layout (strings, template literals, comments, regular
expressions, operators, brackets) and copies e between them through
untouched.
That design has one hard guarantee, and it is the important one: **the formatter
only ever adds or remo.** No token is rewritten, no string is
re-quoted, no operator is re-ordered. The printed file is the extracted file
with different whitespace, so the two are identical once whitespace is
collapsed:
That guarantee is checked two ways. The test suite pins the cases that used to
break it: a quote that must not pair across code, a regular expression that
starts with =, a di that must not be scanned for a pattern, and a
template literal that has to survive a run of minified code in front of it. On
top of that, the whole 40 MB claude.exe bundle is formatted end to end and
compared, module by module, and every module is then parsed as an ECMAScript
module by node itself.
Node only honours node --check on a .js file when that file has noimport or export statement in it. A Bun bundle always does, so checking
one by its path silently succeeds without parsing anything. Pipe the module
in through standard input with --input-type=module to make node parse it
for real:
switch cases++/--, and ?. glued correctly/ di from /regex/ literals, including after if (...) heads/=/g from the source/= as an assignment operatorimport/export specifier lists on one line, empty braces inline${...} interpolations, and copies large ones // @bun module header, so one unbalanced moduleIt preserves the author's own line breaks where they exist and is deliberately
conser about the rest. On claude.exe the whole 40 MB bundle formats in
about 21 seconds, every one of the 2156 modules comes back with its content
untouched, and every module is then parsed as an ECMAScript module by node
itself.
The trade-offs are honest ones:
When a binary is built with --bytecode, the Ja you extract is the
*fallback* source. The bundle also carries a Ja bytecode payload,
and that is what actually executes at runtime. unbuned reports this:
Use --bytecode to write the bytecode out. It writes one blob per module intobytecode/, taken from the bytecode pointer and length the module graph
already records for each file:
Slicing the section into a "head" before the Ja and a "tail" after it,
which is what this used to do, produces blobs that are mostly the module graph
and the source rather than bytecode. On claude.exe that head was about
109 MB of mostly metadata. The pointer-driven path writes 2164 real blobs andbytecode/index.json, and falls back to head and tail only when a binary's
graph carries no pointers at all.
Every blob starts with the same header, and unbuned checks it:
The declared length matches the graph's recorded length for every module inclaude.exe, which is a useful integrity check on the extraction.
Each module's own string pool is read too and recorded per module. In practice
those pools are dominated by Ja runtime names such as generator,iterator and homeObject, rather than application identifiers, and a
printable run sometimes runs one byte past the name it holds. Treat them as
leads. The literals that actually name things live in a shared table the
compiler appends once per binary, which unbuned does not read yet.
What unbuned does not do is turn bytecode back into Ja. JSC bytecode
is a register machine whose opcodes, register allocation and calling con
are undocumented and change between releases, so reconstructing source from it
is a compiler backend rather than a parser. The blobs are written in a form a
disassembler can work from, but no disassembler is bundled.
A Bun executable is not only Ja. Non-Ja files are embedded as
Zstandard frames inside the same section and ne in the source at
all. --assets finds and extracts them:
claude.exe turns out to ship 138 of them, about 10 MB decompressed: skill
definitions with YAML front matter, HTML templates, and browser
bundles.
Their names come from the module graph, which records the real path for every
embedded file, so all 138 are named exactly. Two suffixes are Bun's plumbing
rather than part of the name and are removed: the trailing .zst that marks
the compressed frame, and the .txt Bun appends to keep a non-text asset on
its text pipeline. The content hash stays, because it is part of the name the
bundler recorded and dropping it would collide distinct assets that share a
stem:
Not every embedded file is compressed. droid.exe stores its native
helpers and its skills as UTF-16, so each file is sliced out at the
length the graph records, and the recorded encoding decides whether it is
written as stored or transcoded to UTF-8. A helper the bundler could not give
an extension ends its recorded name in a bare dot, which is why the payload's
own magic decides the extension:
The last one is Bun's placeholder extension for files whose original one is
not part of the bundle, which strands the real extension inside the stem. The
recorded name is still used; only the extension is recovered.
A binary with no readable graph falls back to naming each file from its own
contents, taking the name: field of front matter, an HTML <title>, a
top-le, or a banner comment. Either way the exact section offset
lands in assets.json, and the graph's own path is recorded per asset undername, so anything left as asset-NNNN can still be traced back.
The Ja refers to these files by their hashed names, so all of those
references are listed in assets.json under referenced_names.
Zstandard support is optional. When no binding is installed the frames are
still extracted, just left compressed as .zst:
---
--sources goes one le than the bundle. Bun keeps the text every
module was built from, and the path of that text, inside the source map region
each module record points at. This is the only place the original TypeScript
survives, because the bundle itself has already been flattened into chunks.
The record is not the JSON source map the bundler produced. Bun walks that
JSON once while compiling and writes a compact table instead, laid out byserialize_json_source_map_for_standalone in StandaloneModuleGraph.rs:
| Offset | Contents |
|---|---|
| 0 | u32 source count |
| 4 | u32 length of the raw |
| 8 | one {offset, length} pointer per source path |
| then | one {offset, length} pointer per source text |
| then | the blob |
| then | every path, then every source text Zstandard compressed |
Every offset is absolute within the record, which gives a usable anchor: the
first path must begin exactly where the pointers and the VLQ blob end. A
record that does not line up is rejected rather than half-read.
Paths are recorded relative to whichever file imported them, so a shared
dependency arrives as ../../node_modules/.... Dropping the leading ..
segments turns that back into the path the file actually had, and the tree
lands the way it was compiled:
For droid.exe that is 4,914 files and 42.6 MB of original source: 2,487node_modules dependencies, 1,281 files of droid's own src, and 1,146
workspace packages.
The same file often backs more than one chunk. Those are written once and
listed in sources/sources.json under duplicate, alongside the chunk each
copy came from, so nothing is silently o.
Not every build carries them. claude.exe embeds no source maps, so--sources reports nothing there, and that is a property of the binary
rather than a failure.
---
Run the executable through unbuned:
Output
The extracted Ja will be saved to output/<executable-name>/<executable-name>.js.
---
Extracted bundles from real Bun applications are committed underoutput/ so you can see exactly what unbuned pulls
out of production binaries before running it yourself.
claude.exe) in fullThe most complete run, produced by one command:
| | |
|---|---|
| Executable | 247.7 MB PE64, .bun section of 153.9 MB |
| Ja | 40.9 MB, 2,156 compiled modules |
| Module graph | 2,384 embedded files, all named |
| Entry point | B:/~BUN/root/cli |
| Assets | 138 Zstandard frames, about 10 MB decompressed |
That run writes 229 MB across 2,299 files, so it is not committed. The
browsable extraction above is the same bundle unformatted; run the command to
get the formatted output, the per-module files, and the manifests locally.
The asset names are the clearest e the module graph parse works. Bun
stores each asset as a compressed, content-hashed frame with a useless name, and
all 138 come back with the name the compiler recorded:
Module names come from the same table, so --modules writes real source paths
like 0006-cli.js instead of 0000-fs.js guesses.
droid.exe)output/droid/droid.jsThe tracked sample abo from an earlier build. Factory CLI
v0.232.0 (275 MB, installed to ~/bin/droid.exe) is worth a full dump,
and it is the binary that exercises source recovery:
That is 5,623 files and 364 MB: 623 bundled chunks named from the graph
(entry point B:/~BUN/root/droid), 80 named assets including rg.exe,keytar, the rust_pty libraries for four platforms, two sound effects
and the SKILL.md files, and 4,914 original sources. The full tree is
untracked because it is large; regenerate it with the command above.
freebuff)output/freebuff/freebuff.jsslate.exe), proprietaryoutput/slate/slate.jsoutput/slate/slate-full.7z, 3.1 MBSlate is the one target here that ships no public source. Its npm package is
marked Proprietary and carries no repository field, so the only way to read
its code is to extract it. Both compiled modules are ESM, and the module
graph names every one of the 16 embedded files, which is why the assets come
back with real names instead of content hashes:
The strings show what it is: an OpenCode fork with its own API host, carryingOPENCODE_ identifiers and api.randomlabs.ai endpoints throughout.
claude.exe), 0.3.220output/claude-sdk/claude.jsoutput/claude-sdk/claude-sdk-full.7z, 12.8 MBThis build is different from the standalone claude.exe above in a way worth
recording: it is bytecode compiled, and all three modules carry the// @bun @bun-cjs header. The Ja you get back is the
fallback source, not what actually runs. The graph names the entry point asB:/~BUN/root/src/entrypoints/cli.js and two nati, and all five
assets are recovered by name:
The bundle also carries the largest regular expressions this project has met,
a 10 KB emoji alternation table that a nai reads as di. See
Formatting The Output for why a whitespace-only
guarantee is not the same as a parse.
amp.exe), 0.0.1790956876output/amp/amp.js.bun section of 17,745,205 bytesThis is the one sample in this repository whose Ja is UTF-16, and
it is the reason unbuned detects the encoding rather than
assuming plain bytes. The bundle marker sits eight bytes into the section and
every ASCII character behind it is followed by a padding NUL, so a byte-level
search for // @bun finds nothing and every text ratio reads as half binary.
The recovered bundle is ordinary Ja once decoded: it is valid UTF-8
with no NUL padding left in it, and node --check parses it cleanly. The
module graph names the entry point B:/~BUN/root/amp-windows-x64.exe, and the
one recovered asset is the nati the CLI publishes to the page:
The graph also confirms the extraction independently. It records the entry
point at section offset 8 with a length of exactly 17,317,422 bytes, which is
precisely what unbuned emits, and the only other graph file is the keyring
addon. That is a much stronger check than "the output looks like Ja".
One thing to know if you use --modules on this build: the extractor reports
two module headers where the graph records one Ja file. The second// @bun is the first line of a script held inside a template literal
(xei = \#!/usr/bin/env bun\n...), so it starts a line without being aamp.js
module boundary. The concatenated is unaffected, because the boundary
between the two is inside a string and the file still parses. See
Limitations for why that is left alone rather than special
cased.
These samples are the proof point. unbuned is built to rip useful code out of
real shipped Bun executables, not just synthetic fixtures.
Re tools live or die on credibility. Including extracted bundles from Droid, Claude Code, Slate, the Claude Agent SDK, Freebuff, and the Amp CLI makes the value concrete:
When extraction succeeds, you get:
file on diskThat makes unbuned useful for both fast triage and deeper re sessions.
If you want to judge the extractor before running it yourself, open a sample bundle such as output/claude/claude.js and search through it. The repo is meant to prove the claim, not just make it.
---
unbuned uses a multi-stage extraction process:
1. Format Detection: detect the executable container and choose the best extraction path.
2. Section Discovery: locate .bun in PE and ELF files, or __BUN,__bun in Mach-O files.// @bun
3. Uni Slice Selection: pick an architecture from a FAT Mach-O, or the first one.
4. Magic-Byte Fallback: search for the Bun bundle marker when section metadata is not enough.
5. Ja Marker Detection: find the marker that denotes the bundle start.// @bun
6. Module Discovery: walk the NUL-delimited headers to index every module.//# debugId=
7. Boundary Detection: measure non-printable byte ratios to find the transition from code to binary data.
8. Boundary Refinement: use markers like , //# sourceMappingURL=, and })(); to stop cleanly.output/<name>/<name>.js
9. Tail Trimming: place the exact end of the last module and drop trailing bytecode.
10. Extraction: write the recovered Ja as raw bytes to .
Most Bun builds store the bundle as plain bytes, but some ship the whole
Ja region as UTF-16, where every ASCII character is followed by a
padding NUL. amp.exe (the Amp CLI) is one of these. Such a bundle defeats a// @bun
plain byte search for the marker, and every byte-level text ratio
reads as roughly half binary.
unbuned detects this without being told. It looks for the marker as plainbytes.translate
bytes first and then as UTF-16 in either byte order, and threads the detected
encoding through the boundary scan, module splitting and header parsing.
Classification switches from over raw bytes tostr.translate o code units, because the low byte of an accented
Latin letter or a Cyrillic character is itself an unprintable value, and
sampling low bytes alone would stop the scan early inside ordinary source.
Offsets stay in the original byte space throughout, so module offsets, the
module graph and the manifest still describe the file as it sits on disk. The
text is decoded to UTF-8 only where it is actually consumed: filenames, asset
paths, and the files written out. manifest.json records what was found underja.encoding.
This holds for every container format, not just PE. Mach-O and uni
Mach-O terminate their bundle with a NUL instead of padding it out to the end
of the section, and in UTF-16 that padding NUL follows every ASCII character,
so the terminator has to be located on the code unit grid or the scan stops one
byte in. The test suite covers PE, Mach-O, uni Mach-O and ELF in both
byte orders.
The extractor uses a practical heuristic tuned for real Bun payloads:
))), so a 150 MB section is o code unitsThe structured data that follows the Ja is not always dense binary. Bun
interlea bytes into its metadata, so the trailing region can sit
around 60% non-text while ordinary source sits under 10%. A backwards window
scan can therefore only ever land within one window of the true boundary, and
some compiled modules legitimately contain NUL bytes, so a NUL terminator is
not a reliable stop either.
unbuned instead scans *forwards* from the last module header, which is known
to be text, and stops where the density rises.
Clamping that result to the statistical cap is not good enough on its own. The
cap lands on a chunk boundary, and the chunk that trips the threshold usually
*begins* with real source and ends with the blob, so the clamp throws that
source away. unbuned therefore distinguishes two cases. If a binary window
was located before the cap, the boundary is settled there and refined to the
exact code unit. If none was, the cap is sitting inside source, and the
boundary is the first non-text code unit after it. Either way the result is a
code-unit boundary, never a window boundary, and a trailing sweep removes any
blob that rode along with it.
The practical benefit is that the output ends where the source ends. On
amp.exe this recovered the final export default b3n(); that a cap-alignednode --check
boundary cut mid-token, and the bundle now parses under . The
practical cost is bounded: when a bundle genuinely ends on a long run of
control characters, the boundary can land a few bytes early.
---
---
---
stays hashed and the original import graph is not
recovered
Original sources only exist when the build embedded source maps, and
what comes back is the pre-bundle text, not the original repository
When a binary ships no readable module graph, filenames fall back to being
inferred from content
Module headers are found by anchoring to line starts, so a // @bun line that
sits inside a template literal can be mistaken for a boundary. This never
corrupts the concatenated .js, which stays a single correct stream, but it
can produce a bad slice in --modules. Telling the two apart properly needs
Ja string tracking, so unbuned does not guess: on builds like
the Amp CLI, cross-check --modules against the module graph, which is
authoritative
--format reformats layout only; it ne, reorders, or rewrites code
Very long single tokens, and huge or deeply nested template interpolations,
stay on one line
JSC bytecode is dumped raw, not decompiled
The end of the Ja region is found statistically and then snapped to
a code-unit boundary, so it can still land a few bytes early when a bundle
ends on a run of control characters
Some obfuscated code will still require manual analysis ---
Contributing
Contributions are welcome, especially around:
more fixture co
JSC bytecode analysis
better boundary heuristics
more formatter layout rules
additional real-world Bun samples Run the tests with:
Bug reports, PRs, and extraction results are all useful.
---
Ethical Use
This tool is meant for legitimate reverse engineering, malware analysis, security research, learning, and recovery work. Respect licenses, contracts, and local law. Use it responsibly.
---
Related Projects
asar - Extract Electron app.asar` archives---
<di="center">
Built for people who would rather read the Bun app than speculate about it
Star this repo if you find it useful.
</div>
---
MIT License - see LICENSE for details.
---
If this repo helps you crack open a Bun executable faster, that is exactly what it was built for.
Currently open to work. If you're looking for someone with security research, reverse engineering, malware analysis, or full-stack de experience, hit me up.
---
_Remember: with great power comes great responsibility. Use this tool ethically and legally._
开发语言:JavaScript
Star 数:56
该项目属于CTF思路方向的开源资源,代码结构清晰,适合安全研究人员学习参考。项目围绕Bun decompiler-style JavaScript extractor for Bun-compiled executables. Zero-dependency Python tool for reverse engineering, malware analysis, and code recovery.展开,提供了可运行的实现,对理解相关安全机制、工具原理与攻防技术具有较高的研究价值。
从技术实现角度看,此类项目的核心在于对底层原理的深入理解与工程化落地。开发者在研究过程中可以结合官方文档与社区讨论,梳理项目的初始化流程、核心模块职责与数据流转,逐步掌握其中的技术细节与设计思路。
1. 下载源码压缩包,解压到本地环境。
2. 根据项目类型准备对应的运行环境与依赖。
3. 按照 README 中的步骤完成配置与部署。
4. 在隔离的测试环境中运行验证,避免影响生产系统。
本资源来源于开源社区,仅供安全研究、技术学习与合法授权范围内的攻防演练使用,请勿用于任何非法或侵害他人权益的用途。使用本项目产生的一切后果由使用者自行承担。
| 资源标题 | unbuned - Bun decompiler-style JavaScript extractor for Bun- |
| 所属分类 | CTF思路 |
| 发布作者 | admin |
| 发布时间 | 2026-10-06 00:05:27 |
| 下载权限 | 免费下载 |
| 附件数量 | 2 个下载链接 |
| 浏览次数 | 123 |
| 讨论数量 | 0 |
| 主题状态 | 正常讨论中 |