The Calysteon Corner

A Guide to the Cyber Galaxy

Home

CVE-2026-43654: A Kernel Heap Information Disclosure via Uninitialized CMSG Padding

Apple Security Advisory: macOS Tahoe 26.5


Introduction

This is a kernel heap information disclosure I found in sbcreatecontrol(), part of bsd/kern/uipc_socket2.c in Apple’s open source XNU kernel. The bug lives in the alignment padding of a control message. It is three bytes wide, and stale kernel heap data passes through it into userspace.

Because XNU shares its ancestry with the other BSDs, a useful technique is available: when a code path in XNU looks suspicious, I can line it up against FreeBSD and OpenBSD. If those trees have already hardened a function that XNU left untouched, the divergence points straight at a bug. That is how this one was found.

When you call recvmsg(), the kernel can return out-of-band ancillary data alongside your payload. That data is packaged as a series of control messages, each prefixed by a struct cmsghdr:

struct cmsghdr {
    u_int32_t cmsg_len;    // data byte count, including this header
    int       cmsg_level;  // originating protocol
    int       cmsg_type;   // protocol-specific type
    // followed by cmsg_len - sizeof(struct cmsghdr) bytes of data
};

The wire format requires each control message to be aligned. Two macros matter here, and the vulnerability lives in the difference between them:

#define CMSG_LEN(l)   (CMSG_ALIGN(sizeof(struct cmsghdr)) + (l))
#define CMSG_SPACE(l) (CMSG_ALIGN(sizeof(struct cmsghdr)) + CMSG_ALIGN(l))

CMSG_LEN(l) is the number of meaningful bytes: the aligned header plus exactly l bytes of payload. CMSG_SPACE(l) is the total footprint the message occupies once the payload itself is rounded up to the next 4-byte boundary. For an aligned payload these two agree. But consider a single-byte payload, which is exactly what IP_RECVTOS and IP_RECVTTL deliver:

sizeof(struct cmsghdr) = 12
CMSG_LEN(1)            = 12 + 1        = 13
CMSG_SPACE(1)          = 12 + align(1) = 12 + 4 = 16

CMSG_LEN(1) is 13. CMSG_SPACE(1) is 16. There is a 3-byte gap between the end of the payload and the end of the message.

The Vulnerability

Every ancillary control message in the BSD stack is created by sbcreatecontrol(). Here is the relevant portion:

cp = mtod(m, struct cmsghdr *);
VERIFY(IS_P2ALIGNED(cp, sizeof(u_int32_t)));
(void) memcpy(CMSG_DATA(cp), p, size);   // [1] copies only 'size' bytes
m->m_len = (int32_t)CMSG_SPACE(size);    // [2] m_len spans the padding too
cp->cmsg_len = CMSG_LEN(size);

Consider those two lines.

At [1], the kernel copies exactly size bytes of real data into the message body. For IP_RECVTOS, size is sizeof(u_char), so precisely one byte is written.

At [2], the mbuf’s length is set to CMSG_SPACE(size), which as computed above is 16 bytes for a 1-byte payload. The message now claims to be 16 bytes long, but only 13 of them were ever initialized. The final 3 bytes, the alignment padding, are never touched.

The question is what was already sitting in those three bytes before this mbuf was handed to us. If mbufs came back zeroed, the answer would be a run of nulls. They do not.

Root Cause

Two facts turn a cosmetic oversight into a real leak.

First, mbufs are not zeroed on allocation. The mbuf zone is created without the Z_ZERO flag, so m_get() returns memory with whatever contents the zone happened to have (bsd/kern/uipc_mbuf.c, around the ZONE_ID_MBUF setup):

// ZONE_ID_MBUF is created without Z_ZERO -> m_dat[] is not cleared

Second, when an mbuf is freed, XNU takes the fast path that skips scrubbing:

zfree_nozero(...);  // freed memory is returned to the zone un-wiped

Together, the mbuf zone is a pool of recently freed network buffers whose contents are never cleared on the way out or on the way back in. Whatever protocol data, header fragment, or allocator metadata last occupied those bytes is still there when the buffer is recycled into our control message. The 3-byte padding gap exposes that pool.

The final piece is copyout_control(), which ships the message to userspace. It copies the full CMSG_ALIGN(cmsg_len) region, padding and all, out to the caller’s buffer. Nothing along the way notices that three of those bytes were never legitimately written. They travel intact from the kernel heap into an unprivileged process.

This is not a novel bug class. Both FreeBSD and OpenBSD encountered this exact uninitialized-padding leak in their own sbcreatecontrol() and fixed it years ago by zeroing the buffer before populating it. The remedy in both trees is a single line:

bzero(cp, CMSG_SPACE(size));

XNU forked from the same lineage and never picked up the hardening. Diffing the shared function against its counterparts makes the divergence clear: where FreeBSD and OpenBSD zero, XNU does not. For completeness, the twin function sbcreatecontrol_mbuf() has the identical flaw. It appends into a shared mbuf with the same m->m_len += CMSG_SPACE(size) accounting and never clears the padding either.

Reproduction

Reaching the vulnerable path requires no privilege. Conceptually, we open a UDP socket, ask for the TOS byte via IP_RECVTOS (or the TTL via IP_RECVTTL), send ourselves a datagram on loopback, and read back the control message with recvmsg(). The 1-byte TOS payload guarantees the 3-byte padding gap.

Poisoning the userspace control buffer with 0xCC before the recvmsg() makes the leak visible: anything the kernel does not overwrite stays 0xCC. On a fixed kernel the padding comes back as zeroes. On a vulnerable one, those three bytes are neither 0xCC nor zero. They are live kernel heap.

Observed Output

Running the harness in a loop across 200 rounds, non-zero padding showed up in 130+ of them. The leaked bytes are not random noise; many are recognizable fragments of data belonging to other processes on the machine:

22 3a 7b  ":{   JSON fragment from daemon HTTP traffic
4f 68 0a  Oh\n  HTTP response fragment
75 63 68  uch   String fragment (URL/header)
12 0b 2f  ../   Path fragment
a8 00 24        Network metadata
90 a2 5b        Kernel allocator metadata

The ":{ and Oh\n are pieces of some daemon’s HTTP traffic. The ../ is a path fragment. The last two rows are kernel allocator bookkeeping. All of it recycled out of the mbuf zone and into a process that was never entitled to see any of it.

The primitive needs no entitlements, no network permission prompt, and no user interaction: just socket(), setsockopt(), sendto(), and recvmsg() on loopback, well within reach of a sandboxed iOS app. Enabling both IP_RECVTOS and IP_RECVTTL yields 6 leaked bytes per recvmsg(), which sustains roughly 60 KB/s of stale mbuf-zone data drawn from every network-active process on the device. The fix is the one FreeBSD and OpenBSD already shipped: a single bzero(cp, CMSG_SPACE(size)) in each of sbcreatecontrol() and sbcreatecontrol_mbuf(), filling the padding gap before it can be read.