Why GPU attack surfaces keep paying off
GPU drivers dominate Android exploitation for a straightforward reason: they are reachable from the untrusted app sandbox on every device, and they contain some of the most complex memory-management code in the kernel. The Qualcomm Adreno and ARM Mali drivers cover the vast majority of Android hardware, so a single driver bug class can scale across many models. Five of the seven Android 0-days detected as exploited in the wild in 2021 targeted GPU drivers, and CVE-2021-39793, disclosed in March 2022, continued that trend.
The deeper appeal is that GPU drivers handle elaborate CPU-GPU memory sharing. Logic errors in that code often yield arbitrary read/write of physical memory or defeat memory protections outright. Because the primitive is granted through the driver's intended functionality, it is invisible to mitigations aimed at control-flow hijacking. Prior work by Guang Gong and Ben Hawkes exploited such logic flaws in GPU opcode handling to obtain arbitrary memory access.
Root cause: a fence reference race
CVE-2022-22057 is a use-after-free in the Qualcomm GPU driver's kgsl_timeline code, introduced when the msm 5.4 kernel added the timeline feature and associated ioctls. The bug lives in how the timeline's fences list manages the lifetime of dma_fence objects. Notably, the list does not hold its own reference to each fence. Instead, a custom release function, timeline_fence_release, removes a fence from timeline->fences before freeing it:
static void timeline_fence_release(struct dma_fence *fence)
{
...
spin_lock_irqsave(&timeline->fence_lock, flags);
/* If the fence is still on the active list, remove it */
list_for_each_entry_safe(cur, temp, &timeline->fences, node) {
if (f != cur)
continue;
list_del_init(&f->node); //<----- 1. Remove fence
break;
}
spin_unlock_irqrestore(&timeline->fence_lock, flags);
...
kgsl_timeline_put(f->timeline);
dma_fence_free(fence); //<------- 2. frees the fence
}
The removal is correctly serialized by timeline->fence_lock. The problem is that IOCTL_KGSL_TIMELINE_DESTROY can race with that release path. When destroying a timeline, the driver copies the fences into a local list temp, removes them from timeline->fences, and then bumps each fence's refcount to keep it alive while it is in temp:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock(&timeline->fence_lock); //<------------- a.
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
dma_fence_get(&fence->base);
list_replace_init(&timeline->fences, &temp);
spin_unlock(&timeline->fence_lock);
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) { //<----- b.
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
If a fence's refcount has already dropped to zero, but timeline_fence_release has not yet executed its removal from timeline->fences, kgsl_ioctl_timeline_destroy will still move that fence into temp and increment its reference. That increment is ineffective: the release path is already past the point of no return and will free the object regardless of the new reference. If the destroy ioctl then accesses that fence after the free, the use-after-free fires.
Widening the race window
Triggering the race requires ordering the destroy and release paths so that the refcount reaches zero while the kernel holds timeline->fence_lock in kgsl_ioctl_timeline_destroy. By adding a large number of dma_fence objects to the timeline, you can inflate the time the destroy path spends moving them to temp. Dropping the last refcount of the final fence on a second thread during that window makes timeline_fence_release block on the lock until the destroy path has finished copying. By then, all fences are in temp, the timeline list is empty, and the release path proceeds to free the object that the destroy path is about to use.
The Mitigation Stack on Modern Samsung Kernels
While triggering the vulnerability is straightforward, exploiting it on current hardware is not. The test device, a Samsung Galaxy Z Flip3 running a 5.x kernel, ships with a more comprehensive mitigation set than most Google Pixel devices—and considerably more than older 4.x devices. The latter frequently lack kCFI (Kernel Control Flow Integrity) and automatic variable initialization, while 5.x devices enable both. Samsung additionally layers on RKP (Realtime Kernel Protection), which restricts writes to kernel code, page tables, and process credentials. Those protections collectively make arbitrary code execution difficult even after an attacker has achieved arbitrary memory read and write. The following sections examine how each mitigation shapes an exploit for this bug.
kCFI Constrains the Callback Primitive
kCFI is the most labor-intensive mitigation to defeat, particularly when combined with Samsung's hypervisor. It restricts indirect call targets to functions with matching signatures, preventing wholesale control-flow hijacking. In this vulnerability, after dma_fence is freed, the kernel invokes dma_fence_signal_locked:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base); //<---- free'd fence is used
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
That function then calls cur->func, a function pointer stored in the fence->cb_list list:
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) {
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
...
}
Absent kCFI, an attacker could replace the freed fence with a fully fake object, controlling both cb_list/func and the function's arguments. Combined with a separate bug for KASLR bypass, this would yield a trivial arbitrary-call primitive. kCFI blocks that route by forcing func to have the type dma_fence_func_t, which dramatically narrows usable targets.
Samsung's older JOPP (jump-oriented programming prevention) checks were relatively easy to evade, but kCFI has no comparable shortcut. One established bypass technique pairs a double free with freelist corruption and the Kernel Space Mirroring Attack (KSMA), as demonstrated in previous work like "Three dark clouds over the Android kernel" and "Typhoon Mangkhut." This bug does offer a double free when dma_fence_put executes after the initial free:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base); //<----- free'd fence can be freed again
}
spin_unlock_irq(&timeline->lock);
...
}
That call decrements the refcount of the fake fence. Setting it to one triggers a second free. However, KSMA remains out of reach because it requires overwriting swapper_pg_dir, which the Samsung hypervisor protects.
Variable Initialization Frustrates Partial Spray
Since Android 11, kernels can enable automatic variable initialization through build flags. The Z Flip3's configuration includes such flags:
# Memory initialization
#
CONFIG_CC_HAS_AUTO_VAR_INIT_PATTERN=y
CONFIG_CC_HAS_AUTO_VAR_INIT_ZERO=y
# CONFIG_INIT_STACK_NONE is not set
# CONFIG_INIT_STACK_ALL_PATTERN is not set
CONFIG_INIT_STACK_ALL_ZERO=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set
# end of Memory initialization
While many 4.x devices omit this feature, 5.x kernels generally include it. Beyond mitigating uninitialized-memory bugs, this feature blocks partial object replacement—spraying only the first bytes of a freed object while preserving the rest. That technique, described in Jann Horn's "Mitigations are attack surface, too," no longer works. As this exploit relies on heap spraying to reclaim the freed dma_fence, the constraint materially reduces available spray strategies.
The Delayed Free Problem
kfree_rcu is not a mitigation, but it behaves like one. The freed fence object is not released immediately with kfree; dma_fence_free schedules an RCU-delayed free. This adds uncertainty about when the object is actually reclaimed, similar to the quarantine mechanism in Scudo, the default Android userspace allocator. A similar delayed-free scheme was proposed (and rejected) for the Linux kernel.
Delayed freeing complicates both object replacement and tight race windows. However, techniques from "Racing against the clock—hitting a tiny kernel race window" and "Exploiting race conditions on [ancient] Linux" can widen any race window enough to accommodate the delay. In practice, this exploit performs object replacement under kfree_rcu twice, the second time without knowing which CPU executes the free. Even unoptimized, the exploit achieves roughly 70% reliability on the test device. The uncertainty of the CPU core accounts for most of the failures; the delayed free itself is less problematic when scheduler manipulation primitives are available.
RKP Raises the Bar for Privilege Escalation
Samsung RKP enforces write protection on key memory regions, preventing direct credential overwrites to gain root, protecting SELinux state, and shielding kernel code and page tables. Nonetheless, arbitrary read/write—even under those restrictions—opens alternative paths. SELinux rules can be modified by corrupting the avc cache, as demonstrated in earlier Samsung exploits, and root privileges can be obtained by hijacking existing root processes. Here, RKP chiefly operates in tandem with kCFI to prevent calls to arbitrary functions.
The bug discussed here is exploited with the full mitigation stack enabled.
Building the exploit primitives
The bug is a garden-variety use-after-free: a race condition yields both an arbitrary function call and a double free. To keep the discussion grounded, I'll use it to show how kernel mitigations shape the path from a UAF to a working exploit.
Populating the fence list
The vulnerability needs dma_fence objects on a kgsl_timeline's fences list, with their refcounts dropped while the timeline is destroyed. Two ioctls can add fences there:
long kgsl_ioctl_timeline_fence_get(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
timeline = kgsl_timeline_by_id(device, param->timeline);
...
fence = kgsl_timeline_fence_alloc(timeline, param->seqno); //<----- dma_fence created and added to timeline
...
sync_file = sync_file_create(fence);
if (sync_file) {
fd_install(fd, sync_file->file);
param->handle = fd;
}
...
}
IOCTL_KGSL_TIMELINE_FENCE_GET allocates a dma_fence via kgsl_timeline_fence_alloc, adds it to the timeline, and hands the caller a sync_file descriptor. Closing that descriptor drops the fence's refcount to zero.
The other route is IOCTL_KGSL_TIMELINE_WAIT:
long kgsl_ioctl_timeline_wait(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
fence = kgsl_timelines_to_fence_array(device, param->timelines,
param->count, param->timelines_size,
(param->flags == KGSL_TIMELINE_WAIT_ANY)); //<------ dma_fence created and added to timeline
...
if (!timeout)
ret = dma_fence_is_signaled(fence) ? 0 : -EBUSY;
else {
ret = dma_fence_wait_timeout(fence, true, timeout); //<----- 1.
...
}
dma_fence_put(fence);
...
}
This creates fences with kgsl_timelines_to_fence_array and adds them to the timeline. If a timeout is set, the call enters dma_fence_wait_timeout (path 1), blocking until the timeout or an interrupt. When that returns, dma_fence_put drops the refcount to zero. So a large timeout leaves a fence on the timeline that an interrupt can free.
Although IOCTL_KGSL_TIMELINE_FENCE_GET looks simpler, closing the sync_file introduces enough overhead to make the free timing unreliable. The exploit instead uses IOCTL_KGSL_TIMELINE_FENCE_GET to add persistent fences that pad timeline->fences, enlarging the race window. The final fence — the one actually exploited — comes from IOCTL_KGSL_TIMELINE_WAIT and is released by sending an interrupt to that thread.
Forcing a wider race
The core race requires dropping a fence's refcount inside the first window in the code below:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
//BEGIN OF FIRST RACE WINDOW
spin_lock(&timeline->fence_lock);
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
dma_fence_get(&fence->base);
list_replace_init(&timeline->fences, &temp);
spin_unlock(&timeline->fence_lock);
//END OF FIRST RACE WINDOW
//BEGIN OF SECOND RACE WINDOW
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
//END OF SECOND RACE WINDOW
...
}
Filling timeline->fences with many persistent fences makes hitting that window easy. But the rest of the bug — the code below, plus the object replacement — must finish before the second window closes:
spin_lock_irqsave(&timeline->fence_lock, flags);
list_for_each_entry_safe(cur, temp, &timeline->fences, node) {
if (f != cur)
continue;
list_del_init(&f->node);
break;
}
spin_unlock_irqrestore(&timeline->fence_lock, flags);
trace_kgsl_timeline_fence_release(f->timeline->id, fence->seqno);
kgsl_timeline_put(f->timeline);
dma_fence_free(fence);
A spin_lock delays this code until the first window ends, but by then timeline->fences is empty, so the loop runs quickly. The problem is dma_fence_free, which uses kfree_rcu; the actual free is delayed, so replacing the freed fence before the second window ends is impossible without controlling the scheduler. The technique, drawn from "Exploiting race conditions on [ancient] Linux," exploits preemption timing.
Linux gives each task a fair CPU share and can preempt a running task to schedule another. Tasks can also voluntarily yield (e.g., waiting on I/O or calling sched_yield()). Preemption can happen inside ioctl calls, except in critical regions like spinlock holders. CPU affinity and scheduling priorities make this predictable.
Two tasks pinned to the same CPU — one SCHED_NORMAL, one SCHED_IDLE — can be orchestrated as follows:
- The
SCHED_NORMALtask blocks on a syscall (e.g., reading an empty pipe), voluntarily yielding so theSCHED_IDLEtask runs. - Data written to the pipe wakes the
SCHED_NORMALtask, which preempts the idle one due to priority. - The
SCHED_NORMALtask then runs a busy loop, keeping theSCHED_IDLEtask suspended.
Applied to the exploit, the sequence is:
- A thread runs
IOCTL_KGSL_TIMELINE_WAIT, adding adma_fenceto a timeline. It's pinned toSPRAY_CPUwith a large timeout, then idles waiting for an interrupt. - A
SCHED_NORMALtask onDESTROY_CPUblocks on an empty pipe, letting a lower-priority task run there. - A
SCHED_IDLEtask onDESTROY_CPUrunsIOCTL_KGSL_TIMELINE_DESTROYon that timeline. - An interrupt to the first task frees the
dma_fencewhileIOCTL_KGSL_TIMELINE_DESTROYexecutes within the first race window. - Data written to the pipe wakes the
SCHED_NORMALtask, which preempts theSCHED_IDLEone mid-destroy and enters a busy loop. - With the destroy paused, there's time for
kfree_rcuto finish, the fence to be freed and replaced. The destroy then resumes, operating on the fake object.
Preemption can't occur while a spinlock is held, so IOCTL_KGSL_TIMELINE_DESTROY is only interruptible between spinlocks:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
spin_lock(&timeline->fence_lock);
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
...
spin_unlock(&timeline->fence_lock);
//Preemption window
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
...
}
spin_unlock_irq(&timeline->lock);
...
}
That window looks tiny, but in practice, attempting preemption while the first spinlock is held succeeds as soon as the lock releases. The ideal timeline is shown below, with red blocks marking non-preemptible spinlock regions and dotted lines for idle tasks:
The actual behavior is messier:
For object replacement, the standard sendmsg technique is used — it's a well-documented way to overwrite freed kernel memory with controlled data. The first 12 bytes have some restrictions, but they don't affect this exploit. From here on, assume the freed dma_fence is replaced by attacker-controlled data.
Operating on the fake fence
Once replaced, the fake object flows into kgsl_ioctl_timeline_destroy:
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
Three functions receive the fake fence: dma_fence_set_error, dma_fence_signal_locked, and dma_fence_put. dma_fence_set_error writes an error code to the object — potentially useful with another replacement strategy, but not with sendmsg hijacking, so it's not explored here. dma_fence_signal_locked behaves as:
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
if (unlikely(test_and_set_bit(DMA_FENCE_FLAG_SIGNALED_BIT, //<-- 1.
&fence->flags)))
return -EINVAL;
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 2.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 3.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
It checks fence->flags (1): if DMA_FENCE_FLAG_SIGNALED_BIT is set, the fence was already signaled and the function exits early. Otherwise list_replace moves entries from fence->cb_list to a temporary list (2) and invokes stored callbacks (3). Due to kCFI, only functions of a matching type can be called — and with no known function addresses at this stage, reaching that path almost certainly crashes the kernel. So the fake object must set DMA_FENCE_FLAG_SIGNALED_BIT to make this function bail out.
That leaves dma_fence_put, which decrements the refcount and, if it hits zero, calls dma_fence_release:
void dma_fence_release(struct kref *kref)
{
...
if (fence->ops->release)
fence->ops->release(fence);
else
dma_fence_free(fence);
}
dma_fence_release will dereference fence->ops and call fence->ops->release. Two obstacles arise: fence->ops must point to valid memory, and the function pointer must either be null or a function with the right type signature. With valid memory and the right type, the arbitrary call is achievable.
This leads to two strategies. One is conventional: replace the fence object with something usable or exploit dma_fence_put and dma_fence_set_error for controlled writes, while carefully managing flags and refcount to avoid the crash paths. The other is to find a different angle.
From predictable memory to controlled fake objects
While building the exploit, a predictable memory region for fake objects becomes critical. The Software Input Output Translation Lookaside Buffer (SWIOTLB) is allocated extremely early during boot, so its physical address is essentially fixed for a given hardware configuration. On Android arm64 devices, which lack a “high memory” region, the virtual address is derived from the physical address by adding a constant offset, as implemented by kmap:
#define __virt_to_phys_nodebug(x) ({ \
phys_addr_t __x = (phys_addr_t)(__tag_reset(x)); \
__is_lm_address(__x) ? __lm_to_phys(__x) : __kimg_to_phys(__x); \
})
#define __is_lm_address(addr) (!(((u64)addr) & BIT(vabits_actual - 1)))
#define __lm_to_phys(addr) (((addr) + physvirt_offset))
The relevant constants are defined in arch/arm64/include/asm/memory.h. The physvirt_offset used in the translation is itself a fixed constant, set once in arm64_memblock_init:
void __init arm64_memblock_init(void)
{...
memstart_addr = round_down(memblock_start_of_DRAM(),
ARM64_MEMSTART_ALIGN);
physvirt_offset = PHYS_OFFSET - PAGE_OFFSET;
...
}
In theory, the SWIOTLB could be reached through the adsp driver from an untrusted app, but on the 5.x kernel used by the target device, it is only allocated when CONFIG_DMA_ZONE32 is enabled—which it is not here.
However, the boot log reveals that other memory regions are also carved out very early, with equally predictable addresses:
<6>[ 0.000000] [0: swapper: 0] Reserved memory: created CMA memory pool at 0x00000000f2800000, size 212 MiB
<6>[ 0.000000] [0: swapper: 0] OF: reserved mem: initialized node secure_display_region, compatible id shared-dma-pool
...
<6>[ 0.000000] [0: swapper: 0] OF: reserved mem: initialized node user_contig_region, compatible id shared-dma-pool
<6>[ 0.000000] [0: swapper: 0] Reserved memory: created CMA memory pool at 0x00000000f0c00000, size 12 MiB
<6>[ 0.578613] [7: swapper/0: 1] platform soc:qcom,ion:qcom,ion-heap@22: assigned reserved memory node sdsp_region
...
<6>[ 0.578829] [7: swapper/0: 1] platform soc:qcom,ion:qcom,ion-heap@26: assigned reserved memory node user_contig_region
...
Those Reserved memory entries correspond to the pools that back ion buffers. On Android, the ion_allocator provides DMA-capable memory shared between kernel drivers and userspace. An untrusted app can open /dev/ion and call ION_IOC_ALLOC to obtain an ion buffer; the returned file descriptor supports mmap to map the backing store into userspace.
A key property of ion heaps is that some can provide physically contiguous memory. Hardware peripherals that perform direct memory access benefit from or even require contiguity. To guarantee such regions are available on demand, the ion driver allocates these pools at boot time as carved-out regions—again making their addresses predictable, since they depend only on the device tree, available memory, and physical start address.
In practice, the user_contig_region pool is almost never touched, so mapping the entire region to userspace succeeds consistently. This makes a perfect staging area: allocate from that pool, mmap it, and you have controlled data at a known kernel address.
This solves an earlier dead-end with the fake fence object. When dma_fence_release is invoked:
void dma_fence_release(struct kref *kref)
{
...
if (fence->ops->release)
fence->ops->release(fence);
else
dma_fence_free(fence);
}
the field fence->ops must reference a memory area filled with zeros so that fence->ops->release is not called—there is no usable function address matching that signature. By pointing fence->ops into a zero-filled ion buffer, the kernel instead takes the dma_fence_free path, freeing the fake object and yielding the desired double-free primitive without crashing.
Breaking out of a CPU-pinning loop
One loose end remains: the destroy function’s cleanup loop. After the fence object is freed and replaced, kgsl_ioctl_timeline_destroy iterates a list with list_for_each_entry_safe:
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
The macro reads the next pointer from temp, then keeps following each entry’s node.next until it returns to temp. If that never happens, the loop spins forever inside a spinlock, prompting the watchdog to trigger a kernel panic.
This is where automatic variable initialization becomes painful. The kgsl_timeline_fence layout places node at the very end:
struct kgsl_timeline_fence {
struct dma_fence base;
struct kgsl_timeline *timeline;
struct list_head node;
};
A partial overwrite of only base would have left timeline and node intact, preserving valid list pointers. But with automatic variable initialization, freeing the object and replacing it with a smaller allocation zeroes the entire chunk, wiping out both fields. The fake object must therefore provide its own node, which imposes two requirements:
- Every
nextpointer must reference another fakekgsl_timeline_fencethat can survive the loop’s operations—dma_fence_set_error,dma_fence_signal_locked, anddma_fence_put—without crashing. That means crafting multiple fake objects. - One
nextpointer must lead back totemp, the stack-allocated list head, to exit promptly.
Creating the fake entries themselves is straightforward now that ion buffers provide predictable addresses. Making the list circular among fake objects is also easy:
But that only creates an infinite loop, which will eventually trip the watchdog. Even if the fake entries never dereference invalid memory, the CPU is held indefinitely inside a spinlock, and the panic follows shortly.
A way out appears by examining dma_fence_signal_locked:
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
struct list_head cb_list;
...
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 1.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 2.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
For each fake fence, this function runs. The dereference risk is at point 2 of that code, which calls a function pointer; providing a valid func is not feasible, so that path must never be reached.
Avoiding it requires that fence.cb_list be an empty list, with both next and prev set to its own address. The first fake fence cannot satisfy this, because its actual kernel address is unknown. But the subsequent fake objects, residing in the ion buffer at a known address, can be crafted with an empty cb_list. When list_replace runs:
static inline void list_replace(struct list_head *old,
struct list_head *new)
{
//old->next = &(fence->cb_list)
new->next = old->next;
//new->next = &(fence->cb_list) => fence->cb_list.prev = &cb_list
new->next->prev = new;
//new->prev = fence->cb_list.prev => &cb_list
new->prev = old->prev;
//&cb_list->next = &cb_list
new->prev->next = new;
}
it writes the address of the stack variable cb_list into fence->cb_list.prev, located inside the ion buffer. Since the ion buffer remains mapped into userspace, that address can be read directly by polling.
Because dma_fence_signal_locked executes inside kgsl_ioctl_timeline_destroy after temp has been allocated on the stack:
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
struct list_head temp;
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
//cb_list, is a stack variable allocated inside `dma_fence_signal_locked`
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
...
}
the address of cb_list sits at a fixed offset from temp. Polling the ion buffer for the written cb_list address yields the stack location; computing temp follows immediately. With that, the next pointer of one fake kgsl_timeline_fence can be updated in the ion buffer to point back to temp, ending the loop before the watchdog fires:
Fake Objects and Freelist Control
The fake object handed to dma_fence_put can be controlled well enough that the refcount decrement drives the code into dma_fence_free, which releases the object through kfree_rcu. By reclaiming that address with a second fake object, the exploit gets two live handles to one allocation and can free the chunk from either side at will.
The natural next step is to corrupt the freelist itself. When a slab page frees a chunk (fast path), the allocator writes the address of the next free chunk into the first eight bytes of the just-freed chunk. With two handles to the same object, one handle can free it while the other handle rewrites those first eight bytes, redirecting the freelist to any chosen address—the subsequent allocation will land exactly there. This is a simplification since the game only plays out cleanly within a single slab and on the fast path, but that is achievable in practice.
For rewriting those eight post-free bytes, the exploit reuses a technique described in “Mitigations are attack surface too”: the signalfd object. A signalfd allocation stores an eight-byte mask whose lifetime is tied to the returned file. The mask can be changed at any time by calling signalfd again with a new mask, and the object goes away when the file is closed. That yields a small allocation with user-controlled, rewriteable contents at a known lifetime—exactly what the freelist hijack needs. The steps are:
- Trigger the use-after-free and overwrite the freed
dma_fencewith a fake object viasendmsg, so thatdma_fence_freereleases it throughkfree_rcu. - Spray
signalfdallocations to reclaim the freedsendmsgobject’s address. - Free the
sendmsgobject; the freelist pointer lands in thesignalfdmask’s location. - Modify the mask to plant a chosen freelist pointer, then heap spray to direct future allocations to that address.
Pointing that freelist value at an ion buffer the attacker controls makes subsequent kernel allocations occur inside that buffer. Since the ion buffer is both readable and writable at will, the attacker can reshape anything allocated there—effectively constructing a private, fully controlled view of the kernel heap. From there, an arbitrary read/write primitive is the remaining task.
kfree_rcu and the Race behind Replacement
Two wrinkles make the freelist hijack less deterministic:
- Because
kfree_rcudoes not release memory synchronously, the actual free is deferred and typically runs on a different CPU than the one that invoked it. In this exploit, the invoking CPU happens to be stuck inside thespinlock-guarded loop that processes other fakedma_fenceobjects (until the loop is exited with the write oftemp’s list address). That CPU unavailability guarantees the deferred free goes elsewhere. - Object replacement is CPU-local—a freed chunk goes into the per-CPU cache of the freeing CPU, while an immediate allocation will grab from the cache of the allocating CPU. Without knowing which CPU runs the deferred free or exactly when it runs, placing a replacement object at the same address is a best-effort operation.
On the test device the authors could reach >70% success with a straightforward loop that sprays objects on every CPU, repeated at intervals to cover the timing uncertainty from the kfree_rcu delay. They also reclaimed the freed sendmsg objects themselves with another round of signalfd sprays; that second pass keeps uncontrolled data from landing in those slots and makes spotting the corrupted object easier.
Turning a Freelist Primitive Into Physical Memory Access
The freelist hijacking primitive described earlier becomes far more powerful when combined with a structure like ion_buffer, which is present on all Android devices and accessible from untrusted applications. The key insight is to use the primitive to allocate ion_buffer structures inside an ion buffer backing store that we already control with arbitrary reads and writes. This "fake kernel heap" then lets us corrupt any ion_buffer that gets allocated within it.
Each ion_buffer contains an sg_table that describes the pages backing the buffer. When mmap is called, the kernel walks this structure to map the underlying page to user space:
struct sg_table {
struct scatterlist *sgl; /* the list */
unsigned int nents; /* number of mapped entries */
unsigned int orig_nents; /* original size of list */
};
struct scatterlist {
unsigned long page_link;
unsigned int offset;
unsigned int length;
dma_addr_t dma_address;
#ifdef CONFIG_NEED_SG_DMA_LENGTH
unsigned int dma_length;
#endif
};
The scatterlist's page_link field is an encoded page pointer:
static inline struct page *sg_page(struct scatterlist *sg)
{
#ifdef CONFIG_DEBUG_SG
BUG_ON(sg_is_chain(sg));
#endif
return (struct page *)((sg)->page_link & ~(SG_CHAIN | SG_END));
}
And crucially, a page pointer is just a logical shift of a physical address plus a constant offset:
int ion_heap_map_user(struct ion_heap *heap, struct ion_buffer *buffer,
struct vm_area_struct *vma)
{
struct sg_table *table = buffer->sg_table;
...
for_each_sg(table->sgl, sg, table->nents, i) {
struct page *page = sg_page(sg);
...
//Maps pages to user space
ret = remap_pfn_range(vma, addr, page_to_pfn(page), len,
vma->vm_page_prot);
...
}
return 0;
}
If we control page_link, we can make mmap map any physical page into our address space. On most devices, this alone would get us arbitrary kernel memory access, because the kernel image sits at a fixed physical base; KASLR only randomizes the virtual offset from that base.
Samsung devices complicate things by also randomizing the physical (intermediate) placement of the kernel image. Before we can translate kernel virtual addresses to physical ones, we need a leak. The fake kernel heap provides one cleanly: every ion_buffer points to an ion_heap, whose ion_heap_ops is a global ops vector inside the kernel image:
struct ion_buffer {
struct list_head list;
struct ion_heap *heap;
...
};
struct ion_heap {
struct plist_node node;
enum ion_heap_type type;
struct ion_heap_ops *ops;
...
}
We can recover the kernel base like this:
Locate our target
ion_bufferin the fake heap by setting a uniqueflagsvalue at allocation time via theION_IOC_ALLOCioctl and scanning for it:struct ion_buffer { struct list_head list; struct ion_heap *heap; unsigned long flags; ...Read the
heappointer. This points into low memory whose physical address is a fixed offset away.Modify that
ion_buffer'ssg_tableso its backing page is the one holding the realion_heapobject.Call
mmapon the buffer's file descriptor; the page with theion_heapis now mapped readable from user space, exposing theopspointer and the KASLR offset.
The fake heap also introduces a cleanup problem. The SLUB allocator's kfree expects the object's page to be a slab page; the fake heap fails both the PageSlab and PageCompound checks:
void kfree(const void *x)
{
struct page *page;
void *object = (void *)x;
trace_kfree(_RET_IP_, x);
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
page = virt_to_head_page(x);
if (unlikely(!PageSlab(page))) { //<-------- check if the page allocated is a single page slab
unsigned int order = compound_order(page);
BUG_ON(!PageCompound(page)); //<-------- check if the page is allocated as part of a multipage slab
...
}
...
}
Freeing anything from the fake heap would crash the kernel — unless we make sure nothing is ever freed from it. We can do that by flipping the ION_HEAP_FLAG_DEFER_FREE bit in an ion_heap's flags right before freeing an ion_buffer. The buffer will go onto the heap's free_list instead of being returned to the allocator:
int ion_buffer_destroy(struct ion_device *dev, struct ion_buffer *buffer)
{
...
heap = buffer->heap;
...
if (heap->flags & ION_HEAP_FLAG_DEFER_FREE)
ion_heap_freelist_add(heap, buffer); //<--------- does not free immediately
else
ion_buffer_release(buffer);
return 0;
}
Then we clear the flag again. The buffer sits on that freelist indefinitely, never actually freed. Since we already have a read/write mapping of the page holding the ion_heap, toggling the flag is trivial, and we can repeat this for every object we allocate in the fake heap.
Disabling SELinux in One Write
Once we can poke arbitrary kernel memory, SELinux enforcement is a matter of finding the right toggle. The selinux_enforcing variable (zero means permissive) would normally sit in a page protected by Samsung's KDP/RKP. But in Qualcomm's 5.x kernel branch, Samsung left it writable — an oversight that mirrors one seen in prior research:
//In security/selinux/hooks.c
#ifdef CONFIG_SECURITY_SELINUX_DEVELOP
static int selinux_enforcing_boot;
int selinux_enforcing;
One direct write to set selinux_enforcing to zero is all it takes to put SELinux in permissive mode. Other, more general bypasses exist, but given everything else the exploit already requires, a one-word overwrite wins.
Getting Root Commands Past RKP and kCFI
Samsung's Realtime Kernel Protection write-protects each process's credential structure, so the usual "overwrite our cred with root's" approach is off the table. Prior exploits against Samsung devices have instead aimed at executing code in the context of a root kworker thread, often by corrupting objects queued for work execution.
With arbitrary memory read and write, we can skip the corruption and directly insert entries into a global workqueue's linked list. Many of these workqueues are static kernel objects sitting at fixed offsets from the image base:
ffffffc012c8f7e0 D system_wq
ffffffc012c8f7e8 D system_highpri_wq
ffffffc012c8f7f0 D system_long_wq
ffffffc012c8f7f8 D system_unbound_wq
ffffffc012c8f800 D system_freezable_wq
ffffffc012c8f808 D system_power_efficient_wq
ffffffc012c8f810 D system_freezable_power_efficient_wq
Waiting for a kworker to pick up our entry only constrains us by kCFI: the called function must match the expected work-function signature:
void (func*)(struct work_struct *work)
That still leaves plenty of power. In particular, call_usermodehelper_exec_work matches the signature and will dutifully run a command string we supply. So we append a work_struct carrying the function pointer for call_usermodehelper_exec_work to, say, system_unbound_wq. A kworker executes it, and we get arbitrary shell commands as root, cleanly passing both RKP and kCFI.
Accompanying exploit code and setup notes are available in the GitHub SecurityLab repository for CVE-2022-22057.
Mitigations reshaped the exploit, not the outcome
The vulnerability described here is a use-after-free with fairly standard exploitation primitives. In the end, the exploit was able to bypass every mitigation in place and achieved reliability comparable to a chain built last year. But the mitigations did force the attack down a considerably longer and more convoluted path than it might otherwise have taken.
Indirect call control, in the form of kCFI, was the single largest obstacle. The UAF provides ample opportunity to corrupt an arbitrary function pointer, and with a separate information leak (still pending disclosure at the time of writing) the bug would have been straightforward to exploit — similar in difficulty to the NPU bugs described in previous research. Samsung's RKP combined with kCFI shut down that direct route and left no alternative but to explore much less conventional techniques.
Some of those techniques, however, may generalize well. The "ultimate fake object store" and "device memory mirroring attack" described here can be turned into reusable methods for converting typical primitives into arbitrary memory read and write. Even with RKP's restrictions, arbitrary memory access proved powerful enough for practical purposes. The net effect of kCFI may therefore be less about making bugs unexploitable and more about making some primitives much less attractive than others. Being a late-stage mitigation, it cannot prevent the corruption itself — only constrain how it is used after the fact.
Automatic variable initialization deserves more credit than it typically receives. Though aimed at bugs that read uninitialized memory, it also inhibits partial object replacement, a common exploitation technique. It nearly derailed this exploit entirely; only a lucky stack address leak avoided a hard failure. That mitigation effectively eliminates a class of bugs while also weakening certain primitives that are useful even in otherwise well-hardened targets.
Finally, constructing primitives that interact with the kernel scheduler widened several race windows, which was key to handling the delayed free introduced by kfree_rcu. That delay is not itself a mitigation, but it does resemble the quarantine approach used by the Scudo allocator in Android user space. In kernel contexts, the results here suggest such quarantine strategies may not be particularly effective at deterring UAF exploitation.
Timeline and patch-gap concerns
The vulnerability was reported to Qualcomm on November 16, 2021, and publicly disclosed in early May 2022 — a roughly six-month turnaround, consistent with Qualcomm's typical disclosure cadence. That extended period has previously led some researchers to publish full exploits before patches are public, as with the Adreno GPU work. Qualcomm's process is unusual in that it privately notifies customers within about 90 days (the "Customer Notified Date" in advisories), but full integration and public disclosure may lag by several months.
The interval between private and public disclosure creates a patch-gapping opportunity. One vendor may apply a patch immediately after private disclosure while another waits for the monthly public bulletin. As an example, this patch made it into the Samsung S21 in July 2021 (visible in Samsung's open source code), yet it was only publicly assigned CVE-2021-30305 in October 2021. Cross-vendor firmware comparison could expose such asymmetries. In this specific case, the fix itself was publicly visible by December 2021 with a clear commit message, creating a five-month gap to public disclosure (two months after the private notice). Given the complexity of the resulting exploit, that window is ample for a determined team to weaponize it. Qualcomm's timeline could reasonably be tightened, or the gap between private and public disclosure closed.



