Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
142 changes: 142 additions & 0 deletions tools/ci/check-branch-protection-test.sh
Original file line number Diff line number Diff line change
@@ -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 <leaf>:"
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 <paciasp>" ;;
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 <want-rc> <case-name> <so-name...>
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"
102 changes: 102 additions & 0 deletions tools/ci/check-branch-protection.sh
Original file line number Diff line number Diff line change
@@ -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 <libs-dir>}"
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"
Loading