CONFIG_ARM64_BTI=yand# CONFIG_ARM64_BTI_KERNEL is not setsit side by side in the same config, and GKI disables the second one explicitly. Reading flags does not tell you what is actually protected. To know what a chain needs, an exploit developer must trace each mitigation from config to instruction to fault condition.
This post describes the Android 16 baseline on arm64: what the GKI kernels compile in, what runs by default, and what still depends on hardware, the bootloader, or an opt-in. Android 16 introduced the android16-6.12 GKI, and its compatibility matrix also allows older GKI kernels, from android15-6.6 back to android12-5.10. Everything below describes the two newest. Their gki_defconfig files (6.6, 6.12) agree wherever they set a flag from the summary table; the flags they leave out come from Kconfig defaults. Kernel source references point to android15-6.6-lts, and 6.12 differences are called out. The “android15” in that branch name is the GKI generation, not the OS version.
How this was checked. CONFIG values were read on Linux localhost 6.6.66-android15-8-gb66429556fb8-ab13070261-4k aarch64, an Android 16 Android Studio arm64 AVD, extracted via:
adb shell zcat /proc/config.gz
An emulator shows what is compiled in, not what runs on a phone. This AVD’s boot log reports KASLR disabled due to lack of seed, and its /proc/cpuinfo lists paca pacg but neither bti nor mte, so PAC runs here while BTI and MTE cannot. The “Android 16 status” entries below therefore come from kernel source and Android documentation, not from emulator measurements.
Examples were compiled with NDK r28 (Clang 19.0.0) for aarch64-linux-android35 and analysed in IDA Pro; NDK r30 gives identical disassembly. They are userspace objects that show each instruction sequence, not output from a kernel build, and the GKI kernel itself uses Clang 18.
Summary
Fourteen CONFIG flags: what each one compiles into, and whether it runs on Android 16. Status applies to the android15-6.6 and android16-6.12 GKI kernels. “On by default” means no special hardware, bootloader setting or boot parameter is needed; it does not cover separately built vendor modules, which this post did not check.
| Mitigation | CONFIG | Since | Android 16 status | Exploit Primitive Affected |
|---|---|---|---|---|
| kCFI | CONFIG_CFI_CLANG | Linux 6.1 (kCFI); CFI since Android 9 | On by default | Indirect call hijacking |
| PAC user | CONFIG_ARM64_PTR_AUTH | Linux 5.0 | On where hardware has it (ARMv8.3+); the binary must be built with branch protection | Return address forgery |
| PAC kernel | CONFIG_ARM64_PTR_AUTH_KERNEL | Linux 5.7; in android12-5.10 | On where hardware has it (ARMv8.3+); return addresses only | Return address forgery |
| MTE | CONFIG_ARM64_MTE | Linux 5.10 / Android 12 | Opt-in: needs MTE hardware plus a manifest entry, system property, or Advanced Protection | Heap UAF / OOB detect |
| KASAN-HW | CONFIG_KASAN_HW_TAGS | Linux 5.11; in android12-5.10 | Bootloader-controlled on Android, on MTE hardware | Kernel heap tag faults |
| SCS | CONFIG_SHADOW_CALL_STACK | Android 10; Linux 5.8 mainline | Kernel: only on CPUs without PAC (dynamic SCS). User: opt-in per component | Return address overwrite |
| KASLR | CONFIG_RANDOMIZE_BASE | Android 8; Linux 4.6 on arm64 | Depends on the boot chain (seed or RNDR) | Kernel base known without leak |
| SLUB Random | CONFIG_SLAB_FREELIST_RANDOM | Linux 4.8 | On by default | Predictable slab alloc order |
| SLUB Hardened | CONFIG_SLAB_FREELIST_HARDENED | Linux 4.14 | On by default | Freelist pointer forgery |
| SSP | CONFIG_STACKPROTECTOR_STRONG | Linux 3.14 | On by default | Stack linear overflow to ret-addr |
| VMAP Stack | CONFIG_VMAP_STACK | Linux 4.9; 4.14 on arm64 | On by default | Stack overflow past the end of the stack |
| KSTACK Offset | CONFIG_RANDOMIZE_KSTACK_OFFSET | Linux 5.13 | Off by default: needs randomize_kstack_offset=on | Stack object placement prediction |
| BTI user | CONFIG_ARM64_BTI | Linux 5.8 | On where hardware has it (ARMv8.5+); the binary must be BTI-marked | Arbitrary JOP landing pads (indirect BR targets) |
| BTI kernel | CONFIG_ARM64_BTI_KERNEL | Linux 5.8 | Not enabled on 6.6 or 6.12 | None on GKI |
kCFI - Kernel Control Flow Integrity
- CONFIG:
CONFIG_CFI_CLANG=y - Available from: Android 9 / kernels 4.9 and 4.14 (original CFI); kCFI since Linux 6.1
- Android 16 status: On by default
- Arch: arm64, x86_64
- Layer: Kernel
- Scope: Kernel-space only; user processes use compiler-level CFI separately
How It Works
kCFI is the kernel’s forward-edge control-flow integrity. The original CONFIG_CFI_CLANG implementation shipped with Android 9 (kernels 4.9 and 4.14, first on Pixel 3) and was replaced by kCFI on Linux 6.1 and later, which does not need LTO; this post covers the kCFI implementation. Clang instruments every indirect call site to verify, at runtime, that the target function’s type signature matches the call site’s expected type. The mechanism has two components.
First, Clang places a 32-bit type hash in the four bytes immediately before every function that may be called indirectly. The hash is computed as the lower 32 bits of xxHash64 applied to the Itanium C++ mangled type name of the function pointer type. For void (*)(int, int) the mangled name is _ZTSFviiE and the resulting hash is 0xED059254, the constant visible in the disassembly and decompilation below. At an indirect call site, the compiler emits a load from (target - 4), compares it against the expected hash (a compile-time constant), and issues a BRK instruction if they do not match.
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/include/linux/cfi.h
static inline int cfi_get_offset(void)
{
return 4; // hash lives 4 bytes before function entry
}
static inline u32 cfi_get_func_hash(void *func)
{
u32 hash;
// reads *(u32 *)(func - 4): the pre-function type hash
if (get_kernel_nofault(hash, func - cfi_get_offset()))
return 0;
return hash;
}
These helpers are for code that needs to read a hash at runtime. Call sites do not use them: the compiler inlines the check, as the disassembly below shows.
On arm64, a CFI type mismatch fires a BRK whose immediate records which registers hold the expected type and the call target:
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/arch/arm64/include/asm/brk-imm.h
#define CFI_BRK_IMM_TARGET GENMASK(4, 0) // bits [4:0] = register number holding the target
#define CFI_BRK_IMM_TYPE GENMASK(9, 5) // bits [9:5] = register number holding the expected type
#define CFI_BRK_IMM_BASE 0x8000
The kernel’s cfi_handler() catches any BRK in that range, reads both registers from pt_regs, and calls report_cfi_failure(). GKI leaves CONFIG_CFI_PERMISSIVE unset, so a violation reaches die("Oops - CFI"), and CONFIG_PANIC_ON_OOPS=y turns that into a panic.
User-space CFI uses -fsanitize=cfi at NDK build time and requires LTO (-flto) to validate indirect call targets across translation units; it is a distinct implementation from kCFI and not controlled by this kernel CONFIG.
Example: C Source
// kcfi.c
typedef void (*handler_t)(int, int);
void add_handler(int a, int b) { volatile int r = a + b; (void)r; }
void mul_handler(int a, int b) { volatile int r = a * b; (void)r; }
void dispatch(handler_t fn, int x, int y) { fn(x, y); }
NDK Compilation Command
aarch64-linux-android35-clang -O1 -fsanitize=kcfi -fno-inline -c kcfi.c -o kcfi.o
Disassembly (IDA Pro)
.text:00000000 DCD 0xED059254 ; kCFI type hash for void(int,int)
.text:00000004 add_handler:
.text:00000004 SUB SP, SP, #0x10
.text:00000008 ADD W8, W1, W0
.text:0000000C STR W8, [SP,#0x10+var_4]
.text:00000010 LDR W8, [SP,#0x10+var_4]
.text:00000014 ADD SP, SP, #0x10
.text:00000018 RET
.text:0000001C DCD 0xED059254 ; kCFI type hash: same signature as add_handler
.text:00000020 mul_handler:
.text:00000020 SUB SP, SP, #0x10
.text:00000024 MUL W8, W1, W0
.text:00000028 STR W8, [SP,#0x10+var_4]
.text:0000002C LDR W8, [SP,#0x10+var_4]
.text:00000030 ADD SP, SP, #0x10
.text:00000034 RET
.text:00000038 DCD 0x804D3176 ; kCFI type hash for void(handler_t,int,int): different type
.text:0000003C dispatch:
.text:0000003C MOV X3, X0 ; X3 = fn (function pointer)
.text:00000040 MOV W0, W1
.text:00000044 MOV W1, W2
.text:00000048 LDUR W16, [X3,#-4] ; load type hash from (fn - 4)
.text:0000004C MOVK W17, #0x9254
.text:00000050 MOVK W17, #0xED05,LSL#16 ; W17 = compile-time expected hash 0xED059254
.text:00000054 CMP W16, W17 ; actual vs expected type hash
.text:00000058 B.EQ loc_60 ; match → safe to dispatch
.text:0000005C BRK #0x8223 ; MISMATCH → CFI trap (type in W17, target in X3)
.text:00000060 loc_60:
.text:00000060 BR X3 ; call validated target
IDA shows LDUR W16, [X3,#-4] (the pre-function type hash load) with the exact negative offset. The B.EQ loc_60 / BRK #0x8223 pair makes the control flow explicit: match falls through to BR X3, mismatch hits the BRK trap. IDA’s MOVK pair building 0xED059254 matches the hash constant in the decompiled output.
IDA Pro Decompilation
void __fastcall dispatch(void (*a1)(int, int), int a2, int a3)
{
if ( *((_DWORD *)a1 - 1) != 0xED059254 ) // load [fn-4], compare type hash
__break(0x8223u); // BRK #0x8223: type in W17, target in X3
((void (*)(int, int))a1)(a2, a3); // call verified target
}
IDA Pro exposes the type hash check as a direct integer comparison, and __break(0x8223u) maps directly to BRK #0x8223. The constant 0xED059254 is the Clang-computed type hash for the void(int,int) function signature. Without retyping, IDA Pro shows a1 as _DWORD * and prints the same hash as the signed value -318401964.
What This Protection Prevents
| Exploit Primitive | Blocked? | Condition |
|---|---|---|
| Overwrite kernel function pointer to arbitrary address | Yes | Target lacks matching type hash |
| Corrupt an ops-table pointer to reach a fake table | No, if its entries are valid | kCFI checks only the final call target, not the table pointer, so a fake table of real functions with the expected type passes. Entries pointing anywhere else still fail |
| Pivot through mismatched type callback | Yes | Type hash comparison fails, BRK fires |
| Jump to gadget inside existing function body | Mostly | Gadget address is not a function entry, so func - 4 normally holds code; it passes only if those 4 bytes equal the expected hash |
| Call a different function with the same signature (type confusion) | No | Same signature produces a matching hash, so a legitimate function passes the check |
Design Pressure on Developers
kCFI imposes one concrete design constraint: each indirect call site has exactly
one expected type hash, baked in at compile time. A dispatcher that holds function
pointers of different types and picks among them at runtime cannot route them
through a single BR; the hash check at that call site accepts only one signature.
Two patterns the kernel uses to stay compatible:
Uniform signature normalises all callbacks to one type and passes context
through void *. The kernel notifier chain is a direct example:
int (*notifier_call)(struct notifier_block *, unsigned long, void *).
Typed ops-struct slots give each slot its own fixed type, so N slots produce
N statically-known hashes at N separate call sites. struct file_operations is
the canonical case: read, write, ioctl each have a distinct type and a
distinct call site.
The tradeoff with the void *ctx approach: compile-time type checking on the
context struct is gone. kCFI enforces the boundary type; what the handler does
with the pointer after the cast is outside its scope.
PAC - Pointer Authentication
- CONFIG (userspace):
CONFIG_ARM64_PTR_AUTH=y - CONFIG (kernel):
CONFIG_ARM64_PTR_AUTH_KERNEL=y - Available from (userspace): Linux 5.0 / ARMv8.3-A hardware
- Available from (kernel): Linux 5.7; already in android12-5.10 under
CONFIG_ARM64_PTR_AUTH(the separate_KERNELsymbol came later) - Android 16 status: On where the CPU implements PAC. Kernel return addresses only. User binaries must be built with branch protection; AOSP’s
armv9-*andarmv8-a-branchprottargets use-mbranch-protection=standard - Arch: ARMv8.3+ only
- Layer: Both (separate CONFIGs)
How It Works
ARMv8.3-A adds five key pairs stored in EL1 system registers: APIA, APIB (instruction address keys), APDA, APDB (data address keys), and APGA (generic authentication key). The four signing instructions (PACIA, PACIB, PACDA, PACDB) insert a cryptographic signature into the pointer’s unused high bits. The four authenticating instructions (AUTIA, AUTIB, AUTDA, AUTDB) verify the signature; failure corrupts the upper bits of the pointer, so it faults on the next use.
The kernel uses the APIA key for return address signing:
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/arch/arm64/include/asm/pointer_auth.h
struct ptrauth_key {
unsigned long lo, hi; // 128-bit key, two 64-bit halves
};
struct ptrauth_keys_kernel {
struct ptrauth_key apia; // kernel instruction pointer auth key
};
// Key material from get_random_bytes(); installed into system registers:
// __ptrauth_key_install_nosync(k, v) writes:
// SYS_APIAKEYlo_EL1 <- v.lo
// SYS_APIAKEYhi_EL1 <- v.hi
// followed by isb()
User-space key lifecycle (source: pointer_auth.h, pointer_auth.c)
- Init (exec):
ptrauth_thread_init_user()callsptrauth_keys_init_user(), which callsget_random_bytes()for all five user key pairs (apia,apib,apda,apdb,apga). New keys are generated on exec. - Fork: Keys are inherited across fork, so every app forked from Zygote shares Zygote’s user keys.
- Context switch:
ptrauth_thread_switch_user(tsk)writes APIB/APDA/APDB/APGA to EL1 system registers immediately. The user APIA key is deferred and loaded in theentry.Skernel_exitmacro via__ptrauth_keys_install_userjust beforeeret, because the kernel itself uses APIA for its own return-address signing until that point. - Reset:
prctl(PR_PAC_RESET_KEYS, mask)->ptrauth_prctl_reset_keys()callsget_random_bytes()for the selected key(s) (all of them whenmaskis 0), thenptrauth_keys_install_user()installs APIB/APDA/APDB/APGA immediately. A new APIA reaches the register atkernel_exit.
Kernel key lifecycle (source: asm_pointer_auth.h)
- Init (fork):
copy_thread()runsptrauth_thread_init_kernel(tsk), which callsptrauth_keys_init_kernel(), which callsget_random_bytes()for the singleapiakey. - Context switch:
cpu_switch_toinentry.Srunsptrauth_keys_install_kernel, which writesSYS_APIAKEYLO_EL1/SYS_APIAKEYHI_EL1followed byisb. Entry from userspace reinstalls the key with__ptrauth_keys_install_kernel_nosync. The boot task uses the C helperptrauth_thread_switch_kernel()fromboot_init_stack_canary(). - No reset: kernel APIA keys are fixed after fork; no prctl interface.
Validation point: AUTIASP executes immediately before RET on every protected function. The CPU verifies the signed pointer there.
On authentication failure:
Without FEAT_FPAC, AUT* raises no exception. On basic PAuth it writes a 2-bit error code into the pointer (bits 54:53 when top-byte-ignore applies, otherwise 62:61). With FEAT_PAuth2 it XORs the computed PAC into the pointer instead.
Without FEAT_FPAC | With FEAT_FPAC (optional, SoC-dependent) | |
|---|---|---|
| When it faults | When the bad pointer is used | At the AUT* instruction itself (exception class 0x1C) |
| User-space | SIGSEGV (SEGV_MAPERR) when the CPU fetches from the corrupted address | do_el0_fpac() -> SIGILL (ILL_ILLOPN) at the AUTIASP instruction |
| Kernel-space | Instruction abort -> die_kernel_fault() -> Oops, which CONFIG_PANIC_ON_OOPS=y turns into a panic | do_el1_fpac() -> die("Oops - FPAC") -> panic |
Example: C Source
// pac.c
typedef int (*op_t)(int);
int square(int x) { return x * x; }
int apply(op_t fn, int val) { return fn(val); }
int pipeline(int a, int b) { return apply(square, a) + apply(square, b); }
NDK Compilation Command
aarch64-linux-android35-clang -O1 -mbranch-protection=pac-ret -fno-inline -c pac.c -o pac.o
-mbranch-protection=pac-ret enables PAC signing of return addresses using the APIA key (paciasp/autiasp sequence) on every non-leaf function.
Disassembly (IDA Pro)
.text:00000008 apply:
.text:00000008 MOV X2, X0 ; X2 = fn (function pointer)
.text:0000000C MOV W0, W1
.text:00000010 BR X2 ; tail-call: no PAC needed (no saved LR)
.text:00000014 pipeline:
.text:00000014 PACIASP ; SIGN LR with APIA key (modifier=SP)
.text:00000018 STP X29, X30, [SP,#-0x10+var_20]!
.text:0000001C STR X21, [SP,#0x20+var_10]
.text:00000020 STP X20, X19, [SP,#0x20+var_s0]
.text:00000024 MOV X29, SP
.text:00000028 ADRL X20, square ; IDA resolves: X20 = &square (named)
.text:00000030 MOV W19, W1
.text:00000034 MOV W1, W0
.text:00000038 MOV X0, X20
.text:0000003C BL apply ; apply(square, a)
.text:00000040 MOV W21, W0
.text:00000044 MOV X0, X20
.text:00000048 MOV W1, W19
.text:0000004C BL apply ; apply(square, b)
.text:00000050 ADD W0, W0, W21
.text:00000054 LDP X20, X19, [SP,#0x20+var_s0]
.text:00000058 LDR X21, [SP,#0x20+var_10]
.text:0000005C LDP X29, X30, [SP+0x20+var_20],#0x30
.text:00000060 AUTIASP ; AUTHENTICATE LR; corrupts upper bits on fail
.text:00000064 RET
IDA resolves the ADRP+ADD pair into ADRL X20, square, a named symbol reference. The PACIASP / AUTIASP bookend is explicit: LR is signed at entry and authenticated before RET. Without FEAT_FPAC, a tampered LR leaves AUTIASP with corrupted upper bits, and the instruction fetch after RET faults.
What This Protection Prevents
| Exploit Primitive | Blocked? | Bypass Condition |
|---|---|---|
| Overwrite return address on the stack | Yes | Forged value lacks valid PAC |
| Redirect indirect call via function pointer | No | PAC does not protect kernel call targets; kCFI covers those |
| Forge arbitrary kernel pointer | No | The kernel builds with pac-ret only; data and function pointers are not signed |
| Leak signed LR → reuse its signature | Partial | Signature is bound to SP; it can be replayed in a frame with the same SP |
| Forge a signature after leaking the keys | No | Keys also sit in memory at task_struct->thread.keys_kernel and keys_user, so an arbitrary kernel read reveals them |
| Corrupt only the PAC bits in a known pointer | Yes | Authentication fails either way. FEAT_FPAC: direct exception at AUT*; without FPAC: the corrupted LR faults after RET |
PAC width and key storage. With 39-bit VA, pointer_auth.h gives user pointers bits 54:39, or 16 bits. Kernel pointers get bits 63:39 less the bit 55 selector, so at most 24 bits, and top-byte-ignore can reduce that. In the kernel, a wrong guess ends in a panic under CONFIG_PANIC_ON_OOPS=y, so guessing is not a practical way in. Because cpu_switch_to reloads the kernel key from task_struct, a write to keys_kernel should also change the key in use after the next switch. That follows from the code path and was not tested here.
MTE - Memory Tagging Extension
- CONFIG:
CONFIG_ARM64_MTE=y(hardware support),CONFIG_KASAN_HW_TAGS=y(kernel heap tagging) - Available from: Linux 5.10 (user MTE), Linux 5.11 (
KASAN_HW_TAGS), both in android12-5.10 / ARMv8.5-A hardware required - Android 16 status: Compiled in; opt-in for user processes, bootloader-controlled for the kernel
- Arch: ARMv8.5+ only
- Layer: Both
How It Works
MTE adds a 4-bit tag to every 16-byte-aligned granule in physical memory and embeds a matching 4-bit tag in pointer bits 59..56. Every load and store compares the pointer tag against the granule tag. A mismatch raises either a synchronous exception or an asynchronous fault recorded in TFSR_EL1 (kernel) or TFSRE0_EL1 (userspace). IRG inserts a random tag; STG/ST2G write tags to memory; LDG loads a tag.
CONFIG_KASAN_HW_TAGS is not a separate protection. It is KASAN’s hardware backend that replaces shadow memory with MTE IRG/STG instructions. It depends on CONFIG_ARM64_MTE=y and requires ARMv8.5-A silicon. Benefit over CONFIG_KASAN_GENERIC: no shadow memory (~12.5% of RAM), though hardware tag storage still has its own cost.
Tag check fault modes are controlled via prctl(PR_SET_TAGGED_ADDR_CTRL), defined in include/uapi/linux/prctl.h:
| Mode | Flag | Fault behavior | Signal |
|---|---|---|---|
| SYNC | PR_MTE_TCF_SYNC | Aborts immediately on mismatch | SIGSEGV / SEGV_MTESERR (exact address) |
| ASYNC | PR_MTE_TCF_ASYNC | Deferred to next kernel entry | SIGSEGV / SEGV_MTEAERR (no address) |
| ASYMM | both flags set | Reads sync, writes async | Mix. The kernel picks the mode: the CPU’s preferred mode if requested (default async), otherwise async, then asymmetric, then sync |
Apps declare mode via android:memtagMode=(off|default|sync|async) in AndroidManifest.xml.
MTE is probabilistic, not deterministic. With 4-bit tags (16 values), the probability of an accidental tag match (a missed bug) is approximately 6-7% per access. In the kernel, tag 0xF is a match-all: proc.S sets TCR_EL1.TCMA1 for KASAN_HW_TAGS, so accesses through a pointer with top byte 0xFF skip the check. Userspace has no such wildcard. Sub-granule overflows (accesses that stay within the same 16-byte granule) are a known blind spot: MTE cannot detect them regardless of mode. Scudo exposes two tuning values through mallopt(M_MEMTAG_TUNING, ...): M_MEMTAG_TUNING_BUFFER_OVERFLOW (deterministic linear overflow detection) and M_MEMTAG_TUNING_UAF (independent random tags, ~93% detection of both UAF and spatial bugs).
Not enabled by default. Android documents MTE as disabled unless a process opts in. User-space MTE requires per-process opt-in via prctl(PR_SET_TAGGED_ADDR_CTRL, PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC, 0, 0, 0) and tagged memory must be mapped with mmap(..., PROT_MTE). Android documents that security-critical system components, including system_server and zygote64, use ASYNC mode from Android 12. Advanced Protection turns MTE on for supported apps.
Hardware. MTE hardware requires ARMv8.5-A or ARMv9 silicon. The first commercial Android phone with MTE support is the Pixel 8 (Tensor G3); Pixel 6/7 (Tensor G1/G2) use ARMv8.2 cores and have no MTE hardware.
Kernel. MTE is controlled by the bootloader, which appends kasan=on or kasan=off to the command line. That argument is what decides it: with no kasan= argument at all, kasan_init_hw_tags() initializes on MTE hardware.
Kernel allocation tagging
| File | Entry point | Role |
|---|---|---|
mm/kasan/hw_tags.c | kasan_init_hw_tags() | Boot-time MTE mode selection (sync/async/asymm) |
mm/slub.c | kmalloc, kmem_cache_alloc | SLUB allocator: calls KASAN hooks on allocation and free, which tag objects when kernel MTE is on |
mm/kasan/common.c | __kasan_slab_alloc() | Main SLUB tagging path: assign_tag() (usually via kasan_random_tag()) -> set_tag() -> kasan_unpoison() (emits IRG/STG) |
Hardware tag-based KASAN covers slab, page_alloc (with optional sampling), and non-executable vmalloc mappings (kasan.vmalloc, on by default). Stack and static data are not covered.
MTE Instructions
MTE tagging comes from the allocator, not from compiler output, so this section has no compiled example. These are the core instructions:
IRG X0, X0 ; Insert Random Tag: bits [59:56] of X0 = random 4-bit value
STG X0, [X0] ; Store Allocation Tag for the 16-byte granule at X0
LDG X0, [X0] ; Load Allocation Tag from the granule at X0 into X0[59:56]
Any subsequent access through a pointer whose bits [59:56] do not match the stored granule tag raises a Tag Check Fault: before the access completes in SYNC mode, and recorded for later delivery in ASYNC mode.
What This Protection Prevents
| Exploit Primitive | Blocked? | Condition |
|---|---|---|
Heap UAF: access after kfree | Probabilistic | Freed granule gets a new tag; a stale pointer still matches ~6-7% of the time. In ASYNC mode the access completes before the fault is reported |
| OOB heap write past chunk boundary | Probabilistic | Random tags on neighbouring chunks still match ~6-7% of the time; sub-granule overflows (within same 16-byte granule) are not detected. In ASYNC mode the write lands first |
| Linear heap spray to overwrite adjacent object | Probabilistic | Tag mismatch at the spray destination boundary, with the same limits |
| Kernel heap OOB (KASAN_HW_TAGS) | Probabilistic, if kernel MTE is on | Slab objects are tagged on allocation; kernel heap accesses checked, with the same limits |
Kernel access through a forged 0xFF top-byte pointer | No | TCMA1 makes tag 0xF a match-all |
| Bypass by learning the tag first | No | A pointer carrying the right tag passes the check. Tags can come from a leaked tagged pointer or from a speculative oracle such as TikTag, which leaks them without faulting on Pixel 8 |
SCS - Shadow Call Stack
- CONFIG:
CONFIG_SHADOW_CALL_STACK=y - Available from: Android 10; Linux 5.8 mainline
- Arch: arm64 (requires
x18reserved;-ffixed-x18) - Layer: Kernel and userspace, controlled separately
- Android 16 status: Kernel: only on CPUs without PAC. Userspace: opt-in per component
How It Works
SCS reserves the x18 register as the shadow stack pointer. At every non-leaf function’s prologue, the compiler emits STR x30, [x18], #8, storing the link register to the shadow stack and incrementing x18 by 8. At the epilogue, LDR x30, [x18, #-8]! restores the return address from the shadow stack. An attacker who overwrites the on-stack copy of LR cannot influence the actual return address used. x18 is not on the normal call stack, but it is saved to thread_info.scs_sp on context switch, and the shadow stack itself is ordinary read-write memory.
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/arch/arm64/include/asm/scs.h
// x18 (scs_sp) is loaded from current->thread_info.scs_sp on kernel entry
// scs_save(tsk) writes x18 back to tsk->thread_info.scs_sp on exit
static inline bool should_patch_pac_into_scs(void)
{
// "We only enable the shadow call stack dynamically if we are running
// on a system that does not implement PAC or BTI. PAC and SCS provide
// roughly the same level of protection, and BTI relies on the PACIASP
// instructions serving as landing pads..."
// Returns false if the CPU has address authentication.
}
On GKI, kernel SCS and kernel PAC never run together. gki_defconfig sets CONFIG_UNWIND_PATCH_PAC_INTO_SCS=y, which selects CONFIG_DYNAMIC_SCS. With that set, the top-level Makefile does not add -fsanitize=shadow-call-stack, so the kernel is built with pac-ret alone. At boot, only on a CPU without PAC, the kernel rewrites PACIASP/AUTIASP into shadow stack push and pop and logs Enabling dynamic shadow call stack. On a CPU with PAC, x18 holds no shadow stack in the kernel. The example below is a userspace build; on a CPU that needs dynamic SCS, the patched kernel performs the same push and pop.
Example: C Source
// scs.c
int leaf(int x) { return x + 1; }
int level2(int x) { return leaf(x) + leaf(x + 1); }
int level1(int x) { return level2(x) + level2(x + 2); }
NDK Compilation Command
aarch64-linux-android35-clang -O1 -fsanitize=shadow-call-stack -fno-inline -c scs.c -o scs.o
-fsanitize=shadow-call-stack enables the SCS instrumentation. The Android target already reserves x18, so no extra flag is needed here; other targets must also pass -ffixed-x18, as the kernel build does.
Disassembly (IDA Pro)
.text:00000000 leaf: ; arg0: int; leaf: no SCS (no LR to protect)
.text:00000000 ADD W0, W0, #1
.text:00000004 RET
.text:00000008 level2: ; arg0: int
.text:00000008 STR X30, [X18],#8 ; PUSH LR to shadow stack; X18 += 8
.text:0000000C STP X29, X30, [SP,#-0x20+var_s0]!
.text:00000010 STP X20, X19, [SP,#var_s10]
.text:00000014 MOV X29, SP
.text:00000018 MOV W19, W0
.text:0000001C BL leaf ; IDA resolves: BL leaf (named xref)
.text:00000020 MOV W20, W0
.text:00000024 ADD W0, W19, #1
.text:00000028 BL leaf ; IDA resolves: BL leaf (named xref)
.text:0000002C ADD W0, W0, W20
.text:00000030 LDP X20, X19, [SP,#var_s10]
.text:00000034 LDP X29, X30, [SP+var_s0],#0x20
.text:00000038 LDR X30, [X18,#-8]! ; POP LR from shadow stack; X18 -= 8
.text:0000003C RET ; returns via SHADOW copy; on-stack X30 ignored
IDA resolves both BL targets as leaf (named cross-references). The shadow stack push (STR X30, [X18],#8) and pop (LDR X30, [X18,#-8]!) are the first and last instructions of the non-leaf body. An attacker who overwrites the on-stack copy of X30 at [SP+var_s0+8] has zero effect: the actual return destination is exclusively controlled by [X18,#-8].
What This Protection Prevents
| Exploit Primitive | Blocked? | Condition |
|---|---|---|
| ROP chains that corrupt saved return addresses on the stack | Yes, where SCS runs | SCS does not block ROP/JOP that does not rely on overwriting saved LR |
| Overwrite the saved LR on the stack | Yes, where SCS runs | LR on the stack is a dead copy; the shadow copy at x18 governs the return |
| Overwrite x18 or the shadow stack | Partially | x18 is saved in thread_info.scs_sp and pt_regs, and the shadow stack is writable memory, so an arbitrary write reaches it once it is located |
User-space note: Android supports SCS in userspace, separately from this kernel CONFIG. All system libraries are compiled to reserve x18, but legacy code loaded into a process can still overwrite it, so Android recommends SCS only for self-contained components. Platform components opt in via sanitize { scs: true } in their blueprint files.
KASLR - Kernel Address Space Layout Randomization
- CONFIG:
CONFIG_RANDOMIZE_BASE=y - Available from: Android 8 / Linux 4.6 for arm64
- Arch: arm64, x86_64
- Layer: Kernel
- Android 16 status: Depends on the boot chain supplying a seed, or on a CPU with RNDR
How It Works
When the boot chain supplies entropy, KASLR randomizes the kernel image base address at every boot. Symbol addresses in the image mapping change between reboots, so an attacker cannot hardcode them in an exploit. That does not cover the kernel’s linear map alias, described below.
Boot sequence (source: arch/arm64/kernel/kaslr.c, arch/arm64/kernel/head.S)
| Phase | What happens |
|---|---|
| 1. Bootloader | Supplies entropy through /chosen/kaslr-seed in the device tree. On UEFI, the EFI stub fills that property using the EFI RNG protocol. The image’s physical placement supplies the offset’s low bits |
2. Early boot (__primary_switch) | kaslr_early_init() (called as __pi_kaslr_early_init) returns no offset if the command line has nokaslr. Otherwise it reads the seed and zeroes it, falling back to RNDR if the CPU has it, and computes the image offset. __relocate_kernel walks the embedded relocation table to patch absolute symbol references |
3. __primary_switched | Saves memstart_offset_seed (u16 __initdata), which arm64_memblock_init() later uses to randomize the linear map, but only when the CPU’s physical address range is small enough (see below) |
4. kaslr_init() | Sets the __kaslr_is_enabled flag and logs the result. The image is already randomized by now, but the linear map and module window are decided later |
5. arm64_memblock_init(), module_init_limits() | Randomize the linear map where possible, then pick the module window if kaslr_enabled() is true |
If there’s no seed: With neither a seed nor RNDR, kaslr_offset() < MIN_KIMG_ALIGN and KASLR stays off, with only a boot log warning to show for it. The Android 16 AVD boots this way and logs KASLR disabled due to lack of seed, a clear case of a flag set to =y that is not running.
Image offset: With 39-bit VA, the image offset has 16 bits of entropy: 65,536 possible positions in 2 MiB steps. This is calculated from kaslr_early_init() and __primary_switch, not measured on a device. The offset is fixed for the entire kernel session, with no re-randomization at runtime or on suspend/resume.
The linear map is usually not randomized. arm64_memblock_init() moves it only when the linear region exceeds the CPU’s physical address range (PARange) by at least ARM64_MEMSTART_ALIGN. With GKI’s 39-bit VA the linear region is 256 GiB, so that holds only for a PARange of 36 bits or less; at 40 bits or more the linear map stays at a fixed virtual address. If the kernel’s physical load address is also known, kernel .data can be reached read-write through the linear map with no KASLR leak. Project Zero demonstrated this on a Pixel 9, whose bootloader loads the kernel at a fixed physical address, and reports that other phones such as the Samsung S25 randomize that address. .text is not executable through this mapping, so code reuse still needs the image slide.
Kernel modules
module_init_limits() picks one random 128 MB window at boot that contains the kernel image (due to direct branch range limits), plus a 2 GB window for PLT fallback. module_alloc() then allocates from that window; there is no separate random offset per module. A kernel base leak narrows modules to that window but does not reveal where a specific module landed, so an attacker targeting a vulnerable driver still needs a module address leak.
kptr_restrict=2 (production default on Android) prevents /proc/kallsyms from leaking symbol addresses regardless of KASLR. Enforcement goes through kallsyms_show_value() in kernel/ksyms_common.c. With kptr_restrict=2 it returns false unconditionally, causing all addresses to print as 0000000000000000 regardless of privilege.
Relocation (source: arch/arm64/Makefile): CONFIG_RELOCATABLE adds -shared -Bsymbolic linker flags, embedding the relocation table that early boot walks to fix up absolute references. ADRP+ADD sequences need no patching: they are PC-relative on arm64, so they stay correct wherever the image moves.
What This Protection Prevents
| Exploit Primitive | Blocked? | Bypass Condition |
|---|---|---|
Hardcoded kernel .text addresses or gadget chains | Yes | Addresses differ every boot: offsets from the base are constant, but the base is unknown |
Read addresses from /proc/kallsyms | No, not by KASLR itself | kptr_restrict=2 closes this separately |
| Locate the kernel with an info leak | No | A pointer leak discloses the base; KASLR provides no protection after a leak |
| ROP chain once kernel base is leaked | No | Entire image moves as one unit: all symbol offsets remain static relative to base; one leak gives full gadget map |
Reach kernel .data through the linear map | No, on PARange of 40 bits or more | The linear map stays at a fixed address; the physical load address must also be known |
User-space note: User-space ASLR is separate from CONFIG_RANDOMIZE_BASE and is controlled at runtime through /proc/sys/kernel/randomize_va_space. Android sets randomize_va_space=2 (full randomization). Layouts are randomized at each exec, and Android apps fork from Zygote without an exec, so they share Zygote’s library addresses.
SLUB Freelist Hardening
- CONFIG:
CONFIG_SLAB_FREELIST_HARDENED=y,CONFIG_SLAB_FREELIST_RANDOM=y - Available from: Linux 4.14 (hardened), Linux 4.8 (random)
- Arch: All
- Layer: Kernel
- Android 16 status: On by default
How It Works
kmem_cache is the SLUB allocator’s per-cache descriptor. Each instance tracks size, alignment, and, when these CONFIGs are enabled, security fields that harden freelist management.
CONFIG_SLAB_FREELIST_HARDENED adds a per-cache 64-bit random secret in s->random, initialized at cache creation with a random 64-bit value. Rather than storing the raw next-pointer in a free slot’s embedded freepointer field, SLUB encodes it with three components:
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/mm/slub.c
static inline freeptr_t freelist_ptr_encode(const struct kmem_cache *s,
void *ptr,
unsigned long ptr_addr)
{
unsigned long encoded;
#ifdef CONFIG_SLAB_FREELIST_HARDENED
encoded = (unsigned long)ptr ^ s->random ^ swab(ptr_addr);
#else
encoded = (unsigned long)ptr;
#endif
return (freeptr_t){.v = encoded};
}
swab(ptr_addr) byte-reverses the 64-bit storage address of the freepointer field itself, tying the encoding to the pointer’s location: the same encoded value at a different address decodes to garbage. SLUB also places the free pointer in the middle of the object where it can, so a short linear overflow from the previous object does not reach it.
CONFIG_SLAB_FREELIST_RANDOM adds s->random_seq, a per-cache shuffled index sequence (shuffle_freelist()). When a new slab is allocated, objects are placed in the freelist in shuffled order. Partial slabs already in use are not reshuffled when returned to the partial list.
CONFIG_RANDOM_KMALLOC_CACHES is not enabled in the android15-6.6 or android16-6.12 GKI config, although it has been available since Linux 6.6 (default n). When enabled, it creates 16 kmalloc caches per size class and picks one from the call site’s return address and a per-boot seed, which separates attacker-controlled objects from target objects and makes heap spray significantly harder.
CONFIG_SLAB_MERGE_DEFAULT is unset, which keeps caches of the same size from merging.
Neither freelist CONFIG stops a cross-cache attack, which returns a freed slab page to the page allocator and reuses it for something else, below SLUB. Jann Horn described that approach in 2021 on a Debian 4.19 kernel, and 8kSec Part III covers it on Android.
What This Protection Prevents
| Exploit Primitive | Blocked? | Bypass Condition |
|---|---|---|
| Forge freelist pointer to redirect next allocation | Yes | Forged value must satisfy ptr ^ s->random ^ swab(addr) |
| Linear OOB into freelist pointer field | Yes | OOB write lands on encoded value; decode produces garbage |
| Predict allocation address for spray placement | Yes (partially) | Fresh slabs are shuffled; partial slabs are not reshuffled |
Leak s->random then forge | No | One (encoded, ptr, addr) triple directly reveals s->random |
Stack Protector
- CONFIG:
CONFIG_STACKPROTECTOR=y,CONFIG_STACKPROTECTOR_STRONG=y - Available from: Mainline (decades);
_STRONGvariant since Linux 3.14 - Arch: Architectures with
HAVE_STACKPROTECTOR, including arm64 and x86_64 - Layer: Kernel
- Android 16 status: On by default (kernel only)
How It Works
At function entry, the compiler inserts a canary load, stores it to a slot on the stack frame, and at exit reloads and compares it; a mismatch calls __stack_chk_fail(), which calls panic() and never returns.
CONFIG_STACKPROTECTOR_STRONG instruments functions that meet any of these criteria: local arrays of any type or length, taking the address of a local variable, or using register local variables. The compiler selects functions based on these heuristics, so not all functions are instrumented. When the option was introduced, it covered 20.5% of kernel functions versus 2.81% for the basic variant.
On arm64, CONFIG_STACKPROTECTOR_PER_TASK is automatically enabled when the compiler supports it. The kernel build passes -mstack-protector-guard=sysreg -mstack-protector-guard-reg=sp_el0 -mstack-protector-guard-offset=<TSK_STACK_CANARY>, so kernel functions read the canary as:
MRS Xn, SP_EL0 ; SP_EL0 = current (task_struct *)
LDR Xn, [Xn, #TSK_STACK_CANARY] ; per-task canary from task_struct::stack_canary
The canary is unique per task: dup_task_struct() calls get_random_canary() = get_random_long() & CANARY_MASK at thread creation, and boot_init_stack_canary() does the same for the boot task. CANARY_MASK zeroes the lowest byte (0xffffffffffffff00 on arm64 LE), leaving 56 effective bits of entropy. The zero byte acts as a string terminator, blocking strcpy-based overwrites from reaching the canary. The canary does not change during the thread’s lifetime.
The example below demonstrates userspace SSP (NDK / bionic); the canary is at TPIDR_EL0 + 0x28 (TLS slot). The kernel mechanism uses SP_EL0 as described above. Userspace coverage varies by target: AOSP armv9-* build targets pass -fno-stack-protector alongside -mbranch-protection=standard.
Example: C Source
// ssp.c
#include <string.h>
extern void consume(char *);
void process(const char *input, int len) {
char buf[128];
if (len > 0 && len < 128) {
memcpy(buf, input, len);
buf[len] = '\0';
}
consume(buf);
}
NDK Compilation Command
aarch64-linux-android35-clang -O1 -fstack-protector-strong -fno-inline -c ssp.c -o ssp.o
-fstack-protector-strong enables canary insertion for all functions containing local arrays (including buf[128] here).
Disassembly (IDA Pro)
; Stack frame (IDA analysis): dest[128] at SP+0x08, var_28 (canary) at X29-0x08
.text:00000000 process: ; arg: void *src, size_t n
.text:00000000 SUB SP, SP, #0xC0
.text:00000004 STP X29, X30, [SP,#0xB0+var_20]
.text:00000008 STR X21, [SP,#0xB0+var_10]
.text:0000000C STP X20, X19, [SP,#0xB0+var_s0]
.text:00000010 ADD X29, SP, #0x90
.text:00000014 MRS X20, TPIDR_EL0 ; X20 = thread TLS base
.text:00000018 SUB W10, W1, #1
.text:0000001C LDR X9, [X20,#0x28] ; canary ← TLS[0x28] (bionic TLS stack guard slot)
.text:00000020 CMP W10, #0x7E ; (n-1) <= 126 ?
.text:00000024 STUR X9, [X29,#0x20+var_28] ; STORE canary above dest[128]
.text:00000028 B.HI loc_4C
.text:0000002C MOV X8, X0
.text:00000030 MOV W19, W1
.text:00000034 ADD X0, SP, #0xB0+dest ; dest ; X0 = &dest (IDA named: 'dest')
.text:00000038 MOV X1, X8 ; src
.text:0000003C MOV X2, X19 ; n
.text:00000040 ADD X21, SP, #0xB0+dest
.text:00000044 BL memcpy ; IDA named xref → memcpy
.text:00000048 STRB WZR, [X21,X19] ; dest[n] = '\0'
.text:0000004C loc_4C:
.text:0000004C ADD X0, SP, #0xB0+dest
.text:00000050 BL consume ; IDA named xref → consume
.text:00000054 LDR X8, [X20,#0x28] ; reload live canary from TLS
.text:00000058 LDUR X9, [X29,#0x20+var_28] ; reload stored canary from stack
.text:0000005C CMP X8, X9 ; live vs stored
.text:00000060 B.NE loc_78 ; mismatch → smash detected
.text:00000064 LDP X20, X19, [SP,#0xB0+var_s0]
.text:00000068 LDR X21, [SP,#0xB0+var_10]
.text:0000006C LDP X29, X30, [SP,#0xB0+var_20]
.text:00000070 ADD SP, SP, #0xC0
.text:00000074 RET
.text:00000078 loc_78:
.text:00000078 BL __stack_chk_fail ; IDA named xref → __stack_chk_fail
IDA names dest as a 128-byte _BYTE[128] stack variable, annotates inline argument comments (; dest, ; src, ; n) at the memcpy call, and resolves all three external calls as named symbols (memcpy, consume, __stack_chk_fail). The canary layout is unambiguous: MRS TPIDR_EL0 → LDR [X20,#0x28] → STUR [X29,#-8] stores the TLS canary above dest[128]; the matching LDR/LDUR/CMP trio at exit verifies it.
Key Symbols
| Symbol | Location | Notes |
|---|---|---|
task_struct::stack_canary | include/linux/sched.h | unsigned long; guarded by #ifdef CONFIG_STACKPROTECTOR; per-task canary value |
__stack_chk_guard | arch/arm64/include/asm/stackprotector.h | Global fallback; not used at runtime on arm64 when CONFIG_STACKPROTECTOR_PER_TASK is active |
__stack_chk_fail() | kernel/panic.c | Calls panic(); never returns |
What This Protection Prevents
| Exploit Primitive | Blocked? | Bypass Condition |
|---|---|---|
| Linear stack buffer overflow reaching return address | Yes | Canary must be leaked or bypassed |
| Local variable overflow reaching saved frame pointer | Yes (_STRONG covers arrays) | Canary is above all covered locals |
| Heap-based return address overwrite | No | Canary only guards the current stack frame |
| Canary leak via info-leak + reuse | No | If current->stack_canary is leaked before corruption, the overwrite can preserve it |
VMAP Stack - Virtually-Mapped Kernel Stacks
- CONFIG:
CONFIG_VMAP_STACK=y - Available from: Linux 4.9 (x86), Linux 4.14 (arm64,
CONFIG_HAVE_ARCH_VMAP_STACK) - Arch: Architectures with
HAVE_ARCH_VMAP_STACK, including arm64 and x86_64 - Layer: Kernel
- Android 16 status: On by default
How It Works
Without CONFIG_VMAP_STACK, kernel stacks are physically contiguous pages. A stack overflow can silently write into the adjacent physical page without causing an immediate fault.
With CONFIG_VMAP_STACK, each kernel stack is allocated via vmalloc, which leaves an unmapped guard page (no PTE) after every allocation, so the page below a stack is unmapped too. A stack overflow that crosses that boundary causes an immediate page fault. On arm64, stacks are aligned to 2 * THREAD_SIZE so exception entry can cheaply detect an overflowed SP. It then switches to a per-CPU overflow stack, logs "Insufficient stack space to handle exception!", and panics with "kernel stack overflow". The guard page is 4 KB on this kernel; the stack itself is 16 KB on arm64 (THREAD_SHIFT = 14).
High address ┌─────────────────────────────────────┐
│ Kernel Stack (16 KB) │ ← actual thread stack
├─────────────────────────────────────┤
│ Guard Page (4 KB) │ ← no PTE: no access
│ unmapped virtual page │ fault on first overflow byte
Low address └─────────────────────────────────────┘
No compiled example is needed here, because the protection is entirely in the memory layout decisions made during fork()/copy_process(). The relevant kernel path is alloc_thread_stack_node() in kernel/fork.c, which calls __vmalloc_node_range() to allocate the stack; vmalloc appends one guard page by default unless VM_NO_GUARD is passed.
What This Protection Prevents
| Exploit Primitive | Blocked? | Condition |
|---|---|---|
| Stack overflow writing adjacent kernel object | Yes | Guard page faults before adjacent page is reached. A write that lands beyond the 4 KB guard page can skip it |
| Silent stack overflow used as heap primitive | Yes | Cannot overflow silently; fault is immediate |
| Linear overflow into a neighboring stack | Yes | Virtual address space separates all stacks by at least one guard page. An arbitrary write is not stopped, and the stack pages also remain reachable through the linear map |
| Deep recursion running off the stack | Yes | Guard page fault triggers kernel panic via stack overflow detection |
KSTACK Offset Randomization
- CONFIG:
CONFIG_RANDOMIZE_KSTACK_OFFSET=y - Available from: Linux 5.13
- Arch: arm64, x86_64
- Layer: Kernel
- Android 16 status: Compiled in, off by default
How It Works
CONFIG_RANDOMIZE_KSTACK_OFFSET=y only builds the feature. Whether it runs is controlled by a static key whose default comes from CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT, which neither the 6.6 nor the 6.12 gki_defconfig sets. The GKI built-in command line does not pass randomize_kstack_offset=on either, so it is off unless a device’s boot chain adds that parameter.
When enabled, at every syscall entry, add_random_kstack_offset() pads the kernel stack pointer by a random amount before the syscall handler’s frame is established. All stack-allocated variables in the handler land at unpredictable addresses relative to any previous syscall. The next offset is pre-computed at syscall exit via choose_random_kstack_offset(get_random_u16()) (both wired in arch/arm64/kernel/syscall.c), chosen on exit rather than entry because userspace controls timing less well inside the kernel than outside it, and to reduce how long the new offset sits in memory during kernel execution.
The mechanism uses __builtin_alloca_uninitialized (or __builtin_alloca as fallback) to pad SP, with no register manipulation or compiler flag required. The key definitions are in include/linux/randomize_kstack.h:
// https://android.googlesource.com/kernel/common/+/refs/heads/android15-6.6-lts/include/linux/randomize_kstack.h
DECLARE_PER_CPU(u32, kstack_offset);
// Applied at syscall entry: pads SP by per-CPU stored offset
add_random_kstack_offset();
// Called at syscall exit: pre-computes the next offset
choose_random_kstack_offset(get_random_u16());
// 10-bit cap; AAPCS 16-byte alignment removes 4 low bits
// -> 6 effective bits of entropy, SP[9:4], range 0-1008 bytes
#define KSTACK_OFFSET_MAX(x) ((x) & 0x3FF)
Effective entropy: 6 bits (SP bits [9:4]). AAPCS mandates 16-byte SP alignment at function boundaries, consuming the 4 low bits. The result is a random pad of 0 to 1008 bytes in 16-byte steps per syscall.
Scope: syscall entry only, not applied to hardware interrupts or exceptions.
What This Protection Prevents
| Exploit Primitive | Blocked? | Bypass Condition |
|---|---|---|
| Predict stack variable address across syscalls | Yes, when enabled | Offset changes every syscall; 6 bits entropy per call |
| Stack-based info leak assuming fixed layout | Yes, when enabled | Layout shifts between calls |
| Stack pivot relying on known stack frame offset | Yes, when enabled | Offset unknown without a fresh leak each syscall |
| Overflow into adjacent stack object | No | VMAP_STACK guard page handles overflows; KSTACK_OFFSET only moves the frame |
BTI - Branch Target Identification
- CONFIG:
CONFIG_ARM64_BTI=y(userspace),CONFIG_ARM64_BTI_KERNEL is not set(kernel) - Available from: ARMv8.5-A hardware; Linux 5.8
- Arch: ARMv8.5+ only
- Layer: Userspace only on GKI
- Android 16 status: User: on where hardware has it, for BTI-marked binaries. Kernel: not enabled on 6.6 or 6.12
How It Works
BTI adds a branch-target identification mechanism to ARMv8.5. On a guarded page, any indirect branch (BLR, BR) must land on a compatible landing pad or it raises a Branch Target Exception. (RET is not restricted by BTI and can land on any instruction.) The BTI instruction is encoded as HINT #N:
BTI c (hint #34, encoding 0xd503245f) valid target for BLR, and for BR via X16/X17
BTI j (hint #36, encoding 0xd503249f) valid target for BR
BTI jc (hint #38, encoding 0xd50324df) valid target for both
PACIASP and PACIBSP also act as landing pads for BLR and for BR via X16/X17, which is why pac-ret code still works under BTI.
Four separate pieces decide whether BTI applies to a userspace binary:
- Opting in: ELF binaries set a GNU property bit (
GNU_PROPERTY_AARCH64_FEATURE_1_BTI) in.note.gnu.property. - Turning on enforcement: the GP bit in the PTE, the hardware guarded-page bit behind
PROT_BTI, activates BTI per page. Atexecve,arch_elf_adjust_prot()maps executable segments withPROT_BTI, but only for the interpreter, or for the executable itself when it is static. Bionic’s linker appliesPROT_BTIto everything else it loads, library by library. SCTLR_EL1.BT0andBT1:bti_enable()sets these system-wide, which does something narrower: it stopsBRthrough registers other than X16/X17 from landing onPACIxSP.-z force-bti: this linker flag marks that one output file as BTI-compatible, warning about any input without the property. It has no effect on other libraries loaded into the process.
Example: C Source
// bti.c
typedef int (*fn_t)(int);
int double_val(int x) { return x * 2; }
int triple_val(int x) { return x * 3; }
int call_indirect(fn_t fn, int v) { return fn(v); }
NDK Compilation Command
aarch64-linux-android35-clang -O1 -mbranch-protection=bti -fno-inline -c bti.c -o bti.o
-mbranch-protection=bti inserts BTI c (hint #34) at every function entry that is a valid indirect branch target.
Disassembly (IDA Pro)
.text:00000000 double_val:
.text:00000000 BTI c ; hint #34 (0xD503245F): valid indirect-call landing pad
.text:00000004 LSL W0, W0, #1
.text:00000008 RET
.text:0000000C triple_val:
.text:0000000C BTI c ; every function entry is a BTI landing pad
.text:00000010 ADD W0, W0, W0,LSL#1
.text:00000014 RET
.text:00000018 call_indirect: ; arg0: __int64 (__fastcall *)(_QWORD), arg1: unsigned int
.text:00000018 BTI c ; call_indirect itself is callable indirectly
.text:0000001C MOV X16, X0 ; X16 = fn (function pointer, using IP0 convention)
.text:00000020 MOV W0, W1
.text:00000024 BR X16 ; indirect branch via X16: target needs BTI c, BTI j, BTI jc or PACIxSP
IDA annotates the full argument types for call_indirect (__int64 (__fastcall *)(_QWORD) and unsigned int) from its type inference. BR X16 to any address without a compatible landing pad raises a Branch Target Exception (exception class 0x0D; do_el0_bti() delivers SIGILL / ILL_ILLOPC in userspace). Clang picks X16 for this tail call because BR through X16/X17 may land on BTI c, which is what function entries carry.
The BTI Kernel Gap
CONFIG_ARM64_BTI_KERNEL is not set in the android15-6.6 and android16-6.12 GKI builds. This is the gap worth understanding precisely.
When CONFIG_ARM64_BTI_KERNEL is compiled in, the kernel passes -mbranch-protection=pac-ret+bti to Clang for all kernel object files and maps kernel text with the GP bit set. Every kernel function entry would carry a BTI c instruction, and any indirect branch to an arbitrary address within kernel text would fault.
Without it, the kernel’s .text pages are fully traversable via indirect branches. Once an attacker controls a branch the compiler did not check, a kernel JOP chain can use any 4-byte aligned address within kernel .text as a gadget entry point. A corrupted C function pointer still has to get past kCFI first.
| State | What it means for a kernel exploit |
|---|---|
CONFIG_ARM64_BTI=y only (GKI) | Userspace JOP restricted on BTI hardware; kernel JOP gadget selection not restricted by BTI |
CONFIG_ARM64_BTI=y and CONFIG_ARM64_BTI_KERNEL=y (not GKI; the kernel option depends on the user one) | Userspace and kernel both enforced: every kernel indirect branch must land on a compatible landing pad, so JOP is restricted to those |
This is a deliberate choice: the option is default y, and gki_defconfig turns it off explicitly, without stating why. android16-6.12-lts keeps the same line, so the gap carries forward. Its hardening.config does list CONFIG_ARM64_BTI_KERNEL=y, but that is a general config fragment, and the GKI build does not take the setting from it.
What This Protection Prevents (Userspace Only on GKI)
| Exploit Primitive | Blocked (userspace)? | Blocked (kernel)? |
|---|---|---|
| JOP chain using mid-function gadgets | Yes: must land on BTI j or BTI jc | No: BTI_KERNEL not enabled |
| ROP gadget at non-function address | No: RET is unrestricted by BTI | No |
BR x16 to arbitrary address | Yes: target needs BTI c, BTI j or BTI jc | No |
Kernel function pointer overwrite to arbitrary .text | N/A | Not by BTI; kCFI still checks instrumented call sites |
Beyond the Fourteen Flags
The summary table does not tell the whole story. These are also enabled in the GKI build, by gki_defconfig or by a Kconfig default, and each changes what an exploit can do unless its row says it is off by default:
| CONFIG | Effect |
|---|---|
CONFIG_ARM64_PAN=y, CONFIG_ARM64_SW_TTBR0_PAN=y | Stops the kernel touching user memory outside the user access routines, so a corrupted kernel pointer cannot simply aim at a fake object in user memory. PAN is detected at runtime on ARMv8.1+; the second option emulates it by switching TTBR0_EL1 |
CONFIG_ARM64_E0PD=y | On ARMv8.5+ hardware, makes EL0 accesses through TTBR1 fault in constant time, which protects KASLR from that timing signal with lower overhead than KPTI |
CONFIG_RODATA_FULL_DEFAULT_ENABLED=y | Extends read-only protection to the linear alias, so code and read-only data cannot be rewritten through that second mapping. rodata=off or rodata=on disables it at boot |
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | Zeroes most heap and page allocations by default, which closes most uninitialized-memory leaks from them. Slab caches with a constructor or SLAB_TYPESAFE_BY_RCU are exceptions |
CONFIG_HARDENED_USERCOPY=y | Bounds-checks copy_to_user/copy_from_user against slab, stack and vmalloc objects, and rejects kernel text |
CONFIG_FORTIFY_SOURCE=y | Adds compile-time and runtime bounds checks to string and memory functions |
CONFIG_BUG_ON_DATA_CORRUPTION=y | Turns detected data corruption, such as a damaged list, into a BUG |
CONFIG_STATIC_USERMODEHELPER=y, path "" | Forces usermode helper calls to a fixed empty path, so overwriting modprobe_path no longer runs a chosen binary |
# CONFIG_SLAB_MERGE_DEFAULT is not set | Keeps same-size slab caches separate |
CONFIG_SHUFFLE_PAGE_ALLOCATOR=y | Compiled in, but off by default: page allocator free lists are randomized only with page_alloc.shuffle=1, which neither GKI built-in command line passes |
CONFIG_KFENCE=y | Sampled detection of heap errors, with no MTE needed. GKI samples every 500 ms, so it catches only a small share of allocations |
CONFIG_PANIC_ON_OOPS=y | Turns every kernel Oops into a panic, and CONFIG_PANIC_TIMEOUT=-1 reboots immediately, so a failed exploit attempt usually costs a reboot |
kvm-arm.mode=protected (built-in command line) | Requests pKVM wherever the hypervisor is available |
CONFIG_LIST_HARDENED=y is selected by CONFIG_BUG_ON_DATA_CORRUPTION, so it applies to both kernels. CONFIG_INIT_STACK_ALL_ZERO=y and CONFIG_UNMAP_KERNEL_AT_EL0=y are on in the 6.6 config and default on in 6.12’s Kconfig.
What None of This Stops
These protections target control-flow hijacking and the memory bugs that lead to it. None of them stops a write to writable kernel data once an attacker has a read/write primitive, and that write needs no forged return address or redirected call. Read-only kernel data is the exception: a plain write faults there, so changing it takes a page-table write or another bypass.
Consolidated Exploit Primitive Table
A single view of the summary table’s kernel mitigations on the Android 16 GKI kernels. A - means that column does not address the primitive; the flags in Beyond the Fourteen Flags are not shown.
| Exploit Primitive | kCFI | PAC or SCS | MTE | KASLR | SLUB-H | SLUB-R | SSP | VMAP | KSTACK | BTI-K |
|---|---|---|---|---|---|---|---|---|---|---|
| Return address overwrite (stack) | - | ✓ | - | - | - | - | ◐ | - | - | - |
| Indirect call to arbitrary target | ✓ | - | - | - | - | - | - | - | - | ✗ |
| Function pointer corruption to a different type | ✓ | - | - | - | - | - | - | - | - | - |
| Function pointer corruption to the same type | - | - | - | - | - | - | - | - | - | - |
| Heap UAF access | - | - | ◐ | - | - | - | - | - | - | - |
| Heap OOB write past boundary | - | - | ◐ | - | - | - | - | - | - | - |
| Freelist pointer forgery | - | - | - | - | ✓ | - | - | - | - | - |
| Predictable slab layout | - | - | - | - | - | ◐ | - | - | - | - |
| Kernel base known without leak | - | - | - | ◐ | - | - | - | - | - | - |
Kernel .data reached through a fixed linear map | - | - | - | - | - | - | - | - | - | - |
| Stack overflow past the end of the stack | - | - | - | - | - | - | - | ✓ | - | - |
| Stack object placement prediction | - | - | - | - | - | - | - | - | ◐ | - |
| Kernel ROP chain | - | ✓ | - | ◐ | - | - | ◐ | - | - | - |
| Kernel JOP gadget selection | ◐ | - | - | ◐ | - | - | - | - | - | ✗ |
| Forging a kernel data pointer | - | - | - | - | - | - | - | - | - | - |
| Data-only write with an existing read/write primitive | - | - | - | - | - | - | - | - | - | - |
Key: ✓ addressed on GKI · ◐ conditional or partial · ✗ could address it, but not enabled on GKI · - does not address it
The “PAC or SCS” column means the kernel runs exactly one: PAC with PAC hardware, dynamic SCS without it.
| ◐ Mitigation | Limit |
|---|---|
| MTE | Needs hardware and bootloader enablement |
| KASLR | Needs boot chain entropy; offers nothing after a leak |
| KSTACK | Needs a command line parameter |
| SLUB-R | Shuffles only fresh slabs |
| SSP | Catches only linear overflows in instrumented functions |
| kCFI | Limits JOP only at compiler-checked indirect calls |
Userspace differs: BTI restricts JOP and PAC signs return addresses only on capable hardware and in binaries built for them, SCS is opt-in per component, and MTE is opt-in per process.
Next Post
The next post puts these mitigations to work against a real Android vulnerability, tracing which ones an exploit must bypass at each step.
References
- android15-6.6-lts kernel source: primary source for kernel code references in this post
- android16-6.12-lts gki_defconfig: 6.12 GKI configuration
- Android common kernels: Android 16 kernel compatibility matrix
- Arm MTE on Android: MTE defaults and system components
- MTE bootloader support:
kasan=on/offboot flow - Arm Architecture Reference Manual for A-profile architecture: PACIASP/AUTIASP, IRG/STG/LDG, BTI instruction encodings
- Project Zero: Defeating KASLR by Doing Nothing at All: fixed arm64 linear map on Android
- TikTag: Breaking Arm’s Memory Tagging Extension with Speculative Execution: speculative MTE tag leakage on Pixel 8