passes: add mba scheme to Constants (and+or+add identity)

(a&b)+(a|b) = a+b, documented honestly: falls to the same symbolic
evaluator as xor/add/shift (every sub-expression is a foldable
LDC,LDC,BINOP triple), but defeats a pattern matcher hardcoded to one
opcode ("ldc,ldc,ixor = split constant"). Added to ConstantsConfig
default schemes.

Tests: mba_form_uses_and_or_add_not_a_single_binop, round-trip coverage
in int_forms_round_trip/long_forms_keep_shape.
This commit is contained in:
Loki 2026-10-14 01:30:10 +02:00
parent 550f62e473
commit 2b19ed6096
Signed by: boba
GPG key ID: 253067914055423B
4 changed files with 133 additions and 10 deletions

View file

@ -124,7 +124,8 @@ impl Default for FlowConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct ConstantsConfig { pub struct ConstantsConfig {
/// Splitting schemes (`xor`, `add`, `shift`), picked by RNG per constant. /// Splitting schemes (`xor`, `add`, `shift`, `mba`), picked by RNG per
/// constant.
pub schemes: Vec<String>, pub schemes: Vec<String>,
/// Insert opaque predicates (at most this many per method). /// Insert opaque predicates (at most this many per method).
pub max_predicates: usize, pub max_predicates: usize,
@ -135,7 +136,12 @@ pub struct ConstantsConfig {
impl Default for ConstantsConfig { impl Default for ConstantsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
schemes: vec!["xor".to_string(), "add".to_string(), "shift".to_string()], schemes: vec![
"xor".to_string(),
"add".to_string(),
"shift".to_string(),
"mba".to_string(),
],
max_predicates: 1, max_predicates: 1,
skip: Vec::new(), skip: Vec::new(),
} }

View file

@ -1,8 +1,16 @@
//! Constant-splitting schemes and opaque predicates. //! Constant-splitting schemes and opaque predicates.
//! //!
//! Integer constants outside the `iconst` range are replaced by triples //! Integer constants outside the `iconst` range are replaced by triples
//! (`x^k`, `a+b`, shift); an opaque predicate is an always-true condition with //! (`x^k`, `a+b`, shift) or the `mba` form below; an opaque predicate is an
//! a one-`nop` dead branch (valid, never executed, breaks linear disassembly). //! always-true condition with a one-`nop` dead branch (valid, never
//! executed, breaks linear disassembly).
//!
//! None of this survives a real constant-propagation pass (every operand is
//! a literal, so a general evaluator folds the whole expression regardless
//! of depth) - that is a known, documented limit, not a claim otherwise.
//! `mba` raises the bar against a *pattern* matcher instead: `xor`/`add`
//! hardcode one opcode triple as "this is a split constant", `mba` needs the
//! `(a&b)+(a|b) = a+b` identity specifically.
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim}; use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim};
use noxis_core::Rng; use noxis_core::Rng;
@ -13,6 +21,7 @@ pub(super) enum Scheme {
Xor, Xor,
Add, Add,
Shift, Shift,
Mba,
} }
impl Scheme { impl Scheme {
@ -21,6 +30,7 @@ impl Scheme {
"xor" => Some(Scheme::Xor), "xor" => Some(Scheme::Xor),
"add" => Some(Scheme::Add), "add" => Some(Scheme::Add),
"shift" => Some(Scheme::Shift), "shift" => Some(Scheme::Shift),
"mba" => Some(Scheme::Mba),
_ => None, _ => None,
} }
} }
@ -36,7 +46,7 @@ pub(super) fn encrypt_int(value: i32, scheme: Scheme, rng: &mut Rng) -> Vec<Insn
} }
(value ^ key, key) (value ^ key, key)
} }
Scheme::Add => { Scheme::Add | Scheme::Mba => {
let part = rng.next_u64() as i32; let part = rng.next_u64() as i32;
(part, value.wrapping_sub(part)) (part, value.wrapping_sub(part))
} }
@ -46,6 +56,7 @@ pub(super) fn encrypt_int(value: i32, scheme: Scheme, rng: &mut Rng) -> Vec<Insn
Scheme::Xor => binary(first, second, Arith::Xor, Prim::Int), Scheme::Xor => binary(first, second, Arith::Xor, Prim::Int),
Scheme::Add => binary(first, second, Arith::Add, Prim::Int), Scheme::Add => binary(first, second, Arith::Add, Prim::Int),
Scheme::Shift => shift_form(value), Scheme::Shift => shift_form(value),
Scheme::Mba => mba_form(first, second),
} }
} }
@ -59,7 +70,7 @@ pub(super) fn encrypt_long(value: i64, scheme: Scheme, rng: &mut Rng) -> Vec<Ins
} }
(value ^ key, key) (value ^ key, key)
} }
Scheme::Add => { Scheme::Add | Scheme::Mba => {
let part = rng.next_u64() as i64; let part = rng.next_u64() as i64;
(part, value.wrapping_sub(part)) (part, value.wrapping_sub(part))
} }
@ -69,6 +80,7 @@ pub(super) fn encrypt_long(value: i64, scheme: Scheme, rng: &mut Rng) -> Vec<Ins
Scheme::Xor => binary_long(first, second, Arith::Xor), Scheme::Xor => binary_long(first, second, Arith::Xor),
Scheme::Add => binary_long(first, second, Arith::Add), Scheme::Add => binary_long(first, second, Arith::Add),
Scheme::Shift => shift_form_long(value), Scheme::Shift => shift_form_long(value),
Scheme::Mba => mba_form_long(first, second),
} }
} }
@ -92,6 +104,53 @@ fn binary_long(first: i64, second: i64, op: Arith) -> Vec<Insn> {
] ]
} }
/// MBA identity: `a+b == (a&b) + (a|b)` (exact, bit-exact under wrapping).
/// `a`/`b` are pushed twice (no stack juggling needed) so each sub-expression
/// stays a plain, correct `and`/`or` - the shape, not a single opcode, is
/// what a matcher has to recognize.
fn mba_form(a: i32, b: i32) -> Vec<Insn> {
vec![
Insn::Const(Const::Int(a)),
Insn::Const(Const::Int(b)),
Insn::Binary {
prim: Prim::Int,
op: Arith::And,
},
Insn::Const(Const::Int(a)),
Insn::Const(Const::Int(b)),
Insn::Binary {
prim: Prim::Int,
op: Arith::Or,
},
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
]
}
/// The same for `long`.
fn mba_form_long(a: i64, b: i64) -> Vec<Insn> {
vec![
Insn::Const(Const::Long(a)),
Insn::Const(Const::Long(b)),
Insn::Binary {
prim: Prim::Long,
op: Arith::And,
},
Insn::Const(Const::Long(a)),
Insn::Const(Const::Long(b)),
Insn::Binary {
prim: Prim::Long,
op: Arith::Or,
},
Insn::Binary {
prim: Prim::Long,
op: Arith::Add,
},
]
}
/// `v = ((v >> 1) << 1) | (v & 1)` - works for any value, including negatives. /// `v = ((v >> 1) << 1) | (v & 1)` - works for any value, including negatives.
fn shift_form(value: i32) -> Vec<Insn> { fn shift_form(value: i32) -> Vec<Insn> {
vec![ vec![

View file

@ -30,6 +30,30 @@ fn eval_int(insns: &[Insn]) -> i32 {
Insn::Const(Const::Int(b)), Insn::Const(Const::Int(b)),
Insn::Binary { .. }, Insn::Binary { .. },
] => (a << 1) | (b & 1), ] => (a << 1) | (b & 1),
// MBA: `(a&b) + (a|b) == a+b`. Each sub-expression is still a
// foldable `LDC,LDC,BINOP` triple - a generic constant evaluator
// still resolves this instantly. What it defeats is a *pattern*
// matcher hardcoded to one opcode (`ldc,ldc,ixor` = "this is a
// split constant"): it would need to recognize the `and+or+add`
// identity specifically, not just fold a literal pair.
[
Insn::Const(Const::Int(a1)),
Insn::Const(Const::Int(b1)),
Insn::Binary {
op: noxis_classfile::Arith::And,
..
},
Insn::Const(Const::Int(a2)),
Insn::Const(Const::Int(b2)),
Insn::Binary {
op: noxis_classfile::Arith::Or,
..
},
Insn::Binary {
op: noxis_classfile::Arith::Add,
..
},
] if a1 == a2 && b1 == b2 => (a1 & b1).wrapping_add(a1 | b1),
_ => panic!("not a form: {insns:?}"), _ => panic!("not a form: {insns:?}"),
} }
} }
@ -38,7 +62,7 @@ fn eval_int(insns: &[Insn]) -> i32 {
fn int_forms_round_trip() { fn int_forms_round_trip() {
let mut rng = noxis_core::Rng::from_seed(11); let mut rng = noxis_core::Rng::from_seed(11);
for value in [-1000, -7, -6, 6, 7, 42, 65536, i32::MAX, i32::MIN] { for value in [-1000, -7, -6, 6, 7, 42, 65536, i32::MAX, i32::MIN] {
for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift] { for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift, Scheme::Mba] {
assert_eq!(eval_int(&encrypt_int(value, scheme, &mut rng)), value); assert_eq!(eval_int(&encrypt_int(value, scheme, &mut rng)), value);
} }
} }
@ -47,7 +71,7 @@ fn int_forms_round_trip() {
#[test] #[test]
fn long_forms_keep_shape() { fn long_forms_keep_shape() {
let mut rng = noxis_core::Rng::from_seed(12); let mut rng = noxis_core::Rng::from_seed(12);
for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift] { for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift, Scheme::Mba] {
let insns = encrypt_long(1_000_000_000_000, scheme, &mut rng); let insns = encrypt_long(1_000_000_000_000, scheme, &mut rng);
assert!(matches!( assert!(matches!(
insns.last(), insns.last(),
@ -59,6 +83,34 @@ fn long_forms_keep_shape() {
} }
} }
/// MBA shape: `and`/`or`/`add` on two reused operands, not `xor`/`add` on a
/// single pair - a matcher hardcoded to one opcode triple (`ldc,ldc,ixor`)
/// doesn't recognize this as "a split constant" without the identity.
#[test]
fn mba_form_uses_and_or_add_not_a_single_binop() {
let mut rng = noxis_core::Rng::from_seed(13);
for value in [-1000, -6, 6, 42, 65536, i32::MAX, i32::MIN] {
let insns = encrypt_int(value, Scheme::Mba, &mut rng);
let ops: Vec<_> = insns
.iter()
.filter_map(|i| match i {
Insn::Binary { op, .. } => Some(*op),
_ => None,
})
.collect();
assert_eq!(
ops,
[
noxis_classfile::Arith::And,
noxis_classfile::Arith::Or,
noxis_classfile::Arith::Add,
],
"unexpected mba shape: {insns:?}"
);
assert_eq!(insns.len(), 7, "unexpected mba insn count: {insns:?}");
}
}
fn sample() -> Program { fn sample() -> Program {
let code = Code { let code = Code {
max_stack: 1, max_stack: 1,

View file

@ -82,8 +82,14 @@ opaque-предикаты - условия, истинность которых
- Шаблонный opaque-предикат (всегда один и тот же `A+B`) узнаётся - Шаблонный opaque-предикат (всегда один и тот же `A+B`) узнаётся
паттерном - отсюда требование нескольких схем. паттерном - отсюда требование нескольких схем.
**Статус: готово** для `int`/`long` (`xor`/`add`/`shift` + opaque-предикаты); **Статус: готово** для `int`/`long` (`xor`/`add`/`shift`/`mba` +
`float`/`double` не трогаются. opaque-предикаты); `float`/`double` не трогаются. `mba` (`(a&b)+(a|b)=a+b`,
13.10.2026) не ломает честную оговорку выше - он раскрывается тем же
символьным вычислителем, что и `xor`/`add`/`shift`. Разница в том, что
бьёт: `xor`/`add` сворачиваются одним паттерном «два литерала + одна
операция»; `mba` требует опознать саму тождество `and+or+add`, а не просто
свернуть литералы - отсекает деобфускаторы с жёстко вшитым паттерном на
один опкод, но не символьные вычислители.
## 4. Обфускация контрольного потока (`Flow`) ## 4. Обфускация контрольного потока (`Flow`)