Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 26 additions & 1 deletion Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 150 additions & 6 deletions scripts/ci/check-binary-composition.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <binary-or-dir>...
# Directories are scanned recursively; format (ELF / Mach-O / PE) is detected
Expand Down Expand Up @@ -187,6 +190,62 @@ gnu_stack_flags() {
esac
}

# Echoes "<start-dec> <end-dec>" 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 "<name> <start-dec> <end-dec>" 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
Expand Down Expand Up @@ -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 <<EOF
$(got_sections "$file")
EOF
bind_state=$(bind_now_state "$file")
if [ "$got_seen" -eq 0 ]; then
# Same anti-vacuity rule as A0: with no GOT section to place, the
# coverage test above is trivially satisfied and would read green.
report FAIL A1d-bind-now "$token" \
"no .got* section found ($ELF_READER) — the coverage test has nothing to check and would pass vacuously; section headers may have been removed"
elif [ -n "$got_writable" ]; then
report FAIL A1d-bind-now "$token" \
"GOT section(s)$got_writable fall outside PT_GNU_RELRO [$relro_start,$relro_end) — they stay WRITABLE after relocation (link with -z now)"
elif [ "$bind_state" = lazy ]; then
report FAIL A1d-bind-now "$token" \
"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)"
elif [ "$bind_state" = static ]; then
report PASS A1d-bind-now "$token" \
"$got_seen GOT section(s) inside PT_GNU_RELRO; no PT_DYNAMIC, so nothing binds at runtime"
else
report PASS A1d-bind-now "$token" \
"$got_seen GOT section(s) inside PT_GNU_RELRO and the dynamic section requests BIND_NOW"
fi
fi

# A2 — test-only seams.
for needle in "${SEAM_NEEDLES[@]}"; do
assert_absent "$file" "$token" A2-no-test-seams "$needle"
Expand Down
Loading