diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 990ce4d981b2..a3950ee560fd 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1306,7 +1306,7 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, memcpy(dirname_buf, path, dirname_len); dirname_buf[dirname_len] = 0; - int socket_dir_fd = open(dirname_buf, O_CLOEXEC | O_PATH | O_DIRECTORY, 0700); + int socket_dir_fd = open(dirname_buf, O_CLOEXEC | O_PATH | O_DIRECTORY); if (socket_dir_fd == -1) { errno = ENAMETOOLONG; return LIBUS_SOCKET_ERROR; diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 62a91376f6e7..6ac5f71e084c 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -439,8 +439,9 @@ export interface Toolchain { rustHostTriple: string | undefined; strip: string; /** - * llvm-strip. On Linux hosts GNU strip is the default (`strip` above) but - * can't read Mach-O, so darwin cross-compiles swap this in as `cfg.strip`. + * llvm-strip. Preferred as `cfg.strip` on Linux (GNU strip's -R drops + * PT_GNU_RELRO) and for darwin cross-compiles (GNU strip can't read + * Mach-O). Optional: GNU strip remains the fallback. */ llvmStrip: string | undefined; dsymutil: string | undefined; @@ -1227,16 +1228,21 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con rustLld: toolchain.rustLld, rustLlvmVersion: toolchain.rustLlvmVersion, rustSysroot: toolchain.rustSysroot, - // Cross strips: linux-gnu uses -strip (GNU, handles -R .eh_frame - // fully; host strip rejects foreign-arch ELF); other cross targets use - // llvm-strip. + // Linux strips with llvm-strip whenever it's available. GNU strip's + // `-R
` (stripFlags on release-gnu) rewrites program headers + // from the section table and drops PT_GNU_RELRO in the process, silently + // undoing `-z relro -z now` on the shipped binary. llvm-strip keeps + // PT_GNU_RELRO; it leaves the removed sections' file extent as a zero + // gap in LOAD[0] instead of compacting it (~+0.8 MB on-disk, no RSS + // cost since nothing reads that range). Non-linux targets already use + // llvm-strip. GNU strip stays the last-resort fallback. strip: ld64StripSwap?.strip ?? - (crossTarget !== undefined - ? linux && abi === "gnu" && existsSync(`/usr/bin/${crossTarget}-strip`) - ? `/usr/bin/${crossTarget}-strip` - : (toolchain.llvmStrip ?? toolchain.strip) - : toolchain.strip), + (linux + ? (toolchain.llvmStrip ?? toolchain.strip) + : crossTarget !== undefined + ? (toolchain.llvmStrip ?? toolchain.strip) + : toolchain.strip), dsymutil: toolchain.dsymutil, bun: toolchain.bun, jsRuntime: toolchain.jsRuntime, diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 11077235f547..0cb930933297 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -435,12 +435,34 @@ export const globalFlags: Flag[] = [ desc: "Assume no symbol interposition (enables more inlining across TUs)", }, - // ─── Hardening (assertions builds) ─── + // ─── Hardening ─── + // Tranche 1: mitigations that work unchanged under the existing -no-pie + // link. PIE/ASLR is intentionally NOT here: flipping it moves JSC/WTF + // const-pointer tables from .rodata into .data.rel.ro (see + // deps/webkit.ts for the ~550 KB RW-vtable trade) and forces every + // direct dep to rebuild -fPIC. That is a separate change with its own + // size/RSS/rebuild cost to measure. { flag: "-fno-delete-null-pointer-checks", when: c => c.assertions, desc: "Don't optimize out null checks (hardening)", }, + { + flag: "-fstack-protector-strong", + when: c => c.unix, + desc: "Stack canaries on functions with local arrays / address-taken locals (C/C++ only; Rust side needs -Zstack-protector separately)", + }, + { + // -U first: distro toolchains (and glibc's own features.h under -O) may + // predefine it, and redefining at a different level is a hard error. + // Level 3 adds _FORTIFY_SOURCE dynamic __builtin_dynamic_object_size + // checks (glibc ≥ 2.34); older glibc transparently falls back to 2. + // Release-only because it requires -O1+; !asan because ASAN already + // interposes the same libc entry points and the two fight. + flag: ["-U_FORTIFY_SOURCE", "-D_FORTIFY_SOURCE=3"], + when: c => c.linux && c.release && !c.asan, + desc: "glibc fortified libc wrappers (compile-time + runtime bounds on memcpy/sprintf/...)", + }, // ─── Diagnostics ─── { @@ -1234,8 +1256,12 @@ export const linkerFlags: Flag[] = [ flag: [ "-Wl,--as-needed", "-Wl,-z,stack-size=12800000", - "-Wl,-z,lazy", - "-Wl,-z,norelro", + // Full RELRO. We link ~428 PLT slots; eager-binding them at startup + // is unmeasurable against a JSC VM init, and it lets ld.so remap + // .got/.got.plt/.data.rel.ro read-only so a write-what-where can't + // retarget a libc call. Replaces the historical -z lazy / -z norelro. + "-Wl,-z,relro", + "-Wl,-z,now", // (no --pack-dyn-relocs=relr: DT_RELR needs glibc ≥ 2.36 to load, // and we wrap symbols for portability down to 2.17. With -no-pie // there are <500 R_*_RELATIVE entries anyway — not worth the compat @@ -1254,7 +1280,7 @@ export const linkerFlags: Flag[] = [ "-Wl,--build-id=sha1", ], when: c => c.linux, - desc: "Linux linker tuning: lazy binding, large stack, fast gdb loading", + desc: "Linux linker tuning: full RELRO, large stack, fast gdb loading", }, { flag: "-Wl,--gc-sections", @@ -1485,17 +1511,19 @@ export const stripFlags: Flag[] = [ // musl: no eh_frame handling differences, but CMake gates on NOT musl so we do too. // // Gated on release to match -Wl,--no-eh-frame-hdr in linkerFlags above - // (both fire on `c.linux && c.abi === "gnu" && c.release`). GNU strip - // does not rewrite the program header table, so the PT_GNU_EH_FRAME - // phdr must already be absent at link time — which the matching - // --no-eh-frame-hdr above guarantees. Nothing unwinds at runtime - // (`panic = "abort"`, `-fno-exceptions`); release backtraces use frame - // pointers. Saves ~962 KB of R-segment (.eh_frame 806 KB + - // .eh_frame_hdr 142 KB + .gcc_except_table 13 KB) that otherwise gets - // dragged into RSS via 64 KB fault-around on adjacent .rodata reads. + // (both fire on `c.linux && c.abi === "gnu" && c.release`). Nothing + // unwinds at runtime (`panic = "abort"`, `-fno-exceptions`); release + // backtraces use frame pointers. + // + // Runs under llvm-strip (see config.ts `strip:`), which zeroes these + // sections in place rather than compacting LOAD[0], so the on-disk win + // is smaller than GNU strip's (~0.8 MB of zero gap left behind). We + // accept that because GNU strip's -R rewrites the program-header table + // from sections and drops PT_GNU_RELRO, undoing full RELRO on the + // shipped binary. The zero gap is never faulted at runtime. flag: ["-R", ".eh_frame", "-R", ".eh_frame_hdr", "-R", ".gcc_except_table"], when: c => c.linux && c.abi === "gnu" && c.release, - desc: "Remove unwind sections (GNU strip required — llvm-strip leaves [LOAD #2 [R]])", + desc: "Remove unwind sections (llvm-strip; GNU strip's -R drops PT_GNU_RELRO)", }, ]; diff --git a/scripts/verify-hardening.sh b/scripts/verify-hardening.sh new file mode 100755 index 000000000000..3d1336666d2c --- /dev/null +++ b/scripts/verify-hardening.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Usage: scripts/verify-hardening.sh +# +# Prints a hardening truth table for a Linux bun binary using readelf and a +# live /proc//maps probe, then a PASS/FAIL line per control. Exit status +# is the FAIL count. +# +# Controls checked: +# PIE ET_DYN (image participates in ASLR) +# RELRO GNU_RELRO segment present + DT_BIND_NOW (full RELRO) +# CANARY __stack_chk_fail imported +# FORTIFY at least one __*_chk@ libc import +# CET x86 IBT/SHSTK feature bits in .note.gnu.property +# NX GNU_STACK is RW, not RWE +# JIT-W^X no rwx mapping in a live process +# +# PIE/CET/JIT-W^X are expected FAIL today (tranche 2+). RELRO/CANARY/FORTIFY +# are the tranche-1 targets this patch flips to PASS. + +set -uo pipefail + +BIN=${1:?"usage: $0 "} +[[ -r "$BIN" ]] || { echo "error: cannot read $BIN" >&2; exit 64; } + +readelf=${READELF:-readelf} +hdr=$("$readelf" -hW "$BIN" 2>/dev/null) +seg=$("$readelf" -lW "$BIN" 2>/dev/null) +dyn=$("$readelf" -dW "$BIN" 2>/dev/null) +dynsym=$("$readelf" --dyn-syms -W "$BIN" 2>/dev/null) +notes=$("$readelf" -nW "$BIN" 2>/dev/null) + +elf_type=$(grep -oE 'Type:[[:space:]]+[A-Z]+' <<<"$hdr" | awk '{print $2}') +has_relro=$(grep -c 'GNU_RELRO' <<<"$seg") +has_bindnow=$(grep -cE 'BIND_NOW|FLAGS_1.*\bNOW\b' <<<"$dyn") +stack_perm=$(grep 'GNU_STACK' <<<"$seg" | grep -oE 'RW[E ]' | tr -d ' ') +n_canary=$(grep -c '__stack_chk_fail' <<<"$dynsym") +n_fortify=$(grep -cE '__[a-z_]+_chk(@|$)' <<<"$dynsym") +has_cet=$(grep -cE 'IBT|SHSTK|x86 feature:' <<<"$notes") +n_plt=$("$readelf" -rW "$BIN" 2>/dev/null | grep -c JUMP_SLOT) + +# Live probe: start the binary, read its maps, look for rwx and record the +# image base (so repeated invocations show whether ASLR moved it). +rwx_count="n/a" +rwx_detail="" +image_base="n/a" +if [[ -x "$BIN" ]]; then + maps=$("$BIN" -e ' + const fs = require("fs"); + process.stdout.write(fs.readFileSync("/proc/self/maps","utf8")); + ' 2>/dev/null) + if [[ -n "$maps" ]]; then + rwx_count=$(grep -cE '\brwx' <<<"$maps") + rwx_detail=$(grep -E '\brwx' <<<"$maps" | head -1 | awk '{print $1, $NF}') + image_base=$(head -1 <<<"$maps" | cut -d- -f1) + fi +fi + +row() { printf ' %-10s %-6s %s\n' "$1" "$2" "$3"; } +off_on() { [[ "$1" -gt 0 ]] && echo ON || echo OFF; } + +echo "hardening: $BIN" +echo +row CONTROL STATE EVIDENCE +row PIE "$([[ "$elf_type" == DYN ]] && echo ON || echo OFF)" "ELF type=$elf_type, image base=$image_base" +row RELRO "$([[ "$has_relro" -gt 0 && "$has_bindnow" -gt 0 ]] && echo full || { [[ "$has_relro" -gt 0 ]] && echo part || echo OFF; })" "GNU_RELRO=$has_relro BIND_NOW=$has_bindnow PLT=$n_plt" +row CANARY "$(off_on "$n_canary")" "__stack_chk_fail imports=$n_canary" +row FORTIFY "$(off_on "$n_fortify")" "*_chk imports=$n_fortify" +row CET "$(off_on "$has_cet")" "$([[ "$has_cet" -gt 0 ]] && grep -E 'IBT|SHSTK' <<<"$notes" | head -1 | xargs || echo 'no .note.gnu.property x86 feature')" +row NX "$([[ "$stack_perm" == RW ]] && echo ON || echo OFF)" "GNU_STACK=$stack_perm" +row JIT-W^X "$([[ "$rwx_count" == 0 ]] && echo ON || echo OFF)" "rwx maps=$rwx_count${rwx_detail:+ ($rwx_detail)}" +echo + +fails=() +[[ "$elf_type" == DYN ]] || fails+=(PIE) +[[ "$has_relro" -gt 0 && "$has_bindnow" -gt 0 ]] || fails+=(RELRO) +[[ "$n_canary" -gt 0 ]] || fails+=(CANARY) +[[ "$n_fortify" -gt 0 ]] || fails+=(FORTIFY) +[[ "$has_cet" -gt 0 ]] || fails+=(CET) +[[ "$stack_perm" == RW ]] || fails+=(NX) +[[ "$rwx_count" == 0 || "$rwx_count" == "n/a" ]] || fails+=(JIT-W^X) + +if [[ ${#fails[@]} -eq 0 ]]; then + echo "PASS: all controls" +else + echo "FAIL: ${fails[*]}" +fi +exit ${#fails[@]} diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index e8b87bb5b820..d0f6568a1091 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -30,6 +30,8 @@ pub enum ElfError { BunSectionNotFound, #[error("NoWritableLoadSegment")] NoWritableLoadSegment, + #[error("LoadSegmentPastWritableSegment")] + LoadSegmentPastWritableSegment, #[error("NewVaddrCollides")] NewVaddrCollides, } @@ -223,10 +225,12 @@ impl ElfFile { // flags). Growing an existing PT_LOAD is the layout a linker would // naturally produce; WSL1's kernel loader rejects binaries that // instead add a late PT_LOAD by repurposing PT_GNU_STACK (#29963). + // `-z relro` emits two PF_W loads; .bun is in the one that ends last. let phdr_size = size_of::(); let mut rw_phdr_index: Option = None; let mut rw_phdr: Elf64_Phdr = Elf64_Phdr::ZEROED; let mut max_vaddr_end: u64 = 0; + let mut max_load_file_end: u64 = 0; for i in 0..ehdr.e_phnum as usize { let phdr_offset = usize::try_from(ehdr.e_phoff).expect("int cast") + i * phdr_size; let phdr: Elf64_Phdr = read_struct(&self.data[phdr_offset..][..phdr_size]); @@ -239,7 +243,14 @@ impl ElfFile { max_vaddr_end = vaddr_end; } - if (phdr.p_flags & PF_W) != 0 && rw_phdr_index.is_none() { + let file_end = phdr.p_offset + phdr.p_filesz; + if file_end > max_load_file_end { + max_load_file_end = file_end; + } + + if (phdr.p_flags & PF_W) != 0 + && rw_phdr_index.is_none_or(|_| file_end > rw_phdr.p_offset + rw_phdr.p_filesz) + { rw_phdr_index = Some(i); rw_phdr = phdr; } @@ -248,6 +259,9 @@ impl ElfFile { let Some(rw_index) = rw_phdr_index else { return Err(ElfError::NoWritableLoadSegment); }; + if rw_phdr.p_offset + rw_phdr.p_filesz != max_load_file_end { + return Err(ElfError::LoadSegmentPastWritableSegment); + } // Place the new data at a page-aligned virtual address past every // existing mapping. page_size is ≥ 128 so this also guarantees the diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 8b5b4c123b76..24ca3691f824 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3057,16 +3057,20 @@ JSC_DEFINE_HOST_FUNCTION(Process_functiongetgid, (JSGlobalObject * globalObject, JSC_DEFINE_HOST_FUNCTION(Process_functiongetgroups, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); - int ngroups = getgroups(0, nullptr); auto throwScope = DECLARE_THROW_SCOPE(vm); + int ngroups = getgroups(0, nullptr); + if (ngroups == -1) { + throwSystemError(throwScope, globalObject, "getgroups"_s, errno); + return {}; + } + Vector groupVector(ngroups); + ngroups = getgroups(ngroups, groupVector.begin()); if (ngroups == -1) { throwSystemError(throwScope, globalObject, "getgroups"_s, errno); return {}; } JSArray* groups = constructEmptyArray(globalObject, nullptr, ngroups); RETURN_IF_EXCEPTION(throwScope, {}); - Vector groupVector(ngroups); - getgroups(ngroups, groupVector.begin()); for (unsigned i = 0; i < ngroups; i++) { groups->putDirectIndex(globalObject, i, jsNumber(groupVector[i])); } diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 090907258878..fdfc10fa01df 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isArm64, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; -import { chmodSync } from "node:fs"; +import { chmodSync, realpathSync } from "node:fs"; import { join } from "path"; describe("Bun.build compile", () => { @@ -441,8 +441,17 @@ if (isLinux) { // #29963: the writable PT_LOAD must have been grown to cover .bun, // rather than a new late PT_LOAD being appended. expect(writableLoadCoversBun).toBe(true); - // A stock bun has 3 PT_LOAD segments; the fix must not add a 4th. - expect(loadCount).toBe(3); + // The compiled binary must have the same PT_LOAD count as the bun + // binary it was produced from (3 without `-z relro`, 4 with). + const srcHeader = new Uint8Array(await Bun.file(realpathSync(bunExe())).slice(0, 4096).arrayBuffer()); + const srcView = new DataView(srcHeader.buffer); + const srcPhoff = Number(srcView.getBigUint64(32, true)); + const srcPhnum = srcView.getUint16(56, true); + let srcLoadCount = 0; + for (let i = 0; i < srcPhnum; i++) { + if (srcView.getUint32(srcPhoff + i * phentsize, true) === PT_LOAD) srcLoadCount++; + } + expect(loadCount).toBe(srcLoadCount); // JSC bytecode cache requires 128-byte-aligned deserialization input. // StandaloneModuleGraph writes bytecode at payload offset 120 assuming // the `[u64 size]` header sits at a 128-byte-aligned vaddr (so bytecode diff --git a/test/cli/binary-hardening.test.ts b/test/cli/binary-hardening.test.ts new file mode 100644 index 000000000000..c208c34e8e47 --- /dev/null +++ b/test/cli/binary-hardening.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { bunExe, isLinux } from "harness"; +import { realpathSync } from "node:fs"; + +// Linux-only: asserts the ELF hardening properties that scripts/build/flags.ts +// is expected to produce. Kept in the test suite so a flag regression (e.g. +// reintroducing -z norelro, or a strip tool that drops PT_GNU_RELRO) fails CI +// rather than shipping silently. See scripts/verify-hardening.sh for the full +// audit including the controls this change does not flip (PIE/CET/JIT W^X). + +async function readelf(args: string[]): Promise { + const bin = realpathSync(bunExe()); + await using proc = Bun.spawn({ + cmd: ["readelf", ...args, bin], + stdout: "pipe", + stderr: "pipe", + }); + const [out, exited] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(exited).toBe(0); + return out; +} + +describe.skipIf(!isLinux)("binary hardening (linux ELF)", () => { + test("link: full RELRO", async () => { + const seg = await readelf(["-lW"]); + // -Wl,-z,relro: PT_GNU_RELRO segment must survive strip. + expect(seg).toContain("GNU_RELRO"); + + // -Wl,-z,now: DT_BIND_NOW or DF_1_NOW so ld.so eagerly binds and then + // mprotects the RELRO segment read-only. + const dyn = await readelf(["-dW"]); + expect(dyn).toMatch(/BIND_NOW|\bNOW\b/); + }); + + test("compile: stack canaries", async () => { + // -fstack-protector-strong on the C/C++ side pulls in __stack_chk_fail. + const syms = await readelf(["--dyn-syms", "-W"]); + expect(syms).toContain("__stack_chk_fail"); + }); +});