verify: add adversary module - generic constant-folding script
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.
This commit is contained in:
parent
2b19ed6096
commit
59637e50dc
4 changed files with 261 additions and 1 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -383,6 +383,7 @@ dependencies = [
|
|||
"noxis-classfile",
|
||||
"noxis-core",
|
||||
"noxis-jar",
|
||||
"noxis-passes",
|
||||
"noxis-structure-check",
|
||||
"regex",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -18,5 +18,8 @@ thiserror = { workspace = true }
|
|||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
noxis-passes = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
noxis-structure-check = { workspace = true }
|
||||
|
|
|
|||
252
crates/noxis-verify/src/adversary/mod.rs
Normal file
252
crates/noxis-verify/src/adversary/mod.rs
Normal file
|
|
@ -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<FoldedConstant> {
|
||||
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<FoldedConstant>) {
|
||||
let mut i = 0;
|
||||
while i < insns.len() {
|
||||
if !is_literal(&insns[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut stack: Vec<Entry> = 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<Val> {
|
||||
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<i32> {
|
||||
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<i64> {
|
||||
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<Insn> = 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<i64> = folded.iter().map(|f| f.value).collect();
|
||||
let expected: Vec<i64> = values.iter().map(|v| i64::from(*v)).collect();
|
||||
assert_eq!(
|
||||
recovered, expected,
|
||||
"adversary disagrees with source for seed {seed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue