Initial commit: noxis JVM bytecode obfuscator

Workspace of 9 crates (noxis, noxis-core, noxis-classfile, noxis-analysis,
noxis-passes, noxis-runtime, noxis-jar, noxis-verify, noxis-structure-check).
AGPL-3.0-only license, Rust 2024 edition, unsafe_code denied.
This commit is contained in:
Loki 2026-10-08 22:01:03 +02:00
commit a641e69455
140 changed files with 17197 additions and 0 deletions

View file

@ -0,0 +1,21 @@
[package]
name = "noxis-passes"
description = "Трансформации: переименование, строки, константы, CFG, шум, отладка"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
noxis-analysis = { workspace = true }
noxis-classfile = { workspace = true }
noxis-core = { workspace = true }
noxis-runtime = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[lints]
workspace = true
[build-dependencies]
noxis-structure-check = { workspace = true }

View file

@ -0,0 +1,3 @@
fn main() {
noxis_structure_check::check();
}

View file

@ -0,0 +1,159 @@
//! Схемы разбиения констант и opaque-предикаты.
//!
//! Целые константы вне `iconst`-диапазона заменяются тройками (`x^k`,
//! `a+b`, сдвиг); opaque-предикат — всегда истинное условие с мёртвой
//! ветвью из одного `nop` (валидно, не исполняется, ломает линейный
//! дисассемблирование).
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim};
use noxis_core::Rng;
/// Схема разбиения (имена из `ConstantsConfig::schemes`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Scheme {
Xor,
Add,
Shift,
}
impl Scheme {
pub(super) fn parse(name: &str) -> Option<Self> {
match name {
"xor" => Some(Scheme::Xor),
"add" => Some(Scheme::Add),
"shift" => Some(Scheme::Shift),
_ => None,
}
}
}
/// Разбить `int`-константу на вычисляющую её тройку.
pub(super) fn encrypt_int(value: i32, scheme: Scheme, rng: &mut Rng) -> Vec<Insn> {
let (first, second) = match scheme {
Scheme::Xor => {
let mut key = rng.next_u64() as i32;
if key == 0 {
key = 0x5BD1_E995u32 as i32;
}
(value ^ key, key)
}
Scheme::Add => {
let part = rng.next_u64() as i32;
(part, value.wrapping_sub(part))
}
Scheme::Shift => (value >> 1, value),
};
match scheme {
Scheme::Xor => binary(first, second, Arith::Xor, Prim::Int),
Scheme::Add => binary(first, second, Arith::Add, Prim::Int),
Scheme::Shift => shift_form(value),
}
}
/// Разбить `long`-константу (те же три схемы на `l*`-опкодах).
pub(super) fn encrypt_long(value: i64, scheme: Scheme, rng: &mut Rng) -> Vec<Insn> {
let (first, second) = match scheme {
Scheme::Xor => {
let mut key = rng.next_u64() as i64;
if key == 0 {
key = 0x5BD1_E995_7A93_2AA9u64 as i64;
}
(value ^ key, key)
}
Scheme::Add => {
let part = rng.next_u64() as i64;
(part, value.wrapping_sub(part))
}
Scheme::Shift => (value >> 1, value),
};
match scheme {
Scheme::Xor => binary_long(first, second, Arith::Xor),
Scheme::Add => binary_long(first, second, Arith::Add),
Scheme::Shift => shift_form_long(value),
}
}
/// `(a, b, op)` как три инструкции.
fn binary(first: i32, second: i32, op: Arith, prim: Prim) -> Vec<Insn> {
vec![
Insn::Const(Const::Int(first)),
Insn::Const(Const::Int(second)),
Insn::Binary { prim, op },
]
}
fn binary_long(first: i64, second: i64, op: Arith) -> Vec<Insn> {
vec![
Insn::Const(Const::Long(first)),
Insn::Const(Const::Long(second)),
Insn::Binary {
prim: Prim::Long,
op,
},
]
}
/// `v = ((v >> 1) << 1) | (v & 1)` — работает для любых, включая отрицательные.
fn shift_form(value: i32) -> Vec<Insn> {
vec![
Insn::Const(Const::Int(value >> 1)),
Insn::Const(Const::Int(1)),
Insn::Binary {
prim: Prim::Int,
op: Arith::Shl,
},
Insn::Const(Const::Int(value & 1)),
Insn::Binary {
prim: Prim::Int,
op: Arith::Or,
},
]
}
/// То же для `long` (величина сдвига — всегда `int`!).
fn shift_form_long(value: i64) -> Vec<Insn> {
vec![
Insn::Const(Const::Long(value >> 1)),
Insn::Const(Const::Int(1)),
Insn::Binary {
prim: Prim::Long,
op: Arith::Shl,
},
Insn::Const(Const::Long(value & 1)),
Insn::Binary {
prim: Prim::Long,
op: Arith::Or,
},
]
}
/// Opaque-предикат `(C*C + C) & 1 == 0` (истина для любого `C`):
/// `[pred, ifne DEAD, goto MERGE, DEAD: nop, MERGE:]`, дальше — оригинал.
///
/// Метки выдаёт вызывающий код (свободные номера метода).
pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
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,
},
Insn::Branch(BranchCond::Zero(Cond::Ne), dead),
Insn::Goto(merge),
Insn::Mark(dead),
Insn::Nop,
Insn::Mark(merge),
]
}

View file

@ -0,0 +1,215 @@
//! Пасс шифрования констант: числа → арифметика, плюс opaque-предикаты.
//!
//! `float`/`double` не трогаются (битовая точность через целые — отдельно).
//! Мелочь из `iconst`-диапазона (`-1..=5`, `0L/1L`) остаётся как есть:
//! её шифрование только раздувает горячий код.
mod forms;
#[cfg(test)]
mod tests;
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Const, Insn, Program, compute_frames};
use noxis_core::{Context, CoreError, Pass, PassId, PassReport};
use forms::{Scheme, encrypt_int, encrypt_long, opaque_true};
/// Пасс шифрования констант.
#[derive(Debug, Default, Clone, Copy)]
pub struct ConstantsPass;
impl Pass for ConstantsPass {
fn id(&self) -> PassId {
PassId::Constants
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let cfg = ctx.config.constants.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!("неизвестная схема `{name}`")))?,
);
}
if schemes.is_empty() {
return Err(pass_error("нет схем шифрования"));
}
let mut rng = ctx.rng("constants");
let mut replaced = 0usize;
let mut predicates = 0usize;
let mut skipped = 0usize;
let mut mutated: Vec<(usize, usize)> = Vec::new();
for (ci, class) in program.classes.iter_mut().enumerate() {
if cfg
.skip
.iter()
.any(|prefix| class.this_class.starts_with(prefix))
{
skipped += 1;
continue;
}
for (mi, method) in class.methods.iter_mut().enumerate() {
let Some(code) = method.code.as_mut() else {
continue;
};
let mut fresh = Vec::with_capacity(code.insns.len());
let mut touched = false;
for insn in code.insns.drain(..) {
match insn {
Insn::Const(Const::Int(value)) if !is_tiny_int(value) => {
let scheme = schemes[rng.below(schemes.len() as u64) as usize];
fresh.extend(encrypt_int(value, scheme, &mut rng));
replaced += 1;
touched = true;
}
Insn::Const(Const::Long(value)) if value != 0 && value != 1 => {
let scheme = schemes[rng.below(schemes.len() as u64) as usize];
fresh.extend(encrypt_long(value, scheme, &mut rng));
replaced += 1;
touched = true;
}
other => fresh.push(other),
}
}
code.insns = fresh;
if touched {
code.max_stack = code.max_stack.saturating_add(2);
mutated.push((ci, mi));
}
}
}
insert_predicates(
program,
&mut mutated,
cfg.max_predicates,
&mut rng,
&mut predicates,
)?;
let hierarchy = ClassHierarchy::build(program);
for (ci, mi) in &mutated {
refresh_frames(program, *ci, *mi, &hierarchy)?;
}
Ok(report(replaced, predicates, skipped))
}
}
/// Мелочь, которую шифровать невыгодно (уже `iconst`).
fn is_tiny_int(value: i32) -> bool {
(-1..=5).contains(&value)
}
/// Opaque-предикаты в начало методов с кодом (не больше лимита на метод).
fn insert_predicates(
program: &mut Program,
mutated: &mut Vec<(usize, usize)>,
max_predicates: usize,
rng: &mut noxis_core::Rng,
predicates: &mut usize,
) -> Result<(), CoreError> {
if max_predicates == 0 {
return Ok(());
}
let classes = program.classes.len();
for ci in 0..classes {
let methods = program.classes[ci].methods.len();
for mi in 0..methods {
let empty_or_missing = program.classes[ci].methods[mi]
.code
.as_ref()
.is_none_or(|code| code.insns.is_empty());
if empty_or_missing {
continue;
}
let missing = || pass_error("метод пропал при вставке предиката");
let mut top = program.classes[ci]
.methods
.get(mi)
.and_then(|method| method.code.as_ref())
.ok_or_else(missing)?
.labels()
.into_iter()
.max()
.unwrap_or(0);
for _ in 0..max_predicates {
top += 1;
let dead = top;
top += 1;
let merge = top;
let at = program.classes[ci]
.methods
.get(mi)
.and_then(|method| method.code.as_ref())
.ok_or_else(missing)?
.insns
.iter()
.position(|insn| !matches!(insn, Insn::Mark(_)))
.unwrap_or(0);
let code = program.classes[ci]
.methods
.get_mut(mi)
.and_then(|method| method.code.as_mut())
.ok_or_else(missing)?;
let mut block = opaque_true(dead, merge, rng);
block.extend(code.insns.drain(at..));
code.insns.splice(at..at, block);
code.max_stack = code.max_stack.saturating_add(2);
*predicates += 1;
}
if !mutated.contains(&(ci, mi)) {
mutated.push((ci, mi));
}
}
}
Ok(())
}
/// Пересчитать фреймы затронутого метода.
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.this_class.clone(),
class
.methods
.get(mi)
.cloned()
.ok_or_else(|| pass_error("метод пропал"))?,
)
};
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("метод пропал"))?
.frames = frames;
Ok(())
}
fn report(replaced: usize, predicates: usize, skipped: usize) -> PassReport {
PassReport {
pass: PassId::Constants,
transformed: replaced + predicates,
skipped,
notes: vec![format!(
"констант разбито: {replaced}, предикатов: {predicates}"
)],
}
}
fn pass_error(msg: impl Into<String>) -> CoreError {
CoreError::pass(PassId::Constants, msg)
}

View file

@ -0,0 +1,111 @@
//! Тесты пасса констант: формы вычисляют исходник, мелочь цела.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Const, Insn, Prim, Program, Version};
use noxis_core::{Config, Context, Pass};
use super::ConstantsPass;
use super::forms::{Scheme, encrypt_int, encrypt_long};
/// Формы обратимы арифметически (интерпретатор троек/пятёрок).
fn eval_int(insns: &[Insn]) -> i32 {
match insns {
[
Insn::Const(Const::Int(a)),
Insn::Const(Const::Int(b)),
Insn::Binary { op, .. },
] => {
use noxis_classfile::Arith::{Add, Xor};
match op {
Xor => a ^ b,
Add => a.wrapping_add(*b),
_ => panic!("не та тройка: {insns:?}"),
}
}
[
Insn::Const(Const::Int(a)),
Insn::Const(Const::Int(1)),
Insn::Binary { .. },
Insn::Const(Const::Int(b)),
Insn::Binary { .. },
] => (a << 1) | (b & 1),
_ => panic!("не форма: {insns:?}"),
}
}
#[test]
fn int_forms_round_trip() {
let mut rng = noxis_core::Rng::from_seed(11);
for value in [-1000, -7, -6, 6, 7, 42, 65536, i32::MAX, i32::MIN] {
for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift] {
assert_eq!(eval_int(&encrypt_int(value, scheme, &mut rng)), value);
}
}
}
#[test]
fn long_forms_keep_shape() {
let mut rng = noxis_core::Rng::from_seed(12);
for scheme in [Scheme::Xor, Scheme::Add, Scheme::Shift] {
let insns = encrypt_long(1_000_000_000_000, scheme, &mut rng);
assert!(matches!(
insns.last(),
Some(Insn::Binary {
prim: Prim::Long,
..
})
));
}
}
fn sample() -> Program {
let code = Code {
max_stack: 1,
max_locals: 0,
insns: vec![
Insn::Const(Const::Int(100)),
Insn::Const(Const::Int(3)),
Insn::Const(Const::Long(10)),
Insn::Return(None),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
Program {
classes: vec![
ClassBuilder::new("demo/App", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0009, "run", "()V", code)
.build(),
],
resources: Vec::new(),
}
}
/// Пасс: большие константы разбиты, мелочь и структура целы.
#[test]
fn constants_split_and_predicated() {
let mut program = sample();
let mut ctx = Context::new(Config {
seed: Some(9),
..Config::default()
});
let report = ConstantsPass
.apply(&mut ctx, &mut program)
.expect("constants");
let code = program.classes[0].methods[0].code.as_ref().expect("код");
let has = |v: i32| code.insns.contains(&Insn::Const(Const::Int(v)));
assert!(!has(100), "исходная константа уцелела");
assert!(has(3), "мелочь тронута");
assert!(
!code.insns.contains(&Insn::Const(Const::Long(10))),
"long уцелел"
);
// Opaque-предикат: мёртвая ветвь из `nop`.
assert!(code.insns.contains(&Insn::Nop), "нет предиката");
assert!(report.transformed >= 3, "мало трансформаций: {report:?}");
assert_eq!(report.skipped, 0);
}

View file

@ -0,0 +1,246 @@
//! Пасс отладочной информации: удаление и ложная подмена.
//!
//! Источник требований — `info/evo-weaknesses.md` §3: у разобранного
//! образца оригинальные `SourceFile` у 160 классов, 10 218 `LineNumberTable`
//! и имена локальных переменных. Это дешевле всего чинить и дороже всего
//! отдавать.
//!
//! Важно: удалять недостаточно. Инструменты (JADX/CFR) верят отладке сильнее,
//! чем байткоду, поэтому `spoof_source_file` подставляет **ложное** имя —
//! консистентное, но не связанное с реальным исходником.
use noxis_classfile::{Attribute, ClassFile, Code, Method, Program};
use noxis_core::{Context, CoreError, DebugConfig, Pass, PassId, PassReport};
use crate::namegen::NameGenerator;
/// Атрибут уровня класса/метода, который мы выкидываем как «сырой».
const ENCLOSING_METHOD: &str = "EnclosingMethod";
/// Атрибут уровня метода с именами параметров (`-parameters`).
const METHOD_PARAMETERS: &str = "MethodParameters";
/// Пасс отладочной информации.
#[derive(Debug, Default, Clone, Copy)]
pub struct DebugInfoPass;
impl Pass for DebugInfoPass {
fn id(&self) -> PassId {
PassId::DebugInfo
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let cfg = ctx.config.debug.clone();
let style = ctx.config.rename.class_style;
let mut source_names =
NameGenerator::new(ctx.rng("debug/source_file"), style).for_classes();
let mut stripped = 0usize;
let mut spoofed = 0usize;
for class in &mut program.classes {
spoofed += spoof_source_file(class, &mut source_names, &cfg);
stripped += strip_signatures(&mut class.attributes, &cfg);
for method in &mut class.methods {
stripped += strip_signatures(&mut method.attributes, &cfg);
stripped += strip_parameters(method, &cfg);
if let Some(code) = method.code.as_mut() {
stripped += strip_code(code, &cfg);
}
}
for field in &mut class.fields {
stripped += strip_signatures(&mut field.attributes, &cfg);
}
}
let mut notes = Vec::new();
if stripped > 0 {
notes.push(format!("удалено атрибутов: {stripped}"));
}
if spoofed > 0 {
notes.push(format!("подменено SourceFile: {spoofed}"));
}
Ok(PassReport {
pass: PassId::DebugInfo,
transformed: stripped + spoofed,
skipped: 0,
notes,
})
}
}
/// Оставляет только атрибуты, для которых `keep` вернул `true`.
fn retain(attrs: &mut Vec<Attribute>, keep: impl FnMut(&Attribute) -> bool) -> usize {
let before = attrs.len();
attrs.retain(keep);
before - attrs.len()
}
/// Тела методов: `LineNumberTable`, `LocalVariableTable`.
fn strip_code(code: &mut Code, cfg: &DebugConfig) -> usize {
retain(&mut code.attributes, |attr| match attr {
Attribute::LineNumberTable(_) => !cfg.strip_line_numbers,
Attribute::LocalVariableTable(_) | Attribute::LocalVariableTypeTable(_) => {
!cfg.strip_local_variables
}
_ => true,
})
}
/// Уровень класса/метода/поля: `Signature`, `InnerClasses`, `EnclosingMethod`.
fn strip_signatures(attrs: &mut Vec<Attribute>, cfg: &DebugConfig) -> usize {
if !cfg.strip_signatures {
return 0;
}
retain(attrs, |attr| match attr {
Attribute::Signature(_) | Attribute::InnerClasses(_) => false,
Attribute::EnclosingMethod { .. } => false,
Attribute::Raw { name, .. } => name != ENCLOSING_METHOD,
_ => true,
})
}
/// Имена параметров метода (`MethodParameters`).
fn strip_parameters(method: &mut Method, cfg: &DebugConfig) -> usize {
if !cfg.strip_local_variables {
return 0;
}
retain(&mut method.attributes, |attr| match attr {
Attribute::MethodParameters(_) => false,
Attribute::Raw { name, .. } => name != METHOD_PARAMETERS,
_ => true,
})
}
/// Подменяет `SourceFile` ложным, но правдоподобным именем.
fn spoof_source_file(class: &mut ClassFile, names: &mut NameGenerator, cfg: &DebugConfig) -> usize {
if !cfg.spoof_source_file {
return 0;
}
let mut replaced = 0;
for attr in &mut class.attributes {
if matches!(attr, Attribute::SourceFile(_)) {
*attr = Attribute::SourceFile(format!("{}.java", names.next_name()));
replaced += 1;
}
}
replaced
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Insn, LineNumber, LocalVariable, Resource, Version};
use noxis_core::{Config, Context, RenameConfig};
use super::*;
fn sample() -> Program {
let code = Code {
max_stack: 0,
max_locals: 1,
insns: vec![Insn::Return(None)],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: vec![
Attribute::LineNumberTable(vec![LineNumber { start: 0, line: 12 }]),
Attribute::LocalVariableTable(vec![LocalVariable {
start: 0,
end: 0,
name: "this".into(),
descriptor: "Ldemo/App;".into(),
index: 0,
}]),
],
};
let mut class = ClassBuilder::new("demo/App", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0001, "run", "()V", code)
.attribute(Attribute::SourceFile("RealApp.java".into()))
.attribute(Attribute::Signature("Ljava/lang/Object;".into()))
.attribute(Attribute::InnerClasses(Vec::new()))
.build();
class.methods[0].attributes.push(Attribute::Raw {
name: METHOD_PARAMETERS.into(),
data: vec![],
});
Program {
classes: vec![class],
resources: vec![Resource {
path: "app.json".into(),
data: br#"{}"#.to_vec(),
}],
}
}
fn run(program: &mut Program, debug: DebugConfig) -> PassReport {
let config = Config {
seed: Some(7),
debug,
rename: RenameConfig {
class_style: noxis_core::NameStyle::ShortLatin,
..RenameConfig::default()
},
..Config::default()
};
let mut ctx = Context::new(config);
DebugInfoPass.apply(&mut ctx, program).expect("debug pass")
}
#[test]
fn default_config_strips_and_spoofs() {
let mut program = sample();
let report = run(&mut program, DebugConfig::default());
let class = &program.classes[0];
let code = class.methods[0].code.as_ref().expect("code");
assert!(code.attributes.is_empty(), "атрибуты кода должны уйти");
assert!(
class.methods[0].attributes.is_empty(),
"MethodParameters уходит"
);
match class.attributes[0] {
Attribute::SourceFile(ref name) => assert_ne!(name, "RealApp.java"),
_ => panic!("SourceFile должен быть первым"),
}
assert_eq!(class.attributes.len(), 3, "Signature/InnerClasses остаются");
assert!(report.transformed > 0);
}
#[test]
fn signatures_and_parameters_survive_when_disabled() {
let debug = DebugConfig {
strip_line_numbers: false,
strip_local_variables: false,
spoof_source_file: false,
strip_signatures: false,
};
let mut program = sample();
run(&mut program, debug);
let class = &program.classes[0];
assert_eq!(class.methods[0].code.as_ref().unwrap().attributes.len(), 2);
assert_eq!(class.methods[0].attributes.len(), 1);
assert_eq!(class.attributes.len(), 3);
assert_eq!(
class.attributes[0],
Attribute::SourceFile("RealApp.java".into())
);
}
#[test]
fn same_seed_gives_the_same_spoofed_name() {
let mut first = sample();
let mut second = sample();
run(&mut first, DebugConfig::default());
run(&mut second, DebugConfig::default());
assert_eq!(first.classes[0].attributes, second.classes[0].attributes);
}
}

View file

@ -0,0 +1,95 @@
//! # noxis-passes
//!
//! Трансформации байткода. Сборка конвейера из пассов (`standard_pipeline`)
//! живёт здесь же: ядро (`noxis-core`) знает только контракт [`Pass`].
pub mod constants;
pub mod debug;
pub mod namegen;
pub mod noise;
pub mod rename;
pub mod resources;
pub mod strings;
use noxis_core::Pass;
pub use constants::ConstantsPass;
pub use debug::DebugInfoPass;
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):
/// `rename → resources → strings → constants → flow → noise → debug`.
///
/// Нереализованные пассы просто не добавлены — порядок уже соблюдён,
/// поэтому каждый следующий пасс вставляется на своё место, а не
/// дописывается в конец.
pub fn standard_pipeline() -> noxis_core::Pipeline {
noxis_core::Pipeline::new()
.push(Box::new(RenamePass) as Box<dyn Pass>)
.push(Box::new(ResourcesPass) as Box<dyn Pass>)
.push(Box::new(StringsPass) as Box<dyn Pass>)
.push(Box::new(ConstantsPass) as Box<dyn Pass>)
.push(Box::new(NoisePass) as Box<dyn Pass>)
.push(Box::new(DebugInfoPass) as Box<dyn Pass>)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Insn, Program, Version};
use noxis_core::{Config, Context, PassId};
use super::*;
fn sample() -> Program {
let code = Code {
max_stack: 0,
max_locals: 1,
insns: vec![Insn::Return(None)],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
Program {
classes: vec![
ClassBuilder::new("demo/App", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0001, "run", "()V", code)
.build(),
],
resources: Vec::new(),
}
}
#[test]
fn standard_pipeline_runs_end_to_end_in_order() {
let mut ctx = Context::new(Config {
seed: Some(1),
..Config::default()
});
let mut program = sample();
let report = standard_pipeline()
.run(&mut ctx, &mut program)
.expect("pipeline");
let order: Vec<_> = report.passes.iter().map(|p| p.pass).collect();
assert_eq!(
order,
vec![
PassId::Rename,
PassId::Resources,
PassId::Strings,
PassId::Constants,
PassId::Noise,
PassId::DebugInfo
]
);
assert_eq!(report.counters.get("classes"), Some(&1));
}
}

View file

@ -0,0 +1,119 @@
//! Алфавиты, ключевые слова и запрещённые имена.
//!
//! Списки вынесены отдельно: к ним обращаются и генератор, и пасс
//! переименования (резервирование keep-имён).
/// Жёсткие ключевые слова Java: имя класса/метода не может им совпадать.
pub(super) const KEYWORDS: &[&str] = &[
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"strictfp",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"try",
"void",
"volatile",
"while",
"true",
"false",
"null",
"_",
"var",
"record",
"sealed",
"permits",
"yield",
];
/// Имена, которые не стоит затирать: мешают читать/логировать и ловятся
/// инструментами до любой деобфускации.
pub(super) const AVOID_CLASS: &[&str] = &[
"Object",
"String",
"Class",
"Record",
"Enum",
"Cloneable",
"Iterable",
"Comparable",
"Thread",
"Runnable",
"Override",
"Deprecated",
"SuppressWarnings",
];
pub(super) const AVOID_MEMBER: &[&str] = &[
"toString",
"equals",
"hashCode",
"clone",
"wait",
"notify",
"notifyAll",
"finalize",
"main",
"run",
"call",
"apply",
];
pub(super) const LATIN_LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
pub(super) const LATIN_UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/// Визуально неразличимые символы — ядро стиля `IlO0`.
pub(super) const CONFUSABLE: &[u8] = b"IlO01";
/// Unicode-буквы, валидные как Java-идентификаторы (Cyrillic/Greek/CJK).
pub(super) const UNICODE_LETTERS: &[char] = &[
'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'п', 'р', 'с', 'т', 'у', 'ф',
'х', 'ц', 'ч', 'ш', 'щ', 'э', 'ю', 'я', 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'λ', 'μ', 'π',
'ρ', 'σ', 'τ', 'φ', 'ω', '日', '本', '語', '中', '文', '国',
];
pub(super) const DECOY_LEFT: &[&str] = &[
"broken", "silent", "quiet", "amber", "cobalt", "frozen", "hollow", "vivid", "mellow",
"scarlet",
];
pub(super) const DECOY_RIGHT: &[&str] = &[
"mirror", "lantern", "harbor", "ember", "shadow", "quarry", "meadow", "cipher", "anchor",
"ripple",
];

View file

@ -0,0 +1,215 @@
//! Генерация имён: стили, анти-ключевые слова, уникальность в scope.
pub mod alphabet;
use std::collections::HashSet;
use noxis_core::{NameStyle, Rng};
use self::alphabet::{
AVOID_CLASS, AVOID_MEMBER, CONFUSABLE, DECOY_LEFT, DECOY_RIGHT, KEYWORDS, LATIN_LOWER,
LATIN_UPPER, UNICODE_LETTERS,
};
/// Генератор имён с гарантированной уникальностью в пределах одного scope.
pub struct NameGenerator {
rng: Rng,
style: NameStyle,
used: HashSet<String>,
/// Счётчик-страховка, если случайные варианты исчерпаны.
fallback: u64,
avoid_class: bool,
}
impl NameGenerator {
/// Генератор для указанного стиля.
pub fn new(rng: Rng, style: NameStyle) -> Self {
Self {
rng,
style,
used: HashSet::new(),
fallback: 0,
avoid_class: false,
}
}
/// Режим «имена классов»: дополнительно избегаем имён из JDK/аннотаций.
pub fn for_classes(mut self) -> Self {
self.avoid_class = true;
self
}
/// Занять конкретное имя (защита от коллизий с keep-именами).
pub fn reserve(&mut self, name: impl Into<String>) {
self.used.insert(name.into());
}
/// Следующее свободное имя.
pub fn next_name(&mut self) -> String {
for _ in 0..64 {
let candidate = self.candidate();
if self.acceptable(&candidate) {
self.used.insert(candidate.clone());
return candidate;
}
}
// Страховка: детерминированный счётчик, гарантированно уникален.
loop {
self.fallback += 1;
let candidate = format!("{}{}", self.style_prefix(), self.fallback);
if self.acceptable(&candidate) {
self.used.insert(candidate.clone());
return candidate;
}
}
}
fn style_prefix(&self) -> &'static str {
match self.style {
NameStyle::ShortLatin | NameStyle::IlO0 | NameStyle::MixedLatin => "z",
NameStyle::Unicode => "\u{043e}",
NameStyle::DecoyWords => "tmp",
}
}
fn candidate(&mut self) -> String {
match self.style {
NameStyle::ShortLatin => {
let len = 1 + self.rng.below(3) as usize;
random_from(LATIN_LOWER, len, &mut self.rng)
}
NameStyle::IlO0 => {
let len = 3 + self.rng.below(5) as usize;
random_from(CONFUSABLE, len, &mut self.rng)
}
NameStyle::MixedLatin => {
let len = 6 + self.rng.below(7) as usize;
let mut out = String::with_capacity(len);
for i in 0..len {
let alpha = if self.rng.below(2) == 0 {
LATIN_LOWER
} else {
LATIN_UPPER
};
let idx = self.rng.below(alpha.len() as u64) as usize;
let ch = alpha[idx] as char;
// Первый символ — только буква (не цифра).
if i == 0 && !ch.is_ascii_alphabetic() {
out.push('a');
} else {
out.push(ch);
}
}
out
}
NameStyle::Unicode => {
let len = 4 + self.rng.below(6) as usize;
(0..len)
.map(|_| {
let idx = self.rng.below(UNICODE_LETTERS.len() as u64) as usize;
UNICODE_LETTERS[idx]
})
.collect()
}
NameStyle::DecoyWords => {
let l = DECOY_LEFT[self.rng.below(DECOY_LEFT.len() as u64) as usize];
let r = DECOY_RIGHT[self.rng.below(DECOY_RIGHT.len() as u64) as usize];
let sep = if self.rng.below(2) == 0 { "_" } else { "" };
let mut name = format!("{l}{sep}{r}");
if self.rng.below(2) == 0 {
name = capitalize(&name);
}
name
}
}
}
fn acceptable(&self, name: &str) -> bool {
if name.is_empty() || self.used.contains(name) {
return false;
}
if KEYWORDS.contains(&name) {
return false;
}
if self.avoid_class && AVOID_CLASS.contains(&name) {
return false;
}
if !self.avoid_class && AVOID_MEMBER.contains(&name) {
return false;
}
let Some(first) = name.chars().next() else {
return false;
};
if !first.is_alphabetic() && first != '_' && first != '$' {
return false;
}
name.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
}
}
fn random_from(alphabet: &[u8], len: usize, rng: &mut Rng) -> String {
(0..len)
.map(|_| {
let idx = rng.below(alphabet.len() as u64) as usize;
alphabet[idx] as char
})
.collect()
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::alphabet::{AVOID_MEMBER, KEYWORDS};
use super::*;
#[test]
fn names_are_unique_and_valid() {
let mut names = NameGenerator::new(Rng::from_seed(1), NameStyle::ShortLatin);
let mut seen = HashSet::new();
for _ in 0..500 {
let name = names.next_name();
assert!(seen.insert(name.clone()), "повтор имени {name}");
assert!(!KEYWORDS.contains(&name.as_str()));
assert!(name.starts_with(|c: char| c.is_ascii_alphabetic()));
}
}
#[test]
fn avoids_reserved_member_names() {
let mut names = NameGenerator::new(Rng::from_seed(2), NameStyle::DecoyWords);
for _ in 0..500 {
let name = names.next_name();
assert!(!AVOID_MEMBER.contains(&name.as_str()));
}
}
#[test]
fn unicode_style_produces_identifier_valid_names() {
let mut names = NameGenerator::new(Rng::from_seed(3), NameStyle::Unicode);
for _ in 0..100 {
let name = names.next_name();
assert!(
name.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
);
}
}
#[test]
fn reserved_names_never_reissued() {
let mut names = NameGenerator::new(Rng::from_seed(4), NameStyle::ShortLatin);
names.reserve("abc");
for _ in 0..300 {
assert_ne!(names.next_name(), "abc");
}
}
}

View file

@ -0,0 +1,189 @@
//! Пасс шума: junk-классы и вплетание их в граф достижимости.
//!
//! Мёртвый код выкидывается shrink-анализом, поэтому каждый junk-класс
//! получает ребро из живого метода (`invokestatic shadow/decoyN.run()V`).
//! Junk-аннотации не генерируются: сырые атрибуты не несут свежие индексы пула.
mod weave;
use noxis_classfile::{ClassBuilder, Program, Version, empty_code};
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-метода.
const ACC_PUBLIC_STATIC: u16 = 0x0009;
/// Пасс шума.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoisePass;
impl Pass for NoisePass {
fn id(&self) -> PassId {
PassId::Noise
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let ratio = ctx.config.noise.junk_ratio;
let annotations = ctx.config.noise.junk_annotations;
let weave = ctx.config.noise.weave_reachable;
if ratio == 0 && annotations == 0 {
return Ok(PassReport {
pass: PassId::Noise,
transformed: 0,
skipped: 0,
notes: vec!["шум выключен: junk_ratio=0, junk_annotations=0".to_string()],
});
}
let version = program
.classes
.first()
.map_or(Version::JAVA_21, |c| c.version);
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 added = junk_names.len();
let mut woven = 0usize;
if weave && added > 0 {
woven = weave_first_reachable(program, real_classes, &junk_names)?;
}
let mut skipped = 0usize;
let mut notes = Vec::new();
if added > 0 {
notes.push(format!("добавлено junk-классов: {added}"));
}
if woven > 0 {
notes.push(format!("вплетено вызовов: {woven}"));
}
if added == 0 && woven == 0 {
notes.push("нечего шуметь: нет классов или методов с кодом".to_string());
}
if annotations > 0 {
skipped = annotations.saturating_mul(program.classes.len());
notes.push(format!(
"junk_annotations пропущен: сырые атрибуты не могут нести свежие индексы пула (пропущено: {skipped})"
));
}
for note in &notes {
ctx.note(note.clone());
}
Ok(PassReport {
pass: PassId::Noise,
transformed: added + woven,
skipped,
notes,
})
}
}
#[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);
}
}

View file

@ -0,0 +1,80 @@
//! Вплетание junk-классов в граф достижимости живых методов.
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Insn, InvokeKind, Program, compute_frames};
use noxis_core::{CoreError, PassId};
use super::JUNK_ENTRY;
/// Вплетает вызов первого junk-класса в первый метод с кодом.
///
/// Вызов встаёт перед терминалом (`Return`/`AReturn`/`AThrow`), чтобы не
/// породить недостижимый хвост без фреймов; фреймы пересчитываются.
pub(super) fn weave_first_reachable(
program: &mut Program,
real_classes: usize,
junk_names: &[String],
) -> Result<usize, CoreError> {
let missing = || CoreError::pass(PassId::Noise, "цель вплетания пропала");
let Some(owner) = junk_names.first() else {
return Err(missing());
};
let target = program.classes[..real_classes.min(program.classes.len())]
.iter()
.enumerate()
.find_map(|(ci, c)| {
c.methods
.iter()
.position(|m| m.code.is_some())
.map(|mi| (ci, mi))
});
let Some((ci, mi)) = target else { return Ok(0) };
let class_name = program
.classes
.get(ci)
.ok_or_else(missing)?
.this_class
.clone();
let call = Insn::Invoke {
kind: InvokeKind::Static,
owner: owner.clone(),
name: JUNK_ENTRY.0.to_string(),
descriptor: JUNK_ENTRY.1.to_string(),
itf: false,
};
{
let code = program
.classes
.get_mut(ci)
.and_then(|c| c.methods.get_mut(mi))
.and_then(|m| m.code.as_mut())
.ok_or_else(missing)?;
if matches!(
code.insns.last(),
Some(Insn::Return(_) | Insn::AReturn | Insn::AThrow)
) {
code.insns.insert(code.insns.len() - 1, call);
} else {
code.insns.push(call);
}
}
let snapshot = program
.classes
.get(ci)
.and_then(|c| c.methods.get(mi))
.cloned()
.ok_or_else(missing)?;
let hierarchy = ClassHierarchy::build(program);
let frames = compute_frames(&class_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(missing)?
.frames = frames;
Ok(1)
}

View file

@ -0,0 +1,237 @@
//! Назначение новых имён: карты, семейства виртуальных методов,
//! пул пакетов и финальное ширинирование доступа.
use std::collections::{BTreeMap, BTreeSet};
use noxis_analysis::{Analysis, ClassHierarchy};
use noxis_classfile::Program;
use noxis_core::{Config, Context, NameStyle, Rng};
use super::{ACC_PRIVATE, ACC_PROTECTED, ACC_PUBLIC, ACC_STATIC};
use crate::namegen::NameGenerator;
/// `(owner, name, descriptor)` — как в JVM-ссылке.
pub(super) type MemberKey = (String, String, String);
/// Карты переименований одного прогона: `declared` — где объявлено,
/// `alias` — наследники, которые наследуют это имя без своего объявления.
#[derive(Debug, Default)]
pub(super) struct RenameMaps {
pub(super) classes: BTreeMap<String, String>,
pub(super) methods: BTreeMap<MemberKey, String>,
pub(super) methods_alias: BTreeMap<MemberKey, String>,
pub(super) fields: BTreeMap<MemberKey, String>,
pub(super) fields_alias: BTreeMap<MemberKey, String>,
}
impl RenameMaps {
pub(super) fn method(&self, owner: &str, name: &str, desc: &str) -> Option<String> {
let key = (owner.to_string(), name.to_string(), desc.to_string());
self.methods
.get(&key)
.or_else(|| self.methods_alias.get(&key))
.cloned()
}
pub(super) fn field(&self, owner: &str, name: &str, desc: &str) -> Option<String> {
let key = (owner.to_string(), name.to_string(), desc.to_string());
self.fields
.get(&key)
.or_else(|| self.fields_alias.get(&key))
.cloned()
}
/// Псевдоними: наследник вызывает член, объявленный в предке.
pub(super) fn expand(&mut self, hierarchy: &ClassHierarchy, owners: &[String]) {
let methods: Vec<(MemberKey, String)> = self
.methods
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for ((owner, name, desc), new_name) in &methods {
for child in owners {
if child == owner || !hierarchy.is_subclass_of(child, owner) {
continue;
}
let key = (child.clone(), name.clone(), desc.clone());
if self.methods.contains_key(&key) {
continue;
}
self.methods_alias
.entry(key)
.or_insert_with(|| new_name.clone());
}
}
let fields: Vec<(MemberKey, String)> = self
.fields
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for ((owner, name, desc), new_name) in &fields {
for child in owners {
if child == owner || !hierarchy.is_subclass_of(child, owner) {
continue;
}
let key = (child.clone(), name.clone(), desc.clone());
if self.fields.contains_key(&key) {
continue;
}
self.fields_alias
.entry(key)
.or_insert_with(|| new_name.clone());
}
}
}
}
/// Переименование виртуальных методов «семейством»: одно имя на всю иерархию,
/// включая мостовые методы с ковариантным возвратом.
pub(super) fn rename_virtual_families(
program: &Program,
analysis: &Analysis,
maps: &mut RenameMaps,
cfg: &Config,
ctx: &mut Context,
) -> usize {
// Семейство = (имя, параметры): возврат игнорируем — так захватываем
// ковариантные возвраты и мостовые методы (`ACC_BRIDGE`) разом.
let mut families: BTreeMap<(String, String), Vec<MemberKey>> = BTreeMap::new();
let mut locked_families: BTreeSet<(String, String)> = BTreeSet::new();
for class in &program.classes {
let locked_class = analysis.is_locked(&class.this_class);
for method in &class.methods {
if method.name == "<init>" || method.name == "<clinit>" {
continue;
}
if method.access_flags & (ACC_STATIC | ACC_PRIVATE) != 0 {
continue;
}
let Some(params) = params_of(&method.descriptor) else {
continue;
};
let family = (method.name.clone(), params);
if locked_class || analysis.keep.keeps_member(&method.name) {
locked_families.insert(family.clone());
}
families.entry(family).or_default().push((
class.this_class.clone(),
method.name.clone(),
method.descriptor.clone(),
));
}
}
let mut renamed = 0usize;
let mut family_index = 0u64;
for (family_key, members) in families {
if locked_families.contains(&family_key) {
continue;
}
let mut names = NameGenerator::new(
ctx.rng_nth("rename/virtual-families", family_index),
cfg.rename.member_style,
);
family_index += 1;
for (_, name, _) in &members {
names.reserve(name.clone());
}
let new_name = names.next_name();
for key in members {
if maps.methods.contains_key(&key) {
continue;
}
maps.methods.insert(key, new_name.clone());
renamed += 1;
}
}
renamed
}
/// `(<params>)<ret>` → `(<params>)`, либо `None`, если дескриптор кривой.
fn params_of(descriptor: &str) -> Option<String> {
let close = descriptor.find(')')?;
if !descriptor.starts_with('(') {
return None;
}
Some(descriptor[..=close].to_string())
}
pub(super) fn parent_package(internal: &str) -> String {
match internal.rfind('/') {
Some(i) => internal[..i].to_string(),
None => String::new(),
}
}
pub(super) fn simple_name(internal: &str) -> String {
match internal.rfind('/') {
Some(i) => internal[i + 1..].to_string(),
None => internal.to_string(),
}
}
/// Пул случайных пакетов (2–3 сегмента); при выключенном разбросе — один пустой.
pub(super) fn generate_packages(cfg: &Config, rng: &mut Rng) -> Vec<String> {
if !cfg.rename.spread_packages {
return vec![String::new()];
}
let style = match cfg.rename.class_style {
NameStyle::Unicode => NameStyle::Unicode,
NameStyle::IlO0 => NameStyle::IlO0,
_ => NameStyle::MixedLatin,
};
let mut names = NameGenerator::new(rng.clone(), style).for_classes();
let pool = cfg.rename.package_pool.max(1);
(0..pool)
.map(|_| {
let segments = 2 + rng.below(2) as usize;
let parts: Vec<String> = (0..segments).map(|_| names.next_name()).collect();
parts.join("/")
})
.collect()
}
/// Переписать дескриптор/сигнатуру: `Lpkg/Class;` → новое имя класса.
pub(super) fn rewrite_descriptor(desc: &str, classes: &BTreeMap<String, String>) -> String {
if !desc.contains('L') {
return desc.to_string();
}
let mut out = String::with_capacity(desc.len());
let mut chars = desc.chars().peekable();
while let Some(c) = chars.next() {
if c != 'L' {
out.push(c);
continue;
}
let mut name = String::new();
for c in chars.by_ref() {
if c == ';' {
break;
}
name.push(c);
}
out.push('L');
out.push_str(classes.get(&name).map_or(name.as_str(), String::as_str));
out.push(';');
}
out
}
/// Расширить доступ до `public`, чтобы переезд в другой пакет не ломал вызовы.
pub(super) fn widen_all(program: &mut Program) {
for class in program.classes.iter_mut() {
class.access_flags = widen(class.access_flags);
for field in class.fields.iter_mut() {
field.access_flags = widen(field.access_flags);
}
for method in class.methods.iter_mut() {
method.access_flags = widen(method.access_flags);
}
}
}
fn widen(flags: u16) -> u16 {
(flags & !(ACC_PRIVATE | ACC_PROTECTED)) | ACC_PUBLIC
}

View file

@ -0,0 +1,232 @@
//! Пасс переименования: классы, поля, методы, локальные переменные, дескрипторы.
//!
//! Это самая дешёвая и самая отдающая техника (см. `info/evo-weaknesses.md`:
//! у EvoVis члены не переименовывались вообще, и класс читался по `javap -p`).
//!
//! Тонкости, которые здесь учитываются:
//! - вызов `Sub.staticMethod()` может указывать владельцем подкласс — нужны
//! псевдонимы (`alias`) для всей иерархии;
//! - мостовые методы (`ACC_BRIDGE`) и ковариантные возвраты живут в одном
//! «семействе» метода — переименовывать их нужно разом;
//! - `serialVersionUID`, JNI, рефлексия, сериализация — не трогаем.
/// Карты и стратегия назначения имён.
mod assign;
/// Переписывание тел методов и атрибутов.
mod rewrite;
/// Интеграционные тесты пасса.
#[cfg(test)]
mod tests;
use noxis_analysis::{Analysis, ClassHierarchy, RenameSafety, method_rename_safety};
use noxis_classfile::Program;
use noxis_core::{Context, CoreError, Pass, PassId, PassReport, field_key, method_key};
use self::assign::{
RenameMaps, generate_packages, parent_package, rename_virtual_families, simple_name, widen_all,
};
use self::rewrite::rewrite_class;
use crate::namegen::NameGenerator;
const ACC_PUBLIC: u16 = 0x0001;
const ACC_PRIVATE: u16 = 0x0002;
const ACC_PROTECTED: u16 = 0x0004;
const ACC_STATIC: u16 = 0x0008;
const ACC_FINAL: u16 = 0x0010;
const ACC_ENUM: u16 = 0x4000;
/// Пасс переименования.
#[derive(Debug, Default, Clone, Copy)]
pub struct RenamePass;
impl Pass for RenamePass {
fn id(&self) -> PassId {
PassId::Rename
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let analysis = Analysis::run(program, &ctx.config)
.map_err(|e| CoreError::pass(PassId::Rename, e.to_string()))?;
let hierarchy = ClassHierarchy::build(program);
let owners: Vec<String> = program
.classes
.iter()
.map(|c| c.this_class.clone())
.collect();
let cfg = ctx.config.clone();
let mut maps = RenameMaps::default();
let mut renamed_classes = 0usize;
// 1. Имена классов.
{
let packages = generate_packages(&cfg, &mut ctx.rng("rename/packages"));
let mut names =
NameGenerator::new(ctx.rng("rename/classes"), cfg.rename.class_style).for_classes();
for name in &owners {
if analysis.is_locked(name) {
names.reserve(simple_name(name));
}
}
for name in &owners {
if analysis.is_locked(name) {
continue;
}
let package = if cfg.rename.spread_packages {
let idx = ctx
.rng_nth("rename/pick", renamed_classes as u64)
.below(packages.len() as u64) as usize;
packages[idx].clone()
} else {
parent_package(name)
};
let simple = names.next_name();
let new_name = if package.is_empty() {
simple
} else {
format!("{package}/{simple}")
};
maps.classes.insert(name.clone(), new_name);
renamed_classes += 1;
}
}
// 2. Простые (не-виртуальные) члены: private/static/final.
let mut renamed_methods = 0usize;
let mut renamed_fields = 0usize;
for (idx, class) in program.classes.iter().enumerate() {
let owner = class.this_class.clone();
let locked = analysis.is_locked(&owner);
let mut method_names = NameGenerator::new(
ctx.rng_nth("rename/methods", idx as u64),
cfg.rename.member_style,
);
let mut field_names = NameGenerator::new(
ctx.rng_nth("rename/fields", idx as u64),
cfg.rename.member_style,
);
if !locked {
for method in &class.methods {
if method.name == "<init>" || method.name == "<clinit>" {
method_names.reserve(method.name.clone());
continue;
}
if analysis.keep.keeps_member(&method.name) {
method_names.reserve(method.name.clone());
continue;
}
let safety = method_rename_safety(
program,
&hierarchy,
&owner,
&method.name,
&method.descriptor,
false,
);
match safety {
RenameSafety::Safe => {
let new = method_names.next_name();
maps.methods.insert(
(
owner.clone(),
method.name.clone(),
method.descriptor.clone(),
),
new,
);
renamed_methods += 1;
}
// Виртуальный не-final: переименовывается только вместе
// со всем семейством (см. rename_virtual_families).
_ => method_names.reserve(method.name.clone()),
}
}
for field in &class.fields {
if analysis.keep.keeps_member(&field.name)
|| field.name == "serialVersionUID"
|| field.name == "serialPersistentFields"
{
field_names.reserve(field.name.clone());
continue;
}
let is_enum_const = field.access_flags & ACC_ENUM != 0;
if is_enum_const && !cfg.rename.rename_enum_constants {
field_names.reserve(field.name.clone());
continue;
}
let visible = field.access_flags & (ACC_PRIVATE | ACC_STATIC | ACC_FINAL) == 0;
if visible && !cfg.rename.rename_public_members {
field_names.reserve(field.name.clone());
continue;
}
let new = field_names.next_name();
maps.fields.insert(
(owner.clone(), field.name.clone(), field.descriptor.clone()),
new,
);
renamed_fields += 1;
}
} else {
for method in &class.methods {
method_names.reserve(method.name.clone());
}
for field in &class.fields {
field_names.reserve(field.name.clone());
}
}
}
// 3. Виртуальные семейства — только в агрессивном режиме.
if cfg.rename.rename_public_members {
renamed_methods += rename_virtual_families(program, &analysis, &mut maps, &cfg, ctx);
}
// 4. Псевдоними для наследников.
maps.expand(&hierarchy, &owners);
// 5. Переписать определения и все ссылки.
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.
if cfg.rename.widen_access {
widen_all(program);
}
// 7. Маппинги в отчёт (обратная деобфускация stack trace'ей).
for ((owner, name, desc), new_name) in maps.methods.iter() {
ctx.mapping
.methods
.insert(method_key(owner, name, desc), new_name.clone());
}
for ((owner, name, desc), new_name) in maps.fields.iter() {
ctx.mapping
.fields
.insert(field_key(owner, name, desc), new_name.clone());
}
for (old, new_name) in maps.classes.iter() {
ctx.mapping.classes.insert(old.clone(), new_name.clone());
}
let locked = analysis.locked_classes.len();
if locked > 0 {
ctx.note(format!("rename: {locked} классов в keep (не тронуты)"));
}
Ok(PassReport {
pass: PassId::Rename,
transformed: renamed_classes + renamed_methods + renamed_fields,
skipped: locked,
notes: vec![format!(
"классов: {renamed_classes}, методов: {renamed_methods}, полей: {renamed_fields}"
)],
})
}
}

View file

@ -0,0 +1,100 @@
//! Переписывание тела метода: инструкции, кадры, handler'ы, атрибуты кода.
use noxis_classfile::{Code, Const, Insn};
use noxis_core::{Config, Context};
use super::super::assign::{RenameMaps, rewrite_descriptor};
use super::rewrite_attributes;
use super::rewrite_frame;
pub(super) fn rewrite_code(
code: &mut Code,
maps: &RenameMaps,
cfg: &Config,
orig_owner: &str,
ctx: &mut Context,
local_counter: &mut u64,
) {
// Имена классов в кадрах тоже переименовываются, иначе верификатор
// увидит старые типы (`compute_frames` их не пересчитывает здесь).
for frame in code.frames.iter_mut() {
rewrite_frame(frame, maps);
}
for insn in code.insns.iter_mut() {
match insn {
Insn::Const(Const::Class(name)) => {
if let Some(mapped) = maps.classes.get(name.as_str()) {
*name = mapped.clone();
}
}
Insn::GetStatic {
owner,
name,
descriptor,
}
| Insn::PutStatic {
owner,
name,
descriptor,
}
| Insn::GetField {
owner,
name,
descriptor,
}
| Insn::PutField {
owner,
name,
descriptor,
} => {
if let Some(new_name) = maps.field(owner, name, descriptor) {
*name = new_name;
}
if let Some(mapped) = maps.classes.get(owner.as_str()) {
*owner = mapped.clone();
}
*descriptor = rewrite_descriptor(descriptor, &maps.classes);
}
Insn::Invoke {
owner,
name,
descriptor,
..
} => {
if let Some(new_name) = maps.method(owner, name, descriptor) {
*name = new_name;
}
if let Some(mapped) = maps.classes.get(owner.as_str()) {
*owner = mapped.clone();
}
*descriptor = rewrite_descriptor(descriptor, &maps.classes);
}
Insn::New(name)
| Insn::CheckCast(name)
| Insn::InstanceOf(name)
| Insn::ANewArray(name) => {
if let Some(mapped) = maps.classes.get(name.as_str()) {
*name = mapped.clone();
}
}
_ => {}
}
}
for handler in code.exception_table.iter_mut() {
if let Some(catch) = handler.catch_type.clone()
&& let Some(mapped) = maps.classes.get(catch.as_str())
{
handler.catch_type = Some(mapped.clone());
}
}
rewrite_attributes(
&mut code.attributes,
maps,
cfg,
orig_owner,
ctx,
local_counter,
);
}

View file

@ -0,0 +1,173 @@
//! Переписывание одного класса: имена, дескрипторы, код, атрибуты.
mod code;
use noxis_classfile::{Attribute, ClassFile, StackMapFrame, VerificationType};
use noxis_core::{Config, Context};
use super::assign::{RenameMaps, rewrite_descriptor};
use crate::namegen::NameGenerator;
use code::rewrite_code;
pub(super) fn rewrite_class(
class: &mut ClassFile,
orig_owner: &str,
maps: &RenameMaps,
cfg: &Config,
local_counter: &mut u64,
ctx: &mut Context,
) {
if let Some(new_name) = maps.classes.get(orig_owner) {
class.this_class = new_name.clone();
}
if let Some(mapped) = class
.super_class
.as_ref()
.and_then(|sup| maps.classes.get(sup))
.cloned()
{
class.super_class = Some(mapped);
}
for iface in &mut class.interfaces {
if let Some(mapped) = maps.classes.get(iface.as_str()) {
*iface = mapped.clone();
}
}
for field in &mut class.fields {
let key = (
orig_owner.to_string(),
field.name.clone(),
field.descriptor.clone(),
);
if let Some(new_name) = maps.fields.get(&key) {
field.name = new_name.clone();
}
field.descriptor = rewrite_descriptor(&field.descriptor, &maps.classes);
rewrite_attributes(
&mut field.attributes,
maps,
cfg,
orig_owner,
ctx,
local_counter,
);
}
for method in &mut class.methods {
let key = (
orig_owner.to_string(),
method.name.clone(),
method.descriptor.clone(),
);
if let Some(new_name) = maps.methods.get(&key) {
method.name = new_name.clone();
}
method.descriptor = rewrite_descriptor(&method.descriptor, &maps.classes);
if let Some(code) = &mut method.code {
rewrite_code(code, maps, cfg, orig_owner, ctx, local_counter);
}
rewrite_attributes(
&mut method.attributes,
maps,
cfg,
orig_owner,
ctx,
local_counter,
);
}
rewrite_attributes(
&mut class.attributes,
maps,
cfg,
orig_owner,
ctx,
local_counter,
);
}
fn rewrite_attributes(
attributes: &mut [Attribute],
maps: &RenameMaps,
cfg: &Config,
orig_owner: &str,
ctx: &mut Context,
local_counter: &mut u64,
) {
for attr in attributes.iter_mut() {
match attr {
Attribute::Signature(sig) => {
*sig = rewrite_descriptor(sig, &maps.classes);
}
Attribute::Exceptions(list) => {
for name in list {
if let Some(mapped) = maps.classes.get(name.as_str()) {
*name = mapped.clone();
}
}
}
Attribute::InnerClasses(inner) => {
for entry in inner.iter_mut() {
if let Some(mapped) = maps.classes.get(entry.inner.as_str()) {
entry.inner = mapped.clone();
}
if let Some(mapped) = entry
.outer
.as_ref()
.and_then(|outer| maps.classes.get(outer))
.cloned()
{
entry.outer = Some(mapped);
}
}
}
Attribute::LocalVariableTable(vars) => {
for var in vars.iter_mut() {
var.descriptor = rewrite_descriptor(&var.descriptor, &maps.classes);
if cfg.rename.rename_locals {
*local_counter += 1;
let mut names = NameGenerator::new(
ctx.rng_nth("rename/locals", *local_counter),
cfg.rename.local_style,
);
let new = names.next_name();
ctx.mapping.locals.insert(
format!("{orig_owner}/{}@{}", var.name, var.index),
new.clone(),
);
var.name = new;
}
}
}
_ => {}
}
}
}
/// Имена классов внутри одного кадра (`Object(..)` в locals/stack).
fn rewrite_frame(frame: &mut StackMapFrame, maps: &RenameMaps) {
match frame {
StackMapFrame::Same { .. } | StackMapFrame::Chop { .. } => {}
StackMapFrame::SameLocals { local, .. } => rewrite_type(local, maps),
StackMapFrame::Append { locals, .. } => rewrite_types(locals, maps),
StackMapFrame::Full { locals, stack, .. } => {
rewrite_types(locals, maps);
rewrite_types(stack, maps);
}
}
}
fn rewrite_types(types: &mut [VerificationType], maps: &RenameMaps) {
for item in types.iter_mut() {
rewrite_type(item, maps);
}
}
fn rewrite_type(item: &mut VerificationType, maps: &RenameMaps) {
if let VerificationType::Object(name) = item
&& let Some(mapped) = maps.classes.get(name.as_str())
{
*name = mapped.clone();
}
}

View file

@ -0,0 +1,193 @@
//! Тесты пасса переименования: keep, ссылки, семейства, алиасы.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::collections::BTreeMap;
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{
ClassBuilder, ExceptionHandler, Insn, InvokeKind, Program, Resource, empty_code,
};
use noxis_core::{Config, Context, Pass, RenameConfig};
use super::RenamePass;
use super::assign::{RenameMaps, rewrite_descriptor};
fn sample() -> Program {
let main = ClassBuilder::new("app/Main", None)
.method_with_code(
0x0009,
"main",
"([Ljava/lang/String;)V",
noxis_classfile::Code {
max_stack: 4,
max_locals: 1,
insns: vec![
Insn::Invoke {
kind: InvokeKind::Static,
owner: "app/Helper".into(),
name: "make".into(),
descriptor: "()Lapp/Helper;".into(),
itf: false,
},
Insn::Pop,
Insn::Return(None),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
},
)
.build();
let helper = ClassBuilder::new("app/Helper", None)
.method_with_code(
0x0009 | 0x0001,
"make",
"()Lapp/Helper;",
noxis_classfile::Code {
max_stack: 1,
max_locals: 0,
insns: vec![Insn::New("app/Helper".into()), Insn::Return(None)],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
},
)
.method_with_code(0x0002, "secret", "()V", empty_code(0))
.build();
Program {
classes: vec![main, helper],
resources: vec![Resource {
path: "data.txt".into(),
data: b"x".to_vec(),
}],
}
}
fn run_rename(program: &mut Program) -> Context {
let config = Config {
seed: Some(9),
keep: vec!["app/Main".to_string()],
rename: RenameConfig {
package_pool: 4,
..RenameConfig::default()
},
..Config::default()
};
let mut ctx = Context::new(config);
let report = RenamePass.apply(&mut ctx, program).expect("rename");
assert!(report.transformed > 0, "ничего не переименовано");
ctx
}
#[test]
fn renames_unlocked_class_and_references() {
let mut program = sample();
let ctx = run_rename(&mut program);
assert_eq!(
program.class("app/Main").map(|c| c.this_class.as_str()),
Some("app/Main"),
"keep-класс обязан остаться"
);
let helper = program
.classes
.iter()
.find(|c| c.this_class != "app/Main")
.cloned()
.expect("helper");
assert_ne!(helper.this_class, "app/Helper");
let main = program.class("app/Main").expect("main");
let code = main.methods[0].code.as_ref().expect("code");
let (new_owner, new_desc) = match &code.insns[0] {
Insn::Invoke {
owner, descriptor, ..
} => (owner.clone(), descriptor.clone()),
other => panic!("ожидался Invoke, получено {other:?}"),
};
assert_eq!(new_owner, helper.this_class);
assert!(new_desc.contains(&helper.this_class), "desc: {new_desc}");
let renamed_secret = helper
.methods
.iter()
.find(|m| m.name != "make")
.map(|m| m.name.clone())
.expect("secret переименован");
assert_ne!(renamed_secret, "secret");
assert!(!ctx.mapping.methods.is_empty());
}
#[test]
fn descriptor_rewrite_handles_arrays() {
let mut map = BTreeMap::new();
map.insert("a/b/C".to_string(), "p/Q".to_string());
map.insert("a/b/D".to_string(), "p/R".to_string());
assert_eq!(
rewrite_descriptor("(La/b/C;[La/b/D;)La/b/C;", &map),
"(Lp/Q;[Lp/R;)Lp/Q;"
);
assert_eq!(rewrite_descriptor("([[I)V", &map), "([[I)V");
}
#[test]
fn exception_handler_type_is_rewritten() {
let mut program = sample();
program
.classes
.push(ClassBuilder::new("app/Boom", None).build());
program.classes[1].methods[0]
.code
.as_mut()
.expect("code")
.exception_table
.push(ExceptionHandler {
start: 0,
end: 1,
handler: 2,
catch_type: Some("app/Boom".into()),
});
let ctx = run_rename(&mut program);
let new_boom = ctx.mapping.class("app/Boom").expect("boom renamed");
let helper = program
.classes
.iter()
.find(|c| c.this_class != "app/Main")
.expect("helper");
let handler = &helper.methods[0]
.code
.as_ref()
.expect("code")
.exception_table[0];
assert_eq!(handler.catch_type.as_deref(), Some(new_boom));
}
#[test]
fn subclass_gets_alias_for_inherited_member() {
let base = ClassBuilder::new("p/Base", None)
.method_with_code(0x0009, "shared", "()V", empty_code(0))
.build();
let sub = ClassBuilder::new("p/Sub", Some("p/Base")).build();
let program = Program {
classes: vec![base, sub],
resources: Vec::new(),
};
let hierarchy = ClassHierarchy::build(&program);
let mut maps = RenameMaps::default();
maps.methods.insert(
("p/Base".into(), "shared".into(), "()V".into()),
"zz".into(),
);
let owners = vec!["p/Base".to_string(), "p/Sub".to_string()];
maps.expand(&hierarchy, &owners);
// Вызов через подкласс обязан находить новое имя.
assert_eq!(
maps.method("p/Base", "shared", "()V").as_deref(),
Some("zz")
);
assert_eq!(maps.method("p/Sub", "shared", "()V").as_deref(), Some("zz"));
// Чужого члена не выдумываем.
assert_eq!(maps.method("p/Sub", "other", "()V"), None);
}

View file

@ -0,0 +1,170 @@
//! Пасс ресурсов: патч текстовых файлов архива по маппингу классов.
//!
//! Без этого обфускация Fabric-мода просто не стартует: `fabric.mod.json`
//! хранит main-class точкой, `*.mixins.json` — пути внутренними именами,
//! refmap и access widener — тоже. Материал: `info/evo-weaknesses.md` §3/§8
//! (у разобранного образца ресурсы вообще не трогали).
//!
//! Замена **границная**, а не подстрочная: `org/example/Foo` не должен
//! совпасть внутри `myorg/example/FooBar`. Правило то же, что у проверенного
//! референса: сначала дескрипторный вид `Lpkg/Class;`, затем внутренний вид
//! с `/`, затем бинарный с `.`, длинные имена первыми.
/// Интеграционные тесты пасса.
#[cfg(test)]
mod tests;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use noxis_classfile::Program;
use noxis_core::{Context, CoreError, Pass, PassId, PassReport};
/// Расширения текстовых ресурсов, которые имеют смысл переписывать.
const TEXT_EXTS: [&str; 9] = [
".json",
".mcmeta",
".properties",
".txt",
".xml",
".yml",
".yaml",
".cfg",
".accesswidener",
];
/// Service loader'ы: файла без расширения, но каждая строка — имя класса.
const SERVICES_PREFIX: &str = "META-INF/services/";
/// Пасс патча ресурсов.
#[derive(Debug, Default, Clone, Copy)]
pub struct ResourcesPass;
impl Pass for ResourcesPass {
fn id(&self) -> PassId {
PassId::Resources
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let map = ctx.mapping.classes.clone();
if map.is_empty() {
return Ok(PassReport {
pass: PassId::Resources,
transformed: 0,
skipped: program.resources.len(),
notes: vec!["маппинг классов пуст — ресурсы не тронуты".into()],
});
}
let mut patched = 0usize;
let mut skipped = 0usize;
for resource in &mut program.resources {
if !is_text(&resource.path) {
skipped += 1;
continue;
}
let Ok(text) = std::str::from_utf8(&resource.data) else {
skipped += 1;
continue;
};
let updated = remap_text(text, &map);
if updated == text {
skipped += 1;
continue;
}
resource.data = updated.into_bytes();
patched += 1;
}
let mut notes = Vec::new();
if patched > 0 {
notes.push(format!("патч ресурсов: {patched}, пропущено: {skipped}"));
}
Ok(PassReport {
pass: PassId::Resources,
transformed: patched,
skipped,
notes,
})
}
}
/// Текстовый ли это ресурс.
///
/// Бинарии (картинки, `.class` в `versions/`, нативные библиотеки) не
/// трогаем: их переписывание ломает содержимое молча.
pub fn is_text(path: &str) -> bool {
if path.starts_with(SERVICES_PREFIX) {
return !path.ends_with('/');
}
let lower = path.to_ascii_lowercase();
TEXT_EXTS.iter().any(|ext| lower.ends_with(ext))
}
/// Переписывает все ссылки на классы в тексте по таблице `old → new`.
pub fn remap_text(text: &str, map: &BTreeMap<String, String>) -> String {
if map.is_empty() {
return text.to_string();
}
// Длинные имена первыми: иначе `org/example/Foo` совпадёт внутри
// `org/example/FooBar` ещё до того, как до него дойдёт очередь.
let mut pairs: Vec<(&String, &String)> = map.iter().collect();
pairs.sort_by_key(|(from, _)| Reverse(from.len()));
let mut out = text.to_string();
for (from, to) in pairs {
out = out.replace(&format!("L{from};"), &format!("L{to};"));
out = replace_delimited(&out, from, to, false);
out = replace_delimited(&out, &from.replace('/', "."), &to.replace('/', "."), true);
}
out
}
/// Заменяет все вхождения `old`, отделённые от surrounding текста границами
/// имени. `dotted` выбирает набор запрещённых соседей: `/` или `.`.
fn replace_delimited(text: &str, old: &str, new: &str, dotted: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(old) {
let before = &rest[..at];
let after = &rest[at + old.len()..];
if is_boundary(before.chars().next_back(), dotted)
&& is_boundary(after.chars().next(), dotted)
{
out.push_str(before);
out.push_str(new);
rest = after;
continue;
}
// Совпадение частичное — пропускаем первый символ и ищем дальше.
let mut chars = rest[at..].chars();
out.push_str(before);
match chars.next() {
Some(first) => {
out.push(first);
rest = chars.as_str();
}
None => break,
}
}
out.push_str(rest);
out
}
/// `true`, если символ слева/справа допускает начало/конец имени класса.
fn is_boundary(neighbour: Option<char>, dotted: bool) -> bool {
let Some(ch) = neighbour else {
return true;
};
if ch.is_alphanumeric() || ch == '_' || ch == '$' {
return false;
}
if dotted { ch != '.' } else { ch != '/' }
}

View file

@ -0,0 +1,160 @@
//! Тесты пасса ресурсов: границы совпадений и патч содержимого.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{Program, Resource};
use noxis_core::{Config, Context, Pass};
use super::{ResourcesPass, is_text, remap_text};
fn mapping() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
map.insert("org/example/Foo".to_string(), "a/b/C".to_string());
map.insert("org/example/FooBar".to_string(), "a/b/D".to_string());
map
}
#[test]
fn descriptor_form_is_replaced() {
let text = "Lorg/example/Foo; [Lorg/example/FooBar;";
assert_eq!(
remap_text(text, &mapping()),
"La/b/C; [La/b/D;",
"дескрипторный вид обязан заменяться без проверки границ"
);
}
#[test]
fn internal_form_respects_boundaries() {
let map = mapping();
assert_eq!(remap_text("owner=Lorg/example/Foo;", &map), "owner=La/b/C;");
assert_eq!(
remap_text("myorg/example/Foo", &map),
"myorg/example/Foo",
"совпадение внутри более длинного пути не трогаем"
);
assert_eq!(
remap_text("org/example/FooBaz", &map),
"org/example/FooBaz",
"суффикс не должен совпасть с префиксом"
);
}
#[test]
fn dotted_form_respects_boundaries() {
let map = mapping();
assert_eq!(
remap_text("\"org.example.Foo\" => \"x\"", &map),
"\"a.b.C\" => \"x\""
);
assert_eq!(
remap_text("myorg.example.Foo", &map),
"myorg.example.Foo",
"точка внутри имени не является границей"
);
}
#[test]
fn longest_name_wins() {
assert_eq!(
remap_text("Lorg/example/FooBar;", &mapping()),
"La/b/D;",
"длинное имя обрабатывается раньше короткого"
);
}
#[test]
fn empty_map_is_a_noop() {
let text = "Lorg/example/Foo; и org.example.Foo";
assert_eq!(remap_text(text, &Default::default()), text);
}
#[test]
fn unicode_text_survives_unrelated_substitution() {
let text = "комментарий: Лорем_ипсум — org/example/Bar";
assert_eq!(remap_text(text, &mapping()), text);
}
#[test]
fn text_detection_covers_known_formats() {
assert!(is_text("fabric.mod.json"));
assert!(is_text("demo.mixins.json"));
assert!(is_text("META-INF/services/org.example.Loader"));
assert!(is_text("mod.accesswidener"));
assert!(is_text("META-INF/versions/1.20/mod.json"));
assert!(!is_text("assets/tex.png"));
assert!(!is_text("lib/native.so"));
assert!(!is_text("META-INF/services/"));
}
fn context_with_mapping() -> Context {
let mut ctx = Context::new(Config {
seed: Some(3),
..Config::default()
});
ctx.mapping
.classes
.insert("org/example/Foo".into(), "a/b/C".into());
ctx
}
#[test]
fn pass_patches_text_and_counts_the_rest() {
let mut program = Program {
classes: Vec::new(),
resources: vec![
Resource {
path: "fabric.mod.json".into(),
data: br#"{"entrypoints":{"main":["org.example.Foo"]}}"#.to_vec(),
},
Resource {
path: "META-INF/services/org.example.Foo".into(),
data: b"# keep\n".to_vec(),
},
Resource {
path: "assets/icon.png".into(),
data: vec![0x89, 0x50, 0x4E, 0x47],
},
],
};
let mut ctx = context_with_mapping();
let report = ResourcesPass
.apply(&mut ctx, &mut program)
.expect("resources");
assert_eq!(report.transformed, 1, "патчим только JSON");
assert_eq!(report.skipped, 2);
let json = std::str::from_utf8(&program.resources[0].data).unwrap();
assert!(
json.contains("a.b.C"),
"binary-имя в JSON не заменено: {json}"
);
assert!(!json.contains("org.example.Foo"), "старое имя осталось");
assert_eq!(program.resources[2].data, vec![0x89, 0x50, 0x4E, 0x47]);
}
#[test]
fn pass_is_a_noop_without_a_mapping() {
let mut program = Program {
classes: Vec::new(),
resources: vec![Resource {
path: "fabric.mod.json".into(),
data: br#"{"x":"org.example.Foo"}"#.to_vec(),
}],
};
let mut ctx = Context::new(Config::default());
let report = ResourcesPass
.apply(&mut ctx, &mut program)
.expect("resources");
assert_eq!(report.transformed, 0);
assert_eq!(report.skipped, 1);
assert!(
!report.notes.is_empty(),
"должна быть заметка о пустом маппинге"
);
}

View file

@ -0,0 +1,60 @@
//! Шифрование строк: чанки, salt под схему, последовательность вызова.
//!
//! Один `Const::String` превращается в `ldc cipher; ldc salt; invokestatic
//! d` на чанк; чанки склеиваются `String.concat` (без `StringBuilder` —
//! меньше инструкций и нет вызовов конструктора).
use noxis_classfile::{Const, Insn, InvokeKind};
use noxis_runtime::{DECODER_DESC, DECODER_METHOD};
/// Разбить строку на чанки по границе символов (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) {
return vec![text.to_string()];
}
let count = chars.len().div_ceil(split_above);
let count = count.clamp(1, 3);
let per = chars.len().div_ceil(count);
chars
.chunks(per)
.map(|chunk| chunk.iter().collect())
.collect()
}
/// Salt, попадающий в нужную схему: `salt % total == index`.
///
/// Salt всегда неотрицательный (Java `%` сохраняет знак — отрицательный salt
/// выбрал бы не ту ветку декодера). `index` обязан быть меньше `total`.
pub(super) fn salt_for_scheme(raw: u64, index: usize, total: usize) -> i32 {
let total = total.max(1) as u64;
let index = (index as u64) % total;
let rounds = (i32::MAX as u64 - index) / total + 1;
(index + (raw >> 33) % rounds * total) as i32
}
/// Вызовы декодера на чанки + склейка (`[(cipher, salt)]`).
pub(super) fn decode_sequence(owner: &str, parts: &[(String, i32)]) -> Vec<Insn> {
let mut out = Vec::with_capacity(parts.len() * 4);
for (position, (cipher, salt)) in parts.iter().enumerate() {
out.push(Insn::Const(Const::String(cipher.clone())));
out.push(Insn::Const(Const::Int(*salt)));
out.push(Insn::Invoke {
kind: InvokeKind::Static,
owner: owner.to_string(),
name: DECODER_METHOD.to_string(),
descriptor: DECODER_DESC.to_string(),
itf: false,
});
if position > 0 {
out.push(Insn::Invoke {
kind: InvokeKind::Virtual,
owner: "java/lang/String".to_string(),
name: "concat".to_string(),
descriptor: "(Ljava/lang/String;)Ljava/lang/String;".to_string(),
itf: false,
});
}
}
out
}

View file

@ -0,0 +1,193 @@
//! Пасс шифрования строк: `Const::String` → вызовы рантайм-декодера.
//!
//! Схема строки выбирается ротацией (`salt % n`), salt детерминирован
//! (KDF или RNG-поток — см. `derive_key`). Декодер генерируется один на
//! программу и только если была зашифрована хотя бы одна строка.
mod crypt;
#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Const, Insn, Program, compute_frames};
use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed};
use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
use crate::namegen::NameGenerator;
use crypt::{decode_sequence, salt_for_scheme, split_chunks};
/// Пасс шифрования строк.
#[derive(Debug, Default, Clone, Copy)]
pub struct StringsPass;
impl Pass for StringsPass {
fn id(&self) -> PassId {
PassId::Strings
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let cfg = ctx.config.strings.clone();
let mut schemes = Vec::with_capacity(cfg.schemes.len());
for name in &cfg.schemes {
schemes.push(Scheme::parse(name).map_err(|e| pass_error(e.to_string()))?);
}
if schemes.is_empty() {
return Err(pass_error("нет схем шифрования"));
}
// Фаза 1: собрать все шифруемые строки (позиция + текст).
let mut targets: Vec<(usize, usize, usize, String)> = Vec::new();
let mut skipped = 0usize;
for (ci, class) in program.classes.iter().enumerate() {
for (mi, method) in class.methods.iter().enumerate() {
let Some(code) = method.code.as_ref() else {
continue;
};
for (ii, insn) in code.insns.iter().enumerate() {
if let Insn::Const(Const::String(text)) = insn {
if text.chars().count() >= cfg.min_len && !is_skipped(text, &cfg.skip) {
targets.push((ci, mi, ii, text.clone()));
} else {
skipped += 1;
}
}
}
}
}
if targets.is_empty() {
return Ok(report(
0,
skipped,
vec!["нет строк для шифрования".to_string()],
));
}
// Фаза 2: зашифровать и подставить последовательности вызовов.
let master = ctx.master_seed;
let mut rng = ctx.rng("strings");
let owner = decoder_name(ctx, program);
let mut chunk_counter = 0usize;
let mut salt_counter = 0u64;
let mut replacements: BTreeMap<(usize, usize, usize), Vec<Insn>> = BTreeMap::new();
for (ci, mi, ii, text) in &targets {
let mut parts = Vec::new();
for chunk in split_chunks(text, cfg.split_above) {
let scheme = chunk_counter % schemes.len();
let raw = next_salt(&mut rng, master, &mut salt_counter, cfg.derive_key);
let salt = salt_for_scheme(raw, scheme, schemes.len());
let blob = encrypt(schemes[scheme], master, salt, &chunk);
parts.push((blob.cipher, salt));
chunk_counter += 1;
}
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
}
let mut mutated: Vec<(usize, usize)> = Vec::new();
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;
};
let mut fresh = Vec::with_capacity(code.insns.len());
let mut touched = false;
for (ii, insn) in code.insns.drain(..).enumerate() {
if let Some(sequence) = replacements.remove(&(ci, mi, ii)) {
fresh.extend(sequence);
touched = true;
} else {
fresh.push(insn);
}
}
if touched {
code.max_stack = code.max_stack.saturating_add(2);
mutated.push((ci, mi));
}
code.insns = fresh;
}
}
// Фаза 3: класс-декодер один на программу.
let scheme_names: Vec<&str> = cfg.schemes.iter().map(String::as_str).collect();
let decoder =
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
program.classes.push(decoder);
ctx.note(format!("декодер строк: {owner}"));
// Фаза 4: фреймы затронутых методов.
let hierarchy = ClassHierarchy::build(program);
for (ci, mi) in &mutated {
let (name, snapshot) = {
let class = program
.classes
.get(*ci)
.ok_or_else(|| pass_error("класс пропал"))?;
(
class.this_class.clone(),
class
.methods
.get(*mi)
.cloned()
.ok_or_else(|| pass_error("метод пропал"))?,
)
};
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("метод пропал"))?
.frames = frames;
}
Ok(report(
targets.len(),
skipped,
vec![format!(
"зашифровано строк: {}, декодер: {owner}",
targets.len()
)],
))
}
}
fn report(transformed: usize, skipped: usize, notes: Vec<String>) -> PassReport {
PassReport {
pass: PassId::Strings,
transformed,
skipped,
notes,
}
}
fn pass_error(msg: impl Into<String>) -> CoreError {
CoreError::pass(PassId::Strings, msg)
}
/// Строка подпадает под `skip`-паттерны конфига.
fn is_skipped(text: &str, skip: &[String]) -> bool {
skip.iter().any(|pattern| text.contains(pattern))
}
/// Salt чанка: KDF от позиции или RNG-поток (см. `derive_key`).
fn next_salt(rng: &mut noxis_core::Rng, master: u64, counter: &mut u64, derive: bool) -> u64 {
let value = if derive {
derive_seed(master, "strings/salt", *counter)
} else {
rng.next_u64()
};
*counter += 1;
value
}
/// Детерминированное имя декодера без коллизий с классами программы.
fn decoder_name(ctx: &mut Context, program: &Program) -> String {
let mut names = NameGenerator::new(ctx.rng("strings/decoder"), NameStyle::ShortLatin);
for class in &program.classes {
names.reserve(class.this_class.clone());
}
loop {
let owner = decoder_owner(&format!("{}/{}", names.next_name(), names.next_name()));
if program.class(&owner).is_none() {
return owner;
}
}
}

View file

@ -0,0 +1,129 @@
//! Тесты пасса строк: подстановка, чанки, детерминизм шифротекста.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Const, Insn, Program, Version};
use noxis_core::{Config, Context};
use noxis_runtime::{Scheme, encrypt};
use super::StringsPass;
use super::crypt::{decode_sequence, salt_for_scheme, split_chunks};
use noxis_core::Pass;
fn sample() -> Program {
let code = Code {
max_stack: 1,
max_locals: 0,
insns: vec![
Insn::Const(Const::String("hello world".to_string())),
Insn::Const(Const::String("x".to_string())),
Insn::Const(Const::String("y".repeat(100))),
Insn::Const(Const::Int(5)),
Insn::Return(None),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
Program {
classes: vec![
ClassBuilder::new("demo/App", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0009, "run", "()V", code)
.build(),
],
resources: Vec::new(),
}
}
/// Короткие строки не режутся, длинные — на границе символов.
#[test]
fn chunks_split_by_chars() {
assert_eq!(split_chunks("abc", 64), vec!["abc".to_string()]);
let chunks = split_chunks(&"y".repeat(100), 64);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks.concat().len(), 100);
}
/// Salt всегда неотрицателен и попадает в схему.
#[test]
fn salt_hits_scheme() {
for raw in [0u64, 1, 7, u64::MAX, 123456789] {
for (index, total) in [(0, 1), (0, 2), (1, 2), (2, 3)] {
let salt = salt_for_scheme(raw, index, total);
assert!(salt >= 0, "отрицательный salt");
assert_eq!(salt as usize % total, index);
}
}
}
/// Пасс: строки заменены вызовами, шифротекст воспроизводим, декодер есть.
#[test]
fn strings_replaced_and_deterministic() {
let mut program = sample();
let config = Config {
seed: Some(7),
..Config::default()
};
let mut ctx = Context::new(config.clone());
let report = StringsPass.apply(&mut ctx, &mut program).expect("strings");
assert_eq!(report.transformed, 2);
assert_eq!(report.skipped, 1);
// Декодер добавлен последним.
assert_eq!(program.classes.len(), 2);
let decoder = program.classes.last().expect("декодер");
assert!(decoder.methods.iter().any(|m| m.name == "d"));
// Исходных строк не осталось (шифры — длинные Const::String — на месте);
// короткая на месте.
let code = program.classes[0].methods[0].code.as_ref().expect("код");
let kept: Vec<&String> = code
.insns
.iter()
.filter_map(|insn| match insn {
Insn::Const(Const::String(s)) => Some(s),
_ => None,
})
.collect();
assert!(!kept.contains(&&"hello world".to_string()));
assert!(!kept.iter().any(|s| *s == &"y".repeat(100)));
assert!(kept.contains(&&"x".to_string()));
// Каждый вызов сверяется с прямым шифрованием (тот же сид → те же блобы).
let schemes = [Scheme::XorSplitmix, Scheme::XteaCtr];
let mut triples = Vec::new();
let mut window: Vec<&Insn> = Vec::new();
for insn in &code.insns {
window.push(insn);
if window.len() > 3 {
window.remove(0);
}
if let [
Insn::Const(Const::String(cipher)),
Insn::Const(Const::Int(salt)),
Insn::Invoke { name, .. },
] = &window[..]
&& name == "d"
{
triples.push((cipher.clone(), *salt));
}
}
// "hello world" (1 чанк) + "y"*100 (2 чанка).
assert_eq!(triples.len(), 3);
let originals = ["hello world".to_string(), "y".repeat(50), "y".repeat(50)];
for ((cipher, salt), original) in triples.iter().zip(originals) {
let scheme = schemes[*salt as usize % schemes.len()];
let blob = encrypt(scheme, 7, *salt, &original);
assert_eq!(cipher, &blob.cipher);
}
// Склейка чанков через `concat` — ровно одна; конструктор жив.
assert_eq!(
code.insns
.iter()
.filter(|insn| matches!(insn, Insn::Invoke { name, .. } if name == "concat"))
.count(),
1
);
assert!(!decode_sequence("o", &[("c".to_string(), 1)]).is_empty());
}