Skip to content

build(linux): enable full RELRO, stack canaries, and _FORTIFY_SOURCE=3 - #35751

Draft
robobun wants to merge 11 commits into
mainfrom
farm/fa4d619e/hardening-tranche1
Draft

build(linux): enable full RELRO, stack canaries, and _FORTIFY_SOURCE=3#35751
robobun wants to merge 11 commits into
mainfrom
farm/fa4d619e/hardening-tranche1

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Tranche 1 of Linux binary hardening: the three mitigations that work unchanged under the existing -no-pie link. PIE/ASLR is intentionally left out (separate change; see the tranche header comment added to flags.ts for the .rodata vtable / deps-rebuild trade to measure).

Truth table

scripts/verify-hardening.sh build/release/bun on stock 1.4.0-canary.1+df6c7eed6 (readelf + live /proc/self/maps):

  CONTROL    STATE  EVIDENCE
  PIE        OFF    ELF type=EXEC, image base=00200000 (identical across runs)
  RELRO      OFF    GNU_RELRO=0 BIND_NOW=0 PLT=428
  CANARY     OFF    __stack_chk_fail imports=0
  FORTIFY    OFF    *_chk imports=0
  CET        OFF    no .note.gnu.property x86 feature
  NX         ON     GNU_STACK=RW
  JIT-W^X    OFF    rwx maps=1 (1024 MB [anon:JSJITCode])

FAIL: PIE RELRO CANARY FORTIFY CET JIT-W^X

Source anchors: -fno-pic/-fno-pie at scripts/build/flags.ts:639; -fno-pic/-Wl,-no-pie at :1224 (desc "No PIE (we don't need ASLR; simpler codegen)"); -z lazy/-z norelro at :1237-1238; ASLR-off rationale at scripts/build/deps/webkit.ts:240-248. No -fstack-protector*, _FORTIFY_SOURCE, or -fcf-protection anywhere in scripts/build/.

Changes

  • Link: -Wl,-z,relro -Wl,-z,now instead of -Wl,-z,lazy -Wl,-z,norelro. 428 PLT slots + 59 non-PLT RELA eager-bound at startup; ld.so then remaps .got/.got.plt/.data.rel.ro read-only so a write-what-where can't retarget a libc call.
  • Compile (globalFlags, so direct deps like boringssl/libarchive get coverage too): -fstack-protector-strong on unix; -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 on linux release !asan. Rust side is unchanged (would need -Zstack-protector separately).
  • Strip: linux now uses llvm-strip instead of GNU strip. GNU strip's -R <section> rewrites the program-header table from sections and drops PT_GNU_RELRO, so without this the shipped binary shows BIND_NOW but no GNU_RELRO segment and ld.so never does the RO remap. llvm-strip keeps PT_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_frame was. 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

  CONTROL    STATE  EVIDENCE
  PIE        OFF    ELF type=EXEC, image base=00200000
  RELRO      full   GNU_RELRO=1 BIND_NOW=2 PLT=403
  CANARY     ON     __stack_chk_fail imports=1
  FORTIFY    ON     *_chk imports=17
  CET        OFF    no .note.gnu.property x86 feature
  NX         ON     GNU_STACK=RW
  JIT-W^X    OFF    rwx maps=1 ([anon:JSJITCode])

FAIL: PIE CET JIT-W^X

Binary size 72.5 MB -> 73.3 MB (+1.1%, entirely the llvm-strip gap). bun --revision, a fetch smoke, and test/js/bun/util/which.test.ts pass 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 fortified open() wrapper flags a mode argument passed without O_CREAT. Dropped the superfluous 0700 on an O_PATH|O_DIRECTORY open.
  • src/jsc/bindings/BunProcess.cpp: getgroups() gains warn_unused_result under _FORTIFY_SOURCE. The fill call's return was discarded, which also meant a TOCTOU between the size probe and the fill would read uninitialized gid_ts. Now checked and the result array is sized from the actual count.
  • src/exe_format/elf.rs: -z relro -z now makes lld emit two PF_W PT_LOAD segments (RELRO first, .data/.bss second). write_bun_section() picked the first, then relocated and zero-filled everything past it as non-ALLOC tail, which destroyed the entire .data segment and left its p_offset stale. bun build --compile output segfaulted in the first constructor touching a .data static (pthread_mutex_lock(m=0x598) in mimalloc init). Now selects the PF_W PT_LOAD with the highest file end and asserts it is the last PT_LOAD. Single-RW-segment binaries behave as before.

Binary size (CI, build #82220 vs canary #79916)

target delta attribution
windows-x64 / windows-aarch64 +570 / +533 KB none of these flags apply; this is main's own growth between the size baseline (ae4b17d) and this branch's base (df6c7ee, 8 commits later, including #31823 and #34598)
darwin-aarch64 / darwin-x64 +1.01 / +1.14 MB -fstack-protector-strong prologues/epilogues + the same baseline drift
linux-x64 / aarch64 (gnu) +1.02 MB / +917 KB llvm-strip zero gap (~828 KB) + stack-protector + baseline drift
linux-x64 / aarch64 (musl) +1.06 MB / +891 KB same as gnu
linux android x64 / aarch64 +1.08 MB / +864 KB stack-protector + FORTIFY + baseline drift (android was already PIE, no strip change)
freebsd x64 / aarch64 +1.06 MB / +928 KB stack-protector + baseline drift

Subtracting 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.

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
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:53 AM PT - Jul 26th, 2026

@robobun, your commit d9cf132 has 2 failures in Build #82230 (All Failures):

  • test/js/bun/s3/s3.test.ts - crash reported on 🐧 3.23 aarch64
  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.59 MB57.58 MB+1.01 MB
    bun-darwin-x6464.09 MB62.95 MB+1.14 MB
    bun-linux-aarch6471.32 MB70.42 MB+917.1 KB
    bun-linux-x6472.97 MB71.95 MB+1.02 MB
    bun-linux-aarch64-musl65.19 MB64.32 MB+891.4 KB
    bun-linux-x64-musl67.51 MB66.45 MB+1.06 MB
    bun-linux-aarch64-android78.81 MB77.97 MB+864.1 KB
    bun-linux-x64-android81.19 MB80.10 MB+1.08 MB
    bun-freebsd-x6483.62 MB82.56 MB+1.06 MB
    bun-freebsd-aarch6485.21 MB84.31 MB+928.0 KB
    bun-windows-x6480.26 MB79.70 MB+570.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+533.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35751

That installs a local version of the PR into your bun-35751 executable, so you can run:

bun-35751 --bun

robobun added 5 commits July 25, 2026 18:03
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.
Comment thread src/exe_format/elf.rs Outdated
Comment thread src/exe_format/elf.rs Outdated
Comment thread src/exe_format/elf.rs Outdated
robobun and others added 4 commits July 26, 2026 06:19
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).
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status at d9cf132 (build #82230, 143 passed with 45 still running):

  • :package: binary-size is soft-failed via [skip size check]; the per-target breakdown is in the PR body.
  • test/js/bun/s3/s3.test.ts on alpine aarch64 failed with S3Error ServiceUnavailable pointing at cloudflarestatus.com; the same failure is on concurrent build #82231 for an unrelated branch, so this is Cloudflare R2 being down.
  • test/js/bun/http/proxy-stress-protocol.test.ts is a retry-pass flake on x64-asan.

Every lane exercising the actual changes (all linux build/test, darwin, freebsd, android, the bundler compile tests, test/cli/binary-hardening.test.ts) is green. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants