mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
482 lines
17 KiB
Python
Executable File
482 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
txm_patcher.py — Dynamic patcher for TXM (Trusted Execution Monitor) images.
|
|
|
|
Finds TXM patch sites dynamically and applies trustcache/entitlement/developer
|
|
mode bypasses. NO hardcoded offsets.
|
|
|
|
Dependencies: keystone-engine, capstone
|
|
"""
|
|
|
|
import struct
|
|
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
|
|
|
|
# ── Assembly / disassembly singletons ──────────────────────────
|
|
_ks = Ks(KS_ARCH_ARM64, KS_MODE_LE)
|
|
_cs = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)
|
|
_cs.detail = True
|
|
_cs.skipdata = True
|
|
|
|
|
|
def _asm(s):
|
|
enc, _ = _ks.asm(s)
|
|
if not enc:
|
|
raise RuntimeError(f"asm failed: {s}")
|
|
return bytes(enc)
|
|
|
|
|
|
MOV_X0_0 = _asm("mov x0, #0")
|
|
MOV_X0_1 = _asm("mov x0, #1")
|
|
MOV_W0_1 = _asm("mov w0, #1")
|
|
MOV_X0_X20 = _asm("mov x0, x20")
|
|
STRB_W0_X20_30 = _asm("strb w0, [x20, #0x30]")
|
|
NOP = _asm("nop")
|
|
PACIBSP = _asm("hint #27")
|
|
|
|
|
|
def _disasm_one(data, off):
|
|
insns = list(_cs.disasm(data[off : off + 4], off))
|
|
return insns[0] if insns else None
|
|
|
|
|
|
def _find_asm_pattern(data, asm_str):
|
|
enc, _ = _ks.asm(asm_str)
|
|
pattern = bytes(enc)
|
|
results = []
|
|
off = 0
|
|
while True:
|
|
idx = data.find(pattern, off)
|
|
if idx < 0:
|
|
break
|
|
results.append(idx)
|
|
off = idx + 4
|
|
return results
|
|
|
|
|
|
# ── TXMPatcher ─────────────────────────────────────────────────
|
|
|
|
|
|
class TXMPatcher:
|
|
"""Dynamic patcher for TXM images.
|
|
|
|
Patches:
|
|
1. Trustcache binary-search BL → mov x0, #0
|
|
(in the AMFI cert verification function identified by the
|
|
unique constant 0x20446 loaded into w19)
|
|
2. Selector24 hash extraction: nop LDR X1 + nop BL
|
|
3. get-task-allow entitlement check BL → mov x0, #1
|
|
4. Selector42|29: shellcode hook + manifest flag force
|
|
5. debugger entitlement check BL → mov w0, #1
|
|
6. developer-mode guard branch → nop
|
|
"""
|
|
|
|
def __init__(self, data, verbose=True):
|
|
self.data = data
|
|
self.raw = bytes(data)
|
|
self.size = len(data)
|
|
self.verbose = verbose
|
|
self.patches = []
|
|
|
|
def _log(self, msg):
|
|
if self.verbose:
|
|
print(msg)
|
|
|
|
def emit(self, off, patch_bytes, desc):
|
|
self.patches.append((off, patch_bytes, desc))
|
|
if self.verbose:
|
|
before_insns = list(_cs.disasm(self.raw[off : off + 4], off))
|
|
after_insns = list(_cs.disasm(patch_bytes, off))
|
|
b_str = (
|
|
f"{before_insns[0].mnemonic} {before_insns[0].op_str}"
|
|
if before_insns
|
|
else "???"
|
|
)
|
|
a_str = (
|
|
f"{after_insns[0].mnemonic} {after_insns[0].op_str}"
|
|
if after_insns
|
|
else "???"
|
|
)
|
|
print(f" 0x{off:06X}: {b_str} → {a_str} [{desc}]")
|
|
|
|
def apply(self):
|
|
self.find_all()
|
|
for off, pb, _ in self.patches:
|
|
self.data[off : off + len(pb)] = pb
|
|
if self.verbose and self.patches:
|
|
self._log(f"\n [{len(self.patches)} TXM patches applied]")
|
|
return len(self.patches)
|
|
|
|
def find_all(self):
|
|
self.patches = []
|
|
self.patch_trustcache_bypass()
|
|
self.patch_get_task_allow_force_true()
|
|
self.patch_selector42_29_shellcode()
|
|
self.patch_debugger_entitlement_force_true()
|
|
self.patch_developer_mode_bypass()
|
|
return self.patches
|
|
|
|
# ── helpers ──────────────────────────────────────────────────
|
|
def _asm_at(self, asm_line, addr):
|
|
enc, _ = _ks.asm(asm_line, addr=addr)
|
|
if not enc:
|
|
raise RuntimeError(f"asm failed at 0x{addr:X}: {asm_line}")
|
|
return bytes(enc)
|
|
|
|
def _find_func_start(self, off, back=0x1000):
|
|
start = max(0, off - back)
|
|
for scan in range(off & ~3, start - 1, -4):
|
|
if self.raw[scan : scan + 4] == PACIBSP:
|
|
return scan
|
|
return None
|
|
|
|
def _find_refs_to_offset(self, target_off):
|
|
refs = []
|
|
for off in range(0, self.size - 8, 4):
|
|
a = _disasm_one(self.raw, off)
|
|
b = _disasm_one(self.raw, off + 4)
|
|
if not a or not b:
|
|
continue
|
|
if a.mnemonic != "adrp" or b.mnemonic != "add":
|
|
continue
|
|
if len(a.operands) < 2 or len(b.operands) < 3:
|
|
continue
|
|
if a.operands[0].reg != b.operands[1].reg:
|
|
continue
|
|
if a.operands[1].imm + b.operands[2].imm == target_off:
|
|
refs.append((off, off + 4))
|
|
return refs
|
|
|
|
def _find_string_refs(self, needle):
|
|
if isinstance(needle, str):
|
|
needle = needle.encode()
|
|
refs = []
|
|
seen = set()
|
|
off = 0
|
|
while True:
|
|
s_off = self.raw.find(needle, off)
|
|
if s_off < 0:
|
|
break
|
|
off = s_off + 1
|
|
for r in self._find_refs_to_offset(s_off):
|
|
if r[0] not in seen:
|
|
seen.add(r[0])
|
|
refs.append((s_off, r[0], r[1]))
|
|
return refs
|
|
|
|
def _find_debugger_gate_func_start(self):
|
|
refs = self._find_string_refs(b"com.apple.private.cs.debugger")
|
|
starts = set()
|
|
for _, _, add_off in refs:
|
|
for scan in range(add_off, min(add_off + 0x20, self.size - 8), 4):
|
|
i = _disasm_one(self.raw, scan)
|
|
n = _disasm_one(self.raw, scan + 4)
|
|
p1 = _disasm_one(self.raw, scan - 4) if scan >= 4 else None
|
|
p2 = _disasm_one(self.raw, scan - 8) if scan >= 8 else None
|
|
if not all((i, n, p1, p2)):
|
|
continue
|
|
if not (
|
|
i.mnemonic == "bl"
|
|
and n.mnemonic == "tbnz"
|
|
and n.op_str.startswith("w0, #0,")
|
|
and p1.mnemonic == "mov"
|
|
and p1.op_str == "x2, #0"
|
|
and p2.mnemonic == "mov"
|
|
and p2.op_str == "x0, #0"
|
|
):
|
|
continue
|
|
fs = self._find_func_start(scan)
|
|
if fs is not None:
|
|
starts.add(fs)
|
|
if len(starts) != 1:
|
|
return None
|
|
return next(iter(starts))
|
|
|
|
def _find_udf_cave(self, min_insns=6, near_off=None, max_distance=0x80000):
|
|
need = min_insns * 4
|
|
start = 0 if near_off is None else max(0, near_off - 0x1000)
|
|
end = self.size if near_off is None else min(self.size, near_off + max_distance)
|
|
best = None
|
|
best_dist = None
|
|
off = start
|
|
while off < end:
|
|
run = off
|
|
while run < end and self.raw[run : run + 4] == b"\x00\x00\x00\x00":
|
|
run += 4
|
|
if run - off >= need:
|
|
prev = _disasm_one(self.raw, off - 4) if off >= 4 else None
|
|
if prev and prev.mnemonic in (
|
|
"b",
|
|
"b.eq",
|
|
"b.ne",
|
|
"b.lo",
|
|
"b.hs",
|
|
"cbz",
|
|
"cbnz",
|
|
"tbz",
|
|
"tbnz",
|
|
):
|
|
# Leave 2-word safety gap after the preceding branch.
|
|
padded = off + 8
|
|
if padded + need <= run:
|
|
return padded
|
|
return off
|
|
if near_off is not None and _disasm_one(self.raw, off):
|
|
dist = abs(off - near_off)
|
|
if best is None or dist < best_dist:
|
|
best = off
|
|
best_dist = dist
|
|
off = run + 4 if run > off else off + 4
|
|
return best
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# Trustcache bypass
|
|
#
|
|
# The AMFI cert verification function has a unique constant:
|
|
# mov w19, #0x2446; movk w19, #2, lsl #16 (= 0x20446)
|
|
#
|
|
# Within that function, a binary search calls a hash-compare
|
|
# function with SHA-1 size:
|
|
# mov w2, #0x14; bl <hash_cmp>; cbz w0, <match>
|
|
# followed by:
|
|
# tbnz w0, #0x1f, <lower_half> (sign bit = search direction)
|
|
#
|
|
# Patch: bl <hash_cmp> → mov x0, #0
|
|
# This makes cbz always branch to <match>, bypassing the
|
|
# trustcache lookup entirely.
|
|
# ═══════════════════════════════════════════════════════════
|
|
def patch_trustcache_bypass(self):
|
|
# Step 1: Find the unique function marker (mov w19, #0x2446)
|
|
locs = _find_asm_pattern(self.raw, "mov w19, #0x2446")
|
|
if len(locs) != 1:
|
|
self._log(f" [-] TXM: expected 1 'mov w19, #0x2446', found {len(locs)}")
|
|
return
|
|
marker_off = locs[0]
|
|
|
|
# Step 2: Find the containing function (scan back for PACIBSP)
|
|
pacibsp = _asm("hint #27")
|
|
func_start = None
|
|
for scan in range(marker_off & ~3, max(0, marker_off - 0x200), -4):
|
|
if self.raw[scan : scan + 4] == pacibsp:
|
|
func_start = scan
|
|
break
|
|
if func_start is None:
|
|
self._log(" [-] TXM: function start not found")
|
|
return
|
|
|
|
# Step 3: Within the function, find mov w2, #0x14; bl; cbz w0; tbnz w0, #0x1f
|
|
func_end = min(func_start + 0x2000, self.size)
|
|
insns = list(_cs.disasm(self.raw[func_start:func_end], func_start))
|
|
|
|
for i, ins in enumerate(insns):
|
|
if not (ins.mnemonic == "mov" and ins.op_str == "w2, #0x14"):
|
|
continue
|
|
if i + 3 >= len(insns):
|
|
continue
|
|
bl_ins = insns[i + 1]
|
|
cbz_ins = insns[i + 2]
|
|
tbnz_ins = insns[i + 3]
|
|
if (
|
|
bl_ins.mnemonic == "bl"
|
|
and cbz_ins.mnemonic == "cbz"
|
|
and "w0" in cbz_ins.op_str
|
|
and tbnz_ins.mnemonic in ("tbnz", "tbz")
|
|
and "#0x1f" in tbnz_ins.op_str
|
|
):
|
|
self.emit(
|
|
bl_ins.address, MOV_X0_0, "trustcache bypass: bl → mov x0, #0"
|
|
)
|
|
return
|
|
|
|
self._log(" [-] TXM: binary search pattern not found in function")
|
|
|
|
def patch_get_task_allow_force_true(self):
|
|
"""Force get-task-allow entitlement call to return true."""
|
|
refs = self._find_string_refs(b"get-task-allow")
|
|
if not refs:
|
|
self._log(" [-] TXM: get-task-allow string refs not found")
|
|
return False
|
|
|
|
cands = []
|
|
for _, _, add_off in refs:
|
|
for scan in range(add_off, min(add_off + 0x20, self.size - 4), 4):
|
|
i = _disasm_one(self.raw, scan)
|
|
n = _disasm_one(self.raw, scan + 4)
|
|
if not i or not n:
|
|
continue
|
|
if (
|
|
i.mnemonic == "bl"
|
|
and n.mnemonic == "tbnz"
|
|
and n.op_str.startswith("w0, #0,")
|
|
):
|
|
cands.append(scan)
|
|
|
|
if len(cands) != 1:
|
|
self._log(
|
|
f" [-] TXM: expected 1 get-task-allow BL site, found {len(cands)}"
|
|
)
|
|
return False
|
|
|
|
self.emit(cands[0], MOV_X0_1, "get-task-allow: bl -> mov x0,#1")
|
|
return True
|
|
|
|
def patch_selector42_29_shellcode(self):
|
|
"""Selector 42|29 patch via dynamic cave shellcode + branch redirect."""
|
|
fn = self._find_debugger_gate_func_start()
|
|
if fn is None:
|
|
self._log(" [-] TXM: debugger-gate function not found (selector42|29)")
|
|
return False
|
|
|
|
stubs = []
|
|
for off in range(4, self.size - 24, 4):
|
|
p = _disasm_one(self.raw, off - 4)
|
|
i0 = _disasm_one(self.raw, off)
|
|
i1 = _disasm_one(self.raw, off + 4)
|
|
i2 = _disasm_one(self.raw, off + 8)
|
|
i3 = _disasm_one(self.raw, off + 12)
|
|
i4 = _disasm_one(self.raw, off + 16)
|
|
i5 = _disasm_one(self.raw, off + 20)
|
|
if not all((p, i0, i1, i2, i3, i4, i5)):
|
|
continue
|
|
if not (p.mnemonic == "bti" and p.op_str == "j"):
|
|
continue
|
|
if not (i0.mnemonic == "mov" and i0.op_str == "x0, x20"):
|
|
continue
|
|
if not (
|
|
i1.mnemonic == "bl" and i2.mnemonic == "mov" and i2.op_str == "x1, x21"
|
|
):
|
|
continue
|
|
if not (
|
|
i3.mnemonic == "mov"
|
|
and i3.op_str == "x2, x22"
|
|
and i4.mnemonic == "bl"
|
|
and i5.mnemonic == "b"
|
|
):
|
|
continue
|
|
if i4.operands and i4.operands[0].imm == fn:
|
|
stubs.append(off)
|
|
|
|
if len(stubs) != 1:
|
|
self._log(f" [-] TXM: selector42|29 stub expected 1, found {len(stubs)}")
|
|
return False
|
|
stub_off = stubs[0]
|
|
|
|
cave = self._find_udf_cave(min_insns=6, near_off=stub_off)
|
|
if cave is None:
|
|
self._log(" [-] TXM: no UDF cave found for selector42|29 shellcode")
|
|
return False
|
|
|
|
self.emit(
|
|
stub_off,
|
|
self._asm_at(f"b #0x{cave:X}", stub_off),
|
|
"selector42|29: branch to shellcode",
|
|
)
|
|
self.emit(cave, NOP, "selector42|29 shellcode pad: udf -> nop")
|
|
self.emit(cave + 4, MOV_X0_1, "selector42|29 shellcode: mov x0,#1")
|
|
self.emit(
|
|
cave + 8, STRB_W0_X20_30, "selector42|29 shellcode: strb w0,[x20,#0x30]"
|
|
)
|
|
self.emit(cave + 12, MOV_X0_X20, "selector42|29 shellcode: mov x0,x20")
|
|
self.emit(
|
|
cave + 16,
|
|
self._asm_at(f"b #0x{stub_off + 4:X}", cave + 16),
|
|
"selector42|29 shellcode: branch back",
|
|
)
|
|
return True
|
|
|
|
def patch_debugger_entitlement_force_true(self):
|
|
"""Force debugger entitlement call to return true."""
|
|
refs = self._find_string_refs(b"com.apple.private.cs.debugger")
|
|
if not refs:
|
|
self._log(" [-] TXM: debugger refs not found")
|
|
return False
|
|
|
|
cands = []
|
|
for _, _, add_off in refs:
|
|
for scan in range(add_off, min(add_off + 0x20, self.size - 4), 4):
|
|
i = _disasm_one(self.raw, scan)
|
|
n = _disasm_one(self.raw, scan + 4)
|
|
p1 = _disasm_one(self.raw, scan - 4) if scan >= 4 else None
|
|
p2 = _disasm_one(self.raw, scan - 8) if scan >= 8 else None
|
|
if not all((i, n, p1, p2)):
|
|
continue
|
|
if (
|
|
i.mnemonic == "bl"
|
|
and n.mnemonic == "tbnz"
|
|
and n.op_str.startswith("w0, #0,")
|
|
and p1.mnemonic == "mov"
|
|
and p1.op_str == "x2, #0"
|
|
and p2.mnemonic == "mov"
|
|
and p2.op_str == "x0, #0"
|
|
):
|
|
cands.append(scan)
|
|
|
|
if len(cands) != 1:
|
|
self._log(f" [-] TXM: expected 1 debugger BL site, found {len(cands)}")
|
|
return False
|
|
|
|
self.emit(cands[0], MOV_W0_1, "debugger entitlement: bl -> mov w0,#1")
|
|
return True
|
|
|
|
def patch_developer_mode_bypass(self):
|
|
"""Developer-mode bypass: NOP conditional guard before deny log path."""
|
|
refs = self._find_string_refs(
|
|
b"developer mode enabled due to system policy configuration"
|
|
)
|
|
if not refs:
|
|
self._log(" [-] TXM: developer-mode string ref not found")
|
|
return False
|
|
|
|
cands = []
|
|
for _, _, add_off in refs:
|
|
for back in range(add_off - 4, max(add_off - 0x20, 0), -4):
|
|
ins = _disasm_one(self.raw, back)
|
|
if not ins:
|
|
continue
|
|
if ins.mnemonic not in ("tbz", "tbnz", "cbz", "cbnz"):
|
|
continue
|
|
if not ins.op_str.startswith("w9, #0,"):
|
|
continue
|
|
cands.append(back)
|
|
|
|
if len(cands) != 1:
|
|
self._log(
|
|
f" [-] TXM: expected 1 developer mode guard, found {len(cands)}"
|
|
)
|
|
return False
|
|
|
|
self.emit(cands[0], NOP, "developer mode bypass")
|
|
return True
|
|
|
|
|
|
# ── CLI entry point ────────────────────────────────────────────
|
|
if __name__ == "__main__":
|
|
import sys, argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Dynamic TXM patcher")
|
|
parser.add_argument("txm", help="Path to raw or IM4P TXM image")
|
|
parser.add_argument("-q", "--quiet", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
print(f"Loading {args.txm}...")
|
|
file_raw = open(args.txm, "rb").read()
|
|
|
|
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: raw")
|
|
|
|
data = bytearray(payload)
|
|
print(f" size: {len(data)} bytes ({len(data) / 1024:.1f} KB)\n")
|
|
|
|
patcher = TXMPatcher(data, verbose=not args.quiet)
|
|
n = patcher.apply()
|
|
print(f"\n {n} patches applied.")
|