v3d the bug that almost was
five days on this. the bug is a one-line OOB-write in the v3d DRM driver on the
Raspberry Pi 4, confirmed against source. the chain wrote root on 2026-05-11:
modprobe path overwritten, payload ran with full CapEff, persistent
backdoor dropped in /etc/passwd. by every test that matters it's an
exploitable kernel bug.
it does not ship.
i spent five days working out why. the answer was one sentence i should have written down on day one.
the primitive
it lives at drivers/gpu/drm/v3d/v3d_sched.c:466, inside v3d_rewrite_csd_job_wg_counts_from_indirect, reached through the V3D_CPU_JOB_TYPE_INDIRECT_CSD path of DRM_IOCTL_V3D_SUBMIT_CPU:
for (int i = 0; i < 3; i++) {
if (indirect_csd->wg_uniform_offsets[i] != 0xffffffff) {
u32 uniform_idx = indirect_csd->wg_uniform_offsets[i];
((uint32_t *)indirect->vaddr)[uniform_idx] = wg_counts[i];
}
}
indirect->vaddr is a kernel virtual address from vmap(), set a few lines earlier in the same function:
void v3d_get_bo_vaddr(struct v3d_bo *bo)
{
bo->vaddr = vmap(obj->pages, obj->base.size >> PAGE_SHIFT,
VM_MAP, pgprot_writecombine(PAGE_KERNEL));
}
uniform_idx is a u32 read straight out of struct drm_v3d_indirect_csd.wg_uniform_offsets[i], with no bound check against the indirect BO's actual size. wg_counts[i] is another u32, read from a second BO's vmap at a user-controlled offset.
any unprivileged user in the render group can submit an INDIRECT_CSD CPU job. on a stock Raspberry Pi OS Bookworm box that's np, pi, or whatever default-shell user the install created (check your /etc/group). the kernel then writes a chosen u32 to indirect->vaddr + idx*4, and the target can sit many MB above or below where the BO actually lives.
how the chain ends
the exploit pattern is older than v3d. spray ~80,000 four-KB v3d BOs, each of which gets a v3d MMU page-table entry in v3d's dma_alloc_wc-allocated page table. find one whose entry sits at a known kernel address. OOB-write that entry to point at modprobe_path's physical page (PFN 0x178f on this kernel). submit a v3d TFU job that copies the string "/tmp/x" through the corrupted mapping, and now the modprobe contents are your payload. touch a binfmt the kernel will autoload (/tmp/exec_trigger with a bogus magic header does it); the kernel calls modprobe, which is now /tmp/x, running as uid=0.
given indirect->vaddr and the address of the target page-table entry, the rest is high-school algebra:
uniform_idx = (target_pte_addr - indirect->vaddr) / 4
both values are vmalloc-area kernel pointers. so i need to know them.
the thing that breaks it
v3d_get_bo_vaddr has no cache check. look at it again: it calls vmap() every time and writes the result over the previous one. the matching v3d_put_bo_vaddr calls vunmap. every INDIRECT_CSD submission allocates two fresh vmap regions, one for bo and one for indirect, uses them once, and frees them. tail /proc/vmallocinfo while you spam submissions and you watch them pile up:
$ grep v3d_get_bo_vaddr /proc/vmallocinfo | tail
0xffffffc083229000-0xffffffc08322b000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffc08322b000-0xffffffc08322d000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffc083237000-0xffffffc083239000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffc083239000-0xffffffc08323b000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffc08323b000-0xffffffc08323d000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffc08323f000-0xffffffc083241000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
two new addresses per submission. you cannot probe in submission N and reuse the address in submission N+1, because by the time N+1's kthread hits vmap() again it's a different kernel virtual address.
so i need to read the vmap address before the kernel function does the OOB-write. the only file in /proc that hands out raw vmalloc kernel addresses is /proc/vmallocinfo. and over in mm/vmalloc.c:
proc_create_seq_private("vmallocinfo", S_IRUSR, NULL, ...);
S_IRUSR is mode 0400, root-only, hardcoded at proc-entry creation. no sysctl turns it off. no kptr_restrict setting opens it up. i checked every distro that ships a stock kernel for the Pi 4: Raspberry Pi OS Bookworm, Ubuntu 24.04 Noble for arm64, DietPi, Manjaro ARM, Alpine, Arch Linux ARM, openSUSE Leap and Tumbleweed. they all inherit the mainline file mode. the two that ship kernel.kptr_restrict=0 by default, Manjaro ARM and Arch Linux ARM, don't help either, because the file's inode permission check fails before kptr_restrict is ever read.
that's the gap. the bug works. the chain mechanics are sound. i cannot get the one address i need from an unprivileged process on a default kernel anywhere.
five days of doing this anyway
the stub.ko mirage
day one i noticed something strange. with a do-nothing kernel module loaded, stub.ko, literally a module_init that pr_infos and returns 0, the chain hit 3 out of 5 boots on my baked-in d_guess values. with no module loaded: 0 of 6. same chain code, same boot config, same payload. the only difference was whether stub.ko showed up in lsmod or not.
i built a theory: module loading eats some vmalloc area through the module_alloc path, and shifts where v3d_get_bo_vaddr's vmap() lands by just enough to drop the result into a band my hardcoded d_guess values happened to cover.
the theory was wrong.
i spent two days trying to reproduce the effect from userspace. SO_ATTACH_FILTER with progressively larger cBPF programs to drive bpf_prog_pack_alloc through module_alloc: zero of thirty across N_CBPF = 1, 2, 4, 8, 16. pthread_create with CONFIG_VMAP_STACK=y to allocate 16 KiB kernel stacks one at a time: zero of twenty-three across N_CONSUME = 1, 16, 32, 64. i adjusted d_guess bands. i retimed the chain. i varied victim BO counts. i rebooted the Pi a hundred times. zero hits.
on day five i finally opened the kernel source instead of theorizing about the behavior. arm64 has two distinct vmalloc subregions, separately initialized by vmalloc_init(): the modules area at MODULES_VADDR..MODULES_END, where module_alloc lives, and the regular vmalloc area at VMALLOC_START..VMALLOC_END, where vmap() from v3d_get_bo_vaddr goes. they are different rb_root trees inside vmap_node and share nothing. module_alloc cannot, mechanically, touch vmap()'s allocator state.
with the research-mode setup in place (kernel.kptr_restrict=0, no_hash_pointers on the cmdline, a systemd one-shot unit that chmod o+r /proc/vmallocinfo) you can check this directly:
$ grep load_module /proc/vmallocinfo | head -1
0xffffffde66bc6000-0xffffffde66bc9000 12288 load_module+0x760/0x2168 ...
$ grep v3d_get_bo_vaddr /proc/vmallocinfo | head -1
0xffffffc081d41000-0xffffffc081d43000 8192 v3d_get_bo_vaddr+0x4c/0x70 [v3d] vmap
0xffffffde… against 0xffffffc0…. hundreds of GiB apart, in different worlds.
"3 out of 5 with stub.ko" was sample noise. five days fighting a ghost that was never there. mea culpa.
the lab-works-perfectly trap
with those three knobs flipped the chain is basically solved. submit one INDIRECT_CSD with wg_counts[0..2] == 0, so the kernel function early-returns from v3d_rewrite_csd_job_wg_counts_from_indirect after the v3d_get_bo_vaddr calls but before the OOB-write loop. the vmaps still materialize in /proc/vmallocinfo, but no OOB-write and no CSD-hardware job happen. diff vmallocinfo against the pre-submit snapshot and grab the two new v3d_get_bo_vaddr entries. per the source, v3d_get_bo_vaddr(bo) runs first (lower address) and v3d_get_bo_vaddr(indirect) runs second (higher address; the rb-tree's find_vmap_lowest_match picks the next free region above where it just placed bo). the higher one is indirect->vaddr. set outer_map[0..2] = ATTACK_PTE, predict the next submission's indirect->vaddr with a small empirical delta (about 16 KiB on this kernel; two BOs at 8 KiB each leave the next lowest free region at prev + 0x4000, modulo other vmap activity), and fire the real submission with uniform_idx computed for that prediction.
4 out of 10 boots. reliable enough that with a retry loop you could call it a real exploit, in this lab configuration, on this kernel.
then i tightened the kernel back to production defaults: kptr_restrict=2, no_hash_pointers off, /proc/vmallocinfo back to 0400, systemd unit removed. the blind-fire version of the chain (same code, no /proc/vmallocinfo read, just a wide sweep of uniform_idx values guessing where the cluster lands) got 0 out of 10.
you can build something that runs end-to-end and pops a shell, and the moment it leans on any state that doesn't survive default hardening it stops being an exploit and becomes a research demo. three sysctls is three sysctls too many.
the 40% ceiling
4 out of 10 in the lab is the ceiling anyway, even with perfect knowledge of every address. look at this part of v3d_rewrite_csd_job_wg_counts_from_indirect again:
wg_counts = (uint32_t *)(bo->vaddr + indirect_csd->offset);
…
args->cfg[0] = wg_counts[0] << V3D_CSD_CFG012_WG_COUNT_SHIFT;
args->cfg[1] = wg_counts[1] << V3D_CSD_CFG012_WG_COUNT_SHIFT;
args->cfg[2] = wg_counts[2] << V3D_CSD_CFG012_WG_COUNT_SHIFT;
num_batches = DIV_ROUND_UP(indirect_csd->wg_size, 16) *
(wg_counts[0] * wg_counts[1] * wg_counts[2]);
…
((uint32_t *)indirect->vaddr)[uniform_idx] = wg_counts[i];
the same wg_counts values do double duty: they're both the value written by the OOB-write and the workgroup counts the v3d hardware runs a compute shader with, right after the rewrite function returns and the CSD job is queued. the OOB-write value i want is ATTACK_PTE = 0x3000178f, a valid v3d MMU PTE pointing at modprobe's PFN. as a workgroup count it's 805 million. multiply three of those together and num_batches overflows into nonsense, the CSD hardware tries to dispatch, walks the v3d MMU into invalid territory, and dmesg fills with:
v3d fec00000.v3d: MMU error from client L2T (0) at 0x0, pte invalid
v3d fec00000.v3d: [drm:v3d_reset [v3d]] *ERROR* Resetting GPU for hang.
v3d fec00000.v3d: [drm:v3d_reset [v3d]] *ERROR* V3D_ERR_STAT: 0x00001000
v3d_reset kills the in-flight CSD job, possibly the queued TFU jobs too, and restarts the v3d driver. whether the TFU writes through the corrupted PTE land before the reset hits is a race. mostly it goes my way: the OOB-write happens in v3d_rewrite_csd_job_wg_counts_from_indirect on the cpu_job kthread before the CSD job is queued, and the TFU dispatch is on a separate pipeline. about 40% of the time the reset is fast enough to break the chain.
you might try to dodge this with a different cpu_job type. TIMESTAMP_QUERY writes ktime_get_real_ns() as a u64, RESET_TIMESTAMP_QUERY writes zero, COPY_PERFORMANCE_QUERY writes perf-counter values. none of those are valid v3d MMU PTEs, and none give you control over the written value. the coupling between OOB-write value and CSD workgroup count is structural: the source reads wg_counts once and uses it for both.
so 40% is the ceiling. that's the bug class on this driver in this kernel.
what it would take to ship
one thing: an unprivileged primitive that hands me the kernel virtual address of
an arbitrary v3d BO's vmap region. a single value. with it the
chain runs 40% per boot on a default-hardened kernel, which is a real exploit.
without it it's a demo with three sysctls flipped, and i'm not going to pretend
otherwise.
io_uring's stale-cmd[] leak (POLL_ADD primes cmd[40..47] with &io_poll_wake, ASYNC_CANCEL retires the kiocb to req_cache without scrubbing, WAITID then reads the stale bytes into siginfo) gives kernel text base on 6.12. on Ubuntu 24.04's 6.8 kernel the structural separation between cmd[] and WAITID's async_data seems to kill it: the WAITID op reads from a separate slab allocation, not the cancelled POLL's cmd[] residue. and even when it works, KASLR base doesn't tell you where vmalloc allocations land. the vmalloc area isn't at a fixed offset from text base; boot-time vmalloc allocations come from allocator state, not from where the kernel image got loaded.
the aa-rootns userns CAP bypass that beats AppArmor's apparmor_restrict_unprivileged_userns=1 on Ubuntu 24.04 gives namespace-scoped caps. /proc/vmallocinfo's permission check runs against init_user_ns, not the bypassed namespace, so all 41 caps inside your nested userns do nothing here. ns_capable(&init_user_ns, ...) stays false.
the v3d COPY_TIMESTAMP_QUERY CPU job is itself a u64 OOB-read at timestamp->vaddr + queries[i].offset, with the read value written to a BO you can mmap and read from userspace. structurally that's a leak primitive. but the user-controlled offset is relative to another dynamic vmalloc address, timestamp->vaddr, which is the same kind of address i don't know in the first place. chicken and egg.
Spectre-v2 BHB poisoning on the Pi 4's Cortex-A72 works as an unprivileged kernel branch-target-injection primitive; i have a vusec/bhi-spectre-bhb port running through to cross-privilege gadget execution. turning that into a vmalloc-area pointer leak is a multi-month side-channel project, and i have not done it.
five days, one inode-mode bit
the bug is real and CVD-grade. wg_uniform_offsets[i] does not validate against the indirect BO's actual size in any released v3d driver since the CPU jobs landed in mainline 6.6. every Raspberry Pi running 6.6 or later has it. the exploit primitive sits one inode-mode bit from shippable, and that bit is hardcoded in the kernel source.
some things i got wrong along the way: v3d_get_bo_vaddr does not cache, every call vmaps, every put unmaps, which i should have read before theorizing. the OOB-write target is indirect->vaddr, not bo->vaddr, which the source says plainly. arm64's vmalloc area and modules area are separate rb-trees, so you cannot shift one by stressing the other. "3 out of 5 with stub.ko" was noise, not signal. a sample size of five tells you nothing about a chain whose true hit rate is 0%.
the chain code, the lab predict primitive, the source dive, the dead ends are all sitting on a development Pi. maybe Spectre-v2 pans out. maybe a future CVE drops a vmalloc-area pointer somewhere unprivileged users can read. maybe the v3d driver eventually decouples wg_counts-as-write-value from wg_counts-as-shader-arg. or maybe somebody sends a patch to proc_create_seq_private("vmallocinfo", S_IRUSR…) that i haven't seen.
until then, this is a primitive that proves you can hold the whole bug class on paper and still have no exploit.
. _SiCk · afflicted.sh