diff --git a/.github/workflows/vm-native.yml b/.github/workflows/vm-native.yml new file mode 100644 index 00000000..5dea081f --- /dev/null +++ b/.github/workflows/vm-native.yml @@ -0,0 +1,63 @@ +name: Native VM analysis validation + +on: + push: + branches: + - feat/themida-vm-structural-20261015 + pull_request: + paths: + - 'crates/angryier-cfg/**' + - 'crates/angryier-expr/**' + - 'crates/angryier-ir/**' + - 'crates/angryier-loader/**' + - 'crates/angryier-models/**' + - 'crates/angryier-cli/**' + - 'crates/angryier-runtime/**' + - '.github/workflows/vm-native.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + vm-native: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: rustup show + - name: Rust cache + uses: Swatinem/rust-cache@v2 + - name: Check VM crates + run: cargo check -p angryier-cfg -p angryier-expr -p angryier-ir -p angryier-loader -p angryier-models -p angryier-runtime -p angryier-cli --all-targets + - name: VM CFG tests + run: cargo test -p angryier-cfg + - name: VM expression tests + run: cargo test -p angryier-expr + - name: Symbolic/Concolic execution regression suite + run: cargo test -p angryier-execution + - name: AngryIR effect and trace regression suite + run: cargo test -p angryier-ir + - name: VM loader tests + run: cargo test -p angryier-loader + - name: Win32 API and synchronization tests + run: cargo test -p angryier-models + - name: Native runtime unit and fidelity tests + run: cargo test -p angryier-runtime --features "xed script" --lib + - name: VM runtime integration tests + run: cargo test -p angryier-runtime --features "xed script" --test lua_api + - name: Windows PE call-return fidelity regressions + run: cargo test -p angryier-runtime --features "xed script" --test pe_driver + - name: VM handler SSA materialization integration + run: cargo test -p angryier-runtime --features "xed script" --test vm_materialization + - name: CLI forensic command tests + run: cargo test -p angryier-cli + # Existing main has unrelated clippy warnings. Retain diagnostic output + # instead of hiding the compile/test result behind that baseline debt. + - name: Clippy analysis (baseline warnings reported) + run: cargo clippy -p angryier-cfg -p angryier-expr -p angryier-ir -p angryier-loader -p angryier-models -p angryier-runtime --lib + - name: Clippy forensic CLI + run: cargo clippy -p angryier-cli --bin angryier diff --git a/crates/angryier-cfg/src/lib.rs b/crates/angryier-cfg/src/lib.rs index 0555e6e3..01c200ee 100644 --- a/crates/angryier-cfg/src/lib.rs +++ b/crates/angryier-cfg/src/lib.rs @@ -18,6 +18,8 @@ use angryier_types::Address; pub mod themida; pub mod vpc; +pub mod vm; +pub mod xrefs; /// How control leaves a basic block. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -867,7 +869,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts { Some(angryier_arch::MemoryBase::InstructionPointer { .. }) => { insn.address .checked_add(u64::from(insn.length)) - .and_then(|end| end.checked_add(mem.displacement as u64)) + .and_then(|end| end.checked_add_signed(mem.displacement)) .map(AbstractVal::Const) } Some(angryier_arch::MemoryBase::Register(b)) => { @@ -876,7 +878,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts { // &table[idx] — an address, not a target: Unknown. match (facts.values.get(&b.parent).copied(), mem.index) { (Some(AbstractVal::Const(c)), None) => { - c.checked_add(mem.displacement as u64).map(AbstractVal::Const) + c.checked_add_signed(mem.displacement).map(AbstractVal::Const) } _ => None, } @@ -900,7 +902,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts { Some(angryier_arch::MemoryIndex::Register(idx)) => { insn.address .checked_add(u64::from(insn.length)) - .and_then(|end| end.checked_add(mem.displacement as u64)) + .and_then(|end| end.checked_add_signed(mem.displacement)) .map(|table| AbstractVal::TableEntry { base: table, index: idx.parent, @@ -914,7 +916,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts { Some(angryier_arch::MemoryBase::Register(b)) => { match (facts.values.get(&b.parent).copied(), mem.index) { (Some(AbstractVal::Const(c)), Some(angryier_arch::MemoryIndex::Register(idx))) => { - c.checked_add(mem.displacement as u64).map(|table| AbstractVal::TableEntry { + c.checked_add_signed(mem.displacement).map(|table| AbstractVal::TableEntry { base: table, index: idx.parent, scale: mem.scale, @@ -1058,11 +1060,11 @@ fn resolve_indirect_jump( }; let table = match mem.base { Some(angryier_arch::MemoryBase::InstructionPointer { .. }) => { - insn_end.checked_add(mem.displacement as u64)? + insn_end.checked_add_signed(mem.displacement)? } Some(angryier_arch::MemoryBase::Register(b)) => { match facts.values.get(&b.parent) { - Some(AbstractVal::Const(c)) => c.checked_add(mem.displacement as u64)?, + Some(AbstractVal::Const(c)) => c.checked_add_signed(mem.displacement)?, _ => return None, } } @@ -2384,6 +2386,29 @@ mod tests { assert_eq!(forest.depth(3), 0); } + #[test] + fn negative_rip_relative_jump_table_displacement_resolves() -> Result<(), String> { + // The two qword targets sit BEFORE the dispatch, so the RIP-relative + // displacement is negative. A u64 cast used to reject this case. + let code = [ + 0x18, 0x10, 0, 0, 0, 0, 0, 0, // 0x1000 -> 0x1018 + 0x1a, 0x10, 0, 0, 0, 0, 0, 0, // 0x1008 -> 0x101a + 0xf0, 0x02, // cmp eax,2 @ 0x1010 + 0xf1, 0x08, 0xe8, 0xff, 0xff, 0xff, // jmp [rip+eax*8-24], end=0x1018 + 0x90, 0xc3, // nop; ret @ 0x1018 + 0x90, 0xc3, // nop; ret @ 0x101a + ]; + let cfg = recover(&TestDecoder, 0x1000, &code, 0x1010, identity_form) + .map_err(|error| format!("{error:?}"))?; + assert!(cfg.edges.iter().any(|edge| { + matches!(edge.kind, EdgeKind::TableDispatch { .. }) && edge.to == Some(0x1018) + })); + assert!(cfg.edges.iter().any(|edge| { + matches!(edge.kind, EdgeKind::TableDispatch { .. }) && edge.to == Some(0x101a) + })); + Ok(()) + } + #[test] fn jump_table_resolves_to_entries() -> Result<(), String> { // cmp eax,2; jmp [rip + eax*8 + disp] where disp points 2 entries diff --git a/crates/angryier-cfg/src/themida.rs b/crates/angryier-cfg/src/themida.rs index 617df0e4..a0d81669 100644 --- a/crates/angryier-cfg/src/themida.rs +++ b/crates/angryier-cfg/src/themida.rs @@ -214,7 +214,26 @@ impl DispatchWindow<'_> { } use angryier_semantics_intel64::forms; match insn.form_id { - forms::MOV_R64_IMM64 | forms::MOV_R32_IMM32 | forms::LEA_R64_MEM => Def::Constant, + forms::MOV_R64_IMM64 | forms::MOV_R32_IMM32 => Def::Constant, + // LEA is a constant only with RIP/absolute addressing. A context- + // relative LEA is NOT a constant, even if its displacement is. + forms::LEA_R64_MEM => { + let mem = insn.operands.iter().find_map(|op| match op.kind { + OperandKind::AddressGeneration(mem) | OperandKind::Memory(mem) => Some(mem), + _ => None, + }); + match mem.map(|m| (m.base, m.index)) { + Some((Some(MemoryBase::InstructionPointer { .. }), None)) + | Some((None, None)) => Def::Constant, + Some((Some(MemoryBase::Register(base)), None)) => Def::Derived(base.parent), + Some((None, Some(MemoryIndex::Register(index)))) => { + Def::Derived(index.parent) + } + // Two register inputs cannot be represented by the single- + // source provenance relation. Retain uncertainty. + _ => Def::ClobberedByUnmodeled, + } + }, forms::MOV_R64_R64 | forms::MOV_R32_R32 | forms::MOVZX_R64_R32 | forms::MOVSX_R64_R32 => { match register_source(insn) { Some(src) => Def::From(src), @@ -430,6 +449,58 @@ mod tests { assert_eq!(site.chain_register, Some(RegisterId(0))); } + #[test] + fn lea_context_relative_is_not_a_constant() -> Result<(), &'static str> { + // lea rbx,[r14+0x28]; jmp [rbx+0x38] + let block = [ + insn( + 0x80, + forms::LEA_R64_MEM, + vec![ + reg_op(0, 2, AccessKind::Write), + mem_op(1, Some(0xe), None, 1, 0x28), + ], + ), + insn(0x83, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]), + ]; + let site = (DispatchWindow { instructions: &block }).classify().ok_or("site unavailable")?; + let (_, provenance) = site.base.ok_or("base unavailable")?; + assert!(matches!( + provenance, + BaseProvenance::DerivedFrom { source } if source == RegisterId(0xe) + )); + Ok(()) + } + + #[test] + fn lea_rip_relative_is_constant_but_two_registers_are_not() -> Result<(), &'static str> { + let rip = [ + insn( + 0x90, + forms::LEA_R64_MEM, + vec![reg_op(0, 2, AccessKind::Write), rip_mem_op(-0x20)], + ), + insn(0x93, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]), + ]; + let site = (DispatchWindow { instructions: &rip }).classify().ok_or("RIP site unavailable")?; + assert!(matches!(site.base.ok_or("RIP base unavailable")?.1, BaseProvenance::Constant)); + + let ambiguous = [ + insn( + 0xa0, + forms::LEA_R64_MEM, + vec![ + reg_op(0, 2, AccessKind::Write), + mem_op(1, Some(0xe), Some(0xd), 4, 0x28), + ], + ), + insn(0xa3, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]), + ]; + let site = (DispatchWindow { instructions: &ambiguous }).classify().ok_or("ambiguous site unavailable")?; + assert!(matches!(site.base.ok_or("ambiguous base unavailable")?.1, BaseProvenance::Clobbered)); + Ok(()) + } + #[test] fn derived_base_keeps_source() { // add rbx, 0x20 ; jmp [rbx+0x38] — context member pointer. diff --git a/crates/angryier-cfg/src/vm.rs b/crates/angryier-cfg/src/vm.rs new file mode 100644 index 00000000..337529ca --- /dev/null +++ b/crates/angryier-cfg/src/vm.rs @@ -0,0 +1,458 @@ +//! Opt-in VM-dispatch reconnaissance and context-sensitive transition graphs. +//! +//! Ordinary CFGs are keyed only by native PC, which aliases distinct virtual +//! instructions executed by the same VM handler. This module intentionally +//! separates three levels of evidence: +//! +//! * `inspect_native_cfg`: structural candidates, NOT confirmed VM instructions; +//! * `VmAnalysisSession`: observed/symbolically derived handler transitions, +//! keyed by native PC, virtual PC, state context and executable image version; +//! * `projected_edges`: an explicitly observational virtual CFG projection. +//! +//! The caller must identify handler boundaries and supply trustworthy VPC and +//! context observations. Nothing here guesses a virtual PC from a jump target, +//! fabricates unobserved edges, executes untrusted code or claims full coverage. +//! The existing native CFG recovery remains unchanged. + +use std::collections::{BTreeMap, BTreeSet}; + +use angryier_arch::RegisterId; +use angryier_types::Address; + +use crate::themida::{BaseProvenance, DispatchKind, DispatchWindow}; +use crate::vpc::{VpcBlock, identify_vpc_candidates}; +use crate::{Cfg, EdgeKind}; + +/// A register-only VPC hypothesis; scores are heuristic, not probabilities. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmVpcCandidate { + pub register: RegisterId, + pub confidence: u8, + pub table_base: Option
, +} + +/// Evidence at one native indirect-dispatch site. +#[derive(Clone, Debug)] +pub struct VmDispatchFinding { + pub block_start: Address, + pub dispatch_pc: Address, + pub kind: DispatchKind, + pub base: Option<(RegisterId, BaseProvenance)>, + /// A candidate context register, not a proven VM_CONTEXT base. + pub context_origin: Option, + pub vpc_candidates: Vec, +} + +/// A bounded static scan result. Indirect exits without recognized operands +/// remain unresolved rather than being silently promoted to VM sites. +#[derive(Clone, Debug, Default)] +pub struct VmDispatchReport { + pub findings: Vec, + pub unresolved_indirect_blocks: usize, + pub unclassified_indirect_blocks: usize, +} + +/// Inspect only unresolved native indirect-jump exits. This does not mutate +/// the CFG or allow heuristic VM findings to become confirmed CFG edges. +pub fn inspect_native_cfg(cfg: &Cfg) -> VmDispatchReport { + let blocks: Vec> = cfg + .blocks + .values() + .filter(|block| block.terminator == EdgeKind::IndirectJump) + .map(|block| VpcBlock { + native_pc: block.start, + instructions: &block.instructions, + }) + .collect(); + + let mut candidate_map: BTreeMap> = BTreeMap::new(); + for candidate in identify_vpc_candidates(&blocks) { + candidate_map + .entry(candidate.native_pc) + .or_default() + .push(VmVpcCandidate { + register: candidate.register, + confidence: candidate.confidence, + table_base: candidate.table_base, + }); + } + + let mut report = VmDispatchReport { + unresolved_indirect_blocks: blocks.len(), + ..VmDispatchReport::default() + }; + for block in blocks { + let Some(site) = (DispatchWindow { + instructions: block.instructions, + }) + .classify() else { + report.unclassified_indirect_blocks += 1; + continue; + }; + + let origin = match site.base { + Some((_, BaseProvenance::ContextRegister { source, .. })) + | Some((_, BaseProvenance::DerivedFrom { source })) => Some(source), + _ => None, + }; + report.findings.push(VmDispatchFinding { + block_start: block.native_pc, + dispatch_pc: site.native_pc, + kind: site.kind, + base: site.base, + context_origin: origin, + vpc_candidates: candidate_map.remove(&block.native_pc).unwrap_or_default(), + }); + } + report +} + +/// Exact replay/capture identity. The caller hashes the relevant VM state +/// and code bytes under a pinned interpretation and records their provenance. +/// The digests are not computed or authenticated by this module. +pub type VmDigest = [u8; 32]; + +/// Context-sensitive handler location, disambiguating two virtual instructions +/// executed at the same native PC, including different decryption/key states. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VmNodeKey { + pub native_pc: Address, + pub virtual_pc: Address, + pub state_digest: VmDigest, + pub code_digest: VmDigest, +} + +/// Where a particular transition came from. Even a symbolic result needs +/// independent validation before being represented as semantic equivalence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum VmTransitionSource { + ObservedTrace, + SymbolicCandidate, + ValidatedReplay, +} + +/// One handler-boundary transition. An observation is not a proof that these +/// are all possible successors; the full state and origin remain significant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmTransition { + pub from: VmNodeKey, + pub to: VmNodeKey, + pub source: VmTransitionSource, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmAnalysisLimits { + pub max_nodes: usize, + pub max_edges: usize, +} + +impl Default for VmAnalysisLimits { + fn default() -> Self { + Self { + max_nodes: 100_000, + max_edges: 200_000, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmAnalysisError { + NodeBudgetExceeded { required: usize, limit: usize }, + EdgeBudgetExceeded { required: usize, limit: usize }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmEdgeEvidence { + pub observations: u64, + pub sources: BTreeSet, +} + +/// An aggregated observed edge in a VPC-aware graph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmProjectedEdge { + pub from: (Address, Address), + pub to: (Address, Address), + pub observations: u64, +} + +/// Accumulates explicit VM transition evidence with fail-closed admission. +/// Budget rejection is atomic: an edge cannot leave orphan nodes behind. +/// The graph is observational even when no budget was exceeded. +pub struct VmAnalysisSession { + limits: VmAnalysisLimits, + nodes: BTreeSet, + edges: BTreeMap<(VmNodeKey, VmNodeKey), VmEdgeEvidence>, + attempted: u64, + rejected: u64, +} + +impl VmAnalysisSession { + pub fn new(limits: VmAnalysisLimits) -> Self { + Self { + limits, + nodes: BTreeSet::new(), + edges: BTreeMap::new(), + attempted: 0, + rejected: 0, + } + } + + /// Record evidence without modifying existing edges on admission failure. + pub fn observe(&mut self, transition: VmTransition) -> Result<(), VmAnalysisError> { + self.attempted = self.attempted.saturating_add(1); + let key = (transition.from, transition.to); + if let Some(edge) = self.edges.get_mut(&key) { + edge.observations = edge.observations.saturating_add(1); + edge.sources.insert(transition.source); + return Ok(()); + } + + let new_nodes = usize::from(!self.nodes.contains(&transition.from)) + + usize::from( + transition.to != transition.from && !self.nodes.contains(&transition.to), + ); + let required_nodes = self.nodes.len().saturating_add(new_nodes); + if required_nodes > self.limits.max_nodes { + self.rejected = self.rejected.saturating_add(1); + return Err(VmAnalysisError::NodeBudgetExceeded { + required: required_nodes, + limit: self.limits.max_nodes, + }); + } + let required_edges = self.edges.len().saturating_add(1); + if required_edges > self.limits.max_edges { + self.rejected = self.rejected.saturating_add(1); + return Err(VmAnalysisError::EdgeBudgetExceeded { + required: required_edges, + limit: self.limits.max_edges, + }); + } + + self.nodes.insert(transition.from); + self.nodes.insert(transition.to); + self.edges.insert( + key, + VmEdgeEvidence { + observations: 1, + sources: BTreeSet::from([transition.source]), + }, + ); + Ok(()) + } + + pub fn nodes(&self) -> &BTreeSet { + &self.nodes + } + + pub fn edges(&self) -> &BTreeMap<(VmNodeKey, VmNodeKey), VmEdgeEvidence> { + &self.edges + } + + pub fn attempted(&self) -> u64 { + self.attempted + } + + pub fn rejected(&self) -> u64 { + self.rejected + } + + /// Context-collapsed view for a downstream decompiler. This is a union of + /// observed possibilities, not a complete CFG or an equivalence proof. + pub fn projected_edges(&self) -> Vec { + let mut edges: BTreeMap< + ((Address, Address), (Address, Address)), + u64, + > = BTreeMap::new(); + for ((from, to), evidence) in &self.edges { + let location = ( + (from.native_pc, from.virtual_pc), + (to.native_pc, to.virtual_pc), + ); + let count = edges.entry(location).or_default(); + *count = count.saturating_add(evidence.observations); + } + edges + .into_iter() + .map(|((from, to), observations)| VmProjectedEdge { + from, + to, + observations, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use angryier_arch::{ + AccessKind, DecodedInstruction, InstructionModifiers, MemoryBase, MemoryOperand, + Operand, OperandKind, OperandVisibility, RegisterView, + }; + use angryier_semantics_intel64::forms; + use crate::{BasicBlock, CfgEdge}; + + fn node(native_pc: u64, virtual_pc: u64, state: u8) -> VmNodeKey { + VmNodeKey { + native_pc, + virtual_pc, + state_digest: [state; 32], + code_digest: [0xb7; 32], + } + } + + #[test] + fn native_pc_does_not_alias_distinct_virtual_pcs_or_contexts() { + let mut session = VmAnalysisSession::new(VmAnalysisLimits::default()); + let x = node(0x1000, 0x2000, 1); + let y = node(0x1000, 0x2001, 1); + let z = node(0x1000, 0x2000, 2); + for (from, to) in [(x, y), (y, z), (z, x)] { + assert_eq!(session.observe(VmTransition { + from, + to, + source: VmTransitionSource::ObservedTrace, + }), Ok(())); + } + assert_eq!(session.nodes().len(), 3); + assert_eq!(session.edges().len(), 3); + // Projection collapses only context, not VPC, and stays observational. + assert_eq!(session.projected_edges().len(), 3); + } + + #[test] + fn duplicate_evidence_is_coalesced_without_losing_sources() { + let mut session = VmAnalysisSession::new(VmAnalysisLimits::default()); + let from = node(0x1000, 0x2000, 1); + let to = node(0x1100, 0x2005, 2); + for source in [ + VmTransitionSource::ObservedTrace, + VmTransitionSource::SymbolicCandidate, + VmTransitionSource::ValidatedReplay, + ] { + assert_eq!(session.observe(VmTransition { from, to, source }), Ok(())); + } + let edge = &session.edges()[&(from, to)]; + assert_eq!(edge.observations, 3); + assert_eq!(edge.sources.len(), 3); + assert_eq!(session.projected_edges()[0].observations, 3); + } + + #[test] + fn budgets_reject_atomically_and_preserve_incompleteness() { + let mut session = VmAnalysisSession::new(VmAnalysisLimits { + max_nodes: 2, + max_edges: 1, + }); + let x = node(0x1000, 0x2000, 1); + let y = node(0x1100, 0x2001, 1); + let z = node(0x1200, 0x2002, 1); + assert_eq!(session.observe(VmTransition { + from: x, + to: y, + source: VmTransitionSource::ObservedTrace, + }), Ok(())); + assert_eq!( + session.observe(VmTransition { + from: y, + to: z, + source: VmTransitionSource::ObservedTrace, + }), + Err(VmAnalysisError::NodeBudgetExceeded { required: 3, limit: 2 }) + ); + assert_eq!( + session.observe(VmTransition { + from: y, + to: x, + source: VmTransitionSource::ObservedTrace, + }), + Err(VmAnalysisError::EdgeBudgetExceeded { required: 2, limit: 1 }) + ); + assert_eq!(session.nodes().len(), 2); + assert_eq!(session.edges().len(), 1); + assert_eq!(session.rejected(), 2); + assert_eq!(session.attempted(), 3); + } + + fn register(id: u32) -> RegisterView { + RegisterView::full(RegisterId(id), 64) + } + + fn register_operand(index: u8, id: u32, access: AccessKind) -> Operand { + Operand { + index, + width_bits: 64, + access, + visibility: OperandVisibility::Explicit, + kind: OperandKind::Register(register(id)), + } + } + + #[test] + fn structural_scan_separates_context_provenance_from_confirmed_vpc() { + // MOV RBX,R14 / JMP [RBX+38h]: context-table shape, not proof + // that R14 holds the VM context, and no VPC is identifiable here. + let memory = Operand { + index: 0, + width_bits: 64, + access: AccessKind::Read, + visibility: OperandVisibility::Explicit, + kind: OperandKind::Memory(MemoryOperand { + memory_index: 0, + address_width_bits: 64, + segment: None, + segment_base: None, + base: Some(MemoryBase::Register(register(3))), + index: None, + scale: 1, + displacement: 0x38, + displacement_width_bits: 32, + }), + }; + let insns = vec![ + DecodedInstruction { + address: 0x1000, + length: 3, + form_id: forms::MOV_R64_R64, + features: Vec::new(), + operands: vec![ + register_operand(0, 3, AccessKind::Write), + register_operand(1, 14, AccessKind::Read), + ], + modifiers: InstructionModifiers::default(), + }, + DecodedInstruction { + address: 0x1003, + length: 3, + form_id: forms::JMP_INDIRECT_MEM64, + features: Vec::new(), + operands: vec![memory], + modifiers: InstructionModifiers::default(), + }, + ]; + let cfg = Cfg { + entry: 0x1000, + blocks: BTreeMap::from([( + 0x1000, + BasicBlock { + start: 0x1000, + end: 0x1006, + instructions: insns, + terminator: EdgeKind::IndirectJump, + }, + )]), + edges: vec![CfgEdge { + from: 0x1003, + to: None, + kind: EdgeKind::IndirectJump, + }], + }; + let report = inspect_native_cfg(&cfg); + assert_eq!(report.unresolved_indirect_blocks, 1); + assert_eq!(report.unclassified_indirect_blocks, 0); + assert_eq!(report.findings.len(), 1); + assert_eq!(report.findings[0].context_origin, Some(RegisterId(14))); + assert_eq!(report.findings[0].kind, DispatchKind::ContextTable { disp: 0x38 }); + assert!(report.findings[0].vpc_candidates.is_empty()); + } +} diff --git a/crates/angryier-cfg/src/vpc.rs b/crates/angryier-cfg/src/vpc.rs index 0e45edb9..dd6d792d 100644 --- a/crates/angryier-cfg/src/vpc.rs +++ b/crates/angryier-cfg/src/vpc.rs @@ -51,10 +51,9 @@ //! `r1` then `r2`). Register rotation is also something ordinary //! register allocation produces, so this is the weakest signal. //! -//! This module is intentionally NOT wired into the crate module tree yet -//! (`lib.rs` is owned elsewhere): it is self-contained and drop-in. The -//! tests below activate once the module is declared; until then cargo does -//! not compile this file at all. +//! This module is compiled and exposed by `angryier-cfg`. Its candidates +//! are consumed by the opt-in `vm::inspect_native_cfg` report; they are not +//! automatically promoted to confirmed virtual-PC identities. use std::collections::BTreeMap; diff --git a/crates/angryier-cfg/src/xrefs.rs b/crates/angryier-cfg/src/xrefs.rs new file mode 100644 index 00000000..244250ca --- /dev/null +++ b/crates/angryier-cfg/src/xrefs.rs @@ -0,0 +1,258 @@ +//! Source-backed direct control-flow and RIP-relative cross-references. +//! +//! This is an index of *known* references, not an indirect-call solver. +//! Every result carries its source instruction. Memory-indirect calls refer +//! to their IAT/vtable SLOT rather than falsely claiming the call target. +//! The index is deterministic and is derived from an existing bounded CFG. + +use std::collections::{BTreeMap, BTreeSet}; + +use angryier_arch::{AccessKind, MemoryBase, OperandKind}; +use angryier_types::Address; + +use crate::{Cfg, EdgeKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum XrefKind { + Call, + Branch, + FallThrough, + TableDispatch, + ImportOrIndirectSlot, + AddressOf, + Read, + Write, + ReadWrite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Xref { + pub source: Address, + pub target: Address, + pub kind: XrefKind, + pub table_index: Option, +} + +/// Both incoming and outgoing indexes reuse the same deduplicated evidence. +#[derive(Clone, Debug, Default)] +pub struct XrefIndex { + by_target: BTreeMap>, + by_source: BTreeMap>, + total: usize, +} + +impl XrefIndex { + pub fn from_cfg(cfg: &Cfg) -> Self { + let mut records = BTreeSet::new(); + + for edge in &cfg.edges { + let Some(target) = edge.to else { + continue; + }; + let (kind, table_index) = match edge.kind { + EdgeKind::Call => (XrefKind::Call, None), + EdgeKind::ConditionalTaken | EdgeKind::Unconditional => (XrefKind::Branch, None), + EdgeKind::FallThrough => (XrefKind::FallThrough, None), + EdgeKind::TableDispatch { index } => (XrefKind::TableDispatch, Some(index)), + EdgeKind::IndirectCall | EdgeKind::IndirectJump | EdgeKind::Return => continue, + }; + records.insert(Xref { + source: edge.from, + target, + kind, + table_index, + }); + } + + // Literal RIP-relative reads, writes and pointer expressions are + // anchored to the bytes that actually reference them. + for block in cfg.blocks.values() { + for insn in &block.instructions { + for operand in &insn.operands { + let (memory, kind) = match operand.kind { + OperandKind::AddressGeneration(mem) => (mem, XrefKind::AddressOf), + OperandKind::Memory(mem) => { + if matches!( + insn.form_id, + angryier_semantics_intel64::forms::CALL_INDIRECT_MEM64 + | angryier_semantics_intel64::forms::JMP_INDIRECT_MEM64 + ) { + (mem, XrefKind::ImportOrIndirectSlot) + } else { + let kind = match operand.access { + AccessKind::Read | AccessKind::ConditionalRead => XrefKind::Read, + AccessKind::Write | AccessKind::ConditionalWrite => XrefKind::Write, + AccessKind::ReadWrite | AccessKind::ReadConditionalWrite + | AccessKind::ConditionalReadWrite => XrefKind::ReadWrite, + }; + (mem, kind) + } + } + _ => continue, + }; + if !matches!(memory.base, Some(MemoryBase::InstructionPointer { .. })) + || memory.index.is_some() + { + continue; + } + let Some(target) = insn + .address + .checked_add(u64::from(insn.length)) + .and_then(|base| base.checked_add_signed(memory.displacement)) + else { + continue; + }; + records.insert(Xref { + source: insn.address, + target, + kind, + table_index: None, + }); + } + } + } + + let mut index = Self::default(); + for record in records { + index.by_source.entry(record.source).or_default().push(record); + index.by_target.entry(record.target).or_default().push(record); + index.total += 1; + } + index + } + + pub fn len(&self) -> usize { + self.total + } + + pub fn is_empty(&self) -> bool { + self.total == 0 + } + + pub fn to(&self, target: Address) -> &[Xref] { + self.by_target.get(&target).map_or(&[], Vec::as_slice) + } + + pub fn from(&self, source: Address) -> &[Xref] { + self.by_source.get(&source).map_or(&[], Vec::as_slice) + } + + /// Only direct, statically identified call sites. Memory-indirect call + /// slots are intentionally excluded: resolving their targets requires an + /// import linker, concrete state or explicit evidence from elsewhere. + pub fn callers_of(&self, callee: Address) -> Vec
{ + self.to(callee) + .iter() + .filter(|record| record.kind == XrefKind::Call) + .map(|record| record.source) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use angryier_arch::{ + DecodedInstruction, InstructionModifiers, MemoryOperand, Operand, + OperandVisibility, RelativeBranchOperand, + }; + use crate::{BasicBlock, CfgEdge}; + + fn rip_reference(addr: u64, disp: i64, form: u32, access: AccessKind) -> DecodedInstruction { + DecodedInstruction { + address: addr, + length: 6, + form_id: form, + features: Vec::new(), + operands: vec![Operand { + index: 0, + width_bits: 64, + access, + visibility: OperandVisibility::Explicit, + kind: OperandKind::Memory(MemoryOperand { + memory_index: 0, + address_width_bits: 64, + segment: None, + segment_base: None, + base: Some(MemoryBase::InstructionPointer { width_bits: 64 }), + index: None, + scale: 1, + displacement: disp, + displacement_width_bits: 32, + }), + }], + modifiers: InstructionModifiers::default(), + } + } + + #[test] + fn exact_direct_caller_and_rip_iat_slot_are_distinct() { + let instructions = vec![ + rip_reference(0x1000, -0x10, angryier_semantics_intel64::forms::CALL_INDIRECT_MEM64, AccessKind::Read), + DecodedInstruction { + address: 0x1006, + length: 5, + form_id: angryier_semantics_intel64::forms::CALL_REL32, + features: Vec::new(), + operands: vec![Operand { + index: 0, + width_bits: 32, + access: AccessKind::Read, + visibility: OperandVisibility::Explicit, + kind: OperandKind::RelativeBranch(RelativeBranchOperand { + displacement: 0x15, + displacement_width_bits: 32, + }), + }], + modifiers: InstructionModifiers::default(), + }, + ]; + let cfg = Cfg { + entry: 0x1000, + blocks: BTreeMap::from([( + 0x1000, + BasicBlock { + start: 0x1000, + end: 0x100b, + instructions, + terminator: EdgeKind::Call, + }, + )]), + edges: vec![ + CfgEdge { from: 0x1006, to: Some(0x1020), kind: EdgeKind::Call }, + CfgEdge { from: 0x1006, to: Some(0x100b), kind: EdgeKind::FallThrough }, + CfgEdge { from: 0x1006, to: Some(0x1020), kind: EdgeKind::Call }, // dedup + ], + }; + let idx = XrefIndex::from_cfg(&cfg); + assert_eq!(idx.callers_of(0x1020), vec![0x1006]); + assert_eq!(idx.to(0x0ff6)[0].kind, XrefKind::ImportOrIndirectSlot); + assert!(idx.callers_of(0x0ff6).is_empty()); + assert_eq!(idx.len(), 3); + } + + #[test] + fn rip_relative_negative_displacement_and_conditional_access() { + let insn = rip_reference( + 0x140005000, + -0x20, + angryier_semantics_intel64::forms::MOV_R64_MEM64, + AccessKind::ConditionalRead, + ); + let cfg = Cfg { + entry: 0x140005000, + blocks: BTreeMap::from([( + 0x140005000, + BasicBlock { + start: 0x140005000, + end: 0x140005006, + instructions: vec![insn], + terminator: EdgeKind::IndirectJump, + }, + )]), + edges: Vec::new(), + }; + let idx = XrefIndex::from_cfg(&cfg); + assert_eq!(idx.to(0x140004fe6)[0].kind, XrefKind::Read); + } +} diff --git a/crates/angryier-cli/Cargo.toml b/crates/angryier-cli/Cargo.toml index 7e7571df..bc6deb53 100644 --- a/crates/angryier-cli/Cargo.toml +++ b/crates/angryier-cli/Cargo.toml @@ -15,14 +15,17 @@ angryier-runtime = { path = "../angryier-runtime", optional = true } angryier-models = { path = "../angryier-models", optional = true } angryier-types = { path = "../angryier-types", optional = true } mlua = { version = "0.12", features = ["lua54", "vendored"], optional = true } +angryier-loader = { path = "../angryier-loader", optional = true } +sha2 = { version = "0.10", optional = true } [features] +inspect = ["dep:angryier-loader", "dep:sha2"] # `run` ships by default: the binary is the product, and a bare # `cargo build --release` must yield a run-capable `angryier` (incl. driver # mode). This pulls mlua (vendored Lua 5.4), xed-sys (XED built from source) # and the system libz3 via `angryier-runtime/script` -> `z3`; build the # minimal rustc-only CLI with `cargo build -p angryier-cli --no-default-features`. -default = ["run"] +default = ["run", "inspect"] run = ["dep:angryier", "dep:angryier-runtime", "dep:angryier-models", "dep:angryier-types", "dep:mlua", "angryier-runtime/xed", "angryier-runtime/script"] [lints] diff --git a/crates/angryier-cli/build.rs b/crates/angryier-cli/build.rs new file mode 100644 index 00000000..45d60a6b --- /dev/null +++ b/crates/angryier-cli/build.rs @@ -0,0 +1,31 @@ +//! Bind the compiled CLI to a precise source revision. No git requirement: +//! exported source archives identify themselves explicitly as unknown. +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let result = Command::new("git") + .arg("-C") + .arg(manifest) + .args(args) + .output() + .ok()?; + if !result.status.success() { + return None; + } + let value = String::from_utf8(result.stdout).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn main() { + println!("cargo:rerun-if-env-changed=ANGRYIER_BUILD_SHA"); + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/packed-refs"); + let source_sha = std::env::var("ANGRYIER_BUILD_SHA") + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| git(&["rev-parse", "HEAD"])) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=ANGRYIER_BUILD_SHA={source_sha}"); +} diff --git a/crates/angryier-cli/src/inspection.rs b/crates/angryier-cli/src/inspection.rs new file mode 100644 index 00000000..4b437cb6 --- /dev/null +++ b/crates/angryier-cli/src/inspection.rs @@ -0,0 +1,143 @@ +//! Read-only PE address and Windows minidump inspection CLI commands. +//! These are deliberately independent from native XED/Z3 symbolic execution, +//! so they can run even on machines without those native libraries. + +use angryier_loader::addresses::{PeAddressMap, PeLocation}; +use angryier_loader::minidump_file::MiniDumpFile; + +fn number(text: &str) -> Result { + let value = text.trim(); + if let Some(hex) = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")) { + u64::from_str_radix(hex, 16).map_err(|_| format!("invalid hexadecimal address: {text}")) + } else { + value.parse::().map_err(|_| format!("invalid unsigned integer: {text}")) + } +} + +fn length(text: Option<&String>, default: usize, max: usize) -> Result { + let val = match text { + Some(text) => usize::try_from(number(text)?).map_err(|_| "read length overflows usize".to_string())?, + None => default, + }; + if val > max { + return Err(format!("requested length {val} exceeds maximum {max}")); + } + Ok(val) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect::>().join(" ") +} + +pub fn pe_address(args: &[String]) -> Result { + if !(2..=3).contains(&args.len()) { + return Err("usage: angryier pe-address [length<=256]".to_string()); + } + let file = std::fs::read(&args[0]).map_err(|error| format!("cannot open PE image: {error}"))?; + let mapping = PeAddressMap::parse(&file).map_err(|error| format!("invalid PE image: {error:?}"))?; + let va = number(&args[1])?; + let len = length(args.get(2), 0, 256)?; + let rva = mapping.va_to_rva(va).map_err(|error| format!("VA not in image: {error:?}"))?; + let location = mapping.locate_va(va).map_err(|error| format!("unmapped VA: {error:?}"))?; + let description = match location { + PeLocation::Header { file_offset } => format!("header file_offset=0x{file_offset:x}"), + PeLocation::Section { index, file_offset } => { + let section = &mapping.sections()[index]; + format!("section={} file_offset=0x{file_offset:x}", section.name) + } + PeLocation::ZeroFill { index } => { + format!("section={} zero-fill (no original file bytes)", mapping.sections()[index].name) + } + }; + let mut output = format!( + "image_base=0x{:x} va=0x{va:x} rva=0x{rva:x} {description}", + mapping.image_base() + ); + if len != 0 { + let bytes = mapping.bytes_at_va(&file, va, len) + .map_err(|error| format!("cannot read {len} bytes at VA: {error:?}"))?; + output.push_str(&format!("\nbytes[{len}]={}", hex(bytes))); + } + Ok(output) +} + +/// Resolve the PE import directory to source file/IAT VA evidence. Import +/// names are *metadata*, not evidence that a kernel/user API was executed. +pub fn pe_imports(args: &[String]) -> Result { + if !(1..=2).contains(&args.len()) { + return Err("usage: angryier pe-imports [substring]".to_string()); + } + use angryier_loader::{ImageLoader, Pe32Loader, PeImportKind}; + let bytes = std::fs::read(&args[0]) + .map_err(|e| format!("cannot open PE: {e}"))?; + let map = PeAddressMap::parse(&bytes) + .map_err(|e| format!("PE address map: {e:?}"))?; + let image = Pe32Loader::new().load(&bytes) + .map_err(|e| format!("PE import parse: {e}"))?; + let imports = image.pe_imports().ok_or("not a PE image")?; + let filter = args.get(1).map(|s| s.to_ascii_lowercase()); + let mut out = format!( + "image_base=0x{:x} imports={}\n", map.image_base(), imports.len() + ); + let mut displayed = 0usize; + for entry in imports { + let symbol = match &entry.kind { + PeImportKind::Name(name) => name.clone(), + PeImportKind::Ordinal(ordinal) => format!("#{ordinal}"), + }; + let qualified = format!("{}!{}", entry.dll, symbol); + if filter.as_ref().is_some_and(|query| !qualified.to_ascii_lowercase().contains(query)) { + continue; + } + let slot = map.rva_to_va(entry.iat_rva) + .map_err(|e| format!("IAT RVA overflow: {e:?}"))?; + let offset = match map.locate_rva(entry.iat_rva) { + Ok(PeLocation::Header { file_offset } | PeLocation::Section { file_offset, .. }) => { + format!("0x{file_offset:x}") + } + Ok(PeLocation::ZeroFill { .. }) => "no-file-byte".to_string(), + Err(_) => "unmapped".to_string(), + }; + out.push_str(&format!("slot=0x{slot:x} rva=0x{:x} file_offset={offset} import={qualified}\n", entry.iat_rva)); + displayed += 1; + if displayed > 100_000 { + return Err("too many imports to display safely".to_string()); + } + } + out.push_str(&format!("shown={displayed}")); + Ok(out) +} + +pub fn minidump_read(args: &[String]) -> Result { + if args.len() != 3 { + return Err("usage: angryier dump-read ".to_string()); + } + let addr = number(&args[1])?; + let len = length(args.get(2), 0, 4096)?; + let mut dump = MiniDumpFile::open(&args[0]) + .map_err(|error| format!("cannot inspect dump: {error:?}"))?; + let bytes = dump.read(addr, len) + .map_err(|error| format!("requested memory is unavailable in dump: {error:?}"))?; + Ok(format!( + "captured va=0x{addr:x} length={} regions={} file_bytes={}\n{}", + bytes.len(), + dump.regions().len(), + dump.file_size(), + hex(&bytes) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_exact_hex_addresses_and_rejects_invalid_input() { + assert_eq!(number("0x140404e10"), Ok(0x140404e10)); + assert_eq!(number("12345"), Ok(12345)); + assert!(number("-1").is_err()); + assert!(number("0xZZ").is_err()); + assert_eq!(length(Some(&"0x100".to_string()), 16, 256), Ok(256)); + assert!(length(Some(&"257".to_string()), 16, 256).is_err()); + } +} diff --git a/crates/angryier-cli/src/main.rs b/crates/angryier-cli/src/main.rs index 50483386..45ee7b47 100644 --- a/crates/angryier-cli/src/main.rs +++ b/crates/angryier-cli/src/main.rs @@ -5,6 +5,11 @@ //! This binary reports workspace/crate status. It parses arguments manually //! from `std::env::args()` and uses no external dependencies. +#[cfg(feature = "inspect")] +mod inspection; +#[cfg(feature = "inspect")] +mod provenance; + /// Single version source: the workspace package version inherited via /// `version.workspace = true` in Cargo.toml. `angry.version()` (the runtime /// crate) derives from the same source, so the two cannot drift. @@ -309,6 +314,11 @@ fn help_output() -> String { run {run_desc}\n \ help Print this help message" ) + + if cfg!(feature = "inspect") { + "\n doctor [--show-paths] Report executable hash/build source identity\n pe-address [length] Strict VA/RVA/file-offset lookup\n pe-imports [substring] List exact IAT slot addresses and import names\n dump-read Read captured minidump bytes" + } else { + "" + } } /// Dispatch a parsed argument list to the appropriate subcommand. @@ -350,6 +360,45 @@ fn run(args: &[String]) -> i32 { 1 } } + Some("doctor") => { + #[cfg(feature = "inspect")] + { + match provenance::doctor(&args[2..]) { + Ok(report) => { println!("{report}"); 0 } + Err(error) => { eprintln!("error: {error}"); 1 } + } + } + #[cfg(not(feature = "inspect"))] + { + eprintln!("error: doctor requires --features inspect"); + 1 + } + } + Some("pe-address") | Some("pe-imports") | Some("dump-read") => { + #[cfg(feature = "inspect")] + { + let outcome = match args.get(1).map(String::as_str) { + Some("pe-address") => inspection::pe_address(&args[2..]), + Some("pe-imports") => inspection::pe_imports(&args[2..]), + _ => inspection::minidump_read(&args[2..]), + }; + match outcome { + Ok(result) => { + println!("{result}"); + 0 + } + Err(error) => { + eprintln!("error: {error}"); + 1 + } + } + } + #[cfg(not(feature = "inspect"))] + { + eprintln!("error: forensic inspection requires --features inspect"); + 1 + } + } Some("help") | Some("--help") | Some("-h") => { println!("{}", help_output()); 0 diff --git a/crates/angryier-cli/src/provenance.rs b/crates/angryier-cli/src/provenance.rs new file mode 100644 index 00000000..eca96fcc --- /dev/null +++ b/crates/angryier-cli/src/provenance.rs @@ -0,0 +1,91 @@ +//! Native run provenance and host-checkout comparison. +//! Paths are masked by default: explicit --show-paths is needed for the +//! directory strings; audit logs should normally use SHA-256 identities. + +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::process::Command; + +fn sha256_file(path: &std::path::Path) -> Result { + let mut reader = std::io::BufReader::new( + std::fs::File::open(path).map_err(|e| format!("binary open failed: {e}"))?, + ); + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buffer).map_err(|e| format!("binary read failed: {e}"))?; + if n == 0 { + break; + } + digest.update(&buffer[..n]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn git_head(directory: &std::path::Path) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(directory) + .args(["rev-parse", "--verify", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8(out.stdout).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +pub fn doctor(args: &[String]) -> Result { + let show_paths = match args { + [] => false, + [flag] if flag == "--show-paths" => true, + _ => return Err("usage: angryier doctor [--show-paths]".to_string()), + }; + let binary = std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|e| format!("cannot resolve running executable: {e}"))?; + let binary_hash = sha256_file(&binary)?; + let cwd = std::env::current_dir().map_err(|e| format!("cannot access current directory: {e}"))?; + let cwd_sha = git_head(&cwd); + let compiled_sha = env!("ANGRYIER_BUILD_SHA"); + let verdict = match cwd_sha.as_deref() { + Some(_) if compiled_sha == "unknown" => "unknown-compiled-revision", + Some(sha) if sha == compiled_sha => "matching-revisions", + Some(_) => "mismatched-checkout-and-binary", + None => "no-git-checkout-in-working-directory", + }; + + let mut out = format!( + "provenance_schema=1\npackage={}\ncompiled_revision={}\nworking_checkout_revision={}\nverdict={}\nbinary_sha256={}\nfeatures=run:{},inspect:{}", + env!("CARGO_PKG_VERSION"), + compiled_sha, + cwd_sha.as_deref().unwrap_or("unknown"), + verdict, + binary_hash, + cfg!(feature = "run"), + cfg!(feature = "inspect"), + ); + if show_paths { + out.push_str(&format!("\nbinary_path={}\nworking_directory={}", binary.display(), cwd.display())); + } else { + out.push_str("\npaths=redacted (use --show-paths for local diagnosis)"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn no_paths_in_default_diagnostics() -> Result<(), String> { + let out = doctor(&[])?; + assert!(out.contains("compiled_revision=")); + assert!(out.contains("binary_sha256=")); + assert!(out.contains("paths=redacted")); + assert!(!out.contains("binary_path=")); + assert!(doctor(&["--invalid".to_string()]).is_err()); + Ok(()) + } +} diff --git a/crates/angryier-execution/src/lib.rs b/crates/angryier-execution/src/lib.rs index 546263c5..59335d37 100644 --- a/crates/angryier-execution/src/lib.rs +++ b/crates/angryier-execution/src/lib.rs @@ -6,7 +6,7 @@ pub mod symbolic; pub use interpreter::{ConcreteExecutionError, ConcreteInterpreter}; pub use symbolic::{ ConcolicBinding, ConcolicEvaluator, ConcolicImage, ConcolicSource, PathConstraint, SYMBOLIC_DEBT_SITE_CAP, - SymbolBinding, SymbolicArena, SymbolicBlockSummary, SymbolicBranch, SymbolicDebtSite, SymbolicEvalError, + SymbolBinding, SymbolicArena, SymbolicBlockSummary, SymbolicBlockValueTrace, SymbolicRegisterWriteTrace, SymbolicBranch, SymbolicDebtSite, SymbolicEvalError, SymbolicEvaluator, SymbolicSessionMemory, SymbolicStateSnapshot, bit_to_bool, constant_value, merge_snapshots, }; diff --git a/crates/angryier-execution/src/symbolic.rs b/crates/angryier-execution/src/symbolic.rs index a988c71c..ae0304da 100644 --- a/crates/angryier-execution/src/symbolic.rs +++ b/crates/angryier-execution/src/symbolic.rs @@ -81,7 +81,7 @@ pub struct SymbolicDebtSite { /// Everything else about vectors is exact: whole-vector moves, loads, stores /// and register traffic lower through the existing Concat/Extract/ZExt /// machinery over a flat `BitVec(width_bits)` (the engine's `ExprOp::Concat` -/// is binary little-endian, operands[0] = low bits, and `Extract` immediates +/// takes (high, low), while AngryIR `IrPrimitive::Concat` takes (low, high); `Extract` immediates /// encode `[start:u16 LE, width:u16 LE]`), and scalar bitvector primitives /// applied to whole-vector values stay exact flat 128-bit arithmetic. fn is_vector_debt_primitive(op: IrPrimitive) -> bool { @@ -316,6 +316,30 @@ pub struct SymbolicBlockSummary { pub jump_target: Option, } +/// Evaluated block values in this block's *local* SSA namespace. +/// Each entry is indexed by IrValueId. Never reuse numeric IDs across +/// different blocks, and never infer semantic equivalence from this table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SymbolicBlockValueTrace { + pub summary: SymbolicBlockSummary, + pub values: Vec>, + /// Every architectural write in original instruction order, with the + /// *post-write* full-parent expression. Final block register state is + /// insufficient when one handler writes the same register repeatedly. + pub register_writes: Vec, +} + +/// Value and width at the point an AngryIR WriteRegister effect executes. +/// The original instruction index prevents confusion with block-local +/// IrValueId numbers and with later writes to the same parent register. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SymbolicRegisterWriteTrace { + pub instruction_index: usize, + pub register: u32, + pub expression: ExprId, + pub ty: IrType, +} + /// Symbolically evaluates AngryIR blocks with a shared symbolic register file. pub struct SymbolicEvaluator<'a> { arena: &'a SymbolicArena, @@ -511,6 +535,28 @@ impl<'a> SymbolicEvaluator<'a> { block: &IrBlock, memory: &mut SymbolicSessionMemory, ) -> Result { + // No per-register trace allocation in the ordinary execution path. + self.eval_block_with_memory_inner(block, memory, false) + .map(|evaluation| evaluation.summary) + } + + /// Evaluate with the same semantics and side effects, additionally + /// retaining the producer expressions and ordered register writes for + /// this block. This is the opt-in VM-handler analysis path. + pub fn eval_block_with_memory_values( + &mut self, + block: &IrBlock, + memory: &mut SymbolicSessionMemory, + ) -> Result { + self.eval_block_with_memory_inner(block, memory, true) + } + + fn eval_block_with_memory_inner( + &mut self, + block: &IrBlock, + memory: &mut SymbolicSessionMemory, + trace_register_writes: bool, + ) -> Result { // Block-local fresh-symbol ids: re-evaluations rebuild identical // expressions (see `block_local_symbols`). Fresh symbols stay // distinct WITHIN one evaluation — the counter still advances — so @@ -521,11 +567,12 @@ impl<'a> SymbolicEvaluator<'a> { } let mut values: Vec> = Vec::new(); let mut written_registers = Vec::new(); + let mut register_writes = Vec::new(); let mut branch = None; let mut terminated = false; let mut jump_target = None; - for instruction in &block.instructions { + for (instruction_index, instruction) in block.instructions.iter().enumerate() { let produced = match &instruction.op { IrOp::Constant { ty, bytes_le } => Some((self.constant(*ty, bytes_le)?, *ty)), IrOp::ExprRef { expression, ty } => Some((*expression, *ty)), @@ -539,10 +586,21 @@ impl<'a> SymbolicEvaluator<'a> { let (expression, ty) = match kind { RegisterWriteKind::ReplaceParent => (expression, ty), RegisterWriteKind::ZeroExtendParent => { - // Zero-fill to the register's current symbolic width. - let target_ty = self.registers.get(register).map(|(_, ty)| *ty).unwrap_or(ty); - let target_width = bit_width(target_ty)?; + // A 32-bit x86 write targets its 64-bit parent, + // even when this is the FIRST access to EAX. + // Previously an unseen register defaulted to the + // source's 32-bit type, creating a 32-bit "RAX" + // binding and breaking native replay fidelity. let source_width = bit_width(ty)?; + let known = self.registers.get(register).map(|(_, prior_ty)| *prior_ty); + let known_width = known.and_then(|prior_ty| bit_width(prior_ty).ok()).unwrap_or(0); + let target_width = arch_parent_bits(*register) + .max(source_width) + .max(known_width); + let target_ty = match known { + Some(prior_ty) if known_width == target_width => prior_ty, + _ => IrType::Bits(target_width), + }; if source_width >= target_width { (expression, target_ty) } else { @@ -623,7 +681,9 @@ impl<'a> SymbolicEvaluator<'a> { Some(prev) => self.intern( ExprSort::BitVec(spliced_width + part_width), ExprOp::Concat, - vec![prev, part], + // ExprOp::Concat is (high, low): + // new upper slice goes on the left. + vec![part, prev], Vec::new(), )?, }); @@ -638,6 +698,14 @@ impl<'a> SymbolicEvaluator<'a> { }; self.registers.insert(*register, (expression, ty)); written_registers.push(*register); + if trace_register_writes { + register_writes.push(SymbolicRegisterWriteTrace { + instruction_index, + register: *register, + expression, + ty, + }); + } None } IrOp::Branch { @@ -755,11 +823,15 @@ impl<'a> SymbolicEvaluator<'a> { } } - Ok(SymbolicBlockSummary { - branch, - written_registers, - terminated, - jump_target, + Ok(SymbolicBlockValueTrace { + summary: SymbolicBlockSummary { + branch, + written_registers, + terminated, + jump_target, + }, + values, + register_writes, }) } @@ -1792,11 +1864,17 @@ fn primitive_expr( "concat {left_width}+{right_width}" ))); } + if operands.len() != 2 { + return Err(SymbolicEvalError::UnsupportedOperation("concat requires two inputs".into())); + } + // AngryIR's concrete interpreter defines Concat(low, high). + // The expression DAG/SMT convention is Concat(high, low); + // translating namespaces without swapping corrupts bytes. let value = intern( arena, ExprSort::BitVec(output_width), ExprOp::Concat, - operands, + vec![operands[1], operands[0]], Vec::new(), )?; Ok((value, ty)) @@ -3166,7 +3244,7 @@ fn eval_primitive_concrete(op: IrPrimitive, output_width: u16, inputs: &[(u128, } } IrPrimitive::Concat => { - // Operand order matches the expression language: (low, high). + // The IR primitive is (low, high); expression DAG is high-first. let low_width = inputs[0].1; (inputs[1].0 << low_width) | (inputs[0].0 & mask_u128(low_width)) } @@ -3669,6 +3747,84 @@ mod tests { ]) } + #[test] + fn ir_concat_low_high_matches_concrete_and_symbolic_expression_order() + -> Result<(), SymbolicEvalError> + { + let arena = arena(); + let candidate = block(vec![ + IrInstruction { + result: Some(IrValueId(0)), + op: IrOp::Constant { + ty: bits(8), + bytes_le: vec![0x34], + }, + }, + IrInstruction { + result: Some(IrValueId(1)), + op: IrOp::Constant { + ty: bits(8), + bytes_le: vec![0x12], + }, + }, + IrInstruction { + result: Some(IrValueId(2)), + op: IrOp::Primitive { + op: IrPrimitive::Concat, + ty: bits(16), + inputs: vec![IrValueId(0), IrValueId(1)], + }, + }, + IrInstruction { + result: None, + op: IrOp::WriteRegister { + register: 1, + value: IrValueId(2), + kind: RegisterWriteKind::ReplaceParent, + }, + }, + ]); + let mut evaluator = SymbolicEvaluator::new(&arena); + evaluator.eval_block(&candidate)?; + let resulting = evaluator + .register_value(1) + .ok_or(SymbolicEvalError::UnsupportedOperation("concat produced no register value".into()))?; + assert_eq!(constant_value(&arena, resulting)?, 0x1234); + assert_eq!( + eval_primitive_concrete( + IrPrimitive::Concat, + 16, + &[(0x34u128, 8u16), (0x12u128, 8u16)], + ), + Some(0x1234), + ); + Ok(()) + } + + #[test] + fn producer_expressions_are_exposed_without_reimplementing_evaluation() + -> Result<(), SymbolicEvalError> + { + let arena = arena(); + let block = compare_block(0, 42); + let mut evaluator = SymbolicEvaluator::new(&arena); + let mut memory = SymbolicSessionMemory::new( + angryier_memory::PersistentMemory::new(Vec::new()) + .map_err(|error| SymbolicEvalError::UnsupportedOperation(format!("{error:?}")))?, + ); + let detailed = evaluator.eval_block_with_memory_values(&block, &mut memory)?; + assert!(detailed.summary.branch.is_some()); + assert!(detailed.values.iter().flatten().count() >= 2); + // Numeric expression IDs are arena-global; the *slot indices* are + // block-local IrValueId numbers, requiring explicit trace-step scope. + for instruction in &block.instructions { + if let Some(result) = instruction.result { + assert!(detailed.values.get(result.0 as usize).is_some_and(Option::is_some)); + } + } + Ok(()) + } + #[test] fn evaluates_compare_and_branch_symbolically() -> Result<(), SymbolicEvalError> { let arena = arena(); @@ -4238,17 +4394,17 @@ mod tests { assert_eq!(arena.sort_of(expr), Some(ExprSort::BitVec(512))); let node = arena.get(expr).ok_or(SymbolicEvalError::UndefinedValue(IrValueId(0)))?; assert_eq!(node.op, ExprOp::Concat); - // Little-endian concat: operands[0] is the written low 64 bits, - // operands[1] the preserved high 448 bits. - let low = arena + // Expression concat is (high, low). The high preserved parent + // bits must be FIRST, and the written low 64 bits SECOND. + let high = arena .get(node.operands[0]) .ok_or(SymbolicEvalError::UndefinedValue(IrValueId(0)))?; - assert_eq!(low.op, ExprOp::Constant); - assert_eq!(low.immediate, 0x42u64.to_le_bytes().to_vec()); - let high = arena + assert_eq!(high.op, ExprOp::Extract); + let low = arena .get(node.operands[1]) .ok_or(SymbolicEvalError::UndefinedValue(IrValueId(0)))?; - assert_eq!(high.op, ExprOp::Extract); + assert_eq!(low.op, ExprOp::Constant); + assert_eq!(low.immediate, 0x42u64.to_le_bytes().to_vec()); // Extract immediate = [start:u16 LE][width:u16 LE]. let start = high .immediate diff --git a/crates/angryier-expr/src/lib.rs b/crates/angryier-expr/src/lib.rs index 9fa1d136..273e0bbe 100644 --- a/crates/angryier-expr/src/lib.rs +++ b/crates/angryier-expr/src/lib.rs @@ -4,6 +4,7 @@ use angryier_types::fx::FxHashMap; use angryier_types::{DependencyKey, ExprId, ExpressionNormalizationVersion}; pub mod deobf; +pub mod vm_effects; use std::sync::{ RwLock, atomic::{AtomicU64, Ordering}, diff --git a/crates/angryier-expr/src/vm_effects.rs b/crates/angryier-expr/src/vm_effects.rs new file mode 100644 index 00000000..bd029660 --- /dev/null +++ b/crates/angryier-expr/src/vm_effects.rs @@ -0,0 +1,262 @@ +//! Output-rooted symbolic reduction for virtual-machine handler effects. +//! +//! Handler execution contains large amounts of interpreter-only arithmetic. +//! Instead of retaining every intermediate expression, a caller supplies all +//! externally observable effects and any guards that determine whether those +//! effects execute. This module slices backwards from those roots and applies +//! the existing, width-aware deobfuscator. +//! +//! This is *not* automatic side-effect discovery or a proof that two handler +//! programs are equivalent. Omitting an observable effect is a caller error; +//! memory writes retain both their symbolic address and value, in order. +//! No SMT assertions are discarded, and failures never silently truncate a +//! dependency DAG. + +use std::collections::BTreeSet; + +use angryier_types::ExprId; + +use crate::deobf::deobfuscate; +use crate::ExprArena; + +/// A VM state update the caller has established as observable. An ordered +/// list of writes must not be deduplicated: aliasing memory stores may matter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmEffectKind { + Register { register: u32 }, + Flags, + VirtualPc, + DispatchTarget, + Memory { address: ExprId, bytes: u32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmEffect { + pub kind: VmEffectKind, + pub value: ExprId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmReducedEffects { + /// The caller's effect order is preserved exactly. + pub effects: Vec, + pub guards: Vec, + /// Reachable unique nodes from all original observable roots. + pub original_nodes: usize, + /// Reachable unique nodes after width-aware algebraic simplification. + pub reduced_nodes: usize, + pub symbolic_sources: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmReductionError { + NoObservableRoots, + UnknownExpression { id: ExprId }, + NodeBudgetExceeded { limit: usize }, +} + +/// Collect the exact backward dependency slice with no recursion. In addition +/// to the value expression, symbolic memory *addresses* are required roots. +/// A missing DAG node or exhausted budget aborts the whole reduction. +fn slice_roots( + arena: &A, + roots: impl IntoIterator, + max_nodes: usize, +) -> Result, VmReductionError> { + let mut pending: Vec = roots.into_iter().collect(); + let mut visited = BTreeSet::new(); + while let Some(id) = pending.pop() { + if visited.contains(&id) { + continue; + } + if visited.len() >= max_nodes { + return Err(VmReductionError::NodeBudgetExceeded { limit: max_nodes }); + } + let node = arena.get(id).ok_or(VmReductionError::UnknownExpression { id })?; + visited.insert(id); + pending.extend(node.operands); + } + Ok(visited) +} + +fn effect_roots(effects: &[VmEffect], guards: &[ExprId]) -> Vec { + let mut roots = Vec::with_capacity(effects.len().saturating_mul(2).saturating_add(guards.len())); + roots.extend_from_slice(guards); + for effect in effects { + roots.push(effect.value); + if let VmEffectKind::Memory { address, .. } = effect.kind { + roots.push(address); + } + } + roots +} + +/// Simplify precisely the caller-declared effects and path guards. The +/// preflight walk enforces the budget *before* expensive simplification. +/// The output still needs independent semantic-equivalence validation. +pub fn reduce_vm_effects( + arena: &A, + effects: &[VmEffect], + guards: &[ExprId], + max_nodes: usize, +) -> Result { + if effects.is_empty() && guards.is_empty() { + return Err(VmReductionError::NoObservableRoots); + } + let before = slice_roots(arena, effect_roots(effects, guards), max_nodes)?; + + let reduced_effects: Vec = effects + .iter() + .map(|effect| VmEffect { + kind: match effect.kind { + VmEffectKind::Memory { address, bytes } => VmEffectKind::Memory { + address: deobfuscate(arena, address), + bytes, + }, + other => other, + }, + value: deobfuscate(arena, effect.value), + }) + .collect(); + let reduced_guards: Vec = guards + .iter() + .map(|&root| deobfuscate(arena, root)) + .collect(); + + let after = slice_roots( + arena, + effect_roots(&reduced_effects, &reduced_guards), + max_nodes, + )?; + let mut sources = BTreeSet::new(); + for root in effect_roots(&reduced_effects, &reduced_guards) { + // All roots were already checked by slice_roots. Missing dependency + // metadata is not evidence that a value is independent of inputs. + let summary = arena + .dependency_summary(root) + .ok_or(VmReductionError::UnknownExpression { id: root })?; + sources.extend(summary.symbolic_sources); + } + + Ok(VmReducedEffects { + effects: reduced_effects, + guards: reduced_guards, + original_nodes: before.len(), + reduced_nodes: after.len(), + symbolic_sources: sources.into_iter().collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use angryier_types::ExpressionNormalizationVersion; + use crate::{ExprNode, ExprOp, ExprSort, ShardedExprArena}; + + fn arena() -> ShardedExprArena { + ShardedExprArena::new(ExpressionNormalizationVersion(1)) + } + + fn symbol(arena: &ShardedExprArena, source: u64) -> Result { + arena.intern(ExprNode { + sort: ExprSort::BitVec(32), + op: ExprOp::Symbol, + operands: Vec::new(), + immediate: source.to_le_bytes().to_vec(), + }).map_err(|error| format!("{error:?}")) + } + + fn binary(arena: &ShardedExprArena, op: ExprOp, x: ExprId, y: ExprId) -> Result { + arena.intern(ExprNode { + sort: ExprSort::BitVec(32), + op, + operands: vec![x, y], + immediate: Vec::new(), + }).map_err(|error| format!("{error:?}")) + } + + #[test] + fn mba_handler_effect_reduces_and_retains_both_dependencies() -> Result<(), String> { + let arena = arena(); + let x = symbol(&arena, 1)?; + let y = symbol(&arena, 2)?; + let lhs = binary(&arena, ExprOp::Or, x, y)?; + let rhs = binary(&arena, ExprOp::And, x, y)?; + let obfuscated = binary(&arena, ExprOp::Add, lhs, rhs)?; + let result = reduce_vm_effects( + &arena, + &[VmEffect { + kind: VmEffectKind::Register { register: 3 }, + value: obfuscated, + }], + &[], + 64, + ).map_err(|error| format!("{error:?}"))?; + assert_eq!(result.effects[0].value, binary(&arena, ExprOp::Add, x, y)?); + assert!(result.reduced_nodes < result.original_nodes); + assert_eq!(result.symbolic_sources, vec![1, 2]); + Ok(()) + } + + #[test] + fn memory_effect_keeps_symbolic_address_value_and_order() -> Result<(), String> { + let arena = arena(); + let addr = symbol(&arena, 3)?; + let value = symbol(&arena, 4)?; + let effects = [ + VmEffect { + kind: VmEffectKind::Memory { address: addr, bytes: 4 }, + value, + }, + VmEffect { + kind: VmEffectKind::Memory { address: addr, bytes: 4 }, + value: addr, + }, + ]; + let result = reduce_vm_effects(&arena, &effects, &[addr], 16) + .map_err(|error| format!("{error:?}"))?; + assert_eq!(result.effects, effects); + assert_eq!(result.guards, vec![addr]); + assert_eq!(result.symbolic_sources, vec![3, 4]); + Ok(()) + } + + #[test] + fn unknown_root_and_small_budget_fail_without_truncation() -> Result<(), String> { + let arena = arena(); + let x = symbol(&arena, 1)?; + let y = symbol(&arena, 2)?; + let sum = binary(&arena, ExprOp::Add, x, y)?; + assert_eq!( + reduce_vm_effects(&arena, &[], &[], 8), + Err(VmReductionError::NoObservableRoots) + ); + assert_eq!( + reduce_vm_effects(&arena, &[], &[ExprId(u32::MAX)], 8), + Err(VmReductionError::UnknownExpression { id: ExprId(u32::MAX) }) + ); + assert_eq!( + reduce_vm_effects(&arena, &[], &[sum], 2), + Err(VmReductionError::NodeBudgetExceeded { limit: 2 }) + ); + Ok(()) + } + + #[test] + fn independent_arena_nodes_do_not_pollute_backward_slice() -> Result<(), String> { + let arena = arena(); + let x = symbol(&arena, 1)?; + let y = symbol(&arena, 2)?; + let _noise = binary(&arena, ExprOp::Add, y, y)?; + let result = reduce_vm_effects( + &arena, + &[VmEffect { kind: VmEffectKind::VirtualPc, value: x }], + &[], + 3, + ).map_err(|error| format!("{error:?}"))?; + assert_eq!(result.original_nodes, 1); + assert_eq!(result.reduced_nodes, 1); + assert_eq!(result.symbolic_sources, vec![1]); + Ok(()) + } +} diff --git a/crates/angryier-ir/src/lib.rs b/crates/angryier-ir/src/lib.rs index cddad8c3..cae32d1f 100644 --- a/crates/angryier-ir/src/lib.rs +++ b/crates/angryier-ir/src/lib.rs @@ -2,13 +2,15 @@ mod lower; mod verify; +pub mod vm_footprint; +pub mod vm_trace; pub use lower::{BasicSemanticLowerer, CachedSemanticLowerer, IrLoweringError}; pub use verify::{BasicIrVerifier, IrVerificationError}; use angryier_types::{Address, BlockId, CodeVersionGuard, ContentId, ExprId, ImageId, TargetProfileId}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct IrValueId(pub u32); /// How a register write relates to the register's parent width. diff --git a/crates/angryier-ir/src/vm_footprint.rs b/crates/angryier-ir/src/vm_footprint.rs new file mode 100644 index 00000000..b76f58de --- /dev/null +++ b/crates/angryier-ir/src/vm_footprint.rs @@ -0,0 +1,505 @@ +//! Conservative, single-block AngryIR effect discovery for VM handlers. +//! +//! This is the bridge between VM-site reconnaissance and output-rooted +//! expression reduction. It enumerates *all* explicit architectural writes, +//! memory reads (including otherwise unused potentially faulting loads), and +//! control transfers before an analyst decides which effects are VM-internal. +//! It never interprets an IR value ID as a symbolic ExprId, never joins paths, +//! and never claims that an instruction sequence is a complete handler. +//! +//! A separate symbolic executor must map each retained SSA value into an +//! expression under a known entry state. Only then can vm_effects simplify it. + +use std::collections::BTreeSet; + +use angryier_types::ExprId; + +use crate::{ + BasicIrVerifier, IrBlock, IrOp, IrPrimitive, IrValueId, IrVerificationError, IrVerifier, RegisterWriteKind, +}; + +/// An effect in original instruction order. Store ordering must be preserved: +/// aliasing writes cannot be sorted or coalesced without a proof. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmIrEffect { + MemoryRead { + address: IrValueId, + mask: Option, + result: IrValueId, + }, + MemoryWrite { + address: IrValueId, + value: IrValueId, + }, + RegisterWrite { + register: u32, + value: IrValueId, + kind: RegisterWriteKind, + }, + Branch { + condition: IrValueId, + taken: u64, + not_taken: u64, + }, + Jump { + target: u64, + }, + IndirectJump { + target: IrValueId, + }, + Call { + target: u64, + }, + Return, + Trap { + vector: u32, + }, +} + +/// Inputs referenced by the retained SSA slice. A memory read is represented +/// by its producer value ID; its address/mask are separately traversed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum VmIrSource { + Register(u32), + Expression(ExprId), + MemoryRead(IrValueId), +} + +/// Explicitly unresolved semantic obligations. They are not evidence of an +/// actual exception: a primitive may be side-effect-free on a given path. +/// Consumers must not use this static footprint as an equivalence certificate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmIrCaveat { + /// General primitives may have exceptional or environment-dependent + /// semantics not represented by a pure expression DAG. + PrimitiveRequiresSemanticReview { result: IrValueId, primitive: IrPrimitive }, + /// A block-level inventory is not a whole-handler path or all its exits. + HandlerBoundaryUnverified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmIrLimits { + pub max_instructions: usize, + pub max_dependency_values: usize, +} + +impl Default for VmIrLimits { + fn default() -> Self { + Self { + max_instructions: 16_384, + max_dependency_values: 16_384, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VmIrError { + InvalidIr(IrVerificationError), + InstructionBudgetExceeded { required: usize, limit: usize }, + DependencyBudgetExceeded { limit: usize }, + MissingValueProducer(IrValueId), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmIrFootprint { + /// Effect order matches the IR source order, including all memory writes. + pub effects: Vec, + /// SSA values reachable from effect values and memory fault dependencies. + /// Sorted in producer order (SSA value ID order). + pub dependencies: Vec, + pub sources: Vec, + /// Explicit semantic review work. Never silently interpreted as proof. + pub caveats: Vec, +} + +/// Discover a full *syntactic* effect footprint of one validated AngryIR +/// block. All loads are included even if their result is otherwise dead, +/// because dropping a load without alias/exception analysis can erase faults. +/// A partial-width register write also implicitly reads the parent register. +/// +/// The result is a starting point for a handler effect collector, not a +/// complete handler semantics summary. To analyze multiple blocks, retain +/// each block's independent SSA namespace and explicit path/VM context. +pub fn collect_vm_ir_effects(block: &IrBlock, limits: VmIrLimits) -> Result { + if block.instructions.len() > limits.max_instructions { + return Err(VmIrError::InstructionBudgetExceeded { + required: block.instructions.len(), + limit: limits.max_instructions, + }); + } + BasicIrVerifier.verify(block).map_err(VmIrError::InvalidIr)?; + + let mut producers: Vec<&IrOp> = Vec::new(); + let mut effects = Vec::new(); + let mut roots = Vec::new(); + let mut sources = BTreeSet::new(); + let mut caveats = Vec::new(); + + for instruction in &block.instructions { + if let Some(result) = instruction.result { + // Checked by BasicIrVerifier: value IDs are sequential. + if usize::try_from(result.0).ok() != Some(producers.len()) { + return Err(VmIrError::MissingValueProducer(result)); + } + producers.push(&instruction.op); + } + match &instruction.op { + IrOp::WriteRegister { register, value, kind } => { + effects.push(VmIrEffect::RegisterWrite { + register: *register, + value: *value, + kind: *kind, + }); + roots.push(*value); + if matches!(kind, RegisterWriteKind::PreserveParent { .. }) { + sources.insert(VmIrSource::Register(*register)); + } + } + IrOp::Store { address, value } => { + effects.push(VmIrEffect::MemoryWrite { + address: *address, + value: *value, + }); + roots.extend([*address, *value]); + } + IrOp::Load { address, .. } => { + let result = instruction.result.ok_or(VmIrError::MissingValueProducer(IrValueId( + u32::try_from(producers.len()).unwrap_or(u32::MAX), + )))?; + effects.push(VmIrEffect::MemoryRead { + address: *address, + mask: None, + result, + }); + roots.push(result); + } + IrOp::PredicatedVectorLoad { address, mask, .. } => { + let result = instruction.result.ok_or(VmIrError::MissingValueProducer(IrValueId( + u32::try_from(producers.len()).unwrap_or(u32::MAX), + )))?; + effects.push(VmIrEffect::MemoryRead { + address: *address, + mask: Some(*mask), + result, + }); + roots.push(result); + } + IrOp::Branch { + condition, + taken, + not_taken, + } => { + effects.push(VmIrEffect::Branch { + condition: *condition, + taken: *taken, + not_taken: *not_taken, + }); + roots.push(*condition); + } + IrOp::Jump { target } => effects.push(VmIrEffect::Jump { target: *target }), + IrOp::JumpIndirect { target } => { + effects.push(VmIrEffect::IndirectJump { target: *target }); + roots.push(*target); + } + IrOp::Call { target } => effects.push(VmIrEffect::Call { target: *target }), + IrOp::Return => effects.push(VmIrEffect::Return), + IrOp::Trap { vector } => effects.push(VmIrEffect::Trap { vector: *vector }), + IrOp::Primitive { op, .. } => { + if let Some(result) = instruction.result { + caveats.push(VmIrCaveat::PrimitiveRequiresSemanticReview { result, primitive: *op }); + } + } + IrOp::Constant { .. } | IrOp::ExprRef { .. } | IrOp::ReadRegister { .. } => {} + } + } + + let mut seen = BTreeSet::new(); + let mut pending = roots; + while let Some(value) = pending.pop() { + if seen.contains(&value) { + continue; + } + if seen.len() >= limits.max_dependency_values { + return Err(VmIrError::DependencyBudgetExceeded { + limit: limits.max_dependency_values, + }); + } + let index = usize::try_from(value.0).map_err(|_| VmIrError::MissingValueProducer(value))?; + let producer = producers.get(index).ok_or(VmIrError::MissingValueProducer(value))?; + seen.insert(value); + match producer { + IrOp::ReadRegister { register, .. } => { + sources.insert(VmIrSource::Register(*register)); + } + IrOp::ExprRef { expression, .. } => { + sources.insert(VmIrSource::Expression(*expression)); + } + IrOp::Load { address, .. } => { + sources.insert(VmIrSource::MemoryRead(value)); + pending.push(*address); + } + IrOp::PredicatedVectorLoad { address, mask, .. } => { + sources.insert(VmIrSource::MemoryRead(value)); + pending.extend([*address, *mask]); + } + IrOp::Primitive { inputs, .. } => pending.extend(inputs.iter().copied()), + IrOp::Constant { .. } => {} + // The IR verifier requires every produced value to come from one + // of the value-generating operations above. + _ => return Err(VmIrError::MissingValueProducer(value)), + } + } + + caveats.push(VmIrCaveat::HandlerBoundaryUnverified); + Ok(VmIrFootprint { + effects, + dependencies: seen.into_iter().collect(), + sources: sources.into_iter().collect(), + caveats, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IrBlockKey, IrInstruction, IrPrimitive, IrType}; + use angryier_types::{BlockId, ContentId, ImageId, TargetProfileId}; + + fn block(instructions: Vec) -> IrBlock { + IrBlock { + key: IrBlockKey { + image: ImageId(1), + block: BlockId(1), + address: 0x1000, + semantic_content: ContentId([2; 32]), + target_profile: TargetProfileId(1), + code_versions: Vec::new(), + }, + instructions, + } + } + + fn value(id: u32, op: IrOp) -> IrInstruction { + IrInstruction { + result: Some(IrValueId(id)), + op, + } + } + + fn effect(op: IrOp) -> IrInstruction { + IrInstruction { result: None, op } + } + + fn limits() -> VmIrLimits { + VmIrLimits::default() + } + + #[test] + fn preserves_unused_faulting_load_and_ordered_aliasing_writes() -> Result<(), String> { + let b = block(vec![ + value( + 0, + IrOp::ReadRegister { + register: 14, + ty: IrType::Bits(64), + }, + ), + value( + 1, + IrOp::Load { + address: IrValueId(0), + ty: IrType::Bits(64), + }, + ), + value( + 2, + IrOp::Constant { + ty: IrType::Bits(64), + bytes_le: 7u64.to_le_bytes().to_vec(), + }, + ), + effect(IrOp::Store { + address: IrValueId(0), + value: IrValueId(2), + }), + effect(IrOp::Store { + address: IrValueId(0), + value: IrValueId(2), + }), + effect(IrOp::Return), + ]); + let p = collect_vm_ir_effects(&b, limits()).map_err(|error| format!("{error:?}"))?; + assert_eq!(p.effects.len(), 4); + assert!(matches!( + p.effects[0], + VmIrEffect::MemoryRead { + result: IrValueId(1), + .. + } + )); + assert_eq!(p.effects[1], p.effects[2]); + assert_eq!( + p.sources, + vec![VmIrSource::Register(14), VmIrSource::MemoryRead(IrValueId(1)),] + ); + assert_eq!(p.dependencies, vec![IrValueId(0), IrValueId(1), IrValueId(2)]); + Ok(()) + } + + #[test] + fn retains_mask_and_indirect_dispatch_dependencies() -> Result<(), String> { + let b = block(vec![ + value( + 0, + IrOp::ReadRegister { + register: 10, + ty: IrType::Bits(64), + }, + ), + value( + 1, + IrOp::ReadRegister { + register: 11, + ty: IrType::Bits(8), + }, + ), + value( + 2, + IrOp::PredicatedVectorLoad { + address: IrValueId(0), + mask: IrValueId(1), + ty: IrType::Vector { + width_bits: 256, + lane_bits: 32, + }, + }, + ), + value( + 3, + IrOp::ExprRef { + expression: ExprId(55), + ty: IrType::Bits(64), + }, + ), + effect(IrOp::JumpIndirect { target: IrValueId(3) }), + ]); + let p = collect_vm_ir_effects(&b, limits()).map_err(|error| format!("{error:?}"))?; + assert_eq!( + p.sources, + vec![ + VmIrSource::Register(10), + VmIrSource::Register(11), + VmIrSource::Expression(ExprId(55)), + VmIrSource::MemoryRead(IrValueId(2)), + ] + ); + assert!(p.dependencies.contains(&IrValueId(1))); + assert!( + p.effects + .iter() + .any(|effect| matches!(effect, VmIrEffect::IndirectJump { target: IrValueId(3) })) + ); + Ok(()) + } + + #[test] + fn partial_register_update_keeps_implicit_parent_and_path_condition() -> Result<(), String> { + let b = block(vec![ + value( + 0, + IrOp::Constant { + ty: IrType::Bits(8), + bytes_le: vec![1], + }, + ), + effect(IrOp::WriteRegister { + register: 4, + value: IrValueId(0), + kind: RegisterWriteKind::PreserveParent { + bit_offset: 8, + width_bits: 8, + }, + }), + effect(IrOp::Branch { + condition: IrValueId(0), + taken: 0x10, + not_taken: 0x20, + }), + ]); + let p = collect_vm_ir_effects(&b, limits()).map_err(|error| format!("{error:?}"))?; + assert_eq!(p.sources, vec![VmIrSource::Register(4)]); + assert!(matches!( + p.effects[1], + VmIrEffect::Branch { + condition: IrValueId(0), + .. + } + )); + Ok(()) + } + + #[test] + fn refuses_unverified_ir_and_budgets_without_partial_results() -> Result<(), String> { + let bad = block(vec![effect(IrOp::Store { + address: IrValueId(44), + value: IrValueId(45), + })]); + assert!(matches!( + collect_vm_ir_effects(&bad, limits()), + Err(VmIrError::InvalidIr(_)) + )); + let b = block(vec![ + value( + 0, + IrOp::ReadRegister { + register: 1, + ty: IrType::Bits(64), + }, + ), + value( + 1, + IrOp::ReadRegister { + register: 2, + ty: IrType::Bits(64), + }, + ), + value( + 2, + IrOp::Primitive { + op: IrPrimitive::Add, + ty: IrType::Bits(64), + inputs: vec![IrValueId(0), IrValueId(1)], + }, + ), + effect(IrOp::WriteRegister { + register: 3, + value: IrValueId(2), + kind: RegisterWriteKind::ReplaceParent, + }), + ]); + assert!(matches!( + collect_vm_ir_effects( + &b, + VmIrLimits { + max_instructions: 2, + max_dependency_values: 8 + } + ), + Err(VmIrError::InstructionBudgetExceeded { .. }) + )); + assert!(matches!( + collect_vm_ir_effects( + &b, + VmIrLimits { + max_instructions: 8, + max_dependency_values: 2 + } + ), + Err(VmIrError::DependencyBudgetExceeded { .. }) + )); + assert!( + matches!(collect_vm_ir_effects(&b, limits()), Ok(VmIrFootprint { caveats, .. }) if caveats.iter().any(|c| matches!(c, VmIrCaveat::PrimitiveRequiresSemanticReview { .. }))) + ); + Ok(()) + } +} diff --git a/crates/angryier-ir/src/vm_trace.rs b/crates/angryier-ir/src/vm_trace.rs new file mode 100644 index 00000000..23d26727 --- /dev/null +++ b/crates/angryier-ir/src/vm_trace.rs @@ -0,0 +1,551 @@ +//! Observed multi-block VM-handler traces with conservative AngryIR footprints. +//! +//! The trace caller supplies execution observations from a *single* capture +//! and a *single* thread, including before/after PC, VPC and VM-context hash. +//! A validated IR block is NOT an executed handler, and the code below never +//! discovers a handler boundary from linear disassembly or proves semantics. +//! +//! Each AngryIR block has an independent SSA value namespace: IrValueId(0) +//! in step 1 is unrelated to IrValueId(0) in step 0. Every exported +//! dependency/source/effect is therefore explicitly scoped to its trace step. +//! +//! Ordered memory operations and possibly faulting loads remain retained. +//! Conditional control choices are checked against observed successors, +//! while indirect transfers and unproven fallthroughs remain visible caveats. +//! This is a trace-derived syntactic footprint, never a substitute for +//! differential replay and independent state/effect verification. + +use std::collections::BTreeMap; + +use angryier_types::{Address, CodePageId, CodePageVersion, ImageId, TargetProfileId}; + +use crate::{ + IrBlock, IrOp, IrPrimitive, IrValueId, + vm_footprint::{ + collect_vm_ir_effects, VmIrCaveat, VmIrEffect, VmIrError, VmIrLimits, VmIrSource, + }, +}; + +/// Exact observed machine location. Context digests bind all relevant VM +/// state as supplied by the observer, not authenticated by this module. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmObservedLocation { + pub native_pc: Address, + pub virtual_pc: Address, + pub context_digest: [u8; 32], +} + +/// Cross-step capture identity. These digests must be computed by the +/// producer from exact byte/state/capture evidence, not from guessed values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmTraceIdentity { + pub image: ImageId, + pub target_profile: TargetProfileId, + pub executable_digest: [u8; 32], + pub capture_digest: [u8; 32], + pub initial_state_digest: [u8; 32], +} + +/// One observed execution of a *specific* AngryIR block. An IR block can +/// appear multiple times with different VPC/context values. The code epoch +/// increments whenever executable page versions may change, e.g. after +/// self-modification. Code-version guards must agree within one epoch. +#[derive(Clone, Copy, Debug)] +pub struct VmObservedBlock<'a> { + pub block: &'a IrBlock, + pub before: VmObservedLocation, + pub after: VmObservedLocation, + pub code_epoch: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmTraceLimits { + pub max_steps: usize, + pub max_instructions: usize, + pub max_effects: usize, + pub max_dependencies: usize, +} + +impl Default for VmTraceLimits { + fn default() -> Self { + Self { + max_steps: 4_096, + max_instructions: 65_536, + max_effects: 65_536, + max_dependencies: 131_072, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VmTraceError { + EmptyTrace, + MissingCaptureIdentity, + StepLimitExceeded { required: usize, limit: usize }, + InstructionLimitExceeded { required: usize, limit: usize }, + EffectLimitExceeded { required: usize, limit: usize }, + DependencyLimitExceeded { required: usize, limit: usize }, + BlockIdentityMismatch { step: usize }, + EntryMismatch, + ExitMismatch, + DisconnectedTransition { step: usize }, + CodeEpochRegressed { step: usize }, + CodeVersionConflict { step: usize, page: CodePageId }, + Ir { step: usize, error: VmIrError }, + DirectControlMismatch { step: usize, observed: Address }, + TrapRequiresExceptionEvidence { step: usize }, +} + +/// An IR value ID scoped to one execution step. Converting this to an +/// expression-arena ExprId by numeric cast is always incorrect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct VmScopedValue { + pub step: usize, + pub value: IrValueId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmScopedEffect { + pub step: usize, + pub effect: VmIrEffect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct VmScopedSource { + pub step: usize, + pub source: VmIrSource, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmTraceCaveat { + /// Primitive operation might fault or depend on environment semantics. + PrimitiveReview { + step: usize, + value: IrValueId, + primitive: IrPrimitive, + }, + /// The observer supplied the next PC but the IR does not prove it. + IndirectTargetObservedOnly { step: usize }, + /// The IR has no explicit terminator; next-PC was only observed. + FallthroughObservedOnly { step: usize }, + /// Execution crossed a code-epoch boundary; replay must validate both. + ExecutableCodeEpochChanged { step: usize }, + /// The path's start/end were supplied, not independently proven. + HandlerBoundaryObserverSupplied, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmObservedEdge { + pub from: VmObservedLocation, + pub to: VmObservedLocation, + pub code_epoch: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmTraceFootprint { + /// Ordered effects, preserving stores and loads even across revisits. + pub effects: Vec, + /// Dependencies sorted within each block, but never conflated across + /// independently numbered SSA namespaces. + pub dependencies: Vec, + pub sources: Vec, + pub edges: Vec, + /// Explicitly observed branch predicates and their selected directions. + pub chosen_branches: Vec<(VmScopedValue, bool)>, + pub caveats: Vec, + pub total_instructions: usize, +} + +/// Build an *observational* footprint for one caller-declared VM handler +/// execution. The entry/exit observations are separately supplied by the +/// observer, and every intermediate after-state must match the next +/// before-state, including VPC and context identity. +/// +/// Direct jumps/calls and conditional branches are checked against their +/// declared IR targets; a trap cannot be treated as an ordinary successor. +/// Unmapped indirect targets and implicit fallthroughs are retained as +/// explicit uncertainty, never promoted to proof. +pub fn collect_observed_vm_trace( + identity: VmTraceIdentity, + entry: VmObservedLocation, + exit: VmObservedLocation, + steps: &[VmObservedBlock<'_>], + limits: VmTraceLimits, +) -> Result { + if steps.is_empty() { + return Err(VmTraceError::EmptyTrace); + } + if [identity.executable_digest, identity.capture_digest, identity.initial_state_digest] + .iter().any(|digest| *digest == [0; 32]) + { + return Err(VmTraceError::MissingCaptureIdentity); + } + if steps.len() > limits.max_steps { + return Err(VmTraceError::StepLimitExceeded { + required: steps.len(), + limit: limits.max_steps, + }); + } + if steps[0].before != entry { + return Err(VmTraceError::EntryMismatch); + } + if steps[steps.len() - 1].after != exit { + return Err(VmTraceError::ExitMismatch); + } + + let mut effects = Vec::new(); + let mut dependencies = Vec::new(); + let mut sources = Vec::new(); + let mut edges = Vec::new(); + let mut chosen_branches = Vec::new(); + let mut caveats = vec![VmTraceCaveat::HandlerBoundaryObserverSupplied]; + let mut total_instructions = 0usize; + let mut code_versions: BTreeMap<(u64, CodePageId), CodePageVersion> = BTreeMap::new(); + + for (index, step) in steps.iter().enumerate() { + if step.before.native_pc != step.block.key.address + || step.block.key.image != identity.image + || step.block.key.target_profile != identity.target_profile + || step.before.context_digest == [0; 32] + || step.after.context_digest == [0; 32] + { + return Err(VmTraceError::BlockIdentityMismatch { step: index }); + } + if index > 0 { + if steps[index - 1].after != step.before { + return Err(VmTraceError::DisconnectedTransition { step: index }); + } + if step.code_epoch < steps[index - 1].code_epoch { + return Err(VmTraceError::CodeEpochRegressed { step: index }); + } + if step.code_epoch != steps[index - 1].code_epoch { + caveats.push(VmTraceCaveat::ExecutableCodeEpochChanged { step: index }); + } + } + for guard in &step.block.key.code_versions { + let key = (step.code_epoch, guard.page); + if let Some(previous) = code_versions.insert(key, guard.version) + && previous != guard.version + { + return Err(VmTraceError::CodeVersionConflict { + step: index, + page: guard.page, + }); + } + } + + total_instructions = total_instructions.saturating_add(step.block.instructions.len()); + if total_instructions > limits.max_instructions { + return Err(VmTraceError::InstructionLimitExceeded { + required: total_instructions, + limit: limits.max_instructions, + }); + } + let block_report = collect_vm_ir_effects( + step.block, + VmIrLimits { + max_instructions: limits.max_instructions, + max_dependency_values: limits.max_dependencies, + }, + ) + .map_err(|error| VmTraceError::Ir { step: index, error })?; + + let new_effect_count = effects.len().saturating_add(block_report.effects.len()); + if new_effect_count > limits.max_effects { + return Err(VmTraceError::EffectLimitExceeded { + required: new_effect_count, + limit: limits.max_effects, + }); + } + let new_dependency_count = dependencies.len().saturating_add(block_report.dependencies.len()); + if new_dependency_count > limits.max_dependencies { + return Err(VmTraceError::DependencyLimitExceeded { + required: new_dependency_count, + limit: limits.max_dependencies, + }); + } + + for effect in block_report.effects { + effects.push(VmScopedEffect { step: index, effect }); + } + for value in block_report.dependencies { + dependencies.push(VmScopedValue { step: index, value }); + } + for source in block_report.sources { + sources.push(VmScopedSource { step: index, source }); + } + for caveat in block_report.caveats { + if let VmIrCaveat::PrimitiveRequiresSemanticReview { result, primitive } = caveat { + caveats.push(VmTraceCaveat::PrimitiveReview { + step: index, + value: result, + primitive, + }); + } + } + + match step.block.instructions.last().map(|instruction| &instruction.op) { + Some(IrOp::Branch { condition, taken, not_taken }) => { + let is_taken = if step.after.native_pc == *taken { + true + } else if step.after.native_pc == *not_taken { + false + } else { + return Err(VmTraceError::DirectControlMismatch { + step: index, + observed: step.after.native_pc, + }); + }; + chosen_branches.push(( + VmScopedValue { step: index, value: *condition }, + is_taken, + )); + } + Some(IrOp::Jump { target } | IrOp::Call { target }) + if *target != step.after.native_pc => + { + return Err(VmTraceError::DirectControlMismatch { + step: index, + observed: step.after.native_pc, + }); + } + Some(IrOp::JumpIndirect { .. } | IrOp::Return) => { + caveats.push(VmTraceCaveat::IndirectTargetObservedOnly { step: index }); + } + Some(IrOp::Trap { .. }) => { + return Err(VmTraceError::TrapRequiresExceptionEvidence { step: index }); + } + Some(IrOp::Jump { .. } | IrOp::Call { .. }) => {} + _ => caveats.push(VmTraceCaveat::FallthroughObservedOnly { step: index }), + } + edges.push(VmObservedEdge { + from: step.before, + to: step.after, + code_epoch: step.code_epoch, + }); + } + + Ok(VmTraceFootprint { + effects, + dependencies, + sources, + edges, + chosen_branches, + caveats, + total_instructions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{IrBlockKey, IrInstruction, IrPrimitive, IrType, RegisterWriteKind}; + use angryier_types::{BlockId, CodeVersionGuard, ContentId}; + + fn key(id: u64, address: u64, version: u64) -> IrBlockKey { + IrBlockKey { + image: ImageId(1), + block: BlockId(id), + address, + semantic_content: ContentId([5; 32]), + target_profile: TargetProfileId(1), + code_versions: vec![CodeVersionGuard { + page: CodePageId(1), + version: CodePageVersion(version), + }], + } + } + + fn value(id: u32, op: IrOp) -> IrInstruction { + IrInstruction { + result: Some(IrValueId(id)), + op, + } + } + + fn effect(op: IrOp) -> IrInstruction { + IrInstruction { result: None, op } + } + + fn location(native: u64, virtual_pc: u64, digest: u8) -> VmObservedLocation { + VmObservedLocation { + native_pc: native, + virtual_pc, + context_digest: [digest; 32], + } + } + + fn identity() -> VmTraceIdentity { + VmTraceIdentity { + image: ImageId(1), + target_profile: TargetProfileId(1), + executable_digest: [1; 32], + capture_digest: [2; 32], + initial_state_digest: [3; 32], + } + } + + fn block_a(version: u64) -> IrBlock { + IrBlock { + key: key(1, 0x1000, version), + instructions: vec![ + value(0, IrOp::ReadRegister { register: 14, ty: IrType::Bits(64) }), + value(1, IrOp::Load { address: IrValueId(0), ty: IrType::Bits(64) }), + effect(IrOp::Store { address: IrValueId(0), value: IrValueId(1) }), + effect(IrOp::Store { address: IrValueId(0), value: IrValueId(1) }), + effect(IrOp::Jump { target: 0x2000 }), + ], + } + } + + fn block_b(version: u64) -> IrBlock { + IrBlock { + key: key(2, 0x2000, version), + instructions: vec![ + value(0, IrOp::ReadRegister { register: 1, ty: IrType::Bits(64) }), + value(1, IrOp::ReadRegister { register: 2, ty: IrType::Bits(64) }), + value(2, IrOp::Primitive { + op: IrPrimitive::Eq, + ty: IrType::Bits(1), + inputs: vec![IrValueId(0), IrValueId(1)], + }), + effect(IrOp::WriteRegister { + register: 9, + value: IrValueId(0), + kind: RegisterWriteKind::ReplaceParent, + }), + effect(IrOp::Branch { + condition: IrValueId(2), taken: 0x3000, not_taken: 0x4000, + }), + ], + } + } + + fn observed<'a>(a: &'a IrBlock, b: &'a IrBlock, second_epoch: u64) -> Vec> { + let first = location(0x1000, 0x50, 11); + let next = location(0x2000, 0x51, 12); + let end = location(0x3000, 0x53, 14); + vec![ + VmObservedBlock { block: a, before: first, after: next, code_epoch: 0 }, + VmObservedBlock { block: b, before: next, after: end, code_epoch: second_epoch }, + ] + } + + #[test] + fn scopes_ssa_per_step_and_keeps_store_order_and_branch_choice() -> Result<(), String> { + let a = block_a(1); + let b = block_b(1); + let steps = observed(&a, &b, 0); + let result = collect_observed_vm_trace( + identity(), steps[0].before, steps[1].after, &steps, VmTraceLimits::default(), + ).map_err(|error| format!("{error:?}"))?; + assert_eq!(result.edges.len(), 2); + assert_eq!(result.total_instructions, 10); + assert!(matches!(result.effects[1], VmScopedEffect { step: 0, effect: VmIrEffect::MemoryWrite { .. } })); + assert_eq!(result.effects[1], result.effects[2]); // repeated store stays ordered + assert!(result.dependencies.contains(&VmScopedValue { step: 0, value: IrValueId(0) })); + assert!(result.dependencies.contains(&VmScopedValue { step: 1, value: IrValueId(0) })); + assert_eq!(result.chosen_branches, vec![ + (VmScopedValue { step: 1, value: IrValueId(2) }, true), + ]); + assert!(result.caveats.contains(&VmTraceCaveat::HandlerBoundaryObserverSupplied)); + Ok(()) + } + + #[test] + fn disallows_identity_gaps_wrong_targets_and_unproven_traps() { + let a = block_a(1); + let b = block_b(1); + let mut steps = observed(&a, &b, 0); + steps[1].before.virtual_pc = 0xdead; + assert_eq!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, VmTraceLimits::default()), + Err(VmTraceError::DisconnectedTransition { step: 1 }), + ); + let mut steps = observed(&a, &b, 0); + steps[0].after.native_pc = 0xdead; + assert!(matches!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, VmTraceLimits::default()), + Err(VmTraceError::DisconnectedTransition { .. } | VmTraceError::DirectControlMismatch { .. }) + )); + let mut bad = block_b(1); + bad.instructions.pop(); + bad.instructions.push(effect(IrOp::Trap { vector: 3 })); + let t = observed(&a, &bad, 0); + assert_eq!( + collect_observed_vm_trace(identity(), t[0].before, t[1].after, &t, VmTraceLimits::default()), + Err(VmTraceError::TrapRequiresExceptionEvidence { step: 1 }), + ); + } + + #[test] + fn catches_code_version_conflicts_unless_epoch_explicitly_changes() { + let a = block_a(1); + let b = block_b(2); + let trace = observed(&a, &b, 0); + assert_eq!( + collect_observed_vm_trace(identity(), trace[0].before, trace[1].after, &trace, VmTraceLimits::default()), + Err(VmTraceError::CodeVersionConflict { step: 1, page: CodePageId(1) }), + ); + let trace = observed(&a, &b, 1); + let ok = collect_observed_vm_trace( + identity(), trace[0].before, trace[1].after, &trace, VmTraceLimits::default(), + ).map_err(|error| format!("{error:?}")); + assert!(matches!(ok, Ok(ref p) if p.caveats.contains(&VmTraceCaveat::ExecutableCodeEpochChanged { step: 1 }))); + } + + #[test] + fn all_budgets_fail_closed_without_truncated_success() { + let a = block_a(1); + let b = block_b(1); + let steps = observed(&a, &b, 0); + let mut limits = VmTraceLimits::default(); + limits.max_steps = 1; + assert!(matches!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, limits), + Err(VmTraceError::StepLimitExceeded { .. }) + )); + limits = VmTraceLimits::default(); + limits.max_instructions = 6; + assert!(matches!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, limits), + Err(VmTraceError::InstructionLimitExceeded { .. }) + )); + limits = VmTraceLimits::default(); + limits.max_effects = 2; + assert!(matches!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, limits), + Err(VmTraceError::EffectLimitExceeded { .. }) + )); + limits = VmTraceLimits::default(); + limits.max_dependencies = 2; + assert!(matches!( + collect_observed_vm_trace(identity(), steps[0].before, steps[1].after, &steps, limits), + Err(VmTraceError::DependencyLimitExceeded { .. } | VmTraceError::Ir { .. }) + )); + } + + #[test] + fn repeat_same_block_is_an_independent_observation() -> Result<(), String> { + let b = IrBlock { + key: key(1, 0x1000, 1), + instructions: vec![ + value(0, IrOp::ReadRegister { register: 1, ty: IrType::Bits(64) }), + effect(IrOp::Jump { target: 0x1000 }), + ], + }; + let s1 = location(0x1000, 0x40, 4); + let s2 = location(0x1000, 0x41, 5); + let s3 = location(0x1000, 0x42, 6); + let steps = [ + VmObservedBlock { block: &b, before: s1, after: s2, code_epoch: 0 }, + VmObservedBlock { block: &b, before: s2, after: s3, code_epoch: 0 }, + ]; + let footprint = collect_observed_vm_trace( + identity(), s1, s3, &steps, VmTraceLimits::default() + ).map_err(|e| format!("{e:?}"))?; + assert_eq!(footprint.edges.len(), 2); + assert_eq!(footprint.total_instructions, 4); + Ok(()) + } +} diff --git a/crates/angryier-loader/src/addresses.rs b/crates/angryier-loader/src/addresses.rs new file mode 100644 index 00000000..b2521388 --- /dev/null +++ b/crates/angryier-loader/src/addresses.rs @@ -0,0 +1,299 @@ +//! Exact PE32+ address translation for file-backed binary analysis. +//! +//! A mapped virtual image is NOT a byte-for-byte PE file. This module keeps +//! VA/RVA/file-offset conversion separate and refuses to decode a BSS tail or +//! an unavailable file range as though it held executable bytes. Its file map +//! describes the original image and must not be used to infer post-unpacking +//! changes in process memory. + +use crate::{LoaderError, read_u16_le, read_u32_le, read_u64_le_at}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeAddressError { + InvalidImage, + Overflow, + AmbiguousMapping, + Unmapped { rva: u32 }, + NotFileBacked { rva: u32 }, + CrossesBoundary, + OutOfBounds, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PeFileSection { + pub name: String, + pub rva: u32, + /// Effective address-space span: max(VirtualSize, SizeOfRawData). + pub virtual_span: u32, + pub raw_offset: u32, + pub raw_size: u32, + pub readable: bool, + pub writable: bool, + pub executable: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeLocation { + Header { file_offset: u64 }, + Section { index: usize, file_offset: u64 }, + ZeroFill { index: usize }, +} + +#[derive(Clone, Debug)] +pub struct PeAddressMap { + image_base: u64, + header_len: u32, + file_size: u64, + sections: Vec, +} + +fn checked_u32(bytes: &[u8], offset: usize) -> Result { + read_u32_le(bytes, offset).map_err(|_| PeAddressError::InvalidImage) +} + +fn checked_u16(bytes: &[u8], offset: usize) -> Result { + read_u16_le(bytes, offset).map_err(|_| PeAddressError::InvalidImage) +} + +fn checked_u64(bytes: &[u8], offset: usize) -> Result { + read_u64_le_at(bytes, offset).map_err(|_| PeAddressError::InvalidImage) +} + +impl PeAddressMap { + /// Parse the file-backed PE32+ x86-64 section map using checked offsets. + /// Overlapping mapped section spans and sections overlapping PE headers + /// are rejected: resolving them by arbitrary first-match order would + /// produce misleading disassembly. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.get(..2) != Some(b"MZ") || bytes.len() < 0x40 { + return Err(PeAddressError::InvalidImage); + } + let pe_offset = usize::try_from(checked_u32(bytes, 0x3c)?) + .map_err(|_| PeAddressError::Overflow)?; + let coff = pe_offset.checked_add(4).ok_or(PeAddressError::Overflow)?; + if bytes.get(pe_offset..coff) != Some(b"PE\0\0") { + return Err(PeAddressError::InvalidImage); + } + if checked_u16(bytes, coff)? != 0x8664 { + return Err(PeAddressError::InvalidImage); + } + let sections_count = usize::from(checked_u16(bytes, coff + 2)?); + // Avoid allocating attacker-controlled section counts and parsing + // implausible images that cannot be trusted for analysis. + if sections_count > 96 { + return Err(PeAddressError::InvalidImage); + } + let optional_size = usize::from(checked_u16(bytes, coff + 16)?); + let optional = coff.checked_add(20).ok_or(PeAddressError::Overflow)?; + let optional_end = optional.checked_add(optional_size).ok_or(PeAddressError::Overflow)?; + if optional_size < 64 || optional_end > bytes.len() + || checked_u16(bytes, optional)? != 0x20b + { + return Err(PeAddressError::InvalidImage); + } + let image_base = checked_u64(bytes, optional + 24)?; + let header_len = checked_u32(bytes, optional + 60)?; + if header_len == 0 || u64::from(header_len) > bytes.len() as u64 + || optional_end > usize::try_from(header_len).unwrap_or(0) + { + return Err(PeAddressError::InvalidImage); + } + let table_bytes = sections_count.checked_mul(40).ok_or(PeAddressError::Overflow)?; + let table_end = optional_end.checked_add(table_bytes).ok_or(PeAddressError::Overflow)?; + if table_end > bytes.len() || table_end > usize::try_from(header_len).unwrap_or(0) { + return Err(PeAddressError::InvalidImage); + } + let mut sections = Vec::with_capacity(sections_count); + for i in 0..sections_count { + let off = optional_end + i * 40; + let name_bytes = bytes.get(off..off + 8).ok_or(PeAddressError::InvalidImage)?; + let name_end = name_bytes.iter().position(|b| *b == 0).unwrap_or(8); + let name = String::from_utf8_lossy(&name_bytes[..name_end]).to_string(); + let virtual_size = checked_u32(bytes, off + 8)?; + let rva = checked_u32(bytes, off + 12)?; + let raw_size = checked_u32(bytes, off + 16)?; + let raw_offset = checked_u32(bytes, off + 20)?; + let characteristics = checked_u32(bytes, off + 36)?; + let virtual_span = virtual_size.max(raw_size); + let virtual_end = rva.checked_add(virtual_span).ok_or(PeAddressError::Overflow)?; + let raw_end = u64::from(raw_offset) + .checked_add(u64::from(raw_size)).ok_or(PeAddressError::Overflow)?; + if raw_end > bytes.len() as u64 { + return Err(PeAddressError::OutOfBounds); + } + if virtual_span != 0 && rva < header_len { + return Err(PeAddressError::AmbiguousMapping); + } + if virtual_end < rva { + return Err(PeAddressError::Overflow); + } + sections.push(PeFileSection { + name, + rva, + virtual_span, + raw_offset, + raw_size, + readable: characteristics & 0x4000_0000 != 0, + writable: characteristics & 0x8000_0000 != 0, + executable: characteristics & 0x2000_0000 != 0, + }); + } + sections.sort_by_key(|s| s.rva); + let mut last_end = header_len; + for section in §ions { + if section.virtual_span > 0 && section.rva < last_end { + return Err(PeAddressError::AmbiguousMapping); + } + last_end = last_end.max(section.rva.saturating_add(section.virtual_span)); + } + Ok(Self { + image_base, + header_len, + file_size: bytes.len() as u64, + sections, + }) + } + + pub fn image_base(&self) -> u64 { + self.image_base + } + + pub fn sections(&self) -> &[PeFileSection] { + &self.sections + } + + pub fn va_to_rva(&self, va: u64) -> Result { + let delta = va.checked_sub(self.image_base).ok_or(PeAddressError::OutOfBounds)?; + u32::try_from(delta).map_err(|_| PeAddressError::OutOfBounds) + } + + pub fn rva_to_va(&self, rva: u32) -> Result { + self.image_base.checked_add(u64::from(rva)).ok_or(PeAddressError::Overflow) + } + + pub fn locate_rva(&self, rva: u32) -> Result { + if rva < self.header_len { + return Ok(PeLocation::Header { + file_offset: u64::from(rva), + }); + } + let section = self.sections.iter().enumerate().find(|(_, section)| { + rva >= section.rva && u64::from(rva - section.rva) < u64::from(section.virtual_span) + }); + let (index, section) = section.ok_or(PeAddressError::Unmapped { rva })?; + let offset = rva - section.rva; + if offset < section.raw_size { + let file_offset = u64::from(section.raw_offset) + u64::from(offset); + if file_offset < self.file_size { + return Ok(PeLocation::Section { index, file_offset }); + } + return Err(PeAddressError::OutOfBounds); + } + Ok(PeLocation::ZeroFill { index }) + } + + pub fn locate_va(&self, va: u64) -> Result { + self.locate_rva(self.va_to_rva(va)?) + } + + /// Return a file offset ONLY when all requested bytes are contiguous, + /// file-backed and belong to the same section/header. A read into BSS or + /// across a section boundary fails rather than emitting invented bytes. + pub fn file_offset(&self, va: u64, length: usize) -> Result { + let rva = self.va_to_rva(va)?; + let len = u64::try_from(length).map_err(|_| PeAddressError::Overflow)?; + let rva_end = u64::from(rva).checked_add(len).ok_or(PeAddressError::Overflow)?; + match self.locate_rva(rva)? { + PeLocation::Header { file_offset } => { + if rva_end > u64::from(self.header_len) { + return Err(PeAddressError::CrossesBoundary); + } + Ok(file_offset) + } + PeLocation::Section { index, file_offset } => { + let section = &self.sections[index]; + if rva_end > u64::from(section.rva) + u64::from(section.raw_size) { + return Err(PeAddressError::CrossesBoundary); + } + Ok(file_offset) + } + PeLocation::ZeroFill { .. } => Err(PeAddressError::NotFileBacked { rva }), + } + } + + pub fn bytes_at_va<'a>( + &self, + bytes: &'a [u8], + va: u64, + length: usize, + ) -> Result<&'a [u8], PeAddressError> { + if bytes.len() as u64 != self.file_size { + return Err(PeAddressError::InvalidImage); + } + let offset = usize::try_from(self.file_offset(va, length)?) + .map_err(|_| PeAddressError::Overflow)?; + let end = offset.checked_add(length).ok_or(PeAddressError::Overflow)?; + bytes.get(offset..end).ok_or(PeAddressError::OutOfBounds) + } +} + +impl From for PeAddressError { + fn from(_: LoaderError) -> Self { + Self::InvalidImage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> Vec { + let mut image = vec![0u8; 0x800]; + image[0..2].copy_from_slice(b"MZ"); + image[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes()); + image[0x80..0x84].copy_from_slice(b"PE\0\0"); + image[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes()); + image[0x86..0x88].copy_from_slice(&1u16.to_le_bytes()); + image[0x94..0x96].copy_from_slice(&0xf0u16.to_le_bytes()); + let opt = 0x98; + image[opt..opt + 2].copy_from_slice(&0x20bu16.to_le_bytes()); + image[opt + 24..opt + 32].copy_from_slice(&0x140000000u64.to_le_bytes()); + image[opt + 60..opt + 64].copy_from_slice(&0x200u32.to_le_bytes()); + let sec = 0x188; + image[sec..sec + 5].copy_from_slice(b".text"); + image[sec + 8..sec + 12].copy_from_slice(&0x600u32.to_le_bytes()); + image[sec + 12..sec + 16].copy_from_slice(&0x1000u32.to_le_bytes()); + image[sec + 16..sec + 20].copy_from_slice(&0x200u32.to_le_bytes()); + image[sec + 20..sec + 24].copy_from_slice(&0x200u32.to_le_bytes()); + image[sec + 36..sec + 40].copy_from_slice(&0x6000_0000u32.to_le_bytes()); + image[0x210..0x215].copy_from_slice(&[0x48, 0x89, 0xe0, 0x55, 0x57]); + image + } + + #[test] + fn pe_va_rva_and_disk_bytes_remain_distinct() -> Result<(), PeAddressError> { + let bytes = fixture(); + let map = PeAddressMap::parse(&bytes)?; + let va = 0x140001010; + assert_eq!(map.va_to_rva(va), Ok(0x1010)); + assert_eq!(map.file_offset(va, 5), Ok(0x210)); + assert_eq!(map.bytes_at_va(&bytes, va, 5), Ok(&bytes[0x210..0x215])); + assert_eq!(map.rva_to_va(0x1010), Ok(va)); + assert_eq!(map.locate_rva(0x1400), Ok(PeLocation::ZeroFill { index: 0 })); + assert_eq!(map.file_offset(0x140001400, 1), Err(PeAddressError::NotFileBacked { rva: 0x1400 })); + assert_eq!(map.file_offset(0x1400011ff, 2), Err(PeAddressError::CrossesBoundary)); + assert_eq!(map.locate_rva(0x3000), Err(PeAddressError::Unmapped { rva: 0x3000 })); + Ok(()) + } + + #[test] + fn rejects_ambiguous_and_out_of_range_sections() { + let mut bytes = fixture(); + bytes[0x188 + 12..0x188 + 16].copy_from_slice(&0x100u32.to_le_bytes()); + assert!(matches!(PeAddressMap::parse(&bytes), Err(PeAddressError::AmbiguousMapping))); + let mut bytes = fixture(); + bytes[0x188 + 20..0x188 + 24].copy_from_slice(&0x700u32.to_le_bytes()); + assert!(matches!(PeAddressMap::parse(&bytes), Err(PeAddressError::OutOfBounds))); + assert!(matches!(PeAddressMap::parse(&bytes[..0x90]), Err(PeAddressError::InvalidImage))); + } +} diff --git a/crates/angryier-loader/src/lib.rs b/crates/angryier-loader/src/lib.rs index 3f67e413..2df257f6 100644 --- a/crates/angryier-loader/src/lib.rs +++ b/crates/angryier-loader/src/lib.rs @@ -1,5 +1,9 @@ #![forbid(unsafe_code)] +pub mod addresses; +pub mod minidump; +pub mod minidump_file; + use std::fmt; use std::sync::Mutex; diff --git a/crates/angryier-loader/src/minidump.rs b/crates/angryier-loader/src/minidump.rs new file mode 100644 index 00000000..2e86985d --- /dev/null +++ b/crates/angryier-loader/src/minidump.rs @@ -0,0 +1,298 @@ +//! Read-only Windows minidump memory backed by actual captured bytes. +//! +//! Supports MiniDumpMemoryListStream (5) and MiniDumpMemory64ListStream (9). +//! Uncaptured pages are NEVER synthesized as zero: callers receive a precise +//! missing-address error instead. No process is executed or modified here, +//! and thread contexts, heap objects, handles and synchronization state are +//! not inferred from opaque memory. This is a read-only forensic import seam. + +use std::collections::BTreeMap; + +const HEADER_SIZE: usize = 32; +const DIRECTORY_SIZE: usize = 12; +const MAX_STREAMS: usize = 1024; +const MAX_RANGES: usize = 200_000; +const MAX_READ: usize = 1024 * 1024; +const MEMORY_LIST: u32 = 5; +const MEMORY64_LIST: u32 = 9; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MiniDumpError { + InvalidSignature, + Truncated, + Overflow, + UnsupportedMemoryStream, + TooManyStreams, + TooManyRanges, + InvalidRange, + OverlappingRanges, + MissingCapture { address: u64 }, + ReadLimitExceeded, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MiniDumpSource { + MemoryList, + Memory64List, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MiniDumpRegion { + pub base: u64, + pub len: u64, + pub file_offset: u64, + pub source: MiniDumpSource, +} + +fn u32_at(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(4).ok_or(MiniDumpError::Overflow)?; + let data: [u8; 4] = bytes.get(offset..end).ok_or(MiniDumpError::Truncated)? + .try_into().map_err(|_| MiniDumpError::Truncated)?; + Ok(u32::from_le_bytes(data)) +} + +fn u64_at(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(8).ok_or(MiniDumpError::Overflow)?; + let data: [u8; 8] = bytes.get(offset..end).ok_or(MiniDumpError::Truncated)? + .try_into().map_err(|_| MiniDumpError::Truncated)?; + Ok(u64::from_le_bytes(data)) +} + +fn usize_checked(value: u64) -> Result { + usize::try_from(value).map_err(|_| MiniDumpError::Overflow) +} + +#[derive(Clone, Debug)] +pub struct MiniDumpMemory<'a> { + bytes: &'a [u8], + regions: Vec, + // Indexed by captured virtual-memory start address. + starts: BTreeMap, +} + +impl<'a> MiniDumpMemory<'a> { + pub fn parse(bytes: &'a [u8]) -> Result { + if bytes.get(..4) != Some(b"MDMP") { + return Err(MiniDumpError::InvalidSignature); + } + if bytes.len() < HEADER_SIZE { + return Err(MiniDumpError::Truncated); + } + let count = usize_checked(u64::from(u32_at(bytes, 8)?))?; + if count > MAX_STREAMS { + return Err(MiniDumpError::TooManyStreams); + } + let directory_offset = usize_checked(u64::from(u32_at(bytes, 12)?))?; + let directory_len = count.checked_mul(DIRECTORY_SIZE).ok_or(MiniDumpError::Overflow)?; + let directory_end = directory_offset.checked_add(directory_len).ok_or(MiniDumpError::Overflow)?; + if directory_end > bytes.len() { + return Err(MiniDumpError::Truncated); + } + + let mut regions = Vec::new(); + let mut found = false; + for i in 0..count { + let dir = directory_offset + i * DIRECTORY_SIZE; + let stream_type = u32_at(bytes, dir)?; + if stream_type != MEMORY_LIST && stream_type != MEMORY64_LIST { + continue; + } + found = true; + let size = usize_checked(u64::from(u32_at(bytes, dir + 4)?))?; + let offset = usize_checked(u64::from(u32_at(bytes, dir + 8)?))?; + let stream_end = offset.checked_add(size).ok_or(MiniDumpError::Overflow)?; + if stream_end > bytes.len() { + return Err(MiniDumpError::Truncated); + } + if stream_type == MEMORY_LIST { + if size < 4 { + return Err(MiniDumpError::Truncated); + } + let n = usize_checked(u64::from(u32_at(bytes, offset)?))?; + if n > MAX_RANGES || n > MAX_RANGES.saturating_sub(regions.len()) { + return Err(MiniDumpError::TooManyRanges); + } + let needed = n.checked_mul(16).and_then(|len| len.checked_add(4)) + .ok_or(MiniDumpError::Overflow)?; + if needed > size { + return Err(MiniDumpError::Truncated); + } + for j in 0..n { + let item = offset + 4 + j * 16; + let base = u64_at(bytes, item)?; + let length = u64::from(u32_at(bytes, item + 8)?); + let file_offset = u64::from(u32_at(bytes, item + 12)?); + regions.push(MiniDumpRegion { + base, + len: length, + file_offset, + source: MiniDumpSource::MemoryList, + }); + } + } else { + if size < 16 { + return Err(MiniDumpError::Truncated); + } + let n = usize_checked(u64_at(bytes, offset)?)?; + if n > MAX_RANGES || n > MAX_RANGES.saturating_sub(regions.len()) { + return Err(MiniDumpError::TooManyRanges); + } + let needed = n.checked_mul(16).and_then(|len| len.checked_add(16)) + .ok_or(MiniDumpError::Overflow)?; + if needed > size { + return Err(MiniDumpError::Truncated); + } + let mut file_offset = u64_at(bytes, offset + 8)?; + for j in 0..n { + let item = offset + 16 + j * 16; + let base = u64_at(bytes, item)?; + let length = u64_at(bytes, item + 8)?; + regions.push(MiniDumpRegion { + base, + len: length, + file_offset, + source: MiniDumpSource::Memory64List, + }); + file_offset = file_offset.checked_add(length).ok_or(MiniDumpError::Overflow)?; + } + } + } + if !found { + return Err(MiniDumpError::UnsupportedMemoryStream); + } + regions.sort_by_key(|region| region.base); + let mut starts = BTreeMap::new(); + let mut previous_end: Option = None; + for (index, region) in regions.iter().enumerate() { + let end = region.base.checked_add(region.len).ok_or(MiniDumpError::Overflow)?; + let last_file_byte = region.file_offset.checked_add(region.len).ok_or(MiniDumpError::Overflow)?; + if region.len == 0 || last_file_byte > bytes.len() as u64 { + return Err(MiniDumpError::InvalidRange); + } + if previous_end.is_some_and(|previous| region.base < previous) { + return Err(MiniDumpError::OverlappingRanges); + } + previous_end = Some(end); + starts.insert(region.base, index); + } + Ok(Self { bytes, regions, starts }) + } + + pub fn regions(&self) -> &[MiniDumpRegion] { + &self.regions + } + + pub fn region_at(&self, address: u64) -> Option<&MiniDumpRegion> { + let (_, &index) = self.starts.range(..=address).next_back()?; + let region = &self.regions[index]; + (address - region.base < region.len).then_some(region) + } + + /// Reads captured data without inventing zero-filled gaps. Crossing + /// adjacent captured VA ranges is supported even if their underlying + /// file offsets are non-contiguous. The one-call ceiling bounds allocation. + pub fn read(&self, address: u64, length: usize) -> Result, MiniDumpError> { + if length > MAX_READ { + return Err(MiniDumpError::ReadLimitExceeded); + } + let end = address.checked_add(length as u64).ok_or(MiniDumpError::Overflow)?; + let mut out = Vec::with_capacity(length); + let mut current = address; + while current < end { + let region = self.region_at(current) + .ok_or(MiniDumpError::MissingCapture { address: current })?; + let offset_in_region = current - region.base; + let remaining_region = region.len - offset_in_region; + let available = (end - current).min(remaining_region); + let file_offset = region.file_offset.checked_add(offset_in_region) + .ok_or(MiniDumpError::Overflow)?; + let start = usize_checked(file_offset)?; + let chunk_len = usize_checked(available)?; + let file_end = start.checked_add(chunk_len).ok_or(MiniDumpError::Overflow)?; + out.extend_from_slice( + self.bytes.get(start..file_end).ok_or(MiniDumpError::InvalidRange)? + ); + current = current.checked_add(available).ok_or(MiniDumpError::Overflow)?; + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn memory_list_fixture() -> Vec { + let mut bytes = vec![0u8; 0x200]; + bytes[0..4].copy_from_slice(b"MDMP"); + bytes[8..12].copy_from_slice(&1u32.to_le_bytes()); + bytes[12..16].copy_from_slice(&0x40u32.to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(&MEMORY_LIST.to_le_bytes()); + bytes[0x44..0x48].copy_from_slice(&20u32.to_le_bytes()); + bytes[0x48..0x4c].copy_from_slice(&0x60u32.to_le_bytes()); + bytes[0x60..0x64].copy_from_slice(&1u32.to_le_bytes()); + bytes[0x64..0x6c].copy_from_slice(&0x094464c0u64.to_le_bytes()); + bytes[0x6c..0x70].copy_from_slice(&64u32.to_le_bytes()); + bytes[0x70..0x74].copy_from_slice(&0x100u32.to_le_bytes()); + for i in 0..64 { + bytes[0x100 + i] = i as u8; + } + bytes + } + + #[test] + fn extracts_exact_captured_singleton_bytes() -> Result<(), MiniDumpError> { + let bytes = memory_list_fixture(); + let dump = MiniDumpMemory::parse(&bytes)?; + assert_eq!(dump.regions().len(), 1); + let read = dump.read(0x094464c0, 0x40)?; + assert_eq!(read.len(), 64); + assert_eq!(read[0], 0); + assert_eq!(read[63], 63); + Ok(()) + } + + #[test] + fn missing_page_is_not_zero_filled() -> Result<(), MiniDumpError> { + let bytes = memory_list_fixture(); + let dump = MiniDumpMemory::parse(&bytes)?; + assert_eq!( + dump.read(0x094464fc, 16), + Err(MiniDumpError::MissingCapture { address: 0x09446500 }) + ); + assert_eq!( + dump.read(0x094464c0, MAX_READ + 1), + Err(MiniDumpError::ReadLimitExceeded) + ); + Ok(()) + } + + #[test] + fn memory64_stream_uses_cumulative_file_offsets() -> Result<(), MiniDumpError> { + let mut bytes = vec![0u8; 0x200]; + bytes[0..4].copy_from_slice(b"MDMP"); + bytes[8..12].copy_from_slice(&1u32.to_le_bytes()); + bytes[12..16].copy_from_slice(&0x40u32.to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(&MEMORY64_LIST.to_le_bytes()); + bytes[0x44..0x48].copy_from_slice(&48u32.to_le_bytes()); + bytes[0x48..0x4c].copy_from_slice(&0x60u32.to_le_bytes()); + bytes[0x60..0x68].copy_from_slice(&2u64.to_le_bytes()); + bytes[0x68..0x70].copy_from_slice(&0x100u64.to_le_bytes()); + bytes[0x70..0x78].copy_from_slice(&0x1000u64.to_le_bytes()); + bytes[0x78..0x80].copy_from_slice(&2u64.to_le_bytes()); + bytes[0x80..0x88].copy_from_slice(&0x1002u64.to_le_bytes()); + bytes[0x88..0x90].copy_from_slice(&3u64.to_le_bytes()); + bytes[0x100..0x105].copy_from_slice(&[1, 2, 3, 4, 5]); + let dump = MiniDumpMemory::parse(&bytes)?; + assert_eq!(dump.read(0x1000, 5), Ok(vec![1, 2, 3, 4, 5])); + Ok(()) + } + + #[test] + fn malformed_and_overlap_fail_closed() { + assert!(matches!(MiniDumpMemory::parse(b"bad"), Err(MiniDumpError::InvalidSignature))); + let mut bytes = memory_list_fixture(); + bytes[0x70..0x74].copy_from_slice(&0x1f0u32.to_le_bytes()); + assert!(matches!(MiniDumpMemory::parse(&bytes), Err(MiniDumpError::InvalidRange))); + } +} diff --git a/crates/angryier-loader/src/minidump_file.rs b/crates/angryier-loader/src/minidump_file.rs new file mode 100644 index 00000000..45ce7a35 --- /dev/null +++ b/crates/angryier-loader/src/minidump_file.rs @@ -0,0 +1,255 @@ +//! Streaming, read-only minidump memory reader for large forensic dumps. +//! Only stream tables and the requested captured bytes are read from disk. +//! This avoids loading multi-gigabyte dumps just to inspect an object. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::minidump::{MiniDumpError, MiniDumpRegion, MiniDumpSource}; + +const MAX_STREAMS: usize = 1024; +const MAX_RANGES: usize = 200_000; +const MAX_READ: usize = 1024 * 1024; +const MEMORY_LIST: u32 = 5; +const MEMORY64_LIST: u32 = 9; + +#[derive(Debug)] +pub enum MiniDumpFileError { + Io(std::io::Error), + Format(MiniDumpError), +} + +impl From for MiniDumpFileError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} +impl From for MiniDumpFileError { + fn from(value: MiniDumpError) -> Self { + Self::Format(value) + } +} + +fn as_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(4).ok_or(MiniDumpError::Overflow)?; + let value: [u8; 4] = bytes + .get(offset..end) + .ok_or(MiniDumpError::Truncated)? + .try_into() + .map_err(|_| MiniDumpError::Truncated)?; + Ok(u32::from_le_bytes(value)) +} + +fn as_u64(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(8).ok_or(MiniDumpError::Overflow)?; + let value: [u8; 8] = bytes + .get(offset..end) + .ok_or(MiniDumpError::Truncated)? + .try_into() + .map_err(|_| MiniDumpError::Truncated)?; + Ok(u64::from_le_bytes(value)) +} + +fn read_at( + file: &mut File, + file_size: u64, + offset: u64, + size: usize, +) -> Result, MiniDumpFileError> { + let end = offset + .checked_add(u64::try_from(size).map_err(|_| MiniDumpError::Overflow)?) + .ok_or(MiniDumpError::Overflow)?; + if end > file_size { + return Err(MiniDumpError::Truncated.into()); + } + let mut bytes = vec![0u8; size]; + file.seek(SeekFrom::Start(offset))?; + file.read_exact(&mut bytes)?; + Ok(bytes) +} + +pub struct MiniDumpFile { + file: File, + file_size: u64, + regions: Vec, + starts: BTreeMap, +} + +impl MiniDumpFile { + pub fn open(path: impl AsRef) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let header = read_at(&mut file, file_size, 0, 32)?; + if header.get(..4) != Some(b"MDMP") { + return Err(MiniDumpError::InvalidSignature.into()); + } + let count = usize::try_from(as_u32(&header, 8)?).map_err(|_| MiniDumpError::Overflow)?; + if count > MAX_STREAMS { + return Err(MiniDumpError::TooManyStreams.into()); + } + let directory_rva = u64::from(as_u32(&header, 12)?); + let directory_size = count.checked_mul(12).ok_or(MiniDumpError::Overflow)?; + let directory = read_at(&mut file, file_size, directory_rva, directory_size)?; + + let mut regions = Vec::new(); + let mut found = false; + for i in 0..count { + let off = i * 12; + let kind = as_u32(&directory, off)?; + if kind != MEMORY_LIST && kind != MEMORY64_LIST { + continue; + } + found = true; + let stream_len = usize::try_from(as_u32(&directory, off + 4)?) + .map_err(|_| MiniDumpError::Overflow)?; + let stream_rva = u64::from(as_u32(&directory, off + 8)?); + // Stream data is a list of descriptors, not the memory bytes. + // Refuse maliciously large table allocations before reading. + if stream_len > 16 + MAX_RANGES * 16 { + return Err(MiniDumpError::TooManyRanges.into()); + } + let stream = read_at(&mut file, file_size, stream_rva, stream_len)?; + let header_size = if kind == MEMORY_LIST { 4 } else { 16 }; + if stream.len() < header_size { + return Err(MiniDumpError::Truncated.into()); + } + let ranges = if kind == MEMORY_LIST { + usize::try_from(as_u32(&stream, 0)?).map_err(|_| MiniDumpError::Overflow)? + } else { + usize::try_from(as_u64(&stream, 0)?).map_err(|_| MiniDumpError::Overflow)? + }; + if ranges > MAX_RANGES.saturating_sub(regions.len()) + || ranges.checked_mul(16).and_then(|v| v.checked_add(header_size)) + .is_none_or(|needed| needed > stream.len()) + { + return Err(MiniDumpError::TooManyRanges.into()); + } + let mut file_offset = if kind == MEMORY64_LIST { as_u64(&stream, 8)? } else { 0 }; + for j in 0..ranges { + let entry = header_size + j * 16; + let base = as_u64(&stream, entry)?; + let (len, offset) = if kind == MEMORY_LIST { + ( + u64::from(as_u32(&stream, entry + 8)?), + u64::from(as_u32(&stream, entry + 12)?), + ) + } else { + let len = as_u64(&stream, entry + 8)?; + let offset = file_offset; + file_offset = file_offset.checked_add(len).ok_or(MiniDumpError::Overflow)?; + (len, offset) + }; + regions.push(MiniDumpRegion { + base, + len, + file_offset: offset, + source: if kind == MEMORY_LIST { + MiniDumpSource::MemoryList + } else { + MiniDumpSource::Memory64List + }, + }); + } + } + if !found { + return Err(MiniDumpError::UnsupportedMemoryStream.into()); + } + regions.sort_by_key(|region| region.base); + let mut starts = BTreeMap::new(); + let mut end = None::; + for (index, region) in regions.iter().enumerate() { + let region_end = region.base.checked_add(region.len).ok_or(MiniDumpError::Overflow)?; + let file_end = region.file_offset.checked_add(region.len).ok_or(MiniDumpError::Overflow)?; + if region.len == 0 || file_end > file_size { + return Err(MiniDumpError::InvalidRange.into()); + } + if end.is_some_and(|last| region.base < last) { + return Err(MiniDumpError::OverlappingRanges.into()); + } + end = Some(region_end); + starts.insert(region.base, index); + } + Ok(Self { file, file_size, regions, starts }) + } + + pub fn regions(&self) -> &[MiniDumpRegion] { + &self.regions + } + + pub fn file_size(&self) -> u64 { + self.file_size + } + + /// Read only complete captured bytes with a strict per-call allocation + /// limit. Uncaptured heap pages yield MissingCapture, not fake zeroes. + pub fn read(&mut self, address: u64, length: usize) -> Result, MiniDumpFileError> { + if length > MAX_READ { + return Err(MiniDumpError::ReadLimitExceeded.into()); + } + let end = address.checked_add(length as u64).ok_or(MiniDumpError::Overflow)?; + let mut cursor = address; + let mut out = Vec::with_capacity(length); + while cursor < end { + let (_, &index) = self.starts.range(..=cursor).next_back() + .ok_or(MiniDumpError::MissingCapture { address: cursor })?; + let region = self.regions[index]; + if cursor - region.base >= region.len { + return Err(MiniDumpError::MissingCapture { address: cursor }.into()); + } + let take = (region.len - (cursor - region.base)).min(end - cursor); + let raw_start = region.file_offset.checked_add(cursor - region.base) + .ok_or(MiniDumpError::Overflow)?; + let data = read_at( + &mut self.file, + self.file_size, + raw_start, + usize::try_from(take).map_err(|_| MiniDumpError::Overflow)?, + )?; + out.extend_from_slice(&data); + cursor = cursor.checked_add(take).ok_or(MiniDumpError::Overflow)?; + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn memory64_file_reads_only_requested_bytes() -> Result<(), MiniDumpFileError> { + let mut dump = vec![0u8; 0x200]; + dump[0..4].copy_from_slice(b"MDMP"); + dump[8..12].copy_from_slice(&1u32.to_le_bytes()); + dump[12..16].copy_from_slice(&0x40u32.to_le_bytes()); + dump[0x40..0x44].copy_from_slice(&MEMORY64_LIST.to_le_bytes()); + dump[0x44..0x48].copy_from_slice(&32u32.to_le_bytes()); + dump[0x48..0x4c].copy_from_slice(&0x60u32.to_le_bytes()); + dump[0x60..0x68].copy_from_slice(&1u64.to_le_bytes()); + dump[0x68..0x70].copy_from_slice(&0x100u64.to_le_bytes()); + dump[0x70..0x78].copy_from_slice(&0x094464c0u64.to_le_bytes()); + dump[0x78..0x80].copy_from_slice(&64u64.to_le_bytes()); + dump[0x100..0x140].copy_from_slice(&[0x41u8; 64]); + + let dir = std::env::temp_dir().join(format!( + "angryier-dump-{}-{:?}.dmp", + std::process::id(), + std::thread::current().id(), + )); + let mut file = File::create(&dir)?; + file.write_all(&dump)?; + drop(file); + let mut reader = MiniDumpFile::open(&dir)?; + assert_eq!(reader.file_size(), 512); + assert_eq!(reader.read(0x094464c0, 4)?, vec![0x41; 4]); + assert!(matches!( + reader.read(0x09446500, 1), + Err(MiniDumpFileError::Format(MiniDumpError::MissingCapture { address: 0x09446500 })) + )); + std::fs::remove_file(&dir)?; + Ok(()) + } +} diff --git a/crates/angryier-models/src/lib.rs b/crates/angryier-models/src/lib.rs index da0f29c4..368baf76 100644 --- a/crates/angryier-models/src/lib.rs +++ b/crates/angryier-models/src/lib.rs @@ -315,6 +315,58 @@ impl SimState { 0 } + /// Decode an MS x64 ABI argument from the actual architectural register + /// file, not the generic simulation positional-index convention. + /// + /// RCX/RDX/R8/R9 carry the first four args. Upon entry to a callee, RSP + /// addresses the return address; arg 5 starts at [RSP+0x28] after the + /// 32-byte mandatory shadow space. Missing registers or memory remain + /// unknown (None), never silently become valid zero-valued arguments. + pub fn try_win64_arg(&self, index: usize) -> Option { + match index { + 0 => self.registers.get(&1).copied(), // RCX + 1 => self.registers.get(&2).copied(), // RDX + 2 => self.registers.get(&8).copied(), // R8 + 3 => self.registers.get(&9).copied(), // R9 + _ => { + let slot = u64::try_from(index.checked_sub(4)?).ok()?; + let rsp = *self.registers.get(&4)?; + let offset = slot.checked_mul(8)?.checked_add(0x28)?; + let addr = rsp.checked_add(offset)?; + let end = addr.checked_add(8)?; + let mut data = [0u8; 8]; + for (i, byte) in data.iter_mut().enumerate() { + let location = addr.checked_add(u64::try_from(i).ok()?)?; + if location >= end { + return None; + } + *byte = self.read_byte(location)?; + } + Some(u64::from_le_bytes(data)) + } + } + } + + /// Legacy-compatible Win64 argument accessor for existing model code. + /// Prefer try_win64_arg for fidelity-sensitive APIs: unknown arguments + /// cannot be distinguished from a real zero through this convenience. + pub fn get_win64_arg(&self, index: usize) -> u64 { + self.try_win64_arg(index).unwrap_or(0) + } + + /// Read only memory represented by the simulation; never fabricate + /// absent bytes. Required for stack arguments and snapshot provenance. + pub fn try_read_bytes(&self, addr: u64, len: usize) -> Option> { + if len > 1_048_576 { + return None; + } + let mut out = Vec::with_capacity(len); + for i in 0..len { + out.push(self.read_byte(addr.checked_add(u64::try_from(i).ok()?)?)?); + } + Some(out) + } + /// Sets an argument by direct index in the register file. pub fn set_arg(&mut self, index: usize, val: u64) { self.registers.insert(index as u64, val); @@ -387,6 +439,7 @@ impl SimState { next.exited = true; next } + SimResult::Blocked { .. } | SimResult::Unsupported(_) => self.clone(), } } @@ -396,6 +449,7 @@ impl SimState { SimResult::Continue(s) => *self = s.clone(), SimResult::Return(val) => self.return_value = Some(*val), SimResult::Exit => self.exited = true, + SimResult::Blocked { .. } | SimResult::Unsupported(_) => {}, } } } @@ -409,6 +463,10 @@ pub enum SimResult { Return(u64), /// Target program execution exited. Exit, + /// A real wait would block; no synthetic timeout or signal is invented. + Blocked { handle: u64, timeout_ms: u32 }, + /// Exact API semantics are unavailable; callers must fail closed. + Unsupported(&'static str), } impl core::fmt::Display for SimResult { @@ -417,6 +475,10 @@ impl core::fmt::Display for SimResult { Self::Continue(_) => f.write_str("Continue"), Self::Return(val) => write!(f, "Return({val:#x})"), Self::Exit => f.write_str("Exit"), + Self::Blocked { handle, timeout_ms } => { + write!(f, "Blocked(handle={handle:#x}, timeout_ms={timeout_ms})") + } + Self::Unsupported(reason) => write!(f, "Unsupported({reason})"), } } } @@ -2591,6 +2653,28 @@ mod tests { assert_eq!(state.get_arg(0), 0x9999); } + #[test] + fn win64_args_use_registers_and_shadow_space_not_simulation_indices() { + let mut state = SimState::new(); + state.set_reg(0, 0xDEAD); // RAX is NOT the first argument + state.set_reg(1, 0x1234); // RCX + state.set_reg(2, 0x2345); // RDX + state.set_reg(8, 0x3456); // R8 + state.set_reg(9, 0x4567); // R9 + state.set_reg(4, 0x8000); // RSP + state.shadow_memory(0x8028, 0x5678u64.to_le_bytes().to_vec()); + state.shadow_memory(0x8030, 0x6789u64.to_le_bytes().to_vec()); + assert_eq!(state.try_win64_arg(0), Some(0x1234)); + assert_eq!(state.try_win64_arg(1), Some(0x2345)); + assert_eq!(state.try_win64_arg(2), Some(0x3456)); + assert_eq!(state.try_win64_arg(3), Some(0x4567)); + assert_eq!(state.try_win64_arg(4), Some(0x5678)); + assert_eq!(state.try_win64_arg(5), Some(0x6789)); + assert_eq!(state.try_win64_arg(6), None); + assert_eq!(state.try_read_bytes(0x8040, 8), None); + assert_eq!(state.get_arg(0), 0xDEAD); // separate generic convention + } + #[test] fn sim_procedure_registry_len_and_lookup() { let registry = SimProcedureRegistry::with_stubs(); diff --git a/crates/angryier-models/src/win32/device.rs b/crates/angryier-models/src/win32/device.rs index 18b92ba6..7ee12bf2 100644 --- a/crates/angryier-models/src/win32/device.rs +++ b/crates/angryier-models/src/win32/device.rs @@ -22,13 +22,13 @@ impl SimProcedure for DeviceIoControlProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let h_device = state.get_arg(0); - let _ioctl_code = state.get_arg(1) as u32; - let _in_buf = state.get_arg(2); - let in_size = state.get_arg(3) as usize; - let out_buf = state.get_arg(4); - let out_size = (state.get_arg(5) as usize).min(65536); - let ret_bytes_ptr = state.get_arg(6); + let h_device = state.get_win64_arg(0); + let _ioctl_code = state.get_win64_arg(1) as u32; + let _in_buf = state.get_win64_arg(2); + let in_size = state.get_win64_arg(3) as usize; + let out_buf = state.get_win64_arg(4); + let out_size = (state.get_win64_arg(5) as usize).min(65536); + let ret_bytes_ptr = state.get_win64_arg(6); if !self.handles.is_valid(h_device) { return SimResult::Return(0); // FALSE @@ -63,7 +63,7 @@ impl SimProcedure for GetOverlappedResultProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let transferred_ptr = state.get_arg(2); + let transferred_ptr = state.get_win64_arg(2); if transferred_ptr != 0 { let mut next = state.clone(); next.write_memory(transferred_ptr, 1024u32.to_le_bytes().to_vec()); diff --git a/crates/angryier-models/src/win32/file.rs b/crates/angryier-models/src/win32/file.rs index 148bfc5a..04393498 100644 --- a/crates/angryier-models/src/win32/file.rs +++ b/crates/angryier-models/src/win32/file.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; pub enum Win32HandleKind { File { path: String, offset: u64 }, Device { name: String }, - Event { signaled: bool }, + Event { signaled: bool, manual_reset: bool }, RegKey { path: String }, Process { pid: u32 }, Thread { tid: u32 }, @@ -32,6 +32,15 @@ impl Default for Win32HandleTableState { } } +/// A nonblocking readiness probe. The runtime must schedule actual waits +/// instead of pretending all pending handles were signaled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Win32WaitStatus { + Signaled(u32), + Pending, + Invalid, +} + /// Thread-safe Win32 handle registry. #[derive(Clone, Debug, Default)] pub struct Win32HandleTable { @@ -73,6 +82,88 @@ impl Win32HandleTable { state.handles.contains_key(&handle) } + /// Mutate only a real event handle. Returning false distinguishes an + /// invalid/non-event handle from a successful Win32 API operation. + pub fn set_event_signal(&self, handle: u64, signaled: bool) -> bool { + let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + match state.handles.get_mut(&handle) { + Some(Win32HandleKind::Event { signaled: value, .. }) => { + *value = signaled; + true + } + _ => false, + } + } + + /// Atomic try-wait on a tracked event. An auto-reset event consumes its + /// signal when a waiter actually succeeds. + pub fn wait_event(&self, handle: u64) -> Win32WaitStatus { + let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + match state.handles.get_mut(&handle) { + Some(Win32HandleKind::Event { signaled, manual_reset }) => { + if !*signaled { + Win32WaitStatus::Pending + } else { + if !*manual_reset { + *signaled = false; + } + Win32WaitStatus::Signaled(0) + } + } + Some(Win32HandleKind::Thread { .. }) | Some(Win32HandleKind::Process { .. }) => { + // Thread/process completion is not emulated yet. + Win32WaitStatus::Pending + } + _ => Win32WaitStatus::Invalid, + } + } + + /// Windows WaitForMultipleObjects result selection. An all-wait consumes + /// auto-reset signals only if every requested event is ready. Wait-any + /// selects the lowest signaled index, matching Windows return indexing. + pub fn wait_events(&self, handles: &[u64], all: bool) -> Win32WaitStatus { + if handles.is_empty() || handles.len() > 64 { + return Win32WaitStatus::Invalid; + } + let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if handles.iter().collect::>().len() != handles.len() { + return Win32WaitStatus::Invalid; + } + let mut ready = Vec::new(); + for (index, handle) in handles.iter().enumerate() { + match state.handles.get(handle) { + Some(Win32HandleKind::Event { signaled, .. }) => { + if *signaled { + ready.push(index); + } + } + Some(Win32HandleKind::Thread { .. }) | Some(Win32HandleKind::Process { .. }) => {} + _ => return Win32WaitStatus::Invalid, + } + } + if (all && ready.len() != handles.len()) || (!all && ready.is_empty()) { + return Win32WaitStatus::Pending; + } + if all { + for handle in handles { + if let Some(Win32HandleKind::Event { signaled, manual_reset }) = state.handles.get_mut(handle) + && !*manual_reset + { + *signaled = false; + } + } + Win32WaitStatus::Signaled(0) + } else { + let index = ready[0]; + if let Some(Win32HandleKind::Event { signaled, manual_reset }) = state.handles.get_mut(&handles[index]) + && !*manual_reset + { + *signaled = false; + } + Win32WaitStatus::Signaled(index as u32) + } + } + /// Retrieves the object kind for a handle. pub fn get_kind(&self, handle: u64) -> Option { let state = match self.inner.lock() { @@ -104,7 +195,7 @@ impl SimProcedure for CreateFileAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let name_addr = state.get_arg(0); + let name_addr = state.get_win64_arg(0); let bytes = state.read_null_terminated_string(name_addr, 4096); let path = String::from_utf8_lossy(&bytes).into_owned(); @@ -136,7 +227,7 @@ impl SimProcedure for CreateFileWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let name_addr = state.get_arg(0); + let name_addr = state.get_win64_arg(0); let bytes = state.read_bytes(name_addr, 512); // Decode UTF-16LE characters up to null terminator @@ -178,10 +269,10 @@ impl SimProcedure for ReadFileProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let handle = state.get_arg(0); - let buffer = state.get_arg(1); - let count = (state.get_arg(2) as usize).min(65536); - let read_ptr = state.get_arg(3); + let handle = state.get_win64_arg(0); + let buffer = state.get_win64_arg(1); + let count = (state.get_win64_arg(2) as usize).min(65536); + let read_ptr = state.get_win64_arg(3); if !self.handles.is_valid(handle) { return SimResult::Return(0); // FALSE @@ -216,9 +307,9 @@ impl SimProcedure for WriteFileProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let handle = state.get_arg(0); - let count = state.get_arg(2) as u32; - let written_ptr = state.get_arg(3); + let handle = state.get_win64_arg(0); + let count = state.get_win64_arg(2) as u32; + let written_ptr = state.get_win64_arg(3); if !self.handles.is_valid(handle) { return SimResult::Return(0); // FALSE @@ -253,7 +344,7 @@ impl SimProcedure for CloseHandleProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let handle = state.get_arg(0); + let handle = state.get_win64_arg(0); let res = self.handles.close_handle(handle); SimResult::Return(if res { 1 } else { 0 }) } @@ -268,7 +359,7 @@ impl SimProcedure for GetFileSizeProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let high_ptr = state.get_arg(1); + let high_ptr = state.get_win64_arg(1); if high_ptr != 0 { let mut next = state.clone(); next.write_memory(high_ptr, 0u32.to_le_bytes().to_vec()); @@ -290,7 +381,7 @@ impl SimProcedure for GetFileSizeExProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let size_ptr = state.get_arg(1); + let size_ptr = state.get_win64_arg(1); if size_ptr != 0 { let mut next = state.clone(); next.write_memory(size_ptr, 4096u64.to_le_bytes().to_vec()); @@ -312,7 +403,7 @@ impl SimProcedure for SetFilePointerProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let dist = state.get_arg(1); + let dist = state.get_win64_arg(1); SimResult::Return(dist) } } @@ -391,8 +482,8 @@ impl SimProcedure for GetCurrentDirectoryAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let _len = state.get_arg(0) as usize; - let buf = state.get_arg(1); + let _len = state.get_win64_arg(0) as usize; + let buf = state.get_win64_arg(1); let path = b"C:\\Windows\\System32\0"; let mut next = state.clone(); diff --git a/crates/angryier-models/src/win32/memory.rs b/crates/angryier-models/src/win32/memory.rs index 349b47f9..93f3fdf3 100644 --- a/crates/angryier-models/src/win32/memory.rs +++ b/crates/angryier-models/src/win32/memory.rs @@ -128,10 +128,10 @@ impl SimProcedure for VirtualAllocProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let lp_address = state.get_arg(0); - let dw_size = state.get_arg(1); - let fl_alloc = state.get_arg(2) as u32; - let fl_protect = state.get_arg(3) as u32; + let lp_address = state.get_win64_arg(0); + let dw_size = state.get_win64_arg(1); + let fl_alloc = state.get_win64_arg(2) as u32; + let fl_protect = state.get_win64_arg(3) as u32; let ptr = self.tracker.virtual_alloc(lp_address, dw_size, fl_alloc, fl_protect); SimResult::Return(ptr) @@ -155,10 +155,10 @@ impl SimProcedure for VirtualAllocExProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let lp_address = state.get_arg(1); - let dw_size = state.get_arg(2); - let fl_alloc = state.get_arg(3) as u32; - let fl_protect = state.get_arg(4) as u32; + let lp_address = state.get_win64_arg(1); + let dw_size = state.get_win64_arg(2); + let fl_alloc = state.get_win64_arg(3) as u32; + let fl_protect = state.get_win64_arg(4) as u32; let ptr = self.tracker.virtual_alloc(lp_address, dw_size, fl_alloc, fl_protect); SimResult::Return(ptr) @@ -182,9 +182,9 @@ impl SimProcedure for VirtualProtectProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let lp_address = state.get_arg(0); - let fl_new_protect = state.get_arg(2) as u32; - let lpfl_old_protect = state.get_arg(3); + let lp_address = state.get_win64_arg(0); + let fl_new_protect = state.get_win64_arg(2) as u32; + let lpfl_old_protect = state.get_win64_arg(3); let old = self.tracker.virtual_protect(lp_address, fl_new_protect).unwrap_or(0x04); // PAGE_READWRITE @@ -217,7 +217,7 @@ impl SimProcedure for VirtualFreeProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let lp_address = state.get_arg(0); + let lp_address = state.get_win64_arg(0); let _ = self.tracker.virtual_free(lp_address); SimResult::Return(1) // TRUE } @@ -265,7 +265,7 @@ impl SimProcedure for HeapAllocProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let dw_bytes = state.get_arg(2); + let dw_bytes = state.get_win64_arg(2); let ptr = self.tracker.virtual_alloc(0, dw_bytes, 0x1000, 0x04); SimResult::Return(ptr) } @@ -288,7 +288,7 @@ impl SimProcedure for HeapFreeProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let lp_mem = state.get_arg(2); + let lp_mem = state.get_win64_arg(2); let _ = self.tracker.virtual_free(lp_mem); SimResult::Return(1) // TRUE } @@ -346,7 +346,7 @@ impl SimProcedure for HeapDestroyProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let h_heap = state.get_arg(0); + let h_heap = state.get_win64_arg(0); let _ = self.tracker.virtual_free(h_heap); SimResult::Return(1) } @@ -369,7 +369,7 @@ impl SimProcedure for GlobalAllocProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let dw_bytes = state.get_arg(1); + let dw_bytes = state.get_win64_arg(1); let ptr = self.tracker.virtual_alloc(0, dw_bytes, 0x1000, 0x04); SimResult::Return(ptr) } @@ -392,7 +392,7 @@ impl SimProcedure for GlobalFreeProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let h_mem = state.get_arg(0); + let h_mem = state.get_win64_arg(0); let _ = self.tracker.virtual_free(h_mem); SimResult::Return(0) // NULL indicates success } diff --git a/crates/angryier-models/src/win32/mod.rs b/crates/angryier-models/src/win32/mod.rs index d738a3ff..39ad3c25 100644 --- a/crates/angryier-models/src/win32/mod.rs +++ b/crates/angryier-models/src/win32/mod.rs @@ -115,11 +115,11 @@ pub fn register_win32_procedures( // Synchronization & Events registry.register(Box::new(CreateEventAProcedure::new(handles.clone()))); - registry.register(Box::new(CreateEventWProcedure::new(handles))); - registry.register(Box::new(SetEventProcedure)); - registry.register(Box::new(ResetEventProcedure)); - registry.register(Box::new(WaitForSingleObjectProcedure)); - registry.register(Box::new(WaitForMultipleObjectsProcedure)); + registry.register(Box::new(CreateEventWProcedure::new(handles.clone()))); + registry.register(Box::new(SetEventProcedure::new(handles.clone()))); + registry.register(Box::new(ResetEventProcedure::new(handles.clone()))); + registry.register(Box::new(WaitForSingleObjectProcedure::new(handles.clone()))); + registry.register(Box::new(WaitForMultipleObjectsProcedure::new(handles))); registry.register(Box::new(InitializeCriticalSectionProcedure)); registry.register(Box::new(EnterCriticalSectionProcedure)); registry.register(Box::new(LeaveCriticalSectionProcedure)); diff --git a/crates/angryier-models/src/win32/module.rs b/crates/angryier-models/src/win32/module.rs index a34ef764..11656235 100644 --- a/crates/angryier-models/src/win32/module.rs +++ b/crates/angryier-models/src/win32/module.rs @@ -68,7 +68,7 @@ impl SimProcedure for GetProcAddressProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let h_module = state.get_arg(0); + let h_module = state.get_win64_arg(0); let base = if h_module != 0 { h_module } else { DEFAULT_HMODULE_BASE }; // Deterministic export address: base + 0x1000 SimResult::Return(base.saturating_add(0x1000)) @@ -97,7 +97,7 @@ impl SimProcedure for GetModuleHandleAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let mod_name_ptr = state.get_arg(0); + let mod_name_ptr = state.get_win64_arg(0); if mod_name_ptr == 0 { SimResult::Return(0x140000000) // Default base of main image } else { @@ -115,7 +115,7 @@ impl SimProcedure for GetModuleHandleWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let mod_name_ptr = state.get_arg(0); + let mod_name_ptr = state.get_win64_arg(0); if mod_name_ptr == 0 { SimResult::Return(0x140000000) } else { @@ -133,7 +133,7 @@ impl SimProcedure for GetModuleFileNameAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let filename_ptr = state.get_arg(1); + let filename_ptr = state.get_win64_arg(1); let path = b"C:\\Windows\\System32\\target.exe\0"; let mut next = state.clone(); @@ -153,7 +153,7 @@ impl SimProcedure for GetModuleFileNameWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let filename_ptr = state.get_arg(1); + let filename_ptr = state.get_win64_arg(1); let path_utf16: Vec = "C:\\Windows\\System32\\target.exe\0" .encode_utf16() .flat_map(|u| u.to_le_bytes()) diff --git a/crates/angryier-models/src/win32/registry.rs b/crates/angryier-models/src/win32/registry.rs index 0f946a78..b14a0b1d 100644 --- a/crates/angryier-models/src/win32/registry.rs +++ b/crates/angryier-models/src/win32/registry.rs @@ -22,8 +22,8 @@ impl SimProcedure for RegOpenKeyExAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let subkey_addr = state.get_arg(1); - let phk_result = state.get_arg(4); + let subkey_addr = state.get_win64_arg(1); + let phk_result = state.get_win64_arg(4); let subkey_bytes = state.read_null_terminated_string(subkey_addr, 512); let subkey = String::from_utf8_lossy(&subkey_bytes).into_owned(); @@ -59,7 +59,7 @@ impl SimProcedure for RegOpenKeyExWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let phk_result = state.get_arg(4); + let phk_result = state.get_win64_arg(4); let handle = self.handles.alloc_handle(Win32HandleKind::RegKey { path: "".into(), }); @@ -85,9 +85,9 @@ impl SimProcedure for RegQueryValueExAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let type_ptr = state.get_arg(3); - let data_ptr = state.get_arg(4); - let cb_data_ptr = state.get_arg(5); + let type_ptr = state.get_win64_arg(3); + let data_ptr = state.get_win64_arg(4); + let cb_data_ptr = state.get_win64_arg(5); let mut next = state.clone(); @@ -117,9 +117,9 @@ impl SimProcedure for RegQueryValueExWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let type_ptr = state.get_arg(3); - let data_ptr = state.get_arg(4); - let cb_data_ptr = state.get_arg(5); + let type_ptr = state.get_win64_arg(3); + let data_ptr = state.get_win64_arg(4); + let cb_data_ptr = state.get_win64_arg(5); let mut next = state.clone(); @@ -182,7 +182,7 @@ impl SimProcedure for RegCloseKeyProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let h_key = state.get_arg(0); + let h_key = state.get_win64_arg(0); let _ = self.handles.close_handle(h_key); SimResult::Return(0) } diff --git a/crates/angryier-models/src/win32/system.rs b/crates/angryier-models/src/win32/system.rs index d3098061..330f3521 100644 --- a/crates/angryier-models/src/win32/system.rs +++ b/crates/angryier-models/src/win32/system.rs @@ -18,7 +18,7 @@ impl SimProcedure for GetSystemInfoProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let info_ptr = state.get_arg(0); + let info_ptr = state.get_win64_arg(0); let mut sys_info = vec![0u8; 48]; // wProcessorArchitecture: 9 (PROCESSOR_ARCHITECTURE_AMD64) @@ -64,7 +64,7 @@ impl SimProcedure for GetVersionExAProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let ver_ptr = state.get_arg(0); + let ver_ptr = state.get_win64_arg(0); let mut ver = vec![0u8; 148]; // dwOSVersionInfoSize: 148 @@ -95,7 +95,7 @@ impl SimProcedure for GetVersionExWProcedure { } fn apply(&self, state: &SimState) -> SimResult { - let ver_ptr = state.get_arg(0); + let ver_ptr = state.get_win64_arg(0); let mut ver = vec![0u8; 276]; ver[0..4].copy_from_slice(&276u32.to_le_bytes()); @@ -207,7 +207,7 @@ impl SimProcedure for QueryPerformanceCounterWin32Procedure { } fn apply(&self, state: &SimState) -> SimResult { - let count_ptr = state.get_arg(0); + let count_ptr = state.get_win64_arg(0); if count_ptr != 0 { let mut next = state.clone(); next.write_memory(count_ptr, 10000000u64.to_le_bytes().to_vec()); @@ -229,7 +229,7 @@ impl SimProcedure for QueryPerformanceFrequencyWin32Procedure { } fn apply(&self, state: &SimState) -> SimResult { - let freq_ptr = state.get_arg(0); + let freq_ptr = state.get_win64_arg(0); if freq_ptr != 0 { let mut next = state.clone(); next.write_memory(freq_ptr, 10000000u64.to_le_bytes().to_vec()); // 10 MHz @@ -319,105 +319,169 @@ impl SimProcedure for TerminateProcessProcedure { // Synchronization & Events // --------------------------------------------------------------------------- -/// `HANDLE CreateEventA(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCSTR lpName)` +/// `HANDLE CreateEventA/W(attributes, manual_reset, initial_state, name)` +/// Both variants share the same kernel-style handle tracker. pub struct CreateEventAProcedure { handles: Win32HandleTable, } - impl CreateEventAProcedure { pub fn new(handles: Win32HandleTable) -> Self { Self { handles } } } - impl SimProcedure for CreateEventAProcedure { fn name(&self) -> &'static str { "CreateEventA" } - fn apply(&self, state: &SimState) -> SimResult { - let initial_state = state.get_arg(2) != 0; - let handle = self.handles.alloc_handle(Win32HandleKind::Event { - signaled: initial_state, - }); - SimResult::Return(handle) + let Some(manual_reset) = state.try_win64_arg(1) else { + return SimResult::Unsupported("CreateEventA missing bManualReset"); + }; + let Some(initial_state) = state.try_win64_arg(2) else { + return SimResult::Unsupported("CreateEventA missing bInitialState"); + }; + SimResult::Return(self.handles.alloc_handle(Win32HandleKind::Event { + signaled: initial_state != 0, + manual_reset: manual_reset != 0, + })) } } -/// `HANDLE CreateEventW(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCWSTR lpName)` pub struct CreateEventWProcedure { handles: Win32HandleTable, } - impl CreateEventWProcedure { pub fn new(handles: Win32HandleTable) -> Self { Self { handles } } } - impl SimProcedure for CreateEventWProcedure { fn name(&self) -> &'static str { "CreateEventW" } - fn apply(&self, state: &SimState) -> SimResult { - let initial_state = state.get_arg(2) != 0; - let handle = self.handles.alloc_handle(Win32HandleKind::Event { - signaled: initial_state, - }); - SimResult::Return(handle) + let Some(manual_reset) = state.try_win64_arg(1) else { + return SimResult::Unsupported("CreateEventW missing bManualReset"); + }; + let Some(initial_state) = state.try_win64_arg(2) else { + return SimResult::Unsupported("CreateEventW missing bInitialState"); + }; + SimResult::Return(self.handles.alloc_handle(Win32HandleKind::Event { + signaled: initial_state != 0, + manual_reset: manual_reset != 0, + })) } } /// `BOOL SetEvent(HANDLE hEvent)` -pub struct SetEventProcedure; - +pub struct SetEventProcedure { + handles: Win32HandleTable, +} +impl SetEventProcedure { + pub fn new(handles: Win32HandleTable) -> Self { + Self { handles } + } +} impl SimProcedure for SetEventProcedure { fn name(&self) -> &'static str { "SetEvent" } - - fn apply(&self, _state: &SimState) -> SimResult { - SimResult::Return(1) + fn apply(&self, state: &SimState) -> SimResult { + match state.try_win64_arg(0) { + Some(handle) => SimResult::Return(u64::from(self.handles.set_event_signal(handle, true))), + None => SimResult::Unsupported("SetEvent missing event HANDLE"), + } } } /// `BOOL ResetEvent(HANDLE hEvent)` -pub struct ResetEventProcedure; - +pub struct ResetEventProcedure { + handles: Win32HandleTable, +} +impl ResetEventProcedure { + pub fn new(handles: Win32HandleTable) -> Self { + Self { handles } + } +} impl SimProcedure for ResetEventProcedure { fn name(&self) -> &'static str { "ResetEvent" } - - fn apply(&self, _state: &SimState) -> SimResult { - SimResult::Return(1) + fn apply(&self, state: &SimState) -> SimResult { + match state.try_win64_arg(0) { + Some(handle) => SimResult::Return(u64::from(self.handles.set_event_signal(handle, false))), + None => SimResult::Unsupported("ResetEvent missing event HANDLE"), + } } } /// `DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds)` -pub struct WaitForSingleObjectProcedure; - +pub struct WaitForSingleObjectProcedure { + handles: Win32HandleTable, +} +impl WaitForSingleObjectProcedure { + pub fn new(handles: Win32HandleTable) -> Self { + Self { handles } + } +} impl SimProcedure for WaitForSingleObjectProcedure { fn name(&self) -> &'static str { "WaitForSingleObject" } - - fn apply(&self, _state: &SimState) -> SimResult { - SimResult::Return(0) // WAIT_OBJECT_0 + fn apply(&self, state: &SimState) -> SimResult { + let (Some(handle), Some(timeout)) = (state.try_win64_arg(0), state.try_win64_arg(1)) else { + return SimResult::Unsupported("WaitForSingleObject missing HANDLE/timeout"); + }; + let timeout = timeout as u32; + match self.handles.wait_event(handle) { + super::file::Win32WaitStatus::Signaled(index) => SimResult::Return(u64::from(index)), + super::file::Win32WaitStatus::Invalid => SimResult::Return(0xFFFF_FFFF), // WAIT_FAILED + super::file::Win32WaitStatus::Pending if timeout == 0 => SimResult::Return(258), // WAIT_TIMEOUT + super::file::Win32WaitStatus::Pending => SimResult::Blocked { handle, timeout_ms: timeout }, + } } } -/// `DWORD WaitForMultipleObjects(DWORD nCount, const HANDLE *lpHandles, BOOL bWaitAll, DWORD dwMilliseconds)` -pub struct WaitForMultipleObjectsProcedure; - +/// `DWORD WaitForMultipleObjects(DWORD nCount, const HANDLE*, BOOL all, DWORD timeout)` +pub struct WaitForMultipleObjectsProcedure { + handles: Win32HandleTable, +} +impl WaitForMultipleObjectsProcedure { + pub fn new(handles: Win32HandleTable) -> Self { + Self { handles } + } +} impl SimProcedure for WaitForMultipleObjectsProcedure { fn name(&self) -> &'static str { "WaitForMultipleObjects" } - - fn apply(&self, _state: &SimState) -> SimResult { - SimResult::Return(0) // WAIT_OBJECT_0 + fn apply(&self, state: &SimState) -> SimResult { + let (Some(count), Some(ptr), Some(all), Some(timeout)) = ( + state.try_win64_arg(0), state.try_win64_arg(1), + state.try_win64_arg(2), state.try_win64_arg(3), + ) else { + return SimResult::Unsupported("WaitForMultipleObjects missing arguments"); + }; + if count == 0 || count > 64 { + return SimResult::Return(0xFFFF_FFFF); // WAIT_FAILED + } + let Some(bytes) = state.try_read_bytes(ptr, count as usize * 8) else { + return SimResult::Unsupported("WaitForMultipleObjects handle array not captured"); + }; + let handles: Vec = bytes.chunks_exact(8).filter_map(|chunk| { + let raw: [u8; 8] = chunk.try_into().ok()?; + Some(u64::from_le_bytes(raw)) + }).collect(); + if handles.len() != count as usize { + return SimResult::Unsupported("incomplete HANDLE array"); + } + let timeout = timeout as u32; + match self.handles.wait_events(&handles, all != 0) { + super::file::Win32WaitStatus::Signaled(index) => SimResult::Return(u64::from(index)), + super::file::Win32WaitStatus::Invalid => SimResult::Return(0xFFFF_FFFF), + super::file::Win32WaitStatus::Pending if timeout == 0 => SimResult::Return(258), + super::file::Win32WaitStatus::Pending => SimResult::Blocked { handle: ptr, timeout_ms: timeout }, + } } } diff --git a/crates/angryier-models/tests/win64_events.rs b/crates/angryier-models/tests/win64_events.rs new file mode 100644 index 00000000..7a152355 --- /dev/null +++ b/crates/angryier-models/tests/win64_events.rs @@ -0,0 +1,116 @@ +//! Regression suite for the exact Microsoft x64 ABI and tracked event state. + +use angryier_models::{SimProcedure, SimResult, SimState}; +use angryier_models::win32::file::{Win32HandleKind, Win32HandleTable}; +use angryier_models::win32::{ + CreateEventAProcedure, CreateEventWProcedure, ResetEventProcedure, + SetEventProcedure, WaitForMultipleObjectsProcedure, WaitForSingleObjectProcedure, +}; + +fn make_event(handles: &Win32HandleTable, manual: bool, initial: bool) -> Result { + let proc = CreateEventWProcedure::new(handles.clone()); + let mut state = SimState::new(); + state.set_reg(0, 0xdead_beef); // misleading RAX / generic positional arg0 + state.set_reg(1, 0); // security attributes, RCX + state.set_reg(2, u64::from(manual)); // bManualReset, RDX + state.set_reg(8, u64::from(initial)); // bInitialState, R8 + state.set_reg(9, 0); // lpName, R9 + match proc.apply(&state) { + SimResult::Return(handle) if handle != 0 => Ok(handle), + other => Err(format!("CreateEventW failed: {other:?}")), + } +} + +fn harg(handle: u64, timeout: u32) -> SimState { + let mut state = SimState::new(); + state.set_reg(1, handle); + state.set_reg(2, u64::from(timeout)); + state +} + +#[test] +fn win64_args_are_not_read_from_rax_and_event_state_is_shared() -> Result<(), String> { + let handles = Win32HandleTable::new(); + let event = make_event(&handles, false, false)?; + assert_eq!(handles.get_kind(event), Some(Win32HandleKind::Event { + manual_reset: false, signaled: false, + })); + let wait = WaitForSingleObjectProcedure::new(handles.clone()); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(258)); // zero-time poll + assert_eq!(wait.apply(&harg(event, u32::MAX)), SimResult::Blocked { + handle: event, timeout_ms: u32::MAX, + }); + let set = SetEventProcedure::new(handles.clone()); + let reset = ResetEventProcedure::new(handles.clone()); + assert_eq!(set.apply(&harg(event, 0)), SimResult::Return(1)); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(0)); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(258)); // auto-reset + assert_eq!(set.apply(&harg(event, 0)), SimResult::Return(1)); + assert_eq!(reset.apply(&harg(event, 0)), SimResult::Return(1)); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(258)); + assert_eq!(set.apply(&harg(0xbad, 0)), SimResult::Return(0)); + assert_eq!(wait.apply(&harg(0xbad, 0)), SimResult::Return(0xffff_ffff)); + Ok(()) +} + +#[test] +fn manual_reset_wakes_repeated_pollers_until_reset() -> Result<(), String> { + let handles = Win32HandleTable::new(); + let event = make_event(&handles, true, true)?; + let wait = WaitForSingleObjectProcedure::new(handles.clone()); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(0)); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(0)); + let reset = ResetEventProcedure::new(handles); + assert_eq!(reset.apply(&harg(event, 0)), SimResult::Return(1)); + assert_eq!(wait.apply(&harg(event, 0)), SimResult::Return(258)); + Ok(()) +} + +#[test] +fn wait_all_is_atomic_and_wait_any_consumes_lowest_auto_reset() -> Result<(), String> { + let handles = Win32HandleTable::new(); + let one = make_event(&handles, false, true)?; + let two = make_event(&handles, false, false)?; + let proc = WaitForMultipleObjectsProcedure::new(handles.clone()); + let mut state = SimState::new(); + state.set_reg(1, 2); // nCount + state.set_reg(2, 0x1234); // lpHandles + state.set_reg(8, 1); // bWaitAll + state.set_reg(9, 0); // timeout = 0 + let mut list = Vec::new(); + list.extend_from_slice(&one.to_le_bytes()); + list.extend_from_slice(&two.to_le_bytes()); + state.shadow_memory(0x1234, list); + assert_eq!(proc.apply(&state), SimResult::Return(258)); + // Wait-all failed: first event must remain signaled. + assert_eq!(handles.get_kind(one), Some(Win32HandleKind::Event { + signaled: true, manual_reset: false, + })); + let set = SetEventProcedure::new(handles.clone()); + assert_eq!(set.apply(&harg(two, 0)), SimResult::Return(1)); + assert_eq!(proc.apply(&state), SimResult::Return(0)); + assert_eq!(handles.get_kind(one), Some(Win32HandleKind::Event { + signaled: false, manual_reset: false, + })); + + // Either event can now be signaled; wait-any returns its zero-based index. + assert_eq!(set.apply(&harg(two, 0)), SimResult::Return(1)); + state.set_reg(8, 0); + assert_eq!(proc.apply(&state), SimResult::Return(1)); + assert_eq!(proc.apply(&state), SimResult::Return(258)); + Ok(()) +} + +#[test] +fn absent_handle_memory_and_missing_arguments_cannot_create_success() { + let handles = Win32HandleTable::new(); + let event = CreateEventAProcedure::new(handles.clone()); + assert!(matches!(event.apply(&SimState::new()), SimResult::Unsupported(_))); + let wait = WaitForMultipleObjectsProcedure::new(handles); + let mut state = SimState::new(); + state.set_reg(1, 1); + state.set_reg(2, 0x1234); + state.set_reg(8, 0); + state.set_reg(9, u64::from(u32::MAX)); + assert!(matches!(wait.apply(&state), SimResult::Unsupported(_))); +} diff --git a/crates/angryier-runtime/src/call_audit.rs b/crates/angryier-runtime/src/call_audit.rs new file mode 100644 index 00000000..a62d4a29 --- /dev/null +++ b/crates/angryier-runtime/src/call_audit.rs @@ -0,0 +1,220 @@ +//! Opt-in, per-instruction control/stack audit and first-divergence checks. +//! +//! The regular interpreter remains free of instrumentation overhead. A caller +//! can select only suspected CALL/RET and import sites. Every observation +//! captures pre/post RIP, RSP, and the top-of-stack only when readable and +//! fully concrete. Unknown stack bytes are never treated as return addresses. + +use angryier_arch::Decoder; +use angryier_arch_intel64::register_id; +use angryier_memory::{ByteValue, LayeredMemory}; +use angryier_types::Address; + +use crate::{Process, Runtime, RuntimeError, StepOutcome}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ControlSnapshot { + pub pc: Address, + pub rsp: Option, + pub stack_top: Option, + pub rax: Option, +} + +impl ControlSnapshot { + fn capture(process: &Process) -> Result { + let pc = process.pc()?; + let rsp = process.read_register(register_id::GPR_BASE + 4).ok(); + let stack_top = rsp.and_then(|address| { + let bytes = process.state.memory.read(address, 8).ok()?; + let mut value = [0u8; 8]; + for (i, b) in bytes.iter().enumerate() { + if let ByteValue::Concrete(byte) = b { + value[i] = *byte; + } else { + return None; + } + } + Some(u64::from_le_bytes(value)) + }); + Ok(Self { + pc, + rsp, + stack_top, + rax: process.read_register(register_id::GPR_BASE).ok(), + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReturnValidation { + NotReturn, + Verified, + MissingStackEvidence, + WrongReturn { target: Address, actual: Address, expected_rsp: u64, actual_rsp: Option }, +} + +#[derive(Debug)] +pub struct AuditedStep { + pub before: ControlSnapshot, + pub outcome: Result, + pub after: ControlSnapshot, + pub return_validation: ReturnValidation, +} + +/// When the observed instruction was an ordinary RET or call-correct +/// SimProcedure, validate the popped return address and RSP advancement. +fn verify_return( + before: ControlSnapshot, + after: ControlSnapshot, + outcome: &Result, + is_instance_hook: bool, +) -> ReturnValidation { + let expected_return = matches!( + outcome, + Ok(StepOutcome::Stepped { form_id, .. }) + if *form_id == angryier_semantics_intel64::forms::RET + ) || (is_instance_hook && matches!(outcome, Ok(StepOutcome::SimProcedure { .. }))); + if !expected_return { + return ReturnValidation::NotReturn; + } + let (Some(top), Some(rsp)) = (before.stack_top, before.rsp) else { + return ReturnValidation::MissingStackEvidence; + }; + let Some(expected_rsp) = rsp.checked_add(8) else { + return ReturnValidation::MissingStackEvidence; + }; + if after.pc == top && after.rsp == Some(expected_rsp) { + ReturnValidation::Verified + } else { + ReturnValidation::WrongReturn { + target: top, + actual: after.pc, + expected_rsp, + actual_rsp: after.rsp, + } + } +} + +impl Runtime { + /// A single step with precise pre/post control state even if execution + /// returns an error. Does not modify Process trace or engine behavior + /// beyond whatever the underlying step would have done. + pub fn step_audited(&self, process: &mut Process) -> Result { + let before = ControlSnapshot::capture(process)?; + let is_instance_hook = process.simproc_instances.contains_key(&before.pc); + let outcome = self.step(process); + let after = ControlSnapshot::capture(process)?; + let return_validation = verify_return(before, after, &outcome, is_instance_hook); + Ok(AuditedStep { + before, + outcome, + after, + return_validation, + }) + } +} + +/// Trace snapshots from an external observer must be captured with the same +/// image bytes and initial state as Angryier. An identity mismatch explicitly +/// prevents comparing unrelated execution runs as if they were deterministic. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TraceIdentity { + pub executable_digest: [u8; 32], + pub initial_state_digest: [u8; 32], + pub arch_id: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TraceDifference { + IdentityMismatch, + StepCount { expected: usize, actual: usize }, + /// One trace had valid evidence for a field while the other did not. + /// Missing evidence is NOT equivalent to equality. + EvidenceGap { index: usize, field: &'static str }, + Control { index: usize, native: ControlSnapshot, emulated: ControlSnapshot }, +} + +/// Compare snapshots at the same step boundary. The caller must supply +/// exact matching identity metadata; provenance is never inferred from +/// PC equality alone. Unexpected step counts are incomplete, not success. +pub fn first_divergence( + native_identity: TraceIdentity, + native: &[ControlSnapshot], + emulated_identity: TraceIdentity, + emulated: &[ControlSnapshot], +) -> Option { + if native_identity != emulated_identity { + return Some(TraceDifference::IdentityMismatch); + } + for (index, (a, b)) in native.iter().zip(emulated.iter()).enumerate() { + if a.pc != b.pc + || (a.rsp.is_some() && b.rsp.is_some() && a.rsp != b.rsp) + || (a.stack_top.is_some() && b.stack_top.is_some() && a.stack_top != b.stack_top) + { + return Some(TraceDifference::Control { index, native: *a, emulated: *b }); + } + if a.rsp.is_some() != b.rsp.is_some() { + return Some(TraceDifference::EvidenceGap { index, field: "rsp" }); + } + if a.stack_top.is_some() != b.stack_top.is_some() { + return Some(TraceDifference::EvidenceGap { index, field: "stack_top" }); + } + } + (native.len() != emulated.len()).then_some(TraceDifference::StepCount { + expected: native.len(), + actual: emulated.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(pc: u64, rsp: Option, top: Option) -> ControlSnapshot { + ControlSnapshot { pc, rsp, stack_top: top, rax: None } + } + + #[test] + fn checked_return_validation_detects_pc_and_rsp_corruption() { + let before = state(0x1000, Some(0x2000), Some(0x4000)); + let good = state(0x4000, Some(0x2008), None); + let bad = state(0x1234, Some(0x2000), None); + let result = Ok(StepOutcome::Stepped { + pc: 0x1000, + next_pc: 0x4000, + length: 1, + form_id: angryier_semantics_intel64::forms::RET, + }); + assert_eq!(verify_return(before, good, &result, false), ReturnValidation::Verified); + assert!(matches!(verify_return(before, bad, &result, false), ReturnValidation::WrongReturn { .. })); + assert_eq!(verify_return(state(0x1000, None, None), good, &result, false), ReturnValidation::MissingStackEvidence); + } + + #[test] + fn first_divergence_checks_capture_identity_and_first_change() { + let id = TraceIdentity { + executable_digest: [1; 32], + initial_state_digest: [2; 32], + arch_id: 1, + }; + let native = [state(0x1000, Some(0x8000), Some(0x1100)), + state(0x1100, Some(0x8008), None)]; + let emulated = [native[0], state(0x0f1e, Some(0x8008), None)]; + assert_eq!(first_divergence(id, &native, id, &native), None); + assert!(matches!( + first_divergence(id, &native, id, &emulated), + Some(TraceDifference::Control { index: 1, .. }) + )); + let incomplete = [native[0], state(0x1100, None, None)]; + assert_eq!( + first_divergence(id, &native, id, &incomplete), + Some(TraceDifference::EvidenceGap { index: 1, field: "rsp" }), + ); + let mut changed = id; + changed.initial_state_digest = [9; 32]; + assert_eq!( + first_divergence(id, &native, changed, &emulated), + Some(TraceDifference::IdentityMismatch) + ); + } +} diff --git a/crates/angryier-runtime/src/capture.rs b/crates/angryier-runtime/src/capture.rs new file mode 100644 index 00000000..3413e1a1 --- /dev/null +++ b/crates/angryier-runtime/src/capture.rs @@ -0,0 +1,412 @@ +//! Explicitly selected read-only minidump ranges -> persistent process memory. +//! +//! This is NOT Windows thread/process restoration. Callers must supply ranges, +//! permissions and matching image/capture provenance. Missing dump bytes are +//! never silently converted into zero-backed heap memory. +//! Admission is atomic across all selected ranges; no partial mutation. + +use angryier_loader::minidump_file::MiniDumpFile; +use angryier_memory::{LayeredMemory, MemoryRegion, PersistentMemory}; +use angryier_types::ObjectId; + +use crate::{Process, RuntimeError}; + +/// Exact memory input boundary shared by valid MINIDUMP_MEMORY64 streams +/// and independently verified, pre-extracted heap slices. The trait cannot +/// return invented zero-filled gaps or partial reads. +pub trait CapturedMemoryReader { + fn read_captured(&mut self, address: u64, length: usize) -> Result, String>; +} + +impl CapturedMemoryReader for MiniDumpFile { + fn read_captured(&mut self, address: u64, length: usize) -> Result, String> { + self.read(address, length).map_err(|e| format!("{e:?}")) + } +} + +/// One capture region independently associated with a guest virtual address. +/// No ownership of bytes is taken; the caller must retain each source file. +#[derive(Clone, Copy, Debug)] +pub struct CapturedSlice<'a> { + pub base: u64, + pub bytes: &'a [u8], +} + +/// Read-only collection of verified VA-backed slices. Use when the OS dump's +/// stream directory is missing/corrupted but an independent extraction step +/// has established exact byte-to-VA mappings. The declared source identity +/// is provided by the caller, not authenticated or inferred here. +pub struct SliceCapture<'a> { + source_identity: [u8; 32], + regions: Vec>, +} + +impl<'a> SliceCapture<'a> { + pub fn new( + source_identity: [u8; 32], + mut regions: Vec>, + ) -> Result { + if source_identity == [0; 32] { + return Err(capture_error("capture identity must not be all zeros")); + } + if regions.is_empty() || regions.len() > MAX_SELECTIONS { + return Err(capture_error("no capture regions or excessive region count")); + } + regions.sort_by_key(|r| r.base); + let mut previous_end: Option = None; + for region in ®ions { + if region.bytes.is_empty() { + return Err(capture_error("empty captured slice")); + } + let len = u64::try_from(region.bytes.len()) + .map_err(|_| capture_error("slice length overflow"))?; + let end = region.base.checked_add(len) + .ok_or_else(|| capture_error("slice VA overflow"))?; + if previous_end.is_some_and(|p| region.base < p) { + return Err(capture_error("overlapping captured slices")); + } + previous_end = Some(end); + } + Ok(Self { source_identity, regions }) + } + + pub fn source_identity(&self) -> [u8; 32] { + self.source_identity + } +} + +impl CapturedMemoryReader for SliceCapture<'_> { + fn read_captured(&mut self, address: u64, length: usize) -> Result, String> { + if length > MAX_SINGLE_READ as usize { + return Err("requested capture read exceeds 1 MiB".into()); + } + let end = address.checked_add(length as u64) + .ok_or_else(|| "requested capture address overflow".to_string())?; + let mut cursor = address; + let mut out = Vec::with_capacity(length); + while cursor < end { + let region = self.regions.iter().find(|r| { + cursor >= r.base && cursor - r.base < r.bytes.len() as u64 + }).ok_or_else(|| format!("no captured bytes at VA {cursor:#x}"))?; + let index = usize::try_from(cursor - region.base) + .map_err(|_| "slice index overflow".to_string())?; + let remaining = (end - cursor).min((region.bytes.len() - index) as u64); + let count = usize::try_from(remaining) + .map_err(|_| "slice read size overflow".to_string())?; + out.extend_from_slice(®ion.bytes[index..index + count]); + cursor = cursor.checked_add(remaining) + .ok_or_else(|| "capture cursor overflow".to_string())?; + } + Ok(out) + } +} + +const MAX_CAPTURED_TOTAL: u64 = 64 * 1024 * 1024; +const MAX_SINGLE_READ: u64 = 1024 * 1024; +const MAX_SELECTIONS: usize = 256; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CaptureRange { + pub address: u64, + pub length: u64, + pub readable: bool, + pub writable: bool, + pub executable: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptureBaseline { + /// Change only the current execution state, not its original replay base. + CurrentOnly, + /// Also update entry-state memory; registers/PC/OS context stay unchanged. + ReplayMemory, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaptureImportReport { + pub ranges: usize, + pub bytes: u64, + pub mapped_new_ranges: usize, + pub baseline: CaptureBaseline, +} + +fn capture_error(message: impl Into) -> RuntimeError { + RuntimeError::Environment(format!("capture overlay: {}", message.into())) +} + +fn region_placement( + memory: &PersistentMemory, + address: u64, + length: u64, +) -> Result { + let end = address.checked_add(length).ok_or_else(|| capture_error("VA overflow"))?; + let existing: Vec<&MemoryRegion> = memory.regions().iter() + .filter(|region| { + let Some(region_end) = region.base.checked_add(region.size) else { return true; }; + address < region_end && region.base < end + }) + .collect(); + if existing.is_empty() { + return Ok(true); // entire range unmapped; allocate exactly captured span + } + if existing.len() != 1 + || address < existing[0].base + || end > existing[0].base.saturating_add(existing[0].size) + { + return Err(capture_error("range partially overlaps existing mapping; refusing ambiguous overlay")); + } + Ok(false) +} + +/// Atomically import selected captured VA ranges from one minidump file. +/// The current Process entry_state is unchanged unless ReplayMemory is +/// explicitly selected. The caller must independently bind the dump and +/// executable to matching capture identity; this function cannot assert they +/// came from the same process or moment. +pub fn import_captured_memory( + process: &mut Process, + dump: &mut impl CapturedMemoryReader, + selections: &[CaptureRange], + baseline: CaptureBaseline, +) -> Result { + if selections.is_empty() || selections.len() > MAX_SELECTIONS { + return Err(capture_error("range count must be 1..=256")); + } + let mut total = 0u64; + let mut previous_end = None::; + let mut ordered = selections.to_vec(); + ordered.sort_by_key(|range| range.address); + for range in &ordered { + if range.length == 0 || !range.readable { + return Err(capture_error("nonempty readable ranges required")); + } + let end = range.address.checked_add(range.length) + .ok_or_else(|| capture_error("VA overflow"))?; + if previous_end.is_some_and(|prior| range.address < prior) { + return Err(capture_error("overlapping capture selections")); + } + previous_end = Some(end); + total = total.checked_add(range.length) + .ok_or_else(|| capture_error("size overflow"))?; + if total > MAX_CAPTURED_TOTAL { + return Err(capture_error("capture exceeds 64 MiB budget")); + } + } + + let mut staged = process.state.memory.clone(); + // Object IDs are persistent memory-layer identities; a second import + // must never reuse the same arbitrary ID for a different captured map. + let mut next_object_id = staged.regions().iter().map(|r| r.object.0).max() + .unwrap_or(0).checked_add(1) + .ok_or_else(|| capture_error("no free memory object identity"))?; + let mut new_regions = 0usize; + for range in &ordered { + let add_region = region_placement(&staged, range.address, range.length)?; + if add_region { + let object = ObjectId(next_object_id); + next_object_id = next_object_id.checked_add(1) + .ok_or_else(|| capture_error("memory object identity overflow"))?; + staged = staged.with_region(MemoryRegion { + object, + base: range.address, + size: range.length, + readable: range.readable, + writable: range.writable, + executable: range.executable, + }).map_err(|e| capture_error(format!("{e:?}")))?; + new_regions += 1; + } + let mut offset = 0u64; + while offset < range.length { + let count = (range.length - offset).min(MAX_SINGLE_READ); + let location = range.address.checked_add(offset) + .ok_or_else(|| capture_error("VA overflow"))?; + let bytes = dump.read_captured(location, count as usize) + .map_err(capture_error)?; + // No fabricated bytes; the source reader requires a complete + // captured range and reports the first missing byte. + staged = staged.load_concrete(location, &bytes) + .map_err(|e| capture_error(format!("{e:?}")))?; + offset += count; + } + } + + // Commit only after every source read and memory mapping has succeeded. + process.state.memory = staged.clone(); + if baseline == CaptureBaseline::ReplayMemory { + process.entry_state.memory = staged; + } + Ok(CaptureImportReport { + ranges: ordered.len(), + bytes: total, + mapped_new_ranges: new_regions, + baseline, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use angryier_arch::{DecodedInstruction, Decoder}; + use angryier_loader::{LoadedImage, Segment}; + use angryier_memory::{ByteValue, LayeredMemory}; + use angryier_types::{ImageId, SemanticVersion, TargetProfileId}; + use crate::Runtime; + + #[derive(Debug)] + struct NoDecode; + impl Decoder for NoDecode { + type Error = (); + fn decode(&self, _address: u64, _bytes: &[u8]) -> Result { + Err(()) + } + } + + fn make_process() -> Result { + let image = LoadedImage { + id: ImageId(1), + entry: 0x1000, + target_profile: TargetProfileId(1), + segments: vec![Segment { + address: 0x1000, + bytes: vec![0x90, 0xc3], + readable: true, + writable: false, + executable: true, + }], + symbols: Vec::new(), + program_headers: None, + dynamic: None, + pe: None, + }; + Runtime::new(NoDecode, SemanticVersion(1), TargetProfileId(1)) + .load_image(image) + .map_err(|error| error.to_string()) + } + + fn capture_file() -> Result { + let mut bytes = vec![0u8; 0x200]; + bytes[0..4].copy_from_slice(b"MDMP"); + bytes[8..12].copy_from_slice(&1u32.to_le_bytes()); + bytes[12..16].copy_from_slice(&0x40u32.to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(&9u32.to_le_bytes()); + bytes[0x44..0x48].copy_from_slice(&32u32.to_le_bytes()); + bytes[0x48..0x4c].copy_from_slice(&0x60u32.to_le_bytes()); + bytes[0x60..0x68].copy_from_slice(&1u64.to_le_bytes()); + bytes[0x68..0x70].copy_from_slice(&0x100u64.to_le_bytes()); + bytes[0x70..0x78].copy_from_slice(&0x60000000u64.to_le_bytes()); + bytes[0x78..0x80].copy_from_slice(&32u64.to_le_bytes()); + for i in 0..32 { + bytes[0x100 + i] = (0xa0 + i) as u8; + } + let filename = format!( + "angryier-capture-{}-{:?}.dmp", + std::process::id(), + std::thread::current().id() + ); + let path = std::env::temp_dir().join(filename); + std::fs::write(&path, &bytes).map_err(|error| error.to_string())?; + Ok(path) + } + + fn range(len: u64) -> CaptureRange { + CaptureRange { + address: 0x60000000, + length: len, + readable: true, + writable: true, + executable: false, + } + } + + #[test] + fn captured_heap_overlay_is_exact_and_replayable() -> Result<(), String> { + let path = capture_file()?; + let mut dump = MiniDumpFile::open(&path).map_err(|error| format!("{error:?}"))?; + let mut process = make_process()?; + let report = import_captured_memory( + &mut process, &mut dump, &[range(32)], CaptureBaseline::ReplayMemory, + ).map_err(|error| error.to_string())?; + assert_eq!(report.bytes, 32); + assert_eq!(report.mapped_new_ranges, 1); + let snapshot = process.state.memory.read(0x60000000, 32) + .map_err(|error| format!("{error:?}"))?; + assert_eq!(snapshot.first(), Some(&ByteValue::Concrete(0xa0))); + assert_eq!(snapshot.last(), Some(&ByteValue::Concrete(0xbf))); + process.reset_to_entry(); + assert_eq!( + process.state.memory.read(0x60000000, 32).map_err(|error| format!("{error:?}"))?, + snapshot, + ); + std::fs::remove_file(path).map_err(|error| error.to_string())?; + Ok(()) + } + + #[test] + fn independently_mapped_heap_slices_share_the_atomic_import_pipeline() -> Result<(), String> { + let left = [0xaau8; 16]; + let right = [0xbbu8; 16]; + let regions = vec![ + CapturedSlice { base: 0x60000010, bytes: &right }, + CapturedSlice { base: 0x60000000, bytes: &left }, + ]; + let mut capture = SliceCapture::new([0x42; 32], regions) + .map_err(|e| e.to_string())?; + assert_eq!(capture.source_identity(), [0x42; 32]); + assert_eq!( + capture.read_captured(0x60000008, 16).map_err(|e| e.to_string())?, + [&left[8..], &right[..8]].concat(), + ); + let mut process = make_process()?; + let report = import_captured_memory( + &mut process, &mut capture, &[range(32)], CaptureBaseline::ReplayMemory + ).map_err(|e| e.to_string())?; + assert_eq!(report.mapped_new_ranges, 1); + assert_eq!(report.bytes, 32); + let memory = process.state.memory.read(0x60000000, 32) + .map_err(|e| format!("{e:?}"))?; + assert!(memory[..16].iter().all(|b| *b == ByteValue::Concrete(0xaa))); + assert!(memory[16..].iter().all(|b| *b == ByteValue::Concrete(0xbb))); + Ok(()) + } + + #[test] + fn mapped_slices_reject_overlaps_and_uncaptured_gaps() -> Result<(), String> { + let first = [0x11u8; 8]; + let second = [0x22u8; 8]; + assert!(SliceCapture::new( + [1; 32], + vec![ + CapturedSlice { base: 0x2000, bytes: &first }, + CapturedSlice { base: 0x2004, bytes: &second }, + ] + ).is_err()); + let mut valid = SliceCapture::new( + [1; 32], + vec![ + CapturedSlice { base: 0x2000, bytes: &first }, + CapturedSlice { base: 0x2010, bytes: &second }, + ], + ).map_err(|e| e.to_string())?; + assert!(valid.read_captured(0x2004, 16).is_err()); + assert!(SliceCapture::new( + [0; 32], vec![CapturedSlice { base: 0x2000, bytes: &first }] + ).is_err()); + Ok(()) + } + + #[test] + fn unavailable_dump_bytes_cannot_partially_mutate_process() -> Result<(), String> { + let path = capture_file()?; + let mut dump = MiniDumpFile::open(&path).map_err(|error| format!("{error:?}"))?; + let mut process = make_process()?; + let before = process.state.memory.regions().len(); + assert!(import_captured_memory( + &mut process, &mut dump, &[range(48)], CaptureBaseline::ReplayMemory + ).is_err()); + assert_eq!(process.state.memory.regions().len(), before); + assert!(process.state.memory.read(0x60000000, 1).is_err()); + std::fs::remove_file(path).map_err(|error| error.to_string())?; + Ok(()) + } +} diff --git a/crates/angryier-runtime/src/function_summaries.rs b/crates/angryier-runtime/src/function_summaries.rs index 8fba349d..4e0a5f63 100644 --- a/crates/angryier-runtime/src/function_summaries.rs +++ b/crates/angryier-runtime/src/function_summaries.rs @@ -540,6 +540,11 @@ impl<'a, D: Decoder> SymbolicSession<'a, D> { let process = self.states.get(index)?; if process.process.simproc_hooks.contains_key(&target) || process.process.simproc_instances.contains_key(&target) + // A PE IAT stub is not a pure function, even when its first + // bytes happen to be MOV EAX,1; RET. Its effects belong to the + // imported API contract and must reach the runtime fidelity gate + // instead of being erased by lazy call summarization. + || process.process.pe_import_stubs.contains_key(&target) { return None; } diff --git a/crates/angryier-runtime/src/import_audit.rs b/crates/angryier-runtime/src/import_audit.rs new file mode 100644 index 00000000..ceeb9095 --- /dev/null +++ b/crates/angryier-runtime/src/import_audit.rs @@ -0,0 +1,89 @@ +//! PE IAT stub fidelity classification. +//! +//! A synthetic RET is a *call/return mechanism*, not a Windows API model. +//! Critical user-mode imports must not return through the stub in PROVE or +//! exploratory modes without a registered, explicit model. The deterministic +//! bool surrogate for IsProcessorFeaturePresent is reported as approximate. + +use angryier_types::Address; + +use crate::Process; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImportBehavior { + /// An analyst or host explicitly installed a call-correct SimProcedure. + ExplicitHook, + /// A known hand-coded scalar response; not guaranteed real Windows. + ApproximateScalar, + /// A userland API whose side effects are absent from the synthetic RET. + UnsupportedUserland, + /// Synthetic RET with no modeled effects; not equivalent to an API. + SyntheticReturn, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportFinding { + pub stub: Address, + pub dll: String, + pub export: String, + pub behavior: ImportBehavior, +} + +/// These DLL families are not valid native PE-driver imports. Treating calls +/// as no-op RETs can silently skip thread creation, synchronization, memory +/// and graphics initialization. Name matching follows Windows DLL semantics; +/// ordinal imports in these libraries are also unmodeled. +pub fn is_userland_dll(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "kernel32.dll" | "kernelbase.dll" | "ntdll.dll" | + "user32.dll" | "gdi32.dll" | "gdi32full.dll" | + "advapi32.dll" | "ole32.dll" | "combase.dll" | + "ws2_32.dll" | "ucrtbase.dll" + ) +} + +pub fn classify(dll: &str, export: &str, explicit_hook: bool) -> ImportBehavior { + if explicit_hook { + return ImportBehavior::ExplicitHook; + } + if dll.eq_ignore_ascii_case("kernel32.dll") && export == "IsProcessorFeaturePresent" { + return ImportBehavior::ApproximateScalar; + } + if is_userland_dll(dll) { + ImportBehavior::UnsupportedUserland + } else { + ImportBehavior::SyntheticReturn + } +} + +pub fn inspect_imports(process: &Process) -> Vec { + process + .pe_import_stubs + .iter() + .map(|(&stub, (dll, export))| ImportFinding { + stub, + dll: dll.clone(), + export: export.clone(), + behavior: classify(dll, export, process.simproc_instances.contains_key(&stub)), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stateful_api_imports_never_claim_stub_equivalence() { + assert_eq!(classify("KERNEL32.dll", "CreateThread", false), ImportBehavior::UnsupportedUserland); + assert_eq!(classify("kernelbase.dll", "WaitForSingleObject", false), ImportBehavior::UnsupportedUserland); + assert_eq!(classify("gdi32.dll", "GdiplusStartup", false), ImportBehavior::UnsupportedUserland); + assert_eq!(classify("KERNEL32.dll", "CreateThread", true), ImportBehavior::ExplicitHook); + assert_eq!(classify("ntoskrnl.exe", "DbgPrint", false), ImportBehavior::SyntheticReturn); + assert_eq!( + classify("kernel32.dll", "IsProcessorFeaturePresent", false), + ImportBehavior::ApproximateScalar, + ); + } +} diff --git a/crates/angryier-runtime/src/lib.rs b/crates/angryier-runtime/src/lib.rs index 6b221837..db8179b3 100644 --- a/crates/angryier-runtime/src/lib.rs +++ b/crates/angryier-runtime/src/lib.rs @@ -15,6 +15,13 @@ //! The runtime is generic over the decoder backend so it works with both //! native XED (behind feature gates) and synthetic decoders for testing. +pub mod import_audit; +pub mod capture; +pub mod call_audit; +pub mod vm_observe; +pub mod vm_materialize; +pub mod vm_replay; + use std::collections::BTreeMap; use std::time::Duration; @@ -205,6 +212,44 @@ where hash } +/// Recover a return address from the canonical RSP cell, or at most one +/// nearby executable candidate when the canonical cell is unusable. +/// +/// The latter is a diagnostic fallback for a desynchronized symbolic +/// stack, NOT a proof of the call/return relationship. An ambiguous window +/// deliberately yields None instead of choosing an arbitrary nearest code +/// pointer. The caller must record Concretized debt on any such recovery. +fn select_bounded_stack_return( + rsp: Address, + mut read: Read, + valid: Valid, +) -> Option<(Address, Address)> +where + Read: FnMut(Address) -> Option
, + Valid: Fn(Address) -> bool, +{ + if let Some(value) = read(rsp).filter(|&target| valid(target)) { + return Some((rsp, value)); + } + const OFFSETS: [i64; 16] = [ + -0x40, -0x38, -0x30, -0x28, -0x20, -0x18, -0x10, -0x08, + 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38, 0x40, + ]; + let mut unique: Option<(Address, Address)> = None; + for delta in OFFSETS { + let Some(addr) = rsp.checked_add_signed(delta) else { + continue; + }; + if let Some(target) = read(addr).filter(|&value| valid(value)) { + if unique.is_some() { + return None; + } + unique = Some((addr, target)); + } + } + unique +} + /// Default whole-run wall-clock budget shared by the stable Rust API and /// scripting/CLI frontends. Keeping this below the script feature boundary /// prevents public surfaces from silently drifting. @@ -578,6 +623,55 @@ impl Process { self.terminated = false; } + /// Return an explicit error when a synthetic return-only stub would be + /// mistaken for a user-mode Windows API implementation. This helper is + /// shared by the native interpreter and full symbolic engine. + pub fn unmodeled_import_error(&self, pc: Address) -> Option { + if self.simproc_instances.contains_key(&pc) || self.redirects.contains_key(&pc) { + return None; + } + let (dll, export) = self.pe_import_stubs.get(&pc)?; + (import_audit::classify(dll, export, false) + == import_audit::ImportBehavior::UnsupportedUserland) + .then(|| RuntimeError::Environment(format!( + "unmodeled user-mode import {dll}!{export} at synthetic stub {pc:#x}; no Win32 API effects or worker scheduling occurred" + ))) + } + + /// Mark an explicitly approximate PE API return in the state fidelity + /// ledger. This is de-duplicated per PC to avoid unbounded ledger growth + /// on a tight loop through the same simulated import. + fn record_approximate_import(&mut self, pc: Address) { + if self.simproc_instances.contains_key(&pc) || self.redirects.contains_key(&pc) { + return; + } + let Some((dll, export)) = self.pe_import_stubs.get(&pc) else { + return; + }; + if import_audit::classify(dll, export, false) + != import_audit::ImportBehavior::ApproximateScalar + || self.state.fidelity.entries.iter().any(|entry| { + entry.kind == AnalysisDebtKind::Concretized && entry.source == pc + }) + { + return; + } + self.state.fidelity = self.state.fidelity.record(AnalysisDebtKind::Concretized, pc); + } + + /// Whether the target is executable in the current mapped address + /// space, or has an explicit SimProcedure/exit hook. No per-application + /// address constants: mappings and hook registrations are authoritative. + /// + /// This is a necessary condition for native control-flow validity, not + /// proof that bytes at the address decode to a reachable instruction. + pub fn is_executable_control_target(&self, target: Address) -> bool { + self.state.memory.regions().iter().any(|region| { + region.executable && target >= region.base && target - region.base < region.size + }) || self.simproc_hooks.contains_key(&target) + || self.simproc_instances.contains_key(&target) + } + /// Reads the current program counter (RIP). pub fn pc(&self) -> Result { read_register_u64(&self.state.registers, register_id::RIP.0) @@ -1790,6 +1884,32 @@ impl Runtime { }) } + /// Analyze native VM-dispatch candidates within a bounded executable + /// window. This opt-in scan does not claim virtual-PC recovery or execute + /// any discovered indirect target; confidence is structural only. + pub fn inspect_vm_window( + &self, + process: &Process, + entry: Address, + max_bytes: u64, + ) -> Result { + let cfg = self.recover_cfg_window(process, entry, max_bytes)?; + Ok(angryier_cfg::vm::inspect_native_cfg(&cfg)) + } + + /// Build a source-backed callsite and RIP-relative reference index from + /// the bounded CFG. Unknown indirect targets remain unresolved; imported + /// call slots refer to the IAT location, not a speculative callee. + pub fn inspect_xrefs_window( + &self, + process: &Process, + entry: Address, + max_bytes: u64, + ) -> Result { + let cfg = self.recover_cfg_window(process, entry, max_bytes)?; + Ok(angryier_cfg::xrefs::XrefIndex::from_cfg(&cfg)) + } + /// Recovers the image CFG and extracts pure induction loops — a /// straight-line body (one block, or a chain of blocks linked only by /// unconditional jumps or adjacency) whose only state effects are @@ -2399,6 +2519,12 @@ impl Runtime { // This includes syscalls, CPUID, SimProcedures and terminating blocks. process.record_trace_pc(pc); + // Identical Win32 API fidelity gate for concrete and symbolic paths. + if let Some(error) = process.unmodeled_import_error(pc) { + return Err(error); + } + process.record_approximate_import(pc); + // Control-flow redirects: divert execution to target PC before SimProcedure // or instruction decode. if let Some(&target) = process.redirects.get(&pc) { @@ -3439,6 +3565,12 @@ impl Runtime { process.simproc_dispatches += 1; match result { + SimResult::Blocked { handle, timeout_ms } => Err(RuntimeError::Environment(format!( + "Win32 {name} blocked on {handle:#x} (timeout={timeout_ms}); a thread scheduler or observed wakeup is required" + ))), + SimResult::Unsupported(reason) => Err(RuntimeError::Environment(format!( + "Win32 {name} unsupported: {reason}" + ))), SimResult::Return(val) => { // Write return value to RAX (GPR 0). process.state.registers = process @@ -3524,25 +3656,38 @@ impl Runtime { sim_state.set_reg(u64::from(index), u64::from_le_bytes(buf)); } } - // Stack-argument shadowing: mirror [rsp .. rsp+0x40) (return - // address + 7 stack-argument slots) into the SimState shadow so - // stack-passed OUT/IN parameters (5th argument and beyond, e.g. - // IoCreateDevice's pptrDeviceObject at [rsp+0x38]) are readable - // through SimState::read_bytes. 5th arg = [rsp+0x08] ... 11th = - // [rsp+0x38]; slot 0 is the return address, also useful. + // Stack-argument shadowing: read Windows x64 shadow space at + // [rsp+0x08..0x27], followed by the fifth argument at [rsp+0x28]. + // Retain only observed concrete runs: a symbolic argument must not + // be silently transformed into an apparently known zero. The caller + // can then use try_win64_arg to fail closed on partial evidence. { let rsp = sim_state.get_reg(4); if rsp != 0 - && let Ok(bytes) = process.state.memory.read(rsp, 0x40) + && let Ok(bytes) = process.state.memory.read(rsp, 0x60) { - let concrete: Vec = bytes - .iter() - .map(|b| match b { - ByteValue::Concrete(v) => *v, - ByteValue::Symbolic(_) => 0, - }) - .collect(); - sim_state.write_memory(rsp, concrete); + let mut segment_start: Option = None; + let mut segment = Vec::new(); + for (offset, byte) in bytes.iter().enumerate() { + match byte { + ByteValue::Concrete(value) => { + if segment_start.is_none() { + segment_start = rsp.checked_add(offset as u64); + } + if segment_start.is_some() { + segment.push(*value); + } + } + ByteValue::Symbolic(_) => { + if let Some(base) = segment_start.take() { + sim_state.shadow_memory(base, std::mem::take(&mut segment)); + } + } + } + } + if let Some(base) = segment_start { + sim_state.shadow_memory(base, segment); + } } } @@ -3566,6 +3711,12 @@ impl Runtime { process.simproc_dispatches += 1; match result { + SimResult::Blocked { handle, timeout_ms } => Err(RuntimeError::Environment(format!( + "Win32 {name} blocked on {handle:#x} (timeout={timeout_ms}); no wakeup schedule was supplied" + ))), + SimResult::Unsupported(reason) => Err(RuntimeError::Environment(format!( + "Win32 {name} unsupported: {reason}" + ))), SimResult::Exit => { process.terminated = true; Ok(StepOutcome::SimProcedure { address, name }) @@ -5454,6 +5605,150 @@ mod tests { Ok(()) } + #[test] + fn bounded_return_recovery_rejects_ambiguous_or_overflowing_candidates() { + let rsp = 0x1000u64; + let candidates = [ + (rsp, 0x2000), + (rsp + 8, 0x3000), + (rsp + 0x10, 0x4000), + ]; + let read = |addr| candidates.iter().find(|(a, _)| *a == addr).map(|(_, value)| *value); + let mapped = |target| matches!(target, 0x3000 | 0x4000); + assert_eq!(select_bounded_stack_return(rsp, read, mapped), None); + assert_eq!( + select_bounded_stack_return(rsp, read, |value| value == 0x3000), + Some((rsp + 8, 0x3000)), + ); + assert_eq!( + select_bounded_stack_return(rsp, read, |value| value == 0x2000), + Some((rsp, 0x2000)), + ); + // Negative offsets around address zero are skipped, never wrapped + // into the high canonical address range. + assert_eq!(select_bounded_stack_return(0, |_| None, |_| true), None); + } + + #[test] + fn control_target_validation_uses_dynamic_regions_and_hooks() -> Result<(), RuntimeError> { + let runtime = Runtime::new(SyntheticDecoder::new(), SemanticVersion(1), TargetProfileId(1)); + let mut process = minimal_process()?; + assert!(!process.is_executable_control_target(0x1404_04e10)); + assert!(!process.is_executable_control_target(0x6)); + process.state.memory = process.state.memory.with_region(angryier_memory::MemoryRegion { + object: angryier_types::ObjectId(0x8765), + base: 0x3000_0000, + size: 0x4000, + readable: true, + writable: false, + executable: true, + }).map_err(|e| RuntimeError::Memory(format!("{e:?}")))?; + assert!(process.is_executable_control_target(0x3000_0000)); + assert!(process.is_executable_control_target(0x3000_3fff)); + assert!(!process.is_executable_control_target(0x3000_4000)); + process.hook_simproc(0x7000_1234, "exit"); + assert!(process.is_executable_control_target(0x7000_1234)); + process.stub_function(0x7000_4567, 1); + assert!(process.is_executable_control_target(0x7000_4567)); + // No implicit assumption about the previous Sarin image base. + let _ = runtime; + Ok(()) + } + + #[test] + fn unmodeled_create_thread_stub_does_not_fake_a_return() -> Result<(), RuntimeError> { + let runtime = Runtime::new(SyntheticDecoder::new(), SemanticVersion(1), TargetProfileId(1)); + let mut process = minimal_process()?; + let stub = 0x7000_0000_4140u64; + process.pe_import_stubs.insert( + stub, + ("kernel32.dll".to_string(), "CreateThread".to_string()), + ); + process.write_pc(stub)?; + let rsp_before = process.read_register(register_id::GPR_BASE + 4)?; + let result = runtime.step(&mut process); + assert!(matches!(result, Err(RuntimeError::Environment(ref message)) + if message.contains("CreateThread") && message.contains("worker scheduling"))); + assert_eq!(process.pc()?, stub); + assert_eq!(process.read_register(register_id::GPR_BASE + 4)?, rsp_before); + assert_eq!( + import_audit::inspect_imports(&process)[0].behavior, + import_audit::ImportBehavior::UnsupportedUserland + ); + Ok(()) + } + + #[test] + fn synthetic_success_stub_records_bounded_approximation_debt() -> Result<(), RuntimeError> { + let mut process = minimal_process()?; + let stub = 0x7000_0000_4140; + process.pe_import_stubs.insert( + stub, + ("kernel32.dll".into(), "IsProcessorFeaturePresent".into()), + ); + assert!(process.state.fidelity.is_exact()); + process.record_approximate_import(stub); + assert!(!process.state.fidelity.is_exact()); + assert_eq!(process.state.fidelity.entries.len(), 1); + assert_eq!(process.state.fidelity.entries[0].source, stub); + process.record_approximate_import(stub); + assert_eq!(process.state.fidelity.entries.len(), 1); + process.stub_function(stub, 0); + process.record_approximate_import(stub); + assert_eq!(process.state.fidelity.entries.len(), 1); + Ok(()) + } + + #[test] + fn imported_userland_api_guard_applies_to_symbolic_and_concrete_modes() -> Result<(), RuntimeError> { + let runtime = Runtime::new(SyntheticDecoder::new(), SemanticVersion(1), TargetProfileId(1)); + let mut process = minimal_process()?; + let stub = 0x7000_0000_4140u64; + process.pe_import_stubs.insert( + stub, ("kernel32.dll".to_string(), "CreateThread".to_string()), + ); + process.write_pc(stub)?; + let arena = angryier_expr::ShardedExprArena::new(angryier_types::ExpressionNormalizationVersion(1)); + let mut session = SymbolicSession::new(&runtime, &arena, process.clone()); + let outcome = session.step_state(0); + assert!(matches!(outcome, Err(RuntimeError::Environment(ref message)) if message.contains("CreateThread"))); + assert!(matches!(runtime.step(&mut process), Err(RuntimeError::Environment(ref message)) if message.contains("CreateThread"))); + process.stub_function(stub, 0x1234); + assert!(process.unmodeled_import_error(stub).is_none()); + Ok(()) + } + + #[test] + fn audited_simprocedure_return_matches_stack_contract() -> Result<(), RuntimeError> { + let runtime = Runtime::new(SyntheticDecoder::new(), SemanticVersion(1), TargetProfileId(1)); + let mut process = minimal_process()?; + let region = angryier_memory::MemoryRegion { + object: angryier_types::ObjectId(0x81), + base: 0x1000, + size: 0x10000, + readable: true, + writable: true, + executable: true, + }; + process.state.memory = process.state.memory.with_region(region) + .map_err(|error| RuntimeError::Memory(format!("{error:?}")))?; + let rsp = 0x8000u64; + process.write_register(register_id::GPR_BASE + 4, rsp)?; + process.state.memory = process.state.memory + .load_concrete(rsp, &0x1040u64.to_le_bytes()) + .map_err(|error| RuntimeError::Memory(format!("{error:?}")))?; + let stub = 0x2000u64; + process.write_pc(stub)?; + process.stub_function(stub, 0x1337); + let audit = runtime.step_audited(&mut process)?; + assert!(matches!(audit.outcome, Ok(StepOutcome::SimProcedure { .. }))); + assert_eq!(audit.return_validation, call_audit::ReturnValidation::Verified); + assert_eq!(audit.before.stack_top, Some(0x1040)); + assert_eq!(audit.after.pc, 0x1040); + assert_eq!(audit.after.rsp, Some(rsp + 8)); + Ok(()) + } + #[test] fn stub_function_pops_return_address_and_sets_rax() -> Result<(), RuntimeError> { let decoder = SyntheticDecoder::new(); @@ -6902,6 +7197,14 @@ impl<'a, D: Decoder> SymbolicSession<'a, D> { } } + // A symbolic interpreter must not bypass the same import fidelity + // gate that concrete Runtime::step enforces. Previous runs decoded + // a synthetic MOV EAX,1 / RET here and silently lost worker effects. + if let Some(error) = self.states[index].process.unmodeled_import_error(pc) { + return Err(error); + } + self.states[index].process.record_approximate_import(pc); + // Loop summarization: a summarized header with concrete inputs // collapses its remaining iterations into one step. if let Some(&summary) = self.loop_summaries.get(&pc) @@ -7849,41 +8152,18 @@ impl<'a, D: Decoder> SymbolicSession<'a, D> { state.process.read_register(register_id::GPR_BASE + 4).ok() }); let cell = rsp.and_then(|rsp| { - let read_cell = |addr: u64| -> Option { - state - .memory - .read(self.arena, addr, 64) - .ok() - .and_then(|expr| { - angryier_execution::constant_value(self.arena, expr).ok() - }) - }; - let direct = read_cell(rsp).filter(|v| { - (0x140001000..0x1419A3000).contains(v) - || (0x1437F5000..0x1437F6000).contains(v) - }); - direct.map(|value| (rsp, value)).or_else(|| { - let mut best: Option<(i64, u64, u64)> = None; - for delta in [-0x40i64, -0x38, -0x30, -0x28, -0x20, -0x18, -0x10, -0x8, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38, 0x40] { - let addr = (rsp as i64 + delta) as u64; - if let Some(value) = read_cell(addr).filter(|v| { - (0x140001000..0x1419A3000).contains(v) - || (0x1437F5000..0x1437F6000).contains(v) - }) { - let distance = delta.abs(); - if best.map(|(d, _, _)| distance < d).unwrap_or(true) { - best = Some((distance, addr, value)); - } - } - } - best.map(|(_, addr, value)| (addr, value)) - }) + select_bounded_stack_return( + rsp, + |addr| { + state.memory.read(self.arena, addr, 64).ok().and_then( + |expr| angryier_execution::constant_value(self.arena, expr).ok(), + ) + }, + |address| state.process.is_executable_control_target(address), + ) }); let is_valid_target = target.map(|t| { - (0x140001000..0x1419A3000).contains(&t) - || (0x1437F5000..0x1437F6000).contains(&t) - || (PE_DRIVER_STUB_BASE..PE_DRIVER_STUB_BASE + PE_DRIVER_STUB_SIZE).contains(&t) - || t == EXIT_HOOK + state.process.is_executable_control_target(t) }).unwrap_or(false); if !is_valid_target && let Some((cell_rsp, cell_value)) = cell { @@ -7938,9 +8218,7 @@ impl<'a, D: Decoder> SymbolicSession<'a, D> { // callee as one that immediately returns 0 — // pop the frame the IR pushed, rax = 0, and // record Concretized debt; never execute data. - let exec = state.process.state.memory.regions().iter().any(|r| { - target >= r.base && target < r.base + r.size && r.executable - }); + let exec = state.process.is_executable_control_target(target); if !exec { state.process.state.fidelity = state .process diff --git a/crates/angryier-runtime/src/vm_materialize.rs b/crates/angryier-runtime/src/vm_materialize.rs new file mode 100644 index 00000000..170804d6 --- /dev/null +++ b/crates/angryier-runtime/src/vm_materialize.rs @@ -0,0 +1,326 @@ +//! Materialize observed multi-block AngryIR SSA effects into Angryier ExprIds. +//! +//! Reuses SymbolicEvaluator's actual block implementation, seeded from the +//! preserved pre-handler COW execution state. No synthetic VM semantics and +//! no conversion of IrValueId numerics to ExprId IDs. +//! +//! The result contains ordered register/memory/dispatch effects, chosen +//! branch predicates, potentially faulting memory reads, and simplification. +//! It is an *unverified candidate summary*; differential replay/SMT +//! equivalence remains mandatory before substituting it for execution. + +use std::collections::BTreeMap; + +use angryier_arch_intel64::register_id; +use angryier_execution::{ + SymbolicArena, SymbolicEvaluator, SymbolicSessionMemory, SymbolicStateSnapshot, +}; +use angryier_expr::{ExprArena, vm_effects::{reduce_vm_effects, VmEffect, VmEffectKind, VmReducedEffects}}; +use angryier_ir::{ + IrOp, IrType, IrValueId, + vm_footprint::{VmIrEffect, VmIrSource}, + vm_trace::{collect_observed_vm_trace, VmObservedBlock, VmScopedValue, VmTraceCaveat}, +}; +use angryier_state::RegisterState; +use angryier_types::ExprId; + +use crate::vm_observe::VmHandlerCapture; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VmMaterializationError { + InvalidCapture(&'static str), + MissingInitialRegister { register: u32 }, + UnsupportedRegisterWidth { register: u32, bytes: usize }, + MissingExternalExpr { expression: ExprId }, + MissingSsa { step: usize, value: IrValueId }, + UnsupportedValueType { step: usize, value: IrValueId, ty: IrType }, + UnsupportedPartialRegister { step: usize, register: u32 }, + MultipleWritesToRegister { step: usize, register: u32 }, + InconsistentRegisterWriteTrace { step: usize, register: u32 }, + MissingBranchEvidence { step: usize }, + SymbolicEvaluation { step: usize, reason: String }, + UnderconstrainedSemanticDebt { count: u64 }, + Reduction(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmMaterializedRead { + pub step: usize, + pub address: ExprId, + pub mask: Option, + pub result: ExprId, + pub width_bytes: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmMaterializedBranch { + pub step: usize, + pub condition: ExprId, + pub taken: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmMaterializedCandidate { + /// Full pre-reduction effects (ordered). No store may be discarded. + pub original_effects: Vec, + /// Original trace step corresponding to every ordered effect. + pub effect_steps: Vec, + /// Algebraically reduced version and non-negated branch-condition roots. + pub reduced: VmReducedEffects, + /// Loads kept even when the corresponding result is otherwise dead. + /// The replay gate must separately validate fault/alias behavior. + pub potentially_faulting_reads: Vec, + /// Both the expression and the observed choice are retained: false + /// branches must not be mistaken for true branch assumptions. + pub chosen_branches: Vec, + pub input_had_fidelity_debt: bool, + pub caveats: Vec, +} + +fn value_at( + values: &[Option<(ExprId, IrType)>], + step: usize, + value: IrValueId, +) -> Result<(ExprId, IrType), VmMaterializationError> { + values.get(value.0 as usize).and_then(|item| *item) + .ok_or(VmMaterializationError::MissingSsa { step, value }) +} + +fn byte_width(ty: IrType) -> Option { + let bits = match ty { + IrType::Bits(w) | IrType::Vector { width_bits: w, .. } + | IrType::Opmask { width_bits: w } => u32::from(w), + IrType::Float16 | IrType::BFloat16 => 16, + IrType::Float32 => 32, + IrType::Float64 => 64, + IrType::Float80 => 80, + IrType::Tile => 8192, + }; + (bits > 0).then(|| bits.div_ceil(8)) +} + +/// Convert a captured and structurally checked handler path to candidate +/// expressions, using only the existing evaluator and input COW state. +/// +/// The evaluator emits a post-write expression for EVERY register effect, +/// including repeated writes and partial-parent preservation. Those effects +/// are matched back to the original instruction index; a mismatch fails +/// closed rather than using an unrelated final-register expression. +/// +/// No `PROVE` claim: the output is still path-specific, may contain +/// uninterpreted input symbols, and has not passed independent replay/SMT. +pub fn materialize_vm_handler( + capture: &VmHandlerCapture, + arena: &SymbolicArena, + max_expression_nodes: usize, +) -> Result { + if capture.steps.is_empty() + || capture.steps.len() != capture.footprint.edges.len() + || max_expression_nodes == 0 + { + return Err(VmMaterializationError::InvalidCapture("missing steps/edges or zero expression budget")); + } + // Caller-visible capture structures are mutable. Revalidate the entire + // scoped IR footprint against the pinned trace window before trusting + // its effect/source lists or evaluating any potentially faulting loads. + let replay_steps: Vec> = capture.steps.iter().map(|step| VmObservedBlock { + block: &step.block, + before: step.before, + after: step.after, + code_epoch: step.code_epoch, + }).collect(); + let verified = collect_observed_vm_trace( + capture.window.identity, + capture.window.entry, + capture.window.exit, + &replay_steps, + capture.window.limits, + ).map_err(|_| VmMaterializationError::InvalidCapture("trace identity/IR revalidation failed"))?; + if verified != capture.footprint { + return Err(VmMaterializationError::InvalidCapture("capture footprint differs from recorded IR")); + } + let rip = capture.initial_state.registers.read(register_id::RIP.0) + .map_err(|_| VmMaterializationError::InvalidCapture("entry RIP unavailable"))?; + if rip.as_slice() != capture.window.entry.native_pc.to_le_bytes() { + return Err(VmMaterializationError::InvalidCapture("initial-state RIP differs from captured handler entry")); + } + + let mut seeded_registers = BTreeMap::new(); + for source in &capture.footprint.sources { + match source.source { + VmIrSource::Register(register) => { + if seeded_registers.contains_key(®ister) { + continue; + } + let bytes = capture.initial_state.registers.read(register) + .map_err(|_| VmMaterializationError::MissingInitialRegister { register })?; + if bytes.is_empty() || bytes.len() > 8 { + return Err(VmMaterializationError::UnsupportedRegisterWidth { + register, bytes: bytes.len(), + }); + } + let mut raw = [0u8; 8]; + raw[..bytes.len()].copy_from_slice(&bytes); + seeded_registers.insert(register, u64::from_le_bytes(raw)); + } + VmIrSource::Expression(expression) => { + if arena.get(expression).is_none() { + return Err(VmMaterializationError::MissingExternalExpr { expression }); + } + } + VmIrSource::MemoryRead(_) => {} + } + } + // Seed RSP so the evaluator uses the actual stack even when the static + // footprint doesn't contain an explicit RSP read (e.g. a callee model). + const RSP: u32 = register_id::GPR_BASE + 4; + if !seeded_registers.contains_key(&RSP) { + let bytes = capture.initial_state.registers.read(RSP) + .map_err(|_| VmMaterializationError::MissingInitialRegister { register: RSP })?; + if bytes.len() != 8 { + return Err(VmMaterializationError::UnsupportedRegisterWidth { register: RSP, bytes: bytes.len() }); + } + let mut raw = [0u8; 8]; + raw.copy_from_slice(&bytes); + seeded_registers.insert(RSP, u64::from_le_bytes(raw)); + } + + let mut evaluator = SymbolicEvaluator::new(arena); + evaluator.restore(&SymbolicStateSnapshot { + registers: BTreeMap::new(), + concrete_registers: seeded_registers, + constraints: Vec::new(), + symbols: Vec::new(), + expr_concrete: BTreeMap::new(), + }); + let mut memory = SymbolicSessionMemory::new(capture.initial_state.memory.clone()); + let mut effects = Vec::new(); + let mut effect_steps = Vec::new(); + let mut reads = Vec::new(); + let mut branches = Vec::new(); + let mut guards = Vec::new(); + + for (step_index, recorded) in capture.steps.iter().enumerate() { + match recorded.outcome { + crate::StepOutcome::Stepped { pc, next_pc, .. } + if pc == recorded.before.native_pc && next_pc == recorded.after.native_pc => {} + _ => return Err(VmMaterializationError::InvalidCapture( + "recorded execution outcome does not match native transition", + )), + } + if recorded.before != capture.footprint.edges[step_index].from + || recorded.after != capture.footprint.edges[step_index].to + { + return Err(VmMaterializationError::InvalidCapture("footprint/recorded-step identity mismatch")); + } + let evaluation = evaluator.eval_block_with_memory_values(&recorded.block, &mut memory) + .map_err(|error| VmMaterializationError::SymbolicEvaluation { + step: step_index, + reason: error.to_string(), + })?; + + let mut write_cursor = 0usize; + for scoped in capture.footprint.effects.iter().filter(|e| e.step == step_index) { + match scoped.effect { + VmIrEffect::MemoryWrite { address, value } => { + let (addr, _) = value_at(&evaluation.values, step_index, address)?; + let (val, ty) = value_at(&evaluation.values, step_index, value)?; + let bytes = byte_width(ty).ok_or(VmMaterializationError::UnsupportedValueType { + step: step_index, value, ty, + })?; + effect_steps.push(step_index); + effects.push(VmEffect { + kind: VmEffectKind::Memory { address: addr, bytes }, + value: val, + }); + } + VmIrEffect::RegisterWrite { register, value, kind } => { + // Consume the actual post-write value, not the block's + // FINAL register value. A handler can write RFLAGS/RSP + // or a VM-context register dozens of times per block. + let trace = evaluation.register_writes.get(write_cursor) + .ok_or(VmMaterializationError::InconsistentRegisterWriteTrace { + step: step_index, register, + })?; + write_cursor += 1; + if !matches!( + recorded.block.instructions.get(trace.instruction_index) + .map(|instruction| &instruction.op), + Some(IrOp::WriteRegister { + register: actual_register, + value: actual_value, + kind: actual_kind, + }) if *actual_register == register && *actual_value == value + && *actual_kind == kind && trace.register == register + ) { + return Err(VmMaterializationError::InconsistentRegisterWriteTrace { + step: step_index, register, + }); + } + effect_steps.push(step_index); + effects.push(VmEffect { + kind: VmEffectKind::Register { register }, + value: trace.expression, + }); + } + VmIrEffect::MemoryRead { address, mask, result } => { + let (addr, _) = value_at(&evaluation.values, step_index, address)?; + let (value, ty) = value_at(&evaluation.values, step_index, result)?; + let width_bytes = byte_width(ty).ok_or( + VmMaterializationError::UnsupportedValueType { + step: step_index, value: result, ty, + }, + )?; + let mask = match mask { + Some(v) => Some(value_at(&evaluation.values, step_index, v)?.0), + None => None, + }; + reads.push(VmMaterializedRead { + step: step_index, address: addr, mask, + result: value, width_bytes, + }); + } + VmIrEffect::Branch { condition, .. } => { + let (expr, _) = value_at(&evaluation.values, step_index, condition)?; + let taken = capture.footprint.chosen_branches.iter().find_map(|(value, direction)| { + (*value == VmScopedValue { step: step_index, value: condition }) + .then_some(*direction) + }).ok_or(VmMaterializationError::MissingBranchEvidence { step: step_index })?; + branches.push(VmMaterializedBranch { step: step_index, condition: expr, taken }); + guards.push(expr); + } + VmIrEffect::IndirectJump { target } => { + let (expr, _) = value_at(&evaluation.values, step_index, target)?; + effect_steps.push(step_index); + effects.push(VmEffect { kind: VmEffectKind::DispatchTarget, value: expr }); + } + VmIrEffect::Jump { .. } | VmIrEffect::Call { .. } + | VmIrEffect::Return | VmIrEffect::Trap { .. } => {} + } + } + if write_cursor != evaluation.register_writes.len() { + return Err(VmMaterializationError::InvalidCapture( + "unconsumed ordered register writes after materialization" + )); + } + } + if evaluator.debt_total() > 0 { + return Err(VmMaterializationError::UnderconstrainedSemanticDebt { + count: evaluator.debt_total(), + }); + } + if effects.is_empty() && guards.is_empty() { + return Err(VmMaterializationError::InvalidCapture("no effect/guard roots to reduce")); + } + let reduced = reduce_vm_effects(arena, &effects, &guards, max_expression_nodes) + .map_err(|error| VmMaterializationError::Reduction(format!("{error:?}")))?; + Ok(VmMaterializedCandidate { + original_effects: effects, + effect_steps, + reduced, + potentially_faulting_reads: reads, + chosen_branches: branches, + input_had_fidelity_debt: !capture.initial_state.fidelity.is_exact(), + caveats: capture.footprint.caveats.clone(), + }) +} diff --git a/crates/angryier-runtime/src/vm_observe.rs b/crates/angryier-runtime/src/vm_observe.rs new file mode 100644 index 00000000..2d0b271f --- /dev/null +++ b/crates/angryier-runtime/src/vm_observe.rs @@ -0,0 +1,365 @@ +//! Opt-in VM-handler observation over Angryier's existing native execution. +//! +//! The caller supplies a trusted VPC/context observer and a pinned identity; +//! this module does not identify, guess or decrypt a virtual instruction. +//! `step_with` captures the same AngryIR the concrete interpreter executes. +//! No SimProcedure or redirect can silently stand in for a native handler: +//! those paths have no captured instruction block and fail explicitly. +//! +//! The process is advanced by the underlying runtime; failed observations +//! do NOT rewind the process. To retry deterministically, start from a +//! verified Process checkpoint and keep the capture identity unchanged. + +use angryier_arch::Decoder; +use angryier_ir::{ + IrBlock, + vm_trace::{ + collect_observed_vm_trace, VmObservedBlock, VmObservedLocation, VmTraceFootprint, + VmTraceIdentity, VmTraceLimits, + }, +}; + +use crate::{Process, Runtime, RuntimeError, StepOutcome}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmStateObservation { + pub virtual_pc: u64, + /// Digest of the caller-identified VM context state. + pub context_digest: [u8; 32], + /// Executable-code version epoch supplied by the observer. + pub code_epoch: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VmHandlerWindow { + pub entry: VmObservedLocation, + pub exit: VmObservedLocation, + pub identity: VmTraceIdentity, + pub limits: VmTraceLimits, +} + +#[derive(Clone, Debug)] +pub struct VmRecordedInstruction { + pub block: IrBlock, + pub before: VmObservedLocation, + pub after: VmObservedLocation, + pub code_epoch: u64, + pub outcome: StepOutcome, +} + +#[derive(Clone, Debug)] +pub struct VmHandlerCapture { + pub window: VmHandlerWindow, + /// COW process state from BEFORE the first captured native instruction. + /// Needed for exact entry-state SSA/expr materialization; never use the + /// mutated final process or an unrelated load-time entry_state instead. + pub initial_state: angryier_state::ExecutionState< + angryier_state::PersistentRegisters, + angryier_memory::PersistentMemory, + >, + /// Actual post-handler state from the native execution path. A + /// candidate's concrete replay is compared against this COW snapshot. + pub final_state: angryier_state::ExecutionState< + angryier_state::PersistentRegisters, + angryier_memory::PersistentMemory, + >, + pub steps: Vec, + pub footprint: VmTraceFootprint, +} + +fn observation_location(pc: u64, observed: VmStateObservation) -> VmObservedLocation { + VmObservedLocation { + native_pc: pc, + virtual_pc: observed.virtual_pc, + context_digest: observed.context_digest, + } +} + +impl Runtime { + /// Execute an explicitly bounded and observed VM-handler window. + /// + /// The observer must read VPC, full relevant VM-context digest and code + /// epoch from an independently established mapping. Every `before` + /// snapshot is checked against the previous `after` snapshot, and the + /// terminal observation must match `window.exit`. Silent synthesized + /// successors, skipped SimProcedures, failed execution and step-budget + /// exhaustion are errors, never "completed" handler captures. + /// + /// This is observational groundwork for symbolic materialization, NOT + /// a proof of handler equivalence or a general Themida devirtualizer. + pub fn observe_vm_handler( + &self, + process: &mut Process, + window: VmHandlerWindow, + mut observe: F, + ) -> Result + where + F: FnMut(&Process) -> Result, + { + if process.image_id != window.identity.image + || process.target_profile != window.identity.target_profile + { + return Err(RuntimeError::Environment( + "VM handler capture image/profile identity mismatch".into(), + )); + } + if [ + window.identity.executable_digest, + window.identity.capture_digest, + window.identity.initial_state_digest, + ].iter().any(|digest| *digest == [0; 32]) { + return Err(RuntimeError::Environment( + "VM handler observation lacks executable/capture/initial-state identity".into(), + )); + } + if window.limits.max_steps == 0 { + return Err(RuntimeError::Environment( + "VM handler capture requires a nonzero step budget".into(), + )); + } + let initial_state = process.state.clone(); + let mut steps = Vec::new(); + loop { + let pc = process.pc()?; + let before_obs = observe(process)?; + let before = observation_location(pc, before_obs); + if steps.is_empty() && before != window.entry { + return Err(RuntimeError::Environment( + "VM handler entry PC/VPC/context does not match pinned observation".into(), + )); + } + if before == window.exit { + break; + } + if steps.len() >= window.limits.max_steps { + return Err(RuntimeError::StepLimitExceeded); + } + let mut recorded_block: Option = None; + let outcome = self.step_with(process, |_, block| { + if recorded_block.replace(block.clone()).is_some() { + return Err(RuntimeError::Environment( + "VM observer captured more than one IR block for one step".into(), + )); + } + Ok(()) + })?; + let block = recorded_block.ok_or_else(|| RuntimeError::Environment( + "VM handler window crossed an unrecorded hook/redirect/model; capture incomplete".into(), + ))?; + let after_pc = process.pc()?; + let after_obs = observe(process)?; + let after = observation_location(after_pc, after_obs); + match outcome { + StepOutcome::Stepped { .. } => {} + _ => { + return Err(RuntimeError::Environment( + "VM handler execution ended in trap, termination or model dispatch; capture incomplete".into(), + )); + } + } + steps.push(VmRecordedInstruction { + block, + before, + after, + code_epoch: before_obs.code_epoch, + outcome, + }); + } + let scoped: Vec> = steps.iter().map(|step| VmObservedBlock { + block: &step.block, + before: step.before, + after: step.after, + code_epoch: step.code_epoch, + }).collect(); + let footprint = collect_observed_vm_trace( + window.identity, + window.entry, + window.exit, + &scoped, + window.limits, + ) + .map_err(|error| RuntimeError::Environment(format!( + "VM handler trace validation failed: {error:?}" + )))?; + let final_state = process.state.clone(); + Ok(VmHandlerCapture { window, initial_state, final_state, steps, footprint }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use angryier_types::{ImageId, TargetProfileId}; + + #[test] + fn context_observation_preserves_native_and_virtual_address_distinction() { + let observation = VmStateObservation { + virtual_pc: 0x123, + context_digest: [7; 32], + code_epoch: 5, + }; + let location = observation_location(0x140001000, observation); + assert_eq!(location.native_pc, 0x140001000); + assert_eq!(location.virtual_pc, 0x123); + assert_eq!(location.context_digest, [7; 32]); + } + + #[cfg(feature = "xed")] + #[test] + fn real_runtime_steps_feed_an_observed_two_block_trace() -> Result<(), Box> { + use angryier_loader::{LoadedImage, Segment}; + use angryier_types::{ImageId, SemanticVersion, TargetProfileId}; + let runtime = Runtime::with_native_xed(SemanticVersion(1), TargetProfileId(1)); + let image = LoadedImage { + id: ImageId(1), + entry: 0x1000, + target_profile: TargetProfileId(1), + segments: vec![Segment { + address: 0x1000, + bytes: vec![0x90, 0x90, 0xC3], // NOP; NOP; RET + readable: true, + writable: false, + executable: true, + }], + symbols: Vec::new(), + program_headers: None, + dynamic: None, + pe: None, + }; + let mut process = runtime.load_image(image)?; + let start = VmObservedLocation { + native_pc: 0x1000, + virtual_pc: 0, + context_digest: [1; 32], + }; + let end = VmObservedLocation { + native_pc: 0x1002, + virtual_pc: 2, + context_digest: [3; 32], + }; + let window = VmHandlerWindow { + entry: start, + exit: end, + identity: VmTraceIdentity { + image: ImageId(1), + target_profile: TargetProfileId(1), + executable_digest: [5; 32], + capture_digest: [6; 32], + initial_state_digest: [7; 32], + }, + limits: VmTraceLimits { + max_steps: 2, + ..VmTraceLimits::default() + }, + }; + let capture = runtime.observe_vm_handler(&mut process, window, |state| { + let pc = state.pc()?; + let offset = pc.checked_sub(0x1000).ok_or_else(|| { + RuntimeError::Environment("unexpected native program counter".into()) + })?; + let offset = u8::try_from(offset).map_err(|_| { + RuntimeError::Environment("virtual counter beyond synthetic fixture".into()) + })?; + Ok(VmStateObservation { + virtual_pc: u64::from(offset), + context_digest: [offset + 1; 32], + code_epoch: 0, + }) + })?; + assert_eq!(capture.steps.len(), 2); + assert_eq!(capture.footprint.edges.len(), 2); + assert_eq!(capture.footprint.total_instructions > 0, true); + assert_eq!(capture.steps[0].before, start); + assert_eq!(capture.steps[1].after, end); + assert_eq!(process.pc()?, 0x1002); + Ok(()) + } + + #[cfg(feature = "xed")] + #[test] + fn captured_handler_rejects_missing_identity_before_execution() -> Result<(), Box> { + use angryier_loader::{LoadedImage, Segment}; + use angryier_types::{ImageId, SemanticVersion, TargetProfileId}; + let runtime = Runtime::with_native_xed(SemanticVersion(1), TargetProfileId(1)); + let image = LoadedImage { + id: ImageId(1), + entry: 0x1000, + target_profile: TargetProfileId(1), + segments: vec![Segment { + address: 0x1000, + bytes: vec![0x90], + readable: true, + writable: false, + executable: true, + }], + symbols: Vec::new(), + program_headers: None, + dynamic: None, + pe: None, + }; + let mut process = runtime.load_image(image)?; + let here = VmObservedLocation { + native_pc: 0x1000, + virtual_pc: 0, + context_digest: [1; 32], + }; + let out = VmObservedLocation { + native_pc: 0x1001, + virtual_pc: 1, + context_digest: [2; 32], + }; + let window = VmHandlerWindow { + entry: here, + exit: out, + identity: VmTraceIdentity { + image: ImageId(1), + target_profile: TargetProfileId(1), + executable_digest: [0; 32], + capture_digest: [0; 32], + initial_state_digest: [0; 32], + }, + limits: VmTraceLimits::default(), + }; + let result = runtime.observe_vm_handler(&mut process, window, |_| { + Err(RuntimeError::Environment("observer must not be invoked".into())) + }); + assert!(matches!(result, Err(RuntimeError::Environment(message)) if message.contains("identity"))); + assert_eq!(process.step_count, 0); + assert_eq!(process.pc()?, 0x1000); + Ok(()) + } + + #[test] + fn scoped_handler_window_cannot_claim_missing_capture_identity() { + let window = VmHandlerWindow { + entry: VmObservedLocation { + native_pc: 0x1000, + virtual_pc: 1, + context_digest: [1; 32], + }, + exit: VmObservedLocation { + native_pc: 0x2000, + virtual_pc: 2, + context_digest: [2; 32], + }, + identity: VmTraceIdentity { + image: ImageId(1), + target_profile: TargetProfileId(1), + executable_digest: [0; 32], + capture_digest: [0; 32], + initial_state_digest: [0; 32], + }, + limits: VmTraceLimits::default(), + }; + assert_eq!(window.identity.capture_digest, [0; 32]); + // An actual capture must validate this through collect_observed_vm_trace; + // constructing an untrusted window alone cannot produce a footprint. + assert!(matches!( + collect_observed_vm_trace( + window.identity, window.entry, window.exit, + &[], window.limits + ), + Err(angryier_ir::vm_trace::VmTraceError::EmptyTrace) + )); + } +} diff --git a/crates/angryier-runtime/src/vm_replay.rs b/crates/angryier-runtime/src/vm_replay.rs new file mode 100644 index 00000000..2075da1f --- /dev/null +++ b/crates/angryier-runtime/src/vm_replay.rs @@ -0,0 +1,192 @@ +//! Concrete captured-state replay check for a materialized VM candidate. +//! +//! This checks whether applying the reduced ordered effects to the *exact* +//! captured initial register/memory state reproduces the independently +//! recorded final state on that ONE input. It also checks observed branch +//! decisions and computed indirect targets. The result is not SMT proof, +//! universal handler equivalence, memory alias proof on other inputs, +//! or permission to replace the native handler in PROVE mode. +//! +//! Potentially faulting loads remain replay obligations even on success. + +use angryier_arch_intel64::{Intel64RegisterFile, register_id}; +use angryier_execution::{constant_value, SymbolicArena}; +use angryier_expr::{ExprArena, ExprSort, vm_effects::VmEffectKind}; +use angryier_ir::vm_trace::VmTraceCaveat; +use angryier_memory::{ByteValue, LayeredMemory}; +use angryier_state::RegisterState; +use angryier_types::Address; + +use crate::{vm_materialize::VmMaterializedCandidate, vm_observe::VmHandlerCapture}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VmReplayError { + InconsistentEffects, + ExistingFidelityDebt, + ExecutableChanged, + UnresolvedSymbolicValue { effect: usize }, + UnsupportedEffect { effect: usize }, + RegisterTypeMismatch { register: u32 }, + RegisterWriteFailed { register: u32 }, + RegisterMismatch { register: u32 }, + MemoryWriteFailed { address: Address }, + MemoryMismatch { address: Address }, + MissingMemoryEvidence { address: Address }, + BranchDecisionMismatch { step: usize }, + IndirectControlMismatch { step: usize }, + FinalPcMismatch, +} + +/// This verdict is *per-capture*; it cannot be used as a reusable +/// semantics-equivalence certificate without independent SMT/differential +/// validation under the proposed summary's explicit preconditions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmConcreteReplayReport { + pub checked_registers: usize, + pub checked_memory_stores: usize, + pub checked_branch_decisions: usize, + pub checked_indirect_targets: usize, + pub unresolved_fault_obligations: usize, + pub observed_input_only: bool, +} + +fn constant(arena: &SymbolicArena, expr: angryier_types::ExprId, index: usize) + -> Result +{ + constant_value(arena, expr) + .map_err(|_| VmReplayError::UnresolvedSymbolicValue { effect: index }) +} + +/// Test the candidate's concrete effects against a separate native-execution +/// state. The function does not execute an unknown binary: both snapshots +/// and symbolic expressions are supplied by the caller. +/// +/// Only scalar bitvector register writes and memory writes of 1..=8 bytes +/// are accepted by this first gate. Missing evidence is an explicit error, +/// never assumed to be a matching zero. +pub fn check_vm_concrete_replay( + capture: &VmHandlerCapture, + candidate: &VmMaterializedCandidate, + arena: &SymbolicArena, +) -> Result { + if capture.steps.is_empty() + || candidate.effect_steps.len() != candidate.reduced.effects.len() + || candidate.original_effects.len() != candidate.reduced.effects.len() + || candidate.effect_steps.iter().any(|step| *step >= capture.steps.len()) + { + return Err(VmReplayError::InconsistentEffects); + } + if candidate.input_had_fidelity_debt + || !capture.initial_state.fidelity.is_exact() + || !capture.final_state.fidelity.is_exact() + { + return Err(VmReplayError::ExistingFidelityDebt); + } + if candidate.caveats.iter().any(|c| matches!(c, VmTraceCaveat::ExecutableCodeEpochChanged { .. })) { + return Err(VmReplayError::ExecutableChanged); + } + + let mut registers = capture.initial_state.registers.clone(); + let mut memory = capture.initial_state.memory.clone(); + let mut touched = Vec::<(Address, usize)>::new(); + let mut indirect_targets = 0usize; + + for (index, effect) in candidate.reduced.effects.iter().enumerate() { + let step = candidate.effect_steps[index]; + match effect.kind { + VmEffectKind::Register { register } => { + let raw = registers.read(register) + .map_err(|_| VmReplayError::RegisterWriteFailed { register })?; + let expr = arena.get(effect.value) + .ok_or(VmReplayError::UnresolvedSymbolicValue { effect: index })?; + let ExprSort::BitVec(width) = expr.sort else { + return Err(VmReplayError::RegisterTypeMismatch { register }); + }; + if raw.is_empty() || raw.len() > 8 + || usize::from(width).div_ceil(8) != raw.len() + { + return Err(VmReplayError::RegisterTypeMismatch { register }); + } + let value = constant(arena, effect.value, index)?; + registers = registers.write(register, &value.to_le_bytes()[..raw.len()]) + .map_err(|_| VmReplayError::RegisterWriteFailed { register })?; + } + VmEffectKind::Memory { address, bytes } => { + if !(1..=8).contains(&bytes) { + return Err(VmReplayError::UnsupportedEffect { effect: index }); + } + let at = constant(arena, address, index)?; + let value = constant(arena, effect.value, index)?; + let concrete: Vec = value.to_le_bytes() + .iter().take(bytes as usize) + .copied().map(ByteValue::Concrete).collect(); + memory = memory.write(at, &concrete) + .map_err(|_| VmReplayError::MemoryWriteFailed { address: at })?; + touched.push((at, bytes as usize)); + } + VmEffectKind::DispatchTarget => { + let target = constant(arena, effect.value, index)?; + if target != capture.steps[step].after.native_pc { + return Err(VmReplayError::IndirectControlMismatch { step }); + } + indirect_targets += 1; + } + VmEffectKind::Flags | VmEffectKind::VirtualPc => { + // These need explicit architecture/VM context binding. A + // register write to RFLAGS can instead use Register{..}. + return Err(VmReplayError::UnsupportedEffect { effect: index }); + } + } + } + + let mut verified_branches = 0usize; + for branch in &candidate.chosen_branches { + let cond = constant(arena, branch.condition, branch.step)?; + if (cond & 1 != 0) != branch.taken { + return Err(VmReplayError::BranchDecisionMismatch { step: branch.step }); + } + verified_branches += 1; + } + + // Native RIP is a separate control-flow obligation: no register write + // was fabricated merely to make the replayed register file agree. + let recorded_pc = capture.final_state.registers.read(register_id::RIP.0) + .map_err(|_| VmReplayError::FinalPcMismatch)?; + if recorded_pc.as_slice() != capture.window.exit.native_pc.to_le_bytes() { + return Err(VmReplayError::FinalPcMismatch); + } + + let mut checked_registers = 0usize; + for (register, _) in &Intel64RegisterFile::canonical().architectural_registers { + if register.0 == register_id::RIP.0 { + continue; + } + let got = registers.read(register.0) + .map_err(|_| VmReplayError::RegisterMismatch { register: register.0 })?; + let actual = capture.final_state.registers.read(register.0) + .map_err(|_| VmReplayError::RegisterMismatch { register: register.0 })?; + if got != actual { + return Err(VmReplayError::RegisterMismatch { register: register.0 }); + } + checked_registers += 1; + } + + for &(at, bytes) in &touched { + let replay = memory.read(at, bytes) + .map_err(|_| VmReplayError::MissingMemoryEvidence { address: at })?; + let final_bytes = capture.final_state.memory.read(at, bytes) + .map_err(|_| VmReplayError::MissingMemoryEvidence { address: at })?; + if replay != final_bytes { + return Err(VmReplayError::MemoryMismatch { address: at }); + } + } + + Ok(VmConcreteReplayReport { + checked_registers, + checked_memory_stores: touched.len(), + checked_branch_decisions: verified_branches, + checked_indirect_targets: indirect_targets, + unresolved_fault_obligations: candidate.potentially_faulting_reads.len(), + observed_input_only: true, + }) +} diff --git a/crates/angryier-runtime/tests/pe_driver.rs b/crates/angryier-runtime/tests/pe_driver.rs index 94a6892b..c9fc5134 100644 --- a/crates/angryier-runtime/tests/pe_driver.rs +++ b/crates/angryier-runtime/tests/pe_driver.rs @@ -636,3 +636,129 @@ fn real_inbox_driver_execution() -> Result<(), Box> { Ok(()) } + +/// A second PE fixture using CALL rel32 into a local RET, so concrete and +/// symbolic paths exercise different call forms with the same stack ABI. +fn direct_call_fixture() -> Vec { + let mut image = driver_image(); + let mut code = vec![0x90u8; 0x21]; + // mov eax, 0x42 + code[..5].copy_from_slice(&[0xB8, 0x42, 0x00, 0x00, 0x00]); + // call +0x16: return address entry+0xA, target entry+0x20 + code[5..10].copy_from_slice(&[0xE8, 0x16, 0x00, 0x00, 0x00]); + code[10..13].copy_from_slice(&[0x48, 0x89, 0x01]); // mov [rcx],rax + code[13] = 0xC3; // entry returns via driver EXIT_HOOK + code[0x20] = 0xC3; // local callable routine + image[0x198..0x19c].copy_from_slice(&(code.len() as u32).to_le_bytes()); + image[0x200..0x200 + code.len()].copy_from_slice(&code); + image +} + +#[test] +fn concrete_and_symbolic_direct_indirect_calls_share_stack_and_return_targets() + -> Result<(), Box> +{ + use angryier_expr::ShardedExprArena; + use angryier_runtime::{SymbolicSession, SymbolicStepOutcome}; + use angryier_types::ExpressionNormalizationVersion; + + for (kind, image) in [ + ("direct-rel32", direct_call_fixture()), + ("indirect-rax-import", driver_image()), + ] { + let runtime = Runtime::with_native_xed(SemanticVersion(1), TargetProfileId(1)); + let mut concrete = runtime.load_pe_driver(&image)?; + let mut tracking = runtime.load_pe_driver(&image)?; + if kind == "indirect-rax-import" { + concrete.hook_export_return("ntoskrnl.exe", "DbgPrint", 0x42)?; + tracking.hook_export_return("ntoskrnl.exe", "DbgPrint", 0x42)?; + } + + let mut expected = Vec::new(); + for _ in 0..100 { + if concrete.terminated { + break; + } + expected.push(( + concrete.pc()?, + concrete.read_register(register_id::GPR_BASE + 4)?, + )); + runtime.step(&mut concrete)?; + } + assert!(concrete.terminated, "{kind}: concrete run did not return"); + assert!(expected.len() > 3, "{kind}: fixture did not exercise a call"); + + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let mut symbolic = SymbolicSession::new(&runtime, &arena, tracking); + // Exact step-for-step comparison cannot use call summarization: + // the optimizer is allowed to collapse a pure call+RET into one + // symbolic step, while concrete execution still enters the callee. + // Exercise each architectural call/return boundary instead. + symbolic.set_function_summary_cost_model(std::sync::Arc::new( + angryier_runtime::function_summaries::DepthWidthCostModel { + max_summary_cost: 0, + }, + )); + for (step, (pc, rsp)) in expected.iter().enumerate() { + assert_eq!(symbolic.states[0].process.pc()?, *pc, + "{kind}: symbolic PC diverged before step {step}"); + assert_eq!( + symbolic.states[0].process.read_register(register_id::GPR_BASE + 4)?, + *rsp, + "{kind}: symbolic RSP diverged before step {step}" + ); + let _ = symbolic.step_state(0)?; + } + assert_eq!( + symbolic.states[0].process.read_register(register_id::GPR_BASE + 4)?, + concrete.read_register(register_id::GPR_BASE + 4)?, + "{kind}: mismatched post-return stack" + ); + // Ending at the exit sentinel is a SimProcedure terminal state. + assert!( + symbolic.states[0].process.terminated + || matches!(symbolic.step_state(0), Ok(SymbolicStepOutcome::Terminated)), + "{kind}: symbolic run did not terminate" + ); + } + Ok(()) +} + +#[test] +fn symbolic_indirect_import_cannot_summarize_create_thread_stub() + -> Result<(), Box> +{ + use angryier_expr::ShardedExprArena; + use angryier_runtime::SymbolicSession; + use angryier_types::ExpressionNormalizationVersion; + + // Reuse a real, minimal PE/IAT fixture while changing only the import's + // declared provenance. This tests symbolic call dispatch, not a mock PC + // write to an import stub. The stub remains RET-only; no worker exists. + let runtime = Runtime::with_native_xed(SemanticVersion(1), TargetProfileId(1)); + let mut process = runtime.load_pe_driver(&driver_image())?; + process.pe_import_stubs.insert( + PE_DRIVER_STUB_BASE, + ("kernel32.dll".into(), "CreateThread".into()), + ); + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let mut symbolic = SymbolicSession::new(&runtime, &arena, process); + + // Entry = mov rax,[rip+IAT], then call rax. Lazy summarization must NOT + // inline a synthetic RET and skip the guarded API entry. + let mut reached_import = false; + for _ in 0..10 { + let pc = symbolic.states[0].process.pc()?; + if pc == PE_DRIVER_STUB_BASE { + reached_import = true; + let outcome = symbolic.step_state(0); + assert!(matches!(outcome, Err(angryier_runtime::RuntimeError::Environment(ref message)) + if message.contains("CreateThread") && message.contains("worker scheduling"))); + break; + } + let result = symbolic.step_state(0)?; + assert!(matches!(result, angryier_runtime::SymbolicStepOutcome::Stepped { .. })); + } + assert!(reached_import, "symbolic engine did not reach IAT stub; verify call summarizer"); + Ok(()) +} diff --git a/crates/angryier-runtime/tests/vm_materialization.rs b/crates/angryier-runtime/tests/vm_materialization.rs new file mode 100644 index 00000000..e26fe1d4 --- /dev/null +++ b/crates/angryier-runtime/tests/vm_materialization.rs @@ -0,0 +1,400 @@ +//! Native VM symbolic-materialization regression using a synthetic, + //! two-block handler. No protected target is necessary. +use angryier_arch::{DecodedInstruction, Decoder}; +use angryier_expr::ShardedExprArena; +use angryier_ir::{ + IrBlock, IrBlockKey, IrInstruction, IrOp, IrPrimitive, IrType, IrValueId, + RegisterWriteKind, + vm_trace::{collect_observed_vm_trace, VmObservedBlock, VmObservedLocation, + VmTraceIdentity, VmTraceLimits}, +}; +use angryier_loader::{LoadedImage, Segment}; +use angryier_runtime::{ + Runtime, StepOutcome, + vm_materialize::materialize_vm_handler, + vm_observe::{VmHandlerCapture, VmHandlerWindow, VmRecordedInstruction}, +}; +use angryier_types::{ + BlockId, ContentId, ExpressionNormalizationVersion, ImageId, + SemanticVersion, TargetProfileId, +}; + +#[derive(Debug)] +struct NoDecoder; +impl Decoder for NoDecoder { + type Error = String; + fn decode(&self, _: u64, _: &[u8]) -> Result { + Err("decoder not needed".into()) + } +} +fn location(pc: u64, vpc: u64, state: u8) -> VmObservedLocation { + VmObservedLocation { native_pc: pc, virtual_pc: vpc, context_digest: [state; 32] } +} +fn block(pc: u64, index: u64, source: u32, increment: u64) -> IrBlock { + let v = |id, op| IrInstruction { result: Some(IrValueId(id)), op }; + let e = |op| IrInstruction { result: None, op }; + let mut instructions = vec![ + v(0, IrOp::ReadRegister { register: source, ty: IrType::Bits(64) }), + v(1, IrOp::Constant { + ty: IrType::Bits(64), bytes_le: increment.to_le_bytes().to_vec(), + }), + v(2, IrOp::Primitive { + op: IrPrimitive::Add, ty: IrType::Bits(64), + inputs: vec![IrValueId(0), IrValueId(1)], + }), + ]; + if index == 1 { + // Faultable load whose value is deliberately unused; expression + // reduction must not discard its exception obligation. + instructions.push(v(3, IrOp::Constant { + ty: IrType::Bits(64), bytes_le: 0x1000u64.to_le_bytes().to_vec(), + })); + instructions.push(v(4, IrOp::Load { + address: IrValueId(3), ty: IrType::Bits(8), + })); + } + instructions.push(e(IrOp::WriteRegister { + register: 1, value: IrValueId(2), kind: RegisterWriteKind::ReplaceParent, + })); + instructions.push(e(IrOp::Jump { target: pc + 1 })); + IrBlock { + key: IrBlockKey { + image: ImageId(1), block: BlockId(index), address: pc, + semantic_content: ContentId([8; 32]), target_profile: TargetProfileId(1), + code_versions: Vec::new(), + }, + instructions, + } +} +fn fixture() -> Result { + let runtime = Runtime::new(NoDecoder, SemanticVersion(1), TargetProfileId(1)); + let image = LoadedImage { + id: ImageId(1), entry: 0x1000, target_profile: TargetProfileId(1), + segments: vec![Segment { + address: 0x1000, bytes: vec![0x90, 0x90, 0xc3], + readable: true, writable: false, executable: true, + }], + symbols: Vec::new(), program_headers: None, dynamic: None, pe: None, + }; + let process = runtime.load_image(image).map_err(|e| e.to_string())?; + let a = location(0x1000, 0x50, 1); + let b = location(0x1001, 0x51, 2); + let c = location(0x1002, 0x53, 3); + let window = VmHandlerWindow { + entry: a, exit: c, + identity: VmTraceIdentity { + image: ImageId(1), target_profile: TargetProfileId(1), + executable_digest: [1; 32], capture_digest: [2; 32], + initial_state_digest: [3; 32], + }, + limits: VmTraceLimits::default(), + }; + let mk = |block: IrBlock, before: VmObservedLocation, after: VmObservedLocation| + VmRecordedInstruction { + outcome: StepOutcome::Stepped { + pc: before.native_pc, next_pc: after.native_pc, length: 1, form_id: 1, + }, + block, before, after, code_epoch: 0, + }; + let steps = vec![mk(block(0x1000, 1, 0, 7), a, b), + mk(block(0x1001, 2, 1, 2), b, c)]; + let borrowed: Vec<_> = steps.iter().map(|s| VmObservedBlock { + block: &s.block, before: s.before, after: s.after, code_epoch: s.code_epoch, + }).collect(); + let footprint = collect_observed_vm_trace( + window.identity, a, c, &borrowed, window.limits + ).map_err(|e| format!("{e:?}"))?; + // Independent expected concrete result for the synthetic two-block + // ADD handler: initial RAX=0, RCX=7, then RCX=RCX+2 => 9. + let mut final_process = process.clone(); + final_process.write_register(1, 9).map_err(|error| error.to_string())?; + final_process.write_pc(0x1002).map_err(|error| error.to_string())?; + Ok(VmHandlerCapture { + window, + initial_state: process.state, + final_state: final_process.state, + steps, + footprint, + }) +} +#[test] +fn two_blocks_reuse_symbolic_state_not_numeric_ssa_ids() -> Result<(), String> { + let capture = fixture()?; + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let candidate = materialize_vm_handler(&capture, &arena, 1024) + .map_err(|e| format!("{e:?}"))?; + assert_eq!(candidate.reduced.effects.len(), 2); + assert_eq!(candidate.original_effects.len(), 2); + assert_eq!(candidate.potentially_faulting_reads.len(), 1); + let first = candidate.reduced.effects[0].value; + let second = candidate.reduced.effects[1].value; + assert_eq!(angryier_execution::constant_value(&arena, first).map_err(|e| e.to_string())?, 7); + assert_eq!(angryier_execution::constant_value(&arena, second).map_err(|e| e.to_string())?, 9); + assert!(!candidate.input_had_fidelity_debt); + Ok(()) +} +#[test] +fn edited_footprint_is_rejected_before_materialization() -> Result<(), String> { + let mut capture = fixture()?; + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + capture.footprint.effects.clear(); + assert!(materialize_vm_handler(&capture, &arena, 1024).is_err()); + Ok(()) +} + +#[test] +fn fabricated_native_step_outcome_fails_closed() -> Result<(), String> { + let mut capture = fixture()?; + capture.steps[0].outcome = StepOutcome::Stepped { + pc: 0xdeaf, + next_pc: 0x1001, + length: 1, + form_id: 1, + }; + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + assert!(materialize_vm_handler(&capture, &arena, 1024).is_err()); + Ok(()) +} + +#[test] +fn candidate_replay_is_only_a_captured_input_check() -> Result<(), String> { + let captured = fixture()?; + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let candidate = materialize_vm_handler(&captured, &arena, 1024) + .map_err(|error| format!("{error:?}"))?; + let report = angryier_runtime::vm_replay::check_vm_concrete_replay( + &captured, &candidate, &arena + ).map_err(|error| format!("{error:?}"))?; + assert!(report.checked_registers > 0); + assert_eq!(report.unresolved_fault_obligations, 1); + assert!(report.observed_input_only); + Ok(()) +} + +#[test] +fn captured_replay_detects_changed_register_and_rejects_debt() -> Result<(), String> { + use angryier_state::RegisterState; + use angryier_runtime::vm_replay::{check_vm_concrete_replay, VmReplayError}; + let mut capture = fixture()?; + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let candidate = materialize_vm_handler(&capture, &arena, 1024) + .map_err(|error| format!("{error:?}"))?; + capture.final_state.registers = capture.final_state.registers + .write(1, &8u64.to_le_bytes()).map_err(|error| format!("{error:?}"))?; + assert_eq!( + check_vm_concrete_replay(&capture, &candidate, &arena), + Err(VmReplayError::RegisterMismatch { register: 1 }) + ); + let mut capture = fixture()?; + capture.initial_state.fidelity = capture.initial_state.fidelity + .record(angryier_types::AnalysisDebtKind::Concretized, 0x1000); + let candidate = materialize_vm_handler(&capture, &arena, 1024) + .map_err(|error| format!("{error:?}"))?; + assert_eq!( + check_vm_concrete_replay(&capture, &candidate, &arena), + Err(VmReplayError::ExistingFidelityDebt) + ); + Ok(()) +} + +#[test] +fn repeated_writes_and_partial_parent_updates_replay_in_original_order() + -> Result<(), String> +{ + use angryier_state::RegisterState; + use angryier_runtime::vm_replay::check_vm_concrete_replay; + + let mut capture = fixture()?; + // Make the high bits nonzero so an incorrect low-byte replacement + // cannot accidentally appear equivalent to PreserveParent. + let wide = 0x1_0000_0007u64; + capture.steps[0].block.instructions[1].op = IrOp::Constant { + ty: IrType::Bits(64), + bytes_le: wide.to_le_bytes().to_vec(), + }; + + // The second block first writes RCX = original + 2, then RCX += 5, + // then replaces only the low byte with 3. Three writes to the same + // parent register MUST be materialized as three distinct effects. + let instructions = &mut capture.steps[1].block.instructions; + let terminal = instructions.pop().ok_or("missing direct jump")?; + assert!(matches!(terminal.op, IrOp::Jump { .. })); + instructions.push(IrInstruction { + result: Some(IrValueId(3)), + op: IrOp::Constant { + ty: IrType::Bits(64), + bytes_le: 5u64.to_le_bytes().to_vec(), + }, + }); + instructions.push(IrInstruction { + result: Some(IrValueId(4)), + op: IrOp::Primitive { + op: IrPrimitive::Add, + ty: IrType::Bits(64), + inputs: vec![IrValueId(2), IrValueId(3)], + }, + }); + instructions.push(IrInstruction { + result: None, + op: IrOp::WriteRegister { + register: 1, + value: IrValueId(4), + kind: RegisterWriteKind::ReplaceParent, + }, + }); + instructions.push(IrInstruction { + result: Some(IrValueId(5)), + op: IrOp::Constant { + ty: IrType::Bits(8), + bytes_le: vec![3], + }, + }); + instructions.push(IrInstruction { + result: None, + op: IrOp::WriteRegister { + register: 1, + value: IrValueId(5), + kind: RegisterWriteKind::PreserveParent { + bit_offset: 0, + width_bits: 8, + }, + }, + }); + instructions.push(terminal); + + let borrowed: Vec<_> = capture.steps.iter().map(|step| VmObservedBlock { + block: &step.block, before: step.before, after: step.after, + code_epoch: step.code_epoch, + }).collect(); + capture.footprint = collect_observed_vm_trace( + capture.window.identity, + capture.window.entry, + capture.window.exit, + &borrowed, + capture.window.limits, + ).map_err(|error| format!("{error:?}"))?; + + let expected = 0x1_0000_0003u64; + capture.final_state.registers = capture.final_state.registers + .write(1, &expected.to_le_bytes()) + .map_err(|error| format!("{error:?}"))?; + + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let candidate = materialize_vm_handler(&capture, &arena, 4096) + .map_err(|error| format!("{error:?}"))?; + assert_eq!(candidate.effect_steps, vec![0, 1, 1, 1]); + let expected_writes = [ + wide, + wide + 2, + wide + 7, + expected, + ]; + for (index, value) in expected_writes.iter().enumerate() { + let expression = candidate.reduced.effects[index].value; + assert_eq!( + angryier_execution::constant_value(&arena, expression) + .map_err(|error| error.to_string())?, + *value, + "write {index} was replaced by a later write", + ); + } + let verified = check_vm_concrete_replay(&capture, &candidate, &arena) + .map_err(|error| format!("{error:?}"))?; + assert!(verified.checked_registers > 0); + assert!(verified.observed_input_only); + Ok(()) +} + +#[cfg(feature = "xed")] +#[test] +fn native_xed_two_instruction_execution_replays_materialized_effects() + -> Result<(), String> +{ + use angryier_runtime::{ + vm_observe::VmStateObservation, + vm_replay::check_vm_concrete_replay, + }; + + let runtime = Runtime::with_native_xed(SemanticVersion(1), TargetProfileId(1)); + let image = LoadedImage { + id: ImageId(1), + entry: 0x1000, + target_profile: TargetProfileId(1), + // mov eax, 7 ; add eax, 2 ; nop + // The actual x86 interpreter runs both instructions and produces + // the final register/flag state used as an independent witness. + segments: vec![Segment { + address: 0x1000, + bytes: vec![0xB8, 7, 0, 0, 0, 0x83, 0xC0, 2, 0x90], + readable: true, + writable: false, + executable: true, + }], + symbols: Vec::new(), + program_headers: None, + dynamic: None, + pe: None, + }; + let mut process = runtime.load_image(image).map_err(|error| error.to_string())?; + // A MOV EAX must clear the upper half of RAX, even if the original + // captured RAX had nonzero upper bits. + process.write_register(0, 0xFFFF_FFFF_0000_0000) + .map_err(|error| error.to_string())?; + let location = |native_pc: u64| { + let offset = native_pc.saturating_sub(0x1000); + VmObservedLocation { + native_pc, + virtual_pc: offset, + context_digest: [u8::try_from(offset).unwrap_or(u8::MAX).saturating_add(1); 32], + } + }; + let window = VmHandlerWindow { + entry: location(0x1000), + exit: location(0x1008), + identity: VmTraceIdentity { + image: ImageId(1), + target_profile: TargetProfileId(1), + // Synthetic nonzero identities, not forensic attestations. + executable_digest: [1; 32], + capture_digest: [2; 32], + initial_state_digest: [3; 32], + }, + limits: VmTraceLimits { + max_steps: 2, + ..VmTraceLimits::default() + }, + }; + let captured = runtime.observe_vm_handler(&mut process, window, |running| { + let pc = running.pc()?; + let offset = pc.checked_sub(0x1000) + .ok_or_else(|| angryier_runtime::RuntimeError::Environment( + "native trace left fixture region".into() + ))?; + let context_byte = u8::try_from(offset) + .map_err(|_| angryier_runtime::RuntimeError::Environment( + "native trace offset outside byte fixture".into() + ))?.saturating_add(1); + Ok(VmStateObservation { + virtual_pc: offset, + context_digest: [context_byte; 32], + code_epoch: 0, + }) + }).map_err(|error| error.to_string())?; + + assert_eq!(captured.steps.len(), 2); + assert_eq!(process.pc().map_err(|error| error.to_string())?, 0x1008); + assert_eq!( + process.read_register(0).map_err(|error| error.to_string())?, + 9, + ); + + let arena = ShardedExprArena::new(ExpressionNormalizationVersion(1)); + let candidate = materialize_vm_handler(&captured, &arena, 4096) + .map_err(|error| format!("{error:?}"))?; + let report = check_vm_concrete_replay(&captured, &candidate, &arena) + .map_err(|error| format!("{error:?}"))?; + assert!(report.checked_registers > 0); + assert!(report.observed_input_only); + assert_eq!(report.checked_indirect_targets, 0); + Ok(()) +} diff --git a/docs/ACTIVE_ENGINE_HANDOFF.md b/docs/ACTIVE_ENGINE_HANDOFF.md new file mode 100644 index 00000000..5044faac --- /dev/null +++ b/docs/ACTIVE_ENGINE_HANDOFF.md @@ -0,0 +1,117 @@ +# Angryier — active cross-session engineering checkpoint + +**Updated:** 2026-10-15 (UK date) +**Purpose:** Shared, privacy-safe handoff between independent ChatGPT sessions and devices. +**Scope:** Angryier core engine and experimental VM-analysis pipeline, not a target-specific bypass. +**Canonical PR:** https://github.com/SWORDIntel/Angryier/pull/1 +**Last verified implementation head before this checkpoint:** `221be5fc7e3018b361228a6cfdf8916d890de1c9` +**Target branch:** `feat/themida-vm-structural-20261015` +**Base `main`:** `b3df3e55d9604252b27ea4e1d67bb6367294d9b4` + +> This is a dated checkpoint, not a substitute for checking the current branch/PR/CI. The commit creating this file advances the branch. Check the current Git ref before changing code. Local uncommitted work and separate ChatGPT conversations are not synchronized by GitHub. + +## Verified GitHub state before checkpoint + +- Branch is 88 commits ahead of `main`, zero behind; PR #1 is open, draft and mergeable. **Nothing from PR #1 was merged into `main`.** +- The focused `Native VM analysis validation` run **38763710027** passed. It covers CFG, expression, loader, models, runtime tests, Lua integration, direct/indirect-call fixtures, CLI and scoped Clippy. +- Full CI run **38763710438** failed at the **format** step. This is still an open quality gate; some formatting backlog predates this PR. Do not report full CI as green. +- The original indirect-call frame fix `b3df3e5` on `main` was incorporated into this branch through a two-parent merge, retaining the updated Lua investigation scripts. No force-push is required. + +## Implemented and checked in the shared branch + +### Core control-flow fidelity and Win32 environment + +- Correct signed RIP-relative/address-generation handling; conservative LEA provenance. +- Indirect-call `mirror_call_frame` from the baseline `b3df3e5`; process-memory and symbolic-frame cooperation. +- Executable-target checking uses current mapped executable regions and explicit runtime hooks rather than target-specific address bands. +- Return-address resynchronization uses a bounded search, refuses multiple eligible stack cells, and records concretization debt; not a formal call-stack proof. +- MS x64 arguments come from RCX/RDX/R8/R9 and stack slots; missing argument evidence is distinguishable from concrete zero. +- Windows event models share handle state and distinguish manual reset, auto reset, signaled, pending/blocked and invalid handles. No deterministic multithread scheduler is implemented. +- The synthetic user-mode IAT stub boundary is guarded in **both** concrete and symbolic execution. In particular, `CreateThread` cannot silently succeed through `RET` or a lazy pure-function summary without an explicit model. +- An approximate scalar `IsProcessorFeaturePresent` result records fidelity debt; it is not authoritative Windows behavior. + +### Forensic input and diagnosis + +- PE VA/RVA/file-offset mapping distinguishes original file-backed bytes from zero-fill and rejects invalid overlapping mappings. +- Source-backed xref index covers direct call sites, branches and RIP-relative IAT/indirect slots; it does not invent indirect callees. +- In-memory and streaming MINIDUMP_MEMORY_LIST/MEMORY64_LIST readers reject missing captured bytes instead of synthesizing zeroes. +- Atomic, bounded capture overlays support either valid minidump ranges or independently evidence-mapped heap slices, with collision-free persistent-memory object IDs and explicit replay-baseline control. +- `angryier doctor` reports compiled source revision, current checkout revision, build features and running executable SHA-256, masking local paths by default. +- `angryier pe-address`, `angryier pe-imports`, `angryier dump-read` expose forensic data without repeated external Python probes. +- Audited single-step CALL/RET checks preserve pre/post RIP, RSP, concretely known stack-top and the first trace divergence; incomplete stack evidence has an explicit outcome. + +### Native VM semantic analysis + +- Opt-in `angryier_cfg::vm::inspect_native_cfg` joins existing Themida dispatch classification and candidate VPC heuristics. The findings remain **unverified structural hypotheses**. +- `VmAnalysisSession` stores context-sensitive observed transitions keyed by native PC, virtual PC, state digest and code digest, with bounded resources and no fabricated CFG edges. +- `angryier_expr::vm_effects::reduce_vm_effects` slices from declared observable effects, memory addresses and path guards, with explicit node/source accounting. +- **Newest phone-session work (commit `ac7c6cf4082215450548ee9aa7d4117fdedd550c`, formatting finalized in `221be5fc7e30`):** `crates/angryier-ir/src/vm_footprint.rs` contains the single-block, conservative AngryIR effect collector. It verifies IR, inventories ordered memory writes, potentially faulting reads/masked loads, full/partial register writes, branch/indirect/call/return/trap effects and transitive SSA dependencies. It refuses unsupported/missing producer data and bounded-analysis overruns. Tests passed in focused CI. +- The collector's SSA `IrValueId` **must not** be confused with an expression-arena `ExprId`. No automatic handler summarization/equivalence proof has been claimed. + +## Current research / investigation findings + +- The investigation corrected a major mistake in raw dump-to-image address mapping. Do not revive the earlier assumption that one specific unregistered data structure was encrypted Themida ciphertext. +- Captured provider/singleton/registration heap objects exist, but recovered pointers are observations rather than verified field semantics. +- The application's bounded wait and provider registration rely on missing modeled environment behavior; executing more symbolic instructions is not by itself a solution. +- The earlier indirect-call corruption was genuine and fixed; synthetic success returns for threading/synchronization were only exploration aids, not a faithful OS. +- The deeper component-manager path still has no proven final GUI success. Do not label application initialization complete. + +## Next gates — carry forward in this order + +1. **Trace-backed VM handler boundaries:** native PC, VPC, context identity, and exact executable/state/capture identity. No boundary inference from a disassembly census alone. +2. **Multi-block effect collection:** compose single-block AngryIR footprints without losing memory aliasing, faults, conditional effects, register-part preservation or control exits. +3. **Symbolic materialization:** map retained SSA values and memory operations into width-qualified expression IDs under a pinned entry state; reuse Angryier expression arena, not a new symbolic engine. +4. **Validation:** differential replay / SMT equivalence of handler summaries before execution replacement; record incomplete/failing cases and solver budgets. +5. **Virtual CFG export:** use observed, context-sensitive transitions and produce versioned IR/CFG for METHRA; keep high-level decompilation in METHRA. +6. **Windows environment parity:** deterministic user-mode thread creation and scheduling, per-execution-state handle/event registries, accurate user-mode loader/TEB/PEB, and identity-backed dump/thread checkpoint restoration. No invented worker success. +7. **Quality gate:** address formatting and full CI while keeping focused tests green; use an independent evaluation corpus (known VM/normal compiler-switch negative controls) rather than accepting speed or depth as semantic correctness. + +## Operational constraints + +- Work on the current PR branch; fetch latest branch tip before committing. Avoid blindly merging divergent work. +- Treat reported observations as evidence and distinguish them from hypotheses. +- Prefer reusing ISANITY, AngryIR, existing expression DAG, replay/provenance, QIHSE, and METHRA; no duplicate decoder, database, scheduler or decompiler. +- Keep exploration relaxations opt-in and clearly separated from PROVE fidelity. +- Preserve private analyst data: don't publish hostnames, local account names, credentials, actual IP addresses, raw dumps, user identity or target-specific deployment details in handoff artifacts. Use `#########` wherever an IP would otherwise appear. +- Do not assert ChatGPT conversations have been merged; this GitHub document is a **shared context pointer** only. + +## Minimal cross-device recovery command + +In another ChatGPT session: **“Read the current `SWORDIntel/Angryier` feature branch and `docs/ACTIVE_ENGINE_HANDOFF.md` using GitHub, then continue PR #1 from its current HEAD. Do not assume any unsynced local checkout changes are on GitHub.”** + + +## 2026-10-15 — Native handler materialization milestone (PC continuation) + +**Last verified code revision:** `ffe97dbb64dc3d3343c9271cb17ad34fb3fb9938` +**Focused CI:** https://github.com/SWORDIntel/Angryier/actions/runs/38780871150 — **success**. +This documentation update advances branch HEAD beyond that tested revision; always fetch the latest branch tip. + +### Added and verified + +- `angryier-ir::vm_trace::collect_observed_vm_trace`: multi-block, observed VM traces with **step-scoped SSA IDs**, ordered memory and register effects, explicit VPC/context boundaries, code-epoch guards, budget and branch-destination checks, and uncertainty caveats. Does not infer a VPC automatically. +- `angryier-runtime::vm_observe::Runtime::observe_vm_handler`: opt-in native execution capture which records the AngryIR actually executed, pre/post observations, original COW entry state, final state and verified footprint. Unrecorded hooks and incomplete windows are errors. +- `angryier-execution::SymbolicEvaluator::eval_block_with_memory_values`: opt-in producer-expression readout and **per-register-write post-state expressions**, without creating a second evaluator. Normal `eval_block_with_memory` has ordered-write logging disabled to protect its hot path. +- `angryier-runtime::vm_materialize::materialize_vm_handler`: revalidates the observed capture and maps local SSA values into the existing expression DAG. It preserves successive full-width and partial-parent writes, memory-write order, potentially faulting reads, and observed branch predicates. It feeds output-rooted reduction and refuses unsupported semantics. +- `angryier-runtime::vm_replay::check_vm_concrete_replay`: independently compares scalar effects, native final registers and touched stores for the exact observed input. The report explicitly retains `observed_input_only=true` and unresolved read/fault obligations. **This is not universal semantic equivalence or a PROVE-grade replacement certificate.** + +### Correctness defects discovered by the stronger regressions + +1. **Concat operand mismatch:** AngryIR `IrPrimitive::Concat` is `(low, high)`; `ExprOp::Concat` uses `(high, low)`. Corrected the conversion in the shared symbolic/concolic expression path and the partial-register merge order. +2. **Repeated writes lost:** Using an end-of-block register value for earlier writes was incorrect. The evaluator now records the expression *at each actual write point*. +3. **First zero-extended GPR write mis-width:** `mov eax, imm32` could create a 32-bit symbolic RAX binding if no prior symbolic register binding existed. Corrected the architectural parent-width zero extension. + +The focused suite now includes an independent native x86 two-instruction execution/replay test (`mov eax,7; add eax,2`) and repeated register writes with an upper-bit-preserving low-byte modification. An additional native test starts with nonzero upper RAX bits and confirms the 32-bit write clears them. + +### Next engineering gates + +1. **Input-independent handler relations:** explicitly symbolic VM-context/GPR seeds and precise path guards; no assumption that one captured input generalizes to all inputs. +2. **Validation:** differential re-execution across independent inputs plus SMT equivalence for supported bitvector/memory fragments, with explicit fault and alias obligations. No summary substitution before the relevant equivalence gate. +3. **Complete memory/exception coverage:** the existing replay checker compares candidate-touched stores, not every conceivable write or exception; add bounded observed-write coverage and fail closed on gaps. +4. **Controlled VPC discovery:** bind dispatch-site candidates to measured native state and capture provenance; reject guessed handler boundaries. +5. **Versioned METHRA handoff:** only verified semantics and explicit unresolved edges, without a new decompiler inside Angryier. +6. **Separate Windows thread scheduler/OS fidelity work**, needed for accurate asynchronous initialization but not a prerequisite for the synthetic VM-handler milestones. +7. **Repository-wide formatting/CI backlog** remains distinct from focused VM CI. Do not label the full suite green until the root workflow passes. + +### Operational continuity + +Work from current branch `feat/themida-vm-structural-20261015`, draft PR #1. The history includes `main` commit `b3df3e5` through a prior merge; preserve it. Treat notes as historical and verify current branch HEAD and CI before coding. Keep private analyst dump/host details out of GitHub. diff --git a/docs/THEMIDA_NATIVE_VM.md b/docs/THEMIDA_NATIVE_VM.md new file mode 100644 index 00000000..5a069fd7 --- /dev/null +++ b/docs/THEMIDA_NATIVE_VM.md @@ -0,0 +1,226 @@ +# Native VM analysis — experimental Themida/VMProtect building blocks + +**Status (2026-10-15): structural inspection, bounded evidence graph, and +output-rooted expression reduction. Not an automatic Themida devirtualizer.** + +## Scope and threat model + +This code handles locally obtained binaries and trusted analysis evidence in a +controlled laboratory. A large decode census, an indirect jump, or a VM_CONTEXT +register hypothesis is **not** proof that an instruction belongs to a VM. +Likewise, an observed trace does not establish complete CFG coverage. + +The opt-in API does not modify the ordinary CFG, eagerly resolve unknown +control transfers, bypass debugger protections, execute foreign samples, or +replace the existing concolic/symbolic engine. + +## Implemented + +1. **Signed address handling:** CFG constant/table-base resolution uses + `u64::checked_add_signed` for signed RIP/base displacements. Regression + tests include a negative-displacement table located before the dispatcher. +2. **Conservative LEA provenance:** `themida::DispatchWindow` distinguishes + RIP/absolute constants from register-relative context derivations and + unknown two-register addressing. +3. **Native static VM-site reconnaissance:** `vm::inspect_native_cfg` consumes + unresolved indirect-jump blocks, runs existing `themida` classification and + `vpc` heuristics, and returns candidates with context provenance. + `Runtime::inspect_vm_window(process, entry, max_bytes)` exposes this to + native consumers using the existing bounded executable-window CFG builder. +4. **VPC/context-sensitive observed graph:** `vm::VmAnalysisSession` accepts + explicit handler-boundary transitions keyed by native PC, virtual PC, state + digest, and code digest; distinct virtual instructions in the same handler + cannot accidentally share a node. Duplicate evidence is aggregated. + Exceeding node/edge budgets rejects admission atomically and increments + the rejection count. `projected_edges()` emits only edges supported by + supplied observations, collapsing contexts for downstream analysis. +5. **Output-rooted handler semantics:** `angryier_expr::vm_effects::reduce_vm_effects` + traverses expression DAG dependencies from caller-declared register, + flag, virtual-PC, dispatch, and ordered memory-write effects, plus branch + guards. It retains symbolic memory *addresses* as roots, applies the + existing `deobf` simplifier, and reports source dependencies and node + counts. Unknown nodes and capacity overruns fail closed. + +## AngryIR effect-footprint discovery (experimental) + +`angryier_ir::vm_footprint::collect_vm_ir_effects` now collects an +**instruction-order-preserving syntactic footprint** from a verified, single +AngryIR block. It discovers register writes (including partial parent-register +updates), ordered memory writes, memory reads even when their result is dead +(the read may fault), masked-load address/mask dependencies, branches, indirect +dispatches, direct calls, returns, and explicit traps. It returns the backward +SSA dependency closure in producer order, including source registers, external +expression references and memory read results. Invalid IR or exhausted +instruction/dependency budgets abort the collection without a partial result. + +```rust +use angryier_ir::vm_footprint::{collect_vm_ir_effects, VmIrLimits}; + +// `ir_block` is one lowered, source-identified AngryIR block. +let footprint = collect_vm_ir_effects(&ir_block, VmIrLimits::default())?; +// Consume `footprint.effects` in original order; do not sort stores. +// `footprint.dependencies` are IR value IDs, NOT ExprIds. +// `footprint.caveats` prohibits interpreting this as verified semantics. +``` + +This collector is deliberately **not** a handler synthesizer. It has no +cross-block memory SSA, confirmed VPC role, path-condition accumulation, +Windows process restoration or proof that every architectural exception is +represented. It marks all primitives for semantic review. Do not discard a +register write merely because it appears VM-internal; boundary/state evidence +must justify that decision. In particular, a register write with +`PreserveParent` implicitly depends on the pre-write parent register. + +**Next integration gate:** for a trace with independently established handler +boundaries and VPC/context values, keep each block's SSA namespace distinct, +materialize its retained IR values into expression IDs under the recorded +entry state, and feed ordered, width-qualified effects to +`angryier_expr::vm_effects::reduce_vm_effects`. Differential replay must +validate the resulting summary before it can replace native interpreter +execution. No automatic full Themida devirtualization is implied. + +## Minimal native use + +```rust +// After obtaining Runtime and its loaded Process: +let report = runtime.inspect_vm_window(&process, entry_pc, 1024 * 1024)?; +for site in &report.findings { + println!("dispatch={:#x} kind={:?} possible_context={:?}", + site.dispatch_pc, site.kind, site.context_origin); +} +// No virtual-PC values are manufactured by this static scan. +``` + +Feed exact transitions from a native runtime observer or a versioned external +capture (the observer must establish handler boundaries and VPC values): + +```rust +use angryier_cfg::vm::{ + VmAnalysisLimits, VmAnalysisSession, VmNodeKey, VmTransition, VmTransitionSource, +}; + +let mut vm = VmAnalysisSession::new(VmAnalysisLimits { + max_nodes: 4096, + max_edges: 8192, +}); +let entry = VmNodeKey { + native_pc: 0x140010000, + virtual_pc: 0x100, + state_digest: [1; 32], // replace with an exact capture-derived digest + code_digest: [2; 32], // hash the captured executable bytes +}; +let next = VmNodeKey { + native_pc: 0x140020000, + virtual_pc: 0x105, + state_digest: [3; 32], + code_digest: [2; 32], +}; +vm.observe(VmTransition { + from: entry, + to: next, + source: VmTransitionSource::ObservedTrace, +})?; +assert_eq!(vm.projected_edges().len(), 1); +assert_eq!(vm.rejected(), 0); +``` + +These examples are API sketches; the surrounding client selects its decoder, +source identity and runtime. Synthetic digest literals are **not** valid +capture provenance. Observed transitions should carry independent binary, +architecture, mapping, timestamp/epoch and acquisition provenance in the +client's artifact envelope (METHRA/QIHSE); a digest alone cannot prove origin. + +## Validation + +```sh +cargo fmt --all -- --check +cargo test -p angryier-cfg vm:: +cargo test -p angryier-cfg themida:: +cargo test -p angryier-cfg negative_rip_relative_jump_table_displacement_resolves +cargo test -p angryier-expr vm_effects:: +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets +``` + +Acceptance gates for future milestones: + +- Static dispatch precision/recall on compiler-switch negative controls and + deliberately virtualized functions, with reachable-code ground truth; +- dynamic VPC values and handler boundaries validated against a known VM; +- context-hash compatibility rules, page-version coherence, capture provenance, + and explicit missing-state/incomplete outcomes; +- handler read/write footprint extraction from AngryIR rather than analyst- + supplied roots, preserving memory effects and exception/flag semantics; +- semantic replay or SMT-equivalence tests for every accepted handler summary; +- multi-successor, context-sensitive CFG recovery with measured state budgets; +- versioned IR/CFG handoff to METHRA, without rebuilding a second decompiler. + +## Sarin Advisor 8.3 — native forensic continuation + +The terminal record identifies a PE file-offset mistake and an unsolved +wait/singleton state. The following commands replace the repeated external +PE section and dump-memory probes. They do **not** bypass a wait condition or +prove the runtime's loaded image matches the on-disk file. + +For the lightweight, no-XED/no-Z3 CLI: + +```sh +cargo build --release -p angryier-cli --no-default-features --features inspect +./target/release/angryier pe-address advisor_unpacked_state.exe 0x140404e10 16 +./target/release/angryier dump-read diag_1892.dmp 0x094464c0 64 +``` + +The first command should report the mapped section, RVA and original +file offset (verify the transcript's reported 0x404e10 -> 0x404410 with +your exact PE file). The second opens MINIDUMP_MEMORY_LIST (5) or +MINIDUMP_MEMORY64_LIST (9) **without loading the whole dump** and reads +only the requested captured bytes. If the target heap page is missing, +the reader returns `MissingCapture` with the exact first absent address. + +The file mapping and memory dump are distinct evidence sources. A dump +may reflect a rebased, unpacked or patched image; translating a VA through +the original PE tells you only where original file bytes reside. Do not +substitute one for the other without matching image hashes, load bases, +code-page versions and timestamps. + +Relevant native APIs: + +- `angryier_loader::addresses::PeAddressMap::{parse,locate_va,file_offset,bytes_at_va}` +- `angryier_loader::minidump::MiniDumpMemory` (borrowed in-memory captures) +- `angryier_loader::minidump_file::MiniDumpFile` (streaming large dump files) +- `angryier_cfg::xrefs::XrefIndex::{from_cfg,callers_of,to,from}` +- `Runtime::inspect_xrefs_window` for source-backed direct and RIP-relative + references in a bounded executable region. + +Still missing for exact execution-state replay: thread-context selection, +module mappings and relocations, page protection/version snapshots, Windows +TEB/PEB/loader state, lock/event handle models and a partial-page unknown +policy. The capture reader alone does not safely initialize an +`angryier_runtime::Process`. Keep this work separate from guessed +singleton-object values. + +The observed wait at 0x140404e10 and the two conflicting reported R8 values +must be correlated to one exact dump/thread/timepoint. This branch does not +claim that the wait predicate or GDI+ continuation has been solved. + +## Explicitly not yet implemented + +- Automatic virtual-PC discovery with runtime-state confirmation; +- automatic encrypted-bytecode/key recovery or Themida-version fingerprints; +- full VM handler execution/summarization from binary input; +- Windows live-process checkpoint import, capture or anti-debug handling; +- arbitrary indirect-target resolution, complete virtual CFG or C output; +- proof of general Themida/VMProtect support. + +The most useful next step is an **AngryIR handler-effect collector**, so +`vm_effects` can receive complete and defensible read/write effect sets +without relying on manual or script-generated sink lists. It should validate +summaries by replay before allowing them to replace interpreter execution. + +## Design constraints + +Keep this feature opt-in, keep every ambiguous edge unresolved, preserve the +`PROVE/EXPLORE/HUNT` fidelity distinction, and record exact versioned input +identity. Reuse ISANITY and Angryier semantics, METHRA orchestration, and +QIHSE evidence storage rather than introducing another ISA, scheduler, +database, or decompiler.