CVE-2025-13952: UAF in Imagination GPU Shader Compiler (CVSS 9.8)
A crafted WebGL shader triggers a write use-after-free in the Imagination GPU compiler process. On privileged compiler processes, this enables full device compromise.
# Your Graphics Card Could Be a Hidden Attack Vector
Imagine your computer's graphics chip has its own tiny programming language. Hackers have discovered a way to sneak malicious instructions into this language, and when your browser loads a webpage containing these booby-trapped instructions, they can break into your computer.
Here's what's actually happening. Graphics cards use special code called "shaders" to make games and videos look good. When you visit a website, it can send shader code to your GPU to render fancy graphics. A security flaw means the software that processes this code keeps using old memory addresses after they've been deleted — think of it like a forwarding address that still points to an old apartment, but someone else lives there now. A hacker can exploit this gap to run their own code on your computer.
This is particularly dangerous because graphics drivers often run with special system-level privileges. That means if a hacker breaks in through this route, they don't just get access to your browser — they potentially control your entire device. They could steal passwords, install spyware, or lock you out completely.
Right now, security researchers haven't found anyone actually using this attack in the wild yet, but it's only a matter of time before hackers develop tools to exploit it.
You should start by keeping your graphics drivers updated — that's your first defense. Second, be cautious about clicking links to unfamiliar websites, especially if a friend sends you something unusual. Third, if your system allows it, enable browser sandboxing features that limit what websites can access on your hardware. Your browser updates will eventually patch this too, so keep those current as well.
Want the full technical analysis? Click "Technical" above.
▶ Attack flow — CVE-2025-13952 · Use After Free
Vulnerability Overview
CVE-2025-13952 is a write use-after-free (UAF) in the Imagination Technologies PowerVR GPU shader compiler library. A remote attacker serving a web page containing a malformed GLSL or USC shader program can trigger the bug without any user interaction beyond page load. The compiler processes the shader, walks a stale pointer into freed heap memory, and writes attacker-influenced data to it. CVSS scores this at 9.8 Critical.
The threat model is particularly severe on platforms — primarily Android SoCs using PowerVR Series6, Series8XE, Series9XE, and IMG A-series cores — where the GPU compiler runs in-process inside a privileged driver context. In those configurations the write primitive lands inside a process that may hold CAP_SYS_ADMIN or equivalent, making exploitation a single-hop to kernel.
Root cause: The USC shader compiler retains a raw pointer to an intermediate representation (IR) node after that node is freed during a DCE/CSE optimization pass, then unconditionally writes through the stale pointer when emitting code for a dependent instruction.
Affected Component
The vulnerability lives inside libUSC (Unified Shader Compiler), Imagination's ahead-of-time compiler backend shared across all PowerVR driver stacks. The library is statically linked into the kernel module on older Android BSPs and dynamically loaded as a userspace library on newer ones. The affected symbol is inside the instruction scheduling and register-allocation pipeline.
Affected versions: See NVD / Imagination advisory at imaginationtech.com/gpu-driver-vulnerabilities/
Root Cause Analysis
The compiler represents shader programs as a doubly-linked list of USC_INST nodes. During optimization, dead-code elimination calls RemoveInst(), which unlinks and frees a node. A secondary optimization pass — common subexpression elimination — holds a cached USC_INST *psBestInst pointer to the same node that was just freed. When CSE later tries to rewrite register sources on a dependent instruction, it dereferences psBestInst and writes the replacement source operand directly into the freed object.
/*
* Decompiled pseudocode: USC shader compiler backend
* Function: CopySourceArguments / ApplyCSEForInst
* libUSC.so (representative decompilation)
*/
typedef struct _USC_INST {
/* +0x00 */ struct _USC_INST *psPrev;
/* +0x08 */ struct _USC_INST *psNext;
/* +0x10 */ uint32_t uOpcode;
/* +0x14 */ uint32_t uFlags;
/* +0x18 */ USC_ARGUMENT asDest[2];
/* +0x38 */ USC_ARGUMENT asSrc[4];
/* +0x78 */ uint32_t uSourceCount;
/* +0x7C */ uint32_t uLiveChans;
/* +0x80 */ void *pvCSEData; // CSE annotation pointer
} USC_INST; // sizeof == 0x88
typedef struct _USC_ARGUMENT {
/* +0x00 */ uint32_t uType;
/* +0x04 */ uint32_t uNumber;
/* +0x08 */ uint32_t uChanMask;
/* +0x0C */ uint32_t uFlags;
} USC_ARGUMENT; // sizeof == 0x10
/*
* CSE pass: caches best matching instruction for a given
* value equivalence class.
*/
static void ApplyCSEForGroup(USC_STATE *psState,
USC_INST *psInst,
USC_INST **ppsBestInst) // cached across DCE!
{
USC_INST *psBestInst = *ppsBestInst;
if (psBestInst == NULL) {
*ppsBestInst = psInst;
return;
}
/*
* DCE may have freed psBestInst between the two calls.
* No validity check is performed here.
*
* BUG: psBestInst is never validated against the live instruction
* list. If DCE ran between iterations and freed this node,
* the write below is a UAF write into freed heap memory.
*/
for (uint32_t i = 0; i < psInst->uSourceCount; i++) {
/* UAF WRITE: psBestInst->asSrc[i] may be freed memory */
psInst->asSrc[i] = psBestInst->asSrc[i]; // BUG: stale pointer deref + copy
}
/*
* Second UAF WRITE: marks the best-inst slot with replacement info.
* Attacker controls register numbers via shader source operands.
*/
psBestInst->asDest[0].uNumber = psInst->asDest[0].uNumber; // BUG: write to freed obj
}
The critical detail: USC_ARGUMENT.uNumber (the register number) is directly derived from the shader's operand encoding. An attacker who controls the register numbering in the shader controls the 32-bit value written into the freed object at offset +0x1C relative to psBestInst->asDest[0] (i.e., psBestInst + 0x18 + 0x04 == psBestInst + 0x1C).
Memory Layout
USC COMPILER HEAP — BEFORE DCE PASS:
[0x7f4c002200] USC_INST node A (0x88 bytes) <-- psBestInst cached here
psPrev = 0x7f4c002100
psNext = 0x7f4c002290
uOpcode = 0x0000004E (FMAD)
asDest[0].uNumber = 0x00000003 (r3)
asSrc[0].uNumber = 0x00000007 (r7)
[0x7f4c002290] USC_INST node B (0x88 bytes) <-- psInst (current)
[0x7f4c002320] USC_INST node C (0x88 bytes)
USC COMPILER HEAP — AFTER DCE FREES NODE A:
[0x7f4c002200] FREE CHUNK (0x88 bytes) <-- tcache/bin candidate
fd = 0x7f4c000010 (tcache next ptr)
bk = 0x0000000000 (unused)
[rest of 0x88 bytes = potentially reallocated]
[0x7f4c002290] USC_INST node B (0x88 bytes) <-- psInst still valid
CSE PASS RESUMES — UAF WRITE:
psBestInst = 0x7f4c002200 (STALE — chunk is free or reallocated)
write: *(uint32_t*)(0x7f4c002200 + 0x1C) = psInst->asDest[0].uNumber
= ATTACKER_CONTROLLED_VALUE
If allocator reused 0x7f4c002200 for another USC_INST:
→ Corrupts asDest[0].uNumber of the reallocated node
→ Propagates attacker register number through subsequent codegen
→ Enables type confusion in register allocator output
If allocator reused 0x7f4c002200 for a USC_STATE sub-object:
→ Corrupts compiler state struct (function pointer table adjacent)
→ Potential control flow hijack within compiler process
Exploitation Mechanics
The UAF alone gives a 32-bit write into recycled heap memory at a predictable offset. Turning this into reliable code execution requires heap grooming to control what occupies the freed slot.
EXPLOIT CHAIN:
1. HEAP GROOMING via shader spam
Attacker JS submits ~200 small benign shaders via WebGL to fill
tcache bins for size 0x88 (sizeof USC_INST), draining the free list.
2. TRIGGER SHADER LOAD
Load the crafted shader. The USC compiler allocates fresh 0x88 chunks
for the IR graph. Two FMAD instructions are constructed such that:
- Node A and Node B are in the same CSE equivalence class
- A later DCE pattern kills Node A (zero-use after peephole)
- Node B has a src operand whose register number = target value
3. DCE FREES NODE A
RemoveInst() unlinks Node A from the instruction list and calls
free(NodeA). The 0x88 chunk returns to tcache[0x88].
4. GROOM: REALLOCATE freed slot with a USC_REGALLOC_DATA object
A second concurrent shader compilation (Web Workers) allocates a
USC_REGALLOC_DATA struct which also happens to be 0x88 bytes.
This lands at the address previously held by Node A.
struct USC_REGALLOC_DATA {
/* +0x00 */ void *pfnAllocCallback; // function pointer!
/* +0x08 */ void *pfnFreeCallback;
/* +0x10 */ uint32_t uFlags;
/* +0x14 */ uint32_t uRegCount;
/* +0x18 */ uint32_t uSpillAreaBase; // <-- +0x18 target
/* +0x1C */ uint32_t uSpillAreaSize; // <-- UAF WRITE LANDS HERE
...
};
5. UAF WRITE FIRES
CSE pass resumes, dereferences psBestInst (now = USC_REGALLOC_DATA*),
writes attacker-chosen register number to offset +0x1C:
uSpillAreaSize = ATTACKER_VALUE (e.g. 0xFFFFFFFF)
6. OVERFLOW IN SPILL ALLOCATION
Register allocator calls:
pSpillBuf = malloc(psData->uSpillAreaSize * 4)
= malloc(0xFFFFFFFF * 4) → wraps to tiny allocation
Subsequent spill slot writes overflow pSpillBuf into adjacent heap.
7. HEAP METADATA CORRUPTION → ARBITRARY WRITE
Overflow corrupts next chunk's size/fd pointer.
On Android with jemalloc/scudo, craft fd to point to GOT entry for
a frequently-called libUSC function pointer.
8. CONTROL FLOW HIJACK
Next malloc() returns attacker-controlled address.
Compiler writes function pointer into that address.
Next codegen call dispatches to attacker shellcode.
9. PRIVILEGE ESCALATION (platform-dependent)
On privileged compiler processes (Android kernel driver context):
shellcode calls commit_creds(prepare_kernel_cred(0)) via ROP.
Patch Analysis
The fix is conceptually simple: validate the cached psBestInst pointer against the live instruction list before use, or — more robustly — nullify the cache entry when the instruction is removed.
// BEFORE (vulnerable):
// RemoveInst() frees the USC_INST node but never notifies CSE state.
static void RemoveInst(USC_STATE *psState, USC_INST *psInst)
{
psInst->psPrev->psNext = psInst->psNext;
psInst->psNext->psPrev = psInst->psPrev;
UscFree(psState, psInst); // freed — no CSE cache invalidation
}
static void ApplyCSEForGroup(USC_STATE *psState,
USC_INST *psInst,
USC_INST **ppsBestInst)
{
USC_INST *psBestInst = *ppsBestInst;
if (psBestInst == NULL) { *ppsBestInst = psInst; return; }
for (uint32_t i = 0; i < psInst->uSourceCount; i++) {
psInst->asSrc[i] = psBestInst->asSrc[i]; // UAF if DCE freed psBestInst
}
psBestInst->asDest[0].uNumber = psInst->asDest[0].uNumber; // UAF WRITE
}
// AFTER (patched):
// RemoveInst() now clears any CSE back-reference stored in the node,
// and the CSE equivalence class entry is explicitly invalidated.
static void RemoveInst(USC_STATE *psState, USC_INST *psInst)
{
psInst->psPrev->psNext = psInst->psNext;
psInst->psNext->psPrev = psInst->psPrev;
/* Invalidate CSE equivalence class entry for this instruction */
if (psInst->pvCSEData != NULL) {
USC_CSE_ENTRY *psEntry = (USC_CSE_ENTRY *)psInst->pvCSEData;
psEntry->psBestInst = NULL; // FIX: nullify cache on free
psInst->pvCSEData = NULL;
}
UscFree(psState, psInst);
}
static void ApplyCSEForGroup(USC_STATE *psState,
USC_INST *psInst,
USC_INST **ppsBestInst)
{
USC_INST *psBestInst = *ppsBestInst;
/* FIX: explicit NULL check — RemoveInst() will have cleared this
* if psBestInst was freed during DCE */
if (psBestInst == NULL) { *ppsBestInst = psInst; return; }
for (uint32_t i = 0; i < psInst->uSourceCount; i++) {
psInst->asSrc[i] = psBestInst->asSrc[i]; // safe: psBestInst is live
}
psBestInst->asDest[0].uNumber = psInst->asDest[0].uNumber; // safe
}
The secondary hardening applied by Imagination is wrapping UscFree() to poison freed USC_INST memory with 0xDEADBEEF in debug builds and to use a generation counter scheme in production builds, so any stale pointer dereference traps before writing.
Detection and Indicators
Because the vulnerability lives in a compiler library, runtime detection is difficult. The following signals are worth monitoring:
CRASH SIGNATURE (ASan / KASAN output):
==ERROR: AddressSanitizer: heap-use-after-free on address 0x7f4c00221c
WRITE of size 4 at 0x7f4c00221c thread T0
#0 ApplyCSEForGroup libUSC.so
#1 RunCSEOnBlock libUSC.so
#2 OptimiseShader libUSC.so
#3 glCompileShader_impl libGLESv2.so
SHADER CHARACTERISTICS that trigger the path:
- Two or more FMAD / FMA instructions sharing identical source operands
(same register, same swizzle) — needed to form a CSE class
- A conditional discard or dynamic loop that causes one instruction
to be DCE-eliminated after CSE class is established
- Unusual register pressure: many live variables forcing the compiler
into the spill path after the corrupted uSpillAreaSize is consumed
WEBGL INDICATOR (browser console):
WebGL: CONTEXT_LOST_WEBGL (crash in GPU process)
Followed immediately by: navigator.gpu undefined (process restart)
ANDROID TOMBSTONE:
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR)
fault addr 0x00000000deadbeXX (if poison is active)
backtrace:
#00 libUSC.so (ApplyCSEForGroup+0x???)
#01 libpvrsrvkm.ko (PVRSRVBridgeRGXKickTA3D)
Remediation
Update immediately. Apply the driver package version listed in the Imagination advisory at imaginationtech.com/gpu-driver-vulnerabilities/. OEM BSP integrators must pull the updated libUSC into their kernel/HAL builds.
Browser-level mitigation. Chromium's --disable-webgl flag or blocklist_entry for affected GPU IDs removes the remote attack surface entirely until drivers are patched.
Process isolation. On Android, ensure the GPU compiler runs under isolated_app SELinux domain rather than in the system_server or kernel module context. This does not fix the bug but contains the blast radius.
Heap hardening. Enable Scudo's chunk header integrity checks (SCUDO_OPTIONS=release_to_os_interval_ms=0) and Android's heapprofd in canary builds to detect UAF writes pre-production.
Shader validation. Deploy a pre-compilation shader validator (e.g., glslang + custom IR checker) that rejects shader programs exhibiting the specific FMAD + conditional-discard pattern before they reach libUSC.