feat(kernel): KRNBUG-AUDIT-005 — --pc-probe extension + canary diff identifies XexCheckExecutablePrivilege stub cascade

Extends `--ctor-probe` machinery into `--pc-probe` (clap alias) with
the optional `PC@DISPATCHER:OFFSET` token form: on a hit, the helper
additionally logs `[disp+off]` — what the producer's
`lwz r3, OFFSET(r3)` is about to read. Reuses `parse_hex_u32`; both
flags share parser + storage.

Read-only diagnostic. Lockstep digest preserved (`run digest matches
golden` at -n 50M `--stable-digest`). 588 tests green.

Decisive findings (full deliverable in `audit-findings.md` /
`audit-runs/audit-005/`):

- Failure mode α confirmed for KRNBUG-AUDIT-004: all 9 producer call
  sites for handles 0x100c (5 sites) and 0x15e0 (4 sites) fire 0x at
  -n 500M. The producer code path is not reached.

- Set-diff of kernel-call sequences (canary.log oracle vs ours.log
  at -n 500M) identifies 11 exports canary calls and we don't:
  XGetAVPack, XeCryptSha, XeKeysConsolePrivateKeySign,
  ObCreateSymbolicLink, NtDeviceIoControlFile (×2),
  XamUserReadProfileSettings (×2), XamTaskSchedule, XamTaskCloseHandle,
  KeReleaseSemaphore (×268), KeResetEvent, ExTerminateThread (×2).

- XGetAVPack has exactly one caller (sub_824AB578 at 0x824AB5A0).
  The 4 instructions immediately preceding it are:
      addi r3, r0, 10            ; privilege bit 10
      bl   XexCheckExecutablePrivilege
      cmpli 0, r3, 0
      bc 12, eq, 0x824AB724      ; if r3==0, skip whole block

- exports.rs:193 registers XexCheckExecutablePrivilege as
  stub_return_zero. Always returning 0 -> guest takes the branch
  and skips the entire AV/crypto/save-data init block.

- The other call site (sub_824A9710 at 0x824A99A0) queries privilege
  11 with opposite polarity (bne) -> gates XamTaskSchedule on the
  privilege-NOT-set arm. With both stubs returning 0, the guest
  walks the wrong arm of every privilege-gated branch.

- This explains why the dispatcher fields read zero
  ([0x828F3D08+0x50]=0, [0x828F4070+0x24]=0 from AUDIT-004 dumps):
  the ctors run, but the producers that would populate those fields
  with a non-zero handle never execute.

Next session: replace XexCheckExecutablePrivilege stub with real
priv-bit lookup from XEX header. See audit-findings.md
KRNBUG-AUDIT-005 for the validation matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-04 18:06:22 +02:00
parent 6a070bedc6
commit 3e2fc1ec88
3 changed files with 245 additions and 18 deletions

View File

@@ -184,8 +184,13 @@ enum Commands {
/// pool-element `this` addresses that MSVC ctors fail to
/// preserve in r31. Read-only; lockstep digest unaffected.
/// Examples: `--ctor-probe=0x8217C850`,
/// `--ctor-probe=0x82181750,0x821701C8`.
#[arg(long)]
/// `--ctor-probe=0x82181750,0x821701C8`. KRNBUG-AUDIT-005:
/// each token may be extended to `PC@DISPATCHER:OFFSET` (e.g.
/// `0x821802D8@0x828F3D08:80`); on a hit the helper additionally
/// logs the value of `[DISPATCHER+OFFSET]` — what the producer's
/// `lwz r3, OFFSET(r3)` is about to read. The flag is also
/// reachable as `--pc-probe` (semantically clearer name).
#[arg(long, alias = "pc-probe")]
ctor_probe: Option<String>,
/// Diagnostic. Comma-separated list of guest addresses to dump
/// (64 bytes each, hex + u32 lanes) at end-of-run, after the
@@ -916,27 +921,39 @@ fn cmd_exec_inner(
// worker prologue checks this set on every step; on a hit it
// prints a single back-chain capture line. Empty set = no
// probes = no-op fast path.
let ctor_probe_combined: Option<String> = match (ctor_probe, std::env::var("XENIA_CTOR_PROBE").ok()) {
let ctor_probe_combined: Option<String> = match (
ctor_probe,
std::env::var("XENIA_CTOR_PROBE").ok().or_else(|| std::env::var("XENIA_PC_PROBE").ok()),
) {
(Some(s), _) => Some(s.to_string()),
(None, Some(s)) if !s.is_empty() => Some(s),
_ => None,
};
if let Some(list) = ctor_probe_combined {
for token in list.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let parsed = if let Some(hex) = token.strip_prefix("0x").or_else(|| token.strip_prefix("0X")) {
u32::from_str_radix(hex, 16)
} else {
token.parse::<u32>()
let (pc_str, consumer): (&str, Option<(u32, u32)>) = match token.split_once('@') {
None => (token, None),
Some((pc_part, rest)) => {
let (disp_str, off_str) = rest.split_once(':').ok_or_else(|| {
anyhow::anyhow!(
"invalid --pc-probe token {token:?}: expected PC@DISPATCHER:OFFSET"
)
})?;
let disp = parse_hex_u32(disp_str.trim()).map_err(|e| {
anyhow::anyhow!("invalid dispatcher in {token:?}: {e}")
})?;
let off = parse_hex_u32(off_str.trim()).map_err(|e| {
anyhow::anyhow!("invalid offset in {token:?}: {e}")
})?;
(pc_part.trim(), Some((disp, off)))
}
};
match parsed {
Ok(pc) => {
kernel.ctor_probe_pcs.insert(pc);
}
Err(_) => {
return Err(anyhow::anyhow!(
"invalid PC in --ctor-probe: {token:?}"
));
}
let pc = parse_hex_u32(pc_str).map_err(|e| {
anyhow::anyhow!("invalid PC in --pc-probe: {token:?}: {e}")
})?;
kernel.ctor_probe_pcs.insert(pc);
if let Some(c) = consumer {
kernel.pc_probe_consumers.insert(pc, c);
}
}
if !quiet && !kernel.ctor_probe_pcs.is_empty() {
@@ -946,9 +963,10 @@ fn cmd_exec_inner(
.map(|p| format!("{p:#010x}"))
.collect();
tracing::info!(
"ctor probes armed: {} ({})",
"pc probes armed: {} ({}); consumer-snapshots: {}",
kernel.ctor_probe_pcs.len(),
pcs.join(", ")
pcs.join(", "),
kernel.pc_probe_consumers.len(),
);
}
}