Files
apk-reverse/tests/unit/test_kernel_instrument_scaffold.py
root ab22b225ff Hardware pass: hardware watchpoints, ETE differential tracing, kernel-side event injection
Add the layer under the software tooling, in the repository's shape: what runs
on a host is measured, and every device-bound or kernel-bound piece is a gated,
labelled template.

- scripts/hw_watch.py: arm64 hardware watchpoints/execution breakpoints. WCR/
  BCR encoders with the 8-byte-window and alignment rules (unit-tested to the
  canonical 0x1FFD/0x3F05), a self-contained on-device ptrace helper that
  compiles clean as a static aarch64 ELF (observed: cross gcc 16.2), adb
  orchestration, and hit decoding to module+offset. The stealth ledger is
  stated three times on purpose: the ptrace route leaves TracerPid visible.
- scripts/ete_capture.py: CoreSight ETE capture with the five gates (kernel
  era, coresight sysfs, cs_etm PMU, perf-with-CORESIGHT, permission), the
  perf/OpenCSD build recipe, and the per-thread record wrapper whose
  --control one-instruction run must pass before any real capture.
- scripts/trace_diff.py: differential reduction of two execution traces into
  first-divergence / divergent regions / blocks-unique-to-each-side.
  Producer-agnostic: Stalker BLK logs (raw or run_probe-wrapped) and decoded
  cs_etm address streams alike, host-tested on synthetic flattened-dispatcher
  fixtures.
- scripts/kernel_instrument_scaffold.py: KPM/LKM watchpoint + register-rewrite
  templates plus an honest userspace carrier; every kernel file carries a
  NOT COMPILED AND NOT LOADED banner that verify enforces, and the
  register-rewrite action is fenced as experimental in the template itself.
- references/hardware-watchpoints.md, references/ete-diff-tracing.md,
  references/kernel-event-injection.md: the mechanism, the gates, the traps,
  and the composition recipes (watch the key buffer; diff two runs; flip the
  decision at the trap), with hand-offs instead of duplication.
- Registration: 3 symptom rows (SKILL.md + routing mirror), 3 reference rows,
  4 script rows, coverage/route-inventory/evidence rows, capability-matrix
  entries, known-limitations entry, eval case 4, README trees and coverage
  paragraphs in both languages, and three EXTENSION evidence records stating
  exactly what was measured on this host (encoders, compile, fixtures,
  generate/verify) and what was not (no watchpoint armed, no cs_etm stream,
  nothing kernel-side compiled or loaded anywhere).

Hardware pass 2: SPE/BRBE crash forensics, address symbolization, VM de-virtualization

Calibrated against the two public obfuscator implementations the user supplied
(dpkg123/ollvm-aosp-toolchain: Hikari-family and Pluto-family flattening, the
xvmp IR virtualizer at 63fec41f/9b7cf75e; fetched read-only into
tools/_work/refs, git-ignored). Source facts observed, runtime inferred.

- scripts/trace_addr2sym.py: runtime addresses -> symbol + static offset,
  zero-dependency ELF64 (PT_LOAD vaddr<->file-off conversion, .dynsym/.symtab,
  stripped section+off fallback). Host-verified against a real cross-compiled
  aarch64 .so with readelf as the independent cross-check, plus a hand-built
  minimal ELF covering vaddr!=offset; accepts hw_watch hits, trace mod+off
  keys, or raw address lists. Fixes on the way: --base int coercion, and
  nearest-below symbol semantics for PCs in inter-symbol gaps.
- scripts/vm_handler_slice.py: slice an interpreter trace into handler
  segments (dispatcher detection = histogram top + predecessor diversity; the
  first 4x-margin rule refused the calibrated fixture itself and was replaced),
  signature clustering, open-tail handling, and the guest-opcode trajectory via
  an optional handler->opcode map. Calibrated against xvmp's documented shape:
  vm_exec(code,size,vm_args,num) stubs, boxed arguments observable before
  interpretation, 11 opcodes + 19 arithmetic sub-ops, const-materialization
  dispatch as a distinct segment.
- references/hardware-sampling-and-crash.md: SPE (data flow without a known
  address; PC + data address per sample), BRBE (branch records when a crash
  stack cannot be trusted), the crash loop (tombstone -> branch records ->
  symbolize), and what each adds to the OLLVM/VMP routes. All
  documented-and-gated: no SPE sample or BRBE record was captured here.
- Flattening-recipe corrections from source: Hikari keeps a frame-resident
  switchVar written through an indirect pointer with scramble32-permuted cases;
  Pluto stores switchVar/switchXor rolling-XOR so memory only ever holds noise
  -- hardware-watchpoints.md's state-variable recipe now distinguishes the
  families, and ete-diff-tracing.md carries the family table plus the xvmp
  shape. AntiDebugging injects raw svc inline asm with randomized fields, so
  byte signatures cannot survive it; semantic site-location can.
- Registration: 3 symptom rows (untrustworthy crash stack; runtime addresses
  needing symbols; interpreter loop reading as noise), reference/script rows,
  coverage/evidence/capability-matrix entries, README trees in both languages,
  and EXTENSION-sampling-crash.md recording exactly what was measured (real .so
  symbolization, slicer fixtures, the slicer's self-found dispatcher-rule bug)
  and what was not (no SPE/BRBE capture, no hardened binary ever traced).

Fix all PR #1 review findings by severity; un-entrench the tests that pinned them

Major (device-breaking / wrong results):
- hw_watch: match the arm64 user_hwdebug_state slot ABI (address register
  first, control second -- the swap armed nothing correctly)
- hw_watch: every post-seize error path now clears slots and detaches;
  PTRACE_O_EXITKILL turned an analysis failure into a SIGKILL of the target
- hw_watch: forward genuine target SIGTRAPs (raise/tgkill/int3); only ptrace
  event stops are consumed -- the reference's signal-preservation promise
- hw_watch: run checks push/build/chmod and the helper's exit status; a dead
  capture is RESULT=blocked, never ok-no-hits
- hw_watch: decode offsets from the module base, not each mapping's start
- ete_capture: --control now requires the control record to DECODE
  (perf script event lines), the documented gate, not just a zero exit
- ete_capture: -o before the -- workload separator in --cmd mode; all
  values shell-quoted into su -c; stale remote perf.data removed first;
  perf exit status checked (timeout's 124 is the normal window end)
- kernel scaffold: --set-reg restricted to x0..x30; the rewrite is gated by
  its own breakpoint event so a watchpoint hit cannot run it; LKM pins the
  task (get/put_task_struct) across registration and calls the four-argument
  register_user_hw_breakpoint; --break rows initialize .addr/.len/.bp_type
  (there is no .pc member); --id validated against the module-id grammar
- trace_addr2sym: exact module matching (path or basename, never substring);
  load bias aligned against the PT_LOAD (bias = start - (foff - p_offset)
  - p_vaddr); PCs outside the ELF span are refused instead of landing on the
  last symbol; --json stdout is a pure document (RESULT= in text mode only)
- trace_diff: block keys need 0x or >=8 hex digits (PIDs/counts no longer
  parse); vm_handler_slice: dispatcher needs predecessor diversity, no
  frequency-margin fallback; both JSON outputs pure (check_commands' rule)

Minor/nitpick:
- capability-matrix + evidence-summary: trace reduction status ok->partial
  (no real-target producer ran it; the file's own definition of ok)
- hardware-sampling-and-crash.md and the script examples carry --elf
- kernel-event-injection.md no longer claims the callback's first statement
  is a filter (it prints unfiltered; the filter is a required change)
- Elf64 caches symbol addresses for per-row bisect
- docstring coverage of the touched surface: 16.67% -> 100%

Tests: every json.loads workaround stripping RESULT= is gone; new regression
tests cover the slot ABI, release-on-error, SIGTRAP forwarding, non-first
mapping offsets, -o placement and su -c quoting, the control decode gate,
x31/--id refusals, the guarded rewrite, outside-ELF PCs and bias math,
short-token refusal and the dispatcher margin rule. The minimal-ELF fixture
now packs a full 8-field Elf64_Phdr (its 7-field pack silently shifted
p_memsz onto p_align). 739 passed; flake8/mypy and all five gates clean.

Address the follow-up review on 0bd8dfe (6 findings)

- ete_capture: two-layer quoting for the control-record, decode and cleanup
  commands -- shlex.quote spliced between literal single quotes broke out of
  su -c for any spaced output name; --seconds must be positive (timeout 0
  disables the timer), refused before any adb check
- kernel scaffold: --set-reg now refuses sp/pc as well (the template has no
  rewrite path for them; a generate that silently drops the action is the
  misleading kind of success); both templates arm with a sampling period
  (LKM via hw_breakpoint_init, KPM spells out sample_period=1) -- a zero
  period is a non-sampling event whose callback never fires: armed, silent
- trace_addr2sym: contains_vaddr tests each PT_LOAD's loaded span instead
  of the whole-file extent; a PC in the unmapped gap between segments is
  refused rather than named after the nearest symbol below
- trace_diff: a bare all-digit token is indistinguishable from a date or
  count, so block keys need 0x or a non-decimal hex digit; a date-only log
  stays refused as a capture failure

Tests: two-layer quoting with a spaced control name, --seconds refusal,
sp/pc in the rejection list, sampling-period assertions for both templates,
a two-segment gap fixture, and a date-only parser case. 743 passed; flake8,
mypy and all five gates clean.
2026-10-15 15:33:28 +08:00

190 lines
8.9 KiB
Python

"""Unit tests for kernel_instrument_scaffold.py: spec validation, generate,
verify, and the honesty devices (the unverified banner, the carrier that
cannot arm anything, the experimental action fence)."""
import json
import pytest
from conftest import load_script, run_script
ks = load_script("kernel_instrument_scaffold.py")
class TestSpecValidation:
def test_watch_spec_roundtrip(self):
"""Watch spec roundtrip."""
row = ks.parse_spec_watch("w:0x7b2000123450:8:w")
assert row == {"addr": "0x7b2000123450", "len": 8, "access": "w"}
def test_window_rule_shared_with_hw_watch(self):
"""Window rule shared with hw watch."""
with pytest.raises(ValueError, match="crosses"):
ks.parse_spec_watch("w:0x7b2000123456:8:w")
def test_break_alignment(self):
"""Break alignment."""
assert ks.parse_spec_break("b:0x1234c0")["pc"] == "0x1234c0"
with pytest.raises(ValueError, match="4-byte aligned"):
ks.parse_spec_break("b:0x1234c2")
def test_set_reg_shape(self):
"""Set reg shape."""
row = ks.parse_spec_break("b:0x1234c0", set_reg="x0=0x1")
assert row["set_reg"] == "x0" and row["set_value"] == "0x1"
with pytest.raises(ValueError):
ks.parse_spec_break("b:0x1234c0", set_reg="x0")
def test_set_reg_rejects_registers_the_template_cannot_write(self):
"""x31 is sp's slot in user_pt_regs and the array ends at x30: an
index past 30 writes sp/pc/pstate or off the frame."""
for bad in ("x31=0x1", "x99=0x1", "ax5=0x1", "x0", "y0=0x1"):
with pytest.raises(ValueError):
ks.parse_spec_break("b:0x1234c0", set_reg=bad)
for good in ("x0=0x1", "x30=0x2", "x30=5"):
ks.parse_spec_break("b:0x1234c0", set_reg=good)
def generate(tmp_path, extra=()):
"""Generate the standard scaffold; return its output directory."""
out = tmp_path / "kwatch"
argv = ["generate", "--out", str(out), "--pid", "12345",
"--watch", "w:0x7b2000123450:8:w", "--break", "b:0x7b20001234c0",
"--set-reg", "x0=0x1"] + list(extra)
r = run_script("kernel_instrument_scaffold.py", argv)
assert r.returncode == 0, r.output
return out
class TestGenerate:
def test_layout_and_config(self, tmp_path):
"""Layout and config."""
out = generate(tmp_path)
expected = ["config/instrument.json", "kpm/src/kwatch.c", "kpm/Makefile",
"lkm/kwatch.c", "lkm/Makefile", "module.prop", "README.md",
"post-fs-data.sh"]
for rel in expected:
assert (out / rel).is_file(), rel
cfg = json.loads((out / "config" / "instrument.json").read_text(encoding="utf-8"))
assert cfg["pid"] == 12345
assert cfg["watches"][0]["addr"] == "0x7b2000123450"
assert cfg["breaks"][0]["set_reg"] == "x0"
def test_kernel_files_carry_the_banner_and_the_kpm_lessons(self, tmp_path):
"""Kernel files carry the banner and the kpm lessons."""
out = generate(tmp_path)
kpm = (out / "kpm" / "src" / "kwatch.c").read_text(encoding="utf-8")
lkm = (out / "lkm" / "kwatch.c").read_text(encoding="utf-8")
for body in (kpm, lkm):
assert "NOT COMPILED AND NOT LOADED" in body
assert "EXPERIMENTAL" in body # the set-reg fence
assert "kfunc_def" in kpm
# the banner text mentions the generator, so a stripped banner is traceable
assert "kernel_instrument_scaffold.py" in kpm
# the user-task breakpoint API: four arguments, task last (the per-cpu
# register_wide_* family cannot arm on one target task)
assert "register_user_hw_breakpoint" in lkm
assert "&attr, wp_hit, NULL, task)" in lkm
# the task pointer is pinned past the RCU section and released after
# registration: registration can sleep, an unpinned task can be freed
assert "get_task_struct(task)" in lkm and "put_task_struct(task)" in lkm
# the execution breakpoint row initializes fields wp_cfg declares
# (.addr/.len/.bp_type) -- there is no .pc member to initialize
for body in (kpm, lkm):
assert ".pc = " not in body
assert ".addr = 0x7b20001234c0, .len = 8, .bp_type = 4" in kpm
def test_breakpoint_rows_initialize_declared_fields(self, tmp_path):
"""Breakpoint rows initialize declared fields."""
out = generate(tmp_path)
for body in ((out / "kpm" / "src" / "kwatch.c").read_text(encoding="utf-8"),
(out / "lkm" / "kwatch.c").read_text(encoding="utf-8")):
assert ".pc = " not in body # no such member
assert ".addr = 0x7b20001234c0, .len = 8, .bp_type = 4" in body
def test_events_carry_a_sampling_period(self, tmp_path):
"""A zero sample_period is a non-sampling event: perf arms it, the
overflow callback (wp_hit) never runs, and the module reports armed
events that stay silent forever."""
out = generate(tmp_path)
kpm = (out / "kpm" / "src" / "kwatch.c").read_text(encoding="utf-8")
lkm = (out / "lkm" / "kwatch.c").read_text(encoding="utf-8")
assert "attr.sample_period = 1;" in kpm # hw_breakpoint_init, spelled out
assert "hw_breakpoint_init(&attr);" in lkm
def test_set_reg_rewrite_is_gated_to_its_breakpoint_event(self, tmp_path):
"""wp_hit fires for every registered event (watch and break alike), so
the rewrite must check it landed on its own breakpoint."""
out = generate(tmp_path)
for body in ((out / "kpm" / "src" / "kwatch.c").read_text(encoding="utf-8"),
(out / "lkm" / "kwatch.c").read_text(encoding="utf-8")):
assert "event->attr.bp_addr == 0x7b20001234c0" in body
assert body.count("regs->regs[0] = 0x1;") == 1
# the write sits inside the guard, not at callback top level
guarded = body.split("regs->regs[0] = 0x1;")[0]
assert "event->attr.bp_type & 4" in guarded
def test_id_is_restricted_to_a_module_identifier(self, tmp_path):
"""--id becomes paths, Makefile targets and module.prop entries: a
path separator or leading dot would write outside --out."""
for bad in ("../evil", "a/b", ".hidden", "", "a b"):
argv = ["generate", "--out", str(tmp_path / "x"), "--pid", "1",
"--watch", "w:0x1000:8:w", "--id", bad]
r = run_script("kernel_instrument_scaffold.py", argv)
assert r.returncode == 2, bad
argv = ["generate", "--out", str(tmp_path / "ok"), "--pid", "1",
"--watch", "w:0x1000:8:w", "--id", "kwatch.v2-x"]
r = run_script("kernel_instrument_scaffold.py", argv)
assert r.returncode == 0, r.output
def test_carrier_says_what_it_cannot_do(self, tmp_path):
"""Carrier says what it cannot do."""
out = generate(tmp_path)
readme = (out / "README.md").read_text(encoding="utf-8")
assert "cannot arm a watchpoint" in readme
def test_no_specs_is_refused(self, tmp_path):
"""No specs is refused."""
r = run_script("kernel_instrument_scaffold.py",
["generate", "--out", str(tmp_path / "x"), "--pid", "1"])
assert r.returncode == 2
class TestVerify:
def test_clean_generation_verifies(self, tmp_path):
"""Clean generation verifies."""
out = generate(tmp_path)
r = run_script("kernel_instrument_scaffold.py", ["verify", "--dir", str(out)])
assert r.returncode == 0, r.output
assert "RESULT=ok" in r.output
def test_stripped_banner_is_a_problem(self, tmp_path):
"""Stripped banner is a problem."""
out = generate(tmp_path)
target = out / "kpm" / "src" / "kwatch.c"
body = target.read_text(encoding="utf-8").replace(
"NOT COMPILED AND NOT LOADED", "COMPILED SOMEWHERE, TRUST ME")
target.write_text(body, encoding="utf-8")
r = run_script("kernel_instrument_scaffold.py", ["verify", "--dir", str(out)])
assert r.returncode == 1
assert "banner" in r.output
def test_missing_config_is_a_problem(self, tmp_path):
"""Missing config is a problem."""
out = generate(tmp_path)
(out / "config" / "instrument.json").unlink()
r = run_script("kernel_instrument_scaffold.py", ["verify", "--dir", str(out)])
assert r.returncode == 1
assert "missing" in r.output
class TestGates:
def test_gate_table_includes_the_perf_breakpoint_layer(self):
"""Gate table includes the perf breakpoint layer."""
routes = {g["route"] for g in ks.GATES}
assert "perf hw-breakpoint slots" in routes
assert "KernelPatch / APatch KPM" in routes
r = run_script("kernel_instrument_scaffold.py", ["gates", "--json"])
assert r.returncode == 0
doc = json.loads(r.stdout) # --json stdout is one pure document
assert doc["status"] == "ok" and len(doc["gates"]) == len(ks.GATES)