From 439e93752d737fb1c9f28b342876609b4a16cd3a Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 21 Sep 2026 21:44:20 +0200 Subject: [PATCH 1/3] Fail the build when an arm64 library loses its branch protection Two regressions here are silent. A library built without -mbranch-protection still loads and still passes every test. An -march bump to armv8.3-a turns the PAC hints into retaa, which faults on an ARMv8.0 core. The check asserts both on every arm64 .so. No retaa or retab, and a feature note carrying BTI and PAC. It refuses an empty directory, because a build that produced no library would otherwise pass every assertion it makes. I ran it against real artifacts on both sides. The libraries built before #231 fail on libhttrack.so, whose note the unmarked OpenSSL statics cleared, and the ones built after it pass. A real build only ever produces the passing case, so the test drives the failures through stub tools. Two mutations of the checker each turn exactly one case red, which is what says the cases are not covering for each other. Signed-off-by: Xavier Roche Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 6 ++ tools/ci/check-branch-protection-test.sh | 73 ++++++++++++++++++++++++ tools/ci/check-branch-protection.sh | 69 ++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100755 tools/ci/check-branch-protection-test.sh create mode 100755 tools/ci/check-branch-protection.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8100a34..fb83a7a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -64,6 +64,10 @@ jobs: - name: ndk-build working-directory: app/src/main # contains jni/ — ndk-build auto-discovers Android.mk + Application.mk run: ${{ steps.ndk.outputs.ndk-path }}/ndk-build APP_ABI="${ABIS}" APP_PLATFORM=android-24 -j"$(nproc)" + - name: Check arm64 branch protection + env: + ANDROID_NDK_ROOT: ${{ steps.ndk.outputs.ndk-path }} + run: tools/ci/check-branch-protection.sh app/src/main/libs - uses: actions/upload-artifact@v7 with: name: native-libs @@ -124,6 +128,8 @@ jobs: - run: python3 tools/ci/play_vitals_test.py # No job builds an .aab, so the alignment script's bundle branch has no other cover. - run: tools/ci/check-so-alignment-test.sh + # A real build only ever produces the passing case, so the failures need stubs. + - run: tools/ci/check-branch-protection-test.sh # --- Phases below land with the app modernization (see the plan) --- # diff --git a/tools/ci/check-branch-protection-test.sh b/tools/ci/check-branch-protection-test.sh new file mode 100755 index 0000000..0096127 --- /dev/null +++ b/tools/ci/check-branch-protection-test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Exercise check-branch-protection.sh against stub tools, so the failure cases run +# without a toolchain. A real build only ever produces the passing case. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SUT="$HERE/check-branch-protection.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# Both stubs read the fixture's name, so a case needs no real ELF. "retaa" in the +# name means the disassembly carries one; the feature note comes from the suffix. +mkdir -p "$tmp/bin" +cat >"$tmp/bin/llvm-objdump" <<'STUB' +#!/usr/bin/env bash +echo " 0: d503233f paciasp" +case "$(basename "${!#}")" in +*retaa*) echo " 4: d65f0bff retaa" ;; +*) echo " 4: d65f03c0 ret" ;; +esac +STUB +cat >"$tmp/bin/llvm-readelf" <<'STUB' +#!/usr/bin/env bash +echo "Displaying notes found in: .note.gnu.property" +case "$(basename "${!#}")" in +*-nonote.so) ;; +*-paconly.so) echo " AArch64 feature: PAC" ;; +*-btionly.so) echo " AArch64 feature: BTI" ;; +*) echo " AArch64 feature: BTI, PAC" ;; +esac +STUB +chmod +x "$tmp/bin/llvm-objdump" "$tmp/bin/llvm-readelf" +export PATH="$tmp/bin:$PATH" + +fail=0 +run() { # run + local want="$1" name="$2" out rc=0 + shift 2 + rm -rf "$tmp/libs" + mkdir -p "$tmp/libs/arm64-v8a" + for so in "$@"; do : >"$tmp/libs/arm64-v8a/$so"; done + out="$(bash "$SUT" "$tmp/libs" 2>&1)" || rc=$? + if [ "$rc" -ne "$want" ]; then + echo "FAIL $name: want rc=$want got rc=$rc" + echo "$out" + fail=1 + fi +} + +run 0 "marked, no retaa" good-ok.so other-ok.so +run 1 "retaa faults on ARMv8.0" good-ok.so bad-retaa-ok.so +run 1 "no feature note" good-ok.so bad-nonote.so +run 1 "note without BTI" bad-paconly.so +run 1 "note without PAC" bad-btionly.so + +# An empty or absent directory must fail, or the check reports success on a build +# that produced no libraries at all. +rm -rf "$tmp/libs" +mkdir -p "$tmp/libs/arm64-v8a" +bash "$SUT" "$tmp/libs" >/dev/null 2>&1 && { + echo "FAIL empty dir: want failure" + fail=1 +} +rm -rf "$tmp/libs" +mkdir -p "$tmp/libs" +bash "$SUT" "$tmp/libs" >/dev/null 2>&1 && { + echo "FAIL absent abi dir: want failure" + fail=1 +} + +[ "$fail" -eq 0 ] && echo "check-branch-protection-test: all cases pass" +exit "$fail" diff --git a/tools/ci/check-branch-protection.sh b/tools/ci/check-branch-protection.sh new file mode 100755 index 0000000..869e7f4 --- /dev/null +++ b/tools/ci/check-branch-protection.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Fail if an arm64 .so lost its branch protection, or gained an instruction that +# faults on ARMv8.0. Both regressions are silent: the build stays green and the +# libraries still load. +set -euo pipefail + +LIBS="${1:?usage: $0 }" +ABI=arm64-v8a + +# The toolchain image carries the NDK but no binutils, so fall back to its llvm tools. +find_tool() { + local name="$1" found + found="$(command -v "$name" || true)" + if [ -z "$found" ]; then + for cand in "${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-/nonexistent}}"/toolchains/llvm/prebuilt/*/bin/"$name"; do + [ -x "$cand" ] && found="$cand" && break + done + fi + [ -n "$found" ] || { + echo "check-branch-protection: no $name found" >&2 + exit 1 + } + echo "$found" +} +READELF="$(find_tool llvm-readelf)" +OBJDUMP="$(find_tool llvm-objdump)" + +[ -d "$LIBS/$ABI" ] || { + echo "check-branch-protection: no $LIBS/$ABI directory" >&2 + exit 1 +} +mapfile -t sos < <(find "$LIBS/$ABI" -type f -name '*.so' | sort) +# An empty walk would pass every check below, so name that case rather than report success. +[ "${#sos[@]}" -gt 0 ] || { + echo "check-branch-protection: no .so under $LIBS/$ABI" >&2 + exit 1 +} + +rc=0 +for so in "${sos[@]}"; do + name="$(basename "$so")" + + # paciasp and bti are hints, so an ARMv8.0 core runs them as a NOP. retaa and + # retab are not, and fault there. Clang emits them once -march reaches armv8.3-a. + bad="$("$OBJDUMP" -d "$so" | grep -cwE 'retaa|retab' || true)" + if [ "$bad" -ne 0 ]; then + echo "FAIL $name: $bad retaa/retab, which fault on an ARMv8.0 core" + rc=1 + fi + + # The linker ANDs this note over every input, so its absence means some input + # was built without the flag. Without it the loader never guards the pages. + feat="$("$READELF" -n "$so" | grep -i 'aarch64 feature' || true)" + case "$feat" in + *BTI*PAC* | *PAC*BTI*) ;; + "") + echo "FAIL $name: no AArch64 feature note, so BTI is not enforced" + rc=1 + ;; + *) + echo "FAIL $name: feature note lacks BTI or PAC ($feat)" + rc=1 + ;; + esac +done + +[ "$rc" -eq 0 ] && echo "branch protection: ${#sos[@]} $ABI .so carry BTI and PAC, none use retaa" +exit "$rc" From 3c15b9c9216492b18d260e8b7e96147ebee70911 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 21 Sep 2026 22:06:05 +0200 Subject: [PATCH 2/3] Fail closed when the disassembler fails, and assert something is signed A failing llvm-objdump piped into grep -c reports zero matches, which the retaa check read as a pass. It now captures the disassembly first and exits when that fails. The note assertion alone passes a library that carries the note and signs no function, so the check also requires a paciasp. The test gains that case, plus one that pins the count in the pass line, because a walk that silently shrank would otherwise read as a clean run. Signed-off-by: Xavier Roche Co-Authored-By: Claude Opus 5 (1M context) --- tools/ci/check-branch-protection-test.sh | 21 ++++++++++++++++++++- tools/ci/check-branch-protection.sh | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/tools/ci/check-branch-protection-test.sh b/tools/ci/check-branch-protection-test.sh index 0096127..2dcfcfe 100755 --- a/tools/ci/check-branch-protection-test.sh +++ b/tools/ci/check-branch-protection-test.sh @@ -14,7 +14,10 @@ trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/bin" cat >"$tmp/bin/llvm-objdump" <<'STUB' #!/usr/bin/env bash -echo " 0: d503233f paciasp" +case "$(basename "${!#}")" in +*-unsigned.so) ;; +*) echo " 0: d503233f paciasp" ;; +esac case "$(basename "${!#}")" in *retaa*) echo " 4: d65f0bff retaa" ;; *) echo " 4: d65f03c0 ret" ;; @@ -49,10 +52,26 @@ run() { # run } run 0 "marked, no retaa" good-ok.so other-ok.so +# The pass line says how many libraries were checked, so a silently shrinking +# walk cannot read as a clean run. +rm -rf "$tmp/libs" +mkdir -p "$tmp/libs/arm64-v8a" +: >"$tmp/libs/arm64-v8a/a-ok.so" +: >"$tmp/libs/arm64-v8a/b-ok.so" +case "$(bash "$SUT" "$tmp/libs" 2>&1)" in +*"2 arm64-v8a .so"*) ;; +*) + echo "FAIL pass line: want the count of libraries checked" + fail=1 + ;; +esac run 1 "retaa faults on ARMv8.0" good-ok.so bad-retaa-ok.so run 1 "no feature note" good-ok.so bad-nonote.so run 1 "note without BTI" bad-paconly.so run 1 "note without PAC" bad-btionly.so +# A note can be present on a library whose code was never signed, so the note +# assertion alone would pass it. +run 1 "marked but nothing signed" bad-unsigned.so # An empty or absent directory must fail, or the check reports success on a build # that produced no libraries at all. diff --git a/tools/ci/check-branch-protection.sh b/tools/ci/check-branch-protection.sh index 869e7f4..87b2ccc 100755 --- a/tools/ci/check-branch-protection.sh +++ b/tools/ci/check-branch-protection.sh @@ -41,14 +41,29 @@ rc=0 for so in "${sos[@]}"; do name="$(basename "$so")" + # Capture once and fail closed. Piping objdump straight into grep -c would + # report zero matches when objdump itself failed, which reads as a pass. + dis="$("$OBJDUMP" -d "$so")" || { + echo "check-branch-protection: cannot disassemble $name" >&2 + exit 1 + } + # paciasp and bti are hints, so an ARMv8.0 core runs them as a NOP. retaa and # retab are not, and fault there. Clang emits them once -march reaches armv8.3-a. - bad="$("$OBJDUMP" -d "$so" | grep -cwE 'retaa|retab' || true)" + bad="$(printf '%s\n' "$dis" | grep -cwE 'retaa|retab' || true)" if [ "$bad" -ne 0 ]; then echo "FAIL $name: $bad retaa/retab, which fault on an ARMv8.0 core" rc=1 fi + # Without this the two checks above pass on a library holding no signed + # function at all, which is the regression they exist to catch. + signed="$(printf '%s\n' "$dis" | grep -cw paciasp || true)" + if [ "$signed" -eq 0 ]; then + echo "FAIL $name: no paciasp, so no return address is signed" + rc=1 + fi + # The linker ANDs this note over every input, so its absence means some input # was built without the flag. Without it the loader never guards the pages. feat="$("$READELF" -n "$so" | grep -i 'aarch64 feature' || true)" From 85687aa9ec3d533cbb3191db7791d1f011fb48b7 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Mon, 21 Sep 2026 22:14:20 +0200 Subject: [PATCH 3/3] Anchor the instruction match, so the check cannot pass on a name The paciasp assertion counted its mnemonic anywhere in the disassembly, and objdump prints the file path and every branch target. The same library passed or failed depending on the directory holding it, and a library exporting a function called paciasp passed while signing nothing. The match is now anchored to the instruction column. Two more ways through. llvm-readelf prints one feature line per note section, and bash globs span newlines, so a good line covered for a bad one. The check now wants exactly one. And find's status is lost through a process substitution, so a directory it could not read shrank the walk in silence. The stubs now copy the real tools, down to the path header and the lowercase "aarch64 feature". They printed what the script expected, which cannot catch the script expecting the wrong thing. Dropping the -i from that grep passed every case and turned every real library red. Six mutations, each killed by its own case. Retab dropped, the note match made case-sensitive, the instruction match unanchored. Then the disassembler no longer fail-closed, the two-note guard removed, and find's status ignored. The test moves into the native job, which is a required check, because the job it was in is not and it guards the gate. A failed check keeps the libraries now, since that is when they are worth reading. Signed-off-by: Xavier Roche Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android.yml | 6 +- tools/ci/check-branch-protection-test.sh | 86 +++++++++++++++++++----- tools/ci/check-branch-protection.sh | 54 ++++++++++----- 3 files changed, 108 insertions(+), 38 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d92c055..c3c34c1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -65,11 +65,15 @@ jobs: - name: ndk-build working-directory: app/src/main # contains jni/ — ndk-build auto-discovers Android.mk + Application.mk run: ${{ steps.ndk.outputs.ndk-path }}/ndk-build APP_ABI="${ABIS}" APP_PLATFORM=android-24 -j"$(nproc)" + # The test runs here, not beside the other tool tests, because only this job + # and assemble are required checks, and it guards the gate below it. + - run: tools/ci/check-branch-protection-test.sh - name: Check arm64 branch protection env: ANDROID_NDK_ROOT: ${{ steps.ndk.outputs.ndk-path }} run: tools/ci/check-branch-protection.sh app/src/main/libs - uses: actions/upload-artifact@v7 + if: always() # a failed check is when the libraries are worth reading with: name: native-libs path: app/src/main/libs/**/*.so @@ -129,8 +133,6 @@ jobs: - run: python3 tools/ci/play_vitals_test.py # No job builds an .aab, so the alignment script's bundle branch has no other cover. - run: tools/ci/check-so-alignment-test.sh - # A real build only ever produces the passing case, so the failures need stubs. - - run: tools/ci/check-branch-protection-test.sh # --- Phases below land with the app modernization (see the plan) --- # diff --git a/tools/ci/check-branch-protection-test.sh b/tools/ci/check-branch-protection-test.sh index 2dcfcfe..9cf9b69 100755 --- a/tools/ci/check-branch-protection-test.sh +++ b/tools/ci/check-branch-protection-test.sh @@ -9,28 +9,51 @@ SUT="$HERE/check-branch-protection.sh" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -# Both stubs read the fixture's name, so a case needs no real ELF. "retaa" in the -# name means the disassembly carries one; the feature note comes from the suffix. +# Both stubs read the fixture's name. Suffixes: "unsigned" emits no paciasp, +# "retaa" and "retab" add that instruction, "symref" names a symbol paciasp +# without signing anything, "nonote", "paconly", "btionly" and "twonotes" set +# the feature note, "objfail" makes the disassembler exit non-zero. A name +# matching none of them is the clean baseline. Both stubs copy the real tools' +# layout, down to the path header and the lowercase "aarch64 feature", because +# a stub that prints what the script expects cannot catch the script expecting +# the wrong thing. mkdir -p "$tmp/bin" cat >"$tmp/bin/llvm-objdump" <<'STUB' #!/usr/bin/env bash -case "$(basename "${!#}")" in -*-unsigned.so) ;; -*) echo " 0: d503233f paciasp" ;; +so="${!#}" +case "$(basename "$so")" in +*objfail*) echo "llvm-objdump: error: unknown format" >&2; exit 1 ;; esac -case "$(basename "${!#}")" in -*retaa*) echo " 4: d65f0bff retaa" ;; -*) echo " 4: d65f03c0 ret" ;; +printf '\n%s:\tfile format elf64-littleaarch64\n\nDisassembly of section .text:\n\n' "$so" +echo "0000000000001000 :" +case "$(basename "$so")" in +*unsigned* | *symref*) ;; +*) echo " 1000: d503233f paciasp" ;; +esac +case "$(basename "$so")" in +*retaa*) echo " 1004: d65f0bff retaa" ;; +*retab*) echo " 1004: d65f0fff retab" ;; +*) echo " 1004: d65f03c0 ret" ;; +esac +# A call to a symbol named paciasp, which an unanchored match would count. +case "$(basename "$so")" in +*symref*) echo " 1008: 97fffffe bl 0x0 " ;; esac STUB cat >"$tmp/bin/llvm-readelf" <<'STUB' #!/usr/bin/env bash echo "Displaying notes found in: .note.gnu.property" +echo " Owner Data size Description" +echo " GNU 0x00000010 NT_GNU_PROPERTY_TYPE_0 (property note)" case "$(basename "${!#}")" in *-nonote.so) ;; -*-paconly.so) echo " AArch64 feature: PAC" ;; -*-btionly.so) echo " AArch64 feature: BTI" ;; -*) echo " AArch64 feature: BTI, PAC" ;; +*-paconly.so) echo " Properties: aarch64 feature: PAC" ;; +*-btionly.so) echo " Properties: aarch64 feature: BTI" ;; +*-twonotes.so) + echo " Properties: aarch64 feature: BTI, PAC" + echo " Properties: aarch64 feature: PAC" + ;; +*) echo " Properties: aarch64 feature: BTI, PAC" ;; esac STUB chmod +x "$tmp/bin/llvm-objdump" "$tmp/bin/llvm-readelf" @@ -52,6 +75,31 @@ run() { # run } run 0 "marked, no retaa" good-ok.so other-ok.so +run 1 "retaa faults on ARMv8.0" good-ok.so bad-retaa-ok.so +run 1 "retab faults too" bad-retab-ok.so +run 1 "no feature note" good-ok.so bad-nonote.so +run 1 "note without BTI" bad-paconly.so +run 1 "note without PAC" bad-btionly.so +run 1 "marked but nothing signed" bad-unsigned.so +# objdump prints the path and every branch target, so an unanchored match would +# count a symbol named paciasp as a signed function. +run 1 "paciasp only as a symbol name" bad-symref.so +# One good note must not cover for a bad one. +run 1 "two feature notes" bad-twonotes.so +# A tool that fails must stop the run for that reason, not slide into the +# paciasp check and fail there by accident. +run 1 "disassembler fails" bad-objfail.so +rm -rf "$tmp/libs" +mkdir -p "$tmp/libs/arm64-v8a" +: >"$tmp/libs/arm64-v8a/bad-objfail.so" +case "$(bash "$SUT" "$tmp/libs" 2>&1)" in +*"cannot disassemble"*) ;; +*) + echo "FAIL disassembler message: want the run to stop on the tool" + fail=1 + ;; +esac + # The pass line says how many libraries were checked, so a silently shrinking # walk cannot read as a clean run. rm -rf "$tmp/libs" @@ -65,13 +113,6 @@ case "$(bash "$SUT" "$tmp/libs" 2>&1)" in fail=1 ;; esac -run 1 "retaa faults on ARMv8.0" good-ok.so bad-retaa-ok.so -run 1 "no feature note" good-ok.so bad-nonote.so -run 1 "note without BTI" bad-paconly.so -run 1 "note without PAC" bad-btionly.so -# A note can be present on a library whose code was never signed, so the note -# assertion alone would pass it. -run 1 "marked but nothing signed" bad-unsigned.so # An empty or absent directory must fail, or the check reports success on a build # that produced no libraries at all. @@ -88,5 +129,14 @@ bash "$SUT" "$tmp/libs" >/dev/null 2>&1 && { fail=1 } +# A missing tool must stop the run rather than skip the library. +rm -rf "$tmp/libs" +mkdir -p "$tmp/libs/arm64-v8a" +: >"$tmp/libs/arm64-v8a/a-ok.so" +PATH="/nonexistent" ANDROID_NDK_ROOT=/nonexistent bash "$SUT" "$tmp/libs" >/dev/null 2>&1 && { + echo "FAIL missing tools: want failure" + fail=1 +} + [ "$fail" -eq 0 ] && echo "check-branch-protection-test: all cases pass" exit "$fail" diff --git a/tools/ci/check-branch-protection.sh b/tools/ci/check-branch-protection.sh index 87b2ccc..21f5c61 100755 --- a/tools/ci/check-branch-protection.sh +++ b/tools/ci/check-branch-protection.sh @@ -30,19 +30,33 @@ OBJDUMP="$(find_tool llvm-objdump)" echo "check-branch-protection: no $LIBS/$ABI directory" >&2 exit 1 } -mapfile -t sos < <(find "$LIBS/$ABI" -type f -name '*.so' | sort) +# find's status is lost through a process substitution, so a directory it could +# not read would silently shrink the walk to the ones it could. +listing="$(mktemp)" +trap 'rm -f "$listing"' EXIT +find "$LIBS/$ABI" -type f -name '*.so' >"$listing" || { + echo "check-branch-protection: cannot walk $LIBS/$ABI" >&2 + exit 1 +} +mapfile -t sos <"$listing" # An empty walk would pass every check below, so name that case rather than report success. [ "${#sos[@]}" -gt 0 ] || { echo "check-branch-protection: no .so under $LIBS/$ABI" >&2 exit 1 } +# Count a mnemonic in the instruction column only. objdump prints the file path +# and every branch target, so an unanchored match counts a directory name or a +# symbol called paciasp as if it were a signed function. +count_insn() { + printf '%s\n' "$2" | grep -cE "^[[:space:]]*[0-9a-f]+:.*[[:space:]]$1([[:space:]]|$)" || true +} + rc=0 for so in "${sos[@]}"; do name="$(basename "$so")" - # Capture once and fail closed. Piping objdump straight into grep -c would - # report zero matches when objdump itself failed, which reads as a pass. + # Capture once so a failed objdump does not read as a zero-match pass. dis="$("$OBJDUMP" -d "$so")" || { echo "check-branch-protection: cannot disassemble $name" >&2 exit 1 @@ -50,34 +64,38 @@ for so in "${sos[@]}"; do # paciasp and bti are hints, so an ARMv8.0 core runs them as a NOP. retaa and # retab are not, and fault there. Clang emits them once -march reaches armv8.3-a. - bad="$(printf '%s\n' "$dis" | grep -cwE 'retaa|retab' || true)" + bad=$(($(count_insn retaa "$dis") + $(count_insn retab "$dis"))) if [ "$bad" -ne 0 ]; then echo "FAIL $name: $bad retaa/retab, which fault on an ARMv8.0 core" rc=1 fi - # Without this the two checks above pass on a library holding no signed - # function at all, which is the regression they exist to catch. - signed="$(printf '%s\n' "$dis" | grep -cw paciasp || true)" - if [ "$signed" -eq 0 ]; then + # Without this the note check passes a library that carries the note and signs + # nothing, which is the regression it exists to catch. + if [ "$(count_insn paciasp "$dis")" -eq 0 ]; then echo "FAIL $name: no paciasp, so no return address is signed" rc=1 fi # The linker ANDs this note over every input, so its absence means some input - # was built without the flag. Without it the loader never guards the pages. - feat="$("$READELF" -n "$so" | grep -i 'aarch64 feature' || true)" - case "$feat" in - *BTI*PAC* | *PAC*BTI*) ;; - "") + # skipped the flag. + mapfile -t feat < <("$READELF" -n "$so" | grep -i 'aarch64 feature' || true) + if [ "${#feat[@]}" -eq 0 ]; then echo "FAIL $name: no AArch64 feature note, so BTI is not enforced" rc=1 - ;; - *) - echo "FAIL $name: feature note lacks BTI or PAC ($feat)" + elif [ "${#feat[@]}" -ne 1 ]; then + # Two notes would let a good line cover for a bad one under one match. + echo "FAIL $name: ${#feat[@]} AArch64 feature notes, want exactly one" rc=1 - ;; - esac + else + case "${feat[0]}" in + *BTI*PAC* | *PAC*BTI*) ;; + *) + echo "FAIL $name: feature note lacks BTI or PAC (${feat[0]})" + rc=1 + ;; + esac + fi done [ "$rc" -eq 0 ] && echo "branch protection: ${#sos[@]} $ABI .so carry BTI and PAC, none use retaa"