#!/usr/bin/env python3 """Put boot-progress probes into a COPY of the source XNU kernel (never the original). There is no serial console on the device and the kernel cannot draw before its own video console exists, so the early boot stages are reported by side channels. The stage code lives in the zero padding after the kernel entry (_start is one `b`) and displaced instructions are re-executed there: stage 1 (kernel entry, MMU off) blue bar, then continues stage 2 (just before the MMU is on) magenta bar, then continues stage 3 (right after the MMU is on) waits --reset-delay seconds, then PSCI SYSTEM_RESET. A reboot by itself after that time shows the CPU got through the MMU enable; the frame buffer is not mapped by then, so time is the signal. panic screen (--panic-screen) panic() and its common panic funnel draw their format string on the kernel's own frame buffer mapping and stops (scripts/xnu-panic-screen.c). --no-reset leaves out stages 3 and 4. stage 4 (--reset-after SYMBOL) the same wait and reset, but placed after the call to SYMBOL in --in FUNCTION (the Nth call, --occurrence). It replaces stage 3, so one run asks one question: did the kernel get past that call? The bars are the 1/16th bands of the frame buffer below the green one that DarwinBoot paints (band index = stage). Each patch site is checked against its expected original instruction first, and the tool refuses a kernel that does not match. Offsets are specific to the kernel built by build-xnu-4k-baseline.sh. """ import argparse import os import shutil import struct import subprocess import sys import tempfile from pathlib import Path ENTRY_VA = 0xFFFFFFF00B9D0488 # LC_UNIXTHREAD pc, `b start_first_cpu` START_FIRST_CPU = 0xFFFFFFF00B9D205C MMU_ENABLE_VA = 0xFFFFFFF00B9D2330 # msr SCTLR_EL1, x0 AFTER_MMU_VA = 0xFFFFFFF00B9D2350 # mov x0, #0x300000 PANIC_VA = 0xFFFFFFF00BF69B04 # _panic VINFO_VA = 0xFFFFFFF00C0A2970 # _vinfo PANIC_CAVE_END = 0xFFFFFFF00B9D0FF0 CAVE_SIZE = 0x60 CAVES = {1: 0xFFFFFFF00B9D0500, 2: 0xFFFFFFF00B9D0560, 3: 0xFFFFFFF00B9D05C0, 4: 0xFFFFFFF00B9D0620} TIMEBASE = 19200000 PAINT = """ ldr x1, [{args}, #0x28] ldr x2, [{args}, #0x38] ldr x3, [{args}, #0x48] mul x2, x2, x3 lsr x2, x2, #4 bic x2, x2, #7 mov x4, #{band} mul x5, x2, x4 add x1, x1, x5 movz w6, #{lo} movk w6, #{hi}, lsl #16 1: stp w6, w6, [x1], #8 subs x2, x2, #8 b.hi 1b dsb sy """ SOURCES = { 1: ".text\n.global _start\n_start:\n" + PAINT.format(args='x0', band=1, lo='0x00FF', hi='0xFF00') + " b target\n", 2: ".text\n.global _start\n_start:\n" + PAINT.format(args='x20', band=2, lo='0x00FF', hi='0xFFFF') + " msr SCTLR_EL1, x0\n b target\n", 3: """.text .global _start _start: mov x0, #0x300000 mrs x1, cntpct_el0 ldr x2, ={ticks} add x2, x1, x2 1: isb mrs x1, cntpct_el0 cmp x1, x2 b.lo 1b movz w0, #0x0009 movk w0, #0x8400, lsl #16 mov x1, xzr mov x2, xzr mov x3, xzr smc #0 2: wfe b 2b """ + ".ltorg\n", 4: """.text .global _start _start: bl callee mrs x1, cntpct_el0 ldr x2, ={ticks} add x2, x1, x2 1: isb mrs x1, cntpct_el0 cmp x1, x2 b.lo 1b movz w0, #0x0009 movk w0, #0x8400, lsl #16 mov x1, xzr mov x2, xzr mov x3, xzr smc #0 2: wfe b 2b """ + ".ltorg\n", } RESET_BEFORE = """.text .global _start _start: movz w0, #0x0009 movk w0, #0x8400, lsl #16 mov x1, xzr mov x2, xzr mov x3, xzr smc #0 1: wfe b 1b """ def kernel_symbols(kernel): """Address -> name for the kernel's symbols, from llvm-nm.""" nm = shutil.which('llvm-nm') or '/opt/homebrew/opt/llvm/bin/llvm-nm' table = {} for line in subprocess.run([nm, '-n', kernel], capture_output=True, text=True, check=True).stdout.splitlines(): parts = line.split() if len(parts) == 3: table.setdefault(int(parts[0], 16), parts[2]) return table def function_range(symbols, name): """Start and end (next symbol) of a function by its symbol name.""" starts = sorted(symbols) for start, symbol in symbols.items(): if symbol == name: return start, next(address for address in starts if address > start) raise SystemExit(f'No symbol {name} in the kernel') def find_call(data, symbols, function, name, occurrence): """The VA and target of the Nth `bl name` inside FUNCTION.""" hits = [] low, high = function_range(symbols, function) for va in range(low, high, 4): word = struct.unpack_from('> 26 == 0b100101: delta = word & 0x3FFFFFF delta -= (1 << 26) if delta & (1 << 25) else 0 target = va + delta * 4 if symbols.get(target) == name: hits.append((va, target)) if len(hits) < occurrence: raise SystemExit(f'{function} has {len(hits)} call(s) to {name}, not {occurrence}') return hits[occurrence - 1] def assemble_c(source, va, workdir, symbols): """Build a freestanding C file for VA; returns (bytes, load address of the first byte, address of panic_screen). The linker may start .text a little after VA (it keeps the ELF headers in front), so the load address is read back from the ELF and is what the caller must write the bytes to.""" clang = shutil.which('clang') or '/opt/homebrew/opt/llvm/bin/clang' lld = shutil.which('ld.lld') or '/opt/homebrew/opt/llvm/bin/ld.lld' objcopy = shutil.which('llvm-objcopy') or '/opt/homebrew/opt/llvm/bin/llvm-objcopy' nm = shutil.which('llvm-nm') or '/opt/homebrew/opt/llvm/bin/llvm-nm' # UNIXNU_UEFI is the Mu-Silicium checkout when the unixnu driver runs this script. font_dir = Path(os.environ.get('UNIXNU_UEFI') or ROOT / 'uefi-sheng') / 'Silicon/Silicium/SiliciumPkg/Library/FrameBufferSerialPortLib' if not (font_dir / 'Font.h').is_file(): raise SystemExit('Panic screen requires Mu-Silicium FrameBufferSerialPortLib/Font.h; ' 'set --uefi PATH or [build] uefi in unixnu.local.toml ' '(UNIXNU_UEFI for direct script calls)') obj, elf, binary = (workdir / name for name in ('panic.o', 'panic.elf', 'panic.bin')) definitions = [] for macro, name, width in (('MAX_MEM', '_max_mem', 8), ('PAGE_TOTAL', '_vm_page_pages', 4), ('PAGE_FREE', '_vm_page_free_count', 4)): address = symbols.get(name) if address is None or address % width or not any( base <= address and address + width <= base + size for base, size, _, _ in SEGMENTS): raise SystemExit(f'Missing or invalid diagnostic symbol {name}') definitions.append(f'-D{macro}={address:#x}UL') # Optional: the board's banner buffer for the panic message (kits/sheng/BootProgress.c); kernels without it get 0 (no copy). text = symbols.get('_boot_progress_panic_text') if text is not None: if not any(base <= text and text + 256 <= base + size for base, size, _, _ in SEGMENTS): raise SystemExit('Invalid _boot_progress_panic_text') definitions.append(f'-DPANIC_TEXT={text:#x}UL') subprocess.run([clang, '--target=aarch64-none-elf', '-ffreestanding', '-fno-builtin', '-fno-stack-protector', '-mgeneral-regs-only', '-fno-pic', '-mcmodel=tiny', '-Oz', f'-DVINFO={VINFO_VA:#x}UL', *definitions, f'-I{font_dir}', '-c', source, '-o', obj], check=True) script = workdir / 'panic.ld' script.write_text('SECTIONS { . = %#x; .text : { *(.text.a_entry) *(.text.a_common) *(.text*) *(.rodata*) } /DISCARD/ : { *(.comment) *(.note*) *(.eh_frame*) *(.ARM.*) } }\n' % va) size = shutil.which('llvm-size') or '/opt/homebrew/opt/llvm/bin/llvm-size' sizes = subprocess.run([size, '--format=berkeley', obj], capture_output=True, text=True, check=True).stdout.splitlines()[-1].split() if int(sizes[1]) or int(sizes[2]): # The code runs from the kernel's read-only text: a global would fault on its first store. raise SystemExit(f'{source.name} has writable data (data {sizes[1]}, bss {sizes[2]}); use the stack') subprocess.run([lld, '-T', script, '-e', 'panic_screen', obj, '-o', elf], check=True) # Only .text: a plain binary dump would start at the ELF headers kept in front of it. subprocess.run([objcopy, '-O', 'binary', '-j', '.text', elf, binary], check=True) readelf = shutil.which('llvm-readelf') or '/opt/homebrew/opt/llvm/bin/llvm-readelf' load = None for line in subprocess.run([readelf, '-S', elf], capture_output=True, text=True, check=True).stdout.splitlines(): parts = line.replace('[', ' ').replace(']', ' ').split() if len(parts) >= 4 and parts[1] == '.text': load = int(parts[3], 16) if load is None or load < va: raise SystemExit('Cannot find where the linker put .text') entry = None for line in subprocess.run([nm, elf], capture_output=True, text=True, check=True).stdout.splitlines(): parts = line.split() if len(parts) == 3 and parts[2] == 'panic_screen': entry = int(parts[0], 16) if entry != load: raise SystemExit(f'panic_screen at {entry:#x} is not the first byte {load:#x}') return binary.read_bytes(), load, entry def panic_common_entry(workdir, code, load): """Resolve the real alternate entry and verify its distinct argument ABI.""" nm = shutil.which('llvm-nm') or '/opt/homebrew/opt/llvm/bin/llvm-nm' names = {parts[2]: int(parts[0], 16) for line in subprocess.check_output([nm, workdir / 'panic.elf'], text=True).splitlines() if len(parts := line.split()) == 3} entry = names.get('panic_screen_common') main = names.get('panic_screen_main') if entry is None or main is None or not load <= entry <= load + len(code) - 8: raise SystemExit('Common panic renderer entry is missing or out of bounds') at = entry - load if code[at:at + 8] != struct.pack(' CAVE_SIZE: raise SystemExit(f'Stage {stage} code is too large') data[file_offset(CAVES[stage]):file_offset(CAVES[stage]) + len(code)] = code for site, stage, _ in sites: struct.pack_into(' PANIC_CAVE_END: raise SystemExit(f'panic screen is {len(code):#x} bytes at {load:#x}, the padding ends at {PANIC_CAVE_END:#x}') if any(data[file_offset(load):file_offset(load) + len(code)]): raise SystemExit(f'Padding at {load:#x} is not empty') data[file_offset(load):file_offset(load) + len(code)] = code struct.pack_into('