From 2cbb9db31ebeeb649f46a3c71c5d68d4cc24142b Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 9 Oct 2026 14:04:24 +0200 Subject: [PATCH] passes: junk classes with real bodies and no fingerprint NoisePass now builds branching helper methods (counted int loop, random arithmetic, even/odd merge, non-throwing ops only) whose results the entry sums into a private static field, instead of an empty run()V. Names come from the same NameGenerator styles as real code and live in a random real package (no more shadow/decoy uniformity for grep to catch); every generated method gets fresh StackMapTable frames. weave_first_reachable takes (class, entry) pairs. New NoiseConfig.junk_methods_per_class (default 3). Tests assert invariants (unique valid identifiers, calls, field store, determinism) plus real-JVM verification of junk classes; paranoid+junk_ratio=1 smoke on lorep: 18 junk classes, all load. --- .../src/format/write/code/frames.rs | 2 +- crates/noxis-core/src/config/sections.rs | 4 + crates/noxis-core/src/rng.rs | 2 +- crates/noxis-passes/src/flow/rewire.rs | 2 +- crates/noxis-passes/src/noise/body.rs | 259 ++++++++++++++++++ crates/noxis-passes/src/noise/mod.rs | 253 +++++++++-------- crates/noxis-passes/src/noise/tests.rs | 208 ++++++++++++++ crates/noxis-passes/src/noise/weave.rs | 21 +- crates/noxis-passes/src/rename/assign.rs | 2 +- crates/noxis-passes/src/rename/mod.rs | 52 ++-- crates/noxis-passes/src/strings/crypt.rs | 2 +- 11 files changed, 648 insertions(+), 159 deletions(-) create mode 100644 crates/noxis-passes/src/noise/body.rs create mode 100644 crates/noxis-passes/src/noise/tests.rs diff --git a/crates/noxis-classfile/src/format/write/code/frames.rs b/crates/noxis-classfile/src/format/write/code/frames.rs index f72b79b..7cd6abe 100644 --- a/crates/noxis-classfile/src/format/write/code/frames.rs +++ b/crates/noxis-classfile/src/format/write/code/frames.rs @@ -97,7 +97,7 @@ fn build_frame( })?; if !(1..=3).contains(&count) { return Err(ClassfileError::Write(format!( - "append-кадр на {count} local'ов (нужно 1–3)" + "append-кадр на {count} local'ов (нужно 1-3)" ))); } RtFrame::AppendFrame { diff --git a/crates/noxis-core/src/config/sections.rs b/crates/noxis-core/src/config/sections.rs index d50c280..a153d96 100644 --- a/crates/noxis-core/src/config/sections.rs +++ b/crates/noxis-core/src/config/sections.rs @@ -148,6 +148,9 @@ impl Default for ConstantsConfig { pub struct NoiseConfig { /// How many junk classes per real one (0 = disabled). pub junk_ratio: usize, + /// Branching helper methods per junk class (their results feed a + /// static field so the bodies read as live code, not dead weight). + pub junk_methods_per_class: usize, /// Number of junk annotations per class. pub junk_annotations: usize, /// Weave junk classes into the reachability graph (required for them to @@ -159,6 +162,7 @@ impl Default for NoiseConfig { fn default() -> Self { Self { junk_ratio: 0, + junk_methods_per_class: 3, junk_annotations: 0, weave_reachable: true, } diff --git a/crates/noxis-core/src/rng.rs b/crates/noxis-core/src/rng.rs index 7d12d94..aecc7e0 100644 --- a/crates/noxis-core/src/rng.rs +++ b/crates/noxis-core/src/rng.rs @@ -72,7 +72,7 @@ impl Rng { } } - /// Перемешать срез на месте (Fisher–Yates). + /// Перемешать срез на месте (Fisher-Yates). pub fn shuffle(&mut self, items: &mut [T]) { for i in (1..items.len()).rev() { let j = self.below((i + 1) as u64) as usize; diff --git a/crates/noxis-passes/src/flow/rewire.rs b/crates/noxis-passes/src/flow/rewire.rs index fcf4419..b6eee1e 100644 --- a/crates/noxis-passes/src/flow/rewire.rs +++ b/crates/noxis-passes/src/flow/rewire.rs @@ -33,7 +33,7 @@ fn negate_cond(cond: Cond) -> Cond { /// Turn `if-cond T` into `if-not SKIP; goto T; SKIP:` (`butterfly`). /// -/// `rate` is a 0–100 percentage applied per branch. Semantics are unchanged: +/// `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 diff --git a/crates/noxis-passes/src/noise/body.rs b/crates/noxis-passes/src/noise/body.rs new file mode 100644 index 0000000..b0366c8 --- /dev/null +++ b/crates/noxis-passes/src/noise/body.rs @@ -0,0 +1,259 @@ +//! Junk method bodies: plausible branching integer code. +//! +//! A helper is `private static int helper()`: two int locals (`acc`, `i`), +//! a counted loop with a few random binary ops plus one even/odd if/else +//! merge for a real branching shape. Everything is stack-neutral by +//! construction and uses only non-throwing integer ops: woven junk really +//! runs, so it must never throw. +//! +//! Labels start at fixed small numbers: bodies are built from scratch, so +//! nothing collides (labels are per-method). + +use noxis_classfile::{Arith, BranchCond, Code, Cond, Const, Insn, Prim, SlotKind}; +use noxis_core::Rng; + +/// Non-throwing integer ops for junk arithmetic (`Div`/`Rem` could trap). +const OPS: [Arith; 8] = [ + Arith::Add, + Arith::Sub, + Arith::Mul, + Arith::And, + Arith::Or, + Arith::Xor, + Arith::Shl, + Arith::Shr, +]; + +/// Fresh-label block for one helper (head, end, odd, merge). +const HEAD: u32 = 1; +const END: u32 = 2; +const ODD: u32 = 3; +const MERGE: u32 = 4; + +/// Build one helper body. +pub(super) fn helper_body(rng: &mut Rng) -> Code { + let bound = 4 + rng.below(29) as i32; + let mut 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(bound)), + Insn::Branch(BranchCond::Int(Cond::Ge), END), + ]; + // Straight-line accumulator churn: `acc = acc op i`, two to four ops. + for _ in 0..2 + rng.below(3) { + let op = OPS[rng.below(OPS.len() as u64) as usize]; + insns.extend([ + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Binary { + prim: Prim::Int, + op, + }, + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + ]); + } + // Even/odd merge: `if ((i & 1) == 0) acc += c1 else acc ^= c2`. + let (even, odd) = (rng.next_u64() as i32, rng.next_u64() as i32); + insns.extend([ + Insn::Load { + slot: 1, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(1)), + Insn::Binary { + prim: Prim::Int, + op: Arith::And, + }, + Insn::Branch(BranchCond::Zero(Cond::Ne), ODD), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(even)), + Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }, + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Goto(MERGE), + Insn::Mark(ODD), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Const(Const::Int(odd)), + Insn::Binary { + prim: Prim::Int, + op: Arith::Xor, + }, + Insn::Store { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Mark(MERGE), + ]); + insns.extend([ + Insn::Iinc { index: 1, delta: 1 }, + Insn::Goto(HEAD), + Insn::Mark(END), + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Return(Some(Prim::Int)), + ]); + Code { + max_stack: 4, + max_locals: 2, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + +/// Build the entry body: call every helper, sum the results into the static +/// field (a bare `void` call with a dropped result is a recognizable junk +/// pattern on its own). With no helpers the entry is a bare return. +pub(super) fn entry_body(callees: &[(String, String)], owner: &str, field: &str) -> Code { + let mut insns = Vec::new(); + let mut first = true; + for (callee_owner, callee_name) in callees { + insns.push(Insn::Invoke { + kind: noxis_classfile::InvokeKind::Static, + owner: callee_owner.clone(), + name: callee_name.clone(), + descriptor: "()I".to_string(), + itf: false, + }); + if first { + first = false; + } else { + insns.push(Insn::Binary { + prim: Prim::Int, + op: Arith::Add, + }); + } + } + if callees.is_empty() { + insns.push(Insn::Return(None)); + } else { + insns.push(Insn::PutStatic { + owner: owner.to_string(), + name: field.to_string(), + descriptor: "I".to_string(), + }); + insns.push(Insn::Return(None)); + } + Code { + max_stack: 2, + max_locals: 0, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::*; + + /// Helpers branch and loop instead of returning immediately. + #[test] + fn helper_has_branches_and_loop() { + let mut rng = Rng::from_seed(3); + let code = helper_body(&mut rng); + let has = |pred: &dyn Fn(&Insn) -> bool| code.insns.iter().any(pred); + assert!(has(&|i| matches!(i, Insn::Branch(..))), "no branch"); + assert!(has(&|i| matches!(i, Insn::Goto(_))), "no loop back-edge"); + assert!(has(&|i| matches!(i, Insn::Binary { .. })), "no arithmetic"); + assert!(has(&|i| matches!(i, Insn::Iinc { .. })), "no counter"); + assert!( + has(&|i| matches!(i, Insn::Return(Some(Prim::Int)))), + "no int return" + ); + assert_eq!(code.max_locals, 2); + // Every jump target is declared. + let marks: std::collections::BTreeSet = code + .insns + .iter() + .filter_map(|i| match i { + Insn::Mark(label) => Some(*label), + _ => None, + }) + .collect(); + for insn in &code.insns { + for target in insn.targets() { + assert!(marks.contains(&target), "dangling label {target}"); + } + } + } + + /// Same seed, same body. + #[test] + fn helper_is_deterministic() { + let first = helper_body(&mut Rng::from_seed(9)); + let second = helper_body(&mut Rng::from_seed(9)); + assert_eq!(first.insns, second.insns); + let third = helper_body(&mut Rng::from_seed(10)); + assert_ne!(first.insns, third.insns); + } + + /// The entry calls every helper and stores the sum into the field. + #[test] + fn entry_calls_helpers_and_stores() { + let callees = vec![ + ("junk/A".to_string(), "h0".to_string()), + ("junk/A".to_string(), "h1".to_string()), + ]; + let code = entry_body(&callees, "junk/A", "f"); + let invokes = code + .insns + .iter() + .filter(|i| matches!(i, Insn::Invoke { .. })) + .count(); + assert_eq!(invokes, 2); + assert!( + code.insns + .iter() + .any(|i| matches!(i, Insn::PutStatic { name, .. } if name == "f")), + "no field store" + ); + assert!(matches!(code.insns.last(), Some(Insn::Return(None)))); + } + + /// No helpers, no calls: a bare return, still valid. + #[test] + fn entry_without_helpers_returns() { + let code = entry_body(&[], "junk/A", "f"); + assert_eq!(code.insns, vec![Insn::Return(None)]); + } +} diff --git a/crates/noxis-passes/src/noise/mod.rs b/crates/noxis-passes/src/noise/mod.rs index 3a2f98e..4f8e8a0 100644 --- a/crates/noxis-passes/src/noise/mod.rs +++ b/crates/noxis-passes/src/noise/mod.rs @@ -1,26 +1,38 @@ -//! Пасс шума: junk-классы и вплетание их в граф достижимости. +//! Noise pass: junk classes with real bodies, woven into reachability. //! -//! Мёртвый код выкидывается shrink-анализом, поэтому каждый junk-класс -//! получает ребро из живого метода (`invokestatic shadow/decoyN.run()V`). -//! Junk-аннотации не генерируются: сырые атрибуты не несут свежие индексы пула. +//! Dead code gets dropped by shrink analysis, so every junk class gets an +//! edge from live code (an `invokestatic` of its entry). Junk names come +//! from the same generator (and styles) as real code: self-describing +//! `shadow/decoy` names with a uniform `run` entry would be a grep pattern +//! of their own. +mod body; mod weave; -use noxis_classfile::{ClassBuilder, Program, Version, empty_code}; -use noxis_core::{Context, CoreError, Pass, PassId, PassReport}; +#[cfg(test)] +mod tests; +use crate::namegen::NameGenerator; +use crate::rename::assign::parent_package; +use body::{entry_body, helper_body}; +use noxis_analysis::ClassHierarchy; +use noxis_classfile::{ClassBuilder, Field, Program, Version, compute_frames}; +use noxis_core::{Context, CoreError, Pass, PassId, PassReport}; use weave::weave_first_reachable; -/// Префикс детерминированных имён junk-классов. -const JUNK_PREFIX: &str = "shadow/decoy"; - -/// Имя и дескриптор точки входа junk-класса. -const JUNK_ENTRY: (&str, &str) = ("run", "()V"); - -/// Флаги `public static` для junk-метода. +/// Flags for the junk entry (`public static`, callable from live code). const ACC_PUBLIC_STATIC: u16 = 0x0009; +/// Flags for helpers and the accumulator field (`private static`). +const ACC_PRIVATE_STATIC: u16 = 0x000A; -/// Пасс шума. +/// The entry descriptor (always `()V`). +const ENTRY_DESCRIPTOR: &str = "()V"; +/// Helper methods return the accumulator (`int`). +const HELPER_DESCRIPTOR: &str = "()I"; +/// The accumulator field type. +const FIELD_DESCRIPTOR: &str = "I"; + +/// The noise pass. #[derive(Debug, Default, Clone, Copy)] pub struct NoisePass; @@ -31,6 +43,7 @@ impl Pass for NoisePass { fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result { let ratio = ctx.config.noise.junk_ratio; + let methods_per_class = ctx.config.noise.junk_methods_per_class; let annotations = ctx.config.noise.junk_annotations; let weave = ctx.config.noise.weave_reachable; @@ -39,7 +52,7 @@ impl Pass for NoisePass { pass: PassId::Noise, transformed: 0, skipped: 0, - notes: vec!["шум выключен: junk_ratio=0, junk_annotations=0".to_string()], + notes: vec!["noise off: junk_ratio=0, junk_annotations=0".to_string()], }); } @@ -50,45 +63,99 @@ impl Pass for NoisePass { let real_classes = program.classes.len(); let count = ratio.saturating_mul(real_classes); - let mut junk_names = Vec::with_capacity(count); - let mut suffix = 0usize; - for _ in 0..count { - let name = loop { - let candidate = format!("{JUNK_PREFIX}{suffix}"); - suffix = suffix.saturating_add(1); - if program.class(&candidate).is_none() { - break candidate; - } - }; - let junk = ClassBuilder::new(name.clone(), None) - .version(version) - .method_with_code(ACC_PUBLIC_STATIC, JUNK_ENTRY.0, JUNK_ENTRY.1, empty_code(0)) - .build(); - program.classes.push(junk); - junk_names.push(name); + let mut class_names = + NameGenerator::new(ctx.rng("noise/classes"), ctx.config.rename.class_style) + .for_classes(); + for class in &program.classes { + class_names.reserve(class.this_class.clone()); + } + let mut rng = ctx.rng("noise"); + // (class, entry) pairs for weaving, in creation order. + let mut entries: Vec<(String, String)> = Vec::with_capacity(count); + // (class index, method index) of every generated method, for frames. + let mut fresh_methods: Vec<(usize, usize)> = Vec::new(); + for index in 0..count { + let package = if real_classes > 0 { + let pick = &program.classes[rng.below(real_classes as u64) as usize].this_class; + parent_package(pick) + } else { + String::new() + }; + let simple = class_names.next_name(); + let name = if package.is_empty() { + simple + } else { + format!("{package}/{simple}") + }; + let mut member_names = NameGenerator::new( + ctx.rng_nth("noise/members", index as u64), + ctx.config.rename.member_style, + ); + let entry_name = member_names.next_name(); + let field_name = member_names.next_name(); + let mut helpers: Vec<(String, String)> = Vec::new(); + let mut builder = ClassBuilder::new(name.clone(), None).version(version); + for _ in 0..methods_per_class { + let helper_name = member_names.next_name(); + let body = helper_body(&mut rng); + builder = builder.method_with_code( + ACC_PRIVATE_STATIC, + helper_name.clone(), + HELPER_DESCRIPTOR, + body, + ); + helpers.push((name.clone(), helper_name)); + } + let callees: Vec<(String, String)> = helpers.clone(); + let entry = entry_body(&callees, &name, &field_name); + builder = builder.method_with_code( + ACC_PUBLIC_STATIC, + entry_name.clone(), + ENTRY_DESCRIPTOR, + entry, + ); + let mut junk = builder.build(); + junk.fields.push(Field { + access_flags: ACC_PRIVATE_STATIC, + name: field_name, + descriptor: FIELD_DESCRIPTOR.to_string(), + attributes: Vec::new(), + }); + let base = program.classes.len(); + for mi in 0..junk.methods.len() { + fresh_methods.push((base, mi)); + } + program.classes.push(junk); + entries.push((name, entry_name)); + } + let added = entries.len(); + + // Frames for every generated branching method. + let hierarchy = ClassHierarchy::build(program); + for (ci, mi) in &fresh_methods { + refresh_frames(program, *ci, *mi, &hierarchy)?; } - let added = junk_names.len(); let mut woven = 0usize; if weave && added > 0 { - woven = weave_first_reachable(program, real_classes, &junk_names)?; + woven = weave_first_reachable(program, real_classes, &entries)?; } let mut skipped = 0usize; let mut notes = Vec::new(); if added > 0 { - notes.push(format!("добавлено junk-классов: {added}")); + notes.push(format!("junk classes added: {added}")); } if woven > 0 { - notes.push(format!("вплетено вызовов: {woven}")); + notes.push(format!("woven calls: {woven}")); } if added == 0 && woven == 0 { - notes.push("нечего шуметь: нет классов или методов с кодом".to_string()); + notes.push("nothing to fuzz: no classes or methods with code".to_string()); } if annotations > 0 { skipped = annotations.saturating_mul(program.classes.len()); notes.push(format!( - "junk_annotations пропущен: сырые атрибуты не могут нести свежие индексы пула (пропущено: {skipped})" + "junk_annotations skipped: raw attributes cannot carry fresh pool indices (skipped: {skipped})" )); } @@ -105,85 +172,35 @@ impl Pass for NoisePass { } } -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use noxis_classfile::{Insn, InvokeKind, Resource}; - use noxis_core::{Config, NoiseConfig}; - - use super::*; - - fn sample() -> Program { - Program { - classes: vec![ - ClassBuilder::new("demo/App", Some("java/lang/Object")) - .version(Version::JAVA_21) - .method_with_code(ACC_PUBLIC_STATIC, "run", "()V", empty_code(0)) - .build(), - ], - resources: vec![Resource { - path: "app.json".into(), - data: br#"{}"#.to_vec(), - }], - } - } - - fn run(program: &mut Program, noise: NoiseConfig) -> PassReport { - let config = Config { - seed: Some(42), - noise, - ..Config::default() - }; - let mut ctx = Context::new(config); - NoisePass.apply(&mut ctx, program).expect("noise pass") - } - - #[test] - fn adds_junk_and_weaves_call() { - let mut program = sample(); - let noise = NoiseConfig { - junk_ratio: 2, - junk_annotations: 0, - weave_reachable: true, - }; - let report = run(&mut program, noise); - - assert_eq!(program.classes.len(), 3); - assert_eq!(program.classes[1].this_class, "shadow/decoy0"); - assert_eq!(program.classes[2].this_class, "shadow/decoy1"); - - let insns = &program.classes[0].methods[0].code.as_ref().unwrap().insns; - assert_eq!(insns.len(), 2); - match &insns[0] { - Insn::Invoke { - kind, - owner, - name, - descriptor, - itf, - } => { - assert_eq!(*kind, InvokeKind::Static); - assert_eq!(owner, "shadow/decoy0"); - assert_eq!(name, "run"); - assert_eq!(descriptor, "()V"); - assert!(!itf); - } - other => panic!("ожидался Invoke, получено: {other:?}"), - } - assert!(matches!(insns[1], Insn::Return(None))); - assert_eq!(report.transformed, 3); - assert_eq!(report.skipped, 0); - } - - #[test] - fn disabled_ratio_changes_nothing() { - let mut program = sample(); - let before = program.clone(); - let report = run(&mut program, NoiseConfig::default()); - - assert_eq!(program, before); - assert_eq!(report.transformed, 0); - assert_eq!(report.skipped, 0); - } +/// Recompute the frames of a generated 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(|| CoreError::pass(PassId::Noise, "class vanished"))?; + ( + class.this_class.clone(), + class + .methods + .get(mi) + .cloned() + .ok_or_else(|| CoreError::pass(PassId::Noise, "method vanished"))?, + ) + }; + let frames = compute_frames(&name, &snapshot, hierarchy) + .map_err(|e| CoreError::pass(PassId::Noise, 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(|| CoreError::pass(PassId::Noise, "method vanished"))? + .frames = frames; + Ok(()) } diff --git a/crates/noxis-passes/src/noise/tests.rs b/crates/noxis-passes/src/noise/tests.rs new file mode 100644 index 0000000..89ee39b --- /dev/null +++ b/crates/noxis-passes/src/noise/tests.rs @@ -0,0 +1,208 @@ +//! Noise pass tests: junk shape, names, weaving, determinism. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_classfile::{ + ClassBuilder, Insn, InvokeKind, Program, Resource, Version, empty_code, format, +}; +use noxis_core::{Config, NoiseConfig}; + +use super::*; + +fn sample() -> Program { + Program { + classes: vec![ + ClassBuilder::new("demo/App", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0009, "run", "()V", empty_code(0)) + .build(), + ], + resources: vec![Resource { + path: "app.json".into(), + data: br#"{}"#.to_vec(), + }], + } +} + +fn noisy() -> NoiseConfig { + NoiseConfig { + junk_ratio: 2, + junk_methods_per_class: 2, + junk_annotations: 0, + weave_reachable: true, + } +} + +fn run(program: &mut Program, noise: NoiseConfig) -> PassReport { + let config = Config { + seed: Some(42), + noise, + ..Config::default() + }; + let mut ctx = Context::new(config); + NoisePass.apply(&mut ctx, program).expect("noise pass") +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_alphabetic() || first == '_' || first == '$' => {} + _ => return false, + } + chars.all(|c| c.is_alphanumeric() || c == '_' || c == '$') +} + +#[test] +fn adds_junk_with_bodies_and_weaves_call() { + let mut program = sample(); + let report = run(&mut program, noisy()); + + assert_eq!(program.classes.len(), 3); + let junks = &program.classes[1..]; + let mut names = Vec::new(); + for junk in junks { + // No self-describing fingerprints, valid identifiers, unique. + assert!( + !junk.this_class.contains("shadow") && !junk.this_class.contains("decoy"), + "fingerprint survived: {}", + junk.this_class + ); + for part in junk.this_class.split('/') { + assert!(is_identifier(part), "bad segment in {}", junk.this_class); + } + names.push(junk.this_class.clone()); + // One accumulator field, fed by the entry. + assert_eq!(junk.fields.len(), 1); + assert_eq!(junk.fields[0].descriptor, "I"); + let field = junk.fields[0].name.clone(); + // Helpers have real branching bodies, not bare returns. + let helpers: Vec<_> = junk + .methods + .iter() + .filter(|m| m.descriptor == HELPER_DESCRIPTOR) + .collect(); + assert_eq!(helpers.len(), 2); + for helper in helpers { + let code = helper.code.as_ref().expect("code"); + assert!( + code.insns.iter().any(|i| matches!(i, Insn::Branch(..))), + "helper without branches" + ); + assert!( + code.insns.iter().any(|i| matches!(i, Insn::Binary { .. })), + "helper without arithmetic" + ); + assert!(!code.frames.is_empty(), "helper frames were not computed"); + } + // The entry calls the helpers and stores into the field. + let entry = junk + .methods + .iter() + .find(|m| m.descriptor == ENTRY_DESCRIPTOR) + .expect("entry"); + let entry_code = entry.code.as_ref().expect("code"); + let calls = entry_code + .insns + .iter() + .filter(|i| matches!(i, Insn::Invoke { .. })) + .count(); + assert_eq!(calls, 2, "entry must call every helper"); + assert!( + entry_code + .insns + .iter() + .any(|i| matches!(i, Insn::PutStatic { name, .. } if name == &field)), + "entry must feed the field" + ); + } + assert_ne!(names[0], names[1]); + + // The live method opens with a call into the first junk entry. + let live = program.classes[0].methods[0].code.as_ref().unwrap(); + match &live.insns[0] { + Insn::Invoke { + kind, + owner, + descriptor, + itf, + .. + } => { + assert_eq!(*kind, InvokeKind::Static); + assert_eq!(owner, &names[0]); + assert_eq!(descriptor, "()V"); + assert!(!itf); + } + other => panic!("expected Invoke, got: {other:?}"), + } + assert_eq!(report.transformed, 3); + assert_eq!(report.skipped, 0); +} + +#[test] +fn disabled_ratio_changes_nothing() { + let mut program = sample(); + let before = program.clone(); + let report = run(&mut program, NoiseConfig::default()); + + assert_eq!(program, before); + assert_eq!(report.transformed, 0); + assert_eq!(report.skipped, 0); +} + +#[test] +fn junk_is_deterministic() { + let mut first = sample(); + let mut second = sample(); + run(&mut first, noisy()); + run(&mut second, noisy()); + assert_eq!(first, second); +} + +/// Junk bodies carry real branches: they must verify under a real JVM. +#[test] +fn junk_verifies_under_java() { + if std::process::Command::new("java") + .arg("-version") + .output() + .is_err() + { + eprintln!("SKIP: no java"); + return; + } + let mut program = sample(); + run( + &mut program, + NoiseConfig { + junk_ratio: 1, + ..noisy() + }, + ); + let dir = std::env::temp_dir().join(format!("noxis-noise-{}", 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"); + } + for class in &program.classes { + let dotted = class.this_class.replace('/', "."); + let output = std::process::Command::new("java") + .arg("-Xverify:all") + .arg("-cp") + .arg(&dir) + .arg(&dotted) + .output() + .expect("run java"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("VerifyError") && !stderr.contains("ClassFormatError"), + "{dotted}: {stderr}" + ); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/noxis-passes/src/noise/weave.rs b/crates/noxis-passes/src/noise/weave.rs index 454eac8..ce3be2f 100644 --- a/crates/noxis-passes/src/noise/weave.rs +++ b/crates/noxis-passes/src/noise/weave.rs @@ -1,22 +1,23 @@ -//! Вплетание junk-классов в граф достижимости живых методов. +//! Weaving junk classes into the reachability graph of live methods. use noxis_analysis::ClassHierarchy; use noxis_classfile::{Insn, InvokeKind, Program, compute_frames}; use noxis_core::{CoreError, PassId}; -use super::JUNK_ENTRY; +/// The woven call descriptor (entries are always `()V`). +const WOVEN_DESCRIPTOR: &str = "()V"; -/// Вплетает вызов первого junk-класса в первый метод с кодом. +/// Weave a call of the first junk entry into the first method with code. /// -/// Вызов встаёт перед терминалом (`Return`/`AReturn`/`AThrow`), чтобы не -/// породить недостижимый хвост без фреймов; фреймы пересчитываются. +/// The call lands before the terminal (`Return`/`AReturn`/`AThrow`) so it +/// never strands an unreachable tail without frames; frames are recomputed. pub(super) fn weave_first_reachable( program: &mut Program, real_classes: usize, - junk_names: &[String], + entries: &[(String, String)], ) -> Result { - let missing = || CoreError::pass(PassId::Noise, "цель вплетания пропала"); - let Some(owner) = junk_names.first() else { + let missing = || CoreError::pass(PassId::Noise, "weave target vanished"); + let Some((owner, entry)) = entries.first() else { return Err(missing()); }; let target = program.classes[..real_classes.min(program.classes.len())] @@ -39,8 +40,8 @@ pub(super) fn weave_first_reachable( let call = Insn::Invoke { kind: InvokeKind::Static, owner: owner.clone(), - name: JUNK_ENTRY.0.to_string(), - descriptor: JUNK_ENTRY.1.to_string(), + name: entry.clone(), + descriptor: WOVEN_DESCRIPTOR.to_string(), itf: false, }; { diff --git a/crates/noxis-passes/src/rename/assign.rs b/crates/noxis-passes/src/rename/assign.rs index 857e8dc..d9d2e44 100644 --- a/crates/noxis-passes/src/rename/assign.rs +++ b/crates/noxis-passes/src/rename/assign.rs @@ -158,7 +158,7 @@ fn params_of(descriptor: &str) -> Option { Some(descriptor[..=close].to_string()) } -pub(super) fn parent_package(internal: &str) -> String { +pub(crate) fn parent_package(internal: &str) -> String { match internal.rfind('/') { Some(i) => internal[..i].to_string(), None => String::new(), diff --git a/crates/noxis-passes/src/rename/mod.rs b/crates/noxis-passes/src/rename/mod.rs index bbed81b..ae6a081 100644 --- a/crates/noxis-passes/src/rename/mod.rs +++ b/crates/noxis-passes/src/rename/mod.rs @@ -1,20 +1,20 @@ -//! Пасс переименования: классы, поля, методы, локальные переменные, дескрипторы. +//! The rename pass: classes, fields, methods, locals, descriptors. //! -//! Это самая дешёвая и самая отдающая техника (см. `info/evo-weaknesses.md`: -//! у EvoVis члены не переименовывались вообще, и класс читался по `javap -p`). +//! The cheapest technique with the highest payoff (see `info/evo-weaknesses.md`: +//! EvoVis never renamed members at all, so the class read plainly via `javap -p`). //! -//! Тонкости, которые здесь учитываются: -//! - вызов `Sub.staticMethod()` может указывать владельцем подкласс - нужны -//! псевдонимы (`alias`) для всей иерархии; -//! - мостовые методы (`ACC_BRIDGE`) и ковариантные возвраты живут в одном -//! «семействе» метода - переименовывать их нужно разом; -//! - `serialVersionUID`, JNI, рефлексия, сериализация - не трогаем. +//! Subtleties handled here: +//! - a `Sub.staticMethod()` call may name a subclass as the owner - aliases +//! (`alias`) are needed across the hierarchy; +//! - bridge methods (`ACC_BRIDGE`) and covariant returns live in one method +//! "family" - they must be renamed together; +//! - `serialVersionUID`, JNI, reflection, serialization stay untouched. -/// Карты и стратегия назначения имён. -mod assign; -/// Переписывание тел методов и атрибутов. +/// Name maps and the assignment strategy. +pub(crate) mod assign; +/// Rewriting method bodies and attributes. mod rewrite; -/// Интеграционные тесты пасса. +/// Pass integration tests. #[cfg(test)] mod tests; @@ -35,7 +35,7 @@ const ACC_STATIC: u16 = 0x0008; const ACC_FINAL: u16 = 0x0010; const ACC_ENUM: u16 = 0x4000; -/// Пасс переименования. +/// The rename pass. #[derive(Debug, Default, Clone, Copy)] pub struct RenamePass; @@ -58,7 +58,7 @@ impl Pass for RenamePass { let mut maps = RenameMaps::default(); let mut renamed_classes = 0usize; - // 1. Имена классов. + // 1. Class names. { let packages = generate_packages(&cfg, &mut ctx.rng("rename/packages")); let mut names = @@ -91,7 +91,7 @@ impl Pass for RenamePass { } } - // 2. Простые (не-виртуальные) члены: private/static/final. + // 2. Simple (non-virtual) members: private/static/final. let mut renamed_methods = 0usize; let mut renamed_fields = 0usize; @@ -138,8 +138,8 @@ impl Pass for RenamePass { ); renamed_methods += 1; } - // Виртуальный не-final: переименовывается только вместе - // со всем семейством (см. rename_virtual_families). + // Non-final virtual: renamed only together with the whole + // family (see rename_virtual_families). _ => method_names.reserve(method.name.clone()), } } @@ -179,28 +179,28 @@ impl Pass for RenamePass { } } - // 3. Виртуальные семейства - только в агрессивном режиме. + // 3. Virtual families - aggressive mode only. if cfg.rename.rename_public_members { renamed_methods += rename_virtual_families(program, &analysis, &mut maps, &cfg, ctx); } - // 4. Псевдоними для наследников. + // 4. Aliases for subclasses. maps.expand(&hierarchy, &owners); - // 5. Переписать определения и все ссылки. + // 5. Rewrite definitions and all references. let mut local_counter = 0u64; for class in &mut program.classes { let orig_owner = class.this_class.clone(); rewrite_class(class, &orig_owner, &maps, &cfg, &mut local_counter, ctx); } - // 6. Ширинирование доступа: после переезда в другие пакеты иначе - // возможен IllegalAccessError. + // 6. Access widening: moving across packages could otherwise + // cause IllegalAccessError. if cfg.rename.widen_access { widen_all(program); } - // 7. Маппинги в отчёт (обратная деобфускация stack trace'ей). + // 7. Mappings for the report (stack-trace deobfuscation). for ((owner, name, desc), new_name) in maps.methods.iter() { ctx.mapping .methods @@ -217,7 +217,7 @@ impl Pass for RenamePass { let locked = analysis.locked_classes.len(); if locked > 0 { - ctx.note(format!("rename: {locked} классов в keep (не тронуты)")); + ctx.note(format!("rename: {locked} keep-listed classes (untouched)")); } Ok(PassReport { @@ -225,7 +225,7 @@ impl Pass for RenamePass { transformed: renamed_classes + renamed_methods + renamed_fields, skipped: locked, notes: vec![format!( - "классов: {renamed_classes}, методов: {renamed_methods}, полей: {renamed_fields}" + "classes: {renamed_classes}, methods: {renamed_methods}, fields: {renamed_fields}" )], }) } diff --git a/crates/noxis-passes/src/strings/crypt.rs b/crates/noxis-passes/src/strings/crypt.rs index 7189506..52eab22 100644 --- a/crates/noxis-passes/src/strings/crypt.rs +++ b/crates/noxis-passes/src/strings/crypt.rs @@ -7,7 +7,7 @@ use noxis_classfile::{Const, Insn, InvokeKind}; use noxis_runtime::{DECODER_DESC, DECODER_METHOD}; -/// Разбить строку на чанки по границе символов (1–3 штуки). +/// Разбить строку на чанки по границе символов (1-3 штуки). pub(super) fn split_chunks(text: &str, split_above: usize) -> Vec { let chars: Vec = text.chars().collect(); if chars.len() <= split_above.max(1) {