Skip to content

fix(arm64): unwind past PAC-signed return addresses - #12

Merged
jpnurmi merged 2 commits into
masterfrom
jpnurmi/fix/strip-pac
Oct 30, 2026
Merged

jpnurmi merged 2 commits into
masterfrom
jpnurmi/fix/strip-pac

Conversation

@jpnurmi

@jpnurmi jpnurmi commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

strip_pac() only strips with a mask, and nothing sets one. The __bionic_clear_pac_bits() fallback was compiled out because it is not in the NDK, so a signed return address ended the unwind. Strip with xpaclri like bionic does, spelled as hint #7 for assemblers without Armv8.3-A support. It is a NOP on older CPUs.

Proof

  • It is bionic's __bionic_clear_pac_bits() inlined, which upstream libunwindstack calls in strip_pac(). Only the mnemonic differs.
  • XPACLRI strips the PAC from the instruction address in LR (x30). It has no operands, so the x30 register variable with a "+r" constraint passes the value in and out. Its operation Strip(X[30], data=FALSE) takes no key or modifier.
  • Its encoding d50320ff is in the HINT space with CRm=0000, op2=111, which is hint #7, and decodes as a NOP without FEAT_PAuth. LLVM defines XPACLRI as hint #7, and compiler-rt's sanitizers strip PACs with the same hint #7.

With NDK r27, RegsArm64::set_pc() compiles to:

mov     x30, x20
xpaclri
mov     x20, x30

Verification

We don't have Android ARM64 covered by the CI, so verified on a Pixel 8 Pro (Tensor G3, Android 17). The test signs an address with pacia1716 and passes it to set_pc(), then calls abort() through two -mbranch-protection=pac-ret functions and unwinds from a SIGABRT handler like sentry-native's inproc backend.

master:

HWCAP_PACA: 1
strip: address 0x0000005f61598760, signed 0x00729f5f61598760, set_pc 0x00729f5f61598760 (NOT stripped)
abort: 2 frames
    #00 pc 000000000007fdc0  /apex/com.android.runtime/lib64/bionic/libc.so (abort+160)
    #01 pc 0074f1df615986d0  <unknown>

This PR:

HWCAP_PACA: 1
strip: address 0x0000005e48ad0780, signed 0x0075895e48ad0780, set_pc 0x0000005e48ad0780 (stripped)
abort: 5 frames
    #00 pc 000000000007fdc0  /apex/com.android.runtime/lib64/bionic/libc.so (abort+160)
    #01 pc 00000000000286ec  /data/local/tmp/pac_test (pac_crash()+12)
    #02 pc 00000000000286fc  /data/local/tmp/pac_test (pac_caller()+12)
    #03 pc 0000000000028778  /data/local/tmp/pac_test (main+120)
    #04 pc 000000000007183c  /apex/com.android.runtime/lib64/bionic/libc.so (__libc_init+124)
Steps to reproduce

Save the two files below in pac_test/ of a libunwindstack-ndk checkout, then run on an arm64 device with PAC, on master and on this branch:

cmake -S pac_test -B pac_test/build -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=21 -DANDROID_STL=c++_static \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo -DUNWINDSTACK_DIR=$PWD
cmake --build pac_test/build
adb push pac_test/build/pac_test /data/local/tmp/
adb shell /data/local/tmp/pac_test

pac_test/CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(pac_test LANGUAGES C CXX ASM)

add_subdirectory(${UNWINDSTACK_DIR}/cmake unwindstack)

add_executable(pac_test pac_test.cpp)
target_include_directories(pac_test PRIVATE ${UNWINDSTACK_DIR}/include)
target_compile_options(pac_test PRIVATE -mbranch-protection=pac-ret)
target_link_libraries(pac_test PRIVATE unwindstack)

pac_test/pac_test.cpp:

// Checks that libunwindstack strips PAC-signed return addresses on arm64.
#include <inttypes.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>
#include <unistd.h>

#include <memory>
#include <unwindstack/MachineArm64.h>
#include <unwindstack/Maps.h>
#include <unwindstack/Memory.h>
#include <unwindstack/Regs.h>
#include <unwindstack/RegsArm64.h>
#include <unwindstack/Unwinder.h>

static uint64_t pacia1716(uint64_t ptr, uint64_t modifier) {
  register uint64_t x17 __asm("x17") = ptr;
  register uint64_t x16 __asm("x16") = modifier;
  asm volatile("hint #8" : "+r"(x17) : "r"(x16));  // pacia1716
  return x17;
}

static void strip_check() {
  uint64_t addr = (uint64_t)(uintptr_t)&strip_check;
  uint64_t signed_addr = pacia1716(addr, 0x1234);
  unwindstack::RegsArm64 regs;
  regs.SetPseudoRegister(unwindstack::ARM64_PREG_RA_SIGN_STATE, 1);
  regs.set_pc(signed_addr);
  printf("strip: address 0x%016" PRIx64 ", signed 0x%016" PRIx64 ", set_pc 0x%016" PRIx64
         " (%s)\n",
         addr, signed_addr, regs.pc(), regs.pc() == addr ? "stripped" : "NOT stripped");
}

// same calls as sentry__unwind_stack_libunwindstack()
static void on_abort(int, siginfo_t*, void* uctx) {
  std::unique_ptr<unwindstack::Regs> regs(
      unwindstack::Regs::CreateFromUcontext(unwindstack::Regs::CurrentArch(), uctx));
  unwindstack::LocalMaps maps;
  maps.Parse();
  auto memory = unwindstack::Memory::CreateProcessMemoryCached(getpid());
  unwindstack::Unwinder unwinder(64, &maps, regs.get(), memory);
  unwinder.Unwind();
  printf("abort: %zu frames\n", unwinder.NumFrames());
  for (size_t i = 0; i < unwinder.NumFrames(); i++) {
    printf("  %s\n", unwinder.FormatFrame(i).c_str());
  }
  fflush(stdout);
  _exit(0);
}

__attribute__((noinline)) void pac_crash() {
  abort();
}

__attribute__((noinline)) void pac_caller() {
  pac_crash();
  asm volatile("");
}

int main() {
  printf("HWCAP_PACA: %d\n", (getauxval(AT_HWCAP) & HWCAP_PACA) != 0);
  strip_check();
  struct sigaction sa = {};
  sa.sa_sigaction = on_abort;
  sa.sa_flags = SA_SIGINFO;
  sigaction(SIGABRT, &sa, nullptr);
  pac_caller();
  return 1;
}

Ref:

@jpnurmi
jpnurmi added this pull request to stack #13 October 29, 2026 07:53
Base automatically changed from jpnurmi/ci/sdk-tools to master October 29, 2026 12:10
`strip_pac()` only strips with a mask, and nothing sets one. The
`__bionic_clear_pac_bits()` fallback was compiled out because it is
not in the NDK, so a signed return address ended the unwind.

Strip with `xpaclri` like bionic does, spelled as `hint #7` for
assemblers without Armv8.3-A support. It is a NOP on older CPUs.

Pixel 8 Pro (Tensor G3, Android 17), `abort()` through two
`-mbranch-protection=pac-ret` functions, unwound from a SIGABRT
handler like sentry-native's inproc backend.

Before:
    #00 pc 000000000007fdc0  .../libc.so (abort+160)
    #1 pc 006e8cdf7ce216d0  <unknown>

After:
    #00 pc 000000000007fdc0  .../libc.so (abort+160)
    #1 pc 000000000002867c  .../pac_test (pac_crash()+12)
    #2 pc 000000000002868c  .../pac_test (pac_caller()+12)
    #3 pc 0000000000028708  .../pac_test (main+120)
    #4 pc 000000000007183c  .../libc.so (__libc_init+124)

Ref: getsentry/sentry-native#2258
@jpnurmi
jpnurmi force-pushed the jpnurmi/fix/strip-pac branch from e3fdf2e to d38db07 Compare October 29, 2026 12:10
@jpnurmi
jpnurmi removed this pull request from stack #13 October 29, 2026 12:18
@jpnurmi
jpnurmi merged commit d81a291 into master Oct 30, 2026
10 checks passed
@jpnurmi
jpnurmi deleted the jpnurmi/fix/strip-pac branch October 30, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants