Files
vphone-cli/scripts/patchers/kernel.py
T
2026-03-01 02:47:52 +09:00

1416 lines
59 KiB
Python
Executable File

#!/usr/bin/env python3
"""
kernel_patcher.py — Dynamic kernel patcher for iOS prelinked kernelcaches.
Finds all patch sites by string anchors, ADRP+ADD cross-references,
BL frequency analysis, and Mach-O structure parsing. Nothing is hardcoded;
works across kernel variants (vresearch101, vphone600, etc.).
Dependencies: keystone-engine, capstone
"""
import struct, plistlib
from collections import defaultdict
from keystone import Ks, KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN as KS_MODE_LE
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN
from capstone.arm64_const import (ARM64_OP_REG, ARM64_OP_IMM,
ARM64_REG_W0, ARM64_REG_X0, ARM64_REG_X8)
# ── Assembly / disassembly helpers ───────────────────────────────
_ks = Ks(KS_ARCH_ARM64, KS_MODE_LE)
_cs = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)
_cs.detail = True
def asm(s):
enc, _ = _ks.asm(s)
if not enc:
raise RuntimeError(f"asm failed: {s}")
return bytes(enc)
NOP = asm("nop")
MOV_X0_0 = asm("mov x0, #0")
MOV_X0_1 = asm("mov x0, #1")
MOV_W0_0 = asm("mov w0, #0")
MOV_W0_1 = asm("mov w0, #1")
RET = asm("ret")
CMP_W0_W0 = asm("cmp w0, w0")
CMP_X0_X0 = asm("cmp x0, x0")
def _asm_u32(s):
"""Assemble a single instruction and return its uint32 encoding."""
return struct.unpack("<I", asm(s))[0]
def _verify_disas(u32_val, expected_mnemonic):
"""Verify a uint32 encoding disassembles to expected mnemonic via capstone."""
code = struct.pack("<I", u32_val)
insns = list(_cs.disasm(code, 0, 1))
assert insns and insns[0].mnemonic == expected_mnemonic, \
f"0x{u32_val:08X} disassembles to {insns[0].mnemonic if insns else '???'}, expected {expected_mnemonic}"
return u32_val
# Named instruction constants (via keystone where possible, capstone-verified otherwise)
_PACIBSP_U32 = _asm_u32("hint #27") # keystone doesn't know 'pacibsp'
_RET_U32 = _asm_u32("ret")
_RETAA_U32 = _verify_disas(0xD65F0BFF, "retaa") # keystone can't assemble PAC returns
_RETAB_U32 = _verify_disas(0xD65F0FFF, "retab") # verified via capstone disassembly
_FUNC_BOUNDARY_U32S = frozenset((_RET_U32, _RETAA_U32, _RETAB_U32, _PACIBSP_U32))
def _rd32(buf, off):
return struct.unpack_from("<I", buf, off)[0]
def _rd64(buf, off):
return struct.unpack_from("<Q", buf, off)[0]
# ── KernelPatcher ────────────────────────────────────────────────
class KernelPatcher:
"""Dynamic kernel patcher — all offsets found at runtime."""
def __init__(self, data, verbose=True):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot for searching
self.size = len(data)
self.patches = [] # collected (offset, bytes, description)
self.verbose = verbose
self._log("[*] Parsing Mach-O segments …")
self._parse_macho()
self._log("[*] Discovering kext code ranges from __PRELINK_INFO …")
self._discover_kext_ranges()
self._log("[*] Building ADRP index …")
self._build_adrp_index()
self._log("[*] Building BL index …")
self._build_bl_index()
self._find_panic()
self._log(f"[*] _panic at foff 0x{self.panic_off:X} "
f"({len(self.bl_callers[self.panic_off])} callers)")
# ── Logging ──────────────────────────────────────────────────
def _log(self, msg):
if self.verbose:
print(msg)
# ── Mach-O / segment parsing ─────────────────────────────────
def _parse_macho(self):
"""Parse top-level Mach-O: discover BASE_VA, segments, code ranges."""
magic = _rd32(self.raw, 0)
if magic != 0xFEEDFACF:
raise ValueError(f"Not a 64-bit Mach-O (magic 0x{magic:08X})")
self.code_ranges = [] # [(start_foff, end_foff), ...]
self.all_segments = [] # [(name, vmaddr, fileoff, filesize, initprot)]
self.base_va = None
ncmds = struct.unpack_from("<I", self.raw, 16)[0]
off = 32 # past mach_header_64
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", self.raw, off)
if cmd == 0x19: # LC_SEGMENT_64
segname = self.raw[off+8:off+24].split(b'\x00')[0].decode()
vmaddr, vmsize, fileoff, filesize = struct.unpack_from(
"<QQQQ", self.raw, off + 24)
initprot = struct.unpack_from("<I", self.raw, off + 60)[0]
self.all_segments.append(
(segname, vmaddr, fileoff, filesize, initprot))
if segname == "__TEXT":
self.base_va = vmaddr
CODE_SEGS = ("__PRELINK_TEXT", "__TEXT_EXEC", "__TEXT_BOOT_EXEC")
if segname in CODE_SEGS and filesize > 0:
self.code_ranges.append((fileoff, fileoff + filesize))
off += cmdsize
if self.base_va is None:
raise ValueError("__TEXT segment not found — cannot determine BASE_VA")
self.code_ranges.sort()
total_mb = sum(e - s for s, e in self.code_ranges) / (1024 * 1024)
self._log(f" BASE_VA = 0x{self.base_va:016X}")
self._log(f" {len(self.code_ranges)} executable ranges, total {total_mb:.1f} MB")
def _va(self, foff):
return self.base_va + foff
def _foff(self, va):
return va - self.base_va
# ── Kext range discovery ─────────────────────────────────────
def _discover_kext_ranges(self):
"""Parse __PRELINK_INFO + embedded kext Mach-Os to find code section ranges."""
self.kext_ranges = {} # bundle_id -> (text_start, text_end)
# Find __PRELINK_INFO segment
prelink_info = None
for name, vmaddr, fileoff, filesize, _ in self.all_segments:
if name == "__PRELINK_INFO":
prelink_info = (fileoff, filesize)
break
if prelink_info is None:
self._log(" [-] __PRELINK_INFO not found, using __TEXT_EXEC for all")
self._set_fallback_ranges()
return
foff, fsize = prelink_info
pdata = self.raw[foff:foff + fsize]
# Parse the XML plist
xml_start = pdata.find(b"<?xml")
xml_end = pdata.find(b"</plist>")
if xml_start < 0 or xml_end < 0:
self._log(" [-] __PRELINK_INFO plist not found")
self._set_fallback_ranges()
return
xml = pdata[xml_start:xml_end + len(b"</plist>")]
pl = plistlib.loads(xml)
items = pl.get("_PrelinkInfoDictionary", [])
# Kexts we need ranges for
WANTED = {
"com.apple.filesystems.apfs": "apfs",
"com.apple.security.sandbox": "sandbox",
"com.apple.driver.AppleMobileFileIntegrity": "amfi",
}
for item in items:
bid = item.get("CFBundleIdentifier", "")
tag = WANTED.get(bid)
if tag is None:
continue
exec_addr = item.get("_PrelinkExecutableLoadAddr", 0) & 0xFFFFFFFFFFFFFFFF
kext_foff = exec_addr - self.base_va
if kext_foff < 0 or kext_foff >= self.size:
continue
# Parse this kext's embedded Mach-O to find __TEXT_EXEC.__text
text_range = self._parse_kext_text_exec(kext_foff)
if text_range:
self.kext_ranges[tag] = text_range
self._log(f" {tag:10s} __text: 0x{text_range[0]:08X} - 0x{text_range[1]:08X} "
f"({(text_range[1]-text_range[0])//1024} KB)")
# Derive the ranges used by patch methods
self._set_ranges_from_kexts()
def _parse_kext_text_exec(self, kext_foff):
"""Parse an embedded kext Mach-O header and return (__text start, end) in file offsets."""
if kext_foff + 32 > self.size:
return None
magic = _rd32(self.raw, kext_foff)
if magic != 0xFEEDFACF:
return None
ncmds = struct.unpack_from("<I", self.raw, kext_foff + 16)[0]
off = kext_foff + 32
for _ in range(ncmds):
if off + 8 > self.size:
break
cmd, cmdsize = struct.unpack_from("<II", self.raw, off)
if cmd == 0x19: # LC_SEGMENT_64
segname = self.raw[off+8:off+24].split(b'\x00')[0].decode()
if segname == "__TEXT_EXEC":
vmaddr = struct.unpack_from("<Q", self.raw, off + 24)[0]
filesize = struct.unpack_from("<Q", self.raw, off + 48)[0]
nsects = struct.unpack_from("<I", self.raw, off + 64)[0]
# Parse sections to find __text
sect_off = off + 72
for _ in range(nsects):
if sect_off + 80 > self.size:
break
sectname = self.raw[sect_off:sect_off+16].split(b'\x00')[0].decode()
if sectname == "__text":
sect_addr = struct.unpack_from("<Q", self.raw, sect_off + 32)[0]
sect_size = struct.unpack_from("<Q", self.raw, sect_off + 40)[0]
sect_foff = sect_addr - self.base_va
return (sect_foff, sect_foff + sect_size)
sect_off += 80
# No __text section found, use the segment
seg_foff = vmaddr - self.base_va
return (seg_foff, seg_foff + filesize)
off += cmdsize
return None
def _set_ranges_from_kexts(self):
"""Set patch-method ranges from discovered kext info, with fallbacks."""
# Full __TEXT_EXEC range
text_exec = None
for name, vmaddr, fileoff, filesize, _ in self.all_segments:
if name == "__TEXT_EXEC":
text_exec = (fileoff, fileoff + filesize)
break
if text_exec is None:
text_exec = (0, self.size)
self.text_exec_range = text_exec
self.apfs_text = self.kext_ranges.get("apfs", text_exec)
self.amfi_text = self.kext_ranges.get("amfi", text_exec)
self.sandbox_text = self.kext_ranges.get("sandbox", text_exec)
# Kernel code = full __TEXT_EXEC (includes all kexts, but that's OK)
self.kern_text = text_exec
def _set_fallback_ranges(self):
"""Use __TEXT_EXEC for everything when __PRELINK_INFO is unavailable."""
text_exec = None
for name, vmaddr, fileoff, filesize, _ in self.all_segments:
if name == "__TEXT_EXEC":
text_exec = (fileoff, fileoff + filesize)
break
if text_exec is None:
text_exec = (0, self.size)
self.text_exec_range = text_exec
self.apfs_text = text_exec
self.amfi_text = text_exec
self.sandbox_text = text_exec
self.kern_text = text_exec
# ── Index builders ───────────────────────────────────────────
def _build_adrp_index(self):
"""Index ADRP instructions by target page for O(1) string-ref lookup."""
self.adrp_by_page = defaultdict(list)
for rng_start, rng_end in self.code_ranges:
for off in range(rng_start, rng_end, 4):
insn = _rd32(self.raw, off)
if (insn & 0x9F000000) != 0x90000000:
continue
rd = insn & 0x1F
immhi = (insn >> 5) & 0x7FFFF
immlo = (insn >> 29) & 0x3
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= (1 << 21)
pc = self._va(off)
page = (pc & ~0xFFF) + (imm << 12)
self.adrp_by_page[page].append((off, rd))
n = sum(len(v) for v in self.adrp_by_page.values())
self._log(f" {n} ADRP entries, {len(self.adrp_by_page)} distinct pages")
def _build_bl_index(self):
"""Index BL instructions by target offset."""
self.bl_callers = defaultdict(list) # target_off -> [caller_off, ...]
for rng_start, rng_end in self.code_ranges:
for off in range(rng_start, rng_end, 4):
insn = _rd32(self.raw, off)
if (insn & 0xFC000000) != 0x94000000:
continue
imm26 = insn & 0x3FFFFFF
if imm26 & (1 << 25):
imm26 -= (1 << 26)
target = off + imm26 * 4
self.bl_callers[target].append(off)
def _find_panic(self):
"""Find _panic: most-called function whose callers reference '@%s:%d' strings."""
candidates = sorted(self.bl_callers.items(), key=lambda x: -len(x[1]))[:15]
for target_off, callers in candidates:
if len(callers) < 2000:
break
confirmed = 0
for caller_off in callers[:30]:
for back in range(caller_off - 4, max(caller_off - 32, 0), -4):
insn = _rd32(self.raw, back)
# ADD x0, x0, #imm
if (insn & 0xFFC003E0) == 0x91000000:
add_imm = (insn >> 10) & 0xFFF
if back >= 4:
prev = _rd32(self.raw, back - 4)
if (prev & 0x9F00001F) == 0x90000000: # ADRP x0
immhi = (prev >> 5) & 0x7FFFF
immlo = (prev >> 29) & 0x3
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= (1 << 21)
pc = self._va(back - 4)
page = (pc & ~0xFFF) + (imm << 12)
str_foff = self._foff(page + add_imm)
if 0 <= str_foff < self.size - 10:
snippet = self.raw[str_foff:str_foff + 60]
if b"@%s:%d" in snippet or b"%s:%d" in snippet:
confirmed += 1
break
break
if confirmed >= 3:
self.panic_off = target_off
return
self.panic_off = candidates[2][0] if len(candidates) > 2 else candidates[0][0]
# ── Helpers ──────────────────────────────────────────────────
def _disas_at(self, off, count=1):
"""Disassemble *count* instructions at file offset. Returns a list."""
end = min(off + count * 4, self.size)
if off < 0 or off >= self.size:
return []
code = bytes(self.raw[off:end])
return list(_cs.disasm(code, off, count))
def _is_bl(self, off):
"""Return BL target file offset, or -1 if not a BL."""
insns = self._disas_at(off)
if insns and insns[0].mnemonic == "bl":
return insns[0].operands[0].imm
return -1
def _is_cond_branch_w0(self, off):
"""Return True if instruction is a conditional branch on w0 (cbz/cbnz/tbz/tbnz)."""
insns = self._disas_at(off)
if not insns:
return False
i = insns[0]
if i.mnemonic in ("cbz", "cbnz", "tbz", "tbnz"):
return i.operands[0].type == ARM64_OP_REG and i.operands[0].reg == ARM64_REG_W0
return False
def find_string(self, s, start=0):
"""Find string, return file offset of the enclosing C string start."""
if isinstance(s, str):
s = s.encode()
off = self.raw.find(s, start)
if off < 0:
return -1
# Walk backward to the preceding NUL — that's the C string start
cstr = off
while cstr > 0 and self.raw[cstr - 1] != 0:
cstr -= 1
return cstr
def find_string_refs(self, str_off, code_start=None, code_end=None):
"""Find all (adrp_off, add_off, dest_reg) referencing str_off via ADRP+ADD."""
target_va = self._va(str_off)
target_page = target_va & ~0xFFF
page_off = target_va & 0xFFF
refs = []
for adrp_off, rd in self.adrp_by_page.get(target_page, []):
if code_start is not None and adrp_off < code_start:
continue
if code_end is not None and adrp_off >= code_end:
continue
if adrp_off + 4 >= self.size:
continue
nxt = _rd32(self.raw, adrp_off + 4)
# ADD (imm) 64-bit: 1001_0001_00_imm12_Rn_Rd
if (nxt & 0xFFC00000) != 0x91000000:
continue
add_rn = (nxt >> 5) & 0x1F
add_imm = (nxt >> 10) & 0xFFF
if add_rn == rd and add_imm == page_off:
add_rd = nxt & 0x1F
refs.append((adrp_off, adrp_off + 4, add_rd))
return refs
def find_function_start(self, off, max_back=0x4000):
"""Walk backwards to find PACIBSP or STP x29,x30,[sp,#imm].
When STP x29,x30 is found, continues backward up to 0x20 more
bytes to look for PACIBSP (ARM64e functions may have several STP
instructions in the prologue before STP x29,x30).
"""
for o in range(off - 4, max(off - max_back, 0), -4):
insn = _rd32(self.raw, o)
if insn == _PACIBSP_U32:
return o
dis = self._disas_at(o)
if dis and dis[0].mnemonic == "stp" and "x29, x30, [sp" in dis[0].op_str:
# Check further back for PACIBSP (prologue may have
# multiple STP instructions before x29,x30)
for k in range(o - 4, max(o - 0x24, 0), -4):
if _rd32(self.raw, k) == _PACIBSP_U32:
return k
return o
return -1
def _disas_n(self, buf, off, count):
"""Disassemble *count* instructions from *buf* at file offset *off*."""
end = min(off + count * 4, len(buf))
if off < 0 or off >= len(buf):
return []
code = bytes(buf[off:end])
return list(_cs.disasm(code, off, count))
def _fmt_insn(self, insn, marker=""):
"""Format one capstone instruction for display."""
raw = insn.bytes
hex_str = " ".join(f"{b:02x}" for b in raw)
s = f" 0x{insn.address:08X}: {hex_str:12s} {insn.mnemonic:8s} {insn.op_str}"
if marker:
s += f" {marker}"
return s
def _print_patch_context(self, off, patch_bytes, desc):
"""Print disassembly before/after a patch site for debugging."""
ctx = 3 # instructions of context before and after
# -- BEFORE (original bytes) --
lines = [f" ┌─ PATCH 0x{off:08X}: {desc}"]
lines.append(" │ BEFORE:")
start = max(off - ctx * 4, 0)
before_insns = self._disas_n(self.raw, start, ctx + 1 + ctx)
for insn in before_insns:
if insn.address == off:
lines.append(self._fmt_insn(insn, " ◄━━ PATCHED"))
elif off < insn.address < off + len(patch_bytes):
lines.append(self._fmt_insn(insn, " ◄━━ PATCHED"))
else:
lines.append(self._fmt_insn(insn))
# -- AFTER (new bytes) --
lines.append(" │ AFTER:")
after_insns = self._disas_n(self.raw, start, ctx)
for insn in after_insns:
lines.append(self._fmt_insn(insn))
# Decode the patch bytes themselves
patch_insns = list(_cs.disasm(patch_bytes, off, len(patch_bytes) // 4))
for insn in patch_insns:
lines.append(self._fmt_insn(insn, " ◄━━ NEW"))
# Trailing context after the patch
trail_start = off + len(patch_bytes)
trail_insns = self._disas_n(self.raw, trail_start, ctx)
for insn in trail_insns:
lines.append(self._fmt_insn(insn))
lines.append(f" └─")
self._log("\n".join(lines))
def emit(self, off, patch_bytes, desc):
"""Record a patch and print before/after disassembly context."""
self.patches.append((off, patch_bytes, desc))
if self.verbose:
self._print_patch_context(off, patch_bytes, desc)
def _find_by_string_in_range(self, string, code_range, label):
"""Find string, find ADRP+ADD ref in code_range, return ref list."""
str_off = self.find_string(string)
if str_off < 0:
self._log(f" [-] string not found: {string!r}")
return []
refs = self.find_string_refs(str_off, code_range[0], code_range[1])
if not refs:
self._log(f" [-] no code refs to {label} (str at 0x{str_off:X})")
return refs
# ── Chained fixup pointer decoding ───────────────────────────
def _decode_chained_ptr(self, val):
"""Decode an arm64e chained fixup pointer to a file offset.
- auth rebase (bit63=1): foff = bits[31:0]
- non-auth rebase (bit63=0): VA = (bits[50:43] << 56) | bits[42:0]
"""
if val == 0:
return -1
if val & (1 << 63): # auth rebase
return val & 0xFFFFFFFF
else: # non-auth rebase
target = val & 0x7FFFFFFFFFF # bits[42:0]
high8 = (val >> 43) & 0xFF
full_va = (high8 << 56) | target
if full_va > self.base_va:
return full_va - self.base_va
return -1
# ═══════════════════════════════════════════════════════════════
# Per-patch finders
# ═══════════════════════════════════════════════════════════════
def patch_apfs_root_snapshot(self):
"""Patch 1: NOP the tbnz w8,#5 that gates sealed-volume root snapshot panic."""
self._log("\n[1] _apfs_vfsop_mount: root snapshot sealed volume check")
refs = self._find_by_string_in_range(
b"Rooting from snapshot with xid",
self.apfs_text, "apfs_vfsop_mount log")
if not refs:
refs = self._find_by_string_in_range(
b"Failed to find the root snapshot",
self.apfs_text, "root snapshot panic")
if not refs:
return False
for adrp_off, add_off, _ in refs:
for scan in range(add_off, min(add_off + 0x200, self.size), 4):
insns = self._disas_at(scan)
if not insns:
continue
i = insns[0]
if i.mnemonic not in ("tbnz", "tbz"):
continue
# Check: tbz/tbnz w8, #5, ...
ops = i.operands
if (len(ops) >= 2
and ops[0].type == ARM64_OP_REG
and ops[1].type == ARM64_OP_IMM
and ops[1].imm == 5):
self.emit(scan, NOP,
f"NOP {i.mnemonic} {i.op_str} "
"(sealed vol check) [_apfs_vfsop_mount]")
return True
self._log(" [-] tbz/tbnz w8,#5 not found near xref")
return False
def patch_apfs_seal_broken(self):
"""Patch 2: NOP the conditional branch leading to 'root volume seal is broken' panic."""
self._log("\n[2] _authapfs_seal_is_broken: seal broken panic")
str_off = self.find_string(b"root volume seal is broken")
if str_off < 0:
self._log(" [-] string not found")
return False
refs = self.find_string_refs(str_off, *self.apfs_text)
if not refs:
self._log(" [-] no code refs")
return False
for adrp_off, add_off, _ in refs:
# Find BL _panic after string ref
bl_off = -1
for scan in range(add_off, min(add_off + 0x40, self.size), 4):
bl_target = self._is_bl(scan)
if bl_target == self.panic_off:
bl_off = scan
break
if bl_off < 0:
continue
# Search backwards for a conditional branch that jumps INTO the
# panic path. The error block may set up __FILE__/line args
# before the string ADRP, so allow target up to 0x40 before it.
err_lo = adrp_off - 0x40
for back in range(adrp_off - 4, max(adrp_off - 0x200, 0), -4):
target, kind = self._decode_branch_target(back)
if target is not None and err_lo <= target <= bl_off + 4:
self.emit(back, NOP,
f"NOP {kind} (seal broken) "
"[_authapfs_seal_is_broken]")
return True
self._log(" [-] could not find conditional branch to NOP")
return False
_COND_BRANCH_MNEMONICS = frozenset((
"b.eq", "b.ne", "b.cs", "b.hs", "b.cc", "b.lo",
"b.mi", "b.pl", "b.vs", "b.vc", "b.hi", "b.ls",
"b.ge", "b.lt", "b.gt", "b.le", "b.al",
"cbz", "cbnz", "tbz", "tbnz",
))
def _decode_branch_target(self, off):
"""Decode conditional branch at off via capstone. Returns (target, mnemonic) or (None, None)."""
insns = self._disas_at(off)
if not insns:
return None, None
i = insns[0]
if i.mnemonic in self._COND_BRANCH_MNEMONICS:
# Target is always the last IMM operand
for op in reversed(i.operands):
if op.type == ARM64_OP_IMM:
return op.imm, i.mnemonic
return None, None
def patch_bsd_init_rootvp(self):
"""Patch 3: NOP the conditional branch guarding the 'rootvp not authenticated' panic."""
self._log("\n[3] _bsd_init: rootvp not authenticated panic")
str_off = self.find_string(b"rootvp not authenticated after mounting")
if str_off < 0:
self._log(" [-] string not found")
return False
refs = self.find_string_refs(str_off, *self.kern_text)
if not refs:
self._log(" [-] no code refs in kernel __text")
return False
for adrp_off, add_off, _ in refs:
# Find the BL _panic after the string ref
bl_panic_off = -1
for scan in range(add_off, min(add_off + 0x40, self.size), 4):
bl_target = self._is_bl(scan)
if bl_target == self.panic_off:
bl_panic_off = scan
break
if bl_panic_off < 0:
continue
# Search backwards for a conditional branch whose target is in
# the error path (the block ending with BL _panic).
# The error path is typically a few instructions before BL _panic.
err_lo = bl_panic_off - 0x40 # error block start (generous)
err_hi = bl_panic_off + 4 # error block end
for back in range(adrp_off - 4, max(adrp_off - 0x400, 0), -4):
target, kind = self._decode_branch_target(back)
if target is not None and err_lo <= target <= err_hi:
self.emit(back, NOP,
f"NOP {kind} (rootvp auth) [_bsd_init]")
return True
self._log(" [-] conditional branch into panic path not found")
return False
def patch_proc_check_launch_constraints(self):
"""Patches 4-5: mov w0,#0; ret at _proc_check_launch_constraints start.
The AMFI function does NOT reference the symbol name string
'_proc_check_launch_constraints' — only the kernel wrapper does.
Instead, use 'AMFI: Validation Category info' which IS referenced
from the actual AMFI function.
"""
self._log("\n[4-5] _proc_check_launch_constraints: stub with mov w0,#0; ret")
str_off = self.find_string(b"AMFI: Validation Category info")
if str_off < 0:
self._log(" [-] 'AMFI: Validation Category info' string not found")
return False
refs = self.find_string_refs(str_off, *self.amfi_text)
if not refs:
self._log(" [-] no code refs in AMFI")
return False
for adrp_off, add_off, _ in refs:
func_start = self.find_function_start(adrp_off)
if func_start < 0:
continue
self.emit(func_start, MOV_W0_0,
"mov w0,#0 [_proc_check_launch_constraints]")
self.emit(func_start + 4, RET,
"ret [_proc_check_launch_constraints]")
return True
self._log(" [-] function start not found")
return False
def _get_kernel_text_range(self):
"""Return (start, end) file offsets of the kernel's own __TEXT_EXEC.__text.
Parses fileset entries (LC_FILESET_ENTRY) to find the kernel component,
then reads its Mach-O header to get the __TEXT_EXEC.__text section.
Falls back to the full __TEXT_EXEC segment.
"""
# Try fileset entries
ncmds = struct.unpack_from("<I", self.raw, 16)[0]
off = 32
for _ in range(ncmds):
cmd, cmdsize = struct.unpack_from("<II", self.raw, off)
if cmd == 0x80000035: # LC_FILESET_ENTRY
vmaddr = struct.unpack_from("<Q", self.raw, off + 8)[0]
str_off_in_cmd = struct.unpack_from("<I", self.raw, off + 24)[0]
entry_id = self.raw[off + str_off_in_cmd:].split(b'\x00')[0].decode()
if entry_id == "com.apple.kernel":
kext_foff = vmaddr - self.base_va
text_range = self._parse_kext_text_exec(kext_foff)
if text_range:
return text_range
off += cmdsize
return self.kern_text
@staticmethod
def _is_func_boundary(insn):
"""Return True if *insn* typically ends/starts a function."""
return insn in _FUNC_BOUNDARY_U32S
def patch_PE_i_can_has_debugger(self):
"""Patches 6-7: mov x0,#1; ret at _PE_i_can_has_debugger."""
self._log("\n[6-7] _PE_i_can_has_debugger: stub with mov x0,#1; ret")
# Strategy 1: find symbol name in __LINKEDIT and parse nearby VA
str_off = self.find_string(b"\x00_PE_i_can_has_debugger\x00")
if str_off < 0:
str_off = self.find_string(b"PE_i_can_has_debugger")
if str_off >= 0:
linkedit = None
for name, vmaddr, fileoff, filesize, _ in self.all_segments:
if name == "__LINKEDIT":
linkedit = (fileoff, fileoff + filesize)
if linkedit and linkedit[0] <= str_off < linkedit[1]:
name_end = self.raw.find(b'\x00', str_off + 1)
if name_end > 0:
for probe in range(name_end + 1, min(name_end + 32, self.size - 7)):
val = _rd64(self.raw, probe)
func_foff = val - self.base_va
if self.kern_text[0] <= func_foff < self.kern_text[1]:
first_insn = _rd32(self.raw, func_foff)
if first_insn != 0 and first_insn != 0xD503201F:
self.emit(func_foff, MOV_X0_1,
"mov x0,#1 [_PE_i_can_has_debugger]")
self.emit(func_foff + 4, RET,
"ret [_PE_i_can_has_debugger]")
return True
# Strategy 2: code pattern — function starts with ADRP x8,
# preceded by a function boundary, has many BL callers,
# and reads a 32-bit (w-register) value within first few instructions.
self._log(" [*] trying code pattern search...")
# Determine kernel-only __text range from fileset entries if available
kern_text_start, kern_text_end = self._get_kernel_text_range()
best_off = -1
best_callers = 0
for off in range(kern_text_start, kern_text_end - 12, 4):
dis = self._disas_at(off)
if not dis or dis[0].mnemonic != "adrp":
continue
# Must target x8
if dis[0].operands[0].reg != ARM64_REG_X8:
continue
# Must be preceded by function boundary
if off >= 4:
prev = _rd32(self.raw, off - 4)
if not self._is_func_boundary(prev):
continue
# Must read a w-register (32-bit) from [x8, #imm] within first 6 instructions
has_w_load = False
for k in range(1, 7):
if off + k * 4 >= self.size:
break
dk = self._disas_at(off + k * 4)
if dk and dk[0].mnemonic == "ldr" and dk[0].op_str.startswith("w") and "x8" in dk[0].op_str:
has_w_load = True
break
if not has_w_load:
continue
# Count callers — _PE_i_can_has_debugger has ~80-200 callers
# (widely used but not a basic kernel primitive)
n_callers = len(self.bl_callers.get(off, []))
if 50 <= n_callers <= 250 and n_callers > best_callers:
best_callers = n_callers
best_off = off
if best_off >= 0:
self._log(f" [+] code pattern match at 0x{best_off:X} ({best_callers} callers)")
self.emit(best_off, MOV_X0_1, "mov x0,#1 [_PE_i_can_has_debugger]")
self.emit(best_off + 4, RET, "ret [_PE_i_can_has_debugger]")
return True
self._log(" [-] function not found")
return False
def patch_post_validation_nop(self):
"""Patch 8: NOP the TBNZ after TXM CodeSignature error logging.
The 'TXM [Error]: CodeSignature: selector: ...' string is followed
by a BL (printf/log), then a TBNZ that branches to an additional
validation path. NOP the TBNZ to skip it.
"""
self._log("\n[8] post-validation NOP (txm-related)")
str_off = self.find_string(b"TXM [Error]: CodeSignature")
if str_off < 0:
self._log(" [-] 'TXM [Error]: CodeSignature' string not found")
return False
refs = self.find_string_refs(str_off, *self.kern_text)
if not refs:
refs = self.find_string_refs(str_off)
if not refs:
self._log(" [-] no code refs")
return False
for adrp_off, add_off, _ in refs:
# Scan forward past the BL (printf/log) for a TBNZ
for scan in range(add_off, min(add_off + 0x40, self.size), 4):
insns = self._disas_at(scan)
if not insns:
continue
if insns[0].mnemonic == "tbnz":
self.emit(scan, NOP,
f"NOP {insns[0].mnemonic} {insns[0].op_str} "
"[txm post-validation]")
return True
self._log(" [-] TBNZ not found after TXM error string ref")
return False
def patch_post_validation_cmp(self):
"""Patch 9: cmp w0,w0 in postValidation (AMFI code signing).
The 'AMFI: code signature validation failed' string is in the CALLER
function, not in postValidation itself. We find the caller, collect
its BL targets, then look inside each target for CMP W0, #imm + B.NE.
"""
self._log("\n[9] postValidation: cmp w0,w0 (AMFI code signing)")
str_off = self.find_string(b"AMFI: code signature validation failed")
if str_off < 0:
self._log(" [-] string not found")
return False
refs = self.find_string_refs(str_off, *self.amfi_text)
if not refs:
refs = self.find_string_refs(str_off)
if not refs:
self._log(" [-] no code refs")
return False
caller_start = self.find_function_start(refs[0][0])
if caller_start < 0:
self._log(" [-] caller function start not found")
return False
# Collect unique BL targets from the caller function
# Only stop at PACIBSP (new function), not at ret/retab (early returns)
bl_targets = set()
for scan in range(caller_start, min(caller_start + 0x2000, self.size), 4):
if scan > caller_start + 8 and _rd32(self.raw, scan) == _PACIBSP_U32:
break
target = self._is_bl(scan)
if target >= 0:
bl_targets.add(target)
# In each BL target in AMFI, look for: BL ... ; CMP W0, #imm ; B.NE
# The CMP must check W0 (return value of preceding BL call).
for target in sorted(bl_targets):
if not (self.amfi_text[0] <= target < self.amfi_text[1]):
continue
for off in range(target, min(target + 0x200, self.size), 4):
if off > target + 8 and _rd32(self.raw, off) == _PACIBSP_U32:
break
dis = self._disas_at(off, 2)
if len(dis) < 2:
continue
i0, i1 = dis[0], dis[1]
if i0.mnemonic != "cmp" or i1.mnemonic != "b.ne":
continue
# Must be CMP W0, #imm (first operand = w0, second = immediate)
ops = i0.operands
if len(ops) < 2:
continue
if ops[0].type != ARM64_OP_REG or ops[0].reg != ARM64_REG_W0:
continue
if ops[1].type != ARM64_OP_IMM:
continue
# Must be preceded by a BL within 2 instructions
has_bl = False
for gap in (4, 8):
if self._is_bl(off - gap) >= 0:
has_bl = True
break
if not has_bl:
continue
self.emit(off, CMP_W0_W0,
f"cmp w0,w0 (was {i0.mnemonic} {i0.op_str}) "
"[postValidation]")
return True
self._log(" [-] CMP+B.NE pattern not found in caller's BL targets")
return False
def patch_check_dyld_policy(self):
"""Patches 10-11: Replace two BL calls in _check_dyld_policy_internal with mov w0,#1.
The function is found via its reference to the Swift Playgrounds
entitlement string. The two BLs immediately preceding that string
reference (each followed by a conditional branch on w0) are patched.
"""
self._log("\n[10-11] _check_dyld_policy_internal: mov w0,#1 (two BLs)")
# Anchor: entitlement string referenced from within the function
str_off = self.find_string(
b"com.apple.developer.swift-playgrounds-app.development-build")
if str_off < 0:
self._log(" [-] swift-playgrounds entitlement string not found")
return False
refs = self.find_string_refs(str_off, *self.amfi_text)
if not refs:
refs = self.find_string_refs(str_off)
if not refs:
self._log(" [-] no code refs in AMFI")
return False
for adrp_off, add_off, _ in refs:
# Walk backward from the ADRP, looking for BL + conditional-on-w0 pairs
bls_with_cond = [] # [(bl_off, bl_target), ...]
for back in range(adrp_off - 4, max(adrp_off - 80, 0), -4):
bl_target = self._is_bl(back)
if bl_target < 0:
continue
if self._is_cond_branch_w0(back + 4):
bls_with_cond.append((back, bl_target))
if len(bls_with_cond) >= 2:
bl2_off, bl2_tgt = bls_with_cond[0] # closer to ADRP
bl1_off, bl1_tgt = bls_with_cond[1] # farther from ADRP
# The two BLs must call DIFFERENT functions — this
# distinguishes _check_dyld_policy_internal from other
# functions that repeat calls to the same helper.
if bl1_tgt == bl2_tgt:
continue
self.emit(bl1_off, MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @1]")
self.emit(bl2_off, MOV_W0_1,
"mov w0,#1 (was BL) [_check_dyld_policy_internal @2]")
return True
self._log(" [-] _check_dyld_policy_internal BL pair not found")
return False
def _find_validate_root_hash_func(self):
"""Find validate_on_disk_root_hash function via 'authenticate_root_hash' string."""
str_off = self.find_string(b"authenticate_root_hash")
if str_off < 0:
return -1
refs = self.find_string_refs(str_off, *self.apfs_text)
if not refs:
return -1
return self.find_function_start(refs[0][0])
def patch_apfs_graft(self):
"""Patch 12: Replace BL to validate_on_disk_root_hash with mov w0,#0.
Instead of stubbing _apfs_graft at entry, find the specific BL
that calls the root hash validation and neutralize just that call.
"""
self._log("\n[12] _apfs_graft: mov w0,#0 (validate_root_hash BL)")
# Find _apfs_graft function
exact = self.raw.find(b"\x00apfs_graft\x00")
if exact < 0:
self._log(" [-] 'apfs_graft' string not found")
return False
str_off = exact + 1
refs = self.find_string_refs(str_off, *self.apfs_text)
if not refs:
self._log(" [-] no code refs")
return False
graft_start = self.find_function_start(refs[0][0])
if graft_start < 0:
self._log(" [-] _apfs_graft function start not found")
return False
# Find validate_on_disk_root_hash function
vrh_func = self._find_validate_root_hash_func()
if vrh_func < 0:
self._log(" [-] validate_on_disk_root_hash not found")
return False
# Scan _apfs_graft for BL to validate_on_disk_root_hash
# Don't stop at ret/retab (early returns) — only stop at PACIBSP (new function)
for scan in range(graft_start, min(graft_start + 0x2000, self.size), 4):
if scan > graft_start + 8 and _rd32(self.raw, scan) == _PACIBSP_U32:
break
bl_target = self._is_bl(scan)
if bl_target == vrh_func:
self.emit(scan, MOV_W0_0, "mov w0,#0 [_apfs_graft]")
return True
self._log(" [-] BL to validate_on_disk_root_hash not found in _apfs_graft")
return False
def patch_apfs_vfsop_mount_cmp(self):
"""Patch 13: cmp x0,x0 in _apfs_vfsop_mount (current_thread == kernel_task check).
The target CMP follows the pattern: BL (returns current_thread in x0),
ADRP + LDR + LDR (load kernel_task global), CMP x0, Xm, B.EQ.
We require x0 as the first CMP operand to distinguish it from other
CMP Xn,Xm instructions in the same function.
"""
self._log("\n[13] _apfs_vfsop_mount: cmp x0,x0 (mount rw check)")
refs_upgrade = self._find_by_string_in_range(
b"apfs_mount_upgrade_checks\x00",
self.apfs_text, "apfs_mount_upgrade_checks")
if not refs_upgrade:
return False
func_start = self.find_function_start(refs_upgrade[0][0])
if func_start < 0:
return False
# Find BL callers of _apfs_mount_upgrade_checks
callers = self.bl_callers.get(func_start, [])
if not callers:
for off_try in [func_start, func_start + 4]:
callers = self.bl_callers.get(off_try, [])
if callers:
break
if not callers:
self._log(" [-] no BL callers of _apfs_mount_upgrade_checks found")
for off in range(self.apfs_text[0], self.apfs_text[1], 4):
bl_target = self._is_bl(off)
if bl_target >= 0 and func_start <= bl_target <= func_start + 4:
callers.append(off)
for caller_off in callers:
if not (self.apfs_text[0] <= caller_off < self.apfs_text[1]):
continue
# Scan a wider range — the CMP can be 0x800+ bytes before the BL
caller_func = self.find_function_start(caller_off)
scan_start = caller_func if caller_func >= 0 else max(caller_off - 0x800, self.apfs_text[0])
scan_end = min(caller_off + 0x100, self.apfs_text[1])
for scan in range(scan_start, scan_end, 4):
dis = self._disas_at(scan)
if not dis or dis[0].mnemonic != "cmp":
continue
ops = dis[0].operands
if len(ops) < 2:
continue
# Require CMP Xn, Xm (both register operands)
if ops[0].type != ARM64_OP_REG or ops[1].type != ARM64_OP_REG:
continue
# Require x0 as first operand (return value from BL)
if ops[0].reg != ARM64_REG_X0:
continue
# Skip CMP x0, x0 (already patched or trivial)
if ops[0].reg == ops[1].reg:
continue
self.emit(scan, CMP_X0_X0,
f"cmp x0,x0 (was {dis[0].mnemonic} {dis[0].op_str}) "
"[_apfs_vfsop_mount]")
return True
self._log(" [-] CMP x0,Xm not found near mount_upgrade_checks caller")
return False
def patch_apfs_mount_upgrade_checks(self):
"""Patch 14: Replace TBNZ w0,#0xe with mov w0,#0 in _apfs_mount_upgrade_checks.
Within the function, a BL calls a small flag-reading leaf function,
then TBNZ w0,#0xe branches to the error path. Replace the TBNZ
with mov w0,#0 to force the success path.
"""
self._log("\n[14] _apfs_mount_upgrade_checks: mov w0,#0 (tbnz bypass)")
refs = self._find_by_string_in_range(
b"apfs_mount_upgrade_checks\x00",
self.apfs_text, "apfs_mount_upgrade_checks")
if not refs:
return False
func_start = self.find_function_start(refs[0][0])
if func_start < 0:
self._log(" [-] function start not found")
return False
# Scan for BL followed by TBNZ w0
# Don't stop at ret/retab (early returns) — only stop at PACIBSP (new function)
for scan in range(func_start, min(func_start + 0x200, self.size), 4):
if scan > func_start + 8 and _rd32(self.raw, scan) == _PACIBSP_U32:
break
bl_target = self._is_bl(scan)
if bl_target < 0:
continue
# Check if BL target is a small leaf function (< 0x20 bytes, ends with ret)
is_leaf = False
for k in range(0, 0x20, 4):
if bl_target + k >= self.size:
break
dis = self._disas_at(bl_target + k)
if dis and dis[0].mnemonic == "ret":
is_leaf = True
break
if not is_leaf:
continue
# Check next instruction is TBNZ w0, #0xe
next_off = scan + 4
insns = self._disas_at(next_off)
if not insns:
continue
i = insns[0]
if i.mnemonic == "tbnz" and len(i.operands) >= 1:
if (i.operands[0].type == ARM64_OP_REG and
i.operands[0].reg == ARM64_REG_W0):
self.emit(next_off, MOV_W0_0,
"mov w0,#0 [_apfs_mount_upgrade_checks]")
return True
self._log(" [-] BL + TBNZ w0 pattern not found")
return False
def _find_validate_payload_manifest_func(self):
"""Find the AppleImage4 validate_payload_and_manifest function."""
str_off = self.find_string(b"validate_payload_and_manifest")
if str_off < 0:
return -1
refs = self.find_string_refs(str_off, *self.apfs_text)
if not refs:
return -1
return self.find_function_start(refs[0][0])
def patch_handle_fsioc_graft(self):
"""Patch 15: Replace BL to validate_payload_and_manifest with mov w0,#0.
Instead of stubbing _handle_fsioc_graft at entry, find the specific
BL that calls AppleImage4 validation and neutralize just that call.
"""
self._log("\n[15] _handle_fsioc_graft: mov w0,#0 (validate BL)")
exact = self.raw.find(b"\x00handle_fsioc_graft\x00")
if exact < 0:
self._log(" [-] 'handle_fsioc_graft' string not found")
return False
str_off = exact + 1
refs = self.find_string_refs(str_off, *self.apfs_text)
if not refs:
self._log(" [-] no code refs")
return False
fsioc_start = self.find_function_start(refs[0][0])
if fsioc_start < 0:
self._log(" [-] function start not found")
return False
# Find the validation function
val_func = self._find_validate_payload_manifest_func()
if val_func < 0:
self._log(" [-] validate_payload_and_manifest not found")
return False
# Scan _handle_fsioc_graft for BL to validation function
for scan in range(fsioc_start, min(fsioc_start + 0x400, self.size), 4):
insns = self._disas_at(scan)
if not insns:
continue
if scan > fsioc_start + 8 and insns[0].mnemonic == "pacibsp":
break
bl_target = self._is_bl(scan)
if bl_target == val_func:
self.emit(scan, MOV_W0_0, "mov w0,#0 [_handle_fsioc_graft]")
return True
self._log(" [-] BL to validate_payload_and_manifest not found")
return False
# ── Sandbox MACF hooks ───────────────────────────────────────
def _find_sandbox_ops_table_via_conf(self):
"""Find Sandbox mac_policy_ops table via mac_policy_conf struct."""
self._log("\n[*] Finding Sandbox mac_policy_ops via mac_policy_conf...")
seatbelt_off = self.find_string(b"Seatbelt sandbox policy")
sandbox_raw = self.raw.find(b"\x00Sandbox\x00")
sandbox_off = sandbox_raw + 1 if sandbox_raw >= 0 else -1
if seatbelt_off < 0 or sandbox_off < 0:
self._log(" [-] Sandbox/Seatbelt strings not found")
return None
self._log(f" [*] Sandbox string at foff 0x{sandbox_off:X}, "
f"Seatbelt at 0x{seatbelt_off:X}")
data_ranges = []
for name, vmaddr, fileoff, filesize, prot in self.all_segments:
if name in ("__DATA_CONST", "__DATA") and filesize > 0:
data_ranges.append((fileoff, fileoff + filesize))
for d_start, d_end in data_ranges:
for i in range(d_start, d_end - 40, 8):
val = _rd64(self.raw, i)
if val == 0 or (val & (1 << 63)):
continue
if (val & 0x7FFFFFFFFFF) != sandbox_off:
continue
val2 = _rd64(self.raw, i + 8)
if (val2 & (1 << 63)) or (val2 & 0x7FFFFFFFFFF) != seatbelt_off:
continue
val_ops = _rd64(self.raw, i + 32)
if not (val_ops & (1 << 63)):
ops_off = val_ops & 0x7FFFFFFFFFF
self._log(f" [+] mac_policy_conf at foff 0x{i:X}, "
f"mpc_ops -> 0x{ops_off:X}")
return ops_off
self._log(" [-] mac_policy_conf not found")
return None
def _read_ops_entry(self, table_off, index):
"""Read a function pointer from the ops table, handling chained fixups."""
off = table_off + index * 8
if off + 8 > self.size:
return -1
val = _rd64(self.raw, off)
if val == 0:
return 0
return self._decode_chained_ptr(val)
def patch_sandbox_hooks(self):
"""Patches 16-25: Stub Sandbox MACF hooks with mov x0,#0; ret.
Uses mac_policy_ops struct indices from XNU source (xnu-11215+).
"""
self._log("\n[16-25] Sandbox MACF hooks")
ops_table = self._find_sandbox_ops_table_via_conf()
if ops_table is None:
return False
HOOK_INDICES = {
"file_check_mmap": 36,
"mount_check_mount": 87,
"mount_check_remount": 88,
"mount_check_umount": 91,
"vnode_check_rename": 120,
}
sb_start, sb_end = self.sandbox_text
patched_count = 0
for hook_name, idx in HOOK_INDICES.items():
func_off = self._read_ops_entry(ops_table, idx)
if func_off is None or func_off <= 0:
self._log(f" [-] ops[{idx}] {hook_name}: NULL or invalid")
continue
if not (sb_start <= func_off < sb_end):
self._log(f" [-] ops[{idx}] {hook_name}: foff 0x{func_off:X} "
f"outside Sandbox (0x{sb_start:X}-0x{sb_end:X})")
continue
self.emit(func_off, MOV_X0_0, f"mov x0,#0 [_hook_{hook_name}]")
self.emit(func_off + 4, RET, f"ret [_hook_{hook_name}]")
self._log(f" [+] ops[{idx}] {hook_name} at foff 0x{func_off:X}")
patched_count += 1
return patched_count > 0
# ═══════════════════════════════════════════════════════════════
# Main entry point
# ═══════════════════════════════════════════════════════════════
def find_all(self):
"""Find and record all kernel patches. Returns list of (offset, bytes, desc)."""
self.patch_apfs_root_snapshot() # 1
self.patch_apfs_seal_broken() # 2
self.patch_bsd_init_rootvp() # 3
self.patch_proc_check_launch_constraints() # 4-5
self.patch_PE_i_can_has_debugger() # 6-7
self.patch_post_validation_nop() # 8
self.patch_post_validation_cmp() # 9
self.patch_check_dyld_policy() # 10-11
self.patch_apfs_graft() # 12
self.patch_apfs_vfsop_mount_cmp() # 13
self.patch_apfs_mount_upgrade_checks() # 14
self.patch_handle_fsioc_graft() # 15
self.patch_sandbox_hooks() # 16-25
return self.patches
def apply(self):
"""Find all patches and apply them to self.data. Returns patch count."""
patches = self.find_all()
for off, patch_bytes, desc in patches:
self.data[off:off + len(patch_bytes)] = patch_bytes
if self.verbose and patches:
self._log(f"\n{'═'*60}")
self._log(f"VERIFICATION: {len(patches)} patches applied")
self._log(f"{'═'*60}")
for off, patch_bytes, desc in sorted(patches):
insns = self._disas_n(self.data, off, len(patch_bytes) // 4)
if insns:
dis_str = "; ".join(f"{i.mnemonic} {i.op_str}" for i in insns)
else:
dis_str = "???"
self._log(f" 0x{off:08X}: {dis_str:40s}{desc}")
return len(patches)
# ── CLI entry point ──────────────────────────────────────────────
if __name__ == "__main__":
import sys, argparse
parser = argparse.ArgumentParser(
description="Dynamic kernel patcher — find & apply patches on iOS kernelcaches")
parser.add_argument("kernelcache", help="Path to raw or IM4P kernelcache")
parser.add_argument("-c", "--context", type=int, default=5,
help="Instructions of context before/after each patch (default: 5)")
parser.add_argument("-q", "--quiet", action="store_true",
help="Suppress index-building progress (only show patches)")
args = parser.parse_args()
path = args.kernelcache
print(f"Loading {path}...")
file_raw = open(path, "rb").read()
# Auto-detect IM4P vs raw Mach-O
if file_raw[:4] == b"\xcf\xfa\xed\xfe":
payload = file_raw
print(f" format: raw Mach-O")
else:
try:
from pyimg4 import IM4P
im4p = IM4P(file_raw)
if im4p.payload.compression:
im4p.payload.decompress()
payload = im4p.payload.data
print(f" format: IM4P (fourcc={im4p.fourcc})")
except Exception:
payload = file_raw
print(f" format: unknown (treating as raw)")
data = bytearray(payload)
print(f" size: {len(data)} bytes ({len(data)/1024/1024:.1f} MB)\n")
kp = KernelPatcher(data, verbose=not args.quiet)
patches = kp.find_all()
# ── Print ranged before / after disassembly for every patch ──
ctx = args.context
print(f"\n{'═'*72}")
print(f" {len(patches)} PATCHES — before / after disassembly (context={ctx})")
print(f"{'═'*72}")
# Apply patches to get the "after" image
after = bytearray(kp.raw) # start from original
for off, pb, _ in patches:
after[off:off + len(pb)] = pb
for i, (off, patch_bytes, desc) in enumerate(sorted(patches), 1):
n_insns = len(patch_bytes) // 4
start = max(off - ctx * 4, 0)
end = off + n_insns * 4 + ctx * 4
total = (end - start) // 4
before_insns = kp._disas_n(kp.raw, start, total)
after_insns = kp._disas_n(after, start, total)
print(f"\n{'─'*70}")
print(f" │ [{i:2d}] 0x{off:08X}: {desc}")
print(f" ├{'─'*34}{'─'*35}")
print(f" │ {'BEFORE':^33}{'AFTER':^34}")
print(f" ├{'─'*34}{'─'*35}")
# Build line pairs
max_lines = max(len(before_insns), len(after_insns))
for j in range(max_lines):
def fmt(insn):
if insn is None:
return " " * 33
h = insn.bytes.hex()
return f"0x{insn.address:07X} {h:8s} {insn.mnemonic:6s} {insn.op_str}"
bi = before_insns[j] if j < len(before_insns) else None
ai = after_insns[j] if j < len(after_insns) else None
bl = fmt(bi)
al = fmt(ai)
# Mark if this address is inside the patched range
addr = (bi.address if bi else ai.address) if (bi or ai) else 0
in_patch = off <= addr < off + len(patch_bytes)
marker = " ◄" if in_patch else " "
print(f" │ {bl:33s}{al:33s}{marker}")
print(f" └{'─'*34}{'─'*35}")