#!/usr/bin/env python3 # SPDX-License-Identifier: MIT """End-to-end offline check of SNAP_KERNEL_REFMOD_SKIP (probes/refmodskip.py). python3 tools/test-kernel-refmod-skip.py [--output NEW_DIR] It builds the real loader image for the stock J700 26A428 kernelcache with the patch off, explicitly off and on, alone and combined with sysreg-zero, native calls and the SPTM HVC experiment, and compares the uploaded chunks word by word. It decodes each site before and after, emulates the skipped call's entry for the site's arguments, and walks the code after each site. It needs the 26A428 KDK (dSYM), capstone and numpy. It does not execute XNU and establishes no speedup. --output writes the site manifest. Failure modes, written before the code: 1. A default build changes. Guard: with the variable unset or 0 every executable word of the uploaded image equals the stock loaded image, and the unset, 0 and refmod_skip=False builds are chunk-for-chunk identical. 2. Something besides the sites changes. Guard: the patched build has the same chunk addresses and lengths and differs from the default build only in the image words at the decoded sites. 3. The wrong function is taken for the reference clear. Guard: the decoded function is, independently, the KDK's phys_attribute_clear_with_flush_range (segment translation, dSYM symbol at offset 0); its DWARF has no return type and the parameters (pn, bits, options, arg, flush_range); an independent numpy decode finds the same 16 BLs to it. 4. A dropped call does more than clear the reference bit (a modify clear would lose dirty tracking for file-backed writes and MADV_FREE; reusable ledgers; CLEAR_WRITE). Guard: each site's arguments are exactly MOVZ W1,#0x40 (PP_ATTR_REFERENCED, xnu-12377 pmap_data.h), W2,#0x20 (PMAP_OPTIONS_NOFLUSH, vm/pmap.h), X3,#0, X4,#0; a concrete emulation of the function's entry with those arguments reaches one tail call, to the KDK's arm_force_fast_fault_with_flush_range, with allow_mode VM_PROT_WRITE (write kept: no write protection), options NOFLUSH, bits_to_clear REFERENCED and no flush range; the same emulation of a modify clear gives a mode without VM_PROT_WRITE, so the decode tells them apart. Every other BL to the function (14, all modify clears among them) is unchanged. 5. Another path reaches the BL with other arguments. Guard: no direct branch in any executable segment (independent numpy decode) lands on the three later MOVZs or the BL; the KDK function holding each site has no BR-family jump; the window holds no BTI landing pad. 6. The NOP is visible to the caller. Guard: the call returns to site+4, the NOP's fall-through; on every path from there to a call or return, no caller-saved register x0-x17 is read before it is written (capstone); where a path returns with x0 unwritten, each caller of the containing function is walked the same way for x0. 7. The sites drift from the policy. Guard: the KDK inline chain of each site is pmap_clear_refmod_options inlined in vm_page_balance_inactive (vm_pageout.c) or vm_fault_deactivate_behind (vm_fault.c), one each. 8. An unknown or different kernel is patched. Guard: a changed digest is refused; a pinned digest whose closure gains or loses a site is refused; a branch into a window and an indirect jump in a site's region are refused. 9. Composition breaks other patches. Guard: with sysreg-zero and with SNAP_NATIVE_CALLS (private and HVC) the native contract is unchanged and the image is stock plus exactly the HVC words, the sysreg sites and these sites; the site sets are disjoint. 10. A second application or a bad flag. Guard: patching patched data is refused; SNAP_KERNEL_REFMOD_SKIP=2 and a non-boolean keyword are refused. """ import argparse import bisect import contextlib import hashlib import importlib import io import json import os import re import struct import subprocess import sys from pathlib import Path import numpy as np from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM, CS_GRP_CALL, CS_GRP_JUMP, CS_GRP_RET from capstone import arm64 as A ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'tools')) sys.path.insert(0, str(ROOT / 'probes')) kc_load = importlib.import_module('kc-load') import refmodskip as z # noqa: E402 import sysregzero # noqa: E402 from nativecalls import HVC_WORD # noqa: E402 KERNELCACHE = ROOT / 'downloads/extracted/j700ap/kernelcache.release.mac17p' XNU = ROOT / 'gpu/adreno/pmap-panic/xnu-12377/osfmk' KDK = Path('/Library/Developer/KDKs/KDK_27.0_26A428.kdk/System/Library/Kernels/kernel.release.t8140') DSYM = KDK.with_name(KDK.name + '.dSYM') / 'Contents/Resources/DWARF' / KDK.name ENV = ('SNAP_KERNEL_REFMOD_SKIP', 'SNAP_KERNEL_SYSREG_ZERO', 'SNAP_NATIVE_CALLS', 'SNAP_SPTM_HVC_CALLS', 'SNAP_SEED') NOP = 0xd503201f MASK = (1 << 64) - 1 VM_PROT_READ, VM_PROT_WRITE, VM_PROT_EXECUTE = 1, 2, 4 POLICY = {'vm_page_balance_inactive': 'vm_pageout.c', 'vm_fault_deactivate_behind': 'vm_fault.c'} checks = 0 def need(condition, message): global checks if not condition: raise SystemExit('FAIL: ' + message) checks += 1 def refused(action, text): try: action() except ValueError as error: return text in str(error) return False def define(path, name): m = re.search(r'#define\s+' + name + r'\s+(0x[0-9a-fA-F]+|\d+)', (XNU / path).read_text()) need(m is not None, f'{name} not in {path}') return int(m.group(1), 0) @contextlib.contextmanager def environment(**values): saved = {k: os.environ.get(k) for k in ENV} try: for k in ENV: os.environ.pop(k, None) os.environ['SNAP_SEED'] = 'test-kernel-refmod-skip' os.environ.update(values) yield finally: for k, v in saved.items(): if v is None: os.environ.pop(k, None) else: os.environ[k] = v def build(xb, log, **kw): with contextlib.redirect_stdout(log): return xb.build(str(KERNELCACHE), **kw) class Code: """Independent decoding of the image's executable segments (numpy, capstone).""" def __init__(self, image): self.image = image self.segments = [(va, off, size) for _, va, _, off, size in image.executable_segments] self.md = Cs(CS_ARCH_ARM64, CS_MODE_ARM) self.md.detail = True pcs, words = [], [] for va, off, size in self.segments: w = np.frombuffer(image.data, dtype='> 5) & 0x7ffff, 19), pc + 4 * sext((w >> 5) & 0x3fff, 14))) keep = imm26 | imm19 | imm14 call = (w & 0xfc000000) == 0x94000000 return pc[keep].astype(np.uint64), target[keep].astype(np.uint64), call[keep] def kdk_symbols(): out = subprocess.run(['nm', '-n', str(DSYM)], capture_output=True, text=True, check=True).stdout rows = sorted({(int(p[0], 16), p[2]) for p in (line.split() for line in out.splitlines()) if len(p) == 3 and p[1] in 'tTsS'}) # the dSYM marks functions 's' return [a for a, _ in rows], [n for _, n in rows] def dwarf(name): """[(tag, name, has_type)] for the subprogram and its direct parameters.""" out = subprocess.run(['xcrun', 'dwarfdump', f'--name={name}', '--show-children', str(DSYM)], capture_output=True, text=True, check=True).stdout entries, current = [], None for line in out.splitlines(): m = re.match(r'0x[0-9a-f]+:(\s+)(DW_TAG_\w+)', line) if m: current = [len(m.group(1)), m.group(2), None, False] entries.append(current) elif current and 'DW_AT_name' in line and current[2] is None: current[2] = re.search(r'\("(.*)"\)', line).group(1) elif current and 'DW_AT_type' in line: current[3] = True need(entries and entries[0][1] == 'DW_TAG_subprogram' and entries[0][2] == name, f'no DWARF for {name}') depth, params = entries[0][0], [] for e in entries[1:]: if e[0] <= depth: break if e[1] == 'DW_TAG_formal_parameter' and e[0] == depth + 2: params.append(e[2]) return entries[0][3], params def emulate(code, entry, args): """Concrete walk of the function's entry for x0..x4 = args, to its first branch out of the function. Returns (target, {x0..x5}). Only the instruction forms of this entry are modelled; anything else fails.""" regs = {f'x{n}': None for n in range(31)} regs.update({f'x{n}': v for n, v in enumerate(args)}) md, pc, flags = code.md, entry, None for _ in range(200): i = code.insn(pc) m, ops = i.mnemonic, i.operands name = [md.reg_name(op.reg) if op.type == A.ARM64_OP_REG else None for op in ops] def val(k): if ops[k].type == A.ARM64_OP_IMM: return ops[k].imm & MASK r = name[k] if r in ('xzr', 'wzr'): return 0 v = regs['x' + r[1:] if r[0] == 'w' else r] need(v is not None, f'{pc:#x}: {m} reads an unknown {r}') return v & 0xffffffff if r[0] == 'w' else v def put(k, v): r = name[k] regs['x' + r[1:] if r[0] == 'w' else r] = v & (0xffffffff if r[0] == 'w' else MASK) nxt = pc + 4 if m in ('pacibsp', 'autibsp', 'stp') or (m in ('add', 'sub') and name[0] in ('sp', 'fp')): pass elif m == 'ldp': for r in name[:2]: regs['x29' if r == 'fp' else 'x30' if r == 'lr' else r] = None elif m == 'mov': put(0, val(1)) elif m in ('orr', 'and'): put(0, val(1) | val(2) if m == 'orr' else val(1) & val(2)) elif m == 'ubfiz': put(0, (val(1) & ((1 << ops[3].imm) - 1)) << ops[2].imm) elif m in ('cmp', 'tst'): flags = (val(0) - val(1)) & MASK == 0 if m == 'cmp' else val(0) & val(1) == 0 elif m == 'csel' and i.cc in (A.ARM64_CC_EQ, A.ARM64_CC_NE): put(0, val(1) if flags == (i.cc == A.ARM64_CC_EQ) else val(2)) elif m in ('tbz', 'tbnz'): if ((val(0) >> ops[1].imm) & 1) == (m == 'tbnz'): nxt = ops[2].imm & MASK elif m in ('b.eq', 'b.ne'): if flags == (m == 'b.eq'): nxt = ops[0].imm & MASK elif m == 'b': return ops[0].imm & MASK, {k: regs[k] for k in ('x0', 'x1', 'x2', 'x3', 'x4', 'x5')} else: raise SystemExit(f'FAIL: {pc:#x}: {m} {i.op_str} is outside the modelled entry') pc = nxt raise SystemExit('FAIL: the entry walk did not leave the function') CALLER_SAVED = frozenset(f'x{n}' for n in range(18)) def liveness(code, start, entries, callers, regs=CALLER_SAVED, depth=0, limit=2000): """Read-before-write of regs on every path from start to a call or return.""" md, bad, seen, work, visited = code.md, [], set(), [(start, frozenset())], 0 def x(r): return 'x' + r[1:] if r[0] == 'w' and r[1:].isdigit() else r while work: pc, written = work.pop() while (pc, written) not in seen and visited < limit: seen.add((pc, written)) visited += 1 i = code.insn(pc) reads, writes = i.regs_access() bad += [(pc, i.mnemonic, r) for r in {x(md.reg_name(v)) for v in reads} & regs - written] written |= {x(md.reg_name(v)) for v in writes} & regs if i.group(CS_GRP_CALL): break if i.group(CS_GRP_RET): if 'x0' in regs and 'x0' not in written: need(depth < 3, f'{pc:#x}: x0 unwritten through too many returns') owner = entries[bisect.bisect_right(entries, pc) - 1] for site in callers.get(owner, ()): bad += liveness(code, site + 4, entries, callers, frozenset({'x0'}), depth + 1, limit)[0] visited += 1 break if i.group(CS_GRP_JUMP): op = i.operands[-1] if i.operands else None need(op is not None and op.type == A.ARM64_OP_IMM, f'{pc:#x}: indirect jump after a site') if i.mnemonic == 'b': pc = op.imm & MASK continue work.append((op.imm & MASK, written)) pc += 4 need(visited < limit, f'{start:#x}: liveness walk did not close') return bad, visited def admission(image, found, code): """Failure modes 8 and 10 on modified copies of the real image.""" stock = bytes(image.data) need(refused(lambda: z.admit(kc_load.Image(stock[:-1] + bytes([stock[-1] ^ 1]))), 'no reviewed refmod-skip closure'), 'a changed kernel digest was admitted') def offset(va): return next(off + va - v for v, off, size in code.segments if v <= va < v + size) others = [pc for pc, before in found['calls'] if set(before) != z.ARGUMENTS] reference = next(pc for pc, before in found['calls'] if before == [0x52800801, 0x52800002, 0xd2800003, 0xd2800004]) # pmap_clear_reference site = found['sites'][0] lo, hi = site['region'] filler = next(va for va in range(lo, site['va'] - 16, 4) if code.word(va) & 0xffc00000 == 0xf9400000) # an LDR b_into = 0x14000000 | (((site['va'] - 8) - filler) >> 2 & 0x3ffffff) for name, edits, text in ( ('added site', {reference - 12: 0x52800402}, 'closure differs'), # its MOVZ W2, #0 -> #0x20 ('removed site', {site['va'] - 12: 0x52800002}, 'closure differs'), ('branch into a window', {filler: b_into}, 'lands inside the argument window'), ('indirect jump in a region', {filler: 0xd61f0200}, 'indirect jump in the containing region')): changed = bytearray(stock) for va, word in edits.items(): struct.pack_into('= 4 and chain[-2].startswith('pmap_clear_refmod_options pmap.c:') and chain[-1].split()[0] in POLICY and chain[-1].split()[1].startswith(POLICY[chain[-1].split()[0]] + ':'), f'{s["va"]:#x}: inline chain {chain}') need(sorted(s['chain'][-1].split()[0] for s in sites) == sorted(POLICY), 'not one site per policy function') admission(image, found, code) import xnuboot as xb xb.configure_guest('j700ap-26A428', KERNELCACHE.parent) log = io.StringIO() stock = bytearray(image.data) for off, value in kc_load.chained_fixups(image, 0): struct.pack_into(' NOP; ' f'{len(untouched)} other calls unchanged; {walked} instructions walked after the sites; defaults unchanged') def runpy_symbolicator(): import runpy return runpy.run_path(str(ROOT / 'tools/kc-symbolicate.py'))['Symbolicator']() if __name__ == '__main__': main()