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

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.

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

No deployments
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