Skip to content

Commit b3d898e

Browse files
authored
Merge pull request #1914 from DeusData/fix/elf-hardening-relro-now
fix(build): put the whole GOT inside the RELRO window on ELF targets
2 parents b6e4beb + 7b92d2a commit b3d898e

4 files changed

Lines changed: 208 additions & 7 deletions

File tree

‎Makefile.cbm‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,35 @@ endif
181181
# ~259 MB of tree-sitter parse tables was mapped executable at runtime while the
182182
# amd64 build of the same source mapped them R only. That is a large ROP gadget
183183
# surface for data that is never executed, and it is invisible to section flags.
184+
#
185+
# -z relro + -z now finish the same job on the OTHER writable-but-shouldn't-be
186+
# region: the GOT. Both are free at runtime here (a static binary resolves
187+
# everything at link time, so there is no lazy binding left to pay for), and
188+
# they are added because the SHIPPED artifact measurably lacked the property --
189+
# not because the linker accepts the flags. Measured on this binary, built the
190+
# way .github/workflows/_build.yml builds the Linux release (STATIC=1, gcc 13.3
191+
# / GNU ld 2.42, Ubuntu 24.04 aarch64); PT_GNU_RELRO ends at 0x11DA0000 in both:
192+
#
193+
# without -z now: .got 0x11d9ec20+0x13c8, .got.plt 0x11d9ffe8+0x40
194+
# -> .got.plt ends at 0x11DA0028, i.e. 40 bytes PAST the
195+
# window: those GOT slots stayed writable for the whole
196+
# process lifetime, in every Linux binary we have shipped
197+
# with -z now: .got.plt folded into .got (0x11d9ec20+0x13c8, ends
198+
# 0x11D9FFE8) -> the whole GOT is inside the window and is
199+
# re-mapped read-only after startup
200+
#
201+
# -z relro is already this toolchain's default and changes nothing here; it is
202+
# named anyway so the property stops depending on one distro's spec file (the
203+
# musl/portable and glibc-floor images are different toolchains, and a default
204+
# is not a guarantee). A1c/A1d in scripts/ci/check-binary-composition.sh assert
205+
# the OUTCOME on the produced binary, because a flag the compiler accepts is
206+
# not evidence that the artifact gained anything -- the same reason A1 exists
207+
# next to the .note.GNU-stack annotation.
184208
ELF_HARDENING_FLAGS :=
185209
ifeq ($(IS_LINUX),yes)
186210
ifneq ($(IS_MINGW),yes)
187-
ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code
211+
ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code \
212+
-Wl,-z,relro -Wl,-z,now
188213
endif
189214
endif
190215

‎scripts/ci/check-binary-composition.sh‎

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,27 @@
1313
# contents independently inspectable; it does not establish which feature, if
1414
# any, caused an opaque third-party ML verdict.
1515
#
16-
# This script is the proof that each removal stayed removed. It asserts only
17-
# NEGATIVE properties (needle absent), plus one canary string we know ships,
18-
# because an absence check aimed at the wrong file — a compressed artifact, a
19-
# stub, a truncated download — would otherwise pass vacuously and read green.
20-
# A missing tool is a hard error for the same reason: a skipped assertion must
21-
# never look like a satisfied one.
16+
# This script is the proof that each removal stayed removed. The needle scans
17+
# assert NEGATIVE properties (needle absent), plus one canary string we know
18+
# ships, because an absence check aimed at the wrong file — a compressed
19+
# artifact, a stub, a truncated download — would otherwise pass vacuously and
20+
# read green. The A1* checks assert structural properties of the produced ELF
21+
# instead, for the mirror-image reason: a linker flag that was accepted is not
22+
# evidence that the binary gained anything, so the mitigation is measured in
23+
# the artifact. A missing tool is a hard error on both sides: a skipped
24+
# assertion must never look like a satisfied one.
2225
#
2326
# Usage: scripts/ci/check-binary-composition.sh <binary-or-dir>...
2427
# Directories are scanned recursively; format (ELF / Mach-O / PE) is detected
2528
# per file from its magic bytes and each assertion runs where it is
2629
# meaningful. Exit 0 = every assertion passed, 1 = at least one failed,
2730
# 2 = usage error, missing tool, or nothing checkable was found (a vacuous
2831
# run is a failure, not a pass).
32+
# CBM_COMPOSITION_FIXTURE=1 declares the target a packaging FIXTURE (a stub
33+
# compiled by a contract test, not a release link): A1d-bind-now is then
34+
# reported n/a instead of asserted, because eager binding is a property of
35+
# the release link flags, not of the packaging path the fixture exercises.
36+
# Only contract tests set it; release derivation never does.
2937
set -euo pipefail
3038

3139
case "${1:-}" in
@@ -187,6 +195,62 @@ gnu_stack_flags() {
187195
esac
188196
}
189197

198+
# Echoes "<start-dec> <end-dec>" of the PT_GNU_RELRO window, empty if the
199+
# segment is absent, "unsupported" if the resolved reader cannot report it.
200+
# The hex→decimal conversion happens in the shell for the same reason
201+
# exec_load_bytes does it there: strtonum() is a gawk extension and CI's awk is
202+
# mawk, where it is undefined and the arithmetic would silently be 0.
203+
relro_range() {
204+
case "$ELF_READER_KIND" in
205+
readelf)
206+
"$ELF_READER" -lW "$1" 2>/dev/null |
207+
awk '/GNU_RELRO/ { print $3, $6; exit }' |
208+
while read -r vaddr memsz; do
209+
start=$((16#${vaddr#0x}))
210+
echo "$start $((start + 16#${memsz#0x}))"
211+
done
212+
;;
213+
objdump)
214+
echo unsupported
215+
;;
216+
esac
217+
}
218+
219+
# Echoes "<name> <start-dec> <end-dec>" for every GOT section, one per line.
220+
# The leading "[ 5]" index column is stripped BEFORE awk splits the line: its
221+
# width changes with the section count, so field numbers would otherwise shift
222+
# between binaries and the addresses would be read out of the wrong columns.
223+
got_sections() {
224+
"$ELF_READER" -SW "$1" 2>/dev/null |
225+
sed -e 's/^[[:space:]]*\[[[:space:]]*[0-9]*\][[:space:]]*//' |
226+
awk '$1 ~ /^\.got/ && $3 ~ /^[0-9a-fA-F]+$/ && $5 ~ /^[0-9a-fA-F]+$/ { print $1, $3, $5 }' |
227+
while read -r name addr size; do
228+
start=$((16#$addr))
229+
echo "$name $start $((start + 16#$size))"
230+
done
231+
}
232+
233+
# static | now | lazy — how the binary binds at load time.
234+
# Both tests are awk, not `grep -q`: grep exits on its first match, the reader
235+
# upstream takes EPIPE, and under `set -o pipefail` the satisfied case would be
236+
# reported as the failing one. awk consumes its whole input and cannot do that.
237+
bind_now_state() {
238+
if ! "$ELF_READER" -lW "$1" 2>/dev/null |
239+
awk '$1 == "DYNAMIC" { found = 1 } END { exit !found }'; then
240+
echo static
241+
return 0
242+
fi
243+
if "$ELF_READER" -dW "$1" 2>/dev/null |
244+
awk '/\(BIND_NOW\)/ { found = 1 }
245+
/\(FLAGS\)/ && /BIND_NOW/ { found = 1 }
246+
/\(FLAGS_1\)/ && / NOW([ ]|$)/ { found = 1 }
247+
END { exit !found }'; then
248+
echo now
249+
else
250+
echo lazy
251+
fi
252+
}
253+
190254
# ── Reporting ───────────────────────────────────────────────────────
191255
# PASS and FAIL both go to stdout so the per-assertion sequence stays in order
192256
# in a CI log (stderr would interleave nondeterministically); only the final
@@ -309,6 +373,103 @@ check_file() {
309373
printf 'n/a %-22s %s: segment-permission check is ELF-only\n' A1b-rodata-noexec "$token"
310374
fi
311375

376+
# A1c — RELRO. PT_GNU_RELRO is the window the loader re-maps read-only once
377+
# startup relocation is done. Without it .init_array, .fini_array,
378+
# .data.rel.ro and the GOT stay writable for the whole process lifetime,
379+
# which is what turns a stray write into control-flow hijack. ELF-only:
380+
# Mach-O and PE have no equivalent segment.
381+
relro_window=''
382+
if [ "$fmt" = elf ]; then
383+
relro_window=$(relro_range "$file")
384+
if [ "$relro_window" = unsupported ]; then
385+
printf 'n/a %-22s %s: %s cannot report segment addresses\n' \
386+
A1c-relro "$token" "$ELF_READER_KIND"
387+
elif [ -z "$relro_window" ]; then
388+
report FAIL A1c-relro "$token" \
389+
"no PT_GNU_RELRO program header — .data.rel.ro and the GOT stay writable for the process lifetime (link with -z relro)"
390+
else
391+
report PASS A1c-relro "$token" \
392+
"PT_GNU_RELRO covers $((${relro_window##* } - ${relro_window%% *})) bytes"
393+
fi
394+
else
395+
printf 'n/a %-22s %s: RELRO is a GNU/ELF segment\n' A1c-relro "$token"
396+
fi
397+
398+
# A1d — eager binding, asserted on the OUTCOME instead of on the flag.
399+
# -z now is what folds .got.plt into the RELRO window, but "the linker
400+
# accepted -z now" proves nothing about the artifact, so what is measured
401+
# here is the property itself: no GOT slot may be writable after startup,
402+
# i.e. every .got* section must lie inside A1c's window.
403+
#
404+
# That is not a formality on the shipped artifact. The Linux release
405+
# binaries are linked -static, and a static link on Ubuntu 24.04 / ld 2.42
406+
# emits PT_GNU_RELRO yet places .got.plt immediately PAST its end: in our
407+
# own release-shape binary .got.plt ran 0x11d9ffe8+0x40 against a window
408+
# ending at 0x11DA0000, so 40 bytes of GOT stayed writable for the process
409+
# lifetime. This assertion fails on that binary and passes on the one built
410+
# with -z now, which is the only reason to believe it measures anything.
411+
#
412+
# A binary with a PT_DYNAMIC is additionally required to carry
413+
# BIND_NOW/FLAGS_1 NOW, because there RELRO alone cannot help: lazy binding
414+
# writes the GOT after the loader has already re-protected it. A static
415+
# binary has no PT_DYNAMIC and nothing to bind at runtime, so GOT coverage
416+
# is the whole property there — a distinct reported outcome, never a skip.
417+
#
418+
# Scope: this is a property of the RELEASE link (-z now in Makefile.cbm).
419+
# Contract tests hand the gate a stub compiled straight from a .c file to
420+
# exercise packaging, format detection and the needle scans; a stub is not
421+
# linked with -z now and never ships, so asserting eager binding on it
422+
# tests the fixture's compiler defaults, not the artifact. Such a caller
423+
# declares itself with CBM_COMPOSITION_FIXTURE=1 and A1d is reported n/a —
424+
# a named exemption, printed on every run, never a silent skip. Every
425+
# other assertion still runs on the fixture unchanged.
426+
if [ "${CBM_COMPOSITION_FIXTURE:-0}" = 1 ]; then
427+
printf 'n/a %-22s %s: packaging fixture (CBM_COMPOSITION_FIXTURE=1) — eager binding is asserted on release links only\n' \
428+
A1d-bind-now "$token"
429+
elif [ "$fmt" != elf ]; then
430+
printf 'n/a %-22s %s: BIND_NOW is an ELF dynamic-section property\n' \
431+
A1d-bind-now "$token"
432+
elif [ "$relro_window" = unsupported ]; then
433+
printf 'n/a %-22s %s: %s cannot report section addresses\n' \
434+
A1d-bind-now "$token" "$ELF_READER_KIND"
435+
elif [ -z "$relro_window" ]; then
436+
report FAIL A1d-bind-now "$token" \
437+
"no PT_GNU_RELRO, so no GOT section can be read-only after relocation (see A1c)"
438+
else
439+
relro_start=${relro_window%% *}
440+
relro_end=${relro_window##* }
441+
got_seen=0
442+
got_writable=''
443+
while read -r got_name got_start got_end; do
444+
[ -z "$got_name" ] && continue
445+
got_seen=$((got_seen + 1))
446+
if [ "$got_start" -lt "$relro_start" ] || [ "$got_end" -gt "$relro_end" ]; then
447+
got_writable="$got_writable $got_name"
448+
fi
449+
done <<EOF
450+
$(got_sections "$file")
451+
EOF
452+
bind_state=$(bind_now_state "$file")
453+
if [ "$got_seen" -eq 0 ]; then
454+
# Same anti-vacuity rule as A0: with no GOT section to place, the
455+
# coverage test above is trivially satisfied and would read green.
456+
report FAIL A1d-bind-now "$token" \
457+
"no .got* section found ($ELF_READER) — the coverage test has nothing to check and would pass vacuously; section headers may have been removed"
458+
elif [ -n "$got_writable" ]; then
459+
report FAIL A1d-bind-now "$token" \
460+
"GOT section(s)$got_writable fall outside PT_GNU_RELRO [$relro_start,$relro_end) — they stay WRITABLE after relocation (link with -z now)"
461+
elif [ "$bind_state" = lazy ]; then
462+
report FAIL A1d-bind-now "$token" \
463+
"dynamic binary with neither BIND_NOW nor FLAGS_1 NOW — the GOT is filled lazily, after the loader has already applied RELRO (link with -z now)"
464+
elif [ "$bind_state" = static ]; then
465+
report PASS A1d-bind-now "$token" \
466+
"$got_seen GOT section(s) inside PT_GNU_RELRO; no PT_DYNAMIC, so nothing binds at runtime"
467+
else
468+
report PASS A1d-bind-now "$token" \
469+
"$got_seen GOT section(s) inside PT_GNU_RELRO and the dynamic section requests BIND_NOW"
470+
fi
471+
fi
472+
312473
# A2 — test-only seams.
313474
for needle in "${SEAM_NEEDLES[@]}"; do
314475
assert_absent "$file" "$token" A2-no-test-seams "$needle"

‎tests/test_mcpb_bundle_contract.sh‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@
1414
# the SQLite OMIT_LOAD_EXTENSION marker — live in .rodata. This test packages
1515
# the same opaque selected bytes under foreign target labels deliberately; this
1616
# boundary proves it never invokes strip/codesign and never changes bytes.
17+
#
18+
# One assertion is scoped out, by name: A1d-bind-now measures eager binding,
19+
# a property of the RELEASE link (-z now in Makefile.cbm). The stub below is
20+
# compiled straight from stub.c with the compiler's defaults, so on an ELF leg
21+
# it would fail A1d for a reason that has nothing to do with packaging.
22+
# CBM_COMPOSITION_FIXTURE=1 tells the gate this target is a fixture; the gate
23+
# prints A1d as n/a and runs everything else unchanged. Release derivation
24+
# never sets it.
1725
set -euo pipefail
26+
export CBM_COMPOSITION_FIXTURE=1
1827

1928
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
2029
FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-mcpb-contract.XXXXXX")"

‎tests/test_release_candidate_derivation_contract.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
# Contract: release candidates are two immutable derivations of one linker
33
# output. This uses the host's real compiler/strip/signing tools.
44
set -euo pipefail
5+
# The input below is a stub compiled with the compiler's defaults, not a
6+
# release link: it exercises derivation (strip/codesign/hash binding), not the
7+
# link flags. Declare it a fixture so the composition gate reports A1d-bind-now
8+
# (eager binding, a -z now property of Makefile.cbm) as n/a instead of failing
9+
# on the fixture's own link; every other assertion still runs on it.
10+
export CBM_COMPOSITION_FIXTURE=1
511

612
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
713
FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-candidate-derive.XXXXXX")"

0 commit comments

Comments
 (0)