#!/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=False):
        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._patch_num = 0  # running counter for clean one-liners

        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 apply it to self.data immediately.

        Writing through to self.data ensures _find_code_cave() sees
        previously allocated shellcode and won't reuse the same cave.
        """
        self.patches.append((off, patch_bytes, desc))
        self.data[off : off + len(patch_bytes)] = patch_bytes
        self._patch_num += 1
        print(f"  [{self._patch_num:2d}] 0x{off:08X}  {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.patches = []
        self._patch_num = 0
        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."""
        self._patch_num = 0
        patches = self.find_all()
        # emit() already writes patches through to self.data,
        # but re-apply in case subclasses override find_all().
        for off, patch_bytes, desc in patches:
            self.data[off : off + len(patch_bytes)] = patch_bytes
        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(
        "-v",
        "--verbose",
        action="store_true",
        help="Show detailed before/after disassembly for each patch",
    )
    parser.add_argument(
        "-c",
        "--context",
        type=int,
        default=5,
        help="Instructions of context before/after each patch (default: 5, requires -v)",
    )
    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=args.verbose)
    patches = kp.find_all()
    print(f"\n  {len(patches)} patches found")

    if args.verbose:
        # ── 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}")
