From fece72b049db1954eb38f2fce139cec5eb499b00 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 18:09:25 +0200 Subject: [PATCH 1/2] fix(build): put the whole GOT inside the RELRO window on ELF targets Adds -Wl,-z,relro and -Wl,-z,now to ELF_HARDENING_FLAGS, and asserts the resulting property on the produced artifact rather than on the compiler's willingness to accept a flag. The shipped Linux binaries measurably lacked the property. Built the way .github/workflows/_build.yml builds them (STATIC=1, gcc 13.3 / GNU ld 2.42, Ubuntu 24.04 aarch64), PT_GNU_RELRO ends at 0x11DA0000 in both cases: without -z now: .got 0x11d9ec20+0x13c8, .got.plt 0x11d9ffe8+0x40 -> .got.plt ends at 0x11DA0028, 40 bytes PAST the window. Those GOT slots stayed writable for the whole process lifetime, in every Linux binary shipped to date. with -z now: .got.plt folds into .got, the whole GOT sits inside the window and is re-mapped read-only after startup. Both flags are free at runtime for a static binary -- everything resolves at link time, so there is no lazy binding left to pay for. -z relro is already this toolchain's default and changes nothing here; it is named anyway so the property stops depending on one distro's spec file, since the musl/portable and glibc-floor images are different toolchains and a default is not a guarantee. A1c/A1d in scripts/ci/check-binary-composition.sh assert the OUTCOME on the binary via readelf, for the same reason A1 exists beside the .note.GNU-stack annotation: a flag the linker accepts is not evidence the artifact gained anything. PR #1138 demonstrated that directly -- its -pie was silently discarded under -static, and its -D_FORTIFY_SOURCE=2 silently DOWNGRADED Ubuntu's default of 3, both while every compile probe passed. The readers use awk rather than a short-circuiting reader on purpose: a reader that exits at the first match leaves the upstream taking EPIPE, and under `set -o pipefail` the satisfied case is reported as the failing one. Deliberately NOT taken from #1138: -D_FORTIFY_SOURCE=2 (a downgrade on the release platform), -fPIE/-pie (inert under STATIC=1), -fstack-protector-strong (real but unmeasured cost, and #1138 injected it into SQLite, tree-sitter and mimalloc -- the indexing hot loop), and the $(shell) probe machinery (~10 compiler spawns per Makefile parse, making the binary a function of the builder's toolchain). Co-authored-by: PR9000 <119280965+PR9000@users.noreply.github.com> Signed-off-by: Martin Vogel --- Makefile.cbm | 27 ++++- scripts/ci/check-binary-composition.sh | 156 ++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index dc92e1703..de57fe785 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -181,10 +181,35 @@ endif # ~259 MB of tree-sitter parse tables was mapped executable at runtime while the # amd64 build of the same source mapped them R only. That is a large ROP gadget # surface for data that is never executed, and it is invisible to section flags. +# +# -z relro + -z now finish the same job on the OTHER writable-but-shouldn't-be +# region: the GOT. Both are free at runtime here (a static binary resolves +# everything at link time, so there is no lazy binding left to pay for), and +# they are added because the SHIPPED artifact measurably lacked the property -- +# not because the linker accepts the flags. Measured on this binary, built the +# way .github/workflows/_build.yml builds the Linux release (STATIC=1, gcc 13.3 +# / GNU ld 2.42, Ubuntu 24.04 aarch64); PT_GNU_RELRO ends at 0x11DA0000 in both: +# +# without -z now: .got 0x11d9ec20+0x13c8, .got.plt 0x11d9ffe8+0x40 +# -> .got.plt ends at 0x11DA0028, i.e. 40 bytes PAST the +# window: those GOT slots stayed writable for the whole +# process lifetime, in every Linux binary we have shipped +# with -z now: .got.plt folded into .got (0x11d9ec20+0x13c8, ends +# 0x11D9FFE8) -> the whole GOT is inside the window and is +# re-mapped read-only after startup +# +# -z relro is already this toolchain's default and changes nothing here; it is +# named anyway so the property stops depending on one distro's spec file (the +# musl/portable and glibc-floor images are different toolchains, and a default +# is not a guarantee). A1c/A1d in scripts/ci/check-binary-composition.sh assert +# the OUTCOME on the produced binary, because a flag the compiler accepts is +# not evidence that the artifact gained anything -- the same reason A1 exists +# next to the .note.GNU-stack annotation. ELF_HARDENING_FLAGS := ifeq ($(IS_LINUX),yes) ifneq ($(IS_MINGW),yes) -ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code +ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code \ + -Wl,-z,relro -Wl,-z,now endif endif diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index a5b72eedd..b4a0a4c78 100755 --- a/scripts/ci/check-binary-composition.sh +++ b/scripts/ci/check-binary-composition.sh @@ -13,12 +13,15 @@ # contents independently inspectable; it does not establish which feature, if # any, caused an opaque third-party ML verdict. # -# This script is the proof that each removal stayed removed. It asserts only -# NEGATIVE properties (needle absent), plus one canary string we know ships, -# because an absence check aimed at the wrong file — a compressed artifact, a -# stub, a truncated download — would otherwise pass vacuously and read green. -# A missing tool is a hard error for the same reason: a skipped assertion must -# never look like a satisfied one. +# This script is the proof that each removal stayed removed. The needle scans +# assert NEGATIVE properties (needle absent), plus one canary string we know +# ships, because an absence check aimed at the wrong file — a compressed +# artifact, a stub, a truncated download — would otherwise pass vacuously and +# read green. The A1* checks assert structural properties of the produced ELF +# instead, for the mirror-image reason: a linker flag that was accepted is not +# evidence that the binary gained anything, so the mitigation is measured in +# the artifact. A missing tool is a hard error on both sides: a skipped +# assertion must never look like a satisfied one. # # Usage: scripts/ci/check-binary-composition.sh ... # Directories are scanned recursively; format (ELF / Mach-O / PE) is detected @@ -187,6 +190,62 @@ gnu_stack_flags() { esac } +# Echoes " " of the PT_GNU_RELRO window, empty if the +# segment is absent, "unsupported" if the resolved reader cannot report it. +# The hex→decimal conversion happens in the shell for the same reason +# exec_load_bytes does it there: strtonum() is a gawk extension and CI's awk is +# mawk, where it is undefined and the arithmetic would silently be 0. +relro_range() { + case "$ELF_READER_KIND" in + readelf) + "$ELF_READER" -lW "$1" 2>/dev/null | + awk '/GNU_RELRO/ { print $3, $6; exit }' | + while read -r vaddr memsz; do + start=$((16#${vaddr#0x})) + echo "$start $((start + 16#${memsz#0x}))" + done + ;; + objdump) + echo unsupported + ;; + esac +} + +# Echoes " " for every GOT section, one per line. +# The leading "[ 5]" index column is stripped BEFORE awk splits the line: its +# width changes with the section count, so field numbers would otherwise shift +# between binaries and the addresses would be read out of the wrong columns. +got_sections() { + "$ELF_READER" -SW "$1" 2>/dev/null | + sed -e 's/^[[:space:]]*\[[[:space:]]*[0-9]*\][[:space:]]*//' | + awk '$1 ~ /^\.got/ && $3 ~ /^[0-9a-fA-F]+$/ && $5 ~ /^[0-9a-fA-F]+$/ { print $1, $3, $5 }' | + while read -r name addr size; do + start=$((16#$addr)) + echo "$name $start $((start + 16#$size))" + done +} + +# static | now | lazy — how the binary binds at load time. +# Both tests are awk, not `grep -q`: grep exits on its first match, the reader +# upstream takes EPIPE, and under `set -o pipefail` the satisfied case would be +# reported as the failing one. awk consumes its whole input and cannot do that. +bind_now_state() { + if ! "$ELF_READER" -lW "$1" 2>/dev/null | + awk '$1 == "DYNAMIC" { found = 1 } END { exit !found }'; then + echo static + return 0 + fi + if "$ELF_READER" -dW "$1" 2>/dev/null | + awk '/\(BIND_NOW\)/ { found = 1 } + /\(FLAGS\)/ && /BIND_NOW/ { found = 1 } + /\(FLAGS_1\)/ && / NOW([ ]|$)/ { found = 1 } + END { exit !found }'; then + echo now + else + echo lazy + fi +} + # ── Reporting ─────────────────────────────────────────────────────── # PASS and FAIL both go to stdout so the per-assertion sequence stays in order # in a CI log (stderr would interleave nondeterministically); only the final @@ -309,6 +368,91 @@ check_file() { printf 'n/a %-22s %s: segment-permission check is ELF-only\n' A1b-rodata-noexec "$token" fi + # A1c — RELRO. PT_GNU_RELRO is the window the loader re-maps read-only once + # startup relocation is done. Without it .init_array, .fini_array, + # .data.rel.ro and the GOT stay writable for the whole process lifetime, + # which is what turns a stray write into control-flow hijack. ELF-only: + # Mach-O and PE have no equivalent segment. + relro_window='' + if [ "$fmt" = elf ]; then + relro_window=$(relro_range "$file") + if [ "$relro_window" = unsupported ]; then + printf 'n/a %-22s %s: %s cannot report segment addresses\n' \ + A1c-relro "$token" "$ELF_READER_KIND" + elif [ -z "$relro_window" ]; then + report FAIL A1c-relro "$token" \ + "no PT_GNU_RELRO program header — .data.rel.ro and the GOT stay writable for the process lifetime (link with -z relro)" + else + report PASS A1c-relro "$token" \ + "PT_GNU_RELRO covers $((${relro_window##* } - ${relro_window%% *})) bytes" + fi + else + printf 'n/a %-22s %s: RELRO is a GNU/ELF segment\n' A1c-relro "$token" + fi + + # A1d — eager binding, asserted on the OUTCOME instead of on the flag. + # -z now is what folds .got.plt into the RELRO window, but "the linker + # accepted -z now" proves nothing about the artifact, so what is measured + # here is the property itself: no GOT slot may be writable after startup, + # i.e. every .got* section must lie inside A1c's window. + # + # That is not a formality on the shipped artifact. The Linux release + # binaries are linked -static, and a static link on Ubuntu 24.04 / ld 2.42 + # emits PT_GNU_RELRO yet places .got.plt immediately PAST its end: in our + # own release-shape binary .got.plt ran 0x11d9ffe8+0x40 against a window + # ending at 0x11DA0000, so 40 bytes of GOT stayed writable for the process + # lifetime. This assertion fails on that binary and passes on the one built + # with -z now, which is the only reason to believe it measures anything. + # + # A binary with a PT_DYNAMIC is additionally required to carry + # BIND_NOW/FLAGS_1 NOW, because there RELRO alone cannot help: lazy binding + # writes the GOT after the loader has already re-protected it. A static + # binary has no PT_DYNAMIC and nothing to bind at runtime, so GOT coverage + # is the whole property there — a distinct reported outcome, never a skip. + if [ "$fmt" != elf ]; then + printf 'n/a %-22s %s: BIND_NOW is an ELF dynamic-section property\n' \ + A1d-bind-now "$token" + elif [ "$relro_window" = unsupported ]; then + printf 'n/a %-22s %s: %s cannot report section addresses\n' \ + A1d-bind-now "$token" "$ELF_READER_KIND" + elif [ -z "$relro_window" ]; then + report FAIL A1d-bind-now "$token" \ + "no PT_GNU_RELRO, so no GOT section can be read-only after relocation (see A1c)" + else + relro_start=${relro_window%% *} + relro_end=${relro_window##* } + got_seen=0 + got_writable='' + while read -r got_name got_start got_end; do + [ -z "$got_name" ] && continue + got_seen=$((got_seen + 1)) + if [ "$got_start" -lt "$relro_start" ] || [ "$got_end" -gt "$relro_end" ]; then + got_writable="$got_writable $got_name" + fi + done < Date: Fri, 4 Sep 2026 22:02:47 +0200 Subject: [PATCH 2/2] ci(composition): scope A1d-bind-now to release links, declare fixtures by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0o (MCPB bundle contract) and Step 0q (release candidate derivation) hand the composition gate a stub compiled straight from a .c file with the compiler's defaults. That stub exists to exercise packaging, format detection and the needle scans; it is not linked with -z now and never ships. A1d measured eager binding on it anyway, so on a toolchain that binds lazily by default (test-diag: clang-22) Step 0o died deterministically with A1d-bind-now stage/codebase-memory-mcp: GOT section(s) .got.plt fall outside PT_GNU_RELRO [15824,16384) — they stay WRITABLE after relocation The property A1d protects is a property of the release link (-z now in Makefile.cbm), so the assertion is scoped to it: a caller that feeds the gate a packaging fixture declares so with CBM_COMPOSITION_FIXTURE=1 and A1d is reported n/a — printed on every run as a named exemption, never a silent skip. Every other assertion still runs on the fixture unchanged, and release derivation never sets the variable. Verified in the Linux arm64 container (Ubuntu 24.04, gcc 13.3): a stub linked -z lazy reproduces the CI failure text on the gate without the variable and reports n/a with it; both contracts pass under a CC wrapper that links lazily (the shape that was red), and unchanged on macOS. Signed-off-by: Martin Vogel --- scripts/ci/check-binary-composition.sh | 19 ++++++++++++++++++- tests/test_mcpb_bundle_contract.sh | 9 +++++++++ ...t_release_candidate_derivation_contract.sh | 6 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index b4a0a4c78..15fbbf666 100755 --- a/scripts/ci/check-binary-composition.sh +++ b/scripts/ci/check-binary-composition.sh @@ -29,6 +29,11 @@ # meaningful. Exit 0 = every assertion passed, 1 = at least one failed, # 2 = usage error, missing tool, or nothing checkable was found (a vacuous # run is a failure, not a pass). +# CBM_COMPOSITION_FIXTURE=1 declares the target a packaging FIXTURE (a stub +# compiled by a contract test, not a release link): A1d-bind-now is then +# reported n/a instead of asserted, because eager binding is a property of +# the release link flags, not of the packaging path the fixture exercises. +# Only contract tests set it; release derivation never does. set -euo pipefail case "${1:-}" in @@ -409,7 +414,19 @@ check_file() { # writes the GOT after the loader has already re-protected it. A static # binary has no PT_DYNAMIC and nothing to bind at runtime, so GOT coverage # is the whole property there — a distinct reported outcome, never a skip. - if [ "$fmt" != elf ]; then + # + # Scope: this is a property of the RELEASE link (-z now in Makefile.cbm). + # Contract tests hand the gate a stub compiled straight from a .c file to + # exercise packaging, format detection and the needle scans; a stub is not + # linked with -z now and never ships, so asserting eager binding on it + # tests the fixture's compiler defaults, not the artifact. Such a caller + # declares itself with CBM_COMPOSITION_FIXTURE=1 and A1d is reported n/a — + # a named exemption, printed on every run, never a silent skip. Every + # other assertion still runs on the fixture unchanged. + if [ "${CBM_COMPOSITION_FIXTURE:-0}" = 1 ]; then + printf 'n/a %-22s %s: packaging fixture (CBM_COMPOSITION_FIXTURE=1) — eager binding is asserted on release links only\n' \ + A1d-bind-now "$token" + elif [ "$fmt" != elf ]; then printf 'n/a %-22s %s: BIND_NOW is an ELF dynamic-section property\n' \ A1d-bind-now "$token" elif [ "$relro_window" = unsupported ]; then diff --git a/tests/test_mcpb_bundle_contract.sh b/tests/test_mcpb_bundle_contract.sh index e20c2bba7..5b6a16040 100755 --- a/tests/test_mcpb_bundle_contract.sh +++ b/tests/test_mcpb_bundle_contract.sh @@ -14,7 +14,16 @@ # the SQLite OMIT_LOAD_EXTENSION marker — live in .rodata. This test packages # the same opaque selected bytes under foreign target labels deliberately; this # boundary proves it never invokes strip/codesign and never changes bytes. +# +# One assertion is scoped out, by name: A1d-bind-now measures eager binding, +# a property of the RELEASE link (-z now in Makefile.cbm). The stub below is +# compiled straight from stub.c with the compiler's defaults, so on an ELF leg +# it would fail A1d for a reason that has nothing to do with packaging. +# CBM_COMPOSITION_FIXTURE=1 tells the gate this target is a fixture; the gate +# prints A1d as n/a and runs everything else unchanged. Release derivation +# never sets it. set -euo pipefail +export CBM_COMPOSITION_FIXTURE=1 ROOT="$(cd "$(dirname "$0")/.." && pwd)" FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-mcpb-contract.XXXXXX")" diff --git a/tests/test_release_candidate_derivation_contract.sh b/tests/test_release_candidate_derivation_contract.sh index 164dca1c5..9dc00cf05 100755 --- a/tests/test_release_candidate_derivation_contract.sh +++ b/tests/test_release_candidate_derivation_contract.sh @@ -2,6 +2,12 @@ # Contract: release candidates are two immutable derivations of one linker # output. This uses the host's real compiler/strip/signing tools. set -euo pipefail +# The input below is a stub compiled with the compiler's defaults, not a +# release link: it exercises derivation (strip/codesign/hash binding), not the +# link flags. Declare it a fixture so the composition gate reports A1d-bind-now +# (eager binding, a -z now property of Makefile.cbm) as n/a instead of failing +# on the fixture's own link; every other assertion still runs on it. +export CBM_COMPOSITION_FIXTURE=1 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-candidate-derive.XXXXXX")"