diff --git a/CVE-2026-112705/CVE-2026-112705.c b/CVE-2026-112705/CVE-2026-112705.c index 4fbe69c..de84ada 100644 --- a/CVE-2026-112705/CVE-2026-112705.c +++ b/CVE-2026-112705/CVE-2026-112705.c @@ -15,6 +15,7 @@ // Build: clang -O2 -o cve-2026-112705 CVE-2026-112705.c \ // -framework VideoToolbox -framework CoreMedia -framework CoreVideo -framework CoreFoundation // Run: ./cve-2026-112705 [layers] [--force-host] +// ./cve-2026-112705 --sweep (property acceptance 0..12, no frame) #include #include @@ -54,8 +55,10 @@ static void print_readback(VTCompressionSessionRef s, const char *key) { int main(int argc, char **argv) { int layers = 16463; int force_host = 0; + int sweep = 0; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--force-host") == 0) force_host = 1; + else if (strcmp(argv[i], "--sweep") == 0) sweep = 1; else if (atoi(argv[i]) > 0) layers = atoi(argv[i]); } @@ -76,6 +79,21 @@ int main(int argc, char **argv) { set_prop(session, "RealTime", kCFBooleanFalse); printf("EncoderUsage=%d RCMode=%d\n", su, sr); + if (sweep) { + // 27.2 clamps the property to 0..7 (the new assertion string is + // "iNumberOfTemporalLayers <= 7"); 27.0/27.0.1 accepts the whole range. + printf("sweep: NTL acceptance 0..12 (no frame submitted)\n"); + for (int n = 0; n <= 12; n++) { + CFNumberRef nv = CFNumberCreate(NULL, kCFNumberIntType, &n); + int32_t st = set_prop(session, "NumberOfTemporalLayers", nv); + CFRelease(nv); + printf("NTL %2d: status=%d\n", n, st); + } + VTCompressionSessionInvalidate(session); + CFRelease(session); + return 0; + } + CFNumberRef n = CFNumberCreate(NULL, kCFNumberIntType, &layers); int32_t sn = set_prop(session, "NumberOfTemporalLayers", n); CFRelease(n); diff --git a/CVE-2026-112705/CVE-2026-112705.swift b/CVE-2026-112705/CVE-2026-112705.swift index a551856..a461d3e 100644 --- a/CVE-2026-112705/CVE-2026-112705.swift +++ b/CVE-2026-112705/CVE-2026-112705.swift @@ -11,6 +11,7 @@ // Build: xcrun -sdk iphoneos swiftc -O -o cve-2026-112705 CVE-2026-112705.swift \ // -framework VideoToolbox -framework CoreMedia -framework CoreVideo // Run: ./cve-2026-112705 [layers] [--force-host] default 16463 +// ./cve-2026-112705 --sweep property acceptance 0..12, no frame import Foundation import VideoToolbox @@ -19,6 +20,7 @@ import CoreVideo let args = CommandLine.arguments let forceHost = args.contains("--force-host") +let sweep = args.contains("--sweep") let layers = args.dropFirst().first(where: { Int($0) != nil }).flatMap { Int($0) } ?? 16463 func fourCC(_ v: OSStatus) -> String { @@ -63,6 +65,17 @@ print("EncoderUsage=\(su) RCMode=\(sr)") let sn = set(s, "NumberOfTemporalLayers", NSNumber(value: layers)) print("NumberOfTemporalLayers=\(layers) status=\(sn) readback: \(readBack(s, "NumberOfTemporalLayers"))") +if sweep { + // 27.2 clamps to 0...7; 27.0/27.0.1 accepts the full range. + print("sweep: NTL acceptance 0...12 (no frame submitted)") + for n in 0...12 { + let st = set(s, "NumberOfTemporalLayers", NSNumber(value: n)) + print("NTL \(n): status=\(st)") + } + VTCompressionSessionInvalidate(s) + exit(0) +} + if sn != 0 { print("property refused — fixed build (27.2+) or a platform without the AVE2 property") VTCompressionSessionInvalidate(s) diff --git a/Info.plist b/Info.plist index c81a589..668bb59 100644 Binary files a/Info.plist and b/Info.plist differ diff --git a/README.md b/README.md index 9574714..2caa1db 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ bash scripts/build-sim.sh # → build/sim/Cinder27.app (arm64 simulator) python3 scripts/gen_seeds.py # → seeds/ (the app's built-in seeds, byte-identical to the .py POCs) ``` -Device install: bundle id `com.jbjf.cinder27` (v2.2 build 6), unsigned — sign with your own certificate + a matching (or wildcard) provisioning profile, then sideload. +Device install: bundle id `com.jbjf.cinder27` (v2.3 build 7), unsigned — sign with your own certificate + a matching (or wildcard) provisioning profile, then sideload. ## Verification status @@ -145,6 +145,8 @@ Kernel R/W feasibility for the high/critical entries is in [docs/KERNEL-RW-ASSES 112507 reachability was re-measured on the target (iPhone19,7, iOS 27.0.1) with the new `kern.ipc.lifecycle` module and an `armed` counter in `kern.ipc`: `mach_port_request_notification` returns -300 (MIG_TYPE_ERROR) for every type/right combination tried, 0 armed out of 12,288 registrations. The request table is never armed, so the race is not reachable from an unentitled app; the earlier "survived the interleaving" line is retracted. Details in [docs/KERNEL-RW-ASSESSMENT.md](docs/KERNEL-RW-ASSESSMENT.md). +**iOS 27.2 firmware diff (27.0.1 vs 27.2, iPhone19,7):** [docs/IOS27.2-KERNEL-DIFF.md](docs/IOS27.2-KERNEL-DIFF.md). The update carries 162 advisory CVEs; 93 of 303 common kexts changed text size. Confirmed fix sites: 112705 clamps `NumberOfTemporalLayers` to 0…7 (`iNumberOfTemporalLayers <= 7` assertion), 113910 adds `chunk %u produced %lld of %lld expected bytes`, 112507 adds `ipr != NULL` / `table != NULL` in `ipc_misc.c`. `__DATA_SPTM` is byte-identical across both builds; the PPL/SPTM/MIE string and code surface is unchanged, so no bypass for those three is fixed in 27.2. + ## Module semantics - **tier 1** — direct primitive hit (OOB write / UAF / type confusion); the crash is the evidence diff --git a/README.zh-CN.md b/README.zh-CN.md index 6481267..e5d1fb0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -130,7 +130,7 @@ bash scripts/build-sim.sh # → build/sim/Cinder27.app(arm64 模拟器 python3 scripts/gen_seeds.py # → seeds/(app 内置种子,与 .py POC 产物逐字节一致) ``` -设备安装:bundle id `com.jbjf.cinder27`(v2.2 build 6),未签名——用自有证书 + 匹配(或通配符)描述文件签名后侧载。 +设备安装:bundle id `com.jbjf.cinder27`(v2.3 build 7),未签名——用自有证书 + 匹配(或通配符)描述文件签名后侧载。 ## 验证状态 @@ -144,6 +144,8 @@ armed 不是超时——是**死亡 oracle 命中**:负载触发进程崩溃 112507 的可达性已在目标机(iPhone19,7 / iOS 27.0.1)上用新增的 `kern.ipc.lifecycle` 模块与 `kern.ipc` 的 `armed` 计数重测:`mach_port_request_notification` 在所有类型/权限组合上都返回 -300(MIG_TYPE_ERROR),12,288 次注册 0 次成功。请求表从未被武装,因此无特权 app 触达不到该竞态;此前“活过交错”的结论撤回。详见 [docs/KERNEL-RW-ASSESSMENT.md](docs/KERNEL-RW-ASSESSMENT.md)。 +**iOS 27.2 固件 diff(27.0.1 vs 27.2,iPhone19,7):** [docs/IOS27.2-KERNEL-DIFF.md](docs/IOS27.2-KERNEL-DIFF.md)。本次更新含 162 条公告 CVE;303 个共有 kext 中 93 个 `__TEXT_EXEC` 尺寸变化。已确认的修复点:112705 把 `NumberOfTemporalLayers` 收紧到 0…7(断言 `iNumberOfTemporalLayers <= 7`),113910 新增 `chunk %u produced %lld of %lld expected bytes`,112507 在 `ipc_misc.c` 新增 `ipr != NULL` / `table != NULL`。`__DATA_SPTM` 两版字节完全一致,PPL/SPTM/MIE 的字符串与代码面没有变化 —— 27.2 没有修这三个 bypass。 + ## 模块语义 - **tier 1** — 直接原语命中(OOB 写 / UAF / 类型混淆),崩溃即证据 diff --git a/Sources/Catalog4.swift b/Sources/Catalog4.swift index ff25f3d..0400052 100644 --- a/Sources/Catalog4.swift +++ b/Sources/Catalog4.swift @@ -54,12 +54,12 @@ extension Catalog { lines.append("EncoderUsage=\(su) RCMode=\(sr)") var accepted: [Int] = [] - for n in [64, 1024, 16462, 16463] { + for n in [0, 1, 7, 8, 64, 1024, 16462, 16463] { let st = aveSet(s, "NumberOfTemporalLayers", NSNumber(value: n)) lines.append("NTL \(n): status=\(st) readback: \(aveRead(s, "NumberOfTemporalLayers"))") if st == 0 { accepted.append(n) } } - ctx.disarm("ave.ntl", "accepted=\(accepted.count)/4") + ctx.disarm("ave.ntl", "accepted=\(accepted.count)/8") let verdict: String if accepted.contains(16463) { verdict = tl("16463 被接受 —— 该构建的设备在 fire 时会 panic") diff --git a/docs/IOS27.2-KERNEL-DIFF.md b/docs/IOS27.2-KERNEL-DIFF.md new file mode 100644 index 0000000..2e731d3 --- /dev/null +++ b/docs/IOS27.2-KERNEL-DIFF.md @@ -0,0 +1,175 @@ +# iOS 27.2 kernel differential — 27.0.1 vs 27.2 (iPhone19,7) + +Scope: the iOS 27.2 security update (24B119) against 27.0.1 (24A446) for iPhone 18 +Pro Max (`iPhone19,7`). The question: what does the update actually change in the +kernel, which of the 162 advisory CVEs can be tied to a fix site, and does anything +in the diff look like a PPL / SPTM / MIE bypass. + +## How the images were obtained + +Both restore images are the production IPSWs from Apple's CDN: + +| Build | Version | Size | Kernelcache member | +|---|---|---|---| +| 24A446 | 27.0.1 | 12,728,434,807 B | `kernelcache.release.v6x-pandia` | +| 24B119 | 27.2 | 13,722,855,612 B | `kernelcache.release.v6x-pandia` | + +The kernelcache was pulled straight out of the ZIP with HTTP Range requests, so the +analysis did not wait for the full 26 GB. The container is IM4P; the payload is LZFSE. + +| Build | Kernelcache (decoded) | SHA-256 (first 16) | +|---|---|---| +| 27.0.1 | 78,315,520 B | `dec73956a9a443c5` | +| 27.2 | 79,167,488 B | `9a6efbc4be67319a` | + +Both are `MH_FILESET` images with a `__DATA_SPTM` segment, carrying 303 (27.0.1) and +305 (27.2) fileset entries. Two kexts are new: `com.apple.driver.ApplePencilDriver`, +`com.apple.iokit.AppleSecurityResearch`; none were removed. + +## Reading the diff + +Raw text-segment hashes are useless here: the fileset is relinked, so branch +displacements and absolute literal pools move in almost every kext even when the +code is unchanged. The diff therefore keys on: + +1. `__TEXT_EXEC` size change (layout-independent), +2. `__cstring` / `__oslogstring` set difference (new checks usually add a message), +3. `llvm-objdump` instruction diff for the kexts that survive both filters. + +93 of 303 common kexts changed text size. The largest are the ones the advisory +points at: + +| Delta | Kext | Advisory component | +|---|---|---| +| +115,472 | `com.apple.driver.ASIOKit` | IOKit / audio | +| +39,624 | `com.apple.driver.AppleH16ANEInterface` | Apple Neural Engine | +| +29,376 | `com.apple.kernel` | Kernel | +| +16,080 | `com.apple.driver.AppleAVE2` | AVEVideoEncoder | +| +9,572 | `com.apple.driver.FairPlayIOKit` | Security | +| +9,316 | `com.apple.driver.AppleDisplayCrossbar` | IOMobileFrameBuffer | +| +8,304 | `com.apple.AGXG19P` | GPU | +| +4,712 | `com.apple.driver.AppleMobileDispH19P-DCP` | IOMobileFrameBuffer | +| +4,480 | `com.apple.driver.RTBuddy` | Kernel / RTKit | +| +4,152 | `com.apple.driver.AppleSARService` | Kernel | + +## Fix sites that are confirmed by strings + +### CVE-2026-112705 — AppleAVE2 temporal layers + +27.2 adds the upper bound that 27.0.1 lacks: + +``` +number of temporal layers (prop) is out of range %d %d %d +0 <= pInfo->uPropCfg.sHEVC.iNumberOfTemporalLayers + && pInfo->uPropCfg.sHEVC.iNumberOfTemporalLayers <= 7 +0 < pInfo->sSessionCfg.sEnc.sAlgCfg.sGOP.iNumOfTemporalLayer + && (... <= 7) +AVE_HEVC_CheckVPSExtBounds +HEVC VPS vps_max_layers_minus1 is out of range +HEVC VPS layer_id_in_nuh is out of range +HEVC VPS layer-set total is out of range +``` + +The property is now clamped to **0…7**. The trigger in the 27.0.1 dossier +(`NumberOfTemporalLayers = 16463`, first frame → AppleAVE2 +0xDA78) is exactly the +value this assertion rejects. A `--sweep` mode was added to both POCs: it sets +0…12 and submits no frame. On macOS 27.0 it accepts 1…12 (and 16463); on 27.2 the +>7 rungs must return an error. + +### CVE-2026-113910 — decmpfs chunked zlib + +27.2 adds the produced-vs-expected check in +`AppleFSCompression/Common/ChunkCompression.cpp`: + +``` +%s:%d: chunk %u produced %lld of %lld expected bytes +``` + +That is the exact under-production condition the leak harness drives. The C POC's +`--loop` / `--keep` collector now sits on a documented fix site. + +### CVE-2026-112507 — ipc_port request table + +27.2 adds the checks the 27.0.1 code was missing: + +``` +ipr != ((void*)0) +table != ((void*)0) +ipc_misc.c +``` + +This is a **NULL-check fix**, not a reclaimable-object fix. It matches the device +result already recorded: `mach_port_request_notification` returns -300 +(MIG_TYPE_ERROR) for every type/right combination, 0 of 12,288 registrations armed, +so the request-table path is never entered from an unentitled app. + +### Other kernel changes + +The core kernel gains 62 meaningful strings (build paths and UUIDs excluded). The +security-relevant ones: + +- `VM entry %p lock shared lock overflow` — a new vm_map lock-overflow panic, + consistent with the COW / vm_map CVEs (112474 / 112576). +- `Page modified after framebank withdraw %p` — page-lifecycle check. +- `size <= sdl->sdl_alen` — sockaddr_dl bounds check in the network stack. +- `speculative_pages_freed` / `_demoted` / `_requeued_inactive` — pageout counters. +- `FilterDropBadDirection`, `nx_netif_filter_pkt_to_mbuf`, `pktap != NULL`, + `ret == TB_ERROR_SUCCESS` — network filter validation. +- `ipr != NULL`, `table != NULL`, `ipc_misc.c` — the 112507 fix. +- New entitlements: `com.apple.private.verified-jit-mode`, + `com.apple.private.vfs.unmunged-access-time`, `com.apple.security.cs.debugger`. + +These line up with the 15 Kernel entries in the advisory (UAF, OOB read/write, race, +type confusion, COW integrity, kernel-layout disclosure). The advisory does not +assign individual functions, so the mapping above is evidence-based, not a +per-CVE signature. + +## PPL / SPTM / MIE + +This is the part the request specifically asked to dig into. + +- `__DATA_SPTM` is **byte-identical** in both builds: 311,296 bytes, SHA-256 + `f4bc1e5e95f61af5…`. No SPTM code or data changed. +- The PPL token strings (`UAT_PPL_IOCTL_*`, `PPL map operation failed`, + `PPL state object`) are unchanged; no new PPL checks or messages. +- No MIE / Memory Integrity / mtag / page-protection strings were added or removed. +- The only SPTM-related additions are boot-time memory-size validation: + `memsize=%u MB leaves no memory for XNU/SPTM to manage`, `Mismatch of + ARM_LARGE_MEMORY in SPTM/XNU`, and iBoot DRAM-size checks. These are + configuration checks, not a permission bypass. +- The 27.2 advisory contains no component named PPL, SPTM, MIE, or Page Protection, + and no CVE description mentions them. + +**Verdict: no PPL, SPTM, or MIE bypass is fixed in iOS 27.2, and the firmware diff +shows no change in the SPTM/PPL/MIE code paths.** A bypass PoC for any of the three +would have to come from a different bug, not from this update. The kernel CVEs with +"write kernel memory" impacts are ordinary kernel memory-safety bugs; none of them +is a page-table / monitor bypass on its own, and exploiting one would still have to +defeat SPTM/PPL/MIE at runtime. + +## What can be turned into a PoC + +| CVE | Primitive | PoC status | +|---|---|---| +| 2026-112705 | fixed-offset constant fill in AppleAVE2 | existing Swift/C POC + new `--sweep`; kernel panic on 27.0.1, property refused on 27.2 | +| 2026-113910 | kernel heap disclosure through decmpfs | existing C collector (`--loop`, `--keep`, pointer classes); read-only | +| 2026-112507 | request-table NULL check / race | existing harness; unreachable from an unentitled app (-300) | +| 2026-112696 | ANE validation, +39 KB | no trigger in the diff; ANE kext strings show resource/scheduler validation, not a user-reachable entry | +| 2026-65385 | IOHIDEvent fixed-offset write | IOHIDFamily grew only 236 B (+ `TCCService`); the advisory's IOKit fix is likely in the userland IOKit.framework (DSC), not this kext | +| other Kernel CVEs | UAF/OOB/race | fix sites visible as strings, but no trigger or reclaim strategy; writing a PoC would be guesswork | + +The WebKit, ImageIO, CoreMedia and other userland components live in the dyld shared +cache inside the root filesystem image, which is not part of the ZIP's plain files; +those were not diffed here. + +## Reproduce + +```bash +python3 tools/ipsw/remote_ipsw.py extract kernelcache work/kernel/27.2.im4p +python3 tools/ipsw/ipswlib.py kernel ... # decode + list +python3 tools/ipsw/extract_kext.py work/kernel/27.2.kernel \ + work/kernel/27.2-entries.json com.apple.driver.AppleAVE2 out.macho +python3 tools/ipsw/disasm_diff.py a.macho b.macho +``` + +The tools are read-only: no device is touched, and no firmware is modified. diff --git a/docs/KERNEL-RW-ASSESSMENT.md b/docs/KERNEL-RW-ASSESSMENT.md index 3b9251f..98c7027 100644 --- a/docs/KERNEL-RW-ASSESSMENT.md +++ b/docs/KERNEL-RW-ASSESSMENT.md @@ -87,6 +87,10 @@ harness was measuring port churn, not the bug. The published fix shape (NULL che on `ipr` / `table`) is consistent with a denial or null-dereference path, not with a reclaimable object. +**27.2 firmware confirmation (2026-10-31):** the 27.2 kernel adds `ipr != NULL` / +`table != NULL` in `ipc_misc.c` — a NULL-check fix, not a reclaimable-object fix. +The 27.0.1/27.2 kernelcache diff is in [IOS27.2-KERNEL-DIFF.md](IOS27.2-KERNEL-DIFF.md). + **Verdict: closed for this app.** The remaining hypothetical is a platform process that is allowed to register notifications; that is outside Cinder27's reach. diff --git a/tools/ipsw/README.md b/tools/ipsw/README.md new file mode 100644 index 0000000..e8d3915 --- /dev/null +++ b/tools/ipsw/README.md @@ -0,0 +1,16 @@ +# IPSW kernel-diff toolkit + +Read-only helpers used for the 27.0.1 → 27.2 kernel comparison in +[`docs/IOS27.2-KERNEL-DIFF.md`](../../docs/IOS27.2-KERNEL-DIFF.md). + +| Script | Purpose | +|---|---| +| `remote_ipsw.py` | HTTP-Range ZIP reader: list/extract a member without downloading the whole IPSW | +| `ipswlib.py` | IM4P/LZFSE decode, Mach-O/fileset parse, cstring extraction | +| `extract_kext.py` | dump one fileset entry as a standalone Mach-O (linkedit offsets rebased) | +| `disasm_diff.py` | normalized `llvm-objdump` instruction diff for two kexts | +| `diff_kernel.py` | kext-level diff (bundle id, size, strings, function hashes) | +| `map_advisory.py` | advisory CVE list → component/binary candidate table | + +Requirements: Python 3, `pip install lzfse macholib`, Xcode command line tools +(`llvm-objdump`). Nothing writes to a device. diff --git a/tools/ipsw/diff_kernel.py b/tools/ipsw/diff_kernel.py new file mode 100644 index 0000000..aadbfa4 --- /dev/null +++ b/tools/ipsw/diff_kernel.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Diff two prelinked kernelcaches (27.0.1 vs 27.2) at kext and function level. + +Usage: + python3 diff_kernel.py [outdir] + +Outputs: + kexts.json per-kext uuid/text hash/bundle id for both builds + changed.txt human-readable summary + strings-.txt string adds/removes for changed kexts +""" +import hashlib +import json +import os +import re +import sys +import zipfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ipswlib + + +def ascii_strings(data, minlen=6): + out = [] + cur = bytearray() + for b in data: + if 32 <= b < 127: + cur.append(b) + else: + if len(cur) >= minlen: + out.append(cur.decode("ascii", "ignore")) + cur = bytearray() + if len(cur) >= minlen: + out.append(cur.decode("ascii", "ignore")) + return out + + +BID_RE = re.compile(r"^com\.apple\.[A-Za-z0-9_.-]{4,}$") + + +def bundle_id(m): + """Heuristic: the kext identifier is the longest com.apple.* string in __TEXT.""" + cands = [s for s in ascii_strings(m.text_bytes()) if BID_RE.match(s)] + cands.sort(key=lambda s: (-s.count("."), -len(s))) + return cands[0] if cands else None + + +def func_hashes(m): + starts = m.function_offsets() + text = m.text_bytes() + t = m.text_segment() + out = [] + for i, addr in enumerate(starts): + end = starts[i + 1] if i + 1 < len(starts) else t["vmaddr"] + t["vmsize"] + off = m.vm_to_file(addr) + endoff = m.vm_to_file(end) + if off is None or endoff is None or endoff < off: + continue + chunk = m.data[off:endoff] + out.append((addr, hashlib.sha256(chunk).hexdigest()[:16], len(chunk))) + return out + + +def load(ipsw, member): + data = ipswlib.read_kernelcache(ipsw, member) + ms = ipswlib.scan_machos(data) + rows = [] + for m in ms: + rows.append({ + "uuid": m.uuid, + "cpu": ipswlib.CPU_NAMES.get(m.cputype, hex(m.cputype)), + "bid": bundle_id(m), + "text_sha": hashlib.sha256(m.text_bytes()).hexdigest()[:16], + "text_size": len(m.text_bytes()), + "nfuncs": len(m.function_offsets()) if m.function_starts else 0, + "_m": m, + }) + return data, rows + + +def main(): + a_ipsw, a_mem, b_ipsw, b_mem = sys.argv[1:5] + outdir = sys.argv[5] if len(sys.argv) > 5 else "work/kernel-diff" + os.makedirs(outdir, exist_ok=True) + + print("== extracting", a_mem) + a, ra = load(a_ipsw, a_mem) + print("== extracting", b_mem) + b, rb = load(b_ipsw, b_mem) + + def index(rows): + by_bid = {} + no_bid = [] + for r in rows: + if r["bid"]: + by_bid.setdefault(r["bid"], []).append(r) + else: + no_bid.append(r) + return by_bid, no_bid + + ia, na = index(ra) + ib, nb = index(rb) + + changed, added, removed, same = [], [], [], [] + for bid in sorted(set(ia) | set(ib)): + la = ia.get(bid, []) + lb = ib.get(bid, []) + if la and lb: + # pair in order; usually one Mach-O per kext + for x, y in zip(la, lb): + if x["uuid"] == y["uuid"] and x["text_sha"] == y["text_sha"]: + same.append(bid) + else: + changed.append((bid, x, y)) + elif la: + removed += [bid] + else: + added += [bid] + + lines = [] + lines.append(f"kexts: a={len(ra)} b={len(rb)} same={len(same)} changed={len(changed)} added={len(added)} removed={len(removed)}") + lines.append("") + for bid, x, y in changed: + lines.append(f"CHANGED {bid}: uuid {x['uuid']} -> {y['uuid']} text {x['text_size']} -> {y['text_size']} funcs {x['nfuncs']} -> {y['nfuncs']}") + for bid in added: + lines.append(f"ADDED {bid}") + for bid in removed: + lines.append(f"REMOVED {bid}") + open(os.path.join(outdir, "changed.txt"), "w").write("\n".join(lines) + "\n") + print("\n".join(lines[:80])) + + # string + function diff for changed kexts + for bid, x, y in changed: + ma = x["_m"] + mb = y["_m"] + sa = set(ascii_strings(ma.text_bytes(), 8)) + sb = set(ascii_strings(mb.text_bytes(), 8)) + new = sorted(sb - sa) + gone = sorted(sa - sb) + safe = bid.replace("/", "_") + with open(os.path.join(outdir, f"strings-{safe}.txt"), "w") as f: + f.write(f"# {bid}\n## new strings ({len(new)})\n") + for s in new[:400]: + f.write(s + "\n") + f.write(f"\n## removed strings ({len(gone)})\n") + for s in gone[:200]: + f.write(s + "\n") + fa = func_hashes(ma) + fb = func_hashes(mb) + changed_funcs = sum(1 for p, q in zip(fa, fb) if p[1] != q[1]) + lines.append(f" {bid}: functions {len(fa)} -> {len(fb)}, hash-changed in common prefix {changed_funcs}") + + open(os.path.join(outdir, "changed.txt"), "w").write("\n".join(lines) + "\n") + json.dump([ + {"bid": bid, "a": {k: v for k, v in x.items() if not k.startswith("_")}, + "b": {k: v for k, v in y.items() if not k.startswith("_")}} + for bid, x, y in changed + ], open(os.path.join(outdir, "kexts-changed.json"), "w"), indent=1) + print("wrote", outdir) + + +if __name__ == "__main__": + main() diff --git a/tools/ipsw/disasm_diff.py b/tools/ipsw/disasm_diff.py new file mode 100644 index 0000000..bbbc992 --- /dev/null +++ b/tools/ipsw/disasm_diff.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Disassembly diff for two standalone Mach-O kexts (same bundle id, two builds). + +Usage: python3 disasm_diff.py [max_lines] +Normalizes branch targets and absolute addresses so the diff shows real +instruction changes rather than layout noise. +""" +import difflib +import re +import subprocess +import sys + +ADDR = re.compile(r"\b[0-9a-f]{8,16}\b") + + +def disasm(path): + p = subprocess.run(["xcrun", "llvm-objdump", "--disassemble", path], + capture_output=True, text=True) + lines = [] + for line in p.stdout.splitlines(): + m = re.match(r"^\s*([0-9a-f]{8,16}):\s+([0-9a-f ]+?)\s{2,}(\S.*)$", line) + if not m: + continue + ins = m.group(3).strip() + ins = re.sub(r"0x[0-9a-f]{6,}", "0xADDR", ins) + ins = re.sub(r"#0x[0-9a-f]{4,}", "#0xIMM", ins) + lines.append(ins) + return lines + + +def main(): + a, b = sys.argv[1], sys.argv[2] + limit = int(sys.argv[3]) if len(sys.argv) > 3 else 400 + la, lb = disasm(a), disasm(b) + print(f"# {a}: {len(la)} insns") + print(f"# {b}: {len(lb)} insns") + sm = difflib.SequenceMatcher(a=la, b=lb, autojunk=False) + shown = 0 + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == "equal": + continue + if shown >= limit: + print("... truncated") + break + print(f"\n## {tag} a[{i1}:{i2}] b[{j1}:{j2}]") + for x in la[i1:i2][:14]: + print(" - " + x) + for x in lb[j1:j2][:14]: + print(" + " + x) + shown += 1 + + +if __name__ == "__main__": + main() diff --git a/tools/ipsw/extract_kext.py b/tools/ipsw/extract_kext.py new file mode 100644 index 0000000..7b3d721 --- /dev/null +++ b/tools/ipsw/extract_kext.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Extract one fileset entry (kext) from a kernelcache into a standalone Mach-O. + +Usage: python3 extract_kext.py +""" +import json +import os +import struct +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ipswlib + +LC_SEGMENT_64 = 0x19 + + +def extract(data, off, out): + m = ipswlib.MachO(data, off) + end = 32 + m.sizeofcmds + for s in m.segments: + base = s["fileoff"] if s["fileoff"] >= off else off + s["fileoff"] + end = max(end, base + s["filesize"]) + blob = bytearray(data[off:end]) + + o = 32 + for _ in range(m.ncmds): + cmd, cmdsize = struct.unpack_from("= off: + struct.pack_into("= off: + struct.pack_into("= off: struct.pack_into("= off: struct.pack_into("= off: struct.pack_into("= off: struct.pack_into(" bytes: + if data[:4] != b"IM4P": + return data + # magic(4) type(4) header_len(4 BE) header payload + hlen = struct.unpack(">I", data[8:12])[0] + return data[12 + hlen:] + + +def decompress(payload: bytes) -> bytes: + # LZFSE streams start with bvx2 / bvx- / bvx$ + for magic in (b"bvx2", b"bvx-", b"bvx$"): + i = payload.find(magic) + if i >= 0: + if lzfse is None: + raise RuntimeError("lzfse module missing") + return lzfse.decompress(payload[i:]) + if payload[:2] == b"\x1f\x8b": + import gzip + return gzip.decompress(payload) + return payload + + +def read_kernelcache(ipsw_path: str, member: str) -> bytes: + with zipfile.ZipFile(ipsw_path) as z: + raw = z.read(member) + return decompress(im4p_payload(raw)) + + +# ── Mach-O ────────────────────────────────────────────────────────────────── +MH_MAGIC_64 = 0xFEEDFACF +LC_SEGMENT_64 = 0x19 +LC_SYMTAB = 0x2 +LC_UUID = 0x1B +LC_FUNCTION_STARTS = 0x26 +LC_UNIXTHREAD = 0x5 + +CPU_NAMES = {0x0100000C: "arm64", 0x0100000C | 0x02000000: "arm64e"} + + +class MachO: + def __init__(self, data: bytes, off: int = 0): + self.data = data + self.off = off + magic, cputype, cpusub, filetype, ncmds, sizeofcmds, flags, _ = struct.unpack_from( + "= self.off else self.off + s["fileoff"] + + def vm_to_file(self, addr, seg=None): + for s in self.segments: + if s["vmaddr"] <= addr < s["vmaddr"] + s["vmsize"]: + return self._file_base(s) + (addr - s["vmaddr"]) + return None + + def cstring_bytes(self): + out = bytearray() + for seg in self.segments: + for sec in seg.get("sections", []): + if sec["sect"] in ("__cstring", "__oslogstring"): + base = sec["offset"] if sec["offset"] >= self.off else self.off + sec["offset"] + out += self.data[base: base + sec["size"]] + out += b"\0" + return bytes(out) + + def text_bytes(self): + t = self.text_segment() + if not t: + return b"" + base = self._file_base(t) + return self.data[base: base + t["filesize"]] + + +def scan_machos(data: bytes, limit=20000): + """Scan a prelinked kernelcache for embedded Mach-O headers (page aligned).""" + out = [] + off = 0 + n = len(data) + while off + 32 <= n and len(out) < limit: + if data[off:off + 4] == b"\xcf\xfa\xed\xfe": + try: + m = MachO(data, off) + out.append(m) + except Exception: + pass + off += 0x1000 # kexts are page aligned + return out + + +def macho_summary(m: MachO) -> dict: + text = m.text_bytes() + return { + "off": m.off, + "cpu": CPU_NAMES.get(m.cputype, hex(m.cputype)), + "sub": m.cpusub, + "filetype": m.filetype, + "uuid": m.uuid, + "text_size": len(text), + "text_sha256": hashlib.sha256(text).hexdigest()[:16], + "segments": [(s["name"], s["vmsize"]) for s in m.segments if s["name"].startswith("__")], + "nfuncs": len(m.function_offsets()) if m.function_starts else 0, + } + + +if __name__ == "__main__": + cmd = sys.argv[1] if len(sys.argv) > 1 else "" + if cmd == "list": + with zipfile.ZipFile(sys.argv[2]) as z: + for i in z.infolist(): + if any(k in i.filename for k in ("kernelcache", "dyld_shared_cache", "BuildManifest")): + print(i.filename, i.file_size) + elif cmd == "kernel": + data = read_kernelcache(sys.argv[2], sys.argv[3]) + print("kernelcache bytes", len(data), "sha256", hashlib.sha256(data).hexdigest()[:16]) + ms = scan_machos(data) + print("embedded Mach-Os:", len(ms)) + for m in ms[:10]: + print(macho_summary(m)) + else: + print("usage: ipswlib.py list | kernel ") + + +def dump_macho(m: MachO, path: str) -> int: + """Write the Mach-O image (header through the last segment's file extent).""" + end = 32 + m.sizeofcmds + for s in m.segments: + end = max(end, s["fileoff"] + s["filesize"]) + blob = m.data[m.off:m.off + end] + with open(path, "wb") as f: + f.write(blob) + return len(blob) diff --git a/tools/ipsw/map_advisory.py b/tools/ipsw/map_advisory.py new file mode 100644 index 0000000..9c8862a --- /dev/null +++ b/tools/ipsw/map_advisory.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Map the iOS 27.2 advisory entries to binary candidates and emit a report skeleton. + +Usage: python3 map_advisory.py +The mapping is a heuristic name table; it is refined later by the actual kext and +dylib inventory extracted from the IPSWs. +""" +import json +import sys + +# component -> (kind, candidate binary / kext) +MAP = { + "Kernel": ("kernel", "kernelcache core (pmap/ipc/vm/bsd)"), + "AVEVideoEncoder": ("kernel+user", "com.apple.driver.AppleAVE2 / VideoToolbox AVE plugin"), + "Apple Neural Engine": ("kernel+user", "AppleH16ANEInterface / AppleNeuralEngine.framework"), + "IOKit": ("kernel+user", "IOKit.framework / IOKit kexts"), + "IOHIDFamily": ("kernel", "com.apple.iokit.IOHIDFamily"), + "IOStorageFamily": ("kernel", "com.apple.iokit.IOStorageFamily"), + "IOMobileFrameBuffer": ("kernel", "com.apple.driver.IOMobileFrameBuffer"), + "IOSurface": ("kernel+user", "IOSurface.framework / IOSurface kext"), + "HFS": ("kernel", "com.apple.filesystems.hfs.kext"), + "Compression": ("user", "libcompression.dylib"), + "CoreMedia": ("user", "CoreMedia.framework / mediaplaybackd"), + "CoreGraphics": ("user", "CoreGraphics.framework"), + "CoreText": ("user", "CoreText.framework"), + "CoreAudio": ("user", "CoreAudio.framework / AudioToolbox"), + "Audio": ("user", "AudioToolbox / AudioCodecs"), + "ImageIO": ("user", "ImageIO.framework"), + "FontParser": ("user", "FontParser.framework"), + "libarchive": ("user", "libarchive.2.dylib"), + "LinkPresentation": ("user", "LinkPresentation.framework"), + "WebKit": ("user", "JavaScriptCore / WebKit / WebCore"), + "WebKit Canvas": ("user", "WebCore (canvas)"), + "WebKit JavaScript Bindings": ("user", "JavaScriptCore bindings"), + "WebKit Process Model": ("user", "WebKit process model"), + "WebRTC": ("user", "WebRTC.framework"), + "Security": ("user", "Security.framework"), + "Wi-Fi": ("user+kernel", "WiFi frameworks / IO80211 kext"), + "USB": ("kernel", "USB kexts"), + "Power Management": ("kernel", "com.apple.driver.AppleARMPlatform / PM kexts"), + "Bluetooth": ("user+kernel", "Bluetooth.framework / IOBluetooth kexts"), + "mDNSResponder": ("user", "mDNSResponder"), + "Thread": ("user", "ThreadNetwork.framework"), + "MobileGestalt": ("user", "MobileGestalt"), + "SpringBoard": ("user", "SpringBoard"), + "Messages": ("user", "Messages / IMSharedUtilities"), + "Mail": ("user", "Mail"), + "Maps": ("user", "Maps"), + "Photos Storage": ("user", "Photos"), + "Spotlight": ("user", "Spotlight"), + "Siri": ("user", "Siri"), + "Shortcuts": ("user", "Shortcuts"), + "HomeKit": ("user", "HomeKit"), + "CloudKit": ("user", "CloudKit"), + "Contacts": ("user", "Contacts"), + "Calendar": ("user", "CalendarFoundation"), + "App Store": ("user", "AppStore"), + "App Intents": ("user", "AppIntents"), + "Apple Intelligence": ("user", "AppleIntelligence"), + "AccessorySetupKit": ("user", "AccessorySetupKit"), + "Accounts": ("user", "Accounts"), + "FileProvider": ("user", "FileProvider"), + "Files": ("user", "Files"), + "Heimdal": ("user", "Heimdal (Kerberos)"), + "MediaAnalysis": ("user", "MediaAnalysis"), + "MediaRemote": ("user", "MediaRemote"), + "Quick Look": ("user", "QuickLook"), + "RealityKit": ("user", "RealityKit"), + "ReplayKit": ("user", "ReplayKit"), + "Safety Check": ("user", "SafetyCheck"), + "SceneKit": ("user", "SceneKit"), + "Screen Time": ("user", "ScreenTime"), + "SwiftUI": ("user", "SwiftUI"), + "System Preferences": ("user", "SystemPreferences"), + "TextKit": ("user", "TextKit"), + "Accelerate Framework": ("user", "Accelerate"), + "ColorSync": ("user", "ColorSync"), +} + + +def main(): + rows = json.load(open(sys.argv[1])) + out = sys.argv[2] + lines = ["# iOS 27.2 advisory — component → binary map", ""] + lines.append(f"Entries: {len(rows)} Components: {len(set(r['component'] for r in rows))}") + lines.append("") + lines.append("| CVE | Component | Impact | Binary candidate |") + lines.append("|---|---|---|---|") + for r in sorted(rows, key=lambda x: (x["component"], x["cve"])): + kind, cand = MAP.get(r["component"], ("unknown", "?")) + lines.append(f"| {r['cve']} | {r['component']} | {r['impact'][:70]} | {cand} |") + open(out, "w").write("\n".join(lines) + "\n") + print("wrote", out, len(rows), "entries") + missing = sorted(set(r["component"] for r in rows) - set(MAP)) + print("unmapped components:", missing) + + +if __name__ == "__main__": + main() diff --git a/tools/ipsw/remote_ipsw.py b/tools/ipsw/remote_ipsw.py new file mode 100644 index 0000000..98a8efe --- /dev/null +++ b/tools/ipsw/remote_ipsw.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Remote ZIP reader over HTTP Range, so the kernelcache can be inspected before the +full IPSW finishes downloading. + +Usage: + python3 remote_ipsw.py list + python3 remote_ipsw.py extract +""" +import io +import sys +import urllib.request +import zipfile + + +class HTTPRangeFile(io.RawIOBase): + def __init__(self, url, block=4 << 20): + self.url = url + self.pos = 0 + self.block = block + self.cache = {} + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req) as r: + self.size = int(r.headers["Content-Length"]) + self.ranges = r.headers.get("Accept-Ranges", "").lower() == "bytes" + + def seekable(self): return True + def readable(self): return True + def tell(self): return self.pos + + def seek(self, off, whence=0): + if whence == 0: self.pos = off + elif whence == 1: self.pos += off + else: self.pos = self.size + off + return self.pos + + def _fetch(self, start, end): + key = (start // self.block) * self.block + if key in self.cache: + return self.cache[key] + stop = min(self.size - 1, key + self.block - 1) + req = urllib.request.Request(self.url, headers={"Range": f"bytes={key}-{stop}"}) + with urllib.request.urlopen(req) as r: + data = r.read() + self.cache[key] = data + if len(self.cache) > 8: + self.cache.pop(next(iter(self.cache))) + return data + + def read(self, n=-1): + if n is None or n < 0: + n = self.size - self.pos + if n == 0 or self.pos >= self.size: + return b"" + out = bytearray() + while n > 0 and self.pos < self.size: + block = self._fetch(self.pos, self.pos) + base = (self.pos // self.block) * self.block + off = self.pos - base + take = min(n, len(block) - off) + if take <= 0: + break + out += block[off:off + take] + self.pos += take + n -= take + return bytes(out) + + +def main(): + cmd, url = sys.argv[1], sys.argv[2] + f = HTTPRangeFile(url) + print("size", f.size, "range", f.ranges) + z = zipfile.ZipFile(f) + if cmd == "list": + for i in z.infolist(): + if any(k in i.filename for k in ("kernelcache", "dyld_shared_cache", "BuildManifest")): + print(i.filename, i.file_size, i.compress_type) + elif cmd == "extract": + pat, out = sys.argv[3], sys.argv[4] + names = [i.filename for i in z.infolist() if pat in i.filename and not i.is_dir()] + print("matches:", names[:10], "count", len(names)) + if not names: + return + name = names[0] + with z.open(name) as src, open(out, "wb") as dst: + while True: + chunk = src.read(8 << 20) + if not chunk: break + dst.write(chunk) + print("wrote", out) + else: + print("usage: list|extract") + + +if __name__ == "__main__": + main()