Files
vphone-cli/scripts/patchers/iboot.py
T
Lakr fa054ca719 Add Git LFS instructions and fix Makefile help alignment
- Add git-lfs to brew deps and document git lfs install/pull steps
  in both English and Chinese READMEs
- Fix continuation line alignment in make help (off by one)
- Add missing blank line before VM management section
2026-03-03 13:09:31 +08:00

497 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""
iboot_patcher.py — Dynamic patcher for iBoot-based images (iBSS, iBEC, LLB).
Finds all patch sites by string anchors, instruction patterns, and unique
error-code constants — NO hardcoded offsets. Works across iBoot variants
as long as the code structure is preserved.
iBSS, iBEC, and LLB share the same raw binary; the difference is which
patches are applied:
- iBSS: serial labels + image4 callback bypass
- iBEC: iBSS + boot-args
- LLB: iBEC + rootfs bypass (6 patches) + panic bypass
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)
NOP = _asm("nop")
MOV_X0_0 = _asm("mov x0, #0")
PACIBSP = _asm("hint #27")
def _rd32(buf, off):
return struct.unpack_from("<I", buf, off)[0]
def _wr32(buf, off, v):
struct.pack_into("<I", buf, off, v)
def _disasm_one(data, off):
insns = list(_cs.disasm(data[off : off + 4], off))
return insns[0] if insns else None
def _disasm_n(data, off, n):
return list(_cs.disasm(data[off : off + n * 4], off))
def _find_asm_pattern(data, asm_str):
"""Find all file offsets where the assembled instruction appears."""
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
def _encode_b(pc, target):
"""Encode an unconditional `b` instruction at pc targeting target."""
offset = (target - pc) >> 2
return 0x14000000 | (offset & 0x3FFFFFF)
def _encode_adrp(rd, pc, target):
imm = ((target & ~0xFFF) - (pc & ~0xFFF)) >> 12
imm &= (1 << 21) - 1
return 0x90000000 | ((imm & 3) << 29) | ((imm >> 2) << 5) | (rd & 0x1F)
def _encode_add_imm12(rd, rn, imm12):
return 0x91000000 | ((imm12 & 0xFFF) << 10) | ((rn & 0x1F) << 5) | (rd & 0x1F)
# ── IBootPatcher ───────────────────────────────────────────────
class IBootPatcher:
"""Dynamic patcher for iBoot binaries (iBSS / iBEC / LLB).
mode controls which patches are applied:
'ibss' — serial labels + image4 callback
'ibec' — ibss + boot-args
'llb' — ibec + rootfs bypass + panic bypass
"""
BOOT_ARGS = b"serial=3 -v debug=0x2014e %s"
CHUNK_SIZE, OVERLAP = 0x2000, 0x100
def __init__(self, data, mode="ibss", label=None, verbose=True):
self.data = data # bytearray (mutable)
self.raw = bytes(data) # immutable snapshot
self.size = len(data)
self.mode = mode
self.label = label or f"Loaded {mode.upper()}"
self.verbose = verbose
self.patches = []
def _log(self, msg):
if self.verbose:
print(msg)
# ── emit / apply ───────────────────────────────────────────
def emit(self, off, patch_bytes, desc):
self.patches.append((off, patch_bytes, desc))
if self.verbose:
original = self.raw[off : off + len(patch_bytes)]
before_insns = _disasm_n(self.raw, off, len(patch_bytes) // 4)
after_insns = list(_cs.disasm(patch_bytes, off))
b_str = "; ".join(f"{i.mnemonic} {i.op_str}" for i in before_insns) or "???"
a_str = "; ".join(f"{i.mnemonic} {i.op_str}" for i in after_insns) or "???"
print(f" 0x{off:06X}: {b_str}{a_str} [{desc}]")
def emit_string(self, off, data_bytes, desc):
"""Record a string/data patch (not disassemblable)."""
self.patches.append((off, data_bytes, desc))
if self.verbose:
try:
txt = data_bytes.decode("ascii")
except Exception:
txt = data_bytes.hex()
print(f" 0x{off:06X}: → {repr(txt)} [{desc}]")
def apply(self):
"""Find all patches, apply them, return count."""
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)} {self.mode.upper()} patches applied]")
return len(self.patches)
# ── Master find ────────────────────────────────────────────
def find_all(self):
self.patches = []
self.patch_serial_labels()
self.patch_image4_callback()
if self.mode in ("ibec", "llb"):
self.patch_boot_args()
if self.mode == "llb":
self.patch_rootfs_bypass()
self.patch_panic_bypass()
return self.patches
# ═══════════════════════════════════════════════════════════
# 1. Serial labels — find two long '====...' banner runs
# ═══════════════════════════════════════════════════════════
def patch_serial_labels(self):
label_bytes = self.label.encode() if isinstance(self.label, str) else self.label
eq_runs = []
i = 0
while i < self.size:
if self.raw[i] == ord("="):
start = i
while i < self.size and self.raw[i] == ord("="):
i += 1
if i - start >= 20:
eq_runs.append(start)
else:
i += 1
if len(eq_runs) < 2:
self._log(" [-] serial labels: <2 banner runs found")
return
for run_start in eq_runs[:2]:
write_off = run_start + 1
self.emit_string(write_off, label_bytes, f"serial label")
# ═══════════════════════════════════════════════════════════
# 2. image4_validate_property_callback
# Pattern: b.ne + mov x0, x22 (preceded by cmp within 8 insns)
# Patch: b.ne → NOP, mov x0, x22 → mov x0, #0
# ═══════════════════════════════════════════════════════════
def patch_image4_callback(self):
candidates = []
for insns in self._chunked_disasm():
for i in range(len(insns) - 1):
if insns[i].mnemonic != "b.ne":
continue
if not (
insns[i + 1].mnemonic == "mov" and insns[i + 1].op_str == "x0, x22"
):
continue
addr = insns[i].address
if not any(insns[j].mnemonic == "cmp" for j in range(max(0, i - 8), i)):
continue
# Prefer candidate with movn w22 (sets -1) earlier
neg1 = any(
(insns[j].mnemonic == "movn" and insns[j].op_str.startswith("w22,"))
or (
insns[j].mnemonic == "mov"
and "w22" in insns[j].op_str
and (
"#-1" in insns[j].op_str or "#0xffffffff" in insns[j].op_str
)
)
for j in range(max(0, i - 64), i)
)
candidates.append((addr, neg1))
if not candidates:
self._log(" [-] image4 callback: pattern not found")
return
# Prefer the candidate with the movn w22 (error return -1)
off = None
for a, n in candidates:
if n:
off = a
break
if off is None:
off = candidates[-1][0]
self.emit(off, NOP, "image4 callback: b.ne → nop")
self.emit(off + 4, MOV_X0_0, "image4 callback: mov x0,x22 → mov x0,#0")
# ═══════════════════════════════════════════════════════════
# 3. Boot-args — redirect ADRP+ADD x2 to custom string
# ═══════════════════════════════════════════════════════════
def patch_boot_args(self, new_args=None):
if new_args is None:
new_args = self.BOOT_ARGS
# Find the standalone "%s" format string near "rd=md0"
fmt_off = self._find_boot_args_fmt()
if fmt_off < 0:
self._log(" [-] boot-args: format string not found")
return
# Find ADRP+ADD x2 referencing it
adrp_off, add_off = self._find_boot_args_adrp(fmt_off)
if adrp_off < 0:
self._log(" [-] boot-args: ADRP+ADD x2 not found")
return
# Find a NUL slot for the new string
new_off = self._find_string_slot(len(new_args))
if new_off < 0:
self._log(" [-] boot-args: no NUL slot")
return
self.emit_string(new_off, new_args, "boot-args string")
new_adrp = struct.pack("<I", _encode_adrp(2, adrp_off, new_off))
new_add = struct.pack("<I", _encode_add_imm12(2, 2, new_off & 0xFFF))
self.emit(adrp_off, new_adrp, "boot-args: adrp x2 → new string page")
self.emit(add_off, new_add, "boot-args: add x2 → new string offset")
def _find_boot_args_fmt(self):
anchor = self.raw.find(b"rd=md0")
if anchor < 0:
anchor = self.raw.find(b"BootArgs")
if anchor < 0:
return -1
off = anchor
while off < anchor + 0x40:
off = self.raw.find(b"%s", off)
if off < 0 or off >= anchor + 0x40:
return -1
if self.raw[off - 1] == 0 and self.raw[off + 2] == 0:
return off
off += 1
return -1
def _find_boot_args_adrp(self, fmt_off):
for insns in self._chunked_disasm():
for i in range(len(insns) - 1):
a, b = insns[i], insns[i + 1]
if a.mnemonic != "adrp" or b.mnemonic != "add":
continue
if a.op_str.split(",")[0].strip() != "x2":
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 == fmt_off:
return a.address, b.address
return -1, -1
def _find_string_slot(self, string_len, search_start=0x14000):
off = search_start
while off < self.size:
if self.raw[off] == 0:
run_start = off
while off < self.size and self.raw[off] == 0:
off += 1
if off - run_start >= 64:
write_off = (run_start + 8 + 15) & ~15
if write_off + string_len <= off:
return write_off
else:
off += 1
return -1
# ═══════════════════════════════════════════════════════════
# 4. LLB rootfs bypass — 6 patches in two functions
# ═══════════════════════════════════════════════════════════
def patch_rootfs_bypass(self):
# ── 4a: cbz w0 → unconditional b (error code 0x3B7) ──
self._patch_cbz_before_error(0x3B7, "rootfs: skip sig check (0x3B7)")
# ── 4b: cmp x8, #0x400; b.hs → nop ────────────────────
self._patch_bhs_after_cmp_0x400()
# ── 4c: cbz w0 → unconditional b (error code 0x3C2) ──
self._patch_cbz_before_error(0x3C2, "rootfs: skip sig verify (0x3C2)")
# ── 4d: cbz x8 → nop (ldr xR, [xN, #0x78]) ──────────
self._patch_null_check_0x78()
# ── 4e: cbz w0 → unconditional b (error code 0x110) ──
self._patch_cbz_before_error(0x110, "rootfs: skip size verify (0x110)")
def _patch_cbz_before_error(self, error_code, desc):
"""Find unique 'mov w8, #<error_code>', cbz/cbnz is 4 bytes before.
Convert conditional branch to unconditional b to same target."""
locs = _find_asm_pattern(self.raw, f"mov w8, #{error_code}")
if len(locs) != 1:
self._log(
f" [-] {desc}: expected 1 'mov w8, #{error_code:#x}', "
f"found {len(locs)}"
)
return
err_off = locs[0]
cbz_off = err_off - 4
insn = _disasm_one(self.raw, cbz_off)
if not insn or insn.mnemonic not in ("cbz", "cbnz"):
self._log(
f" [-] {desc}: expected cbz/cbnz at 0x{cbz_off:X}, "
f"got {insn.mnemonic if insn else '???'}"
)
return
# Extract the branch target from the conditional instruction
target = insn.operands[1].imm
b_word = _encode_b(cbz_off, target)
self.emit(cbz_off, struct.pack("<I", b_word), desc)
def _patch_bhs_after_cmp_0x400(self):
"""Find unique 'cmp x8, #0x400', NOP the b.hs that follows."""
locs = _find_asm_pattern(self.raw, "cmp x8, #0x400")
if len(locs) != 1:
self._log(
f" [-] rootfs b.hs: expected 1 'cmp x8, #0x400', found {len(locs)}"
)
return
cmp_off = locs[0]
bhs_off = cmp_off + 4
insn = _disasm_one(self.raw, bhs_off)
if not insn or insn.mnemonic != "b.hs":
self._log(
f" [-] rootfs b.hs: expected b.hs at 0x{bhs_off:X}, "
f"got {insn.mnemonic if insn else '???'}"
)
return
self.emit(bhs_off, NOP, "rootfs: NOP b.hs size check (0x400)")
def _patch_null_check_0x78(self):
"""Find 'ldr x8, [xN, #0x78]; cbz x8' preceding unique error 0x110.
NOP the cbz."""
locs = _find_asm_pattern(self.raw, "mov w8, #0x110")
if len(locs) != 1:
self._log(
f" [-] rootfs null check: expected 1 'mov w8, #0x110', "
f"found {len(locs)}"
)
return
err_off = locs[0]
# Walk backwards from the error code to find ldr+cbz pattern
for scan in range(err_off - 4, max(err_off - 0x300, 0), -4):
i1 = _disasm_one(self.raw, scan)
i2 = _disasm_one(self.raw, scan + 4)
if (
i1
and i2
and i1.mnemonic == "ldr"
and "#0x78" in i1.op_str
and i2.mnemonic == "cbz"
and i2.op_str.startswith("x")
):
self.emit(scan + 4, NOP, "rootfs: NOP cbz x8 null check (#0x78)")
return
self._log(" [-] rootfs null check: ldr+cbz #0x78 pattern not found")
# ═══════════════════════════════════════════════════════════
# 5. LLB panic bypass
# Pattern: mov w8, #0x328; movk w8, #0x40, lsl #16;
# str wzr, ...; str wzr, ...; bl X; cbnz w0
# Patch: NOP the cbnz
# ═══════════════════════════════════════════════════════════
def patch_panic_bypass(self):
mov328_locs = _find_asm_pattern(self.raw, "mov w8, #0x328")
for loc in mov328_locs:
# Verify movk w8, #0x40, lsl #16 follows
next_insn = _disasm_one(self.raw, loc + 4)
if not (
next_insn
and next_insn.mnemonic == "movk"
and "w8" in next_insn.op_str
and "#0x40" in next_insn.op_str
and "lsl #16" in next_insn.op_str
):
continue
# Walk forward to find bl; cbnz w0
for step in range(loc + 8, loc + 32, 4):
i = _disasm_one(self.raw, step)
if i and i.mnemonic == "bl":
ni = _disasm_one(self.raw, step + 4)
if ni and ni.mnemonic == "cbnz":
self.emit(step + 4, NOP, "panic bypass: NOP cbnz w0")
return
break
self._log(" [-] panic bypass: pattern not found")
# ── Chunked disassembly helper ─────────────────────────────
def _chunked_disasm(self):
off = 0
while off < self.size:
end = min(off + self.CHUNK_SIZE, self.size)
insns = list(_cs.disasm(self.raw[off:end], off))
yield insns
off += self.CHUNK_SIZE - self.OVERLAP
# ── CLI entry point ────────────────────────────────────────────
if __name__ == "__main__":
import sys, argparse
parser = argparse.ArgumentParser(
description="Dynamic iBoot patcher (iBSS / iBEC / LLB)"
)
parser.add_argument("firmware", help="Path to raw or IM4P iBoot image")
parser.add_argument(
"-m",
"--mode",
choices=["ibss", "ibec", "llb"],
default="llb",
help="Patch mode (default: llb = all patches)",
)
parser.add_argument(
"-l", "--label", default=None, help="Serial label text (default: 'Loaded MODE')"
)
parser.add_argument("-q", "--quiet", action="store_true")
args = parser.parse_args()
print(f"Loading {args.firmware}...")
file_raw = open(args.firmware, "rb").read()
# Auto-detect IM4P
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 = IBootPatcher(
data, mode=args.mode, label=args.label, verbose=not args.quiet
)
n = patcher.apply()
print(f"\n {n} patches applied.")