From 59637e50dcd4bdfa3f06796f9c9e87e4d259e40e Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 14 Oct 2026 01:35:21 +0200 Subject: [PATCH] verify: add adversary module - generic constant-folding script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit noxis-verify::adversary::fold_constants walks maximal straight-line Const(Int/Long)+Binary{Int,Long} runs, modeling the baseline attack any deobfuscator runs first (and the one raised on the forum: two adjacent literals and one op, foldable in your head). Each stack slot carries its own provenance span, so several split constants placed back to back without a separator are reported as separate findings instead of merging into one run. Two uses: an honest §7 measurement (answer: every Constants scheme, mba included, folds under a generic evaluator - only pattern matchers hardcoded to one opcode are defeated, documented already in constants/forms.rs) and a correctness oracle (every scheme must fold back to the exact source value, now pinned by a regression test). Tests: bare_literals_are_not_folded, every_constants_scheme_folds_back_to_the_source_value. --- Cargo.lock | 1 + crates/noxis-verify/Cargo.toml | 3 + crates/noxis-verify/src/adversary/mod.rs | 252 +++++++++++++++++++++++ crates/noxis-verify/src/lib.rs | 6 +- 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 crates/noxis-verify/src/adversary/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a1ac432..31a6584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,6 +383,7 @@ dependencies = [ "noxis-classfile", "noxis-core", "noxis-jar", + "noxis-passes", "noxis-structure-check", "regex", "serde", diff --git a/crates/noxis-verify/Cargo.toml b/crates/noxis-verify/Cargo.toml index 5a384e0..b054bbc 100644 --- a/crates/noxis-verify/Cargo.toml +++ b/crates/noxis-verify/Cargo.toml @@ -18,5 +18,8 @@ thiserror = { workspace = true } [lints] workspace = true +[dev-dependencies] +noxis-passes = { workspace = true } + [build-dependencies] noxis-structure-check = { workspace = true } diff --git a/crates/noxis-verify/src/adversary/mod.rs b/crates/noxis-verify/src/adversary/mod.rs new file mode 100644 index 0000000..c6cbbd1 --- /dev/null +++ b/crates/noxis-verify/src/adversary/mod.rs @@ -0,0 +1,252 @@ +//! A minimal constant-folding adversary: the kind of tool any attacker +//! reaches for first (and the one a forum reader pointed at - two adjacent +//! literals and a binary op fold in your head). Used two ways: +//! +//! * as an honest measurement for §7 - "how much of `Constants` survives a +//! generic literal evaluator", instead of trusting the docs' own claims; +//! * as a correctness oracle - whatever a splitting scheme emits must fold +//! back to the exact source value, for every scheme we ship, forever. +//! +//! Deliberately NOT a full symbolic/SMT solver: it only walks maximal +//! straight-line runs of `Const(Int/Long)` + `Binary{Int,Long}`, the same +//! vocabulary `noxis_passes::constants` writes. That is the realistic floor, +//! not a strawman - a generic constant-propagation pass in any real +//! deobfuscator does at least this much. +//! +//! Each stack slot carries its own provenance (the span of instructions that +//! produced it), not just a bare value: several independent split constants +//! placed back to back (no separator between them) must be reported as +//! separate findings, not glommed into one run just because nothing happens +//! to separate them on the instruction stream. + +use noxis_classfile::{Arith, Const, Insn, Prim, Program}; + +/// One straight-line run the adversary folded to a single value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FoldedConstant { + pub class: String, + pub method: String, + /// Instruction index the run starts at (first `Const`). + pub start: usize, + /// Instruction count the run spans. + pub len: usize, + /// The folded value (`Int` results are sign-extended). + pub value: i64, +} + +/// Fold every maximal literal-arithmetic run in the program. +pub fn fold_constants(program: &Program) -> Vec { + let mut found = Vec::new(); + for class in &program.classes { + for method in &class.methods { + let Some(code) = method.code.as_ref() else { + continue; + }; + fold_method(&class.this_class, &method.name, &code.insns, &mut found); + } + } + found +} + +#[derive(Clone, Copy)] +enum Val { + I(i32), + L(i64), +} + +impl Val { + fn widen(self) -> i64 { + match self { + Val::I(v) => i64::from(v), + Val::L(v) => v, + } + } +} + +/// A stack slot: the abstract value plus the instruction span that produced +/// it (`start` of its earliest literal, `end` exclusive of the op that +/// closed it). +struct Entry { + val: Val, + start: usize, + end: usize, +} + +fn fold_method(class: &str, method: &str, insns: &[Insn], out: &mut Vec) { + let mut i = 0; + while i < insns.len() { + if !is_literal(&insns[i]) { + i += 1; + continue; + } + let mut stack: Vec = Vec::new(); + let mut j = i; + while j < insns.len() { + match &insns[j] { + Insn::Const(Const::Int(v)) => { + stack.push(Entry { + val: Val::I(*v), + start: j, + end: j + 1, + }); + } + Insn::Const(Const::Long(v)) => { + stack.push(Entry { + val: Val::L(*v), + start: j, + end: j + 1, + }); + } + Insn::Binary { + prim: prim @ (Prim::Int | Prim::Long), + op, + } => { + let Some(b) = stack.pop() else { break }; + let Some(a) = stack.pop() else { break }; + let Some(val) = apply(*prim, *op, a.val, b.val) else { + break; + }; + stack.push(Entry { + val, + start: a.start.min(b.start), + end: j + 1, + }); + } + _ => break, + } + j += 1; + } + // Every stack entry still standing when the run ends is a completed, + // never-consumed sub-expression. A bare literal (span of 1) isn't an + // obfuscated form - only a multi-instruction reduction counts. + for entry in &stack { + if entry.end - entry.start > 1 { + out.push(FoldedConstant { + class: class.to_string(), + method: method.to_string(), + start: entry.start, + len: entry.end - entry.start, + value: entry.val.widen(), + }); + } + } + i = if j > i { j } else { i + 1 }; + } +} + +fn is_literal(insn: &Insn) -> bool { + matches!(insn, Insn::Const(Const::Int(_) | Const::Long(_))) +} + +fn apply(prim: Prim, op: Arith, a: Val, b: Val) -> Option { + match (prim, a, b) { + (Prim::Int, Val::I(a), Val::I(b)) => int_op(op, a, b).map(Val::I), + (Prim::Long, Val::L(a), Val::L(b)) => long_op(op, a, b).map(Val::L), + // A splitting scheme never mixes widths mid-run; a mismatch here + // means this isn't one of ours - stop folding rather than guess. + _ => None, + } +} + +fn int_op(op: Arith, a: i32, b: i32) -> Option { + Some(match op { + Arith::Add => a.wrapping_add(b), + Arith::Sub => a.wrapping_sub(b), + Arith::Mul => a.wrapping_mul(b), + Arith::Div => a.checked_div(b)?, + Arith::Rem => a.checked_rem(b)?, + Arith::And => a & b, + Arith::Or => a | b, + Arith::Xor => a ^ b, + Arith::Shl => a.wrapping_shl(b as u32), + Arith::Shr => a.wrapping_shr(b as u32), + Arith::Ushr => (a as u32).wrapping_shr(b as u32) as i32, + Arith::Neg => return None, + }) +} + +fn long_op(op: Arith, a: i64, b: i64) -> Option { + Some(match op { + Arith::Add => a.wrapping_add(b), + Arith::Sub => a.wrapping_sub(b), + Arith::Mul => a.wrapping_mul(b), + Arith::Div => a.checked_div(b)?, + Arith::Rem => a.checked_rem(b)?, + Arith::And => a & b, + Arith::Or => a | b, + Arith::Xor => a ^ b, + Arith::Shl => a.wrapping_shl(b as u32), + Arith::Shr => a.wrapping_shr(b as u32), + Arith::Ushr => (a as u64).wrapping_shr(b as u32) as i64, + Arith::Neg => return None, + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use noxis_classfile::{ClassBuilder, Code, Version}; + use noxis_core::{Config, Context, Pass}; + use noxis_passes::ConstantsPass; + + use super::*; + + fn sample(values: &[i32]) -> Program { + let mut insns: Vec = values.iter().map(|v| Insn::Const(Const::Int(*v))).collect(); + insns.push(Insn::Return(None)); + let code = Code { + max_stack: 1, + max_locals: 0, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + Program { + classes: vec![ + ClassBuilder::new("demo/App", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0009, "run", "()V", code) + .build(), + ], + resources: Vec::new(), + } + } + + /// A plain literal is not itself an obfuscated form (no arithmetic to + /// fold) - the adversary must not flag it. + #[test] + fn bare_literals_are_not_folded() { + let program = sample(&[1, 2, 3]); + assert!(fold_constants(&program).is_empty()); + } + + /// The correctness oracle: every scheme `Constants` ships must fold back + /// to the exact source value, in source order, even when several split + /// constants sit back to back with nothing separating them - a + /// regression here is a silently wrong obfuscation, not just a weak one. + #[test] + fn every_constants_scheme_folds_back_to_the_source_value() { + for (seed, values) in [ + (1u64, vec![1000, -7, 65536, i32::MAX, i32::MIN]), + (2, vec![42, -65536, 123456789]), + ] { + let mut program = sample(&values); + let mut ctx = Context::new(Config { + seed: Some(seed), + ..Config::default() + }); + ctx.config.constants.max_predicates = 0; // isolate splitting from predicates + ConstantsPass.apply(&mut ctx, &mut program).expect("pass"); + + let folded = fold_constants(&program); + let recovered: Vec = folded.iter().map(|f| f.value).collect(); + let expected: Vec = values.iter().map(|v| i64::from(*v)).collect(); + assert_eq!( + recovered, expected, + "adversary disagrees with source for seed {seed}" + ); + } + } +} diff --git a/crates/noxis-verify/src/lib.rs b/crates/noxis-verify/src/lib.rs index 679daf5..b628aca 100644 --- a/crates/noxis-verify/src/lib.rs +++ b/crates/noxis-verify/src/lib.rs @@ -4,12 +4,16 @@ //! //! * [`leaks`] - утечки исходных имён + секреты открытым текстом; //! * [`java`] - настоящий `java -Xverify:all` по байтам на диске; -//! * [`metrics`] - счётчики программы, раздувание, стабильность маппингов. +//! * [`metrics`] - счётчики программы, раздувание, стабильность маппингов; +//! * [`adversary`] - противник-скрипт: generic свёртка констант как +//! честная мера (§7) и как oracle корректности схем `Constants`. +pub mod adversary; pub mod java; pub mod leaks; pub mod metrics; +pub use adversary::{FoldedConstant, fold_constants}; pub use java::{ DecompileReport, JavaVerdict, decompile_report, jadx_available, java_available, verify_class_bytes, verify_jar,