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.
This commit is contained in:
parent
74ad4552e4
commit
2cbb9db31e
11 changed files with 648 additions and 159 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ impl Rng {
|
|||
}
|
||||
}
|
||||
|
||||
/// Перемешать срез на месте (Fisher–Yates).
|
||||
/// Перемешать срез на месте (Fisher-Yates).
|
||||
pub fn shuffle<T>(&mut self, items: &mut [T]) {
|
||||
for i in (1..items.len()).rev() {
|
||||
let j = self.below((i + 1) as u64) as usize;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
259
crates/noxis-passes/src/noise/body.rs
Normal file
259
crates/noxis-passes/src/noise/body.rs
Normal file
|
|
@ -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<u32> = 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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PassReport, CoreError> {
|
||||
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 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 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 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()
|
||||
/// 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 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);
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
208
crates/noxis-passes/src/noise/tests.rs
Normal file
208
crates/noxis-passes/src/noise/tests.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<usize, CoreError> {
|
||||
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,
|
||||
};
|
||||
{
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ fn params_of(descriptor: &str) -> Option<String> {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.len() <= split_above.max(1) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue