Android kernel LPEs are usually the same three stories with new CVEs on the cover: a wait-queue object outlives the userspace memory that named it, a perf event outlives the mmap that backed it, or a userspace pointer is checked once and used twice. This note stays at that invariant level. There is no Android kernel exploit, no futex requeue recipe, no perf_event_attr payload, no get_user gadget chain.
The lab at the bottom is userspace: a file TOCTOU analog of “check, then use”, and an ASAN-visible stack copy. It is not a kernel PoC.
What “class” means here
An untrusted app reaches the Linux syscall/ioctl surface. A bug in the kernel (or a vendor module) breaks an invariant the rest of the kernel assumed. If the break is a use-after-free, overlapping object, or confused user/kernel copy, the rest of the sandbox is a policy on a process the kernel no longer considers untrusted.
1untrusted app
2 → syscall / ioctl / binder
3 → kernel object lifetime (the invariant)
4 → credentials, maps, SELinux state
I do not need the CVE number to audit a new driver: I need the invariant.
Futex-era invariant
A futex is a 32-bit userspace word plus a kernel wait queue keyed by that address. The kernel sometimes also walks a robust list the thread advertised, so it can wake waiters if the owner dies.
Invariant the code wants:
While this wait-queue node is hashed against
uaddr, the calling task still owns the userspace page thatuaddrpoints at, and the node is only unhashed by the same task’s exit or wake path.
What historical CVEs broke, without the patch diff:
- The wait-queue node was still hashed after the userspace mapping was gone (UAF on the kernel node, or a wake against a reused address).
- The robust-list walk trusted a userspace-linked list whose next pointer was mutated during the walk.
- Requeue moved a waiter from one
uaddrto another without holding both hashes in an order that excluded a parallel unhash.
Reachability is the reason this class mattered on phones: FUTEX_WAIT / FUTEX_WAKE are what pthread_mutex becomes. An ordinary app already calls them. The mitigation story is “patch the lifetime”, plus later sysctl locks and the usual KASLR/CFI tax. None of that is a how-to.
What I write down when I read a new futex CVE: which object was hashed, which path unhashed it, which path still had a pointer. If those three lines are not in the advisory, the advisory is incomplete.
perf_event-era invariant
perf_event_open returns an fd. The fd can be mmap’d (ring buffer), ioctl’d (enable/disable/period), and closed. The kernel object is an perf_event with a lifetime tied to that fd and to CPU contexts that still have it scheduled.
Invariant:
No CPU still has this event scheduled, and no mmap still points at its ring, after the fd’s last reference is dropped.
Broken forms:
- Integer overflow / wrap in buffer-size accounting, so the mmap was smaller than the kernel believed.
- Enable vs close vs munmap races: one CPU writes the ring after the pages were reused.
- Attribute fields accepted from userspace that selected code paths meant for privileged counters.
Android’s practical control is not “understand every attr bit”. It is: untrusted apps should not get perf_event_open (perf_event_paranoid, seccomp, sepolicy). If a build still exposes it to untrusted_app, that is the finding, even before a CVE.
I do not document attr structs that hit the old races.
get_user / put_user / copy_* invariant
The primitive is: a syscall takes a userspace pointer, the kernel must read or write that memory. get_user / put_user / copy_from_user / copy_to_user exist so the access is fault-safe and (with PAN) distinct from kernel accesses.
Invariant:
The pointer is checked (in-range, correct size, still the same object) at the moment of the copy, not at some earlier moment after which userspace can replace the page or the length.
Broken forms:
- TOCTOU:
access_ok/ a length check, then a sleep, then a raw copy of the old pointer. - Wrong-sized accessor (
get_userof 4 bytes into a 8-byte field, or the reverse). - Driver
ioctlthatcopy_from_users a header, trustsheader.len, then copiesheader.lenbytes from a second pointer with no cap.
Vendor GPU/camera ioctls are where this class keeps returning. Hardened usercopy and PAN raise the cost of using a confused pointer; they do not fix a driver that copies len from userspace without a max.
Userspace analog: file TOCTOU + ASAN copy
Kernel get_user TOCTOU is “validate the pointer, drop locks, use it again”. The userspace rhyme is “lstat the path, then open the same path”. I am not claiming this is a kernel exploit. It is the same shape, so the lab has something to compile.
1/* toctou_lab.c — userspace analog. No syscalls into Android kernel internals. */
2#include <errno.h>
3#include <fcntl.h>
4#include <stdio.h>
5#include <stdlib.h>
6#include <string.h>
7#include <sys/stat.h>
8#include <unistd.h>
9
10static int is_safe_path(const char *path) {
11 struct stat st;
12 if (lstat(path, &st) != 0) return 0;
13 if (!S_ISREG(st.st_mode)) return 0;
14 if (st.st_uid != getuid()) return 0;
15 return 1;
16}
17
18/* Check, then open. Between the two, another process may replace `path`. */
19int read_if_safe(const char *path, char *buf, size_t n) {
20 if (!is_safe_path(path)) return -1;
21 int fd = open(path, O_RDONLY | O_CLOEXEC);
22 if (fd < 0) return -1;
23 ssize_t r = read(fd, buf, n - 1);
24 close(fd);
25 if (r < 0) return -1;
26 buf[r] = '\0';
27 return (int)r;
28}
29
30/* Separate planted bug: ASAN sees this, TSAN/TOCTOU does not. */
31void copy_unbounded(const char *s) {
32 char small[16];
33 strcpy(small, s);
34 (void)small[0];
35}
36
37int main(int argc, char **argv) {
38 if (argc < 3) {
39 fprintf(stderr, "usage: %s <path> <copy-arg>\n", argv[0]);
40 return 2;
41 }
42 char buf[64];
43 int n = read_if_safe(argv[1], buf, sizeof buf);
44 fprintf(stderr, "read_if_safe rc=%d errno=%d len=%d prefix=%.4s\n",
45 n, errno, n > 0 ? n : 0, n > 0 ? buf : "");
46 copy_unbounded(argv[2]);
47 return 0;
48}
Build the safe-looking file, then a racer that swaps it. Lab only, same uid, tmpfs.
1cc -O0 -g -fsanitize=address -o toctou_lab toctou_lab.c
2
3mkdir -p /tmp/toctou_lab
4echo 'lab_ok_payload' > /tmp/toctou_lab/good
5echo 'lab_other' > /tmp/toctou_lab/other
6ln -sf good /tmp/toctou_lab/link
Racer (shell, not a kernel primitive):
1# racer.sh — swap the name `link` between a regular file and a different file
2while true; do
3 ln -sfn good /tmp/toctou_lab/link
4 ln -sfn other /tmp/toctou_lab/link
5done
1# terminal A
2sh racer.sh
3
4# terminal B — many runs; some see `good`, some see `other`
5for i in $(seq 1 200); do
6 ./toctou_lab /tmp/toctou_lab/link lab_xxxx
7done 2>&1 | grep prefix | sort | uniq -c
1 114 read_if_safe rc=15 errno=0 len=15 prefix=lab_
2 86 read_if_safe rc=10 errno=0 len=10 prefix=lab_
Both prefixes are lab_ because both lab files start that way. Lengths 15 vs 10 are the tell: lstat said “regular file, my uid” on one inode, open followed a different symlink target. ASAN is silent. TOCTOU is a logic bug; AddressSanitizer does not catch it.
open(path, O_RDONLY|O_NOFOLLOW) or openat + O_PATH + fstat on the fd (check the fd, not the name) closes this analog. fstat after open is the userspace version of “copy from the pointer you have now”.
ASAN on the planted copy
Same binary, argv[2] longer than 16:
1./toctou_lab /tmp/toctou_lab/good $(python3 -c 'print("A"*40)')
1read_if_safe rc=15 errno=0 len=15 prefix=lab_
2=================================================================
3==7124==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED]
4WRITE of size 41 at 0x[REDACTED] thread T0
5 #0 0x[REDACTED] in strcpy
6 #1 0x[REDACTED] in copy_unbounded toctou_lab.c:36
7 #2 0x[REDACTED] in main toctou_lab.c:48
8Shadow bytes around the buggy address:
9 00 00 00 00[f1]f1 f1 f1 00 00[f3]f3
That is the complete repro I want in a write-up: source line, write size 41, 16-byte slot, shadow. It is not an Android root.
Without ASAN the same function is a SIGSEGV / stack smash depending on cookie. I keep the ASAN build for the note.
1$ cc -O0 -g -o toctou_plain toctou_lab.c
2$ ./toctou_plain /tmp/toctou_lab/good $(python3 -c 'print("A"*80)')
3# SIGSEGV or abort on canary — not recorded as a payload
Cross-cutting controls (defenders)
| Control | What it actually does |
|---|---|
| SELinux enforcing | Constrains a userspace process. A full kernel compromise writes its own policy. |
| seccomp-bpf | Shrinks the syscall list that can hit the class. |
perf_event_paranoid + sepolicy | Removes the perf class from untrusted_app if someone left it open. |
| PAN / hardened usercopy / CFI | Raise cost after a confused pointer exists. |
| ASB lag | The operational metric. Class papers do not patch devices. |
Enterprise: minimum security patch level via MDM, no sideload on work profiles. Hunt for “unexpected root” IOCs if you have them; many LPEs leave none in userland logs.
What this post will not do
- No futex requeue sequence, no robust-list poison diagram that is a recipe.
- No
perf_event_attrfield list that hits a named CVE. - No Android
ioctlcommand numbers for a vendor GPU with a copy-size bug. - No kernel ROP, no
commit_credsdiscussion as a method.
If a reader needs those, they need a closed lab and the patch commit, not this page.
Commands appendix
1cc -O0 -g -fsanitize=address -o toctou_lab toctou_lab.c
2./toctou_lab /tmp/toctou_lab/good lab_xxxx
3./toctou_lab /tmp/toctou_lab/good $(python3 -c 'print("A"*40)')
4# racer: ln -sfn between two uid-owned files on the same path