The Qualcomm NPU: A Closer Look
The NPU is a coprocessor dedicated to AI and machine learning workloads. It offloads computationally intensive neural network operations from the CPU, both speeding up tasks and freeing the main processor for other work. While research on NPU drivers has largely focused on Samsung's implementation, the Qualcomm NPU kernel driver has received comparatively little attention—despite being present in numerous devices.
Attack Surface and Affected Devices
The Qualcomm NPU kernel driver was introduced in kernel version 4.14. Any handset running that kernel or newer is exposed to the vulnerabilities discussed here— this includes many mid- to high-end phones released after late 2019, such as the Samsung Galaxy S10, S20, and A71. Google's Pixel devices, however, are not affected: they rely on the edge TPU for AI tasks, ship without NPU firmware, and restrict kernel driver access to root, making the entire attack surface unreachable.
Samsung devices with Qualcomm chipsets are a different story. Unlike the Samsung NPU driver, which was locked down after Project Zero disclosure, the Qualcomm NPU driver on these devices remains accessible from the untrusted app domain. Any installed app can interact with it. The exploit described here was tested on a Galaxy A71 but is expected to work on other Qualcomm-based S series devices running kernel 4.14 or newer with minor adjustments.
Driver Architecture and Interface
The driver source resides in drivers/media/platform/msm/npu. Its primary functions are managing RPC communication between the CPU and NPU, and allocating shared memory for neural network models and data. Documentation is sparse; the behavior described here is derived from code analysis and experimentation.
Interaction happens through the /dev/msm_npu device file. Opening it creates an npu_client object, which is released when all file handles close. The main ioctl handlers are:
npu_map_buf: shares a DMA buffer (allocated via ION) with the NPU for loading modelsnpu_unmap_buf: removes that shared memory mappingnpu_load_network(_v2): loads a model from shared memory, performing validation checks and tracking loaded networks in the globalnetworksarraynpu_unload_network(_v2): removes a model from the NPU and the tracking arraynpu_exec_network(_v2): executes a loaded model, with some validity checks
These ioctls validate inputs, package parameters into RPC messages, and send them via npu_send_network_cmd:
static int npu_send_network_cmd(struct npu_device *npu_dev,
struct npu_network *network, void *cmd_ptr, bool async)
{
...
} else {
...
ret = npu_host_ipc_send_cmd(npu_dev,
IPC_QUEUE_APPS_EXEC, cmd_ptr);
if (ret)
network->cmd_pending = false;
}
return ret;
}
The NPU picks up these messages and acts accordingly. Most commands run synchronously—the driver sends an RPC, waits for the NPU's completion reply, and only then returns from the ioctl. What stands out, however, is the asynchronous npu_exec_network mode, triggered by a boolean async parameter in the ioctl struct:
int32_t npu_host_exec_network(struct npu_client *client,
struct msm_npu_exec_network_ioctl *exec_ioctl)
{
bool async_ioctl = !!exec_ioctl->async;
...
ret = npu_send_network_cmd(npu_dev, network, &exec_packet, async_ioctl);
...
if (async_ioctl) {
pr_debug("Async ioctl, return now\n");
goto exec_done;
}
...
mutex_unlock(&host_ctx->lock);
ret = wait_for_completion_interruptible_timeout(
&network->cmd_done,
(host_ctx->fw_dbg_mode & FW_DBG_MODE_INC_TIMEOUT) ?
NW_DEBUG_TIMEOUT : NW_CMD_TIMEOUT);
mutex_lock(&host_ctx->lock);
...
}
In this mode, the ioctl returns to userspace as soon as the RPC is sent. To retrieve results later, the client uses npu_receive_event. When the NPU finishes, it sends a response RPC, which the kernel processes in the app_msg_proc handler.
The vulnerabilities behind the fall
All three flaws discussed below live in the asynchronous execution path of the npu_exec_network ioctl. To use that path, a caller first maps shared memory with npu_map_buf, loads a model into the NPU with npu_load_network, and then runs it with npu_exec_network with the async flag set. The heart of the problem sits in step two: when a network is loaded, the driver doesn't just hand it to the NPU. It also keeps a driver-side record of the network, and that record is what becomes exploitable.
Loading a network calls alloc_network, which does not dynamically allocate anything. Instead, it claims an entry in the statically sized networks array inside the global ctx of type npu_host_ctx. Since ctx is shared across all users of the driver, the number of simultaneously loaded networks is strictly limited. When alloc_network succeeds, it populates that array entry and returns the entry's id to the caller. The stored npu_network also records the npu_client of the loading process.
That client pointer is used for two things. First, it is an ownership tag: any later lookup by id verifies the caller's npu_client matches the one on the network before granting access. Second, it identifies the originator of an asynchronous request when the NPU later sends a reply message. That reply handling runs in a kernel worker, and the worker looks up the originating npu_network by id from the message, then pushes an event into the client's queue to wake it up.
Because the npu_network array is global, cleanup is critical: closing the file frees the npu_client, and the driver must scrub that pointer from all network records to prevent a dangling reference. That cleanup is done by npu_host_cleanup_networks, called from npu_close, which finds the network holding the client and invokes npu_host_unload_network to remove it. The actual pointer removal happens in the free_network path. It is not, however, guaranteed to be reached on every close.
One specific early return is particularly interesting. If npu_send_network_cmd reports -EBUSY, the code skips free_network entirely, leaving the client pointer in place. That raises two questions: can an attacker force -EBUSY, and even if they can, how would they reclaim such a network given that their file is closed and the client is freed?
Racing the CPU against the NPU
The answer to the first question is straightforward. Inside npu_send_network_cmd, the cmd_pending flag on the npu_network causes an immediate -EBUSY return when already set. That flag is raised when a command is sent to the NPU and cleared when the NPU finishes processing it. By issuing an asynchronous npu_exec_network and promptly closing /dev/msm_npu, the command can still be in flight, leaving cmd_pending set, and the close path then returns early before sanitizing the client pointer.
That same race answers the second question as well. An attacker doesn't need to query for the stale network after closing the fd; the NPU does it for them. When the outstanding asynchronous task eventually completes, its RPC message hits app_msg_proc, which passes the freed client pointer to npu_queue_event. That routine performs a wake_up_interruptible on the client's wait queue. The wake mechanism will invoke a callback stored in a queue entry, passing the address of that entry as the first argument. By replacing the freed npu_client with a forged object whose wait queue points to attacker-controlled data, the attacker executes an arbitrary function with a controlled first argument. Because the call happens on a kernel worker, it runs with root privileges. This is CVE-2021-1940.
An expensive one-character error
Exploiting a use-after-free alone is possible, but it is far easier with a leak of a heap address pointing to attacker-controlled data, providing a reliable place for a fake wait queue. A separate bug offers precisely that value.
The npu_exec_network_v2 API accepts a stats_buf intended to hold performance or debug output from a network run. While inspecting the contents returned after a run, they appeared to be fixed-length gibberish until recognized as memory addresses. The driver-side handling for the completion message in app_msg_proc stores the kernel address of the stats_buf and a user-space destination address inside the event's reserved array. When the application calls npu_receive_event, that event goes through npu_process_kevent, which eventually does a copy_to_user.
The intended copy source is the kernel stats_buf region itself, but the code instead passes the address of kevt->reserved[0] — the address of the location holding the pointers — as the copy source, rather than the value of that location. Since kevt is a fixed layout and the stats_buf size can far exceed it, the copy yields an out-of-bounds read in the kernel heap (hardened usercopy stops it at the next allocation boundary, but it still exposes uninitialized memory including the heap layout). The result is a functional info leak. This is CVE-2021-1968.
Size confusion
A third issue is a data leak triggered purely by compiler layout semantics. In app_msg_proc, the kernel allocates an uninitialized npu_kevent object kevt. While the code appears to set all fields used later, closer inspection shows gaps. The reserved array inside kevt is four elements long, but only reserved[0] and reserved[1] are ever written — normally harmless because the receive path only reads evt plus those two reserved slots.
The evt field is an msm_npu_event, a structure that contains a union whose largest member is data[128]. In the execution path, the union is interpreted specifically as an msm_npu_event_execute_v2_done result and its fields are initialized, but nothing initializes the remaining padding between that smaller type's size and the full 128-byte union. The reserved[4] members likewise stay untouched. After the event completes, the driver blindly copies the entire msm_npu_event structure back to user space via copy_to_user, dumping all that uninitialized padding and stale heap content to the caller. This is CVE-2021-1969.
Chaining the three vulnerabilities
The three bugs combine into a reliable chain: CVE-2021-1968 gives a kernel heap address, CVE-2021-1969 breaks KASLR, and CVE-2021-1940 turns those into arbitrary kernel code execution. The exploit proceeds in three stages:
- CVE-2021-1968 leaks the address of a
stats_buf, which is then freed and reallocated with controlled data. - CVE-2021-1969 leaks a kernel function address to defeat KASLR.
- CVE-2021-1940 triggers a use-after-free to execute a chosen function with a controlled first argument.
Turning a freed buffer into controlled memory
With CVE-2021-1968, calling npu_exec_network_v2 in async mode copies the address of the stats_buf — a 0x4000-byte kzalloc allocation — back to a user-supplied buffer. The buffer is freed when the network is unloaded via npu_unload_network.
The freed chunk can then be reclaimed with a standard sendmsg-based heap spray. Allocations of exactly 0x4000 bytes are rare enough that the message buffer lands at the old stats_buf address with high probability. At that point the leaked address points to fully attacker-controlled data.
Breaking KASLR with an uninitialized stack variable
CVE-2021-1969 leaks uninitialized stack data. The variable in question, kevt in app_proc_msg, is copied back to user space without initialization. Exploitation depends on CONFIG_INIT_STACK_ALL not being enabled; while Android 11 kernel 5.4 devices (S21, Z Flip 3) have this hardening, many Samsung devices running older kernels do not. On the tested A71, the leak reliably exposes the address of host_irq_wq at a fixed offset inside kevt, making it a dependable KASLR defeat.
Winning the use-after-free race
CVE-2021-1940 requires winning a race between the CPU and the NPU. The blue-box events (network load, exec, client free) are initiated and timed by the attacker; red-box events are the NPU's asynchronous responses. The NPU must not complete its task before the freed npu_client is replaced. In practice the race resolves in a few attempts on the A71, and failures are forgiving — they merely leave a network permanently loaded, and only 32 networks exist before the race can't be retried.
The replacement object is another sendmsg spray. The key field is the wait queue in npu_client; its entries are doubly linked list nodes. When the driver calls wake_up_interruptible with &client->wait, the kernel walks that list and invokes each entry's stored func pointer with the entry itself as the first argument. By crafting the fake npu_client so the queue entries point into the known stats_buf, the attacker controls both the function pointer and the data the function receives.
The function pointer must be a legitimate function start. Samsung's RKP enforces a form of CFI (JOPP) that blocks jumps into the middle of functions or to ROP gadgets. The obvious target, __bpf_prog_run32, executes eBPF bytecode but takes that bytecode as its second argument. The wake-up path only controls the first argument.
That gap is bridged with a common kernel pattern: a function that takes a structure and calls a function pointer stored inside it, passing both a controlled first argument and a controlled second argument. ion_buffer_kmap_put fits; it is inlined into ion_dma_buf_vunmap, which is what the exploit actually uses. With a fake buffer as the first argument, the call at the tail of the function invokes buffer->heap->ops->unmap_kernel with both arguments under attacker control. Setting that to __bpf_prog_run32 and the second argument to crafted eBPF bytecode yields arbitrary kernel code execution.
Because the execution happens in a kworker context, the payload runs with root credentials immediately. SELinux and seccomp still apply, but they are the next problem to solve.
Disabling SELinux and launching a root shell
SELinux runs in permissive mode when the selinux_enforcing variable is zero. On many Samsung kernels this variable is not protected by KDP or RKP read-only page tables. The tested firmware, and source code from some S-series devices up to roughly June 2021, ships with CONFIG_SECURITY_SELINUX_DEVELOP enabled and selinux_enforcing writable. Newer firmware (e.g., the S20's September 2021 update) marks it as __kdp_ro, but the bug chain here was fixed in July 2021, so the old firmware remains exposed.
Simply overwriting selinux_enforcing to zero puts SELinux in permissive mode. Then call_usermodehelper, invoked through the kernel execution primitive, spawns a reverse shell with root UID and no SELinux restrictions.
The full exploit is publicly available with setup notes.
Fix Timelines and Broader Implications
The vulnerabilities detailed in this analysis reveal systemic weaknesses in Qualcomm’s NPU driver, which remains an underexplored attack surface compared to the well-documented NPU implementations from Samsung. Critically, on many devices, this driver can be reached directly from the untrusted application sandbox, making it a viable target for remote or local privilege escalation.
The core vulnerability exploited here is a use-after-free condition arising from a race between the NPU and the CPU. In this scenario, the NPU accesses memory resources that the CPU has already freed. This is an unconventional race condition because standard kernel synchronization primitives, such as mutexes, are designed for shared resources within a single processor. When resources are shared across heterogeneous processors (NPU and CPU), those mechanisms do not apply, and additional care is required in resource lifetime management.
Root Context and SELinux Bypass
A distinctive aspect of this bug is that the use-after-free is executed by a kworker kernel thread. Consequently, exploiting it allows kernel code to run in the root context without the need for a separate privilege escalation step. Furthermore, because the selinux_enforcing variable was left unprotected, it was possible to disable SELinux entirely during the exploit. This provides a practical shortcut around Kernel Data Protection (KDP) on Samsung devices, a mitigation that normally prevents credential overwriting by protecting process credentials from modification.
Testing Gaps and Patch Delays
The existence of bugs such as CVE-2021-1968 — which would likely have been surfaced as a functional failure by merely invoking the relevant ioctl in async mode — strongly suggests that the NPU driver lacks adequate testing coverage. The extended remediation timelines compound this concern:
- CVE-2021-1940 took roughly seven months from disclosure to public fix.
- CVE-2021-1968 and CVE-2021-1969 each required about ten months for a fix to appear.
- A prior use-after-free, CVE-2019-10621, took approximately eight months to resolve.
These are not isolated incidents but symptoms of a broader pattern. Given that Android’s security model heavily depends on application sandboxing, prolonged exposure to unpatched kernel vulnerabilities severely undermines that isolation. The longer a vulnerability remains unfixed, the greater the chance it will be chained with other bugs, widening the window of exploitation and degrading the effectiveness of sandboxing. Original equipment manufacturers should therefore conduct a rigorous review of NPU driver usage and impose strict access restrictions on it where feasible.



