|
13 | 13 | # contents independently inspectable; it does not establish which feature, if |
14 | 14 | # any, caused an opaque third-party ML verdict. |
15 | 15 | # |
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. |
22 | 25 | # |
23 | 26 | # Usage: scripts/ci/check-binary-composition.sh <binary-or-dir>... |
24 | 27 | # Directories are scanned recursively; format (ELF / Mach-O / PE) is detected |
25 | 28 | # per file from its magic bytes and each assertion runs where it is |
26 | 29 | # meaningful. Exit 0 = every assertion passed, 1 = at least one failed, |
27 | 30 | # 2 = usage error, missing tool, or nothing checkable was found (a vacuous |
28 | 31 | # 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. |
29 | 37 | set -euo pipefail |
30 | 38 |
|
31 | 39 | case "${1:-}" in |
@@ -187,6 +195,62 @@ gnu_stack_flags() { |
187 | 195 | esac |
188 | 196 | } |
189 | 197 |
|
| 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 | + |
190 | 254 | # ── Reporting ─────────────────────────────────────────────────────── |
191 | 255 | # PASS and FAIL both go to stdout so the per-assertion sequence stays in order |
192 | 256 | # in a CI log (stderr would interleave nondeterministically); only the final |
@@ -309,6 +373,103 @@ check_file() { |
309 | 373 | printf 'n/a %-22s %s: segment-permission check is ELF-only\n' A1b-rodata-noexec "$token" |
310 | 374 | fi |
311 | 375 |
|
| 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 | + |
312 | 473 | # A2 — test-only seams. |
313 | 474 | for needle in "${SEAM_NEEDLES[@]}"; do |
314 | 475 | assert_absent "$file" "$token" A2-no-test-seams "$needle" |
|
0 commit comments