From fece72b049db1954eb38f2fce139cec5eb499b00 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 29 Aug 2026 18:09:25 +0200 Subject: [PATCH] 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 <