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:
commit
a641e69455
140 changed files with 17197 additions and 0 deletions
21
crates/noxis-passes/Cargo.toml
Normal file
21
crates/noxis-passes/Cargo.toml
Normal 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 }
|
||||
3
crates/noxis-passes/build.rs
Normal file
3
crates/noxis-passes/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
noxis_structure_check::check();
|
||||
}
|
||||
159
crates/noxis-passes/src/constants/forms.rs
Normal file
159
crates/noxis-passes/src/constants/forms.rs
Normal 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),
|
||||
]
|
||||
}
|
||||
215
crates/noxis-passes/src/constants/mod.rs
Normal file
215
crates/noxis-passes/src/constants/mod.rs
Normal 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)
|
||||
}
|
||||
111
crates/noxis-passes/src/constants/tests.rs
Normal file
111
crates/noxis-passes/src/constants/tests.rs
Normal 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);
|
||||
}
|
||||
246
crates/noxis-passes/src/debug.rs
Normal file
246
crates/noxis-passes/src/debug.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
95
crates/noxis-passes/src/lib.rs
Normal file
95
crates/noxis-passes/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
119
crates/noxis-passes/src/namegen/alphabet.rs
Normal file
119
crates/noxis-passes/src/namegen/alphabet.rs
Normal 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",
|
||||
];
|
||||
215
crates/noxis-passes/src/namegen/mod.rs
Normal file
215
crates/noxis-passes/src/namegen/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
189
crates/noxis-passes/src/noise/mod.rs
Normal file
189
crates/noxis-passes/src/noise/mod.rs
Normal 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 ¬es {
|
||||
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);
|
||||
}
|
||||
}
|
||||
80
crates/noxis-passes/src/noise/weave.rs
Normal file
80
crates/noxis-passes/src/noise/weave.rs
Normal 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)
|
||||
}
|
||||
237
crates/noxis-passes/src/rename/assign.rs
Normal file
237
crates/noxis-passes/src/rename/assign.rs
Normal 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
|
||||
}
|
||||
232
crates/noxis-passes/src/rename/mod.rs
Normal file
232
crates/noxis-passes/src/rename/mod.rs
Normal 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}"
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
100
crates/noxis-passes/src/rename/rewrite/code.rs
Normal file
100
crates/noxis-passes/src/rename/rewrite/code.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
173
crates/noxis-passes/src/rename/rewrite/mod.rs
Normal file
173
crates/noxis-passes/src/rename/rewrite/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
193
crates/noxis-passes/src/rename/tests.rs
Normal file
193
crates/noxis-passes/src/rename/tests.rs
Normal 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);
|
||||
}
|
||||
170
crates/noxis-passes/src/resources/mod.rs
Normal file
170
crates/noxis-passes/src/resources/mod.rs
Normal 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 != '/' }
|
||||
}
|
||||
160
crates/noxis-passes/src/resources/tests.rs
Normal file
160
crates/noxis-passes/src/resources/tests.rs
Normal 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(),
|
||||
"должна быть заметка о пустом маппинге"
|
||||
);
|
||||
}
|
||||
60
crates/noxis-passes/src/strings/crypt.rs
Normal file
60
crates/noxis-passes/src/strings/crypt.rs
Normal 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
|
||||
}
|
||||
193
crates/noxis-passes/src/strings/mod.rs
Normal file
193
crates/noxis-passes/src/strings/mod.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
crates/noxis-passes/src/strings/tests.rs
Normal file
129
crates/noxis-passes/src/strings/tests.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue