- 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
164 lines
5.2 KiB
Python
164 lines
5.2 KiB
Python
#!/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()
|