Conversation
Tranche 1 of binary hardening: the mitigations that work unchanged under the existing -no-pie link. - Link with -z relro -z now instead of -z lazy -z norelro. Cost is 428 eager PLT binds at startup, unmeasurable against JSC VM init. - Compile C/C++ (bun + direct deps) with -fstack-protector-strong. - Compile linux release (non-ASAN) with -D_FORTIFY_SOURCE=3. - Strip linux binaries with llvm-strip instead of GNU strip. GNU strip's -R rewrites the program-header table from sections and drops PT_GNU_RELRO, silently undoing the relro change on the shipped binary. llvm-strip preserves it; the cost is ~0.8 MB of zero-filled RO gap where .eh_frame was (never faulted at runtime). - Add scripts/verify-hardening.sh: readelf + live /proc/<pid>/maps truth table for a linux binary, exit status = FAIL count. PIE/ASLR is intentionally not in this change; see the header comment in flags.ts for the .data.rel.ro / deps-rebuild trade to measure separately. Before (stock 1.4.0): FAIL: PIE RELRO CANARY FORTIFY CET JIT-W^X After: FAIL: PIE CET JIT-W^X
Collaborator
Author
|
Updated 4:53 AM PT - Jul 26th, 2026
❌ @robobun, your commit d9cf132 has 2 failures in
Add 🧪 To try this PR locally: bunx bun-pr 35751That installs a local version of the PR into your bun-35751 --bun |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
bionic's _FORTIFY_SOURCE fcntl.h wrapper flags a mode argument on open() without O_CREAT/O_TMPFILE as a user-defined warning, which is an error under -Werror. The mode is ignored by the kernel here anyway.
Fails on stock bun (no GNU_RELRO segment, no __stack_chk_fail import), passes on a build with the hardening flags. Guards against a future flag change or a strip tool that drops PT_GNU_RELRO.
_FORTIFY_SOURCE gives getgroups() __attribute__((warn_unused_result)), which under -Werror breaks the build on the fill call. Checking it is also the correct behaviour: if the supplementary group set changes between the size probe and the fill, the old code would read uninitialized or stale gid_t entries. Use the actual count returned by the second call and size the result array from it.
With -z relro -z now the linker emits two writable PT_LOAD segments: the RELRO segment (.data.rel.ro/.got/.got.plt) first, then the regular .data/.bss segment containing the .bun placeholder. write_bun_section() picked the first PF_W segment and treated every file byte after it as non-ALLOC tail to relocate and zero-fill. That zeroed the entire .data segment and left its PT_LOAD's p_offset pointing at garbage, so compiled executables segfaulted in the first constructor that touched a .data/.bss static (mimalloc's tlds_lock at a near-null address). Select the PF_W PT_LOAD with the highest file end instead, and assert no other PT_LOAD has file bytes past it so the tail-relocation logic remains sound. With a single RW segment (non-RELRO binaries, and cross-compiles to older targets) this is the same segment as before.
The #29963 regression guard asserted a compiled binary has exactly 3 PT_LOAD segments. With -z relro the linker emits 4 (the RELRO segment is its own PT_LOAD), which is correct and not the late-PT_LOAD shape WSL1 rejects. Compare against the source binary's PT_LOAD count instead so the test checks what #29963 actually cares about: compile does not add a segment.
Stack-protector prologues/epilogues (all unix) and the llvm-strip zero gap (linux) add ~0.5-1.0 MB per target. Windows (+0.53 MB) gets none of these flags, so that delta is main-branch drift between the size baseline (build #79916, ae4b17d) and this branch's base (df6c7ee, 8 commits later including #31823 and #34598).
Collaborator
Author
|
CI status at d9cf132 (build #82230, 143 passed with 45 still running):
Every lane exercising the actual changes (all linux build/test, darwin, freebsd, android, the bundler compile tests, |
Jarred-Sumner
pushed a commit
that referenced
this pull request
Sep 17, 2026
… in 1.3.14) (#42894) ### Problem - On Linux, `Bun.build({ compile })` in a compiled app reports success, but the new executable crashes at startup: `panic(main thread): Segmentation fault at address 0x993CFF8` in `StandaloneModuleGraph::from_executable`. So do `BUN_BE_BUN=1 ./app build --compile` and `--compile-executable-path ./app`. - Regression: 1.3.11 works, 1.3.12 and 1.3.13 refuse (`NoGnuStackSegment`), 1.3.14 and later write the corrupt executable. chenxin-yan/crust#370 works around it. - Cause: `write_bun_section` (`src/exe_format/elf.rs:363`) finds `BUN_COMPILED` through the `.bun` section header. In a compiled executable that header names the payload. The new address lands on the old payload's length. `BUN_COMPILED` keeps the old address. ### Fix - The writer recognizes a payload by its layout and writes the new one at the same address, which `BUN_COMPILED` already holds. - It refuses any other non-zero `.bun` section: `BunSectionAlreadyWritten`, exit 1. That includes the corrupt executables from 1.3.14 to 1.4.x. - Correct because the output is byte-identical to a compile into a plain bun, which the tests assert. - Verified: `test/bundler/compile-elf-segment-layout.test.ts`. 17 of the 19 new tests fail on 1.4.3-canary.1+09bb54630. 18 of them run on every host (checked on Windows x64). Self-reviewed: two findings, both fixed (Notes). ### Background - `BUN_COMPILED` is an 8-byte variable in the `.bun` section of every bun. It holds 0 in a plain bun, and the address of the payload (`[u64 length][module graph]`) in a compiled executable. - `bun build --compile` copies an executable, appends the payload past everything it maps, and points the `.bun` section header at the payload. - A compiled executable that compiles copies itself. `BUN_BE_BUN=1` makes it act as the `bun` CLI. <details><summary>Notes</summary> **Repro** (Linux x64, `1.4.3-canary.1+09bb54630`): ```sh echo 'console.log("first");' > a.ts echo 'console.log("second");' > b.ts bun build --compile a.ts --outfile ./first BUN_BE_BUN=1 ./first build --compile b.ts --outfile ./second # exit 0 ./second # panic(main thread): Segmentation fault at address 0x993CFF8, exit 139 ``` A debug build reports a READ SEGV in `memcmp`, under `StandaloneModuleGraph::from_executable` (`src/standalone_graph/StandaloneModuleGraph.rs:2908`, the trailer compare). The bytes of `./second`: | file offset | what | value | | --- | --- | --- | | `0x47894f8` | `BUN_COMPILED` | `0x4c9e000`, the address of the first payload | | `0x49d1000` | length of the first payload | `0x4c9f000`, the address of the second payload | | `0x49d2000` | length of the second payload | `0xad` | The fault address is `0x4c9e000 + 8 + 0x4c9f000 - 16 = 0x993CFF8`. **Release window.** I ran the three routes on the release binaries: 1.3.11 produces working executables (it appended the graph to the end of the file). 1.3.12 and 1.3.13 print `Error writing .bun section to ELF: error.NoGnuStackSegment` and exit 1. 1.3.14 (the writer that grows the `PT_LOAD`, #29963) and the canary exit 0 and write the corrupt executable. **How a payload is recognized.** If the first word of `.bun` is 0, `.bun` is `BUN_COMPILED` and the payload is appended as before. A linked `.bun` always holds 0, and `to_executable` never writes an empty payload. If the first word is not 0, all of these must hold: the section is `[length][bytes]`, it starts a page, it starts at the first page boundary past the linked sections, its file offset agrees with the segment, the file backs all of the segment, the segment is the highest `PT_LOAD`, and the payload ends it. Then the payload is replaced. Anything else is refused. The test has one row for each condition. Each row changes a compiled executable so that only that condition fails. I checked with a standalone build of the writer that removing any one condition makes its row fail. **Executables that are already corrupt.** What 1.3.14 to 1.4.x wrote into a compiled executable has the second payload one or more pages past the sections. The writer refuses it (checked with executables made by 1.3.14 and by the canary). It does not repair it: nothing in the file says where `BUN_COMPILED` is. **Stripped executables.** GNU `strip` and `objcopy` rewrite a compiled executable from its sections. After a compile, no section header names the bytes of `BUN_COMPILED`, so these tools write 0 there. Such an executable already runs as a plain bun, with or without this PR. Its `PT_LOAD` no longer ends on a page, so the writer refuses it. That is the right result: a replaced payload would never load. `llvm-strip` keeps the bytes, and the writer accepts its output (the output runs). **A tool compiled by an affected bun stays affected.** The writer is part of the compiled tool. A CLI that was compiled by 1.3.14 to 1.4.x, and that compiles with itself, writes corrupt executables until the CLI is compiled again with a bun that has this fix. **Other platforms.** This PR changes only the ELF writer. - Windows refuses the same command: `Error adding Bun section to PE file: SectionExists`, exit 1 (run on `1.4.3-canary.1+c8b9b5853`). - Mach-O, writer level only (`--target=bun-darwin-x64` into a hand-built template, run on Linux, not executed on macOS): a payload that is not smaller replaces the old one, and the output is byte-identical to a direct compile. A smaller payload is refused: `Error writing standalone module graph: InvalidObject`, exit 1. This PR does not add shrinking to the Mach-O writer. **Overlap with open PRs.** - #37261 (and the same hunk in #37225) also lets `write_bun_section` replace a payload. It finds `BUN_COMPILED` through a trailer that it adds to each payload, and it sets `sh_size` to the aligned size. Executables compiled by 1.3.14 to 1.4.x have no trailer, so they take its append path, which is this bug. The two changes conflict in `write_bun_section`, and each refuses or misreads the payload format of the other. If this PR lands first, #37261 needs a rebase of its `elf.rs` hunk. Which recognizer stays is a maintainer decision. - #33621, #38936 and #35751 edit the same function. The conflicts are textual. - #31787, #41360, #37639 and #38251 harden the reader against a corrupt or truncated payload. This PR does not change the reader. **Self-review.** The first version of this change accepted an executable that the old writer had already corrupted: the compile exited 0 and the output crashed. It is refused now. Its tests covered one of the layout conditions. Now there is one row for each. **Checks.** - Real bun (Linux x64, debug build): `Bun.build({ compile })` in a compiled app, `BUN_BE_BUN=1`, and `--compile-executable-path` all produce executables that run. `first` -> `second` -> `third` chains, a payload that grows, a payload that shrinks, and `--bytecode` work. `cmp` reports no difference from a direct compile (same entry point and outfile name), for the release and the debug build. - Small C programs with the same `.bun` declaration, linked by lld as non-PIE and PIE, with and without `-z relro`, for x64 and aarch64: chains are byte-identical to direct compiles, and the x64 programs run and find the new payload, a zero `.bss`, and nothing of the old payload. - The Android builds of bun (PIE, x64 and aarch64): chains are byte-identical to direct compiles. - Payload sizes around a page boundary (`page - 9`, `page - 8`, `page - 7`, `2 * page - 8`, 1 byte), every old and new pair, for 4 KB and 64 KB pages: byte-identical to direct. - Suites on the debug build: `compile-elf-segment-layout`, `bun-build-compile`, `bundler_compile`, `compile-argv`, `compile-asset-bunfs`, `standalone`, and `test/internal/source-lints/`. In `bun-build-compile.test.ts`, four bytecode tests time out on the debug build with and without this change. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/compile-elf-segment-layout.test.ts <!-- robobun:evidence:end -->
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tranche 1 of Linux binary hardening: the three mitigations that work unchanged under the existing
-no-pielink. PIE/ASLR is intentionally left out (separate change; see the tranche header comment added toflags.tsfor the.rodatavtable / deps-rebuild trade to measure).Truth table
scripts/verify-hardening.sh build/release/bunon stock1.4.0-canary.1+df6c7eed6(readelf + live/proc/self/maps):Source anchors:
-fno-pic/-fno-pieatscripts/build/flags.ts:639;-fno-pic/-Wl,-no-pieat:1224(desc "No PIE (we don't need ASLR; simpler codegen)");-z lazy/-z norelroat:1237-1238; ASLR-off rationale atscripts/build/deps/webkit.ts:240-248. No-fstack-protector*,_FORTIFY_SOURCE, or-fcf-protectionanywhere inscripts/build/.Changes
-Wl,-z,relro -Wl,-z,nowinstead of-Wl,-z,lazy -Wl,-z,norelro. 428 PLT slots + 59 non-PLT RELA eager-bound at startup;ld.sothen remaps.got/.got.plt/.data.rel.roread-only so a write-what-where can't retarget a libc call.-fstack-protector-strongon unix;-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3on linux release!asan. Rust side is unchanged (would need-Zstack-protectorseparately).llvm-stripinstead of GNU strip. GNU strip's-R <section>rewrites the program-header table from sections and dropsPT_GNU_RELRO, so without this the shipped binary showsBIND_NOWbut noGNU_RELROsegment andld.sonever does the RO remap.llvm-stripkeepsPT_GNU_RELRO; the cost is that it zeroes removed sections in place rather than compacting LOAD[0], leaving ~828 KB of zero-filled RO gap where.eh_framewas. That gap is never faulted at runtime.scripts/verify-hardening.sh <binary>: prints the table above + a PASS/FAIL set (exit status = FAIL count). The thing to run against release builds.After
Binary size 72.5 MB -> 73.3 MB (+1.1%, entirely the llvm-strip gap).
bun --revision, a fetch smoke, andtest/js/bun/util/which.test.tspass on the patched release build.no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-compile.test.ts test/cli/binary-hardening.test.ts
Code fixed along the way
Enabling these flags surfaced three latent issues; each is fixed here rather than suppressed.
packages/bun-usockets/src/bsd.c: bionic's fortifiedopen()wrapper flags a mode argument passed withoutO_CREAT. Dropped the superfluous0700on anO_PATH|O_DIRECTORYopen.src/jsc/bindings/BunProcess.cpp:getgroups()gainswarn_unused_resultunder_FORTIFY_SOURCE. The fill call's return was discarded, which also meant a TOCTOU between the size probe and the fill would read uninitializedgid_ts. Now checked and the result array is sized from the actual count.src/exe_format/elf.rs:-z relro -z nowmakes lld emit twoPF_WPT_LOADsegments (RELRO first,.data/.bsssecond).write_bun_section()picked the first, then relocated and zero-filled everything past it as non-ALLOC tail, which destroyed the entire.datasegment and left itsp_offsetstale.bun build --compileoutput segfaulted in the first constructor touching a.datastatic (pthread_mutex_lock(m=0x598)in mimalloc init). Now selects thePF_WPT_LOADwith the highest file end and asserts it is the lastPT_LOAD. Single-RW-segment binaries behave as before.Binary size (CI, build #82220 vs canary #79916)
-fstack-protector-strongprologues/epilogues + the same baseline driftSubtracting the Windows number as baseline drift puts the per-target hardening cost at roughly +350 KB to +500 KB, plus the one-off ~828 KB llvm-strip gap on linux-gnu/musl.