Nebula Security
Overview

July 2026 Android

IonStack part III: Rooting Android 17 with GhostLock

Nebula Security
July 15, 2026
12 min read

Need something less technical? Take a quick look at our bug summary.

Read the bug summary

GhostLock (CVE-2026-43499) is a Linux kernel vulnerability found by Nebula Security that exists in every major distribution since 2011. After turning it into a stable privilege escalation and container escape and winning $92,337 in kernelCTF, we took one step further and used GhostLock to develop the world’s first public Android 17 root. This writeup covers the additional exploit techniques used to migrate the exploit for Android.

In the previous part, we discussed the root cause of GhostLock (CVE-2026-43499), how we reclaimed the ‘freed’ stack, faked a rt_mutex_waiter and got a constrained pointer write, and eventually got control flow hijack from inet6_protos and used DirtyMode to finish privilege escalation on Linux.

Since on Android Control Flow integrity (CFI) is enabled by default, we need to find another alternative to help us finish the last step of privilege escalation.

Besides, as we are now targeting ARM devices and KPTI is enabled, prefetch side channel is no longer easy to use, so we also need another approach to bypass KASLR.

And of course we also need to change our strategy to reclaim the stack.

If there is no CFI protection (like CFI_CLANG, ARM BTI, or Intel CET) enabled, getting arbitrary code execution after controlling a function pointer is a much easier job: RetSpill, Ret2BPFJIT, KEPLER, cpu_entry_area pivot, panic_on_oops disable… and regular ROP.

Backgrounds

(Kernel) Control Flow Integrity on Android

Before 2022, Android used a jump table based white list to check legit call/jump targets and protect CFI. This feature required LTO to be enabled, which introduced heavy compile overhead, and could not protect some flexible functions such as JITed BPF programs (there were no checks for those function calls at all).

In 2022, Android switched to the new Clang CFI feature based on function signature. It hashes the type of function arguments and return value and checks the target hash before each indirect call. This approach supports much more flexible targets and no longer depends on LTO.

The hash is baked in at compile time, emitted into a __cfi_<func> “preamble” that sits right before each function’s entry, and the caller checks the target’s hash before every indirect call.

The check itself is shown in the following figure:

CFI overview

The caller loads the hash sitting ahead of the callee and compares it at callsite. In the figure, proc_do_uuid and proc_dostring share the same argument and return value types, so they would have the same hash and CFI will allow the indirect call to any of them, while for example, commit_creds has a different prototype and would trap into report_cfi_failure().

With the latest CFI implementation on Android, we can only hijack the function pointer to another function that has the same signature, i.e. exact argument and return value types. In the following writeup, we swapped ashmem’s read_iter/write_iter for configfs’s, since they both are VFS handlers with type ssize_t (struct kiocb *, struct iov_iter *) (so they would have the same hash).

Can I forge the hash?

That said, the function hash is fixed for each build, so the attacker can forge a legit hash signature if they can spray executable memory in kernel. Which is in fact potentially feasible as unlike eBPF, cBPF program is not restricted by either unpriv_bpf_disabled sysctl or bpf() blacklist on SELinux. And bpf_jit_harden={1,2} really does little help (at least on x86), according to this kernelCTF writeup.

However, on x86, after 0c3e806ec0f9, all ~500,000 signatures will be randomized once by XORing a shared key during the boot time. This prevents the attacker from predicting the signature without an (arbitrary) kernel memory read.

Probe the address of (almost) arbitrary kernel objects

Accessing kernel data structures such as hash tables can take different numbers of cycles depending on their internal state (e.g., empty buckets versus collision chains). The initial NDSS work showed that an unprivileged process can amplify these software-level timing differences through controlled system calls and infer the state of a kernel data structure. When the targeted hash table folds the caller’s mm_struct pointer into its bucket index, timing the bucket traversal recovers the address of the current mm_struct.

Lukas’s follow-up post combines this timing leak with cross-cache reuse targeting specific objects such as msg_msg and pipe_buffer. Because mm_struct is allocated from the dedicated mm_cachep slab, its leaked address gives the location of the backing slab page. By freeing that page and reclaiming it as a target slab through allocator manipulation, an attacker can get the precise address of the targeted object.

Free (but limited) KASLR bypass with Linear Map

As Project Zero showed, commit 1db780bafa4c removed linear map randomization on arm64, so the base of physmap is no longer randomized. This allows us to access a rw map of kernel image (read only for original ro or rx memory) at fixed address, as shown in following figure.

Linear overview

Even if physmap is properly randomized (or physical ASLR is enabled), its base address can still be probed with KernelSnitch.

However, as the Linear Map is now mapped as non-executable, if we want to reuse some executable code, we will still need a separate KASLR bypass. So we still need the true KASLR slide.

Exploit Summary

  • GhostLock -> Leave a dangling rt_mutex_waiter in the waiter task’s pi_blocked_on.
  • Reclaim -> Use pselect to reclaim the waiter’s frame and fake a rt_mutex_waiter over it.
  • bootid -> Overwrite boot_id’s sysctl .data, read &nfulnl_logger back to leak KASLR slide.
  • ashmem -> Hijack ashmem’s fops with configfs handlers for a constrained kernel read-write.
  • pipe_buffer -> Escalate copy_{to,from}_user to unlimited page* full address read & write.
  • Get root -> Disable SELinux and patch cred struct to escape seccomp and become root.

Note that the physmap base is fixed, and the same GhostLock primitive was used twice, first to leak the KASLR slide in bootid, then to overwrite the ashmem fops.

Exploit overview

Exploit Details

Getting memory write primitive back

Recall the initial primitive from GhostLock. We can eventually write a pointer to an arbitrary (but constrained) address. To do so, we need to:

Reusing the stack

We still start with spraying controlled bytes at the same stack offset, while the syscall that helps us reclaim the stack frame is target-specific, since both the frame depth and a syscall’s reach shift as the kernel image changes. On the Pixel 10 (Android 17) we use pselect, which copies our fd_set bitmaps onto the kernel stack, right over the freed frame.

clone/setsockopt/keyctl and other syscalls with large controlled stack locals work the same way. Here are more that reclaim the frame in our open-sourced PoC code.

Over the reclaimed frame we forge the rt_mutex_waiter:

  • tree/pi_tree, rb nodes crafted so the erase operation will give us a write primitive.
  • task, set to &init_task through its physmap alias, so the chain walk’s task derefs are safe.
  • lock, pointing at a fake rt_mutex we spray into sk_buff data and locate with KernelSnitch.

Fake a waiter

Getting that fake waiter past its structural checks and pointer dereferences needs controlled kernel memory at a known address, the same role the CEA played on x86.

Since the CEA trick no longer holds on ARM, here we spray sk_buff data with sendmsg, a raw-byte elastic object, and locate it with KernelSnitch plus cross-cache reuse. We then place the fake rt_mutex that lock points at into the located sk_buff, to pass the walk’s checks on lock and make the dequeue’s rb-erase our one constrained write.

Hammer with a Nail

On Android, a data-only approach to LPE makes our life easier as we no longer need to deal with CFI. But now since all we get is a weak pointer write with lots of constraints, we look for a similar function-table hijack path, like we used in the Linux exploit.

Project Zero analyzed a modern in-the-wild Android exploit and shared the trick that overwrites ashmem’s file_operations with same-signature configfs handlers, turning its read/write into a constrained kernel read-write that CFI cannot tell apart.

However, reaching ashmem from an untrusted App has gotten harder over time:

  • Before SDK 29, /dev/ashmem could be opened directly and SELinux did not complain.
  • Apps targeting SDK 29 (Android 10) can no longer open /dev/ashmem directly, but for a while we could still dodge that by building against targetSdkVersion 28 or lower.
  • Now, even the old low targetSdkVersion trick is dead, but the untrusted App can still reach the driver by opening that device node directly under a per-boot name, /dev/ashmem<boot_id>.

On Android 17, we use /dev/ashmem<boot_id> to access ashmem, and hijack ashmem_misc.fops table in the same way, putting configfs’s read_iter/write_iter into it (Those handlers are live .text, so this step needs the KASLR slide we recover in the next section).

Ashmem arb rw overview

As the above figure shows, we can first use ASHMEM_SET_NAME to modify ashmem’s private_data, which will be later treated as a struct configfs_buffer* buffer in configfs’s handlers. After we overwrote the ashmem_misc.fops, a read(fd, addr, len) will use buffer->page as the target address, and a write(fd, addr, len) will use buffer->bin_buffer, which eventually gives us an arbitrary kernel memory read and write (It comes from copy_{from,to}_user, so it has few extra checks and a length limit. But still, good enough to finish LPE).

Can Rust rewrite save us?

Android has rewritten ashmem in Rust since Linux mainline dropped it in 5.18.

However, that rewrite only hardens ashmem’s own code, and it still plugs into the VFS through a C-style file_operations table which still sits in ordinary kernel memory.

So CFI bypass using ashmem is still effective even after Rust rewrite.

Leak KASLR, as we still need it

Now the only problem is that we know many kernel addresses, but none of them is executable. The fake fops has to point at the real configfs handlers in executable memory, and the linear-map alias is not executable.

We already have our constrained arbitrary write from GhostLock, so we look for a more suitable address to overwrite, hopefully one that leaks KASLR, maybe by clobbering a length or a data pointer.

After a long search we landed on /proc/sys/kernel/random/boot_id. Its sysctl handler proc_do_uuid formats the 16 bytes at the table’s .data pointer as a UUID string.

So if we use the constrained write to repoint that .data at a slot the kernel already filled with a live kernel pointer, reading boot_id prints that pointer back as a UUID. Decoding it and subtracting its known image offset gives the KASLR slide. Here that slot is the netfilter loggers[0][1], which holds &nfulnl_logger. The whole process is shown as follows:

BootID KASLR leak overview

This is the same idea as the sel_fs_type name-pointer overwrite Project Zero read out through /proc/self/mounts, just via boot_id and proc_do_uuid instead.

Final stage

Now that we have everything we need to replace ashmem’s function table and get an arbitrary kernel memory read and write, it’s time to finish the first Android 17 root!

One step further

As STATIC_USERMODEHELPER was enabled on Android, we need a few more steps instead of tricking usermode_helper into executing our backdoor directly as root.

Using a pipe_buffer (or a similar victim like a Page Table Entry) to read and write kernel memory is more convenient and has almost no checks. So we use KernelSnitch again to locate a pipe_buffer, then use the configfs write to overwrite the pipe_buffer.page and upgrade the read-write primitive to a fully arbitrary one.

On our target, VMEMMAP is fixed, so we can perform virtual address to struct page* translation directly. Even where a device randomizes it, we can just scan all of nearby memory to recover everything we need, as a failed physical read-write will not panic the kernel.

Patch Cred

After we get unlimited read and write, we can walk the task list from init_task to the child we spawned and read its cred. Besides zeroing the uid/gid set (real, effective, saved, fs), we also clear securebits, set all five capability sets to CAP_FULL, and clear the task’s seccomp mode, filter, TIF_SECCOMP, and no_new_privs, so the root child breaks out of the app sandbox entirely.

SELinux bypass

After we locate the task_security_struct via cred’s security pointer, we modify its SELinux osid and sid to the kernel sid. We also write 0 into selinux_enforcing directly through the physical read-write. This makes SELinux globally permissive, so a violating access is merely logged and allowed to proceed.

selinux_enforcing is only writable when the kernel was built with CONFIG_SECURITY_SELINUX_DEVELOP. Otherwise the enforcing state is fixed at build time. Either way, our child can still escape seccomp and get root from the controlled cred struct.

Appendix

The full exploit code can be found in our open source security research project, CyberMeowfia.

Mitigations

Please check the mitigation discussion section from our part II blog.
Note that the patch v1 has introduced CVE-2026-53166 (local DoS caused by NPD), which was fixed by 40a25d59e85b.

RANDOMIZE_KSTACK_OFFSET

If CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT is enabled on the target device, the exploit success rate will drop to 1/8 or less (if we can reclaim every possible stack frame and fill them with rt_mutex_waiter, otherwise less than ~1.5% in the worst case).

Kernel Integrity Check

Some vendors harden the kernel further to make the attack stay hard even after arbitrary kernel memory read and write. Samsung KNOX’s Real-time Kernel Protection (RKP) runs a monitor at EL2 that keeps cred, task_security_struct, and the SELinux state read-only to the EL1 kernel. Its security hooks also check that the live cred and its task_security_struct sit in the protected slabs, so a cred forged in normal memory is rejected.

That said, it is not undefeatable once an attacker has unlimited arbitrary read and write. For example:

Open source community projects

We are glad to see the community has ported GhostLock to broader devices, and we list a few projects here for reference.