Desarmando ejecutables de Bun con unbuned
Traducido del inglés. Lee el original en English.
Por qué lo construí
Me gusta reversear cosas y ver cómo funcionan las apps por dentro. Muchas de las herramientas que uso empezaron a salir como apps compiladas de Bun. Agentes, CLIs y apps de escritorio llegan como un solo exe grande y ya no puedes abrir una carpeta y leer el JavaScript como en una app normal de Node.
Así que construí unbuned. Le das el exe y te devuelve el código separado en los archivos reales con sus nombres reales. Si la app se construyó con source maps hasta recuperas el TypeScript original. Es un solo archivo de Python sin dependencias.
Este post recorre qué hay de verdad dentro de un ejecutable de Bun y cómo unbuned lo desarma. Todos los números vienen de un binario real y un comando así que puedes comprobar todo tú mismo.
Qué es un ejecutable de Bun
Cuando corres bun build --compile Bun toma su propio runtime y le pega tu app empaquetada. El runtime es igual en todas las apps. Tu código vive en su propia sección del binario.
| Plataforma | Dónde vive la app |
|---|---|
| Windows PE | una sección llamada .bun |
| Linux ELF | una sección llamada .bun |
| macOS Mach-O | el segmento y la sección __BUN,__bun, en binarios thin o universales |
Esa sección es todo el objetivo. El resto del archivo es Bun y no interesa para esto. El primer trabajo es encontrar la sección.
Paso 1: encontrar la sección
En Windows eso significa recorrer la tabla de secciones del PE. La tabla empieza justo después de la cabecera opcional y cada entrada mide 40 bytes con el nombre en los primeros 8.
num_sections = struct.unpack('<H', data[pe_offset+6:pe_offset+8])[0]
optional_header_size = struct.unpack('<H', data[pe_offset+20:pe_offset+22])[0]
section_table_offset = pe_offset + 24 + optional_header_size
for i in range(num_sections):
section_offset = section_table_offset + (i * 40)
section_name = data[section_offset:section_offset+8].rstrip(b'\x00').decode('ascii', errors='ignore')
if section_name == '.bun':
...
ELF y Mach-O reciben el mismo trato con sus propias cabeceras. Para un Mach-O universal elige primero la rebanada de arquitectura correcta. Si nada de eso funciona cae a buscar los bytes mágicos de Bun así que un binario con metadatos de sección rotos se puede leer igual.
El archivo se mapea en memoria en vez de cargarse en RAM. Algunos de estos binarios están cerca de los 300 MB y no hay razón para leer todo eso.
Paso 2: Bun deja un mapa al final
Esta es la parte que hizo posibles los nombres reales. Bun no solo concatena tu código. Antes de escribir la sección construye una tabla de cada archivo que embebió llamada StandaloneModuleGraph y la pone al final de todo.
Los últimos 16 bytes de la sección son un marcador literal.
\n---- Bun! ----\n
Los 32 bytes justo antes de ese marcador son una estructura Offsets. Guarda el tamaño de los datos, un puntero a la tabla de archivos, el id del entry point y un conjunto de flags.
...file records...
Offsets { byte_count, modules_ptr, entry_point_id, compile_exec_argv_ptr, flags }
TRAILER "\n---- Bun! ----\n"
Así que buscas el trailer desde el final, retrocedes 32 bytes y ya sabes dónde está la tabla de archivos. Cada archivo recibe un registro de 52 bytes.
name { u32 offset, u32 length }
contents { u32 offset, u32 length }
sourcemap { u32 offset, u32 length }
bytecode { u32 offset, u32 length }
module_info { u32 offset, u32 length }
bytecode_origin_path { u32 offset, u32 length }
encoding u8 0 binary, 1 latin1, 2 utf16
loader u8 0 js, 1 jsx, 2 ts, 3 tsx, 4 json, 5 file, ...
module_format u8 0 none, 1 esm, 2 cjs
side u8 0 server, 1 client
Un detalle importa más de lo que parece. Cada uno de esos offsets es relativo al inicio de la sección más la cabecera de 8 bytes de longitud de la propia sección. Si te falta ese +8 cada puntero cae 8 bytes antes y toda la tabla se lee como basura. El parser también lo verifica. Si byte_count + 8 no cae exactamente en la cabecera rechaza la tabla en vez de leerla a medias.
Los registros se escriben en orden de carga con el entry point primero. Eso significa que el offset contents de un módulo es exactamente donde empieza su texto en el bundle y nombrar un módulo se vuelve una búsqueda en vez de una suposición. En el CLI Droid de Factory, que es un binario de 275 MB, eso da esto.
modules named from module graph: 623 of 623
Module graph: 706 embedded files
Entry point: B:/~BUN/root/droid
Graph loaders: 623 jsx, 43 binary, 40 file
El grafo no puede darte nombres que Bun ya tiró. Para cuando escribe la tabla los chunks ya están hasheados a cosas como chunk-9fxe9jf7.js así que eso es lo que obtienes. unbuned reporta lo que el compilador grabó y no finge saber más.
Paso 3: dónde termina el JavaScript
El bundle en sí es una lista de módulos compilados. Cada uno empieza con una línea de cabecera // @bun y están separados por un byte NUL. Encontrar dónde empieza es fácil. Encontrar dónde termina fue la parte difícil.
Justo después del último módulo viene la metadata de Bun y no es binaria limpia. Bun mezcla bytes imprimibles ahí así que la región después del código queda alrededor de 60% no texto mientras el código normal queda debajo del 10%. Algunos módulos también contienen bytes NUL reales así que no puedes simplemente parar en el primero.
Lo que funcionó fue escanear hacia adelante desde la última cabecera de módulo, que se sabe que es texto, y parar donde la densidad de bytes que no son texto salta. Hace una pasada gruesa y después una fina y termina en el byte exacto en vez del borde de una ventana de escaneo. También busca los marcadores que Bun deja al final del código como //# sourceMappingURL= y //# debugId=.
La comprobación de bytes corre por bytes.translate así que una sección de 150 MB se escanea dentro del código C de CPython en vez de un bucle de Python.
Acertar esto importó. Una versión anterior paraba en el borde de una ventana y en el CLI de Amp cortaba el export default b3n(); final a la mitad. Ahora ese bundle termina donde termina el código y pasa node --check.
La sorpresa UTF-16
El CLI de Amp también me enseñó que no todos los bundles de Bun son bytes planos. Toda su región de JavaScript es UTF-16 así que cada carácter ASCII va seguido de un NUL de relleno. Una búsqueda de bytes de // @bun no encuentra nada y cada ratio de texto se lee como medio binario.
Por eso unbuned ahora busca el marcador como bytes planos primero y luego como UTF-16 en ambos órdenes de bytes y arrastra la codificación que encontró por todos los pasos siguientes. Los offsets se quedan en las posiciones de byte originales así que el manifest sigue describiendo el archivo tal como está en disco. El texto solo se convierte a UTF-8 cuando se escribe.
Paso 4: recuperar el TypeScript original
Si la app se construyó con source maps Bun guarda el texto original de cada archivo dentro del registro de source map que apunta cada módulo. Es el único sitio donde sobrevive el TypeScript original porque el bundle ya está aplanado en chunks.
No es el JSON de source map que esperarías. Bun lee ese JSON una vez al compilar y escribe una tabla compacta en su lugar.
| Offset | Contenido |
|---|---|
0 | u32 cantidad de fuentes |
4 | u32 largo del mapeo VLQ crudo |
8 | un puntero {offset, length} por ruta de fuente |
| luego | un puntero {offset, length} por texto de fuente |
| luego | el blob de mapeo VLQ |
| luego | cada ruta, luego cada texto de fuente comprimidos con Zstandard |
Las rutas son relativas al archivo que las importó así que una dependencia compartida aparece como ../../node_modules/.... Quitar los .. iniciales devuelve cada archivo a donde estaba de verdad.
src/index.ts
src/exec/acpDaemonRunner.ts
packages/logging/src/tracing/enums.ts
node_modules/@opentelemetry/api/build/src/version.js
En Droid eso son 4.914 archivos originales y 42,6 MB de código: 2.487 archivos de dependencias, 1.281 archivos del src de la app y 1.146 archivos de sus paquetes de workspace.
Freebuff explica por qué esto importa. Trae un solo módulo minificado de 7,4 MB con nombres como zUA y xH y nada que separar. Su source map igual tenía el proyecto real dentro y unbuned sacó 1.241 archivos originales y 13,5 MB de TypeScript y JavaScript puestos en las carpetas reales src/, common/, packages/ y sdk/.
Lo demás que hay dentro
Bytecode
Si una app se construye con --bytecode el JavaScript que extraes es solo el respaldo. Lo que corre de verdad es bytecode de JavaScriptCore. unbuned te dice cuándo es el caso y puede escribir un blob de bytecode por módulo usando los punteros del module graph. Cada blob empieza con la misma cabecera. El largo de esa cabecera coincidió con el largo del grafo en cada módulo que probé así que sirve de chequeo de que la extracción es correcta.
No convierte bytecode de vuelta a JavaScript. El bytecode de JSC no está documentado y cambia entre versiones así que eso sería un backend de compilador entero.
Archivos embebidos
Una app de Bun no es solo JavaScript. Otros archivos se guardan en la misma sección y muchos van comprimidos como tramas Zstandard. Droid trae 80 de ellos, unos 88 MB, incluyendo rg.exe, keytar, las librerías rust_pty para cuatro plataformas y los archivos SKILL.md que lee su agente. El module graph nombra los 80 así que vuelven con sus nombres reales. Zstandard es la única pieza opcional. Sin él los archivos igual salen, solo que comprimidos.
Hacerlo legible
Bun entrega código minificado y el código minificado es una línea gigante por módulo. Prettier y los otros formateadores son paquetes de Node que construyen un árbol de sintaxis completo y eso no encaja con una herramienta de un solo archivo en Python y se pone lento en un bundle de 40 MB.
Así que unbuned tiene su propio formateador. Es un lexer de una sola pasada que solo busca las cosas que cambian el diseño como cadenas, template literals, comentarios, regex, operadores y corchetes y copia todo lo de en medio tal cual.
Tiene una regla dura. Solo agrega o quita espacios. Ningún token se reescribe y nada se reordena así que esto siempre se cumple.
assert "".join(extracted.split()) == "".join(formatted.split())
Probar el formateador también destapó una trampa en Node. node --check module.js solo analiza de verdad un archivo cuando no tiene import ni export. Un bundle de Bun siempre los tiene así que revisarlo por ruta pasa sin analizar nada. Hay que pasarlo por la entrada estándar.
node --check --input-type=module < module.js
Probar que funciona
La prueba real fue el CLI de Cline en la versión 3.0.68. Tiene 212 módulos separados y rompió el extractor más que cualquier otro objetivo. Un módulo se escondía detrás de un shebang que ningún patrón de cabecera podía ver y el formateador estaba metiendo espacios en URLs dentro de un archivo de consulta de tree-sitter que iba embebido. Los dos se arreglaron por culpa de este binario.
Después de esos arreglos los módulos vueltos a armar coinciden con el bundle original byte por byte.
modules: 212
js length: 18386663
rebuilt length: 18386663
identical: True
Después escribí el árbol de módulos al disco con los 13 assets al lado y lo ejecuté. El CLI extraído responde --version con el mismo 3.0.68 que el binario real. Probé 19 comandos de solo lectura como --version, --help y --help en cada subcomando y todos dieron salida idéntica byte por byte y el mismo código de salida. Eso es una prueba mucho mejor que «la salida parece JavaScript».
Pruébalo
git clone https://github.com/vibheksoni/unbuned.git
cd unbuned
python unbuned.py app.exe # el JavaScript unido
python unbuned.py app.exe --inspect # qué hay dentro, sin escribir nada
python unbuned.py app.exe --all # módulos, fuentes originales, assets y bytecode
--all en Droid escribe más de 6.000 archivos en unos cuatro segundos y medio.
Si quieres verlo funcionar en algo que conoces, construye una app pequeña con bun build --compile --sourcemap y pásale unbuned. Deberías recuperar tus propios archivos con sus nombres reales.
Úsalo en binarios que tienes permitido mirar. Está hecho para reversing, investigación de seguridad, análisis de malware y recuperar tu propio código cuando el proyecto se perdió pero el exe sigue ahí.
El código está en GitHub. Si algo se rompe en un binario de Bun que tienes abre un issue porque la mayoría de los arreglos de esta herramienta vinieron de un binario real rompiéndola.