
Android kernel CVE analysis and PoC for a MediaTek ION allocator type confusion, covering root-cause diffing, unprivileged trigger, and exploitability assessment.
Summary. CVE-2023-20768 is a type confusion (CWE-843) in MediaTek's ION allocator. I checked whether it is actually exploitable on a Samsung SM-M325F (Galaxy M32, Helio G80) running the July 2022 firmware. The vulnerable code is present, and I proved that it runs on this device when triggered by an unprivileged process. I was not able to weaponize it. The ioctl path is bounded by input validation, and the path that contains the real out-of-bounds read only ever processes genuine ION buffers, because a second check on the dma_buf_ops pointer rejects anything I could forge. This writeup covers how I got to that conclusion, including one intermediate conclusion that turned out to be wrong.
The CVE is public and patched. All testing was done on my own device, rooted with Magisk.
The bug is in MediaTek's ION code, not in upstream Linux or in anything Samsung wrote. MediaTek ships its own fork of the Android ION allocator in the BSP that goes to every vendor building on their chips. The M32 uses a Helio G80, so it gets that code. The same phone with an Exynos SoC would not be affected at all.
I pulled the 2022 (vulnerable) and 2023 (patched) vmlinux images and diffed them in IDA. Two ION functions changed:
| Function | 2022 | 2023 |
|---|---|---|
ion_drv_file_to_buffer | strstr(name, "dmabuf") | is_dma_buf_file() |
_ion_ioctl | strcmp(name, "ion") | is_dma_buf_file() |
is_dma_buf_file does not exist in the 2022 image. It appears in the 2023 one. So both functions were deciding whether a struct file was a dma_buf by looking at a name, and the fix replaced that with a real type check. Confusing an object here means the kernel reads a non-dma_buf as if it were one.
Of the two, _ion_ioctl is the one an unprivileged process can reach:
open("/dev/ion")
-> ion_ioctl (.unlocked_ioctl)
-> ION_IOC_CUSTOM (0xC0104906)
-> ion_custom_ioctl
-> _ion_ioctl
-> case 0: ION_SYS_CACHE_SYNC
-> find_vma(user_VA) (call site at _ion_ioctl+0x9f0)
-> strcmp(vma->vm_file...name, "ion")
The request is an ion_custom_data { u32 cmd = 0; u64 arg; } pointing at a 120 byte ion_sys_data:
spoof.c builds this.
I could not just trace it. The device blocks kprobe_events, set_ftrace_filter and function_graph through Samsung's SELinux policy and kernel hardening, and ION's printks are debug-gated so dmesg stays quiet.
So I used return codes as an oracle instead. Four requests, and the pattern of what comes back tells you where execution went:
C returning success means the switch really is dispatching on sys_cmd. A and B differing means the VA is being processed, which puts execution inside find_vma. That is the vulnerable path, reached without root.
Reaching the check is not the same as beating it. I tested what the strcmp actually accepts:
ION_IOC_SHARE then mmap passes, returns 0.memfd:ion fails, -EFAULT.ion also fails, -EFAULT.So the field being compared at vm_file+0x60 is not the filename. It is internal to dma_buf, almost certainly dma_buf->exp_name, which ION sets to "ion". The check is unsound by design, but nothing I can create from userspace can set that field.
I then fuzzed the path: sync_type 0 through 7, sizes {0, 1, 0x1000, 0x100000, 0xffffffff}, VAs {real ion buffer, memfd, 0}, 120 cases, plus a freed-handle probe for a use-after-free. No crash, device stayed up. Oversized sizes bail out before find_vma and return 0. sync types 3 to 5 hit the m4u path and return -EPERM. Anything above 5 returns -EINVAL. A freed handle returns -EINVAL, so ION validates it and there is no UAF there.
The actual out-of-bounds read is in ion_drv_file_to_buffer. It does ldr [private_data+0x28], reading a non-dma_buf's private_data as if it were a dma_buf. There is an ops == &ion_dma_buf_ops comparison (the table is at 0xFFFFFF800A097F18), but it happens after that read, so it does not prevent it. Downstream, __do_dump_share_fd reads fields at +0x28, +0x48, +0x50, +0xb8, +0xe4 off the returned buffer and prints them, and ldr x8, [buf+0x28]; ldr [x8+0x30] is a wild pointer deref for a confused object.
The trigger is ion_dump_all_share_fds, which uses iterate_fd to walk every ION client process's file descriptors. My first conclusion was that this only runs when you read an ION debugfs node, and this kernel has CONFIG_DEBUG_FS unset. I verified that three ways: /proc/config.gz, debugfs missing from /proc/filesystems, and mount -t debugfs returning ENODEV. I wrote the path off as structurally unreachable.
That was wrong. dump_header, the OOM killer's memory dump, contains a direct bl ion_mm_heap_memory_detail at 0xffffff8008204b9c, and dump_header is called from out_of_memory and oom_kill_process. No debugfs needed.
memcg_oom.c confirms it. It creates a cgroup under /dev/memcg, caps both memory.limit_in_bytes and memory.memsw.limit_in_bytes at 8MB (capping only the first lets the child escape into zram swap), and forks a child that allocates until it dies. dmesg then shows:
dump_header <- oom_kill_process <- out_of_memory <- mem_cgroup_oom_synchronize
followed by the full ion_mm_heap_memory_detail and __do_dump_share_fd output resolving real gralloc dma_bufs. So the vulnerable function executes, during something any unprivileged process can cause. There is a third trigger too, ShowStatus from hang_detect_dump_thread in the MediaTek watchdog.
I held 32 memfds named memfd:dmabuf and triggered the same memcg OOM. If one of mine had been fed to ion_drv_file_to_buffer, the strstr would pass, private_data would be NULL, and the kernel would print [ION]ion_drv_file_to_buffer warnning, dmabuf is NULL at KERN_ERR. That line never appeared. Only graphics and gralloc clients got dumped. Either a plain /dev/ion client's fd table is not what iterate_fd walks here, or memfd fails silently before the print.
Either way the OOM dump only ever handles the system's legitimate dma_bufs, which pass cleanly. memfd is also the only fd type whose name I can control enough to contain "dmabuf", and it cannot fault.
The vulnerability is present. The vulnerable code is reachable and does execute on this build, triggerable without root. It is not weaponizable from userspace here. The ioctl path is bounded by handle validation, a size check and access_ok. The dump path only sees real ION buffers, and forgery is blocked by ops == &ion_dma_buf_ops. Getting further would need a controllable non-ION object whose exp_name is "ion", or a different primitive such as an ION buffer UAF, or a TOCTOU race.
spoof.c — cache-sync ioctl PoC and the differential oraclememcg_oom.c — memcg OOM trigger for the dump pathtrigger.c, oom_trigger.c — earlier trigger attemptsboot_images/ — extracted kernel images (2022 and 2023) and the IDA database for the 2022 build| Offset |
|---|
| Field |
|---|
+0x00 | sys_cmd = 0 |
+0x08 | ion handle (allocate one first, heap_id_mask = 0x1 works) |
+0x10 | user virtual address |
+0x18 | low half = size, high half = sync_type in {0,1,2} |
| Case | Request | Result |
|---|
| A | sys_cmd=0, spoofed VA | -EFAULT |
| B | sys_cmd=0, VA = 0 | 0 |
| C | sys_cmd=4 | 0 |
| D | sys_cmd=99 | -EFAULT |