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-classfile",
|
||||||
"noxis-core",
|
"noxis-core",
|
||||||
"noxis-jar",
|
"noxis-jar",
|
||||||
|
"noxis-passes",
|
||||||
"noxis-structure-check",
|
"noxis-structure-check",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,8 @@ thiserror = { workspace = true }
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
noxis-passes = { workspace = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
noxis-structure-check = { workspace = true }
|
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`] - утечки исходных имён + секреты открытым текстом;
|
//! * [`leaks`] - утечки исходных имён + секреты открытым текстом;
|
||||||
//! * [`java`] - настоящий `java -Xverify:all` по байтам на диске;
|
//! * [`java`] - настоящий `java -Xverify:all` по байтам на диске;
|
||||||
//! * [`metrics`] - счётчики программы, раздувание, стабильность маппингов.
|
//! * [`metrics`] - счётчики программы, раздувание, стабильность маппингов;
|
||||||
|
//! * [`adversary`] - противник-скрипт: generic свёртка констант как
|
||||||
|
//! честная мера (§7) и как oracle корректности схем `Constants`.
|
||||||
|
|
||||||
|
pub mod adversary;
|
||||||
pub mod java;
|
pub mod java;
|
||||||
pub mod leaks;
|
pub mod leaks;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
|
||||||
|
pub use adversary::{FoldedConstant, fold_constants};
|
||||||
pub use java::{
|
pub use java::{
|
||||||
DecompileReport, JavaVerdict, decompile_report, jadx_available, java_available,
|
DecompileReport, JavaVerdict, decompile_report, jadx_available, java_available,
|
||||||
verify_class_bytes, verify_jar,
|
verify_class_bytes, verify_jar,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue