TL;DR — A hello-world compiled with nixpkgs'
bun crashes with SIGSEGV before any user code runs, but only on Linux ≤ 6.6 (e.g. WSL2). The binary is fine on newer kernels. Three independent design decisions collide: bun build --compile recycles the PT_GNU_STACK program-header slot for its payload segment, patchelf (applied to bun itself by nixpkgs) moves that slot to the top of the program-header table, and the pre-6.7 kernel ELF loader only maps a segment's BSS tail when the layout is "natural" — which the resulting binary no longer is. Nothing is wrong with your code, and nothing even gets a chance to run it.1. The mystery
Take the most trivial program imaginable and compile it to a standalone binary with bun from nixpkgs:
$ nix shell nixpkgs#bun $ echo 'console.log("hello, world");' > hello.ts $ bun build --compile hello.ts --outfile hello [4ms] bundle 1 modules [418ms] compile hello $ ./hello $ echo $? 139
Exit code 139 = 128 + 11 = killed by SIGSEGV. No output, no error message, no JavaScript stack trace — the process just dies. The exact same steps with the upstream bun release work fine:
$ curl -sLO <https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64.zip> $ unzip bun-linux-x64.zip $ ./bun-linux-x64/bun build --compile hello.ts --outfile hello-upstream $ ./hello-upstream hello, world
Same bun version (1.3.13), same source file, same machine. One binary runs, one doesn't. And to make it weirder: the crashing binary runs perfectly on most other machines. This is the story of why.
2. The crime scene: a segfault inside the dynamic linker
Since nothing is printed, the crash happens early. A gdb session shows just how early:
$ gdb -batch -ex run -ex bt -ex 'info registers rdi rsi rdx' --args ./hello Program received signal SIGSEGV, Segmentation fault. 0x00007ffff7feb2a8 in memmove () from /nix/store/…-glibc-2.42-67/lib/ld-linux-x86-64.so.2 #0 0x00007ffff7feb2a8 in memmove () (ld-linux-x86-64.so.2) #1 0x00007ffff7fd4c33 in _dl_relocate_object_no_relro (ld-linux-x86-64.so.2) #2 0x00007ffff7fd6671 in _dl_relocate_object (ld-linux-x86-64.so.2) #3 0x00007ffff7fe6fa3 in dl_main (ld-linux-x86-64.so.2) #4 0x00007ffff7fe37b6 in _dl_sysdep_start (ld-linux-x86-64.so.2) #5 0x00007ffff7fe4f01 in _dl_start (ld-linux-x86-64.so.2) #6 0x00007ffff7fe3d48 in _start (ld-linux-x86-64.so.2) rdi 0x6510e40 rsi 0x7ffff7dfd4e0 rdx 0x8
The fault is inside glibc's dynamic loader, while it is still applying relocations to the main executable —
main() is light-years away. The memmove destination (rdi) is 0x6510e40, and it wants to copy 8 bytes there from inside libc.What lives at
0x6510e40? Asking the binary itself:$ readelf -rW ./hello | grep COPY 0000000006510e40 000001b000000005 R_X86_64_COPY 0000000006510e40 stderr@GLIBC_2.2.5 + 0 0000000006510e20 0000029e00000005 R_X86_64_COPY 0000000006510e20 environ@GLIBC_2.2.5 + 0 0000000006510e28 000002bc00000005 R_X86_64_COPY 0000000006510e28 stdout@GLIBC_2.2.5 + 0
These are copy relocations: because this is a non-PIE executable, references to
stderr, stdout and environ are bound to absolute addresses in the executable's own BSS, and the loader copies the initial values from libc at startup. So the loader is writing into the executable's BSS… and faulting. That means the BSS pages simply aren't mapped. The process map at the moment of the crash confirms it — there is a hole:$ gdb -batch -ex run -ex 'info proc mappings' --args ./hello Start Addr End Addr Perms File 0x00000000001ff000 0x0000000000200000 rw-p hello 0x0000000000200000 0x0000000002a07000 r--p hello 0x0000000002b20000 0x0000000006390000 r-xp hello 0x0000000006390000 0x0000000006427000 rw-p hello 0x0000000006511000 0x0000000006512000 r--p hello <- .bun payload ...
0x6427000–0x6511000 — which contains the copy-relocation targets at 0x6510e20–0x6510e40 — is missing. The kernel never mapped it. Mapping the executable's segments is the kernel's job (via execve → load_elf_binary), so the question splits in two:- Why does this binary have a shape the kernel mishandles? (sections 3–4)
- Why does the kernel mishandle it — and only on some machines? (section 5)
3. How bun build --compile works
bun build --compile produces a single-file executable by cloning bun itself: the output binary starts as a byte-for-byte copy of the bun runtime (/proc/self/exe), with your bundled JavaScript appended as a payload in a section called .bun. At startup, the runtime reads the payload directly from its own memory — the payload is mapped at exec time like any other segment, so it works even with execute-only file permissions and no file I/O.To make the kernel map that payload, bun (up to 1.3.13) adds a new
PT_LOAD program header for it. But rather than growing the program-header table, it does something cheeky — it recycles the PT_GNU_STACK slot, converting it in place (src/elf.zig):// Find PT_GNU_STACK and convert it to PT_LOAD for the new .bun data. // PT_GNU_STACK only controls stack executability; on modern kernels the // ... if (phdr.p_type == elf.PT_GNU_STACK) { // Convert to PT_LOAD ... .p_type = elf.PT_LOAD,
PT_GNU_STACK is a flag-only header (it declares whether the stack should be executable; it has no storage of its own), so repurposing its slot is a neat way to add a segment without rewriting the whole table. In an upstream bun binary, the linker placed PT_GNU_STACK at slot #7, after all the PT_LOADs — so the payload segment ends up as the last PT_LOAD in table order, with the highest virtual address. A completely natural layout:Note the left table's slot
#4: the writable PT_LOAD. Its line in the template's program headers shows a MemSiz much larger than its FileSiz:$ readelf -lW ./bun-linux-x64/bun | grep 'RW' LOAD 0x6079e10 0x0000000006390e10 0x0000000006390e10 0x095a80 0x180038 RW 0x1000
That difference —
0x180038 − 0x095a80, about 570 KB — is the runtime's BSS: zero-initialized data that occupies virtual memory but no file bytes. Keep this segment in mind; it's the victim.4. What patchelf did to bun
nixpkgs can't run the upstream bun binary as-is: its
PT_INTERP points to /lib64/ld-linux-x86-64.so.2, which doesn't exist on NixOS. So the bun package is patchelf'd like almost every binary on NixOS — the interpreter is rewritten to a /nix/store path.patchelf does much more than swap a string. When it rewrites an executable, it rebuilds the program-header table (
sortPhdrs()):// A PHDR comes before everything else. if (rdi(y.p_type) == PT_PHDR) return false; if (rdi(x.p_type) == PT_PHDR) return true; // Sort non-PHDRs by address. return rdi(x.p_paddr) < rdi(y.p_paddr);
PT_PHDR first, then everything else sorted by p_paddr. And what is PT_GNU_STACK's physical address? Zero — it covers no memory. So after patchelf, PT_GNU_STACK bubbles up from slot #7 to slot #1, right after PT_PHDR. (This is also where patchelf's other signatures come from: the new first-page PT_LOAD mapping the relocated phdrs + .interp, and the shifted segment offsets.)None of this harms bun itself — the kernel doesn't care about the order of non-
LOAD headers, and bun's own BSS-bearing PT_LOAD is still last in the table. bun runs fine.But remember:
bun build --compile clones this binary, and rewrites whatever slot holds PT_GNU_STACK into the payload PT_LOAD. With the nixpkgs template, that's now slot #1:Here is the resulting program-header table of the compiled
hello, in the exact order the kernel will walk it. Every address quoted from here on comes from this listing:$ readelf -lW ./hello | grep LOAD LOAD 0x611a000 0x0000000006511000 0x0000000006511000 0x001000 0x001000 R 0x1000 <- .bun payload: highest vaddr, listed FIRST LOAD 0x000000 0x00000000001ff000 0x00000000001ff000 0x001000 0x001000 RW 0x1000 LOAD 0x001000 0x0000000000200000 0x0000000000200000 0x2806560 0x2806560 R 0x1000 LOAD 0x2809c00 0x0000000002b20c00 0x0000000002b20c00 0x386f210 0x386f210 R E 0x1000 LOAD 0x6079e10 0x0000000006390e10 0x0000000006390e10 0x095a80 0x180038 RW 0x1000 <- .data + BSS: memsz > filesz, listed LAST
(The
vaddr/filesz/memsz of that last segment are unchanged from the template's slot #4 we saw in section 3 — cloning preserves them.)The compiled binary inherits a pathological layout:
- the payload
PT_LOAD— the segment with the highest virtual address (0x6511000) — is first among thePT_LOADs;
- the BSS-carrying writable
PT_LOAD(vaddr0x6390e10,memsz 0x180038>filesz 0x095a80) is last in the table, at a lower address.
Crucially, patchelf never touches the compiled binary — the damage is baked in at compile time, inherited from the patchelf'd template. You can verify this: patchelf-ing the upstream bun's compiled output afterwards (even twice:
--set-interpreter and --shrink-rpath) preserves the PT_LOAD order, and the result still runs. The poison is specifically in the template bun that nixpkgs ships.5. The kernel: "BSS must come last"
Why does that order matter at all? Because of how the Linux kernel's ELF loader used to compute the BSS mapping. Here is the relevant logic from
load_elf_binary() (Linux 6.6, fs/binfmt_elf.c):for each program header (in table order): if (p_type != PT_LOAD) continue; if (unlikely(elf_brk > elf_bss)) { /* There was a PT_LOAD with p_memsz > p_filesz before this one. Map anonymous pages... */ set_brk(elf_bss + load_bias, elf_brk + load_bias, bss_prot); ... } elf_map(...); /* mmap the file-backed part */ k = p_vaddr + p_filesz; if (k > elf_bss) elf_bss = k; /* global maximums! */ k = p_vaddr + p_memsz; if (k > elf_brk) elf_brk = k; ... retval = set_brk(elf_bss, elf_brk, bss_prot); /* one final mapping */
Two global running maxima —
elf_bss (highest end-of-file content) and elf_brk (highest end-of-memory) — and a one-shot anonymous mapping for whatever lies between them. A segment's BSS is materialized either when the next PT_LOAD is encountered, or once at the very end. The unspoken assumption, baked in since forever: PT_LOADs are sorted by address, and the BSS-bearing segment comes last — which is what every normal linker produces.Now walk the algorithm over our broken binary's table order. Each row's two middle columns are computed straight from the
readelf listing in section 4 (p_vaddr + p_filesz and p_vaddr + p_memsz):step | segment | vaddr + filesz | vaddr + memsz | elf_bss | elf_brk |
1 | .bun payload (R) | 0x6512000 | 0x6512000 | 0x6512000 | 0x6512000 |
2 | page 0 (RW) | 0x200000 | 0x200000 | unchanged | unchanged |
3 | .rodata (R) | 0x2a06560 | 0x2a06560 | unchanged | unchanged |
4 | .text (R E) | 0x6390e10 | 0x6390e10 | unchanged | unchanged |
5 | .data+BSS (RW) | 0x6426890 | 0x6510e48 | unchanged | unchanged |
final | set_brk(0x6512000, 0x6512000) | ㅤ | ㅤ | maps nothing | ㅤ |
The table-first payload instantly pins both maxima to
0x6512000 (above everything else). Every later segment ends below that, so neither maximum ever moves again, the elf_brk > elf_bss condition never fires, and the final set_brk maps an empty range. The .data segment's 570 KB BSS tail — home of stderr, stdout, environ — is never backed by a single page:Then glibc's
ld-linux starts up, applies the R_X86_64_COPY relocations, memmoves 8 bytes into 0x6510e40, hits the hole, and the kernel delivers SIGSEGV. Exit 139, zero output. Case closed.The 6.7 fix
This kernel behavior was rewritten for Linux 6.7 (released January 2024). Sebastian Ott and Kees Cook restructured the loader to handle
filesz < memsz per segment, inside elf_load() itself — commit 585a018627b4 "binfmt_elf: Support segments with 0 filesz and misaligned starts" and companions, merged in d82c0a37d431. Kees' own merge message describes exactly our bug class:Traditionally linkers only did this for .bss and it was always the last segment. As a result, the kernel only handled this case when it was the last segment. We've had two recent cases where linkers were trying to use these kinds of segments for other reasons, and they were in the middle of the segment list. There was no good reason for the kernel not to support this, and the refactor actually ends up making things more readable too.
On 6.7+, each segment's BSS tail is mapped while that segment is loaded, regardless of where it sits in the table. Our pathological binary runs without a hiccup.
6. Why WSL2, and why nobody noticed
So the same binary crashes on one machine and works on another, purely as a function of
uname -r:kernel | behavior |
≤ 6.6 | SIGSEGV before main() |
≥ 6.7 | works |
The machine where this bites me is WSL2, whose Microsoft-shipped kernel is still 6.6 LTS (
6.6.87.2-microsoft-standard-WSL2 at the time of writing) — and wsl --update won't save you, since 6.6 remains the shipping series. Older LTS distros (Debian 12's 6.1, Ubuntu 22.04's 5.15, RHEL 9's 5.14) are in the same boat. Anything running 6.7 or newer — most rolling distros and recent Ubuntu/Fedora — is immune, which is exactly why this could lurk undetected: the failure only appears where an old kernel meets a Nix-built bun-compiled binary.7. The complete causal chain
- Upstream bun's ELF template keeps
PT_GNU_STACKat program-header slot#7(after allPT_LOADs) — the linker's conventional order.
- nixpkgs packages bun:
patchelf --set-interpreterrebuilds the phdr table, sorting byp_paddr;PT_GNU_STACK(paddr 0) moves to slot#1.
bun build --compile(≤ 1.3.13) clones the template and converts thePT_GNU_STACKslot in place into the payloadPT_LOAD. With the nixpkgs template, the payload — highest vaddr in the binary — becomes the firstPT_LOAD, and the BSS-carrying writable segment becomes the last.
- Linux ≤ 6.6 computes one global BSS range via
max(vaddr+filesz)/max(vaddr+memsz); the table-first payload pins both maxima to the top of memory, so the writable segment's BSS is never mapped.
- At startup, glibc's
ld-linuxappliesR_X86_64_COPYrelocations (stderr,stdout,environ) into that unmapped BSS → SIGSEGV inmemmove, before a single byte of user code executes.
- Linux ≥ 6.7 maps BSS per segment → immune. Hence "works on my machine".
Each step is defensible in isolation. The crash lives in the composition.
8. Detection, mitigation, and fixes
Detecting a vulnerable binary. Check for a
PT_LOAD with MemSiz > FileSiz that is not effectively last — i.e. some other PT_LOAD ends at a higher address. Our hello shows exactly this shape (the same listing we walked through in section 4):$ readelf -lW ./hello | grep LOAD LOAD 0x611a000 0x0000000006511000 0x0000000006511000 0x001000 0x001000 R 0x1000 <- .bun payload: highest vaddr, listed FIRST LOAD 0x000000 0x00000000001ff000 0x00000000001ff000 0x001000 0x001000 RW 0x1000 LOAD 0x001000 0x0000000000200000 0x0000000000200000 0x2806560 0x2806560 R 0x1000 LOAD 0x2809c00 0x0000000002b20c00 0x0000000002b20c00 0x386f210 0x386f210 R E 0x1000 LOAD 0x6079e10 0x0000000006390e10 0x0000000006390e10 0x095a80 0x180038 RW 0x1000 <- .data + BSS: memsz > filesz, listed LAST
Run a newer kernel. Any kernel ≥ 6.7 is immune. On WSL2 that currently means a custom kernel (
kernel= in %USERPROFILE%\.wslconfig), since Microsoft still ships 6.6. (Update: it now ships ≥6.18, which can be updated via wsl --update in PowerShell)Rescue an existing binary. Reordering the program-header table — just moving the payload
PT_LOAD entry after the BSS-carrying one, touching nothing else — is enough, because only the table order is pathological:#!/usr/bin/env python3 # fix-phdr-order.py <binary> — move the highest-vaddr PT_LOAD after the # BSS-carrying PT_LOAD in the program header table (in place, table order only). import struct, sys path = sys.argv[1] with open(path, "r+b") as f: hdr = f.read(64) phoff, phentsize, phnum = (struct.unpack("<Q", hdr[32:40])[0], struct.unpack("<H", hdr[54:56])[0], struct.unpack("<H", hdr[56:58])[0]) f.seek(phoff) phdrs = [f.read(phentsize) for _ in range(phnum)] def field(p, off): return struct.unpack("<Q", p[off:off + 8])[0] loads = [(i, p) for i, p in enumerate(phdrs) if struct.unpack("<I", p[:4])[0] == 1] # highest-vaddr PT_LOAD that sits BEFORE a lower BSS-carrying PT_LOAD top = max(loads, key=lambda t: field(t[1], 16)) # p_vaddr @ +16 bss = [t for t in loads if field(t[1], 40) > field(t[1], 32)] # memsz > filesz if bss and loads.index(top) < loads.index(bss[-1]): entry = phdrs.pop(top[0]) last_load = max(i for i, p in enumerate(phdrs) if struct.unpack("<I", p[:4])[0] == 1) phdrs.insert(last_load + 1, entry) f.seek(phoff) for p in phdrs: f.write(p) print(f"{path}: reordered") else: print(f"{path}: layout already fine")
$ python3 fix-phdr-order.py hello hello: reordered $ ./hello hello, world
(Verified on the crashing PoC binary — it springs back to life.)
Fix it properly in bun. The bun team has been circling this exact minefield, and the trail is visible in their issue tracker:
- bun ≤ 1.3.13 uses the
PT_GNU_STACKslot recycling described above (oven-sh/bun#29963— WSL1's loader rejects the resulting "latePT_LOAD" shape withENOEXEC).
- bun 1.3.14 switched tactics: instead of adding a new segment, it extends the writable
PT_LOADto cover the payload — but it located that segment as "the first writable one", which on patchelf'd templates is patchelf's page-0 phdr segment, producing broken binaries on NixOS on any kernel (oven-sh/bun#31023).
- The current development branch matches the writable segment by the virtual address of
.bun, with a comment that now reads like a battle scar: "matched by vaddr, not 'first writable': patchelf'd templates have an extra writable PT_LOAD holding the relocated PHDR + .interp, #31023".
- In the meantime, nixpkgs-side options include shipping bun with the
PT_GNU_STACKslot restored to a conventional position after patchelf, or post-processing compiled outputs with a reorder step like the script above.
Appendix: reproduction environment
- Machine: NixOS 26.05 on WSL2, kernel
6.6.87.2-microsoft-standard-WSL2
- bun: nixpkgs
bun-1.3.13(patchelf'd) vs upstreambun-linux-x64.zipv1.3.13
- patchelf: 0.15.2
- All commands and outputs in this post were run verbatim on that setup; the figures are drawn from the real program headers of the PoC binary (
readelf -lW).
Acknowledgment: this investigation started from a "why does this binary instantly die on my WSL box?" mystery and turned into one of the most instructive bugs I've chased — three reasonable design decisions, composed into a SIGSEGV.