Skip to content
Go back

Android 16 Kernel Memory Protections: From CONFIG Flags to Machine Code

Published:  at  07:00 PM

CONFIG_ARM64_BTI=y and # CONFIG_ARM64_BTI_KERNEL is not set sit 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.

MitigationCONFIGSinceAndroid 16 statusExploit Primitive Affected
kCFICONFIG_CFI_CLANGLinux 6.1 (kCFI); CFI since Android 9On by defaultIndirect call hijacking
PAC userCONFIG_ARM64_PTR_AUTHLinux 5.0On where hardware has it (ARMv8.3+); the binary must be built with branch protectionReturn address forgery
PAC kernelCONFIG_ARM64_PTR_AUTH_KERNELLinux 5.7; in android12-5.10On where hardware has it (ARMv8.3+); return addresses onlyReturn address forgery
MTECONFIG_ARM64_MTELinux 5.10 / Android 12Opt-in: needs MTE hardware plus a manifest entry, system property, or Advanced ProtectionHeap UAF / OOB detect
KASAN-HWCONFIG_KASAN_HW_TAGSLinux 5.11; in android12-5.10Bootloader-controlled on Android, on MTE hardwareKernel heap tag faults
SCSCONFIG_SHADOW_CALL_STACKAndroid 10; Linux 5.8 mainlineKernel: only on CPUs without PAC (dynamic SCS). User: opt-in per componentReturn address overwrite
KASLRCONFIG_RANDOMIZE_BASEAndroid 8; Linux 4.6 on arm64Depends on the boot chain (seed or RNDR)Kernel base known without leak
SLUB RandomCONFIG_SLAB_FREELIST_RANDOMLinux 4.8On by defaultPredictable slab alloc order
SLUB HardenedCONFIG_SLAB_FREELIST_HARDENEDLinux 4.14On by defaultFreelist pointer forgery
SSPCONFIG_STACKPROTECTOR_STRONGLinux 3.14On by defaultStack linear overflow to ret-addr
VMAP StackCONFIG_VMAP_STACKLinux 4.9; 4.14 on arm64On by defaultStack overflow past the end of the stack
KSTACK OffsetCONFIG_RANDOMIZE_KSTACK_OFFSETLinux 5.13Off by default: needs randomize_kstack_offset=onStack object placement prediction
BTI userCONFIG_ARM64_BTILinux 5.8On where hardware has it (ARMv8.5+); the binary must be BTI-markedArbitrary JOP landing pads (indirect BR targets)
BTI kernelCONFIG_ARM64_BTI_KERNELLinux 5.8Not enabled on 6.6 or 6.12None on GKI

kCFI - Kernel Control Flow Integrity

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 PrimitiveBlocked?Condition
Overwrite kernel function pointer to arbitrary addressYesTarget lacks matching type hash
Corrupt an ops-table pointer to reach a fake tableNo, if its entries are validkCFI 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 callbackYesType hash comparison fails, BRK fires
Jump to gadget inside existing function bodyMostlyGadget 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)NoSame 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

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)

Kernel key lifecycle (source: asm_pointer_auth.h)

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_FPACWith FEAT_FPAC (optional, SoC-dependent)
When it faultsWhen the bad pointer is usedAt the AUT* instruction itself (exception class 0x1C)
User-spaceSIGSEGV (SEGV_MAPERR) when the CPU fetches from the corrupted addressdo_el0_fpac() -> SIGILL (ILL_ILLOPN) at the AUTIASP instruction
Kernel-spaceInstruction abort -> die_kernel_fault() -> Oops, which CONFIG_PANIC_ON_OOPS=y turns into a panicdo_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 PrimitiveBlocked?Bypass Condition
Overwrite return address on the stackYesForged value lacks valid PAC
Redirect indirect call via function pointerNoPAC does not protect kernel call targets; kCFI covers those
Forge arbitrary kernel pointerNoThe kernel builds with pac-ret only; data and function pointers are not signed
Leak signed LR → reuse its signaturePartialSignature is bound to SP; it can be replayed in a frame with the same SP
Forge a signature after leaking the keysNoKeys 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 pointerYesAuthentication 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

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:

ModeFlagFault behaviorSignal
SYNCPR_MTE_TCF_SYNCAborts immediately on mismatchSIGSEGV / SEGV_MTESERR (exact address)
ASYNCPR_MTE_TCF_ASYNCDeferred to next kernel entrySIGSEGV / SEGV_MTEAERR (no address)
ASYMMboth flags setReads sync, writes asyncMix. 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

FileEntry pointRole
mm/kasan/hw_tags.ckasan_init_hw_tags()Boot-time MTE mode selection (sync/async/asymm)
mm/slub.ckmalloc, kmem_cache_allocSLUB 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 PrimitiveBlocked?Condition
Heap UAF: access after kfreeProbabilisticFreed 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 boundaryProbabilisticRandom 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 objectProbabilisticTag mismatch at the spray destination boundary, with the same limits
Kernel heap OOB (KASAN_HW_TAGS)Probabilistic, if kernel MTE is onSlab objects are tagged on allocation; kernel heap accesses checked, with the same limits
Kernel access through a forged 0xFF top-byte pointerNoTCMA1 makes tag 0xF a match-all
Bypass by learning the tag firstNoA 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

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 PrimitiveBlocked?Condition
ROP chains that corrupt saved return addresses on the stackYes, where SCS runsSCS does not block ROP/JOP that does not rely on overwriting saved LR
Overwrite the saved LR on the stackYes, where SCS runsLR on the stack is a dead copy; the shadow copy at x18 governs the return
Overwrite x18 or the shadow stackPartiallyx18 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

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)

PhaseWhat happens
1. BootloaderSupplies 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_switchedSaves 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 PrimitiveBlocked?Bypass Condition
Hardcoded kernel .text addresses or gadget chainsYesAddresses differ every boot: offsets from the base are constant, but the base is unknown
Read addresses from /proc/kallsymsNo, not by KASLR itselfkptr_restrict=2 closes this separately
Locate the kernel with an info leakNoA pointer leak discloses the base; KASLR provides no protection after a leak
ROP chain once kernel base is leakedNoEntire 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 mapNo, on PARange of 40 bits or moreThe 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

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 PrimitiveBlocked?Bypass Condition
Forge freelist pointer to redirect next allocationYesForged value must satisfy ptr ^ s->random ^ swab(addr)
Linear OOB into freelist pointer fieldYesOOB write lands on encoded value; decode produces garbage
Predict allocation address for spray placementYes (partially)Fresh slabs are shuffled; partial slabs are not reshuffled
Leak s->random then forgeNoOne (encoded, ptr, addr) triple directly reveals s->random

Stack Protector

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

SymbolLocationNotes
task_struct::stack_canaryinclude/linux/sched.hunsigned long; guarded by #ifdef CONFIG_STACKPROTECTOR; per-task canary value
__stack_chk_guardarch/arm64/include/asm/stackprotector.hGlobal fallback; not used at runtime on arm64 when CONFIG_STACKPROTECTOR_PER_TASK is active
__stack_chk_fail()kernel/panic.cCalls panic(); never returns

What This Protection Prevents

Exploit PrimitiveBlocked?Bypass Condition
Linear stack buffer overflow reaching return addressYesCanary must be leaked or bypassed
Local variable overflow reaching saved frame pointerYes (_STRONG covers arrays)Canary is above all covered locals
Heap-based return address overwriteNoCanary only guards the current stack frame
Canary leak via info-leak + reuseNoIf current->stack_canary is leaked before corruption, the overwrite can preserve it

VMAP Stack - Virtually-Mapped Kernel Stacks

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 PrimitiveBlocked?Condition
Stack overflow writing adjacent kernel objectYesGuard 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 primitiveYesCannot overflow silently; fault is immediate
Linear overflow into a neighboring stackYesVirtual 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 stackYesGuard page fault triggers kernel panic via stack overflow detection

KSTACK Offset Randomization

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 PrimitiveBlocked?Bypass Condition
Predict stack variable address across syscallsYes, when enabledOffset changes every syscall; 6 bits entropy per call
Stack-based info leak assuming fixed layoutYes, when enabledLayout shifts between calls
Stack pivot relying on known stack frame offsetYes, when enabledOffset unknown without a fresh leak each syscall
Overflow into adjacent stack objectNoVMAP_STACK guard page handles overflows; KSTACK_OFFSET only moves the frame

BTI - Branch Target Identification

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:

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.

StateWhat 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 PrimitiveBlocked (userspace)?Blocked (kernel)?
JOP chain using mid-function gadgetsYes: must land on BTI j or BTI jcNo: BTI_KERNEL not enabled
ROP gadget at non-function addressNo: RET is unrestricted by BTINo
BR x16 to arbitrary addressYes: target needs BTI c, BTI j or BTI jcNo
Kernel function pointer overwrite to arbitrary .textN/ANot 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:

CONFIGEffect
CONFIG_ARM64_PAN=y, CONFIG_ARM64_SW_TTBR0_PAN=yStops 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=yOn 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=yExtends 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=yZeroes 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=yBounds-checks copy_to_user/copy_from_user against slab, stack and vmalloc objects, and rejects kernel text
CONFIG_FORTIFY_SOURCE=yAdds compile-time and runtime bounds checks to string and memory functions
CONFIG_BUG_ON_DATA_CORRUPTION=yTurns 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 setKeeps same-size slab caches separate
CONFIG_SHUFFLE_PAGE_ALLOCATOR=yCompiled 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=ySampled 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=yTurns 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 PrimitivekCFIPAC or SCSMTEKASLRSLUB-HSLUB-RSSPVMAPKSTACKBTI-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.

◐ MitigationLimit
MTENeeds hardware and bootloader enablement
KASLRNeeds boot chain entropy; offers nothing after a leak
KSTACKNeeds a command line parameter
SLUB-RShuffles only fresh slabs
SSPCatches only linear overflows in instrumented functions
kCFILimits 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