frames,jar,passes: verifier-driven hardening from real jars

Proven by paranoid runs over lorep (17 classes, fully green),
LoVisual (4081 classes) and oc2r (1278 classes) with per-class
java -Xverify:all, each fix first reproduced as a red test:

- merge: Top absorbs instead of Top-wins (javac declares Top for
  slots unwritten on some inflow; precise types reject those edges).
  Regression: loop_head_keeps_top_for_loop_local.
- emit: collapse [Long|Double, Top] pairs on operand stacks
  (javac writes one entry; the pair is rejected). Regression:
  wide_stack_join_emits_single_entries.
- xfer ArrayLoad: push the wide pair, fixing lcmp underflow.
  Regression: array_load_wide_pushes_pair.
- xfer <init>: convert only the receiver's own allocation site
  instead of every Uninitialized (nested construction clobbered
  the outer object). Regression: nested_construction_keeps_outer.
- xfer ANewArray: array operands gain one dimension ([B] -> [[B)
  instead of an illegal L-wrap ([L[B;). Covered by the same test.
- jar load: duplicate class names (multi-release module-info
  twins) keep the first occurrence instead of crashing save.
  Test: duplicate_class_names_keep_first.
- strings/constants: skip methods past MAX_METHOD_INSNS (8000)
  so giants cannot overflow the 64KB writer ceiling. Tests:
  oversized_methods_are_skipped in both passes.
- merge errors now name both types being merged.

Also: em-dash removed repo-wide (724 spots, rule added to
todo.md), allow-lints only in tests (scratch example deleted),
code comments migrated to English in every file this change
touches.
This commit is contained in:
Loki 2026-10-09 13:47:27 +02:00
parent 2172206e83
commit 139aea9a8f
Signed by: boba
GPG key ID: 253067914055423B
96 changed files with 1053 additions and 831 deletions

View file

@ -92,7 +92,7 @@ fn binary_long(first: i64, second: i64, op: Arith) -> Vec<Insn> {
]
}
/// `v = ((v >> 1) << 1) | (v & 1)` — works for any value, including negatives.
/// `v = ((v >> 1) << 1) | (v & 1)` - works for any value, including negatives.
fn shift_form(value: i32) -> Vec<Insn> {
vec![
Insn::Const(Const::Int(value >> 1)),
@ -137,7 +137,7 @@ fn shift_form_long(value: i64) -> Vec<Insn> {
pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
let constant = rng.next_u64() as i32;
vec![
// P = ((C*C) + C) & 1 — even, so always 0.
// P = ((C*C) + C) & 1 - even, so always 0.
Insn::Const(Const::Int(constant)),
Insn::Dup,
Insn::Binary {
@ -154,7 +154,7 @@ pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
prim: Prim::Int,
op: Arith::And,
},
// P != 0 never holds — the branch into DEAD is not taken, value popped.
// P != 0 never holds - the branch into DEAD is not taken, value popped.
Insn::Branch(BranchCond::Zero(Cond::Ne), dead),
Insn::Goto(merge),
Insn::Mark(dead),

View file

@ -1,8 +1,8 @@
//! Пасс шифрования констант: числа → арифметика, плюс opaque-предикаты.
//! The constants pass: numbers become arithmetic, plus opaque predicates.
//!
//! `float`/`double` не трогаются (битовая точность через целые — отдельно).
//! Мелочь из `iconst`-диапазона (`-1..=5`, `0L/1L`) остаётся как есть:
//! её шифрование только раздувает горячий код.
//! `float`/`double` are left alone (bit-exact paths through integers are
//! separate). Trivia from the `iconst` range (`-1..=5`, `0L/1L`) stays as is:
//! encrypting it only bloats hot code.
mod forms;
@ -10,12 +10,12 @@ mod forms;
mod tests;
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Const, Insn, Program, compute_frames};
use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames};
use noxis_core::{Context, CoreError, Pass, PassId, PassReport};
use forms::{Scheme, encrypt_int, encrypt_long, opaque_true};
/// Пасс шифрования констант.
/// The constants encryption pass.
#[derive(Debug, Default, Clone, Copy)]
pub struct ConstantsPass;
@ -30,17 +30,18 @@ impl Pass for ConstantsPass {
for name in &cfg.schemes {
schemes.push(
Scheme::parse(name)
.ok_or_else(|| pass_error(format!("неизвестная схема `{name}`")))?,
.ok_or_else(|| pass_error(format!("unknown scheme `{name}`")))?,
);
}
if schemes.is_empty() {
return Err(pass_error("нет схем шифрования"));
return Err(pass_error("no encryption schemes"));
}
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();
let mut oversized: Vec<(usize, usize)> = Vec::new();
for (ci, class) in program.classes.iter_mut().enumerate() {
if cfg
@ -55,25 +56,34 @@ impl Pass for ConstantsPass {
let Some(code) = method.code.as_mut() else {
continue;
};
let mut fresh = Vec::with_capacity(code.insns.len());
let original = std::mem::take(&mut code.insns);
let mut fresh = Vec::with_capacity(original.len());
let mut touched = false;
for insn in code.insns.drain(..) {
for insn in &original {
match insn {
Insn::Const(Const::Int(value)) if !is_tiny_int(value) => {
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));
fresh.extend(encrypt_int(*value, scheme, &mut rng));
replaced += 1;
touched = true;
}
Insn::Const(Const::Long(value)) if value != 0 && value != 1 => {
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));
fresh.extend(encrypt_long(*value, scheme, &mut rng));
replaced += 1;
touched = true;
}
other => fresh.push(other),
other => fresh.push(other.clone()),
}
}
if fresh.len() > MAX_METHOD_INSNS {
// Would risk the 64KB writer ceiling: keep the original
// code untouched (predicates included) and count a skip.
code.insns = original;
skipped += 1;
oversized.push((ci, mi));
continue;
}
code.insns = fresh;
if touched {
code.max_stack = code.max_stack.saturating_add(2);
@ -84,6 +94,7 @@ impl Pass for ConstantsPass {
insert_predicates(
program,
&mut mutated,
&oversized,
cfg.max_predicates,
&mut rng,
&mut predicates,
@ -96,15 +107,17 @@ impl Pass for ConstantsPass {
}
}
/// Мелочь, которую шифровать невыгодно (уже `iconst`).
/// Trivia that is not worth encrypting (already `iconst`).
fn is_tiny_int(value: i32) -> bool {
(-1..=5).contains(&value)
}
/// Opaque-предикаты в начало методов с кодом (не больше лимита на метод).
/// Opaque predicates at the top of methods with code (at most the limit).
/// Oversized methods stay fully untouched, predicates included.
fn insert_predicates(
program: &mut Program,
mutated: &mut Vec<(usize, usize)>,
oversized: &[(usize, usize)],
max_predicates: usize,
rng: &mut noxis_core::Rng,
predicates: &mut usize,
@ -116,6 +129,9 @@ fn insert_predicates(
for ci in 0..classes {
let methods = program.classes[ci].methods.len();
for mi in 0..methods {
if oversized.contains(&(ci, mi)) {
continue;
}
let empty_or_missing = program.classes[ci].methods[mi]
.code
.as_ref()
@ -123,7 +139,7 @@ fn insert_predicates(
if empty_or_missing {
continue;
}
let missing = || pass_error("метод пропал при вставке предиката");
let missing = || pass_error("method vanished while inserting a predicate");
let mut top = program.classes[ci]
.methods
.get(mi)
@ -166,7 +182,7 @@ fn insert_predicates(
Ok(())
}
/// Пересчитать фреймы затронутого метода.
/// Recompute the frames of a touched method.
fn refresh_frames(
program: &mut Program,
ci: usize,
@ -177,14 +193,14 @@ fn refresh_frames(
let class = program
.classes
.get(ci)
.ok_or_else(|| pass_error("класс пропал"))?;
.ok_or_else(|| pass_error("class vanished"))?;
(
class.this_class.clone(),
class
.methods
.get(mi)
.cloned()
.ok_or_else(|| pass_error("метод пропал"))?,
.ok_or_else(|| pass_error("method vanished"))?,
)
};
let frames =
@ -194,7 +210,7 @@ fn refresh_frames(
.get_mut(ci)
.and_then(|c| c.methods.get_mut(mi))
.and_then(|m| m.code.as_mut())
.ok_or_else(|| pass_error("метод пропал"))?
.ok_or_else(|| pass_error("method vanished"))?
.frames = frames;
Ok(())
}
@ -205,7 +221,7 @@ fn report(replaced: usize, predicates: usize, skipped: usize) -> PassReport {
transformed: replaced + predicates,
skipped,
notes: vec![format!(
"констант разбито: {replaced}, предикатов: {predicates}"
"constants split: {replaced}, predicates: {predicates}"
)],
}
}

View file

@ -1,14 +1,14 @@
//! Тесты пасса констант: формы вычисляют исходник, мелочь цела.
//! Constant-pass tests: forms compute the source value, trivia survives.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Const, Insn, Prim, Program, Version};
use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Prim, Program, Version};
use noxis_core::{Config, Context, Pass};
use super::ConstantsPass;
use super::forms::{Scheme, encrypt_int, encrypt_long};
/// Формы обратимы арифметически (интерпретатор троек/пятёрок).
/// Forms round-trip arithmetically (an interpreter for triples/quintuples).
fn eval_int(insns: &[Insn]) -> i32 {
match insns {
[
@ -20,7 +20,7 @@ fn eval_int(insns: &[Insn]) -> i32 {
match op {
Xor => a ^ b,
Add => a.wrapping_add(*b),
_ => panic!("не та тройка: {insns:?}"),
_ => panic!("not a triple: {insns:?}"),
}
}
[
@ -30,7 +30,7 @@ fn eval_int(insns: &[Insn]) -> i32 {
Insn::Const(Const::Int(b)),
Insn::Binary { .. },
] => (a << 1) | (b & 1),
_ => panic!("не форма: {insns:?}"),
_ => panic!("not a form: {insns:?}"),
}
}
@ -84,7 +84,7 @@ fn sample() -> Program {
}
}
/// Пасс: большие константы разбиты, мелочь и структура целы.
/// The pass: big constants are split, trivia and structure survive.
#[test]
fn constants_split_and_predicated() {
let mut program = sample();
@ -96,16 +96,54 @@ fn constants_split_and_predicated() {
.apply(&mut ctx, &mut program)
.expect("constants");
let code = program.classes[0].methods[0].code.as_ref().expect("код");
let code = program.classes[0].methods[0].code.as_ref().expect("code");
let has = |v: i32| code.insns.contains(&Insn::Const(Const::Int(v)));
assert!(!has(100), "исходная константа уцелела");
assert!(has(3), "мелочь тронута");
assert!(!has(100), "source constant survived");
assert!(has(3), "trivia touched");
assert!(
!code.insns.contains(&Insn::Const(Const::Long(10))),
"long уцелел"
"long survived"
);
// Opaque-предикат: мёртвая ветвь из `nop`.
assert!(code.insns.contains(&Insn::Nop), "нет предиката");
assert!(report.transformed >= 3, "мало трансформаций: {report:?}");
// Opaque predicate: a dead branch made of `nop`.
assert!(code.insns.contains(&Insn::Nop), "no predicate");
assert!(report.transformed >= 3, "few transforms: {report:?}");
assert_eq!(report.skipped, 0);
}
/// A method that would blow past the 64KB ceiling stays byte-identical.
#[test]
fn oversized_methods_are_skipped() {
let code = Code {
max_stack: 2,
max_locals: 0,
insns: vec![Insn::Const(Const::Int(1_000_000)); MAX_METHOD_INSNS + 1],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let mut program = 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(),
};
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("code");
assert!(
code.insns
.iter()
.all(|i| matches!(i, Insn::Const(Const::Int(1_000_000)))),
"oversized method was rewritten"
);
assert_eq!(report.skipped, 1);
}

View file

@ -1,12 +1,12 @@
//! Пасс отладочной информации: удаление и ложная подмена.
//!
//! Источник требований — `info/evo-weaknesses.md` §3: у разобранного
//! Источник требований - `info/evo-weaknesses.md` §3: у разобранного
//! образца оригинальные `SourceFile` у 160 классов, 10 218 `LineNumberTable`
//! и имена локальных переменных. Это дешевле всего чинить и дороже всего
//! отдавать.
//!
//! Важно: удалять недостаточно. Инструменты (JADX/CFR) верят отладке сильнее,
//! чем байткоду, поэтому `spoof_source_file` подставляет **ложное** имя —
//! чем байткоду, поэтому `spoof_source_file` подставляет **ложное** имя -
//! консистентное, но не связанное с реальным исходником.
use noxis_classfile::{Attribute, ClassFile, Code, Method, Program};

View file

@ -3,7 +3,7 @@
//! Every emitter here is stack-neutral as a whole: whatever the prologue
//! pushes it also consumes before falling through to `body`. That is what
//! makes prepending safe at method entry (empty stack) without any dataflow
//! analysis — all joins at `body` carry an empty stack, so any computed
//! analysis - all joins at `body` carry an empty stack, so any computed
//! `StackMapTable` stays consistent.
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim, SlotKind};
@ -14,7 +14,7 @@ use noxis_core::Rng;
/// The opaque key is computed as `a + b` (two constants, never the key
/// itself) and dispatched over `[key-2, key+2]`; only the middle slot reaches
/// `body`, the rest fall into a dead block that jumps back to `body`.
/// Does not emit `Mark(body)` — the caller places it.
/// Does not emit `Mark(body)` - the caller places it.
pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec<Insn> {
let key = 100 + (rng.next_u64() % 10_000) as i32;
let half = (rng.next_u64() as i32) & 0x3FFF_FFFF;
@ -41,9 +41,9 @@ pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec<Insn> {
/// Sparse `lookupswitch` dispatcher over a counter local (`computed-switch`).
///
/// The counter lives in `slot` (a fresh local owned by the caller): the shape
/// differs from `table_dispatch` on purpose — a sparse switch over a variable
/// differs from `table_dispatch` on purpose - a sparse switch over a variable
/// instead of a dense range over a stack value. Junk keys never equal `key`.
/// Does not emit `Mark(body)` — the caller places it.
/// Does not emit `Mark(body)` - the caller places it.
pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) -> Vec<Insn> {
let key = 100 + (rng.next_u64() % 10_000) as i32;
let mut keys = vec![key];
@ -82,7 +82,7 @@ pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) ->
/// Always-true opaque guard `(C*C + C) & 1 == 0` with a dead `nop` branch.
///
/// Stack-neutral: the computed value is consumed by the branch that never
/// fires. Ends with `Mark(merge)` — execution continues there.
/// fires. Ends with `Mark(merge)` - execution continues there.
/// Same construction as the constants pass uses; the predicate core is shared
/// with the flow splices (`rewire::opaque_compute`).
pub(super) fn opaque_guard(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {

View file

@ -8,7 +8,7 @@
//!
//! Every scheme also cuts the stream with a few `goto` splices (intra-method
//! mixing) and recomputes `StackMapTable` afterwards, so the only thing a
//! scheme may assume is an empty stack at method entry — joins stay consistent
//! scheme may assume is an empty stack at method entry - joins stay consistent
//! by construction (see `dispatch`/`rewire` for why each shape is safe).
mod dispatch;
@ -120,7 +120,7 @@ fn skippable(name: &str, code: &Code, max_insns: usize) -> bool {
if name == "<init>" {
return true;
}
// Frames are never computed for subroutines — keep them byte-identical.
// Frames are never computed for subroutines - keep them byte-identical.
if code
.insns
.iter()
@ -167,7 +167,7 @@ fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, r
prologue.push(Insn::Mark(body));
}
Scheme::ComputedSwitch if code.max_locals == u16::MAX => {
// No free local slot — fall back to the stack-based dispatcher.
// No free local slot - fall back to the stack-based dispatcher.
prologue.extend(table_dispatch(body, dead, rng));
prologue.push(Insn::Mark(body));
}
@ -189,7 +189,7 @@ fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, r
// No skip-goto after the dead block: the dispatcher's dead
// `Goto(body)` already jumps over the handler. A second goto
// would sit after a terminal with no frame of its own, and
// HotSpot requires a frame at every block start — even an
// HotSpot requires a frame at every block start - even an
// unreachable one. try_e IS the dead mark for the same reason.
let (mut handler_code, entry) = dead_catch(try_start, dead, handler, body);
guarded.append(&mut handler_code);

View file

@ -77,8 +77,8 @@ pub(super) fn invert_branches(
/// Each splice computes an always-false opaque predicate `(C*C + C) & 1 != 0`
/// and branches on it: the fallthrough continues the stream, the dead block
/// throws instead of rejoining. Throwing (rather than jumping back) keeps the
/// splice valid at *any* stack depth — `athrow` has no successor, so there is
/// nothing to merge — and the dead block still owns a frame through its entry
/// splice valid at *any* stack depth - `athrow` has no successor, so there is
/// nothing to merge - and the dead block still owns a frame through its entry
/// branch, which HotSpot requires even for code that never executes.
///
/// Cuts land at or after `start` (the entry prologue must stay first) and

View file

@ -10,7 +10,7 @@ use noxis_core::{Config, Context, NameStyle, Rng};
use super::{ACC_PRIVATE, ACC_PROTECTED, ACC_PUBLIC, ACC_STATIC};
use crate::namegen::NameGenerator;
/// `(owner, name, descriptor)` — matches a JVM reference.
/// `(owner, name, descriptor)` - matches a JVM reference.
pub(super) type MemberKey = (String, String, String);
/// Rename maps for one run: `declared` is where a member was declared,

View file

@ -4,11 +4,11 @@
//! у EvoVis члены не переименовывались вообще, и класс читался по `javap -p`).
//!
//! Тонкости, которые здесь учитываются:
//! - вызов `Sub.staticMethod()` может указывать владельцем подкласс — нужны
//! - вызов `Sub.staticMethod()` может указывать владельцем подкласс - нужны
//! псевдонимы (`alias`) для всей иерархии;
//! - мостовые методы (`ACC_BRIDGE`) и ковариантные возвраты живут в одном
//! «семействе» метода — переименовывать их нужно разом;
//! - `serialVersionUID`, JNI, рефлексия, сериализация — не трогаем.
//! «семействе» метода - переименовывать их нужно разом;
//! - `serialVersionUID`, JNI, рефлексия, сериализация - не трогаем.
/// Карты и стратегия назначения имён.
mod assign;
@ -179,7 +179,7 @@ impl Pass for RenamePass {
}
}
// 3. Виртуальные семейства — только в агрессивном режиме.
// 3. Виртуальные семейства - только в агрессивном режиме.
if cfg.rename.rename_public_members {
renamed_methods += rename_virtual_families(program, &analysis, &mut maps, &cfg, ctx);
}

View file

@ -1,8 +1,8 @@
//! Пасс ресурсов: патч текстовых файлов архива по маппингу классов.
//!
//! Без этого обфускация Fabric-мода просто не стартует: `fabric.mod.json`
//! хранит main-class точкой, `*.mixins.json` — пути внутренними именами,
//! refmap и access widener — тоже. Материал: `info/evo-weaknesses.md` §3/§8
//! хранит main-class точкой, `*.mixins.json` - пути внутренними именами,
//! refmap и access widener - тоже. Материал: `info/evo-weaknesses.md` §3/§8
//! (у разобранного образца ресурсы вообще не трогали).
//!
//! Замена **границная**, а не подстрочная: `org/example/Foo` не должен
@ -33,7 +33,7 @@ const TEXT_EXTS: [&str; 9] = [
".accesswidener",
];
/// Service loader'ы: файла без расширения, но каждая строка — имя класса.
/// Service loader'ы: файла без расширения, но каждая строка - имя класса.
const SERVICES_PREFIX: &str = "META-INF/services/";
/// Пасс патча ресурсов.
@ -52,7 +52,7 @@ impl Pass for ResourcesPass {
pass: PassId::Resources,
transformed: 0,
skipped: program.resources.len(),
notes: vec!["маппинг классов пуст — ресурсы не тронуты".into()],
notes: vec!["маппинг классов пуст - ресурсы не тронуты".into()],
});
}
@ -142,7 +142,7 @@ fn replace_delimited(text: &str, old: &str, new: &str, dotted: bool) -> String {
continue;
}
// Совпадение частичное — пропускаем первый символ и ищем дальше.
// Совпадение частичное - пропускаем первый символ и ищем дальше.
let mut chars = rest[at..].chars();
out.push_str(before);
match chars.next() {

View file

@ -71,7 +71,7 @@ fn empty_map_is_a_noop() {
#[test]
fn unicode_text_survives_unrelated_substitution() {
let text = "комментарий: Лорем_ипсум — org/example/Bar";
let text = "комментарий: Лорем_ипсум - org/example/Bar";
assert_eq!(remap_text(text, &mapping()), text);
}

View file

@ -1,7 +1,7 @@
//! Шифрование строк: чанки, salt под схему, последовательность вызова.
//!
//! Один `Const::String` превращается в `ldc cipher; ldc salt; invokestatic
//! d` на чанк; чанки склеиваются `String.concat` (без `StringBuilder` —
//! d` на чанк; чанки склеиваются `String.concat` (без `StringBuilder` -
//! меньше инструкций и нет вызовов конструктора).
use noxis_classfile::{Const, Insn, InvokeKind};
@ -24,7 +24,7 @@ pub(super) fn split_chunks(text: &str, split_above: usize) -> Vec<String> {
/// Salt, попадающий в нужную схему: `salt % total == index`.
///
/// Salt всегда неотрицательный (Java `%` сохраняет знак — отрицательный salt
/// 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;

View file

@ -1,8 +1,8 @@
//! Пасс шифрования строк: `Const::String` → вызовы рантайм-декодера.
//! The strings pass: `Const::String` becomes runtime-decoder calls.
//!
//! Схема строки выбирается ротацией (`salt % n`), salt детерминирован
//! (KDF или RNG-поток — см. `derive_key`). Декодер генерируется один на
//! программу и только если была зашифрована хотя бы одна строка.
//! The scheme rotates per string (`salt % n`), the salt is deterministic
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
//! program and only if at least one string was encrypted.
mod crypt;
@ -12,14 +12,14 @@ mod tests;
use std::collections::BTreeMap;
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Const, Insn, Program, compute_frames};
use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, 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};
/// Пасс шифрования строк.
/// The string encryption pass.
#[derive(Debug, Default, Clone, Copy)]
pub struct StringsPass;
@ -35,9 +35,9 @@ impl Pass for StringsPass {
schemes.push(Scheme::parse(name).map_err(|e| pass_error(e.to_string()))?);
}
if schemes.is_empty() {
return Err(pass_error("нет схем шифрования"));
return Err(pass_error("no encryption schemes"));
}
// Фаза 1: собрать все шифруемые строки (позиция + текст).
// Phase 1: collect every encryptable string (position + text).
let mut targets: Vec<(usize, usize, usize, String)> = Vec::new();
let mut skipped = 0usize;
for (ci, class) in program.classes.iter().enumerate() {
@ -60,10 +60,10 @@ impl Pass for StringsPass {
return Ok(report(
0,
skipped,
vec!["нет строк для шифрования".to_string()],
vec!["no strings to encrypt".to_string()],
));
}
// Фаза 2: зашифровать и подставить последовательности вызовов.
// Phase 2: encrypt and substitute call sequences.
let master = ctx.master_seed;
let mut rng = ctx.rng("strings");
let owner = decoder_name(ctx, program);
@ -83,49 +83,64 @@ impl Pass for StringsPass {
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
}
let mut mutated: Vec<(usize, usize)> = Vec::new();
let mut applied = 0usize;
let mut per_method: BTreeMap<(usize, usize), usize> = BTreeMap::new();
for (ci, mi, _, _) in &targets {
*per_method.entry((*ci, *mi)).or_default() += 1;
}
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 original = std::mem::take(&mut code.insns);
let method_targets = per_method.get(&(ci, mi)).copied().unwrap_or(0);
let mut fresh = Vec::with_capacity(original.len());
let mut touched = false;
for (ii, insn) in code.insns.drain(..).enumerate() {
for (ii, insn) in original.iter().enumerate() {
if let Some(sequence) = replacements.remove(&(ci, mi, ii)) {
fresh.extend(sequence);
touched = true;
} else {
fresh.push(insn);
fresh.push(insn.clone());
}
}
if fresh.len() > MAX_METHOD_INSNS {
// Would risk the 64KB writer ceiling: keep the original
// code (and its strings) untouched.
code.insns = original;
skipped += 1;
continue;
}
code.insns = fresh;
if touched {
applied += method_targets;
code.max_stack = code.max_stack.saturating_add(2);
mutated.push((ci, mi));
}
code.insns = fresh;
}
}
// Фаза 3: класс-декодер один на программу.
// Phase 3: one decoder class per program.
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: фреймы затронутых методов.
ctx.note(format!("string decoder: {owner}"));
// Phase 4: frames of the touched methods.
let hierarchy = ClassHierarchy::build(program);
for (ci, mi) in &mutated {
let (name, snapshot) = {
let class = program
.classes
.get(*ci)
.ok_or_else(|| pass_error("класс пропал"))?;
.ok_or_else(|| pass_error("class vanished"))?;
(
class.this_class.clone(),
class
.methods
.get(*mi)
.cloned()
.ok_or_else(|| pass_error("метод пропал"))?,
.ok_or_else(|| pass_error("method vanished"))?,
)
};
let frames = compute_frames(&name, &snapshot, &hierarchy)
@ -135,16 +150,13 @@ impl Pass for StringsPass {
.get_mut(*ci)
.and_then(|c| c.methods.get_mut(*mi))
.and_then(|m| m.code.as_mut())
.ok_or_else(|| pass_error("метод пропал"))?
.ok_or_else(|| pass_error("method vanished"))?
.frames = frames;
}
Ok(report(
targets.len(),
applied,
skipped,
vec![format!(
"зашифровано строк: {}, декодер: {owner}",
targets.len()
)],
vec![format!("strings encrypted: {applied}, decoder: {owner}")],
))
}
}
@ -162,12 +174,12 @@ fn pass_error(msg: impl Into<String>) -> CoreError {
CoreError::pass(PassId::Strings, msg)
}
/// Строка подпадает под `skip`-паттерны конфига.
/// A string matches the `skip` patterns of the config.
fn is_skipped(text: &str, skip: &[String]) -> bool {
skip.iter().any(|pattern| text.contains(pattern))
}
/// Salt чанка: KDF от позиции или RNG-поток (см. `derive_key`).
/// A chunk salt: KDF from the position or an RNG stream (see `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)
@ -178,7 +190,7 @@ fn next_salt(rng: &mut noxis_core::Rng, master: u64, counter: &mut u64, derive:
value
}
/// Детерминированное имя декодера без коллизий с классами программы.
/// A collision-free deterministic decoder name.
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 {

View file

@ -1,8 +1,8 @@
//! Тесты пасса строк: подстановка, чанки, детерминизм шифротекста.
//! String-pass tests: substitution, chunks, ciphertext determinism.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{ClassBuilder, Code, Const, Insn, Program, Version};
use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Program, Version};
use noxis_core::{Config, Context};
use noxis_runtime::{Scheme, encrypt};
@ -36,7 +36,7 @@ fn sample() -> Program {
}
}
/// Короткие строки не режутся, длинные — на границе символов.
/// Short strings stay whole, long ones split on char boundaries.
#[test]
fn chunks_split_by_chars() {
assert_eq!(split_chunks("abc", 64), vec!["abc".to_string()]);
@ -45,19 +45,19 @@ fn chunks_split_by_chars() {
assert_eq!(chunks.concat().len(), 100);
}
/// Salt всегда неотрицателен и попадает в схему.
/// Salt is always non-negative and lands in its scheme.
#[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!(salt >= 0, "negative salt");
assert_eq!(salt as usize % total, index);
}
}
}
/// Пасс: строки заменены вызовами, шифротекст воспроизводим, декодер есть.
/// The pass: strings become calls, ciphertext is reproducible, decoder exists.
#[test]
fn strings_replaced_and_deterministic() {
let mut program = sample();
@ -70,14 +70,14 @@ fn strings_replaced_and_deterministic() {
assert_eq!(report.transformed, 2);
assert_eq!(report.skipped, 1);
// Декодер добавлен последним.
// The decoder is appended last.
assert_eq!(program.classes.len(), 2);
let decoder = program.classes.last().expect("декодер");
let decoder = program.classes.last().expect("decoder");
assert!(decoder.methods.iter().any(|m| m.name == "d"));
// Исходных строк не осталось (шифры — длинные Const::String — на месте);
// короткая на месте.
let code = program.classes[0].methods[0].code.as_ref().expect("код");
// No source strings left (ciphers - long Const::String - are in place);
// the short one stays.
let code = program.classes[0].methods[0].code.as_ref().expect("code");
let kept: Vec<&String> = code
.insns
.iter()
@ -90,7 +90,7 @@ fn strings_replaced_and_deterministic() {
assert!(!kept.iter().any(|s| *s == &"y".repeat(100)));
assert!(kept.contains(&&"x".to_string()));
// Каждый вызов сверяется с прямым шифрованием (тот же сид → те же блобы).
// Every call checks out against direct encryption (same seed, same blobs).
let schemes = [Scheme::XorSplitmix, Scheme::XteaCtr];
let mut triples = Vec::new();
let mut window: Vec<&Insn> = Vec::new();
@ -109,7 +109,7 @@ fn strings_replaced_and_deterministic() {
triples.push((cipher.clone(), *salt));
}
}
// "hello world" (1 чанк) + "y"*100 (2 чанка).
// "hello world" (1 chunk) + "y"*100 (2 chunks).
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) {
@ -117,7 +117,7 @@ fn strings_replaced_and_deterministic() {
let blob = encrypt(scheme, 7, *salt, &original);
assert_eq!(cipher, &blob.cipher);
}
// Склейка чанков через `concat` — ровно одна; конструктор жив.
// Chunk joins through `concat` - exactly one; the constructor is alive.
assert_eq!(
code.insns
.iter()
@ -127,3 +127,39 @@ fn strings_replaced_and_deterministic() {
);
assert!(!decode_sequence("o", &[("c".to_string(), 1)]).is_empty());
}
/// A method that would blow past the 64KB ceiling keeps its strings.
#[test]
fn oversized_methods_are_skipped() {
let code = Code {
max_stack: 2,
max_locals: 0,
insns: vec![Insn::Const(Const::String("oversized".to_string())); MAX_METHOD_INSNS + 1],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let mut program = 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(),
};
let mut ctx = Context::new(Config {
seed: Some(7),
..Config::default()
});
let report = StringsPass.apply(&mut ctx, &mut program).expect("strings");
let code = program.classes[0].methods[0].code.as_ref().expect("code");
assert!(
code.insns
.iter()
.all(|i| matches!(i, Insn::Const(Const::String(s)) if s == "oversized")),
"oversized method was rewritten"
);
assert_eq!(report.skipped, 1);
}