Files
UniXNU/scripts/symbolicate-original-user-exception.py
2026-10-19 18:49:31 +08:00

153 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Locate actual saved user PCs in the unchanged target's cache and local symbols."""
import argparse
import json
import mmap
from pathlib import Path
import struct
import subprocess
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--report', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
d = json.loads(args.report.read_text())
if d['stop_symbol'] != '_exception_triage' or d['bounded_timeout_interrupted']:
raise SystemExit('Require a real exception entry, not a timeout or kernel panic')
states = d['entry_user_state_candidates']
if len(states) != 1:
raise SystemExit('Require exactly one ABI-qualified saved user state')
u = states[0]
code = struct.unpack('<2Q', bytes.fromhex(d['entry_regions']['1:0:10']['bytes']))
if (int(d['observed_function_entry_arguments'][0], 16) != 6 or code != (1, int(u['user_pc'], 16)) or
int(u['cpsr'], 16) & 31 or int(u['esr'], 16) >> 26 != 0x3c or
bytes.fromhex(u['instruction_bytes'])[:4] != bytes.fromhex('000020d4')):
raise SystemExit('Require cross-qualified actual EL0 BRK exception/code/PC/syndrome')
source = ROOT / 'refer/dyld'
if subprocess.check_output(['git', '-C', source, 'rev-parse', 'HEAD'], text=True).strip() != 'a571176e8e00c47e95b95e3156820ebec0cbd5e6':
raise SystemExit('Unexpected pinned cache-format reference')
work = ROOT / 'build/native-user-exception'
work.mkdir(parents=True, exist_ok=True)
cpp = work / 'offsets.cpp'
cpp.write_text('''#include <cstddef>
#include <cstdio>
#include "dyld_cache_format.h"
int main(){printf("%zu %zu %zu %zu %zu %zu %zu %zu %zu %zu\\n",
offsetof(dyld_cache_header,imagesTextOffset),offsetof(dyld_cache_header,localSymbolsOffset),
offsetof(dyld_cache_header,sharedRegionStart),sizeof(dyld_cache_image_text_info),
sizeof(dyld_cache_local_symbols_info),sizeof(dyld_cache_local_symbols_entry_64),
sizeof(dyld_cache_local_symbols_entry),offsetof(dyld_cache_header,symbolFileUUID),
offsetof(dyld_cache_header,mappingWithSlideOffset),sizeof(dyld_cache_mapping_and_slide_info));}
''')
subprocess.run(['xcrun', 'clang++', '-std=c++17', '-I', source / 'cache-builder', cpp, '-o', work / 'offsets'], check=True)
textoff, localoff, regionoff, textsize, infosize, entrysize, _, uuidfield, slidefield, slidesize = map(
int, subprocess.check_output([work / 'offsets'], text=True).split())
if (textsize, infosize, entrysize) != (32, 24, 16):
raise SystemExit('Unexpected original cache ABI')
cache = ROOT / '.cache/firmware/target-volumes/systemos-mount/System/Library/Caches/com.apple.dyld'
needle = bytes.fromhex(u['instruction_bytes'])
hits = []
catalog = []
for path in sorted(cache.glob('dyld_shared_cache_arm64*')):
if path.suffix == '.symbols':
continue
with path.open('rb') as stream, mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as b:
if b[:16].rstrip(b'\0') != b'dyld_v1 arm64':
raise SystemExit('Unexpected original cache architecture')
mo, mc = struct.unpack_from('<2I', b, 16)
mappings = [struct.unpack_from('<3Q2I', b, mo + i * 32) for i in range(mc)]
so, sc = struct.unpack_from('<2I', b, slidefield)
for i in range(sc):
catalog.append((path, struct.unpack_from('<7Q2I', b, so + i * slidesize)))
pos = b.find(needle)
while pos >= 0:
for va, size, off, _, prot in mappings:
if off <= pos and pos + len(needle) <= off + size and prot & 4:
hits.append(dict(file=path.name, offset=pos, unslid_pc=va + pos - off))
pos = b.find(needle, pos + 1)
if len(hits) != 1:
raise SystemExit('Actual instruction bytes must uniquely identify an executable cache mapping')
hit = hits[0]
slide = int(u['user_pc'], 16) - hit['unslid_pc']
b = (cache / 'dyld_shared_cache_arm64').read_bytes()
region, maximum = struct.unpack_from('<2Q', b, regionoff)[0], struct.unpack_from('<Q', b, regionoff + 16)[0]
if not 0 <= slide <= maximum or slide % 4096:
raise SystemExit('Observed cache slide is outside the original cache bounds')
off, count = struct.unpack_from('<2Q', b, textoff)
images = []
for i in range(count):
_, va, size, path = struct.unpack_from('<16sQ2I', b, off + i * textsize)
images.append((va, size, b[path:b.find(b'\0', path)].decode()))
rows = []
with (cache / 'dyld_shared_cache_arm64.symbols').open('rb') as stream, mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as symbols:
if symbols[88:104] != b[uuidfield:uuidfield + 16]:
raise SystemExit('Original symbols UUID does not match the original main cache')
lo, _ = struct.unpack_from('<2Q', symbols, localoff)
no, nc, so, ss, eo, ec = struct.unpack_from('<6I', symbols, lo)
entries = {struct.unpack_from('<Q', symbols, lo + eo + i * entrysize)[0]:
struct.unpack_from('<2I', symbols, lo + eo + i * entrysize + 8) for i in range(ec)}
pcs = [int(u['user_pc'], 16)] + [int(f['lr'], 16) - 4 for f in u['frames'] if int(f['lr'], 16)]
for pc in pcs:
va = pc - slide
image = next((im for im in images if im[0] <= va < im[0] + im[1]), None)
row = dict(pc=hex(pc), unslid_va=hex(va), image=image[2] if image else None)
if image and image[0] - region in entries:
start, count = entries[image[0] - region]
best = None
if start + count > nc:
raise SystemExit('Symbol entry exceeds the actual local table')
for i in range(start, start + count):
string, kind, section, _, value = struct.unpack_from('<IBBHQ', symbols, lo + no + i * 16)
if not kind & 0xe0 and kind & 0xe == 0xe and section and image[0] <= value <= va and (best is None or value > best[0]):
best = value, string
if best:
value, string = best
if string >= ss:
raise SystemExit('Invalid original symbol string offset')
pos = lo + so + string
row.update(nearest_local_symbol=symbols[pos:symbols.find(b'\0', pos)].decode(), offset=va - value)
rows.append(row)
result = dict(kernel_sha256=d['kernel_sha256'], actual_exception='EXC_BREAKPOINT', actual_codes=list(code),
unique_instruction_match=hit, cache_slide=hex(slide), frames=rows,
local_symbols_uuid_matches=True, functional_ipados_ui_demonstrated=False,
original_files_modified=False)
def read_original(va, length):
for path, mapping in catalog:
start, size, off = mapping[:3]
if start <= va and va + length <= start + size:
with path.open('rb') as stream:
stream.seek(off + va - start)
return stream.read(length), path, mapping
raise SystemExit('Requested static address is not in an original cache mapping')
# Qualify the literal referenced by this observed original failure block;
# this is static disassembly evidence, not a captured runtime log message.
if rows[0].get('nearest_local_symbol') == '-[FBSDisplayMonitor _initWithBookendObserver:transformer:]':
address = hit['unslid_pc'] - 0x48
words = struct.unpack('<2I', read_original(address, 8)[0])
adrp, add = words
if adrp & 0x9f00001f != 0x90000002 or add & 0xffc003ff != 0x91000042:
raise SystemExit('Original assertion reference instructions differ')
immediate = ((adrp >> 29) & 3) | (((adrp >> 5) & 0x7ffff) << 2)
if immediate & (1 << 20):
immediate -= 1 << 21
literal = (address & ~4095) + (immediate << 12) + ((add >> 10) & 4095)
raw, path, mapping = read_original(literal, 32)
pointer, length = struct.unpack_from('<2Q', raw, 16)
with path.open('rb') as stream:
stream.seek(mapping[3])
info = stream.read(40)
if struct.unpack_from('<I', info)[0] != 2 or not 0 < length <= 256:
raise SystemExit('Original assertion requires a bounded CFString and version-2 slide record')
mask, base = struct.unpack_from('<2Q', info, 24)
pointer = (pointer & (~mask & ((1 << 64) - 1))) + base
text = read_original(pointer, length)[0].decode('ascii')
result['static_original_assertion'] = dict(literal_va=hex(literal), text=text,
runtime_string_captured=False)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))