diff --git a/README.md b/README.md index 4d592b6..72b20a2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ | пассы: rename, resources, strings, constants, noise, debug | ✅ | | верификация: red-паттерны, `java -Xverify:all`, метрики | ✅ | | CLI (`obfuscate` / `analyze` / `patterns`) | ✅ | -| пасс flow (кадры готовы, схем флаттенинга пока нет) | 🚧 | +| пасс flow (кадры готовы, 4 схемы: state-spread, computed-switch, butterfly, exception-goto) | ✅ | Подробности в [`docs/architecture.md`](docs/architecture.md), план в [`todo.md`](todo.md). @@ -50,12 +50,8 @@ cargo run -p noxis -- patterns --input output.jar ``` `obfuscate` по умолчанию включает все готовые пассы. Флаги `--no-rename`, -`--no-strings`, `--no-constants`, `--no-noise`, `--no-resources`, `--no-debug-info` -выключают их по отдельности. `--no-flow` честно падает с ошибкой: пасса пока нет. - -Одно `cargo build` уже гоняет проверку структуры кода: `.rs`-файл длиннее -250 строк или директория с больше чем 4 `.rs`-файлами ломают сборку. -Документация (`docs/**`) под это правило не попадает. Правила в [`docs/README.md`](docs/README.md). +`--no-strings`, `--no-constants`, `--no-noise`, `--no-resources`, `--no-debug-info`, +`--no-flow` выключают их по отдельности. ## Структура diff --git a/crates/noxis-classfile/src/format/frames/emit.rs b/crates/noxis-classfile/src/format/frames/emit.rs index 9bb758f..901b0f3 100644 --- a/crates/noxis-classfile/src/format/frames/emit.rs +++ b/crates/noxis-classfile/src/format/frames/emit.rs @@ -35,7 +35,7 @@ pub(super) fn emit_frames( Some(previous) => target .checked_sub(previous) .and_then(|delta| delta.checked_sub(1)) - .ok_or_else(|| merger.invalid("кадры не по порядку"))?, + .ok_or_else(|| merger.invalid("frames out of order"))?, }; out.push(select(delta, &state.locals, &previous_locals, &state.stack)); previous_locals = state.locals.clone(); @@ -54,13 +54,17 @@ fn select( // otherwise halves would produce `Append{[Top, Long]}`. let current = vars(locals); let base = vars(previous); + // The operand stack needs the same collapsing: a `long`/`double` is one + // entry (`javac` writes `[Double, Integer]`, not `[Double, Top, Integer]`); + // an explicit half without its value is rejected by HotSpot. + let stack = stack_vars(stack); if current == base { if stack.is_empty() { return StackMapFrame::Same { offset_delta: delta, }; } - if let [local] = stack { + if let [local] = stack.as_slice() { return StackMapFrame::SameLocals { offset_delta: delta, local: local.clone(), @@ -94,6 +98,30 @@ fn select( } } +/// Stack entries → values: `[Double, Top]` — one entry (like `javac`). +/// +/// Unlike [`vars`], no trailing-`Top` trimming: every stack entry is live by +/// construction, and dropping one would corrupt the depth. +fn stack_vars(stack: &[VerificationType]) -> Vec { + let mut out = Vec::with_capacity(stack.len()); + let mut index = 0; + while index < stack.len() { + match &stack[index] { + VerificationType::Long | VerificationType::Double + if stack.get(index + 1) == Some(&VerificationType::Top) => + { + out.push(stack[index].clone()); + index += 2; + } + other => { + out.push(other.clone()); + index += 1; + } + } + } + out +} + /// Slots → variables: `[Long, Top]` — one entry; trailing `Top`s — gone. /// /// Single `Top`s inside (holes) are kept: in `Full` they are a legal type, and we diff --git a/crates/noxis-classfile/tests/frames.rs b/crates/noxis-classfile/tests/frames.rs index e55e47e..b1f9281 100644 --- a/crates/noxis-classfile/tests/frames.rs +++ b/crates/noxis-classfile/tests/frames.rs @@ -1,9 +1,9 @@ -//! Приёмка `compute_frames` на фикстурах (`tests/data`). +//! Acceptance for `compute_frames`: fixtures plus emitter edge cases. //! -//! Кадры сносятся и считаются заново. Судьи: детерминизм + round-trip IR + -//! настоящий `java -Xverify:all` для классов без внешних зависимостей. -//! (`verify_class` backend'а для приёмки не годится: он ждёт кадры в байтах, -//! а `from_bytes` отдаёт в индексах — см. `todo.md`.) +//! Frames are dropped and recomputed. Judges: determinism + IR round-trip + +//! real `java -Xverify:all` for classes without external dependencies. +//! (The backend's `verify_class` is unfit for acceptance: it expects frames +//! in bytes while `from_bytes` hands out indices — see `todo.md`.) #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -11,9 +11,12 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; -use noxis_classfile::{Hierarchy, compute_frames, format}; +use noxis_classfile::{ + Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, Prim, SlotKind, Version, + {Hierarchy, compute_frames, format}, +}; -/// Карта `класс → суперкласс` для LUB. +/// A `class → superclass` map for LUB. #[derive(Default)] struct MapHierarchy { supers: BTreeMap, @@ -25,13 +28,13 @@ impl Hierarchy for MapHierarchy { } } -/// Классы только на JDK — их `java` проверит полностью. +/// JDK-only classes — `java` checks them fully. const JDK_ONLY: [&str; 2] = [ "dev/loki/lorep/util/TimeUtil", "dev/loki/lorep/util/PaginationUtil", ]; -/// Фикстуры: снести кадры, посчитать заново, проверить выход. +/// Fixtures: drop frames, recompute, check the output. #[test] fn fixtures_recompute_and_verify() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data"); @@ -41,7 +44,7 @@ fn fixtures_recompute_and_verify() { .filter(|path| path.extension().is_some_and(|ext| ext == "class")) .collect(); names.sort(); - assert!(!names.is_empty(), "нет фикстур в tests/data"); + assert!(!names.is_empty(), "no fixtures in tests/data"); let mut supers = BTreeMap::new(); let mut parsed = Vec::new(); @@ -50,7 +53,7 @@ fn fixtures_recompute_and_verify() { supers.insert(class.this_class.clone(), class.super_class.clone()); parsed.push(class); } - // Без суперкласса — только `java/lang/Object`. + // Only `java/lang/Object` has no superclass. supers.entry("java/lang/Object".to_string()).or_insert(None); let hierarchy = MapHierarchy { supers: supers @@ -71,11 +74,11 @@ fn fixtures_recompute_and_verify() { } let first = compute_frames(&class.this_class, method, &hierarchy).expect("recompute"); method.code.as_mut().expect("code").frames = first.clone(); - // Детерминизм: повторный прогон даёт те же кадры. + // Determinism: a second run yields the same frames. let second = compute_frames(&class.this_class, method, &hierarchy).expect("recompute"); - assert_eq!(first, second, "недетерминированные кадры"); + assert_eq!(first, second, "nondeterministic frames"); } - // Round-trip: записанное читается в тот же IR (включая кадры). + // Round-trip: written bytes read back into the same IR (frames included). let written = format::write(class).expect("write"); assert_eq!( format::parse(&written).expect("re-parse"), @@ -89,7 +92,85 @@ fn fixtures_recompute_and_verify() { let _ = std::fs::remove_dir_all(&scratch); } -/// Есть ли `java` для проверки настоящим верификатором. +/// A join with a `double` below on the stack must emit it as ONE entry. +/// +/// Regression: the emitter used to write the slot pair (`[Double, Top]`), +/// like `javac` does for locals — but on the operand stack `javac` writes a +/// bare `[Double]`, and HotSpot rejects the pair with +/// `Inconsistent stackmap frames`. +#[test] +fn wide_stack_join_emits_single_entries() { + // iload_0, i2d, iload_1, , i2d, ddiv, d2i, ireturn. + // At the join the stack holds a double plus an int. + let (dead, into) = (100, 101); + let mut insns = vec![ + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Convert(noxis_classfile::Convert::I2D), + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + ]; + insns.extend([ + Insn::Const(Const::Int(7)), + Insn::Dup, + Insn::Binary { + prim: Prim::Int, + op: Arith::Mul, + }, + Insn::Const(Const::Int(7)), + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::Const(Const::Int(1)), + Insn::Binary { + prim: Prim::Int, + op: Arith::And, + }, + Insn::Branch(BranchCond::Zero(Cond::Ne), dead), + Insn::Goto(into), + Insn::Mark(dead), + Insn::Nop, + Insn::Mark(into), + Insn::Convert(noxis_classfile::Convert::I2D), + Insn::Binary { + prim: Prim::Double, + op: Arith::Div, + }, + Insn::Convert(noxis_classfile::Convert::D2I), + Insn::Return(Some(Prim::Int)), + ]); + let code = Code { + max_stack: 6, + max_locals: 2, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let class = ClassBuilder::new("demo/Wide", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0009, "calc", "(II)I", code) + .build(); + let hierarchy = MapHierarchy::default(); + let frames = compute_frames(&class.this_class, &class.methods[0], &hierarchy).expect("frames"); + let mut class = class; + class.methods[0].code.as_mut().expect("code").frames = frames; + if !java_available() { + eprintln!("SKIP: no java"); + return; + } + let scratch = std::env::temp_dir().join(format!("noxis-wide-{}", std::process::id())); + let bytes = format::write(&class).expect("write"); + java_verify(&scratch, &class, &bytes).expect("java -Xverify:all"); + let _ = std::fs::remove_dir_all(&scratch); +} + +/// Whether `java` is available for checks with the real verifier. fn java_available() -> bool { Command::new("java") .arg("-version") @@ -97,8 +178,8 @@ fn java_available() -> bool { .is_ok_and(|out| out.status.success()) } -/// Записать класс в дерево пакетов и прогнать `java -Xverify:all`. -/// Успех — загрузка без ошибок верификации (`main` при этом отсутствует). +/// Write a class into a package tree and run `java -Xverify:all`. +/// Success means loading without verification errors (`main` may be missing). fn java_verify( scratch: &Path, class: &noxis_classfile::ClassFile, @@ -118,7 +199,7 @@ fn java_verify( .arg(scratch) .arg(&dotted) .output() - .map_err(|e| format!("запуск java: {e}"))?; + .map_err(|e| format!("running java: {e}"))?; let stderr = String::from_utf8_lossy(&out.stderr); for bad in [ "VerifyError", diff --git a/crates/noxis-passes/src/flow/dispatch.rs b/crates/noxis-passes/src/flow/dispatch.rs new file mode 100644 index 0000000..22ab6e6 --- /dev/null +++ b/crates/noxis-passes/src/flow/dispatch.rs @@ -0,0 +1,96 @@ +//! Entry dispatchers: opaque `switch` prologues and opaque guards. +//! +//! Every emitter here is stack-neutral as a whole: whatever the prologue +//! pushes it also consumes before falling through to `body`. That is what +//! makes prepending safe at method entry (empty stack) without any dataflow +//! analysis — all joins at `body` carry an empty stack, so any computed +//! `StackMapTable` stays consistent. + +use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim, SlotKind}; +use noxis_core::Rng; + +/// Dense `tableswitch` dispatcher (`state-spread`). +/// +/// The opaque key is computed as `a + b` (two constants, never the key +/// itself) and dispatched over `[key-2, key+2]`; only the middle slot reaches +/// `body`, the rest fall into a dead block that jumps back to `body`. +/// Does not emit `Mark(body)` — the caller places it. +pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec { + let key = 100 + (rng.next_u64() % 10_000) as i32; + let half = (rng.next_u64() as i32) & 0x3FFF_FFFF; + let low = key - 2; + vec![ + Insn::Const(Const::Int(half)), + Insn::Const(Const::Int(key.wrapping_sub(half))), + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::TableSwitch { + default: dead, + low, + high: key + 2, + targets: vec![dead, dead, body, dead, dead], + }, + Insn::Mark(dead), + Insn::Nop, + Insn::Goto(body), + ] +} + +/// Sparse `lookupswitch` dispatcher over a counter local (`computed-switch`). +/// +/// The counter lives in `slot` (a fresh local owned by the caller): the shape +/// differs from `table_dispatch` on purpose — a sparse switch over a variable +/// instead of a dense range over a stack value. Junk keys never equal `key`. +/// Does not emit `Mark(body)` — the caller places it. +pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) -> Vec { + let key = 100 + (rng.next_u64() % 10_000) as i32; + let mut keys = vec![key]; + while keys.len() < 4 { + let candidate = (rng.next_u64() % 20_000) as i32 - 5_000; + if !keys.contains(&candidate) { + keys.push(candidate); + } + } + let mut pairs: Vec<(i32, u32)> = keys + .into_iter() + .map(|k| (k, if k == key { body } else { dead })) + .collect(); + // The JVM requires lookupswitch pairs sorted by key. + pairs.sort_by_key(|(k, _)| *k); + vec![ + Insn::Const(Const::Int(key)), + Insn::Store { + slot, + kind: SlotKind::Int, + }, + Insn::Load { + slot, + kind: SlotKind::Int, + }, + Insn::LookupSwitch { + default: dead, + pairs, + }, + Insn::Mark(dead), + Insn::Nop, + Insn::Goto(body), + ] +} + +/// Always-true opaque guard `(C*C + C) & 1 == 0` with a dead `nop` branch. +/// +/// Stack-neutral: the computed value is consumed by the branch that never +/// fires. Ends with `Mark(merge)` — execution continues there. +/// Same construction as the constants pass uses; the predicate core is shared +/// with the flow splices (`rewire::opaque_compute`). +pub(super) fn opaque_guard(dead: u32, merge: u32, rng: &mut Rng) -> Vec { + let mut guard = super::rewire::opaque_compute(rng); + guard.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead)); + guard.push(Insn::Goto(merge)); + guard.push(Insn::Mark(dead)); + guard.push(Insn::Nop); + guard.push(Insn::Mark(merge)); + guard +} diff --git a/crates/noxis-passes/src/flow/mod.rs b/crates/noxis-passes/src/flow/mod.rs new file mode 100644 index 0000000..834faa6 --- /dev/null +++ b/crates/noxis-passes/src/flow/mod.rs @@ -0,0 +1,270 @@ +//! Control-flow obfuscation: dispatcher prologues plus CFG rewiring. +//! +//! One scheme per method (rotation), picked from `FlowConfig::schemes`: +//! - `state-spread`: dense `tableswitch` entry dispatcher; +//! - `computed-switch`: sparse `lookupswitch` over a counter local; +//! - `butterfly`: every branch inverted, stream cut with `goto` splices; +//! - `exception-goto`: dispatcher inside `try`, dead catch-all rejoins body. +//! +//! Every scheme also cuts the stream with a few `goto` splices (intra-method +//! mixing) and recomputes `StackMapTable` afterwards, so the only thing a +//! scheme may assume is an empty stack at method entry — joins stay consistent +//! by construction (see `dispatch`/`rewire` for why each shape is safe). + +mod dispatch; +mod rewire; + +#[cfg(test)] +mod tests; + +use dispatch::{lookup_dispatch, opaque_guard, table_dispatch}; +use noxis_analysis::ClassHierarchy; +use noxis_classfile::{Code, Insn, Label, Program, compute_frames}; +use noxis_core::{Context, CoreError, Pass, PassId, PassReport, Rng}; +use rewire::{dead_catch, invert_branches, split_blocks}; + +/// Control-flow obfuscation pass. +#[derive(Debug, Default, Clone, Copy)] +pub struct FlowPass; + +/// Dispatcher scheme (names from `FlowConfig::schemes`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Scheme { + StateSpread, + ComputedSwitch, + Butterfly, + ExceptionGoto, +} + +impl Scheme { + fn parse(name: &str) -> Option { + match name { + "state-spread" => Some(Scheme::StateSpread), + "computed-switch" => Some(Scheme::ComputedSwitch), + "butterfly" => Some(Scheme::Butterfly), + "exception-goto" => Some(Scheme::ExceptionGoto), + _ => None, + } + } +} + +/// Fresh-label allocator over one method body. +pub(crate) struct LabelGen { + next: Label, +} + +impl LabelGen { + fn of(code: &Code) -> Self { + let top = code.labels().into_iter().max().unwrap_or(0); + Self { + next: top.saturating_add(1), + } + } + + fn next(&mut self) -> Label { + let label = self.next; + self.next = self.next.saturating_add(1); + label + } +} + +impl Pass for FlowPass { + fn id(&self) -> PassId { + PassId::Flow + } + + fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result { + let cfg = ctx.config.flow.clone(); + let mut schemes = Vec::with_capacity(cfg.schemes.len()); + for name in &cfg.schemes { + schemes.push( + Scheme::parse(name) + .ok_or_else(|| pass_error(format!("unknown scheme `{name}`")))?, + ); + } + if schemes.is_empty() { + return Err(pass_error("no control-flow schemes configured")); + } + let mut rng = ctx.rng("flow"); + let mut mutated: Vec<(usize, usize)> = Vec::new(); + let mut per_scheme = [0usize; 4]; + let mut skipped = 0usize; + + for (ci, class) in program.classes.iter_mut().enumerate() { + for (mi, method) in class.methods.iter_mut().enumerate() { + let Some(code) = method.code.as_mut() else { + continue; + }; + if skippable(&method.name, code, cfg.max_insns) { + skipped += 1; + continue; + } + let scheme = schemes[rng.below(schemes.len() as u64) as usize]; + apply_scheme(code, scheme, &cfg, &mut rng); + per_scheme[scheme_index(scheme)] += 1; + mutated.push((ci, mi)); + } + } + let hierarchy = ClassHierarchy::build(program); + for (ci, mi) in &mutated { + refresh_frames(program, *ci, *mi, &hierarchy)?; + } + Ok(report(&per_scheme, mutated.len(), skipped)) + } +} + +/// Whether a method body is left alone. +fn skippable(name: &str, code: &Code, max_insns: usize) -> bool { + // The super-call-first shape of a constructor must survive: no prologue + // may run before it. + if name == "" { + return true; + } + // Frames are never computed for subroutines — keep them byte-identical. + if code + .insns + .iter() + .any(|insn| matches!(insn, Insn::Jsr(_) | Insn::Ret(_))) + { + return true; + } + let real = code + .insns + .iter() + .filter(|insn| !matches!(insn, Insn::Mark(_))) + .count(); + if real < 3 { + return true; + } + if code.insns.len() > max_insns { + return true; + } + false +} + +/// Rebuild one method body with the given scheme; prologue stays first. +fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, rng: &mut Rng) { + let mut labels = LabelGen::of(code); + let body = labels.next(); + let dead = labels.next(); + if scheme == Scheme::Butterfly { + invert_branches(&mut code.insns, &mut labels, rng, 100); + } + let real = code + .insns + .iter() + .filter(|insn| !matches!(insn, Insn::Mark(_))) + .count(); + let mut prologue = Vec::new(); + if cfg.encrypt_block_bodies { + let guard_dead = labels.next(); + let guard_merge = labels.next(); + prologue.extend(opaque_guard(guard_dead, guard_merge, rng)); + } + match scheme { + Scheme::StateSpread => { + prologue.extend(table_dispatch(body, dead, rng)); + prologue.push(Insn::Mark(body)); + } + Scheme::ComputedSwitch if code.max_locals == u16::MAX => { + // No free local slot — fall back to the stack-based dispatcher. + prologue.extend(table_dispatch(body, dead, rng)); + prologue.push(Insn::Mark(body)); + } + Scheme::ComputedSwitch => { + let slot = code.max_locals; + prologue.extend(lookup_dispatch(body, dead, slot, rng)); + prologue.push(Insn::Mark(body)); + code.max_locals = code.max_locals.saturating_add(1); + } + Scheme::Butterfly => { + prologue.push(Insn::Mark(body)); + } + Scheme::ExceptionGoto => { + let try_start = labels.next(); + let handler = labels.next(); + let mut guarded = vec![Insn::Mark(try_start)]; + guarded.append(&mut prologue); + guarded.extend(table_dispatch(body, dead, rng)); + // No skip-goto after the dead block: the dispatcher's dead + // `Goto(body)` already jumps over the handler. A second goto + // would sit after a terminal with no frame of its own, and + // HotSpot requires a frame at every block start — even an + // unreachable one. try_e IS the dead mark for the same reason. + let (mut handler_code, entry) = dead_catch(try_start, dead, handler, body); + guarded.append(&mut handler_code); + guarded.push(Insn::Mark(body)); + prologue = guarded; + code.exception_table.push(entry); + } + } + // A `nop` pads the body mark: the original stream may start with its own + // mark (even a handler entry), and two marks on one offset would merge + // frames from inconsistent states. + prologue.push(Insn::Nop); + let cuts = (real / 16).clamp(1, 8); + let start = prologue.len(); + let mut insns = prologue; + insns.append(&mut code.insns); + split_blocks(&mut insns, &mut labels, rng, start, cuts); + code.insns = insns; + code.max_stack = code.max_stack.saturating_add(2); +} + +fn scheme_index(scheme: Scheme) -> usize { + match scheme { + Scheme::StateSpread => 0, + Scheme::ComputedSwitch => 1, + Scheme::Butterfly => 2, + Scheme::ExceptionGoto => 3, + } +} + +/// Recompute the frames of a touched method. +fn refresh_frames( + program: &mut Program, + ci: usize, + mi: usize, + hierarchy: &ClassHierarchy, +) -> Result<(), CoreError> { + let (name, snapshot) = { + let class = program + .classes + .get(ci) + .ok_or_else(|| pass_error("class vanished mid-pass"))?; + ( + class.this_class.clone(), + class + .methods + .get(mi) + .cloned() + .ok_or_else(|| pass_error("method vanished mid-pass"))?, + ) + }; + let frames = + compute_frames(&name, &snapshot, hierarchy).map_err(|e| pass_error(e.to_string()))?; + program + .classes + .get_mut(ci) + .and_then(|c| c.methods.get_mut(mi)) + .and_then(|m| m.code.as_mut()) + .ok_or_else(|| pass_error("method vanished mid-pass"))? + .frames = frames; + Ok(()) +} + +fn report(per_scheme: &[usize; 4], mutated: usize, skipped: usize) -> PassReport { + PassReport { + pass: PassId::Flow, + transformed: mutated, + skipped, + notes: vec![format!( + "methods rebuilt: {mutated} (state-spread: {}, computed-switch: {}, butterfly: {}, exception-goto: {})", + per_scheme[0], per_scheme[1], per_scheme[2], per_scheme[3] + )], + } +} + +fn pass_error(msg: impl Into) -> CoreError { + CoreError::pass(PassId::Flow, msg) +} diff --git a/crates/noxis-passes/src/flow/rewire.rs b/crates/noxis-passes/src/flow/rewire.rs new file mode 100644 index 0000000..c1fbde9 --- /dev/null +++ b/crates/noxis-passes/src/flow/rewire.rs @@ -0,0 +1,230 @@ +//! CFG rewiring: branch inversion, block splitting, exception routing. +//! +//! All rewrites preserve semantics without dataflow analysis: +//! - inverting a branch negates its condition and swaps target/fallthrough; +//! - a `goto` + dead `nop` splice is stack-neutral at any cut point, because +//! both joins carry the same stack the original stream had; +//! - the dead catch-all rejoins `body` with an empty stack after popping the +//! exception, so it merges with the dispatcher's empty-stack edges. + +use noxis_classfile::{BranchCond, Cond, ExceptionHandler, Insn}; +use noxis_core::Rng; + +/// Logical negation of a branch condition. +pub(super) fn negate(cond: BranchCond) -> BranchCond { + match cond { + BranchCond::Zero(c) => BranchCond::Zero(negate_cond(c)), + BranchCond::Int(c) => BranchCond::Int(negate_cond(c)), + BranchCond::Ref { eq } => BranchCond::Ref { eq: !eq }, + BranchCond::Null { is_null } => BranchCond::Null { is_null: !is_null }, + } +} + +fn negate_cond(cond: Cond) -> Cond { + match cond { + Cond::Eq => Cond::Ne, + Cond::Ne => Cond::Eq, + Cond::Lt => Cond::Ge, + Cond::Le => Cond::Gt, + Cond::Gt => Cond::Le, + Cond::Ge => Cond::Lt, + } +} + +/// Turn `if-cond T` into `if-not SKIP; goto T; SKIP:` (`butterfly`). +/// +/// `rate` is a 0–100 percentage applied per branch. Semantics are unchanged: +/// the inverted branch skips a `goto` to the original target, so the taken / +/// not-taken destinations are exactly the old ones. A `nop` pads the fresh +/// mark: whatever follows (an original mark, even a handler entry with its +/// own stack) must not share a bytecode offset with it, or their frames +/// would merge from inconsistent states. +pub(super) fn invert_branches( + insns: &mut Vec, + fresh: &mut crate::flow::LabelGen, + rng: &mut Rng, + rate: u64, +) { + let mut index = 0; + while index < insns.len() { + let (cond, target) = match &insns[index] { + Insn::Branch(cond, target) => (*cond, *target), + _ => { + index += 1; + continue; + } + }; + if rng.below(100) >= rate { + index += 1; + continue; + } + let skip = fresh.next(); + insns.splice( + index..=index, + [ + Insn::Branch(negate(cond), skip), + Insn::Goto(target), + Insn::Mark(skip), + Insn::Nop, + ], + ); + index += 4; + } +} + +/// Cut the stream with opaque-entered splices. +/// +/// Each splice computes an always-false opaque predicate `(C*C + C) & 1 != 0` +/// and branches on it: the fallthrough continues the stream, the dead block +/// throws instead of rejoining. Throwing (rather than jumping back) keeps the +/// splice valid at *any* stack depth — `athrow` has no successor, so there is +/// nothing to merge — and the dead block still owns a frame through its entry +/// branch, which HotSpot requires even for code that never executes. +/// +/// Cuts land at or after `start` (the entry prologue must stay first) and +/// only at reachable positions: a splice in an unreachable gap would leave +/// its `into` mark without a computed state, and the verifier rejects jump +/// targets without frames. +/// +/// A cut is also pushed past a run of consecutive marks. Inserting *before* a +/// targeted mark would reroute its incoming branch edges over the splice, +/// orphaning the splice itself: the mark must keep its edges, so the splice +/// goes after it and inherits its reachability through fallthrough. +pub(super) fn split_blocks( + insns: &mut Vec, + fresh: &mut crate::flow::LabelGen, + rng: &mut Rng, + start: usize, + count: usize, +) { + let live = reachable(insns); + let mut cuts = Vec::with_capacity(count); + for _ in 0..count { + let allowed: Vec = (start..insns.len()) + .filter(|c| live.get(*c).copied().unwrap_or(false)) + .collect(); + if allowed.is_empty() { + break; + } + let mut cut = allowed[rng.below(allowed.len() as u64) as usize]; + while cut < insns.len() && matches!(insns[cut], Insn::Mark(_)) { + cut += 1; + } + // A mark run at the very end leaves no stream to cut into. + if cut < insns.len() { + cuts.push(cut); + } + } + cuts.sort_unstable(); + cuts.dedup(); + for cut in cuts.into_iter().rev() { + let into = fresh.next(); + let dead = fresh.next(); + let mut splice = opaque_compute(rng); + splice.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead)); + splice.push(Insn::Goto(into)); + splice.push(Insn::Mark(dead)); + splice.push(Insn::Nop); + splice.push(Insn::AconstNull); + splice.push(Insn::AThrow); + splice.push(Insn::Mark(into)); + insns.splice(cut..cut, splice); + } +} + +/// The opaque predicate core: computes `(C*C + C) & 1` (always 0) on top of +/// the stack without disturbing what is below it. +pub(super) fn opaque_compute(rng: &mut Rng) -> Vec { + use noxis_classfile::{Arith, Const, Prim}; + + let constant = rng.next_u64() as i32; + vec![ + Insn::Const(Const::Int(constant)), + Insn::Dup, + Insn::Binary { + prim: Prim::Int, + op: Arith::Mul, + }, + Insn::Const(Const::Int(constant)), + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::Const(Const::Int(1)), + Insn::Binary { + prim: Prim::Int, + op: Arith::And, + }, + ] +} + +/// Vec positions reachable from method entry via fallthrough and branch +/// targets. Marks are transparent: control flows through them to the next +/// instruction. Deliberately exception-oblivious (handler-only code reads as +/// unreachable): cuts there are skipped rather than risked. +pub(super) fn reachable(insns: &[Insn]) -> Vec { + use std::collections::{BTreeMap, VecDeque}; + + let mut at_label: BTreeMap = BTreeMap::new(); + for (index, insn) in insns.iter().enumerate() { + if let Insn::Mark(label) = insn { + at_label.entry(*label).or_insert(index); + } + } + let resolve = |label: u32| at_label.get(&label).copied(); + let terminal = |insn: &Insn| { + matches!( + insn, + Insn::Goto(_) + | Insn::Jsr(_) + | Insn::Ret(_) + | Insn::TableSwitch { .. } + | Insn::LookupSwitch { .. } + | Insn::Return(_) + | Insn::AReturn + | Insn::AThrow + ) + }; + let mut live = vec![false; insns.len()]; + let mut queue = VecDeque::new(); + if !live.is_empty() { + queue.push_back(0); + } + while let Some(index) = queue.pop_front() { + if live[index] { + continue; + } + live[index] = true; + let insn = &insns[index]; + if !terminal(insn) && index + 1 < insns.len() { + queue.push_back(index + 1); + } + for target in insn.targets() { + if let Some(resolved) = resolve(target) { + queue.push_back(resolved); + } + } + } + live +} + +/// Dead catch-all over `try_start..try_end`, rejoining `body`. +/// +/// The covered prologue cannot throw (constants, switches, gotos), so the +/// handler never fires; if it ever did, popping the exception and jumping to +/// `body` behaves exactly like the fallthrough path. +pub(super) fn dead_catch( + try_start: u32, + try_end: u32, + handler: u32, + body: u32, +) -> (Vec, ExceptionHandler) { + let code = vec![Insn::Mark(handler), Insn::Pop, Insn::Nop, Insn::Goto(body)]; + let entry = ExceptionHandler { + start: try_start, + end: try_end, + handler, + catch_type: None, + }; + (code, entry) +} diff --git a/crates/noxis-passes/src/flow/tests.rs b/crates/noxis-passes/src/flow/tests.rs new file mode 100644 index 0000000..8fffbbd --- /dev/null +++ b/crates/noxis-passes/src/flow/tests.rs @@ -0,0 +1,264 @@ +//! Unit tests for the control-flow pass: shapes, skips, determinism. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_classfile::{ + Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, Prim, Program, SlotKind, Version, +}; +use noxis_core::{Config, Context, FlowConfig, Pass, PassId}; + +use super::{FlowPass, LabelGen}; + +/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`. +fn branchy_code() -> Code { + let (head, end) = (100, 101); + Code { + max_stack: 2, + max_locals: 2, + insns: vec![ + Insn::Const(Const::Int(0)), + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(0)), + Insn::Store { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Mark(head), + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(10)), + Insn::Branch(BranchCond::Int(Cond::Ge), end), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Iinc { index: 1, delta: 1 }, + Insn::Goto(head), + Insn::Mark(end), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Return(Some(Prim::Int)), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + +fn program_with(methods: Vec<(String, String, Code)>) -> Program { + let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object")); + for (name, descriptor, code) in methods { + builder = builder.method_with_code(0x0009, name, descriptor, code); + } + Program { + classes: vec![builder.version(Version::JAVA_21).build()], + resources: Vec::new(), + } +} + +fn run(program: &mut Program, schemes: &[&str]) -> noxis_core::PassReport { + let config = Config { + seed: Some(42), + flow: FlowConfig { + schemes: schemes.iter().map(|s| s.to_string()).collect(), + max_insns: 4000, + encrypt_block_bodies: false, + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + FlowPass.apply(&mut ctx, program).expect("flow pass") +} + +fn code_of(program: &Program, name: &str) -> Code { + program.classes[0] + .method(name, "()I") + .expect("method") + .code + .clone() + .expect("code") +} + +fn has_switch(insns: &[Insn]) -> bool { + insns + .iter() + .any(|i| matches!(i, Insn::TableSwitch { .. } | Insn::LookupSwitch { .. })) +} + +#[test] +fn state_spread_emits_table_dispatch() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + let before = code_of(&program, "calc").insns.len(); + let report = run(&mut program, &["state-spread"]); + let code = code_of(&program, "calc"); + assert!( + code.insns + .iter() + .any(|i| matches!(i, Insn::TableSwitch { .. })), + "no tableswitch in {code:?}" + ); + assert!(code.insns.len() > before, "stream did not grow"); + assert!(!code.frames.is_empty(), "frames were not recomputed"); + assert_eq!(report.pass, PassId::Flow); + assert_eq!(report.transformed, 1); +} + +#[test] +fn computed_switch_uses_lookup_and_fresh_local() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + run(&mut program, &["computed-switch"]); + let code = code_of(&program, "calc"); + let pairs = code.insns.iter().find_map(|i| match i { + Insn::LookupSwitch { pairs, .. } => Some(pairs.clone()), + _ => None, + }); + let pairs = pairs.expect("no lookupswitch"); + let mut sorted = pairs.clone(); + sorted.sort_by_key(|(k, _)| *k); + assert_eq!(pairs, sorted, "lookupswitch pairs must be sorted"); + assert_eq!(code.max_locals, 3, "counter local was not allocated"); +} + +#[test] +fn butterfly_inverts_without_switch() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + run(&mut program, &["butterfly"]); + let code = code_of(&program, "calc"); + assert!(!has_switch(&code.insns), "butterfly must not dispatch"); + let gotos = code + .insns + .iter() + .filter(|i| matches!(i, Insn::Goto(_))) + .count(); + assert!( + gotos >= 3, + "expected inverted branches + splits, got {gotos}" + ); + let labels = code.labels(); + for insn in &code.insns { + for target in insn.targets() { + assert!(labels.contains(&target), "dangling label {target}"); + } + } + // Every branch target must also be reachable: an orphaned splice leaves + // its marks without computed frames and HotSpot rejects the class. + let live = super::rewire::reachable(&code.insns); + let mut at_mark = std::collections::BTreeMap::new(); + for (index, insn) in code.insns.iter().enumerate() { + if let Insn::Mark(label) = insn { + at_mark.entry(*label).or_insert(index); + } + } + for insn in &code.insns { + for target in insn.targets() { + let index = at_mark.get(&target).expect("resolved above"); + assert!(live[*index], "unreachable branch target {target}"); + } + } +} + +#[test] +fn exception_goto_adds_catch_all() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + run(&mut program, &["exception-goto"]); + let code = code_of(&program, "calc"); + assert_eq!(code.exception_table.len(), 1); + assert_eq!(code.exception_table[0].catch_type, None); + assert!( + code.insns + .iter() + .any(|i| matches!(i, Insn::TableSwitch { .. })), + "dispatcher missing inside try" + ); +} + +#[test] +fn skips_init_and_tiny_methods() { + let tiny = Code { + max_stack: 0, + max_locals: 0, + insns: vec![Insn::Return(None)], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let mut program = program_with(vec![ + ("".to_string(), "()V".to_string(), tiny.clone()), + ("run".to_string(), "()V".to_string(), tiny), + ("calc".to_string(), "()I".to_string(), branchy_code()), + ]); + let report = run( + &mut program, + &[ + "state-spread", + "computed-switch", + "butterfly", + "exception-goto", + ], + ); + assert_eq!(report.transformed, 1); + assert_eq!(report.skipped, 2); +} + +#[test] +fn unknown_scheme_is_rejected() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + let config = Config { + seed: Some(1), + flow: FlowConfig { + schemes: vec!["death-star".to_string()], + max_insns: 4000, + encrypt_block_bodies: false, + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + assert!(FlowPass.apply(&mut ctx, &mut program).is_err()); +} + +#[test] +fn labels_are_allocated_above_existing_ones() { + let code = branchy_code(); + let top = code.labels().into_iter().max().unwrap_or(0); + let mut labels = LabelGen::of(&code); + assert!(labels.next() > top); +} diff --git a/crates/noxis-passes/src/lib.rs b/crates/noxis-passes/src/lib.rs index d328e5a..7bc91f5 100644 --- a/crates/noxis-passes/src/lib.rs +++ b/crates/noxis-passes/src/lib.rs @@ -1,10 +1,11 @@ //! # noxis-passes //! -//! Трансформации байткода. Сборка конвейера из пассов (`standard_pipeline`) -//! живёт здесь же: ядро (`noxis-core`) знает только контракт [`Pass`]. +//! Bytecode transformations. The pipeline assembly (`standard_pipeline`) +//! lives here as well: the core (`noxis-core`) only knows the [`Pass`] contract. pub mod constants; pub mod debug; +pub mod flow; pub mod namegen; pub mod noise; pub mod rename; @@ -15,24 +16,22 @@ use noxis_core::Pass; pub use constants::ConstantsPass; pub use debug::DebugInfoPass; +pub use flow::FlowPass; pub use namegen::NameGenerator; pub use noise::NoisePass; pub use rename::RenamePass; pub use resources::ResourcesPass; pub use strings::StringsPass; -/// Стандартный конвейер в порядке [`docs/architecture.md`](../../docs/architecture.md): +/// Standard pipeline in [`docs/architecture.md`](../../docs/architecture.md) order: /// `rename → resources → strings → constants → flow → noise → debug`. -/// -/// Нереализованные пассы просто не добавлены — порядок уже соблюдён, -/// поэтому каждый следующий пасс вставляется на своё место, а не -/// дописывается в конец. pub fn standard_pipeline() -> noxis_core::Pipeline { noxis_core::Pipeline::new() .push(Box::new(RenamePass) as Box) .push(Box::new(ResourcesPass) as Box) .push(Box::new(StringsPass) as Box) .push(Box::new(ConstantsPass) as Box) + .push(Box::new(FlowPass) as Box) .push(Box::new(NoisePass) as Box) .push(Box::new(DebugInfoPass) as Box) } @@ -70,6 +69,7 @@ mod tests { fn standard_pipeline_runs_end_to_end_in_order() { let mut ctx = Context::new(Config { seed: Some(1), + enable: vec![PassId::Flow], ..Config::default() }); let mut program = sample(); @@ -86,6 +86,7 @@ mod tests { PassId::Resources, PassId::Strings, PassId::Constants, + PassId::Flow, PassId::Noise, PassId::DebugInfo ] diff --git a/crates/noxis-passes/tests/flow_accept.rs b/crates/noxis-passes/tests/flow_accept.rs new file mode 100644 index 0000000..93dcf94 --- /dev/null +++ b/crates/noxis-passes/tests/flow_accept.rs @@ -0,0 +1,263 @@ +//! Flow acceptance: scheme rotation plus a real-JVM execution check. +//! +//! rotation: 30 methods through the full scheme set must not collapse onto +//! one shape. execution: a branchy `calc` still prints 45 after the pass +//! under `java -Xverify:all` (graceful skip without `java`). + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_classfile::{ + Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, InvokeKind, Prim, Program, SlotKind, + Version, format, +}; +use noxis_core::{Config, Context, FlowConfig, Pass}; +use noxis_passes::FlowPass; + +const ALL_SCHEMES: [&str; 4] = [ + "state-spread", + "computed-switch", + "butterfly", + "exception-goto", +]; + +/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`. +fn branchy_code() -> Code { + let (head, end) = (100, 101); + Code { + max_stack: 2, + max_locals: 2, + insns: vec![ + Insn::Const(Const::Int(0)), + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(0)), + Insn::Store { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Mark(head), + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(10)), + Insn::Branch(BranchCond::Int(Cond::Ge), end), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Iinc { index: 1, delta: 1 }, + Insn::Goto(head), + Insn::Mark(end), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Return(Some(Prim::Int)), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + +fn program_with(methods: Vec<(String, String, Code)>) -> Program { + let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object")); + for (name, descriptor, code) in methods { + builder = builder.method_with_code(0x0009, name, descriptor, code); + } + Program { + classes: vec![builder.version(Version::JAVA_21).build()], + resources: Vec::new(), + } +} + +fn run(program: &mut Program, schemes: &[&str]) { + let config = Config { + seed: Some(42), + flow: FlowConfig { + schemes: schemes.iter().map(|s| s.to_string()).collect(), + max_insns: 4000, + encrypt_block_bodies: false, + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + FlowPass.apply(&mut ctx, program).expect("flow pass"); +} + +fn code_of(program: &Program, name: &str) -> Code { + program.classes[0] + .method(name, "()I") + .expect("method") + .code + .clone() + .expect("code") +} + +#[test] +fn rotation_spreads_schemes_across_methods() { + let methods: Vec<(String, String, Code)> = (0..30) + .map(|i| (format!("m{i}"), "()I".to_string(), branchy_code())) + .collect(); + let mut program = program_with(methods); + run(&mut program, &ALL_SCHEMES); + let mut shapes = [false; 4]; + for method in &program.classes[0].methods { + let code = method.code.as_ref().expect("code"); + if code + .insns + .iter() + .any(|i| matches!(i, Insn::TableSwitch { .. })) + { + shapes[0] = true; + } + if code + .insns + .iter() + .any(|i| matches!(i, Insn::LookupSwitch { .. })) + { + shapes[1] = true; + } + if !code.exception_table.is_empty() { + shapes[3] = true; + } + } + let distinct = shapes.iter().filter(|s| **s).count(); + assert!(distinct >= 2, "rotation did not spread: {shapes:?}"); +} + +/// Every split splice must be verifier-safe: the dead block is entered +/// through an opaque branch (so it owns a frame) and ends with `athrow` +/// (so it joins nothing). A dead `nop` with no incoming edge and no frame +/// is rejected by HotSpot (`Expecting a stack map frame`). +#[test] +fn splits_are_opaque_entered_and_throw() { + let mut program = program_with(vec![( + "calc".to_string(), + "()I".to_string(), + branchy_code(), + )]); + run(&mut program, &["butterfly"]); + let code = code_of(&program, "calc"); + let insns = &code.insns; + let throws = insns.iter().filter(|i| matches!(i, Insn::AThrow)).count(); + assert!(throws >= 1, "splits must end dead blocks with athrow"); + for (index, insn) in insns.iter().enumerate() { + if !matches!(insn, Insn::AThrow) { + continue; + } + let head = &insns[..index]; + assert!( + matches!(head.last(), Some(Insn::AconstNull)), + "athrow without a null on top" + ); + let dead = head + .iter() + .rev() + .find_map(|i| match i { + Insn::Mark(label) => Some(*label), + _ => None, + }) + .expect("dead block without a mark"); + assert!( + insns + .iter() + .any(|i| matches!(i, Insn::Branch(_, target) if *target == dead)), + "dead mark {dead} has no opaque entry branch" + ); + } +} + +/// `calc` returns the sum 0+..+9 = 45; `main` prints it. After the pass the +/// class must still verify *and* print 45 under a real JVM. +#[test] +fn flow_preserves_semantics_under_java() { + if std::process::Command::new("java") + .arg("-version") + .output() + .is_err() + { + eprintln!("SKIP: no java"); + return; + } + let main = Code { + max_stack: 2, + max_locals: 1, + insns: vec![ + Insn::GetStatic { + owner: "java/lang/System".to_string(), + name: "out".to_string(), + descriptor: "Ljava/io/PrintStream;".to_string(), + }, + Insn::Invoke { + kind: InvokeKind::Static, + owner: "demo/Flow".to_string(), + name: "calc".to_string(), + descriptor: "()I".to_string(), + itf: false, + }, + Insn::Invoke { + kind: InvokeKind::Virtual, + owner: "java/io/PrintStream".to_string(), + name: "println".to_string(), + descriptor: "(I)V".to_string(), + itf: false, + }, + Insn::Return(None), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let mut program = program_with(vec![ + ("calc".to_string(), "()I".to_string(), branchy_code()), + ( + "main".to_string(), + "([Ljava/lang/String;)V".to_string(), + main, + ), + ]); + run(&mut program, &ALL_SCHEMES); + let dir = std::env::temp_dir().join(format!("noxis-flow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for class in &program.classes { + let bytes = format::write(class).expect("write"); + let mut path = dir.clone(); + for part in class.this_class.split('/') { + path.push(part); + } + path.set_extension("class"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("write class"); + } + let output = std::process::Command::new("java") + .arg("-Xverify:all") + .arg("-cp") + .arg(&dir) + .arg("demo.Flow") + .output() + .expect("run java"); + let _ = std::fs::remove_dir_all(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "java failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(stdout.trim(), "45", "semantics changed: {stdout:?}"); +} diff --git a/crates/noxis/src/cmd/obfuscate.rs b/crates/noxis/src/cmd/obfuscate.rs index 97da762..64b4595 100644 --- a/crates/noxis/src/cmd/obfuscate.rs +++ b/crates/noxis/src/cmd/obfuscate.rs @@ -1,59 +1,56 @@ -//! Команда `obfuscate`: jar → конвейер → jar + маппинги + отчёт. +//! The `obfuscate` command: jar → pipeline → jar + mappings + report. use std::path::PathBuf; use anyhow::{Context as _, Result}; use noxis_core::{Config, Context, PassId}; -/// Аргументы обфускации. +/// Obfuscation arguments. #[derive(Debug, clap::Args)] pub struct Args { - /// Входной jar. + /// Input jar. pub input: PathBuf, - /// Выходной jar. + /// Output jar. #[arg(short, long)] pub output: PathBuf, - /// TOML-конфиг (без него — профиль Standard). + /// TOML config (without it — the Standard profile). #[arg(short, long)] pub config: Option, - /// Мастер-сид (детерминированность по умолчанию — из конфига). + /// Master seed (deterministic by default — taken from the config). #[arg(long)] pub seed: Option, - /// Куда положить JSON-отчёт (по умолчанию рядом с выходом). + /// Where to write the JSON report (next to the output by default). #[arg(long)] pub report: Option, - /// Выключить пасс переименования. + /// Disable the rename pass. #[arg(long)] pub no_rename: bool, - /// Выключить пасс ресурсов. + /// Disable the resources pass. #[arg(long)] pub no_resources: bool, - /// Выключить пасс строк. + /// Disable the strings pass. #[arg(long)] pub no_strings: bool, - /// Выключить пасс констант. + /// Disable the constants pass. #[arg(long)] pub no_constants: bool, - /// Выключить пасс шума. - #[arg(long)] - pub no_noise: bool, - /// Выключить пасс отладочной информации. - #[arg(long)] - pub no_debug_info: bool, - /// Выключить пасс control-flow (ещё не реализован — честная ошибка). + /// Disable the control-flow pass. #[arg(long)] pub no_flow: bool, + /// Disable the noise pass. + #[arg(long)] + pub no_noise: bool, + /// Disable the debug-info pass. + #[arg(long)] + pub no_debug_info: bool, } -/// Прогнать обфускацию. +/// Run the obfuscation. pub fn run(args: Args) -> Result<()> { - if args.no_flow { - anyhow::bail!("пасс flow ещё не реализован (см. todo.md §3.4)"); - } let mut config = match &args.config { Some(path) => { let text = std::fs::read_to_string(path) - .with_context(|| format!("конфиг {}", path.display()))?; + .with_context(|| format!("config {}", path.display()))?; Config::from_toml(&text).map_err(|error| anyhow::anyhow!("{error}"))? } None => Config::default(), @@ -66,6 +63,7 @@ pub fn run(args: Args) -> Result<()> { (args.no_resources, PassId::Resources), (args.no_strings, PassId::Strings), (args.no_constants, PassId::Constants), + (args.no_flow, PassId::Flow), (args.no_noise, PassId::Noise), (args.no_debug_info, PassId::DebugInfo), ] { @@ -75,19 +73,19 @@ pub fn run(args: Args) -> Result<()> { } let entries = noxis_jar::read_file(&args.input) - .with_context(|| format!("чтение {}", args.input.display()))?; + .with_context(|| format!("reading {}", args.input.display()))?; let (entries, stripped) = noxis_jar::strip_signatures(entries); let mut program = - noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("разбор jar: {error}"))?; + noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("parsing jar: {error}"))?; let mut ctx = Context::new(config); let report = noxis_passes::standard_pipeline() .run(&mut ctx, &mut program) - .map_err(|error| anyhow::anyhow!("конвейер: {error}"))?; + .map_err(|error| anyhow::anyhow!("pipeline: {error}"))?; let entries = - noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("сборка jar: {error}"))?; + noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("assembling jar: {error}"))?; noxis_jar::write_file(&args.output, &entries) - .with_context(|| format!("запись {}", args.output.display()))?; + .with_context(|| format!("writing {}", args.output.display()))?; let report_path = args .report @@ -96,14 +94,14 @@ pub fn run(args: Args) -> Result<()> { &report_path, serde_json::to_string_pretty(&SerializableReport::from(&report, &ctx))?, ) - .with_context(|| format!("отчёт {}", report_path.display()))?; + .with_context(|| format!("report {}", report_path.display()))?; std::fs::write( args.output.with_extension("mapping.txt"), ctx.mapping.to_text(), )?; println!( - "noxis: {} → {} (классов: {}, подписей срезано: {}, отчёт: {})", + "noxis: {} → {} (classes: {}, signatures stripped: {}, report: {})", args.input.display(), args.output.display(), report.counters.get("classes").copied().unwrap_or(0), @@ -113,7 +111,7 @@ pub fn run(args: Args) -> Result<()> { Ok(()) } -/// Отчёт в JSON: сид, пассы, счётчики, маппинги текстом. +/// JSON report: seed, passes, counters, mappings as text. #[derive(Debug, serde::Serialize)] struct SerializableReport { seed: u64, @@ -182,6 +180,23 @@ mod tests { noxis_jar::write_file(path, &entries).expect("write jar"); } + fn sample_args(input: PathBuf, output: PathBuf) -> Args { + Args { + input, + output, + config: None, + seed: Some(5), + report: None, + no_rename: false, + no_resources: false, + no_strings: false, + no_constants: false, + no_flow: false, + no_noise: false, + no_debug_info: false, + } + } + #[test] fn obfuscate_end_to_end() { let dir = std::env::temp_dir().join(format!("noxis-cli-{}", std::process::id())); @@ -190,43 +205,44 @@ mod tests { let input = dir.join("in.jar"); let output = dir.join("out.jar"); sample_jar(&input); - run(Args { - input: input.clone(), - output: output.clone(), - config: None, - seed: Some(5), - report: None, - no_rename: false, - no_resources: false, - no_strings: false, - no_constants: false, - no_noise: false, - no_debug_info: false, - no_flow: false, - }) - .expect("obfuscate"); - assert!(output.exists(), "нет выходного jar"); - assert!(dir.join("out.report.json").exists(), "нет отчёта"); - assert!(dir.join("out.mapping.txt").exists(), "нет маппингов"); + run(sample_args(input.clone(), output.clone())).expect("obfuscate"); + assert!(output.exists(), "missing output jar"); + assert!(dir.join("out.report.json").exists(), "missing report"); + assert!(dir.join("out.mapping.txt").exists(), "missing mappings"); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn no_flow_is_honest_error() { - let args = Args { - input: PathBuf::from("in.jar"), - output: PathBuf::from("out.jar"), - config: None, - seed: None, - report: None, - no_rename: false, - no_resources: false, - no_strings: false, - no_constants: false, - no_noise: false, - no_debug_info: false, - no_flow: true, + fn no_flow_disables_the_pass() { + let dir = std::env::temp_dir().join(format!("noxis-cli-flow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("mkdir"); + let input = dir.join("in.jar"); + sample_jar(&input); + let config_path = dir.join("paranoid.toml"); + std::fs::write(&config_path, "profile = \"paranoid\"\n").expect("write config"); + + let run_with_flag = |name: &str, no_flow: bool| { + let output = dir.join(format!("{name}.jar")); + let report_path = dir.join(format!("{name}.report.json")); + let mut args = sample_args(input.clone(), output); + args.config = Some(config_path.clone()); + args.report = Some(report_path.clone()); + args.no_flow = no_flow; + run(args).expect("obfuscate"); + std::fs::read_to_string(&report_path).expect("read report") }; - assert!(run(args).is_err()); + + let with_flow = run_with_flag("with", false); + assert!( + with_flow.contains("\"flow\""), + "flow pass missing under paranoid: {with_flow}" + ); + let without_flow = run_with_flag("without", true); + assert!( + !without_flow.contains("\"flow\""), + "flow pass ran despite --no-flow: {without_flow}" + ); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/docs/architecture.md b/docs/architecture.md index 91a31ad..d117832 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,6 +123,7 @@ Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo | **готово** | IR, модель, конфиг, seed/PRNG, маппинги, анализ, keep, гейт правил, `format::parse`/`write` поверх ристретто, вывод кадров `StackMapTable` | | **готово** | пассы rename/resources/strings/constants/noise/debug, генератор декодера, вывод мастер-секрета в рантайме | | **готово** | `noxis-verify` (red-паттерны, `java -Xverify:all`, метрики), CLI (`obfuscate`/`analyze`/`patterns`) | -| **план** | схемы пасса `flow` (кадры для них уже считаются), фикстуры и regression-тесты | +| **готово** | пасс `flow` (4 схемы + ротация + мешанина, приёмка настоящим `java`) | +| **план** | фикстуры и regression-тесты | Детали выбора — [`stack.md`](stack.md), план — [`../todo.md`](../todo.md). diff --git a/docs/learnings/lessons.md b/docs/learnings/lessons.md index 0fe7e1e..dc2720d 100644 --- a/docs/learnings/lessons.md +++ b/docs/learnings/lessons.md @@ -14,7 +14,7 @@ | 1 | ключ лежит рядом с шифротекстом | вывод мастер-секрета в декодере, ключ из `master‖salt` | Strings | **готово** | | 2 | переименованы типы, но не члены | rename полей/методов + семейства virtual | Rename | **готово** | | 3 | отладочная информация нетронута | strip + ложный `SourceFile`/номера строк | DebugInfo | **готово** (`SourceFile`; номера строк — план) | -| 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | план (кадры готовы) | +| 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | **готово** (4 схемы, приёмка `java`) | | 5 | opaque-предикаты = `A+B` | ротация тождеств + предикаты от состояния | Constants | **готово** (int/long) | | 6 | junk-классы вне графа reachability | `weave_reachable` — обязательная вплетённость | Noise | **готово** (без аннотаций) | | 7 | имена говорят «я защита» | все артефакты получают данные псевдонимы | Rename | **готово** | diff --git a/docs/learnings/priorities.md b/docs/learnings/priorities.md index 57352c7..fee361e 100644 --- a/docs/learnings/priorities.md +++ b/docs/learnings/priorities.md @@ -115,6 +115,6 @@ writer). ✅ P0 DebugInfo + Resources → главные утечки сняты ✅ P1 Strings (B2) + Constants → ключ выводится, схемы ротируются ✅ P1 backend (ristretto) + frames -⬜ P2 Flow (C1) — единственное, что ждёт реализации; верификатор готов +✅ P2 Flow (C1) — готов (09.10.2026): 4 схемы + ротация + мешанина, приёмка настоящим `java` ✅ P3 метрики, красные паттерны, CLI ``` diff --git a/docs/learnings/reference.md b/docs/learnings/reference.md index 3efa2ff..33f0637 100644 --- a/docs/learnings/reference.md +++ b/docs/learnings/reference.md @@ -26,7 +26,7 @@ | `StringDecoderGenerator` | 205 | генерит класс-декодер | `noxis-runtime` + P2 генератор | | `ObfuscatorRunner` | 202 | конвейер, оркестрация | `noxis-passes::standard_pipeline` ✅ | | `NumberObfuscator` | 185 | константы / opaque | пасс `constants` | -| `ControlFlowObfuscator` | 183 | flattening | пасс `flow` (ждёт `StackMapTable`) | +| `ControlFlowObfuscator` | 183 | flattening | пасс `flow` ✅ (4 схемы, свои кадры) | | `FabricKeepRuleScanner` | 147 | keep из конфига Fabric | `noxis-analysis::keep` ✅ | | `Main` | 134 | CLI-точки | `noxis` (clap) | | `StringEncryptor` | 132 | AES-128 строк + chunk'и | `noxis-runtime::scheme` ✅ | diff --git a/docs/techniques.md b/docs/techniques.md index ac1f2f6..3948599 100644 --- a/docs/techniques.md +++ b/docs/techniques.md @@ -104,7 +104,18 @@ opaque-предикаты — условия, истинность которы - Шаблонная схема (все методы перестроены одинаково) детектируется гистограммой длины блоков. -**Статус: не реализован; блокер — `StackMapTable` (см. [`stack.md`](stack.md)).** +**Статус: готов** (`noxis-passes/src/flow/`). Одна схема на метод (ротация +через PRNG) плюс нарезка тела `goto`-сплайсами во всех схемах; кадр +`StackMapTable` пересчитывается после каждой перестройки. Приёмка — +настоящий `java -Xverify:all` и исполнение под JVM (`calc` печатает 45 +после всех схем). Ограничения: `` пропускается (форма +super-call-first); методы с `jsr`/`ret`, короче 3 инструкций и длиннее +`max_insns` пропускаются; `encrypt_block_bodies` добавляет opaque-guard +на входе. Два правила, выученных на крови (см. `StackMapTable`): +вставленная метка не должна делить байт-смещение с чужой (иначе кадры +мерджатся из несовместимых состояний — после каждой такой метки `nop`), +а инструкция после терминальной обязана иметь свой кадр (поэтому dead-блоки +входят через opaque-ветвь и кончаются `athrow`, а не висят недостижимыми). ## 5. Шум (`Noise`)