build(linux): enable full RELRO, stack canaries, and _FORTIFY_SOURCE=3 - #35751
Draft
robobun wants to merge 11 commits into
Draft
build(linux): enable full RELRO, stack canaries, and _FORTIFY_SOURCE=3#35751robobun wants to merge 11 commits into
robobun wants to merge 11 commits into
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, |
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.