#!/usr/bin/env python3 # SPDX-License-Identifier: MIT """End-to-end offline check of SNAP_KERNEL_SYSREG_ZERO (probes/sysregzero.py). python3 tools/test-kernel-sysreg-zero.py [--output NEW_DIR] [--symbolize] It builds the real loader image for the stock J700 26A428 kernelcache with the patch off, explicitly off and on, alone and combined with native calls and the SPTM HVC experiment, and compares the uploaded chunks word by word. It does not execute XNU and establishes no speedup. --output writes the site manifest (with --symbolize, each site's function from the KDK, via tools/kc-symbolicate.py). 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 sysreg_zero=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. A replacement is wrong. Guard: an independent decoder here checks every changed word: before, an MRS/MSR of a listed register; after, MOVZ Xt, #0 with the same Rt (MRS) or NOP (MSR, MRS XZR). 4. The finder misses an access. Guard: an independent full-word scan of the patched image's executable segments finds no access to a listed register. 5. The registers drift from what the monitor does in either build. Guard: the set is exactly the census's 13 lock-only registers plus PMC0, PMC1, PMCR0, UPMSR (ignore default) and GXF_STATUS_EL1 (zero case), never CONTEXTIDR_EL1 or the JAPIB keys. Each ignored one is in fast_trap_ignored and named by no claimed-register macro (tools/test-fast-trap.py holds the full unclaimed proof); the locked default and GXF case bodies and the fast IGNORE/ZERO bodies are exactly "reads zero"; the locked system-register section has no build conditional, so control and fast share it. 6. An unknown or different kernel is patched. Guard: a changed digest is refused; a pinned digest whose decoded closure gains or loses one site is refused; a listed-register word outside an executable __text section is refused. 7. Composition breaks the call-entry contracts. Guard: with SNAP_NATIVE_CALLS (private and HVC) the native contract and its pages are unchanged, and the HVC image differs from stock at exactly the HVC sites plus these sites. 8. A second application or a bad flag. Guard: patching patched data is refused; SNAP_KERNEL_SYSREG_ZERO=2 and a non-boolean keyword are refused. """ import argparse import contextlib import importlib import io import json import os import re import struct import sys from pathlib import Path from source_text import read_source 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 sysregzero as z # noqa: E402 from nativecalls import HVC_WORD # noqa: E402 KERNELCACHE = ROOT / 'downloads/extracted/j700ap/kernelcache.release.mac17p' # docs/SYSREG-CENSUS-20261016.md: PMC2-PMC9 and the five other unclaimed # registers, all reaching only the monitor's ignore default. CENSUS = {(3, 2, 15, crm, 0) for crm in (2, 3, 4, 5, 6, 7, 9, 10)} | { (3, 4, 15, 15, 6), (3, 4, 15, 10, 4), (3, 1, 15, 1, 5), (3, 4, 15, 1, 4), (3, 4, 15, 1, 3)} # PMC0, PMC1, PMCR0, UPMSR: the same ignore default. GXF_STATUS_EL1: zero case. IGNORED = CENSUS | {(3, 2, 15, 0, 0), (3, 2, 15, 1, 0), (3, 1, 15, 0, 0), (3, 7, 15, 6, 4)} ZERO = {(3, 6, 15, 8, 0)} EXPECTED = IGNORED | ZERO EXCLUDED = {(3, 0, 13, 0, 1), (3, 4, 15, 13, 6), (3, 4, 15, 13, 7)} # CONTEXTIDR_EL1, JAPIBKeyLo/Hi ENV = ('SNAP_KERNEL_SYSREG_ZERO', 'SNAP_NATIVE_CALLS', 'SNAP_SPTM_HVC_CALLS', 'SNAP_SEED') 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 access(word): """Independent decode: (is_read, (op0, op1, CRn, CRm, op2), Rt) or None.""" if word >> 22 != 0b1101010100 or not (word >> 20) & 1: return None op0 = 2 | ((word >> 19) & 1) return ((word >> 21) & 1, (op0, (word >> 16) & 7, (word >> 12) & 0xf, (word >> 8) & 0xf, (word >> 5) & 7), word & 0x1f) def movz_zero(word): """Rd of MOVZ Xd, #0, LSL #0, else None.""" return word & 0x1f if word & 0xffe00000 == 0xd2800000 and not (word >> 5) & 0xffff else None def tuples(path, macro): """Every macro(a,b,c,d,e) register tuple in a source file.""" text = read_source(ROOT / path) return {tuple(int(v) for v in m.groups()) for m in re.finditer(macro + r'\(\s*(\d+)u?\s*,\s*(\d+)u?\s*,\s*(\d+)u?\s*,\s*(\d+)u?\s*,\s*(\d+)u?\s*\)', text)} def word_at(chunks, pa): found = [struct.unpack_from(' 30 and not claimed & IGNORED, 'a monitor handler names an ignored register') need(ZERO <= claimed, 'GXF_STATUS_EL1 lost its locked case') def body(text, label): """The normalized statements after `label` up to its break.""" text = re.sub(r'/\*.*?\*/|//[^\n]*', '', text, flags=re.S) m = re.search(re.escape(label) + r'(.*?)break;', text, flags=re.S) need(m is not None, f'no {label!r}') return re.sub(r'\s+', '', m.group(1)) monitor = read_source(ROOT / 'probes/xnuboot.c') locked = monitor[monitor.index('static int xnuboot_trap_locked'):] locked = locked[locked.index('if (ec != 0x18) {'):locked.index('\n#if SNAP_FAST_VCPU_TRAPS')] need(not re.search(r'^\s*#\s*(if|ifdef|ifndef|else|elif)', locked, flags=re.M), 'the locked system-register section differs between monitor builds') need(body(locked, 'default:') == 'record(c,EV_SYSREG,*elr,iss,value,is_read);if(is_read)value=0;', 'the locked default no longer only reads zero and drops writes') need(body(locked, 'case SR_GXF_STATUS_EL1:') == 'if(is_read)value=0;', 'the GXF_STATUS_EL1 case changed') need(re.sub(r'\s+', '', locked).endswith('if(is_read&&rt!=31)frame[rt]=value;*elr+=4;return0;}'), 'the locked sysreg epilogue changed') need(re.search(r'#define\s+SR_GXF_STATUS_EL1\s+SYSREG\(3,\s*6,\s*15,\s*8,\s*0\)', monitor), 'SR_GXF_STATUS_EL1 encoding changed') need(body(header, 'case FAST_TRAP_IGNORE:') == 'if(is_read)value=0;' and body(header, 'case FAST_TRAP_ZERO:') == 'if(is_read)value=0;', 'a fast IGNORE/ZERO body changed') zero = header[header.index('static inline int fast_trap_zero_reg'):] zero = re.sub(r'/\*.*?\*/|\s+', '', zero[:zero.index('}')], flags=re.S) need(zero.endswith('{returnreg==FAST_TRAP_SYSREG(3,6,15,8,0);'), 'fast_trap_zero_reg is not exactly GXF_STATUS_EL1') # 3 on the decoded sites themselves. for s in sites: is_read, reg, rt = access(s['word']) need(reg in EXPECTED and is_read == s['read'] and rt == s['rt'], f'{s["va"]:#x}: site decode') need(movz_zero(s['replacement']) == rt if is_read and rt != 31 else s['replacement'] == 0xd503201f, f'{s["va"]:#x}: replacement') admission(image, sites) 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('> 22 == 0b1101010100 and (a := access(w)) and a[1] in EXPECTED: remaining += 1 need(not remaining, f'{remaining} listed-register accesses remain in the patched image') del explicit_off, zero, keyword, after # 7. With native calls, private and HVC. for hvc in ('0', '1'): with environment(SNAP_NATIVE_CALLS='1', SNAP_SPTM_HVC_CALLS=hvc): base = build(xb, log) both = build(xb, log, sysreg_zero=True) contract = base['native_call_contract'] need(both['native_call_contract']['pages'] == contract['pages'] and both['stub_pa'] == base['stub_pa'] and both['sptm_hvc_calls'] == (hvc == '1'), 'native-call contract changed under sysreg-zero') need(not set(contract['call_sites']) & set(replaced) and not set(contract['writer_sites']) & set(replaced), 'a sysreg site is a native call or writer site') hvc_words = {va: HVC_WORD for va in contract['call_sites']} if hvc == '1' else {} need(uploaded(base) == expected(hvc_words), f'HVC={hvc}: base image is not stock plus HVC words') need(uploaded(both) == expected({**hvc_words, **replaced}), f'HVC={hvc}: image differs from stock beyond the HVC words and the sysreg sites') pages = {va & ~4095 for va in contract['call_sites']} | {va & ~4095 for va in contract['writer_sites']} shared = sum(1 for s in sites if s['va'] & ~4095 in pages) del base, both manifest = dict(kernel_sha256=z.J700_26A428, closure=z.closure(sites), movz=record['movz'], nop=record['nop'], sites_on_native_call_or_writer_pages=shared, sites=[dict(va=hex(s['va']), register=s['register'], op='MRS' if s['read'] else 'MSR', rt=s['rt'], bundle=s['bundle'], before=f'{s["word"]:#010x}', after=f'{s["replacement"]:#010x}') for s in sites]) if args.symbolize: import runpy names = runpy.run_path(str(ROOT / 'tools/kc-symbolicate.py'))['Symbolicator']().resolve( [s['va'] for s in sites]) for entry, line in zip(manifest['sites'], names): entry['function'] = line.split(' ', 1)[-1] if args.output: (args.output / 'sysreg-zero-sites.json').write_text(json.dumps(manifest, indent=1) + '\n') print(f'PASS: {checks} checks; {len(sites)} sites ({record["movz"]} MOVZ, {record["nop"]} NOP), ' f'{changed} changed words; {shared} on native call/writer pages; defaults unchanged') if __name__ == '__main__': main()