diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b5913f2..c3c34c1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -65,7 +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 diff --git a/tools/ci/check-branch-protection-test.sh b/tools/ci/check-branch-protection-test.sh new file mode 100755 index 0000000..9cf9b69 --- /dev/null +++ b/tools/ci/check-branch-protection-test.sh @@ -0,0 +1,142 @@ +#!/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. 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 +so="${!#}" +case "$(basename "$so")" in +*objfail*) echo "llvm-objdump: error: unknown format" >&2; exit 1 ;; +esac +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 " 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" +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 "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" +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 + +# 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 +} + +# 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 new file mode 100755 index 0000000..21f5c61 --- /dev/null +++ b/tools/ci/check-branch-protection.sh @@ -0,0 +1,102 @@ +#!/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 +} +# 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 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 + } + + # 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=$(($(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 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 + # 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 + 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 + 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" +exit "$rc"