Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/bun-usockets/src/bsd.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 16 additions & 10 deletions scripts/build/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <triple>-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 <section>` (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,
Expand Down
54 changes: 41 additions & 13 deletions scripts/build/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───
{
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
},
];

Expand Down
87 changes: 87 additions & 0 deletions scripts/verify-hardening.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Usage: scripts/verify-hardening.sh <path-to-bun>
#
# Prints a hardening truth table for a Linux bun binary using readelf and a
# live /proc/<pid>/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 <binary>"}
[[ -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[@]}
23 changes: 22 additions & 1 deletion src/exe_format/elf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,19 @@ 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).
//
// With `-z relro -z now` lld emits TWO PF_W PT_LOADs: the RELRO
// segment (.data.rel.ro/.got/.got.plt) first, then the regular
// .data/.bss segment. .bun is mutable initialized data so it is in
// the second one. We therefore select the PF_W PT_LOAD whose file
// extent ends last, and assert it is also the last PT_LOAD overall
// so the "everything past its file bytes is non-ALLOC tail" move
// below is sound.
Comment thread
robobun marked this conversation as resolved.
Outdated
let phdr_size = size_of::<Elf64_Phdr>();
let mut rw_phdr_index: Option<usize> = 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]);
Expand All @@ -239,7 +248,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;
}
Expand All @@ -248,6 +264,11 @@ 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 {
// A PT_LOAD with file bytes past the selected RW segment would be
// clobbered by the tail relocation below.
Comment thread
robobun marked this conversation as resolved.
Outdated
return Err(ElfError::InvalidElfFile);
}

// Place the new data at a page-aligned virtual address past every
// existing mapping. page_size is ≥ 128 so this also guarantees the
Expand Down
10 changes: 7 additions & 3 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<gid_t> 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<gid_t> groupVector(ngroups);
getgroups(ngroups, groupVector.begin());
for (unsigned i = 0; i < ngroups; i++) {
groups->putDirectIndex(globalObject, i, jsNumber(groupVector[i]));
}
Expand Down
40 changes: 40 additions & 0 deletions test/cli/binary-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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");
});
});
Loading