From 139aea9a8f3e7604c1a0f74f77c766476ebe0424 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 9 Oct 2026 13:47:27 +0200 Subject: [PATCH] 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 : 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. --- NOTICE.md | 4 +- crates/noxis-analysis/src/entrypoints.rs | 10 +- crates/noxis-analysis/src/hierarchy.rs | 10 +- crates/noxis-analysis/src/keep.rs | 4 +- crates/noxis-analysis/src/lib.rs | 2 +- .../noxis-classfile/src/format/frames/emit.rs | 10 +- .../src/format/frames/merge/descs.rs | 6 +- .../src/format/frames/merge/mod.rs | 39 ++-- .../noxis-classfile/src/format/frames/mod.rs | 10 +- .../src/format/frames/tests/joins.rs | 191 ++++++++++++++++++ .../format/frames/{tests.rs => tests/mod.rs} | 44 ++-- .../src/format/frames/xfer/calls.rs | 33 +-- .../src/format/frames/xfer/mod.rs | 53 +++-- .../src/format/frames/xfer/stack.rs | 8 +- .../noxis-classfile/src/format/parse/attrs.rs | 4 +- .../src/format/parse/code/frames.rs | 4 +- .../src/format/parse/code/labels.rs | 4 +- .../src/format/parse/code/mod.rs | 2 +- .../src/format/parse/insns/basic.rs | 2 +- .../src/format/parse/insns/flow.rs | 4 +- .../src/format/parse/insns/mod.rs | 4 +- .../src/format/parse/insns/refs.rs | 2 +- .../noxis-classfile/src/format/write/attrs.rs | 2 +- .../src/format/write/code/frames.rs | 4 +- .../src/format/write/code/insns.rs | 6 +- .../src/format/write/code/mod.rs | 2 +- .../src/format/write/code/ops.rs | 2 +- .../src/format/write/helpers.rs | 2 +- .../src/format/write/pool/mod.rs | 4 +- .../src/format/write/pool/refs.rs | 2 +- crates/noxis-classfile/src/insn/kinds.rs | 2 +- crates/noxis-classfile/src/insn/mod.rs | 16 +- crates/noxis-classfile/src/lib.rs | 8 + crates/noxis-classfile/src/model/builder.rs | 2 +- crates/noxis-classfile/src/model/mod.rs | 10 +- crates/noxis-classfile/tests/frames.rs | 6 +- crates/noxis-classfile/tests/pool/main.rs | 2 +- crates/noxis-classfile/tests/roundtrip.rs | 2 +- crates/noxis-classfile/tests/spike_frames.rs | 16 +- .../noxis-classfile/tests/spike_ristretto.rs | 4 +- crates/noxis-core/src/config/mod.rs | 4 +- crates/noxis-core/src/config/sections.rs | 6 +- crates/noxis-core/src/lib.rs | 2 +- crates/noxis-core/src/mapping.rs | 2 +- crates/noxis-core/src/pipeline/context.rs | 2 +- crates/noxis-core/src/pipeline/mod.rs | 4 +- crates/noxis-core/src/pipeline/report.rs | 4 +- crates/noxis-core/src/rng.rs | 4 +- crates/noxis-jar/src/archive.rs | 6 +- crates/noxis-jar/src/lib.rs | 8 +- crates/noxis-jar/src/loader.rs | 49 ++++- crates/noxis-jar/src/signatures.rs | 8 +- crates/noxis-passes/src/constants/forms.rs | 6 +- crates/noxis-passes/src/constants/mod.rs | 62 +++--- crates/noxis-passes/src/constants/tests.rs | 64 ++++-- crates/noxis-passes/src/debug.rs | 4 +- crates/noxis-passes/src/flow/dispatch.rs | 10 +- crates/noxis-passes/src/flow/mod.rs | 8 +- crates/noxis-passes/src/flow/rewire.rs | 4 +- crates/noxis-passes/src/rename/assign.rs | 2 +- crates/noxis-passes/src/rename/mod.rs | 8 +- crates/noxis-passes/src/resources/mod.rs | 10 +- crates/noxis-passes/src/resources/tests.rs | 2 +- crates/noxis-passes/src/strings/crypt.rs | 4 +- crates/noxis-passes/src/strings/mod.rs | 68 ++++--- crates/noxis-passes/src/strings/tests.rs | 64 ++++-- crates/noxis-runtime/src/decoder/kdf.rs | 8 +- crates/noxis-runtime/src/decoder/mod.rs | 10 +- crates/noxis-runtime/src/decoder/xor.rs | 8 +- crates/noxis-runtime/src/decoder/xtea.rs | 10 +- crates/noxis-runtime/src/scheme.rs | 16 +- crates/noxis-runtime/tests/exec.rs | 2 +- crates/noxis-structure-check/src/rules.rs | 8 +- crates/noxis-verify/src/java.rs | 6 +- crates/noxis-verify/src/leaks.rs | 4 +- crates/noxis-verify/src/lib.rs | 6 +- crates/noxis/examples/dbg.rs | 123 ----------- crates/noxis/src/cmd/analyze.rs | 2 +- crates/noxis/src/cmd/obfuscate.rs | 4 +- crates/noxis/src/lib.rs | 2 +- docs/README.md | 22 +- docs/architecture.md | 34 ++-- docs/learnings/ai-resistance.md | 54 ++--- docs/learnings/lessons.md | 62 +++--- docs/learnings/priorities.md | 58 +++--- docs/learnings/reference.md | 32 +-- docs/stack.md | 94 ++++----- docs/techniques.md | 54 ++--- info/README.md | 4 +- info/evo-improvements.md | 78 +++---- info/evo-native-reverse.md | 60 +++--- info/evo-nobackend.md | 22 +- info/evo-obfuscation-full.md | 70 +++---- info/evo-obfuscation.md | 20 +- info/evo-payload-pipeline.md | 14 +- info/evo-weaknesses.md | 40 ++-- 96 files changed, 1053 insertions(+), 831 deletions(-) create mode 100644 crates/noxis-classfile/src/format/frames/tests/joins.rs rename crates/noxis-classfile/src/format/frames/{tests.rs => tests/mod.rs} (79%) delete mode 100644 crates/noxis/examples/dbg.rs diff --git a/NOTICE.md b/NOTICE.md index b059baf..7451839 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,8 +1,8 @@ -# NOTICE — сторонние компоненты +# NOTICE - сторонние компоненты Проект `noxis` распространяется по лицензии AGPL-3.0-only (см. [`LICENSE`](LICENSE)). -Ниже — компоненты third-party, которые используются напрямую и чьи +Ниже - компоненты third-party, которые используются напрямую и чьи лицензионные условия обязательны к сохранению. ## ristretto_classfile diff --git a/crates/noxis-analysis/src/entrypoints.rs b/crates/noxis-analysis/src/entrypoints.rs index f559d1f..93349c0 100644 --- a/crates/noxis-analysis/src/entrypoints.rs +++ b/crates/noxis-analysis/src/entrypoints.rs @@ -24,7 +24,7 @@ pub struct Entrypoints { } impl Entrypoints { - /// Собрать entrypoint'ы по программе (без keep-правил — их добавляет [`Self::merge`]). + /// Собрать entrypoint'ы по программе (без keep-правил - их добавляет [`Self::merge`]). pub fn collect(program: &Program) -> Self { let mut out = Self::default(); @@ -101,7 +101,7 @@ fn implements_serializable(program: &Program, start: &str) -> bool { return true; } let Some(class) = program.class(&name) else { - // Внешний предок: считаем, что не знаем — консервативно не keep'им. + // Внешний предок: считаем, что не знаем - консервативно не keep'им. continue; }; if let Some(sup) = &class.super_class { @@ -130,15 +130,15 @@ fn collect_service_interfaces(program: &Program) -> BTreeSet { /// Линейный эвристический поиск имён в рефлексивных API. /// /// Полноценный dataflow не нужен: если рядом с вызовом `Class.forName(...)` / -/// `getMethod(...)` лежит строковый литерал — это имя видно статически, +/// `getMethod(...)` лежит строковый литерал - это имя видно статически, /// значит, его нельзя переименовывать. fn collect_reflective_names(out: &mut Entrypoints, code: &noxis_classfile::Code) { - /// API, чьи аргументы — **имена классов**. + /// API, чьи аргументы - **имена классов**. const CLASS_API: &[(&str, &str)] = &[ ("java/lang/Class", "forName"), ("java/lang/ClassLoader", "loadClass"), ]; - /// API, чьи аргументы — **имена членов** (поле/метод). + /// API, чьи аргументы - **имена членов** (поле/метод). const MEMBER_API: &[(&str, &str)] = &[ ("java/lang/Class", "getMethod"), ("java/lang/Class", "getDeclaredMethod"), diff --git a/crates/noxis-analysis/src/hierarchy.rs b/crates/noxis-analysis/src/hierarchy.rs index 6a57ea1..b982d8f 100644 --- a/crates/noxis-analysis/src/hierarchy.rs +++ b/crates/noxis-analysis/src/hierarchy.rs @@ -40,7 +40,7 @@ impl ClassHierarchy { .and_then(|n| n.super_class.clone()) } - /// Есть ли класс в иерархии (в том числе из внешнего classpath — нет). + /// Есть ли класс в иерархии (в том числе из внешнего classpath - нет). pub fn contains(&self, internal_name: &str) -> bool { self.nodes.contains_key(internal_name) } @@ -57,7 +57,7 @@ impl ClassHierarchy { /// Является ли `child` подклассом `parent` (в пределах программы). /// - /// Неполная иерархия (внешние зависимости не грузим) — это осознанно: + /// Неполная иерархия (внешние зависимости не грузим) - это осознанно: /// обфускатор работает по артефакту, а не по всему classpath. pub fn is_subclass_of(&self, child: &str, parent: &str) -> bool { if child == parent { @@ -137,10 +137,10 @@ pub enum RenameSafety { /// Оценка безопасности переименования метода. /// /// Правила (сознательно консервативные, как в `OBF/InheritanceAnalyzer`): -/// - ``/`` — никогда; -/// - `private`/`static`/`final`/`` внутри финального класса — можно; +/// - ``/`` - никогда; +/// - `private`/`static`/`final`/`` внутри финального класса - можно; /// - `public`/`protected`/`package-private`, если класс не `final` и есть -/// наследники (внутри артефакта или нет) — нельзя, пока нет доказательства. +/// наследники (внутри артефакта или нет) - нельзя, пока нет доказательства. pub fn method_rename_safety( program: &Program, hierarchy: &ClassHierarchy, diff --git a/crates/noxis-analysis/src/keep.rs b/crates/noxis-analysis/src/keep.rs index bdb9ad2..f9895e3 100644 --- a/crates/noxis-analysis/src/keep.rs +++ b/crates/noxis-analysis/src/keep.rs @@ -6,7 +6,7 @@ use regex::Regex; use crate::entrypoints::Entrypoints; use noxis_core::Config; -/// Скомпилированный glob: `*` — любые символы кроме `/`, `**` — всё, `?` — один. +/// Скомпилированный glob: `*` - любые символы кроме `/`, `**` - всё, `?` - один. fn compile(pattern: &str) -> Result { let mut out = String::from("^"); let chars: Vec = pattern.chars().collect(); @@ -107,7 +107,7 @@ impl KeepRules { /// Методы, на которые ссылаются `BootstrapMethods` (`invokedynamic`). /// /// Переименование такого метода без правки bootstrap-записи ломает класс, - /// а правка — задача P2 (см. `todo.md`): пока просто не трогаем имена. + /// а правка - задача P2 (см. `todo.md`): пока просто не трогаем имена. pub fn absorb_bootstrap(&mut self, program: &Program) { for class in &program.classes { for method in &class.methods { diff --git a/crates/noxis-analysis/src/lib.rs b/crates/noxis-analysis/src/lib.rs index fb6fac5..6b4457a 100644 --- a/crates/noxis-analysis/src/lib.rs +++ b/crates/noxis-analysis/src/lib.rs @@ -5,7 +5,7 @@ //! //! Из этих данных пасс переименования узнаёт, **что** трогать нельзя. //! Урок из `info/evo-weaknesses.md`: у EvoVis рефлексия по имени была -//! открытым текстом, а entrypoint'ы — в `fabric.mod.json`; здесь они +//! открытым текстом, а entrypoint'ы - в `fabric.mod.json`; здесь они //! обязаны попадать в keep-набор автоматически. pub mod entrypoints; diff --git a/crates/noxis-classfile/src/format/frames/emit.rs b/crates/noxis-classfile/src/format/frames/emit.rs index 901b0f3..5a95bec 100644 --- a/crates/noxis-classfile/src/format/frames/emit.rs +++ b/crates/noxis-classfile/src/format/frames/emit.rs @@ -1,7 +1,7 @@ //! Emitting compact frames at the points where the verifier expects them. //! //! A frame's kind is chosen by comparing with the previous frame -//! (`Same`/`Append`/`Chop`/`SameLocals`, otherwise `Full`) — like `javac`. +//! (`Same`/`Append`/`Chop`/`SameLocals`, otherwise `Full`) - like `javac`. //! Trailing `Top`s are trimmed both when comparing and when writing: an empty //! slot is not a type. @@ -12,7 +12,7 @@ use super::xfer::State; use super::{StackMapFrame, VerificationType}; use crate::error::ClassfileError; -/// Frames at every point in `targets` (sorted — deltas in order). +/// Frames at every point in `targets` (sorted - deltas in order). /// /// Deltas are in instruction numbers (like the backend after reading); the writer /// recomputes them into bytes itself. @@ -50,7 +50,7 @@ fn select( previous: &[VerificationType], stack: &[VerificationType], ) -> StackMapFrame { - // Comparison is at variable granularity (`long` — one entry, like `javac`); + // Comparison is at variable granularity (`long` - one entry, like `javac`); // otherwise halves would produce `Append{[Top, Long]}`. let current = vars(locals); let base = vars(previous); @@ -98,7 +98,7 @@ fn select( } } -/// Stack entries → values: `[Double, Top]` — one entry (like `javac`). +/// Stack entries → values: `[Double, Top]` - one entry (like `javac`). /// /// Unlike [`vars`], no trailing-`Top` trimming: every stack entry is live by /// construction, and dropping one would corrupt the depth. @@ -122,7 +122,7 @@ fn stack_vars(stack: &[VerificationType]) -> Vec { out } -/// Slots → variables: `[Long, Top]` — one entry; trailing `Top`s — gone. +/// Slots → variables: `[Long, Top]` - one entry; trailing `Top`s - gone. /// /// Single `Top`s inside (holes) are kept: in `Full` they are a legal type, and we /// don't allow them in `Append` (see `select`). diff --git a/crates/noxis-classfile/src/format/frames/merge/descs.rs b/crates/noxis-classfile/src/format/frames/merge/descs.rs index eee664a..e089cf2 100644 --- a/crates/noxis-classfile/src/format/frames/merge/descs.rs +++ b/crates/noxis-classfile/src/format/frames/merge/descs.rs @@ -31,7 +31,7 @@ pub(crate) fn split_array(descriptor: &str) -> Option<(usize, &str)> { Some((dims, descriptor[dims..].trim_end_matches(';'))) } -/// Slots for a single field descriptor (`long`/`double` — pair with `Top`). +/// Slots for a single field descriptor (`long`/`double` - pair with `Top`). pub(crate) fn field_slots(descriptor: &str) -> Result, DescError> { let mut chars = descriptor.chars(); match chars.next() { @@ -57,10 +57,10 @@ pub(crate) fn field_slots(descriptor: &str) -> Result, Des } } -/// Parameter and return slots (`None` — `void`). +/// Parameter and return slots (`None` - `void`). type MethodSlots = (Vec>, Option>); -/// Method descriptor: parameter slots and return slots (`None` — `void`). +/// Method descriptor: parameter slots and return slots (`None` - `void`). pub(crate) fn method_slots(descriptor: &str) -> Result { let (params, ret) = descriptor .split_once('(') diff --git a/crates/noxis-classfile/src/format/frames/merge/mod.rs b/crates/noxis-classfile/src/format/frames/merge/mod.rs index 7da31a0..fc82fc4 100644 --- a/crates/noxis-classfile/src/format/frames/merge/mod.rs +++ b/crates/noxis-classfile/src/format/frames/merge/mod.rs @@ -1,12 +1,12 @@ //! Type merging (LUB) and descriptor parsing. //! -//! The lattice: `Top` is "empty" (the neighbour wins), `Null` is absorbed into a -//! reference, equal types map to themselves, references take the least upper -//! bound over the hierarchy. Incompatible initialized types (`int` vs a reference -//! after `astore` in a handler) yield `Top`, like `javac`'s truncated frames: a -//! `Top` in a frame accepts any actual value, so this is always sound. The -//! exception is uninitialized types: merging them means a broken construction -//! flow — here an honest error, not a silent `Top`. +//! The lattice: `Top` absorbs (a slot unwritten on any inflow stays `Top`, +//! like `javac` - a precise type would reject the inflow that never wrote +//! it). `Null` is absorbed into a reference, equal types map to themselves, +//! references take the least upper bound over the hierarchy. Incompatible +//! initialized types (`int` vs a reference) yield `Top`. The exception is +//! uninitialized types: merging them means a broken construction flow - +//! here an honest error, not a silent `Top`. pub(super) mod descs; @@ -19,7 +19,7 @@ use descs::write_local; /// The class hierarchy for LUB: the superclass chain is enough. pub trait Hierarchy { - /// The direct superclass (`None` — `java/lang/Object` or unknown). + /// The direct superclass (`None` - `java/lang/Object` or unknown). fn superclass(&self, class: &str) -> Option; } @@ -38,7 +38,7 @@ impl Merger<'_> { } } - /// Merge an incoming state; `true` — the slot changed. + /// Merge an incoming state; `true` - the slot changed. pub fn merge_into( &self, slot: &mut Option, @@ -75,11 +75,11 @@ impl Merger<'_> { VerificationType::Object(self.class.clone()) }; slot = write_local(&mut locals, slot, vec![this]) - .map_err(|_| self.invalid("параметры не влезают в locals"))?; + .map_err(|_| self.invalid("parameters do not fit into locals"))?; } for param in params { slot = write_local(&mut locals, slot, param) - .map_err(|_| self.invalid("параметры не влезают в locals"))?; + .map_err(|_| self.invalid("parameters do not fit into locals"))?; } Ok(State { locals, @@ -89,10 +89,10 @@ impl Merger<'_> { fn lub_states(&self, first: &State, second: &State) -> Result { if first.locals.len() != second.locals.len() { - return Err(self.invalid("разная длина locals при слиянии")); + return Err(self.invalid("locals length differs across the merge")); } if first.stack.len() != second.stack.len() { - return Err(self.invalid("разная глубина стека при слиянии")); + return Err(self.invalid("stack depth differs across the merge")); } Ok(State { locals: first @@ -120,7 +120,10 @@ impl Merger<'_> { return Ok(first.clone()); } match (first, second) { - (Top, other) | (other, Top) => Ok(other.clone()), + // A slot unwritten on any inflow stays `Top` (like `javac`): the + // frame keeps accepting every inflow, while a precise type would + // reject the one that never wrote the slot. + (Top, _) | (_, Top) => Ok(Top), (Null, other @ Object(_)) | (other @ Object(_), Null) => Ok(other.clone()), (Null, Null) => Ok(Null), (Integer, Integer) | (Float, Float) | (Long, Long) | (Double, Double) => { @@ -128,10 +131,12 @@ impl Merger<'_> { } (Object(a), Object(b)) => Ok(Object(self.common_superclass(a, b))), // Equal ones are handled above; here uninitialized are always - // different — that's a broken construction flow. + // different - that's a broken construction flow. (VerificationType::UninitializedThis | VerificationType::Uninitialized(_), _) | (_, VerificationType::UninitializedThis | VerificationType::Uninitialized(_)) => { - Err(self.invalid("слияние неинициализированных типов")) + Err(self.invalid(&format!( + "merging uninitialized types ({first:?} with {second:?})" + ))) } // Incompatible initialized (e.g. `int` from the `try` body and // `Throwable` from a handler in one slot): `Top`, like `javac`. @@ -139,7 +144,7 @@ impl Merger<'_> { } } - /// Least common ancestor: climb the chains, fallback — `Object`. + /// Least common ancestor: climb the chains, fallback - `Object`. fn common_superclass(&self, first: &str, second: &str) -> String { if first == second { return first.to_string(); diff --git a/crates/noxis-classfile/src/format/frames/mod.rs b/crates/noxis-classfile/src/format/frames/mod.rs index b640065..5953722 100644 --- a/crates/noxis-classfile/src/format/frames/mod.rs +++ b/crates/noxis-classfile/src/format/frames/mod.rs @@ -2,11 +2,11 @@ //! //! Frames are the most expensive part of any Rust obfuscator: the JVM verifier //! (class version ≥ 50) demands an exact `StackMapTable`, or the class won't load. -//! Implementation options and their costs — `docs/stack.md`. +//! Implementation options and their costs - `docs/stack.md`. //! //! Method: CFG over labels → worklist with types (`xfer`) → LUB merge (`merge`) //! → emit compact frames (`emit`). Byte offsets come from the same encoding the -//! writer uses — otherwise frames would drift apart from the bytes. +//! writer uses - otherwise frames would drift apart from the bytes. mod emit; mod flow; @@ -113,7 +113,7 @@ pub fn compute_frames( emit::emit_frames(&states, &targets, &initial.locals, &merger) } -/// The byte offset of each instruction — using the same encoding as the writer. +/// The byte offset of each instruction - using the same encoding as the writer. fn layout( bare: &[&Insn], labels: &BTreeMap, @@ -188,7 +188,7 @@ fn run_worklist( Ok(()) } -/// Merge a state into an instruction; `true` — changed, keep walking. +/// Merge a state into an instruction; `true` - changed, keep walking. fn flow_into( merger: &Merger<'_>, states: &mut [Option], @@ -220,7 +220,7 @@ fn flow_into( } /// CFG edges without exceptions: fallthrough + branch targets. -/// The exception type at a handler's entry (`catch-all` — any throwable). +/// The exception type at a handler's entry (`catch-all` - any throwable). fn catch_type(catch: &Option) -> VerificationType { match catch { Some(name) => VerificationType::Object(name.clone()), diff --git a/crates/noxis-classfile/src/format/frames/tests/joins.rs b/crates/noxis-classfile/src/format/frames/tests/joins.rs new file mode 100644 index 0000000..2328c65 --- /dev/null +++ b/crates/noxis-classfile/src/format/frames/tests/joins.rs @@ -0,0 +1,191 @@ +//! Join-shape tests: loop heads, wide array loads, nested construction. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::super::{StackMapFrame, VerificationType, compute_frames}; +use super::{NoHierarchy, code_method, load, load_ref, store, store_ref}; +use crate::insn::{BranchCond, Cond, Const, Insn, Prim}; + +/// `anewarray [B` (a partially bound multi-dim array) yields its descriptor +/// verbatim: wrapping it again (`[L[B;`) is not a class and HotSpot rejects +/// the pool entry. +#[test] +fn anewarray_of_array_keeps_descriptor() { + let method = code_method( + "make", + "()Ljava/lang/Object;", + 2, + 1, + vec![ + Insn::Mark(0), + Insn::Const(Const::Int(3)), + Insn::ANewArray("[B".to_string()), + store_ref(0), + load_ref(0), + Insn::Branch(BranchCond::Null { is_null: false }, 1), + Insn::AconstNull, + store_ref(0), + Insn::Goto(2), + Insn::Mark(1), + Insn::Mark(2), + load_ref(0), + Insn::AReturn, + ], + vec![], + ); + let frames = compute_frames("demo/Make", &method, &NoHierarchy).expect("frames"); + assert_eq!( + frames, + vec![StackMapFrame::Append { + offset_delta: 8, + locals: vec![VerificationType::Object("[[B".to_string())], + },] + ); +} + +/// A loop head keeps `Top` for a slot written only inside the loop. +/// +/// Inflows: entry (never wrote it) and back edge (wrote `Integer`). Like +/// `javac`, the join declares `Top`: a precise type would reject the inflow +/// that never wrote the slot (`Type top is not assignable`). +#[test] +fn loop_head_keeps_top_for_loop_local() { + let method = code_method( + "sum", + "()I", + 2, + 3, + vec![ + Insn::Mark(0), + Insn::Const(Const::Int(0)), + store(0), + Insn::Const(Const::Int(0)), + store(1), + Insn::Mark(1), + load(1), + Insn::Const(Const::Int(10)), + Insn::Branch(BranchCond::Int(Cond::Ge), 2), + load(1), + store(2), + load(2), + store(0), + Insn::Iinc { index: 1, delta: 1 }, + Insn::Goto(1), + Insn::Mark(2), + load(0), + Insn::Return(Some(Prim::Int)), + ], + vec![], + ); + let frames = compute_frames("demo/Sum", &method, &NoHierarchy).expect("frames"); + assert_eq!( + frames, + vec![ + StackMapFrame::Append { + offset_delta: 4, + locals: vec![VerificationType::Integer, VerificationType::Integer], + }, + StackMapFrame::Same { offset_delta: 8 }, + ] + ); +} + +/// A nested construction (`new Outer(new Inner())`) initializes only the +/// inner object: the outer `Uninitialized` survives with its own offset, so +/// a later join still sees it instead of a clobbered `Object(Inner)`. +#[test] +fn nested_construction_keeps_outer_uninitialized() { + let method = code_method( + "make", + "()I", + 4, + 1, + vec![ + Insn::Mark(0), + Insn::New("demo/Outer".to_string()), + Insn::Dup, + Insn::New("demo/Inner".to_string()), + Insn::Dup, + Insn::Invoke { + kind: crate::insn::InvokeKind::Special, + owner: "demo/Inner".to_string(), + name: "".to_string(), + descriptor: "()V".to_string(), + itf: false, + }, + Insn::Invoke { + kind: crate::insn::InvokeKind::Special, + owner: "demo/Outer".to_string(), + name: "".to_string(), + descriptor: "(Ldemo/Inner;)V".to_string(), + itf: false, + }, + store_ref(0), + load_ref(0), + Insn::Branch(BranchCond::Null { is_null: false }, 1), + Insn::Const(Const::Int(0)), + Insn::Goto(2), + Insn::Mark(1), + Insn::Const(Const::Int(1)), + Insn::Mark(2), + Insn::Return(Some(Prim::Int)), + ], + vec![], + ); + let frames = compute_frames("demo/Make", &method, &NoHierarchy).expect("frames"); + assert_eq!( + frames, + vec![ + StackMapFrame::Append { + offset_delta: 11, + locals: vec![VerificationType::Object("demo/Outer".to_string())], + }, + StackMapFrame::SameLocals { + offset_delta: 0, + local: VerificationType::Integer, + }, + ] + ); +} + +/// `laload` pushes a pair (`long` + `Top`): a following `lcmp` needs four +/// entries and must not underflow. +#[test] +fn array_load_wide_pushes_pair() { + let method = code_method( + "get", + "([JI)J", + 4, + 2, + vec![ + Insn::Mark(0), + load_ref(0), + load(1), + Insn::ArrayLoad(crate::insn::ArrayKind::Long), + Insn::Const(Const::Long(0)), + Insn::Cmp { + prim: Prim::Long, + nan_great: false, + }, + Insn::Branch(BranchCond::Zero(Cond::Ne), 1), + Insn::Const(Const::Long(1)), + Insn::Goto(2), + Insn::Mark(1), + Insn::Const(Const::Long(0)), + Insn::Mark(2), + Insn::Return(Some(Prim::Long)), + ], + vec![], + ); + let frames = compute_frames("demo/Wide", &method, &NoHierarchy).expect("frames"); + assert_eq!( + frames, + vec![ + StackMapFrame::Same { offset_delta: 8 }, + StackMapFrame::SameLocals { + offset_delta: 0, + local: VerificationType::Long, + }, + ] + ); +} diff --git a/crates/noxis-classfile/src/format/frames/tests.rs b/crates/noxis-classfile/src/format/frames/tests/mod.rs similarity index 79% rename from crates/noxis-classfile/src/format/frames/tests.rs rename to crates/noxis-classfile/src/format/frames/tests/mod.rs index 7e3bcad..41d623d 100644 --- a/crates/noxis-classfile/src/format/frames/tests.rs +++ b/crates/noxis-classfile/src/format/frames/tests/mod.rs @@ -1,4 +1,4 @@ -//! Unit-тесты `compute_frames` на синтезированном IR. +//! Unit tests for `compute_frames` on synthesized IR. #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -7,8 +7,8 @@ use crate::Hierarchy; use crate::insn::{BranchCond, Cond, Const, Insn, Prim, SlotKind}; use crate::model::{Code, ExceptionHandler, Method}; -/// Пустая иерархия: всё неизвестное — наследники `Object` (для LUB хватает). -struct NoHierarchy; +/// Empty hierarchy: everything unknown descends from `Object` (enough for LUB). +pub(super) struct NoHierarchy; impl Hierarchy for NoHierarchy { fn superclass(&self, _class: &str) -> Option { @@ -16,7 +16,7 @@ impl Hierarchy for NoHierarchy { } } -fn code_method( +pub(super) fn code_method( name: &str, descriptor: &str, max_stack: u16, @@ -40,7 +40,7 @@ fn code_method( } } -/// Ветвь → два кадра (`Same`, `SameLocals`). +/// A branch emits two frames (`Same`, `SameLocals`). #[test] fn branch_emits_same_frames() { let mut method = code_method( @@ -76,7 +76,11 @@ fn branch_emits_same_frames() { ); } -/// `try/catch`: handler получает `Full`, продолжение — `Append`. +/// `try/catch`: the handler gets `Full`, the continuation - `Same`. +/// +/// Slot 1 is written only inside the handler, so the join keeps `Top` there +/// (like `javac`): a precise type would reject the inflow that never wrote +/// it (`Type top is not assignable`). #[test] fn handler_emits_full_frame() { let method = code_method( @@ -115,15 +119,12 @@ fn handler_emits_full_frame() { locals: vec![VerificationType::Integer], stack: vec![VerificationType::Object("java/lang/Exception".to_string())], }, - StackMapFrame::Append { - offset_delta: 2, - locals: vec![VerificationType::Object("java/lang/Exception".to_string())], - }, + StackMapFrame::Same { offset_delta: 2 }, ] ); } -/// Два `new` на разных ветвях нельзя слить — честная ошибка, не `Top`. +/// Two `new` on different branches cannot merge - an honest error, not `Top`. #[test] fn uninitialized_merge_is_rejected() { let method = code_method( @@ -150,28 +151,37 @@ fn uninitialized_merge_is_rejected() { ], vec![], ); - let err = compute_frames("demo/Pick", &method, &NoHierarchy).expect_err("слияние"); + let err = compute_frames("demo/Pick", &method, &NoHierarchy).expect_err("merge"); assert!( - err.to_string().contains("неинициализированных"), - "не тот текст: {err}" + err.to_string().contains("uninitialized"), + "wrong message: {err}" ); } -fn load(slot: u16) -> Insn { +mod joins; + +pub(super) fn load(slot: u16) -> Insn { Insn::Load { slot, kind: SlotKind::Int, } } -fn store(slot: u16) -> Insn { +pub(super) fn load_ref(slot: u16) -> Insn { + Insn::Load { + slot, + kind: SlotKind::Ref, + } +} + +pub(super) fn store(slot: u16) -> Insn { Insn::Store { slot, kind: SlotKind::Int, } } -fn store_ref(slot: u16) -> Insn { +pub(super) fn store_ref(slot: u16) -> Insn { Insn::Store { slot, kind: SlotKind::Ref, diff --git a/crates/noxis-classfile/src/format/frames/xfer/calls.rs b/crates/noxis-classfile/src/format/frames/xfer/calls.rs index 98f4950..d25de8c 100644 --- a/crates/noxis-classfile/src/format/frames/xfer/calls.rs +++ b/crates/noxis-classfile/src/format/frames/xfer/calls.rs @@ -1,4 +1,4 @@ -//! Вызовы и константы: дескрипторы, ``, слоты значений. +//! Calls and constants: descriptors, ``, value slots. use super::super::VerificationType; use super::super::merge::descs::{DescError, field_slots, method_slots}; @@ -34,18 +34,12 @@ pub(super) fn invoke( descriptor: &str, ) -> Result<(), ClassfileError> { let (params, ret) = method_slots(descriptor).map_err(|e| context(class, method, e))?; - super::stack::pop_slots( - state, - count_slots(¶ms), - class, - method, - "аргументы вызова", - )?; + super::stack::pop_slots(state, count_slots(¶ms), class, method, "call arguments")?; let mut receiver = None; if kind != InvokeKind::Static { receiver = super::stack::pop_slots(state, 1, class, method, "objectref")?.pop(); } - // `` инициализирует получателя во ВСЕХ его копиях. + // `` initializes the receiver in ALL its copies. if name == "" { match receiver { Some(VerificationType::UninitializedThis) => { @@ -55,9 +49,11 @@ pub(super) fn invoke( &initialized(class), ); } - Some(VerificationType::Uninitialized(_)) => { + // Only the receiver's own allocation site: an enclosing `new` + // whose `` has not run yet must stay uninitialized. + Some(VerificationType::Uninitialized(offset)) => { let fresh = VerificationType::Object(owner.to_string()); - replace_uninit(state, &fresh); + replace(state, &VerificationType::Uninitialized(offset), &fresh); } _ => {} } @@ -68,12 +64,12 @@ pub(super) fn invoke( Ok(()) } -/// Число слотов всех параметров. +/// Slot count across all parameters. pub(super) fn count_slots(params: &[Vec]) -> usize { params.iter().map(Vec::len).sum() } -/// Заменить все вхождения типа новым (инициализация через ``). +/// Replace every occurrence of a type with a new one (init via ``). fn replace(state: &mut State, from: &VerificationType, to: &VerificationType) { for slot in state.locals.iter_mut().chain(state.stack.iter_mut()) { if slot == from { @@ -82,15 +78,6 @@ fn replace(state: &mut State, from: &VerificationType, to: &VerificationType) { } } -/// Любой `Uninitialized` (кроме `this`) → тип владельца (созданный `new`). -fn replace_uninit(state: &mut State, to: &VerificationType) { - for slot in state.locals.iter_mut().chain(state.stack.iter_mut()) { - if matches!(slot, VerificationType::Uninitialized(_)) { - *slot = to.clone(); - } - } -} - fn initialized(class: &str) -> VerificationType { VerificationType::Object(class.to_string()) } @@ -115,7 +102,7 @@ pub(super) fn prim_push(prim: Prim) -> Vec { } } -/// `(слоты аргумента, примитив результата)` для конверсии. +/// `(argument slots, result primitive)` for a conversion. pub(super) fn convert_shapes(op: Convert) -> (usize, Prim) { use Convert::{D2F, D2I, D2L, F2D, F2I, F2L, I2B, I2C, I2D, I2F, I2L, I2S, L2D, L2F, L2I}; use Prim::{Double, Float, Int, Long}; diff --git a/crates/noxis-classfile/src/format/frames/xfer/mod.rs b/crates/noxis-classfile/src/format/frames/xfer/mod.rs index 1814794..44279c8 100644 --- a/crates/noxis-classfile/src/format/frames/xfer/mod.rs +++ b/crates/noxis-classfile/src/format/frames/xfer/mod.rs @@ -1,8 +1,8 @@ -//! Состояние типов и transfer-функции инструкций. +//! Type states and per-instruction transfer functions. //! -//! Стек растёт вправо (`push` — в конец). `long`/`double` занимают два слота: -//! значение + `Top`. Проверяется только ФОРМА (глубина, категории), не -//! соответствие типов — неверные типы честно упадут на верификаторе JVM. +//! The stack grows right (`push` appends). `long`/`double` take two slots: +//! value + `Top`. Only the SHAPE is checked (depth, categories), not +//! type agreement - wrong types honestly fail on the JVM verifier. pub(super) mod calls; pub(super) mod stack; @@ -15,14 +15,14 @@ use crate::insn::{Arith, ArrayKind, BranchCond, Insn, SlotKind}; use calls::{const_slots, context, convert_shapes, count_slots, invoke, prim_push, prim_slots}; use stack::{dup, dup2, local_get, pop_slots, push, store_local, wide_push}; -/// Типы locals + operand stack в одной точке программы. +/// Local types plus the operand stack at one program point. #[derive(Debug, Clone, Default, PartialEq)] pub(super) struct State { pub locals: Vec, pub stack: Vec, } -/// Применить инструкцию к состоянию (на месте). +/// Apply one instruction to the state (in place). pub(super) fn transfer( class: &str, method: &str, @@ -31,7 +31,11 @@ pub(super) fn transfer( state: &mut State, ) -> Result<(), ClassfileError> { match insn { - Insn::Mark(label) => Err(invalid(class, method, &format!("маркер {label} не снят"))), + Insn::Mark(label) => Err(invalid( + class, + method, + &format!("marker {label} was not stripped"), + )), Insn::Nop => Ok(()), Insn::AconstNull => push(state, vec![VerificationType::Null]), Insn::Const(value) => { @@ -51,29 +55,29 @@ pub(super) fn transfer( Insn::Iinc { index, .. } => { let current = local_get(state, *index, class, method)?; if current != VerificationType::Integer { - return Err(invalid(class, method, "iinc не по int")); + return Err(invalid(class, method, "iinc over a non-int")); } Ok(()) } Insn::ArrayLoad(kind) => { - pop_slots(state, 1, class, method, "индекс массива")?; - let array = pop_slots(state, 1, class, method, "массив")?.remove(0); - push(state, vec![array_element(&array, *kind)]) + pop_slots(state, 1, class, method, "array index")?; + let array = pop_slots(state, 1, class, method, "array")?.remove(0); + push(state, wide_push(array_element(&array, *kind))) } Insn::ArrayStore(kind) => { let count = match kind { ArrayKind::Long | ArrayKind::Double => 2, _ => 1, }; - pop_slots(state, count, class, method, "значение")?; - pop_slots(state, 1, class, method, "индекс массива")?; - pop_slots(state, 1, class, method, "массив")?; + pop_slots(state, count, class, method, "value")?; + pop_slots(state, 1, class, method, "array index")?; + pop_slots(state, 1, class, method, "array")?; Ok(()) } Insn::Pop => { let top = pop_slots(state, 1, class, method, "pop")?.remove(0); if top == VerificationType::Top { - return Err(invalid(class, method, "pop половины long/double")); + return Err(invalid(class, method, "pop of a long/double half")); } Ok(()) } @@ -90,7 +94,7 @@ pub(super) fn transfer( Insn::Swap => { let mut taken = pop_slots(state, 2, class, method, "swap")?; if taken.contains(&VerificationType::Top) { - return Err(invalid(class, method, "swap с половиной")); + return Err(invalid(class, method, "swap with a half")); } taken.reverse(); push(state, taken) @@ -100,7 +104,7 @@ pub(super) fn transfer( if *op == Arith::Neg { pop_slots(state, slots as usize, class, method, "neg")?; } else if matches!(op, Arith::Shl | Arith::Shr | Arith::Ushr) { - // Сдвиг берёт (значение, int), а не два значения. + // A shift takes (value, int), not two values. pop_slots(state, slots as usize + 1, class, method, "shift")?; } else { pop_slots(state, 2 * slots as usize, class, method, "arith")?; @@ -128,7 +132,7 @@ pub(super) fn transfer( Insn::Jsr(_) | Insn::Ret(_) => Err(invalid( class, method, - "подпрограммы jsr/ret не поддерживаются", + "jsr/ret subroutines are not supported", )), Insn::TableSwitch { .. } | Insn::LookupSwitch { .. } => { pop_slots(state, 1, class, method, "switch")?; @@ -192,7 +196,7 @@ pub(super) fn transfer( ArrayKind::Int => "[I", ArrayKind::Long => "[J", ArrayKind::Reference => { - return Err(invalid(class, method, "newarray ссылочного типа")); + return Err(invalid(class, method, "newarray of a reference type")); } }; push( @@ -202,7 +206,16 @@ pub(super) fn transfer( } Insn::ANewArray(name) => { pop_slots(state, 1, class, method, "anewarray")?; - push(state, vec![VerificationType::Object(format!("[L{name};"))]) + // `anewarray` adds one dimension: a plain class becomes + // `[L...;`, an already-array operand (`anewarray [B` for a + // partially bound multi-dim) gains one more `[`. Wrapping an + // array operand in `L...;` (`[L[B;`) is not a class. + let descriptor = if name.starts_with('[') { + format!("[{name}") + } else { + format!("[L{name};") + }; + push(state, vec![VerificationType::Object(descriptor)]) } Insn::MultiANewArray { descriptor, dims } => { pop_slots(state, *dims as usize, class, method, "multianewarray")?; diff --git a/crates/noxis-classfile/src/format/frames/xfer/stack.rs b/crates/noxis-classfile/src/format/frames/xfer/stack.rs index 0481536..f9c6744 100644 --- a/crates/noxis-classfile/src/format/frames/xfer/stack.rs +++ b/crates/noxis-classfile/src/format/frames/xfer/stack.rs @@ -5,7 +5,7 @@ use super::State; use crate::error::ClassfileError; use crate::insn::SlotKind; -/// Запись в local с учётом ширины (`long`/`double` — парой с `Top`). +/// Запись в local с учётом ширины (`long`/`double` - парой с `Top`). pub(super) fn store_local( state: &mut State, slot: u16, @@ -38,7 +38,7 @@ pub(super) fn push(state: &mut State, slots: Vec) -> Result<() Ok(()) } -/// Снять `n` слотов (снизу вверх); нехватка — честная ошибка. +/// Снять `n` слотов (снизу вверх); нехватка - честная ошибка. pub(super) fn pop_slots( state: &mut State, n: usize, @@ -84,7 +84,7 @@ pub(super) fn local_set( Ok(()) } -/// Значение local'а целиком: `long`/`double` — парой с `Top`. +/// Значение local'а целиком: `long`/`double` - парой с `Top`. pub(super) fn wide_push(value: VerificationType) -> Vec { match value { VerificationType::Long | VerificationType::Double => vec![value, VerificationType::Top], @@ -112,7 +112,7 @@ pub(super) fn dup( push(state, out) } -/// `dup2*`: форма выбирается по вершине (`Top` — вторая половина `long`). +/// `dup2*`: форма выбирается по вершине (`Top` - вторая половина `long`). pub(super) fn dup2( state: &mut State, class: &str, diff --git a/crates/noxis-classfile/src/format/parse/attrs.rs b/crates/noxis-classfile/src/format/parse/attrs.rs index 361e97c..beaf2a0 100644 --- a/crates/noxis-classfile/src/format/parse/attrs.rs +++ b/crates/noxis-classfile/src/format/parse/attrs.rs @@ -3,7 +3,7 @@ //! `BootstrapMethods`, `EnclosingMethod`, `MethodParameters` and `ConstantValue` //! are modeled explicitly (they hold pool references that survive rebuilding); //! everything else that is structured but unneeded by us goes to `Raw` as-is. -//! `Code` and `StackMapTable` are not handled here — the calling method code +//! `Code` and `StackMapTable` are not handled here - the calling method code //! parses them (see `super::class`). use ristretto_classfile::ConstantPool; @@ -131,7 +131,7 @@ fn inner( }) } -/// The enclosing class's method (`0` — class only, no method). +/// The enclosing class's method (`0` - class only, no method). fn enclosing_method( pool: &ConstantPool<'_>, index: u16, diff --git a/crates/noxis-classfile/src/format/parse/code/frames.rs b/crates/noxis-classfile/src/format/parse/code/frames.rs index 3296d9f..422e8fe 100644 --- a/crates/noxis-classfile/src/format/parse/code/frames.rs +++ b/crates/noxis-classfile/src/format/parse/code/frames.rs @@ -11,7 +11,7 @@ use super::super::pool::class_name; use crate::error::ClassfileError; use crate::format::{StackMapFrame, VerificationType}; -/// Backend frames → ours (index deltas — as-is). +/// Backend frames → ours (index deltas - as-is). pub(super) fn to_frames( frames: &[StackFrame], pool: &ConstantPool<'_>, @@ -100,7 +100,7 @@ fn vtype(value: &RtType, pool: &ConstantPool<'_>) -> Result, index: u16, diff --git a/crates/noxis-classfile/src/format/parse/code/labels.rs b/crates/noxis-classfile/src/format/parse/code/labels.rs index 5048591..f877044 100644 --- a/crates/noxis-classfile/src/format/parse/code/labels.rs +++ b/crates/noxis-classfile/src/format/parse/code/labels.rs @@ -1,6 +1,6 @@ //! Method-body labels: where they must stand and how they resolve. //! -//! The backend numbers instructions in order — labels are placed directly on the +//! The backend numbers instructions in order - labels are placed directly on the //! numbers, without byte offsets. use std::collections::{BTreeMap, BTreeSet}; @@ -13,7 +13,7 @@ use crate::insn::Label; /// Instruction numbers that must carry a label. /// -/// `extra` — debug-table positions (already as numbers): pass insertions must +/// `extra` - debug-table positions (already as numbers): pass insertions must /// not leave them without a label. pub(super) fn label_indexes( code: &[Instruction], diff --git a/crates/noxis-classfile/src/format/parse/code/mod.rs b/crates/noxis-classfile/src/format/parse/code/mod.rs index ed4efe9..f0e472f 100644 --- a/crates/noxis-classfile/src/format/parse/code/mod.rs +++ b/crates/noxis-classfile/src/format/parse/code/mod.rs @@ -1,6 +1,6 @@ //! Backend `Code` → our IR method body: labels, exceptions, frames. //! -//! The backend already turned byte offsets into instruction indices — the +//! The backend already turned byte offsets into instruction indices - the //! converters work directly with them: labels are placed on branch targets, //! try-block boundaries and debug-table positions; frames are carried over as-is //! (index deltas). `LineNumberTable`/`LocalVariableTable` positions are labels diff --git a/crates/noxis-classfile/src/format/parse/insns/basic.rs b/crates/noxis-classfile/src/format/parse/insns/basic.rs index 5c19653..eb1d91a 100644 --- a/crates/noxis-classfile/src/format/parse/insns/basic.rs +++ b/crates/noxis-classfile/src/format/parse/insns/basic.rs @@ -7,7 +7,7 @@ use super::super::pool::const_from; use crate::error::ClassfileError; use crate::insn::{Arith, ArrayKind, Const, Convert, Insn, Prim, SlotKind}; -/// `Some` — the instruction is covered; `None` — hand it to the next converters. +/// `Some` - the instruction is covered; `None` - hand it to the next converters. pub(super) fn convert_basic( instruction: &Instruction, pool: &ConstantPool<'_>, diff --git a/crates/noxis-classfile/src/format/parse/insns/flow.rs b/crates/noxis-classfile/src/format/parse/insns/flow.rs index 6eb8a1a..7d3b65b 100644 --- a/crates/noxis-classfile/src/format/parse/insns/flow.rs +++ b/crates/noxis-classfile/src/format/parse/insns/flow.rs @@ -6,7 +6,7 @@ use super::Resolver; use crate::error::ClassfileError; use crate::insn::{BranchCond, Cond, Insn, Label, Prim}; -/// `Some` — the instruction is covered; `None` — hand it to the next converters. +/// `Some` - the instruction is covered; `None` - hand it to the next converters. pub(super) fn convert_flow( instruction: &Instruction, index: u16, @@ -52,7 +52,7 @@ fn branch(cond: BranchCond, target: u16, resolve: &Resolver<'_>) -> Result) -> Result { let absolute = i32::from(index) .checked_add(delta) diff --git a/crates/noxis-classfile/src/format/parse/insns/mod.rs b/crates/noxis-classfile/src/format/parse/insns/mod.rs index da1c923..c5291f6 100644 --- a/crates/noxis-classfile/src/format/parse/insns/mod.rs +++ b/crates/noxis-classfile/src/format/parse/insns/mod.rs @@ -2,7 +2,7 @@ //! //! Branch offsets come from the backend (absolute for `if*`/`goto`, relative to //! the instruction start for `switch`), so conversion takes a resolver from an -//! offset to a [`Label`](crate::insn::Label) — the calling code assigns labels. +//! offset to a [`Label`](crate::insn::Label) - the calling code assigns labels. mod basic; mod flow; @@ -17,7 +17,7 @@ use crate::insn::{Insn, Label}; /// Resolves an absolute bytecode offset into a label. pub(super) type Resolver<'a> = dyn Fn(u16) -> Result + 'a; -/// Convert one instruction. `index` — its number in the method body (`switch` needs it). +/// Convert one instruction. `index` - its number in the method body (`switch` needs it). pub(super) fn convert( instruction: &Instruction, index: u16, diff --git a/crates/noxis-classfile/src/format/parse/insns/refs.rs b/crates/noxis-classfile/src/format/parse/insns/refs.rs index 3fb89ed..f5623fe 100644 --- a/crates/noxis-classfile/src/format/parse/insns/refs.rs +++ b/crates/noxis-classfile/src/format/parse/insns/refs.rs @@ -7,7 +7,7 @@ use super::super::pool::{class_name, dynamic_parts, expect_kind, is_interface, r use crate::error::ClassfileError; use crate::insn::{ArrayKind, Insn, InvokeKind}; -/// `Some` — the instruction is covered; `None` — hand it to the next converters. +/// `Some` - the instruction is covered; `None` - hand it to the next converters. pub(super) fn convert_ref( instruction: &Instruction, pool: &ConstantPool<'_>, diff --git a/crates/noxis-classfile/src/format/write/attrs.rs b/crates/noxis-classfile/src/format/write/attrs.rs index 865265d..fe9319f 100644 --- a/crates/noxis-classfile/src/format/write/attrs.rs +++ b/crates/noxis-classfile/src/format/write/attrs.rs @@ -1,6 +1,6 @@ //! Our IR attributes → backend attributes. //! -//! Attribute names (`SourceFile`, …) are interned into the pool right here — the +//! Attribute names (`SourceFile`, …) are interned into the pool right here - the //! backend stores only `name_index`, and the string is recovered on read. use ristretto_classfile::attributes::{ diff --git a/crates/noxis-classfile/src/format/write/code/frames.rs b/crates/noxis-classfile/src/format/write/code/frames.rs index 6371da0..f72b79b 100644 --- a/crates/noxis-classfile/src/format/write/code/frames.rs +++ b/crates/noxis-classfile/src/format/write/code/frames.rs @@ -2,7 +2,7 @@ //! //! Our IR stores `offset_delta` in instruction numbers; the backend recomputes //! them into bytes on `to_bytes`. The compact form (`Same`/`SameLocals`) is valid -//! only when the **byte** delta is ≤ 63 — otherwise the extended form is needed. +//! only when the **byte** delta is ≤ 63 - otherwise the extended form is needed. //! We choose by the byte delta: bytes are computed from the current method's layout. use ristretto_classfile::attributes::{StackFrame as RtFrame, VerificationType as RtType}; @@ -14,7 +14,7 @@ use crate::format::{StackMapFrame, VerificationType}; /// The byte offset of an instruction's start (for computing frame deltas). type ByteOf<'a> = &'a (dyn Fn(u32) -> Result + 'a); -/// IR frames → backend frames (index deltas — as-is). +/// IR frames → backend frames (index deltas - as-is). pub(super) fn convert_frames( frames: &[StackMapFrame], pool: &mut Pool, diff --git a/crates/noxis-classfile/src/format/write/code/insns.rs b/crates/noxis-classfile/src/format/write/code/insns.rs index 094f0af..50748d1 100644 --- a/crates/noxis-classfile/src/format/write/code/insns.rs +++ b/crates/noxis-classfile/src/format/write/code/insns.rs @@ -1,7 +1,7 @@ //! Our [`Insn`](crate::insn::Insn) → the backend's `Instruction`. //! //! The reverse side of `super::super::parse::insns`: labels resolve into -//! absolute instruction numbers (`switch` — into deltas from itself), constants +//! absolute instruction numbers (`switch` - into deltas from itself), constants //! and references go to the pool via [`Pool`](super::super::pool::Pool). use indexmap::IndexMap; @@ -13,9 +13,9 @@ use super::{branch, delta, fit_index, ret_op}; use crate::error::ClassfileError; use crate::insn::{Insn, Label, SlotKind}; -/// One IR instruction → a backend instruction (`None` — a label marker). +/// One IR instruction → a backend instruction (`None` - a label marker). /// -/// `index` — the instruction's number in the method (needed for `switch` deltas). +/// `index` - the instruction's number in the method (needed for `switch` deltas). pub(crate) fn convert( insn: &Insn, index: u32, diff --git a/crates/noxis-classfile/src/format/write/code/mod.rs b/crates/noxis-classfile/src/format/write/code/mod.rs index 7bfcdcd..bf7b849 100644 --- a/crates/noxis-classfile/src/format/write/code/mod.rs +++ b/crates/noxis-classfile/src/format/write/code/mod.rs @@ -132,7 +132,7 @@ pub(crate) fn measure(insns: &[RtInsn]) -> Result, ClassfileE Ok(map) } -/// Branch targets don't affect size — substitute the current position for sizing. +/// Branch targets don't affect size - substitute the current position for sizing. fn smash_targets(insn: &mut RtInsn, position: u16) { match insn { RtInsn::Ifeq(target) diff --git a/crates/noxis-classfile/src/format/write/code/ops.rs b/crates/noxis-classfile/src/format/write/code/ops.rs index c89a6e4..ab302d6 100644 --- a/crates/noxis-classfile/src/format/write/code/ops.rs +++ b/crates/noxis-classfile/src/format/write/code/ops.rs @@ -229,7 +229,7 @@ fn arg_count(descriptor: &str) -> Result { .map_err(|_| ClassfileError::Write(format!("слишком много аргументов в {descriptor}"))) } -/// Skip characters up to the separator (inclusive); `false` — the string ended. +/// Skip characters up to the separator (inclusive); `false` - the string ended. fn skip_to(chars: &mut std::str::Chars<'_>, end: char) -> bool { for char in chars { if char == end { diff --git a/crates/noxis-classfile/src/format/write/helpers.rs b/crates/noxis-classfile/src/format/write/helpers.rs index 5bac30f..5768a7e 100644 --- a/crates/noxis-classfile/src/format/write/helpers.rs +++ b/crates/noxis-classfile/src/format/write/helpers.rs @@ -1,7 +1,7 @@ //! Helpers building `InnerClasses`/`BootstrapMethods`/`MethodParameters`. //! //! Moved out of `attrs` so it stays within the 250-line limit; the logic is -//! unchanged — only the location. Each builds a backend entry from an IR +//! unchanged - only the location. Each builds a backend entry from an IR //! element, interning names/descriptors and resolving `CONSTANT_Class` indices //! through the pool. diff --git a/crates/noxis-classfile/src/format/write/pool/mod.rs b/crates/noxis-classfile/src/format/write/pool/mod.rs index 07d99c7..2900d0e 100644 --- a/crates/noxis-classfile/src/format/write/pool/mod.rs +++ b/crates/noxis-classfile/src/format/write/pool/mod.rs @@ -2,10 +2,10 @@ //! //! The pool is built from scratch by a deterministic walk (classes, fields, //! methods, attributes, code), so identical IR yields identical bytes. `Raw` -//! attribute indices are not carried over — see `super::attrs`. +//! attribute indices are not carried over - see `super::attrs`. //! //! The backend does not deduplicate: `add_*` always pushes a new entry. We cache -//! semantic keys ourselves — otherwise `LocalVariableTable` and +//! semantic keys ourselves - otherwise `LocalVariableTable` and //! `LocalVariableTypeTable` would get different `name_index` values for the same //! name, and HotSpot would reject the class (`LVTT entry ... does not match any //! LVT entry`). The walk order is deterministic, so the cache only changes pool diff --git a/crates/noxis-classfile/src/format/write/pool/refs.rs b/crates/noxis-classfile/src/format/write/pool/refs.rs index 5e5adaa..5cb44f3 100644 --- a/crates/noxis-classfile/src/format/write/pool/refs.rs +++ b/crates/noxis-classfile/src/format/write/pool/refs.rs @@ -2,7 +2,7 @@ //! //! This is an `impl Pool` from [`super`], split out so the parent file stays //! within the structure limit. Each builder is memoized by a semantic key -//! (owner/name/descriptor/…) — an identical reference always gets the same index, +//! (owner/name/descriptor/…) - an identical reference always gets the same index, //! and the pool doesn't bloat with duplicates (bloat would otherwise widen `ldc` //! into `ldc_w` and change code length on relocation). diff --git a/crates/noxis-classfile/src/insn/kinds.rs b/crates/noxis-classfile/src/insn/kinds.rs index 851e7a0..6c1a742 100644 --- a/crates/noxis-classfile/src/insn/kinds.rs +++ b/crates/noxis-classfile/src/insn/kinds.rs @@ -88,7 +88,7 @@ pub enum InvokeKind { Interface, } -/// Type conversion (`i2l`, `f2b`, ... — JVMS §6.5.i2). +/// Type conversion (`i2l`, `f2b`, ... - JVMS §6.5.i2). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Convert { I2L, diff --git a/crates/noxis-classfile/src/insn/mod.rs b/crates/noxis-classfile/src/insn/mod.rs index ab0543f..8f77d97 100644 --- a/crates/noxis-classfile/src/insn/mod.rs +++ b/crates/noxis-classfile/src/insn/mod.rs @@ -1,7 +1,7 @@ //! IR instructions: no offsets, branches address a [`Label`]. //! //! This is fundamental: any pass that changes instruction order/length need not -//! recompute jump offsets — the writer does that once at the end of the pipeline. +//! recompute jump offsets - the writer does that once at the end of the pipeline. mod kinds; @@ -27,7 +27,7 @@ pub enum Insn { slot: u16, kind: SlotKind, }, - /// `iinc` — increase a local variable by a constant. + /// `iinc` - increase a local variable by a constant. Iinc { index: u16, delta: i32, @@ -45,7 +45,7 @@ pub enum Insn { Dup2X1, Dup2X2, Swap, - /// `iadd`, `lmul`, `lneg`, ... — type + operation. + /// `iadd`, `lmul`, `lneg`, ... - type + operation. Binary { prim: Prim, op: Arith, @@ -59,9 +59,9 @@ pub enum Insn { Convert(Convert), Branch(BranchCond, Label), Goto(Label), - /// `jsr` — subroutine call (only version ≤ 50 classes). + /// `jsr` - subroutine call (only version ≤ 50 classes). Jsr(Label), - /// `ret` — return from a subroutine (`jsr`/`ret` come in pairs). + /// `ret` - return from a subroutine (`jsr`/`ret` come in pairs). Ret(u16), TableSwitch { default: Label, @@ -73,9 +73,9 @@ pub enum Insn { default: Label, pairs: Vec<(i32, Label)>, }, - /// `return`/`ireturn`/...; `None` — void return. + /// `return`/`ireturn`/...; `None` - void return. Return(Option), - /// `areturn` — return a reference ([`Prim`] has no reference type). + /// `areturn` - return a reference ([`Prim`] has no reference type). AReturn, GetStatic { owner: String, @@ -112,7 +112,7 @@ pub enum Insn { New(String), NewArray(ArrayKind), ANewArray(String), - /// `multianewarray` — array of any dimension; `dims` — how many dimensions from the stack. + /// `multianewarray` - array of any dimension; `dims` - how many dimensions from the stack. MultiANewArray { descriptor: String, dims: u8, diff --git a/crates/noxis-classfile/src/lib.rs b/crates/noxis-classfile/src/lib.rs index 423e991..c5d9cbd 100644 --- a/crates/noxis-classfile/src/lib.rs +++ b/crates/noxis-classfile/src/lib.rs @@ -27,3 +27,11 @@ pub use model::{ LocalVariable, LocalVariableType, Method, MethodParameter, Program, RecordComponent, Resource, Version, }; + +/// Safety budget: a method body that would grow past this many instructions +/// is left alone by the expanding passes (`strings`, `constants`). +/// +/// The writer caps a method body at 64KB; at a pessimistic 8 bytes per +/// instruction this count can never overflow it, while real methods +/// (hundreds of instructions) never come close. +pub const MAX_METHOD_INSNS: usize = 8_000; diff --git a/crates/noxis-classfile/src/model/builder.rs b/crates/noxis-classfile/src/model/builder.rs index 3212ef7..63f30b5 100644 --- a/crates/noxis-classfile/src/model/builder.rs +++ b/crates/noxis-classfile/src/model/builder.rs @@ -17,7 +17,7 @@ pub struct ClassBuilder { impl ClassBuilder { /// A new `public` class with the given superclass (`None` = `java/lang/Object`, - /// except for `java/lang/Object` itself — it has no superclass). + /// except for `java/lang/Object` itself - it has no superclass). pub fn new(this_class: impl Into, super_class: Option<&str>) -> Self { let this_class = this_class.into(); let super_class = match super_class { diff --git a/crates/noxis-classfile/src/model/mod.rs b/crates/noxis-classfile/src/model/mod.rs index 6c3263a..fea3184 100644 --- a/crates/noxis-classfile/src/model/mod.rs +++ b/crates/noxis-classfile/src/model/mod.rs @@ -1,4 +1,4 @@ -//! The "program" structure — the unit of work for the pipeline. +//! The "program" structure - the unit of work for the pipeline. pub mod builder; pub mod version; @@ -8,7 +8,7 @@ pub use version::Version; use crate::format::StackMapFrame; use crate::insn::{Const, Insn, Label}; -/// Unknown/specific attributes are stored as bytes — lossless round-trip. +/// Unknown/specific attributes are stored as bytes - lossless round-trip. #[derive(Debug, Clone, PartialEq)] pub enum Attribute { SourceFile(String), @@ -32,7 +32,7 @@ pub enum Attribute { class: String, method: Option<(String, String)>, }, - /// `MethodParameters`: formal parameter names (`None` — unnamed). + /// `MethodParameters`: formal parameter names (`None` - unnamed). MethodParameters(Vec), /// `Record`: the record class's components. Record(Vec), @@ -47,7 +47,7 @@ pub enum Attribute { #[derive(Debug, Clone, Copy, PartialEq)] pub struct LineNumber { - /// Start label (the writer computes bytes — survives pass insertions). + /// Start label (the writer computes bytes - survives pass insertions). pub start: Label, pub line: u16, } @@ -159,7 +159,7 @@ impl Code { #[derive(Debug, Clone, PartialEq)] pub struct ExceptionHandler { - /// `None` — catch-all (`finally`). + /// `None` - catch-all (`finally`). pub start: Label, pub end: Label, pub handler: Label, diff --git a/crates/noxis-classfile/tests/frames.rs b/crates/noxis-classfile/tests/frames.rs index b1f9281..5920f08 100644 --- a/crates/noxis-classfile/tests/frames.rs +++ b/crates/noxis-classfile/tests/frames.rs @@ -3,7 +3,7 @@ //! Frames are dropped and recomputed. Judges: determinism + IR round-trip + //! real `java -Xverify:all` for classes without external dependencies. //! (The backend's `verify_class` is unfit for acceptance: it expects frames -//! in bytes while `from_bytes` hands out indices — see `todo.md`.) +//! in bytes while `from_bytes` hands out indices - see `todo.md`.) #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -28,7 +28,7 @@ impl Hierarchy for MapHierarchy { } } -/// JDK-only classes — `java` checks them fully. +/// JDK-only classes - `java` checks them fully. const JDK_ONLY: [&str; 2] = [ "dev/loki/lorep/util/TimeUtil", "dev/loki/lorep/util/PaginationUtil", @@ -95,7 +95,7 @@ fn fixtures_recompute_and_verify() { /// A join with a `double` below on the stack must emit it as ONE entry. /// /// Regression: the emitter used to write the slot pair (`[Double, Top]`), -/// like `javac` does for locals — but on the operand stack `javac` writes a +/// like `javac` does for locals - but on the operand stack `javac` writes a /// bare `[Double]`, and HotSpot rejects the pair with /// `Inconsistent stackmap frames`. #[test] diff --git a/crates/noxis-classfile/tests/pool/main.rs b/crates/noxis-classfile/tests/pool/main.rs index aef55f1..3f40d22 100644 --- a/crates/noxis-classfile/tests/pool/main.rs +++ b/crates/noxis-classfile/tests/pool/main.rs @@ -4,7 +4,7 @@ //! сопоставляет запись `LocalVariableTypeTable` с `LocalVariableTable` по //! сырому `name_index`. Дублирующий `Utf8` для одного имени даёт разные индексы //! и рушит класс: `ClassFormatError: LVTT entry ... does not match any LVT -//! entry`. Писатель обязан интернировать строки — поэтому в записанном пуле +//! entry`. Писатель обязан интернировать строки - поэтому в записанном пуле //! каждый `CONSTANT_Utf8` уникален. #![allow(clippy::expect_used, clippy::unwrap_used)] diff --git a/crates/noxis-classfile/tests/roundtrip.rs b/crates/noxis-classfile/tests/roundtrip.rs index bce5a28..ed813ae 100644 --- a/crates/noxis-classfile/tests/roundtrip.rs +++ b/crates/noxis-classfile/tests/roundtrip.rs @@ -2,7 +2,7 @@ //! //! Пул при записи строится заново (порядок другой), поэтому сравнивается IR, //! а не байты. Записанные байты обязаны проходить структурный `verify` -//! backend'а — это ловит битые ссылки пула и кривые атрибуты. +//! backend'а - это ловит битые ссылки пула и кривые атрибуты. #![allow(clippy::expect_used, clippy::unwrap_used)] diff --git a/crates/noxis-classfile/tests/spike_frames.rs b/crates/noxis-classfile/tests/spike_frames.rs index c5d1c5b..3fa378e 100644 --- a/crates/noxis-classfile/tests/spike_frames.rs +++ b/crates/noxis-classfile/tests/spike_frames.rs @@ -1,8 +1,8 @@ //! Spike, часть 2: доступна ли **генерация** кадров `StackMapTable`. //! //! Ключевой вопрос `docs/stack.md` §3: считает ли `ristretto_classfile` кадры -//! сам и отдаёт ли их наружу. Если да — наша работа сводится к записи; если -//! нет — кадры придётся считать (или вендорить их `verifiers::bytecode`). +//! сам и отдаёт ли их наружу. Если да - наша работа сводится к записи; если +//! нет - кадры придётся считать (или вендорить их `verifiers::bytecode`). #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -82,7 +82,7 @@ fn inference_config() -> VerifierConfig { VerifierConfig::default().with_fallback_strategy(FallbackStrategy::AlwaysInference) } -/// Без `StackMapTable` inference обязан пройти сам — значит, движок кадров у +/// Без `StackMapTable` inference обязан пройти сам - значит, движок кадров у /// крейта есть, просто его результат не отдаётся наружу. #[test] fn inference_path_verifies_without_stack_map_table() { @@ -94,7 +94,7 @@ fn inference_path_verifies_without_stack_map_table() { assert_eq!(results[0].path_used, VerificationPath::Inference); } -/// Строгий режим без кадров обязан упасть — иначе требование `StackMapTable` +/// Строгий режим без кадров обязан упасть - иначе требование `StackMapTable` /// для классов ≥ 50 не проверяется вовсе. #[test] fn strict_mode_rejects_missing_stack_map_table() { @@ -102,7 +102,7 @@ fn strict_mode_rejects_missing_stack_map_table() { let config = VerifierConfig::default().with_fallback_strategy(FallbackStrategy::Strict); let outcome = verify_class(&class_file, &Permissive, &config); - // Строгий режим падает либо `Err`, либо `success == false` — оба варианта + // Строгий режим падает либо `Err`, либо `success == false` - оба варианта // приемлемы, важен сам отказ. let rejected = match &outcome { Err(_) => true, @@ -113,13 +113,13 @@ fn strict_mode_rejects_missing_stack_map_table() { /// Негативный результат spike'а: кадр не на том сдвиге не отдаёт диагностику. /// -/// Ждали `Ok(vec![result])` с заполненным `diagnostic.expected` — тогда +/// Ждали `Ok(vec![result])` с заполненным `diagnostic.expected` - тогда /// генерация кадров шла бы итеративным путём через публичный API. Фактически /// `verify_class` в строгом режиме возвращает `Err(VerifyError)`, и ожидаемый /// кадр до нас не доходит: из крейта достаётся только место ошибки /// («merge point at offset N»), но не её содержимое. /// -/// Вывод для `docs/stack.md` §3: проверять кадры крейт умеет, отдавать — нет. +/// Вывод для `docs/stack.md` §3: проверять кадры крейт умеет, отдавать - нет. #[test] fn diagnostic_exposes_expected_frame_on_failure() { let mut class_file = branch_class(); @@ -127,7 +127,7 @@ fn diagnostic_exposes_expected_frame_on_failure() { .constant_pool .add_utf8("StackMapTable") .expect("utf8"); - // Кадр на сдвиге 0, а настоящий якорь — на сдвиге 3. + // Кадр на сдвиге 0, а настоящий якорь - на сдвиге 3. class_file.attributes.push(Attribute::StackMapTable { name_index, frames: vec![StackFrame::SameFrame { frame_type: 0 }], diff --git a/crates/noxis-classfile/tests/spike_ristretto.rs b/crates/noxis-classfile/tests/spike_ristretto.rs index 851e4b0..ead8cfd 100644 --- a/crates/noxis-classfile/tests/spike_ristretto.rs +++ b/crates/noxis-classfile/tests/spike_ristretto.rs @@ -1,6 +1,6 @@ //! Spike: пригоден ли `ristretto_classfile` как backend для чтения/записи. //! -//! Критерии — `docs/stack.md` §2. Тест существует, чтобы ответы на вопросы +//! Критерии - `docs/stack.md` §2. Тест существует, чтобы ответы на вопросы //! spike'а не устаревали: если крейт сломает хотя бы один пункт, сборка упадёт, //! и решение о backend'е придётся пересмотреть. //! @@ -59,7 +59,7 @@ fn round_trip_preserves_identity() { assert_eq!(restored.constant_pool.len(), source.constant_pool.len()); } -/// Кадры `StackMapTable` конструируются и сериализуются публичным API — +/// Кадры `StackMapTable` конструируются и сериализуются публичным API - /// значит, мы можем **записывать** кадры, а не только читать. #[test] fn stack_map_frames_are_constructible() { diff --git a/crates/noxis-core/src/config/mod.rs b/crates/noxis-core/src/config/mod.rs index 97d7668..2bfa2b3 100644 --- a/crates/noxis-core/src/config/mod.rs +++ b/crates/noxis-core/src/config/mod.rs @@ -61,7 +61,7 @@ pub enum Profile { Standard, /// Всё включено, включая агрессивный control-flow. Paranoid, - /// Ничего не включено явно — только `enable` из конфига. + /// Ничего не включено явно - только `enable` из конфига. Manual, } @@ -97,7 +97,7 @@ impl Profile { #[serde(default, deny_unknown_fields)] pub struct Config { pub profile: Profile, - /// Мастер-сид; `None` — случайный, но обязательно попадает в отчёт. + /// Мастер-сид; `None` - случайный, но обязательно попадает в отчёт. pub seed: Option, /// Принудительно включить пассы вне профиля. pub enable: Vec, diff --git a/crates/noxis-core/src/config/sections.rs b/crates/noxis-core/src/config/sections.rs index 072ea01..d50c280 100644 --- a/crates/noxis-core/src/config/sections.rs +++ b/crates/noxis-core/src/config/sections.rs @@ -6,9 +6,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum NameStyle { - /// `a`, `b`, ... `aa` — short Latin. + /// `a`, `b`, ... `aa` - short Latin. ShortLatin, - /// `I`, `l`, `O`, `0` — visually identical. + /// `I`, `l`, `O`, `0` - visually identical. IlO0, /// Random Unicode names (surrogates are valid in modified UTF-8). Unicode, @@ -17,7 +17,7 @@ pub enum NameStyle { MixedLatin, /// Pseudo-meaningful words from another project (misdirection). DecoyWords, - /// Plausible names: `TextureCache`, `resolveToken` — blends in as + /// Plausible names: `TextureCache`, `resolveToken` - blends in as /// ordinary application code instead of obvious noise. Plausible, } diff --git a/crates/noxis-core/src/lib.rs b/crates/noxis-core/src/lib.rs index a3bb72c..273a0a9 100644 --- a/crates/noxis-core/src/lib.rs +++ b/crates/noxis-core/src/lib.rs @@ -3,7 +3,7 @@ //! Ядро обфускатора: конфигурация, детерминированный seed, маппинги, //! контекст прогона и контракт пассов ([`pipeline::Pass`]). //! -//! Ядро ничего не знает о бинарном формате `.class` и о ZIP — только о модели +//! Ядро ничего не знает о бинарном формате `.class` и о ZIP - только о модели //! [`noxis_classfile::Program`]. Сборка конвейера из пассов живёт в //! `noxis-passes`, чтобы не было цикла зависимостей. diff --git a/crates/noxis-core/src/mapping.rs b/crates/noxis-core/src/mapping.rs index 4ceaeb8..14d93ef 100644 --- a/crates/noxis-core/src/mapping.rs +++ b/crates/noxis-core/src/mapping.rs @@ -17,7 +17,7 @@ pub fn field_key(owner: &str, name: &str, descriptor: &str) -> String { /// Таблицы «оригинал → обфусцированное». /// /// Нужна по двум причинам: (1) перевод stack trace'ей обратно в читаемые имена -/// (аналог ZKM Stack Trace Translate), (2) аудит — что именно поменялось. +/// (аналог ZKM Stack Trace Translate), (2) аудит - что именно поменялось. #[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct Mapping { /// internal name класса → новый internal name diff --git a/crates/noxis-core/src/pipeline/context.rs b/crates/noxis-core/src/pipeline/context.rs index d94aa86..4356757 100644 --- a/crates/noxis-core/src/pipeline/context.rs +++ b/crates/noxis-core/src/pipeline/context.rs @@ -31,7 +31,7 @@ impl Context { /// Детерминированный RNG для домена (`"rename"`, `"strings/0"`, ...). /// /// Доменное разделение: разные пассы не должны вытягивать одну - /// последовательность — иначе их «случайности» статистически коррелируют. + /// последовательность - иначе их «случайности» статистически коррелируют. pub fn rng(&self, domain: &str) -> Rng { Rng::from_seed(derive_seed(self.master_seed, domain, 0)) } diff --git a/crates/noxis-core/src/pipeline/mod.rs b/crates/noxis-core/src/pipeline/mod.rs index abc027b..17c3825 100644 --- a/crates/noxis-core/src/pipeline/mod.rs +++ b/crates/noxis-core/src/pipeline/mod.rs @@ -46,7 +46,7 @@ pub trait Pass: Send + Sync { /// Упорядоченная цепочка пассов. /// -/// Порядок — не деталь: см. `docs/architecture.md` (rename → resources → +/// Порядок - не деталь: см. `docs/architecture.md` (rename → resources → /// strings → constants → flow → noise → debug), иначе часть техник ломает /// предыдущие или работает по уже мёртвым именам. #[derive(Default)] @@ -88,7 +88,7 @@ impl Pipeline { for pass in &self.passes { let id = pass.id(); if !ctx.config.is_enabled(id) { - tracing::info!(target: "noxis", pass = %id, "пасс выключен — пропущен"); + tracing::info!(target: "noxis", pass = %id, "пасс выключен - пропущен"); continue; } tracing::info!(target: "noxis", pass = %id, "пасс запущен"); diff --git a/crates/noxis-core/src/pipeline/report.rs b/crates/noxis-core/src/pipeline/report.rs index fa8e634..e75180b 100644 --- a/crates/noxis-core/src/pipeline/report.rs +++ b/crates/noxis-core/src/pipeline/report.rs @@ -1,8 +1,8 @@ -//! Итог прогона: пассы + счётчики — сериализуется в JSON. +//! Итог прогона: пассы + счётчики - сериализуется в JSON. use serde::Serialize; -/// Итог прогона: пассы + счётчики — сериализуется в JSON. +/// Итог прогона: пассы + счётчики - сериализуется в JSON. #[derive(Debug, Clone, Default, Serialize)] pub struct Report { pub seed: u64, diff --git a/crates/noxis-core/src/rng.rs b/crates/noxis-core/src/rng.rs index 71fd703..7d12d94 100644 --- a/crates/noxis-core/src/rng.rs +++ b/crates/noxis-core/src/rng.rs @@ -1,7 +1,7 @@ //! Детерминированный PRNG и вывод производных seed'ов. //! //! Свой генератор, а не `rand`: обфускация должна быть **воспроизводимой между -//! версиями инструмента** — алгоритм замирает в формате, а не в изменении API +//! версиями инструмента** - алгоритм замирает в формате, а не в изменении API //! чужого крейта. Схема: SplitMix64 для инициализации + xoshiro256++ для генерации. /// Генератор xoshiro256++. @@ -97,7 +97,7 @@ impl Rng { } } -/// FNV-1a 64 — детерминированный вывод производного сида. +/// FNV-1a 64 - детерминированный вывод производного сида. /// /// Разделение доменов обязательно: пассы не должны делить одну и ту же /// последовательность (иначе переименование и шифрование «случайно» diff --git a/crates/noxis-jar/src/archive.rs b/crates/noxis-jar/src/archive.rs index adc7c38..0f24af1 100644 --- a/crates/noxis-jar/src/archive.rs +++ b/crates/noxis-jar/src/archive.rs @@ -1,6 +1,6 @@ //! Чтение и запись ZIP-архива JAR. //! -//! Единственная задача модуля — переместить записи из/в вектор [`Entry`], +//! Единственная задача модуля - переместить записи из/в вектор [`Entry`], //! сохранив порядок и не потеряв дубликатов молча. Всё, что связано с //! содержимым (`Program`, маппинги, подпись), живёт в соседних модулях. @@ -30,7 +30,7 @@ pub enum JarError { /// Одна запись архива: имя + байты. /// -/// Порядок в `[`Entry`]` — это порядок в архиве, он сохраняется при +/// Порядок в `[`Entry`]` - это порядок в архиве, он сохраняется при /// чтении и записи. Метод сжатия не хранится: всё пишется deflate'ом, /// что детерминированно и одинаково для одинаковых данных. #[derive(Debug, Clone, PartialEq, Eq)] @@ -51,7 +51,7 @@ impl Entry { /// Читает все не-каталожные записи из `reader`, сохраняя порядок. /// -/// Повторяющееся имя — ошибка, а не тихое предупреждение: битый архив +/// Повторяющееся имя - ошибка, а не тихое предупреждение: битый архив /// с двумя `META-INF/MANIFEST.MF` собрать дороже, чем отказать. pub fn read_entries(reader: R) -> Result, JarError> { let mut archive = zip::ZipArchive::new(reader)?; diff --git a/crates/noxis-jar/src/lib.rs b/crates/noxis-jar/src/lib.rs index ee799e5..417e060 100644 --- a/crates/noxis-jar/src/lib.rs +++ b/crates/noxis-jar/src/lib.rs @@ -4,13 +4,13 @@ //! //! Слой разделён на три несвязанные задачи: //! -//! * [`archive`] — чистая работа с ZIP: порядок записей, сжатие, дубликаты; -//! * [`signatures`] — срез невалидных подписей `META-INF/*.SF|RSA|DSA|EC`; -//! * [`loader`] — перевод архива в [`Program`](noxis_classfile::Program) +//! * [`archive`] - чистая работа с ZIP: порядок записей, сжатие, дубликаты; +//! * [`signatures`] - срез невалидных подписей `META-INF/*.SF|RSA|DSA|EC`; +//! * [`loader`] - перевод архива в [`Program`](noxis_classfile::Program) //! и обратно (классы через [`format`](noxis_classfile::format), остальное //! как ресурсы). //! -//! Обфускация ресурсов — пасс `resources` в `noxis-passes`; сюда она не +//! Обфускация ресурсов - пасс `resources` в `noxis-passes`; сюда она не //! входит, потому что ей нужен маппинг имён, а не I/O. pub mod archive; diff --git a/crates/noxis-jar/src/loader.rs b/crates/noxis-jar/src/loader.rs index 23d3f0c..965fe4b 100644 --- a/crates/noxis-jar/src/loader.rs +++ b/crates/noxis-jar/src/loader.rs @@ -1,9 +1,11 @@ -//! Перевод архива в [`Program`] и обратно. +//! Archive to [`Program`] and back. //! -//! Правило простое: `*.class` уходит в парсер, всё остальное — в ресурсы. -//! Обратно классы сериализуются первыми, затем ресурсы: порядок внутри -//! архива на поведение не влияет, а фиксированный порядок даёт -//! воспроизводимую запись. +//! The rule is simple: `*.class` goes to the parser, everything else - +//! to resources. Classes serialize first, then resources: the order inside +//! the archive does not affect behaviour, and a fixed order gives +//! reproducible writes. + +use std::collections::BTreeSet; use noxis_classfile::Program; use noxis_classfile::format; @@ -11,13 +13,22 @@ use noxis_classfile::model::Resource; use crate::archive::{Entry, JarError}; -/// Разбирает записи архива: классы — через парсер, остальное — как ресурсы. +/// Parses archive entries: classes - through the parser, the rest - as resources. +/// +/// A name that repeats (multi-release jars carry the same class at the root +/// and under `META-INF/versions/*`) is kept once, first occurrence wins: +/// the flat model cannot hold two classes under one name, and later passes +/// (rename maps, jar writing) assume uniqueness. pub fn load(entries: Vec) -> Result { let mut program = Program::default(); + let mut seen: BTreeSet = BTreeSet::new(); for entry in entries { if is_class_entry(&entry.name) { - program.classes.push(format::parse(&entry.data)?); + let class = format::parse(&entry.data)?; + if seen.insert(class.this_class.clone()) { + program.classes.push(class); + } } else { program.resources.push(Resource { path: entry.name, @@ -29,7 +40,7 @@ pub fn load(entries: Vec) -> Result { Ok(program) } -/// Собирает записи архива из [`Program`]: сначала классы, потом ресурсы. +/// Assembles archive entries from [`Program`]: classes first, then resources. pub fn save(program: &Program) -> Result, JarError> { let mut entries = Vec::with_capacity(program.classes.len() + program.resources.len()); @@ -44,7 +55,7 @@ pub fn save(program: &Program) -> Result, JarError> { Ok(entries) } -/// Классовый файл внутри архива: путь оканчивается на `.class`. +/// A class file inside the archive: the path ends in `.class`. fn is_class_entry(name: &str) -> bool { name.len() > ".class".len() && name.ends_with(".class") } @@ -78,7 +89,7 @@ mod tests { assert_eq!(save(&program).expect("save"), source); } - /// До P1 `format::parse` — заглушка; после P1 ожидается успешный разбор. + /// Class entries go through the parser (round-trip keeps them intact). #[test] fn class_entries_go_through_the_parser() { let entries = vec![ @@ -97,4 +108,22 @@ mod tests { Err(other) => panic!("unexpected error: {other}"), } } + + /// Multi-release twins (`demo/App.class` + `META-INF/versions/9/...`): + /// the flat model keeps the first occurrence so no two classes ever + /// share one name downstream. + #[test] + fn duplicate_class_names_keep_first() { + use noxis_classfile::ClassBuilder; + + let class = ClassBuilder::new("demo/App", None).build(); + let bytes = format::write(&class).expect("write"); + let entries = vec![ + Entry::new("demo/App.class", bytes.clone()), + Entry::new("META-INF/versions/9/demo/App.class", bytes), + ]; + let program = load(entries).expect("load"); + assert_eq!(program.classes.len(), 1); + assert_eq!(program.classes[0].this_class, "demo/App"); + } } diff --git a/crates/noxis-jar/src/signatures.rs b/crates/noxis-jar/src/signatures.rs index c68c8f5..3d0c93a 100644 --- a/crates/noxis-jar/src/signatures.rs +++ b/crates/noxis-jar/src/signatures.rs @@ -2,7 +2,7 @@ //! //! После перепаковки архива `META-INF/*.SF` и `*.RSA|DSA|EC` описывают //! **старые** байты: JVM либо отказывается запускать jar, либо ругается на -//! подпись. Вырезать их обязательно — и это делается на уровне записей, +//! подпись. Вырезать их обязательно - и это делается на уровне записей, //! потому что `MANIFEST.MF` остаётся, а его секции `Name:` тоже нужно //! выкинуть (ими занимается пасс `resources`). @@ -19,10 +19,10 @@ const META_INF: &str = "META-INF/"; /// Проверяет, является ли запись архива файлом подписи. /// -/// Совпадение — только для файлов, лежащих **непосредственно** в -/// `META-INF/`: вложенный `META-INF/certs/x.SF` — обычный ресурс. +/// Совпадение - только для файлов, лежащих **непосредственно** в +/// `META-INF/`: вложенный `META-INF/certs/x.SF` - обычный ресурс. /// -/// Регистр не важен (`meta-inf/x.sf` — тоже подпись), `MANIFEST.MF` +/// Регистр не важен (`meta-inf/x.sf` - тоже подпись), `MANIFEST.MF` /// никогда не является подписью. pub fn is_signature_entry(name: &str) -> bool { let Some(relative) = name.strip_prefix(META_INF) else { diff --git a/crates/noxis-passes/src/constants/forms.rs b/crates/noxis-passes/src/constants/forms.rs index 7403587..bb9926e 100644 --- a/crates/noxis-passes/src/constants/forms.rs +++ b/crates/noxis-passes/src/constants/forms.rs @@ -92,7 +92,7 @@ fn binary_long(first: i64, second: i64, op: Arith) -> Vec { ] } -/// `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 { vec![ Insn::Const(Const::Int(value >> 1)), @@ -137,7 +137,7 @@ fn shift_form_long(value: i64) -> Vec { pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec { 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 { 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), diff --git a/crates/noxis-passes/src/constants/mod.rs b/crates/noxis-passes/src/constants/mod.rs index e5ce159..6b8e77b 100644 --- a/crates/noxis-passes/src/constants/mod.rs +++ b/crates/noxis-passes/src/constants/mod.rs @@ -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}" )], } } diff --git a/crates/noxis-passes/src/constants/tests.rs b/crates/noxis-passes/src/constants/tests.rs index 9ec2eab..8d55d61 100644 --- a/crates/noxis-passes/src/constants/tests.rs +++ b/crates/noxis-passes/src/constants/tests.rs @@ -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); +} diff --git a/crates/noxis-passes/src/debug.rs b/crates/noxis-passes/src/debug.rs index fbecaed..a4eb0ad 100644 --- a/crates/noxis-passes/src/debug.rs +++ b/crates/noxis-passes/src/debug.rs @@ -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}; diff --git a/crates/noxis-passes/src/flow/dispatch.rs b/crates/noxis-passes/src/flow/dispatch.rs index 22ab6e6..0526f55 100644 --- a/crates/noxis-passes/src/flow/dispatch.rs +++ b/crates/noxis-passes/src/flow/dispatch.rs @@ -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 { 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 { /// 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 { 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 { diff --git a/crates/noxis-passes/src/flow/mod.rs b/crates/noxis-passes/src/flow/mod.rs index 834faa6..0b17ad5 100644 --- a/crates/noxis-passes/src/flow/mod.rs +++ b/crates/noxis-passes/src/flow/mod.rs @@ -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 == "" { 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); diff --git a/crates/noxis-passes/src/flow/rewire.rs b/crates/noxis-passes/src/flow/rewire.rs index c1fbde9..fcf4419 100644 --- a/crates/noxis-passes/src/flow/rewire.rs +++ b/crates/noxis-passes/src/flow/rewire.rs @@ -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 diff --git a/crates/noxis-passes/src/rename/assign.rs b/crates/noxis-passes/src/rename/assign.rs index abadcd1..857e8dc 100644 --- a/crates/noxis-passes/src/rename/assign.rs +++ b/crates/noxis-passes/src/rename/assign.rs @@ -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, diff --git a/crates/noxis-passes/src/rename/mod.rs b/crates/noxis-passes/src/rename/mod.rs index e65f576..bbed81b 100644 --- a/crates/noxis-passes/src/rename/mod.rs +++ b/crates/noxis-passes/src/rename/mod.rs @@ -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); } diff --git a/crates/noxis-passes/src/resources/mod.rs b/crates/noxis-passes/src/resources/mod.rs index 029c7b1..7cd97b4 100644 --- a/crates/noxis-passes/src/resources/mod.rs +++ b/crates/noxis-passes/src/resources/mod.rs @@ -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() { diff --git a/crates/noxis-passes/src/resources/tests.rs b/crates/noxis-passes/src/resources/tests.rs index 750325e..fbab3cf 100644 --- a/crates/noxis-passes/src/resources/tests.rs +++ b/crates/noxis-passes/src/resources/tests.rs @@ -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); } diff --git a/crates/noxis-passes/src/strings/crypt.rs b/crates/noxis-passes/src/strings/crypt.rs index 30650cf..7189506 100644 --- a/crates/noxis-passes/src/strings/crypt.rs +++ b/crates/noxis-passes/src/strings/crypt.rs @@ -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 { /// 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; diff --git a/crates/noxis-passes/src/strings/mod.rs b/crates/noxis-passes/src/strings/mod.rs index 78cc6d1..cbe1702 100644 --- a/crates/noxis-passes/src/strings/mod.rs +++ b/crates/noxis-passes/src/strings/mod.rs @@ -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) -> 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 { diff --git a/crates/noxis-passes/src/strings/tests.rs b/crates/noxis-passes/src/strings/tests.rs index c0efb30..b01ee08 100644 --- a/crates/noxis-passes/src/strings/tests.rs +++ b/crates/noxis-passes/src/strings/tests.rs @@ -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); +} diff --git a/crates/noxis-runtime/src/decoder/kdf.rs b/crates/noxis-runtime/src/decoder/kdf.rs index 5f960e8..e143433 100644 --- a/crates/noxis-runtime/src/decoder/kdf.rs +++ b/crates/noxis-runtime/src/decoder/kdf.rs @@ -4,7 +4,7 @@ //! Вход собирается `ByteBuffer`, чтобы не материализовывать 26 байт //! поштучно: `allocate.putLong(master).put(domain).putLong(salt).array()`. //! -//! Здесь же — строитель тела метода и короткие эмиссии (общие для всех +//! Здесь же - строитель тела метода и короткие эмиссии (общие для всех //! ветвей декодера). use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, InvokeKind, Label, Prim, SlotKind}; @@ -21,7 +21,7 @@ impl Gen { Self { insns: Vec::new(), labels: 0, - // Слоты 0 (`cipher`) и 1 (`salt`) — параметры метода. + // Слоты 0 (`cipher`) и 1 (`salt`) - параметры метода. slots: 2, } } @@ -32,7 +32,7 @@ impl Gen { label } - /// Занять слоты (`wide` — пара под `long`). + /// Занять слоты (`wide` - пара под `long`). pub(super) fn alloc(&mut self, wide: bool) -> u16 { let slot = self.slots; self.slots += if wide { 2 } else { 1 }; @@ -134,7 +134,7 @@ pub(super) fn emit_kdf(asm: &mut Gen, master: u16, domain: &str) -> u16 { /// Собрать вход `master || domain || salt` в слот `input`. /// -/// Rust считает FNV по little-endian байтам, а `putLong` кладёт big-endian — +/// Rust считает FNV по little-endian байтам, а `putLong` кладёт big-endian - /// поэтому оба `long` разворачиваются (`Long.reverseBytes`). fn build_input(asm: &mut Gen, master: u16, domain: &str, input: u16) { asm.emit(c_int(16 + domain.len() as i32)); diff --git a/crates/noxis-runtime/src/decoder/mod.rs b/crates/noxis-runtime/src/decoder/mod.rs index a3b8aaa..77220cf 100644 --- a/crates/noxis-runtime/src/decoder/mod.rs +++ b/crates/noxis-runtime/src/decoder/mod.rs @@ -6,8 +6,8 @@ //! static String d(String cipher, int salt) //! ``` //! -//! - `cipher` — base64 шифротекста (схема выбирается по salt: `scheme = all[salt % n]`); -//! - `salt` — индекс строки, по нему детерминированно выводятся key и nonce; +//! - `cipher` - base64 шифротекста (схема выбирается по salt: `scheme = all[salt % n]`); +//! - `salt` - индекс строки, по нему детерминированно выводятся key и nonce; //! - мастер-секрет **не передаётся на вызове** и не лежит в одной константе: //! он собирается из двух частей (`k1 ^ k2`) прямо в декодере. //! @@ -63,7 +63,7 @@ pub fn decoder_class( emit_dispatch(&mut asm, &parsed, key, nonce, data, len); emit_epilogue(&mut asm, data); let code = Code { - // Точный пик — 10 слотов (замер 08.10.2026); запас без вреда: + // Точный пик - 10 слотов (замер 08.10.2026); запас без вреда: // лимит обязан лишь покрывать фактическую глубину. max_stack: 12, max_locals: asm.slots, @@ -88,7 +88,7 @@ pub fn decoder_owner(seed_name: &str) -> String { } /// Пролог: `master = k1 ^ k2`; возвращает слот пары. Ни одна часть не равна -/// мастер-секрету (вторая — `master ^ k1`, первая — потоковая). +/// мастер-секрету (вторая - `master ^ k1`, первая - потоковая). fn emit_master(asm: &mut Gen, master: u64) -> u16 { use noxis_core::Rng; @@ -135,7 +135,7 @@ fn emit_len(asm: &mut Gen, data: u16) -> u16 { slot } -/// Диспетчер схем: `salt % n` лестницей `if_icmpne` (n == 1 — без диспетчера). +/// Диспетчер схем: `salt % n` лестницей `if_icmpne` (n == 1 - без диспетчера). fn emit_dispatch(asm: &mut Gen, schemes: &[Scheme], key: u16, nonce: u16, data: u16, len: u16) { if schemes.len() == 1 { dispatch_branch(asm, schemes[0], key, nonce, data, len); diff --git a/crates/noxis-runtime/src/decoder/xor.rs b/crates/noxis-runtime/src/decoder/xor.rs index 34cf328..f42f227 100644 --- a/crates/noxis-runtime/src/decoder/xor.rs +++ b/crates/noxis-runtime/src/decoder/xor.rs @@ -7,7 +7,7 @@ use noxis_classfile::{Arith, ArrayKind, BranchCond, Cond, Convert, Insn, Prim, S use super::kdf::{Gen, c_int, c_long, load, store}; -/// Расшифровать `data` на месте; `len` — длина массива. +/// Расшифровать `data` на месте; `len` - длина массива. pub(super) fn emit_xor(asm: &mut Gen, key: u16, nonce: u16, data: u16, len: u16) { let liga = Slots::alloc(asm); emit_seed(asm, key, nonce, &liga); @@ -76,7 +76,7 @@ fn emit_states(asm: &mut Gen, slots: &Slots) { } } -/// Один `splitmix64`: `sm += PHI`, финализатор — в `out`. +/// Один `splitmix64`: `sm += PHI`, финализатор - в `out`. fn splitmix(asm: &mut Gen, sm: u16, out: u16) { const PHI: i64 = 0x9E37_79B9_7F4A_7C15u64 as i64; const C1: i64 = 0xBF58_476D_1CE4_E5B9u64 as i64; @@ -105,7 +105,7 @@ fn finalize(asm: &mut Gen, amount: i32, constant: i64) { asm.emit(long_op(Arith::Mul)); } -/// Цикл по чанкам: свежий `next_u64` на 8 байт, внутренний — по байтам. +/// Цикл по чанкам: свежий `next_u64` на 8 байт, внутренний - по байтам. /// /// Без `skip`-джойна: стрим всегда свеж на входе во внутренний цикл. fn emit_loop(asm: &mut Gen, data: u16, len: u16, slots: &Slots) { @@ -188,7 +188,7 @@ fn emit_next(asm: &mut Gen, slots: &Slots) { asm.emit(c_int(17)); asm.emit(shift(Arith::Shl)); asm.emit(store(slots.tmp_c, SlotKind::Long)); - // s2 ^= s0; s3 ^= s1; s1 ^= s2; s0 ^= s3 — строго по порядку. + // s2 ^= s0; s3 ^= s1; s1 ^= s2; s0 ^= s3 - строго по порядку. xor_into(asm, s[2], s[0]); xor_into(asm, s[3], s[1]); xor_into(asm, s[1], s[2]); diff --git a/crates/noxis-runtime/src/decoder/xtea.rs b/crates/noxis-runtime/src/decoder/xtea.rs index c82d638..2c1e22f 100644 --- a/crates/noxis-runtime/src/decoder/xtea.rs +++ b/crates/noxis-runtime/src/decoder/xtea.rs @@ -1,16 +1,16 @@ //! Ветвь `xtea-ctr`: XTEA в режиме счётчика, побайтовый XOR на месте. //! -//! Зеркало `scheme::xtea_ctr` побитово: ключ — четыре `u32` из key/nonce, -//! счётчик — номер чанка, все правые сдвиги — логические (`u32` в Rust). +//! Зеркало `scheme::xtea_ctr` побитово: ключ - четыре `u32` из key/nonce, +//! счётчик - номер чанка, все правые сдвиги - логические (`u32` в Rust). use noxis_classfile::{Arith, ArrayKind, BranchCond, Cond, Convert, Insn, Prim, SlotKind}; use super::kdf::{Gen, c_int, load, store}; -/// XTEA delta — знаковое представление для `ldc_w`. +/// XTEA delta - знаковое представление для `ldc_w`. const DELTA: i32 = 0x9E37_79B9u32 as i32; -/// Расшифровать `data` на месте; `len` — длина массива. +/// Расшифровать `data` на месте; `len` - длина массива. pub(super) fn emit_xtea(asm: &mut Gen, key: u16, nonce: u16, data: u16, len: u16) { let slots = Slots::alloc(asm); emit_key_array(asm, key, nonce, &slots); @@ -168,7 +168,7 @@ fn mixing(asm: &mut Gen, dst: u16, src: u16, key_shift: i32, slots: &Slots) { asm.emit(store(dst, SlotKind::Int)); } -/// XOR 8 байт чанка со стримом `(v0, v1)`; хвост короче 8 — частично. +/// XOR 8 байт чанка со стримом `(v0, v1)`; хвост короче 8 - частично. fn emit_xor_bytes(asm: &mut Gen, data: u16, len: u16, slots: &Slots) { asm.emit(c_int(0)); asm.emit(store(slots.index, SlotKind::Int)); diff --git a/crates/noxis-runtime/src/scheme.rs b/crates/noxis-runtime/src/scheme.rs index 84cf203..e06fc5d 100644 --- a/crates/noxis-runtime/src/scheme.rs +++ b/crates/noxis-runtime/src/scheme.rs @@ -1,11 +1,11 @@ //! Схемы шифрования строк: пер-строчный ключ, nonce и несколько алгоритмов. //! //! Требования (см. `info/evo-weaknesses.md` и `docs/techniques.md`): -//! - **разные схемы между строками** — один скрипт не покрывает весь jar; -//! - **ключ не рядом с шифротекстом** — на вызов передаётся только salt, -//! мастер-секрет живёт в рантайм-декодере (сборка мастер-секрета — P2); -//! - **nonce уникален на строку** — keystream не повторяется; -//! - **MAC** — повреждение/подмена шифротекста ловится, а не молча портит текст. +//! - **разные схемы между строками** - один скрипт не покрывает весь jar; +//! - **ключ не рядом с шифротекстом** - на вызов передаётся только salt, +//! мастер-секрет живёт в рантайм-декодере (сборка мастер-секрета - P2); +//! - **nonce уникален на строку** - keystream не повторяется; +//! - **MAC** - повреждение/подмена шифротекста ловится, а не молча портит текст. use noxis_core::{Rng, derive_seed}; @@ -58,7 +58,7 @@ impl Scheme { pub struct Blob { /// base64 шифротекста. pub cipher: String, - /// 32-битный MAC по ключу от plaintext — ловит подмену/порчу. + /// 32-битный MAC по ключу от plaintext - ловит подмену/порчу. pub tag: u32, } @@ -67,7 +67,7 @@ pub fn string_key(master: u64, salt: i32) -> u64 { derive_seed(master, "string/key", u64::from(salt as u32)) } -/// Nonce строки: детерминированный от salt, но другой домен — keystream не +/// Nonce строки: детерминированный от salt, но другой домен - keystream не /// пересекается даже при совпадении ключей. pub fn string_nonce(master: u64, salt: i32) -> u64 { derive_seed(master, "string/nonce", u64::from(salt as u32)) @@ -123,7 +123,7 @@ fn xor_splitmix(key: u64, nonce: u64, data: &mut [u8]) { } } -/// XTEA в режиме счётчика: keystream = XTEA(k, counter), XOR — на месте. +/// XTEA в режиме счётчика: keystream = XTEA(k, counter), XOR - на месте. fn xtea_ctr(key: u64, nonce: u64, data: &mut [u8]) { let k = [ key as u32, diff --git a/crates/noxis-runtime/tests/exec.rs b/crates/noxis-runtime/tests/exec.rs index 121b0b1..2510686 100644 --- a/crates/noxis-runtime/tests/exec.rs +++ b/crates/noxis-runtime/tests/exec.rs @@ -1,7 +1,7 @@ //! Сквозная проверка декодера: шифруем в Rust, расшифровываем настоящим `java`. //! //! Собираются два класса (декодер + вызывающий `main`), пишутся на диск, -//! `java` исполняет — stdout обязан совпасть с исходными строками. Без `java` +//! `java` исполняет - stdout обязан совпасть с исходными строками. Без `java` //! тест пропускается (graceful skip, как в `noxis-classfile/tests/frames.rs`). #![allow(clippy::expect_used, clippy::unwrap_used)] diff --git a/crates/noxis-structure-check/src/rules.rs b/crates/noxis-structure-check/src/rules.rs index 9a9ecf9..47ff35d 100644 --- a/crates/noxis-structure-check/src/rules.rs +++ b/crates/noxis-structure-check/src/rules.rs @@ -1,10 +1,10 @@ //! Project structure rules, invoked from every crate's `build.rs`. //! //! Rules apply to Rust code only, not to documentation: -//! * every `.rs` file holds at most [`MAX_FILE_LINES`] *code* lines — blank +//! * every `.rs` file holds at most [`MAX_FILE_LINES`] *code* lines - blank //! lines and full-line comments (`//`, `///`, `//!`) are not counted; //! * every directory inside a crate's `src/`, `tests/` or `benches/` holds -//! at most [`MAX_DIR_FILES`] files — subdirectories are unlimited. +//! at most [`MAX_DIR_FILES`] files - subdirectories are unlimited. //! //! Deliberately excluded: `Cargo.lock`, `.gitignore`, `docs/`, the legacy //! `info/` tree, and crate roots (they always own `Cargo.toml` plus @@ -34,13 +34,13 @@ const SKIPPED_DIRS: [&str; 2] = ["target", ".git"]; /// /// # Panics /// -/// Panics whenever a rule is violated — that is the point of the gate. +/// Panics whenever a rule is violated - that is the point of the gate. pub fn check() { let manifest = manifest_dir(); let mut violations = Vec::new(); // `src/` is required; `tests/` and `benches/` are checked only when they - // exist — otherwise the line rule is bypassed by moving code into tests. + // exist - otherwise the line rule is bypassed by moving code into tests. // `docs/` stays out of the gate: the rules are about Rust code only. let roots = [ manifest.join("src"), diff --git a/crates/noxis-verify/src/java.rs b/crates/noxis-verify/src/java.rs index 6135873..f83fd65 100644 --- a/crates/noxis-verify/src/java.rs +++ b/crates/noxis-verify/src/java.rs @@ -2,7 +2,7 @@ //! //! Каждый класс грузится отдельной JVM в дереве пакетов: изоляция важнее //! скорости (один битый класс не тянет за собой остальные). Классы, которые -//! не грузятся из-за отсутствующих зависимостей, — `Skipped`, а не провал: +//! не грузятся из-за отсутствующих зависимостей, - `Skipped`, а не провал: //! судить о них верификатор не может. use std::path::Path; @@ -13,7 +13,7 @@ use std::process::Command; pub enum JavaVerdict { /// Загрузился без ошибок верификации (`main` при этом может отсутствовать). Verified, - /// Верификатор отверг класс (текст ошибки — для отчёта). + /// Верификатор отверг класс (текст ошибки - для отчёта). Failed(String), /// Судить нельзя: нет `java` или не хватает зависимостей. Skipped(String), @@ -51,7 +51,7 @@ pub fn verify_class_bytes(internal_name: &str, bytes: &[u8]) -> JavaVerdict { verdict } -/// Проверить все классы jar'а (фильтр — префиксы внутренних имён). +/// Проверить все классы jar'а (фильтр - префиксы внутренних имён). pub fn verify_jar(jar: &Path, scope: &[String]) -> Vec<(String, JavaVerdict)> { let entries = match noxis_jar::read_file(jar) { Ok(entries) => entries, diff --git a/crates/noxis-verify/src/leaks.rs b/crates/noxis-verify/src/leaks.rs index b496455..b006530 100644 --- a/crates/noxis-verify/src/leaks.rs +++ b/crates/noxis-verify/src/leaks.rs @@ -24,7 +24,7 @@ pub const RED_PATTERNS: &[&str] = &[ /// Все идентификаторы программы: классы, члены, строки кода. /// -/// Члены — по простому имени (без владельца): сверка строгая и дешёвая, +/// Члены - по простому имени (без владельца): сверка строгая и дешёвая, /// для точечной проверки есть `find_leaks` с keep-фильтром. pub fn collect_names(program: &Program) -> BTreeSet { let mut names = BTreeSet::new(); @@ -42,7 +42,7 @@ pub fn collect_names(program: &Program) -> BTreeSet { /// Имена из эталона, пережившие обфускацию (минус keep). /// -/// Пустой результат — норма; непустой — тест падает с этим списком. +/// Пустой результат - норма; непустой - тест падает с этим списком. pub fn find_leaks(before: &BTreeSet, after: &Program, keep: &KeepRules) -> Vec { let names = collect_names(after); before diff --git a/crates/noxis-verify/src/lib.rs b/crates/noxis-verify/src/lib.rs index f0870e7..98dfa08 100644 --- a/crates/noxis-verify/src/lib.rs +++ b/crates/noxis-verify/src/lib.rs @@ -2,9 +2,9 @@ //! //! Проверка результата обфускации, а не процесса: //! -//! * [`leaks`] — утечки исходных имён + секреты открытым текстом; -//! * [`java`] — настоящий `java -Xverify:all` по байтам на диске; -//! * [`metrics`] — счётчики программы, раздувание, стабильность маппингов. +//! * [`leaks`] - утечки исходных имён + секреты открытым текстом; +//! * [`java`] - настоящий `java -Xverify:all` по байтам на диске; +//! * [`metrics`] - счётчики программы, раздувание, стабильность маппингов. pub mod java; pub mod leaks; diff --git a/crates/noxis/examples/dbg.rs b/crates/noxis/examples/dbg.rs deleted file mode 100644 index 8c930ba..0000000 --- a/crates/noxis/examples/dbg.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Одноразовый отладочный обход байткода (scratch): ручной парсинг пула и -//! Code-атрибутов, вывод заголовков вложенных атрибутов. -#![allow(clippy::unwrap_used, clippy::expect_used)] - -fn rd_u16(b: &[u8], p: &mut usize) -> u16 { - let v = u16::from_be_bytes([b[*p], b[*p + 1]]); - *p += 2; - v -} -fn rd_u32(b: &[u8], p: &mut usize) -> u32 { - let v = u32::from_be_bytes([b[*p], b[*p + 1], b[*p + 2], b[*p + 3]]); - *p += 4; - v -} -fn hex(b: &[u8]) -> String { - b.iter() - .map(|x| format!("{x:02x}")) - .collect::>() - .join(" ") -} -fn main() { - let entries = noxis_jar::read_file(std::path::Path::new( - "/storage/project/jvm/LoReport/build/libs/lorep-1.1.0.jar", - )) - .unwrap(); - let program = noxis_jar::load(entries).unwrap(); - let class = program - .classes - .iter() - .find(|c| c.this_class.ends_with("ReportStatsCommand")) - .unwrap(); - let prog = noxis_classfile::Program { - classes: vec![class.clone()], - resources: vec![], - }; - let out = noxis_jar::save(&prog).unwrap(); - let entry = out.iter().find(|e| e.name.ends_with(".class")).unwrap(); - let bytes = &entry.data; - // find method_execute Code attr -> sub attrs, dump each sub-attr name_idx + len + first bytes - // manual walk: pool - let mut pos = 8; - let pool_count = rd_u16(bytes, &mut pos); - // collect utf8 entries for name resolution - let mut utf8s: std::collections::BTreeMap = Default::default(); - let mut idx = 1u16; - while idx < pool_count { - let tag = bytes[pos]; - pos += 1; - match tag { - 1 => { - let len = rd_u16(bytes, &mut pos) as usize; - utf8s.insert( - idx, - String::from_utf8_lossy(&bytes[pos..pos + len]).into_owned(), - ); - pos += len; - } - 3 | 4 => pos += 4, - 5 | 6 => { - pos += 8; - idx += 1; - } - 7 | 8 | 16 | 19 | 20 => pos += 2, - 9 | 10 | 11 | 12 | 17 | 18 => pos += 4, - 15 => pos += 3, - _ => { - eprintln!("pool tag {tag}"); - return; - } - } - idx += 1; - } - let name_of = |i: u16| utf8s.get(&i).cloned().unwrap_or(format!("#{i}")); - pos += 6; - let ifaces = rd_u16(bytes, &mut pos); - pos += 2 * ifaces as usize; - let fields = rd_u16(bytes, &mut pos); - for _ in 0..fields { - pos += 6; - let n = rd_u16(bytes, &mut pos); - for _ in 0..n { - pos += 2; - let l = rd_u32(bytes, &mut pos); - pos += l as usize; - } - } - let methods = rd_u16(bytes, &mut pos); - for _ in 0..methods { - pos += 2; - let ni = rd_u16(bytes, &mut pos); - pos += 2; - let an = rd_u16(bytes, &mut pos); - let mname = name_of(ni); - for _ in 0..an { - let aname = rd_u16(bytes, &mut pos); - let alen = rd_u32(bytes, &mut pos) as usize; - let aname_s = name_of(aname); - if aname_s == "Code" { - let start = pos; - pos += 4; - let clen = rd_u32(bytes, &mut pos) as usize; - pos += clen; - let exc = rd_u16(bytes, &mut pos) as usize; - pos += 8 * exc; - let subn = rd_u16(bytes, &mut pos); - for _ in 0..subn { - let sname = rd_u16(bytes, &mut pos); - let slen = rd_u32(bytes, &mut pos) as usize; - eprintln!( - "{mname}: sub={} len={slen} head=[{}]", - name_of(sname), - hex(&bytes[pos..pos + slen.min(24)]) - ); - pos += slen; - } - assert_eq!(pos, start + alen, "Code length mismatch in {mname}"); - } else { - pos += alen; - } - } - } - eprintln!("walk ok"); -} diff --git a/crates/noxis/src/cmd/analyze.rs b/crates/noxis/src/cmd/analyze.rs index 34c0b34..0750021 100644 --- a/crates/noxis/src/cmd/analyze.rs +++ b/crates/noxis/src/cmd/analyze.rs @@ -11,7 +11,7 @@ use noxis_core::Config; pub struct Args { /// Входной jar. pub input: PathBuf, - /// TOML-конфиг с keep-паттернами (без него — пустой keep). + /// TOML-конфиг с keep-паттернами (без него - пустой keep). #[arg(short, long)] pub config: Option, } diff --git a/crates/noxis/src/cmd/obfuscate.rs b/crates/noxis/src/cmd/obfuscate.rs index 64b4595..7092593 100644 --- a/crates/noxis/src/cmd/obfuscate.rs +++ b/crates/noxis/src/cmd/obfuscate.rs @@ -13,10 +13,10 @@ pub struct Args { /// Output jar. #[arg(short, long)] pub output: PathBuf, - /// TOML config (without it — the Standard profile). + /// TOML config (without it - the Standard profile). #[arg(short, long)] pub config: Option, - /// Master seed (deterministic by default — taken from the config). + /// Master seed (deterministic by default - taken from the config). #[arg(long)] pub seed: Option, /// Where to write the JSON report (next to the output by default). diff --git a/crates/noxis/src/lib.rs b/crates/noxis/src/lib.rs index 690f9a0..d65ac95 100644 --- a/crates/noxis/src/lib.rs +++ b/crates/noxis/src/lib.rs @@ -1,3 +1,3 @@ -//! Библиотека CLI: команды как вызываемые функции (тонкий `main` — в `main.rs`). +//! Библиотека CLI: команды как вызываемые функции (тонкий `main` - в `main.rs`). pub mod cmd; diff --git a/docs/README.md b/docs/README.md index aa34c35..3ae7e16 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Документация noxis -Обфускатор JVM/Java-байткода на Rust. Этот файл — индекс. +Обфускатор JVM/Java-байткода на Rust. Этот файл - индекс. ## Что где @@ -10,34 +10,34 @@ | [`techniques.md`](techniques.md) | как работает каждая техника + плюсы/минусы/цена | | [`stack.md`](stack.md) | почему свой IR, какой backend, `StackMapTable`, PRNG, генерация рантайма | | [`learnings/lessons.md`](learnings/lessons.md) | уроки из разбора EvoVis: что защита сделала плохо и чего мы не делаем | -| [`learnings/reference.md`](learnings/reference.md) | разбор референсного обфускатора (Java/ASM) — что берём, что нет | +| [`learnings/reference.md`](learnings/reference.md) | разбор референсного обфускатора (Java/ASM) - что берём, что нет | | [`learnings/priorities.md`](learnings/priorities.md) | что улучшим: стратегия и приоритеты | | [`learnings/ai-resistance.md`](learnings/ai-resistance.md) | новые техники обфускации и устойчивость к LLM-анализу | -План выполнения — [`../todo.md`](../todo.md). Исходный материал разбора — +План выполнения - [`../todo.md`](../todo.md). Исходный материал разбора - [`../info/`](../info/README.md). ## Структурные правила проекта -Правила — **только про Rust-код**: `crates/*/src`, `crates/*/tests`, +Правила - **только про Rust-код**: `crates/*/src`, `crates/*/tests`, `crates/*/benches`. Документация (`docs/**`, корневые `.md`) и `info/` вне гейта: писать подробно там, где это документация, а не ограничивать объём искусственно. Не учитываются также `Cargo.lock`, `.gitignore`, корень крейта (в нём всегда лежат `Cargo.toml` + `build.rs`). 1. **Не больше 250 строк кода** в одном `.rs` файле. Пустые строки и - полно-строчные комментарии (`//`, `///`, `//!`) не считаются — + полно-строчные комментарии (`//`, `///`, `//!`) не считаются - измеряется объём кода, а не документации. 2. **Не больше 4 `.rs`-файлов** в одной директории. Подпапки не ограничены: `src/` может содержать `lib.rs` и три модуля-директории, каждая из которых в свою очередь содержит не больше четырёх файлов. -`tests/` и `benches/` не исключены намеренно — иначе правило +`tests/` и `benches/` не исключены намеренно - иначе правило «250 строк» обходится выносом кода в тесты. -Правило 2 — это не косметика: файл из тысячи строк означает, что в нём +Правило 2 - это не косметика: файл из тысячи строк означает, что в нём смешаны разные причины к изменению (нарушение KISS/DRY/SOLID), а команда из -восьми файлов в одной папке — что модуль перестал быть модулем. +восьми файлов в одной папке - что модуль перестал быть модулем. ### Как проверяется @@ -51,7 +51,7 @@ fn main() { `noxis-structure-check` обходит деревья `src/`, `tests/` и `benches/` **этого крейта** и **падает со стопкой ошибок**, если лимит нарушен. Поэтому -`cargo build` не соберёт нарушение — специально запускать проверку не нужно. +`cargo build` не соберёт нарушение - специально запускать проверку не нужно. Деревья регистрируются через `cargo:rerun-if-changed`, чтобы гейт не срабатывал на правки вне проверяемых путей. @@ -64,9 +64,9 @@ fn main() { ### Что делать при нарушении - файл разрослся → разрезать по одной ответственности на файл: - `mod.rs` = публичный контракт модуля, соседние файлы — внутренности; + `mod.rs` = публичный контракт модуля, соседние файлы - внутренности; - папка переполнилась → завести подпапку и вынести подфункцию целиком, а не «чтобы влезло». -Каждый модуль получает `mod.rs` с `//!`-документацией — это точка входа, +Каждый модуль получает `mod.rs` с `//!`-документацией - это точка входа, читающаяся вместо пяти файлов. diff --git a/docs/architecture.md b/docs/architecture.md index d117832..3f172ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ ## Собственный IR -Пассы не работают с API стороннего парсера — они работают с моделью: +Пассы не работают с API стороннего парсера - они работают с моделью: ```text Program все классы + ресурсы архива @@ -63,15 +63,15 @@ Program все классы + ресурсы архива └─ Resource путь + байты внутри архива ``` -Ключевое решение — **`Insn::Mark(Label)` вместо байтовых смещений**: +Ключевое решение - **`Insn::Mark(Label)` вместо байтовых смещений**: - любой пасс, меняющий порядок или длину инструкций, не пересчитывает jump-офсеты; -- пересчёт делается ровно один раз — в writer'е; +- пересчёт делается ровно один раз - в writer'е; - в IR нет состояния, в котором «смещение уехало, а метка осталась». Атрибуты, которые мы не понимаем, хранятся как `Attribute::Raw { name, data }` -и возвращаются на место без изменений — round-trip без потерь. +и возвращаются на место без изменений - round-trip без потерь. ## Контракт пасса @@ -83,18 +83,18 @@ pub trait Pass: Send + Sync { } ``` -- `Context` — всё состояние прогона: конфиг, мастер-сид, маппинги, заметки. - Из него берётся RNG: `ctx.rng("rename/classes")` — доменное разделение, +- `Context` - всё состояние прогона: конфиг, мастер-сид, маппинги, заметки. + Из него берётся RNG: `ctx.rng("rename/classes")` - доменное разделение, чтобы случайности разных пассов не были статистически коррелированы. -- `PassReport` — что трансформировано, что сознательно пропущено и почему. -- `Report` — собирает отчёты всех пассов + счётчики, сериализуется в JSON. +- `PassReport` - что трансформировано, что сознательно пропущено и почему. +- `Report` - собирает отчёты всех пассов + счётчики, сериализуется в JSON. -Детерминированность — свойство, а не пожелание: одинаковый seed на одинаковой +Детерминированность - свойство, а не пожелание: одинаковый seed на одинаковой версии даёт байт-в-байт одинаковый выход. Это проверяется тестами. ## Конвейер -Порядок пассов — не деталь: +Порядок пассов - не деталь: ```text Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo @@ -102,18 +102,18 @@ Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo почему именно так: -1. **Rename первым** — все остальные техники порождают новые ссылки +1. **Rename первым** - все остальные техники порождают новые ссылки (`LDC`-шум, вызовы декодера, junk-классы), и они уже должны быть в обфусцированных именах. -2. **Resources сразу после Rename** — патч `fabric.mod.json`, `*.mixins.json`, +2. **Resources сразу после Rename** - патч `fabric.mod.json`, `*.mixins.json`, refmap и access widener должен видеть *итоговые* имена классов. -3. **Strings и Constants** — работают по телам методов, ещё не искажённым +3. **Strings и Constants** - работают по телам методов, ещё не искажённым control-flow'ем. -4. **Flow** — искажает CFG. Если поставить его раньше, Strings/Constants +4. **Flow** - искажает CFG. Если поставить его раньше, Strings/Constants перестанут находить свои шаблоны в инструкциях. -5. **Noise** — junk-классы и аннотации добавляются в самом конце, когда +5. **Noise** - junk-классы и аннотации добавляются в самом конце, когда целевой граф уже финален. -6. **DebugInfo последним** — strip/spoof отладочной информации после всех +6. **DebugInfo последним** - strip/spoof отладочной информации после всех трансформаций, чтобы не породить свежие `LineNumberTable`. ## Статус @@ -126,4 +126,4 @@ Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo | **готово** | пасс `flow` (4 схемы + ротация + мешанина, приёмка настоящим `java`) | | **план** | фикстуры и regression-тесты | -Детали выбора — [`stack.md`](stack.md), план — [`../todo.md`](../todo.md). +Детали выбора - [`stack.md`](stack.md), план - [`../todo.md`](../todo.md). diff --git a/docs/learnings/ai-resistance.md b/docs/learnings/ai-resistance.md index 1e3881f..62c8602 100644 --- a/docs/learnings/ai-resistance.md +++ b/docs/learnings/ai-resistance.md @@ -10,7 +10,7 @@ ## 1. Elastic Security Labs: агент с IDA Pro против Tigress (апрель 2026) -[«The Cost of Understanding»](https://www.elastic.co/security-labs/llm-reversing-vs-llm-obfuscation) — +[«The Cost of Understanding»](https://www.elastic.co/security-labs/llm-reversing-vs-llm-obfuscation) - Claude Opus 4.6 с доступом к IDA Pro (через MCP) реверсил crackme, обфусцированный [Tigress](https://tigress.wtf/), затем команда сама собрала ("vibecode"-ила) три защиты, нацеленные именно на модель. Это ближе всего @@ -26,32 +26,32 @@ Claude Opus 4.6 с доступом к IDA Pro (через MCP) реверсил indirect/regs и switch/stack). - Opus решил ~40% из 20 валидных задач. Успех стоил в среднем **$2.39**, - провал — **$4.83**. Ни одна задача уровня "многослойные комбинации" не + провал - **$4.83**. Ни одна задача уровня "многослойные комбинации" не была решена. - **`JIT` (XTEA + рантайм-расшифровка) оказался самой сложной одиночной - трансформацией** — модель понимала структуру движка, но не смогла + трансформацией** - модель понимала структуру движка, но не смогла восстановить ключевые константы. Это ровно наш подход в `Strings` (ключ выводится в рантайме, не лежит в вызове), только применённый ко **всему коду метода**, а не к строкам. - На самых жёстких комбинациях цена и время выросли в ~4.5× и ~4.3×, но задачи, которые Opus уже умел решать, он решил всё равно. **Flatten + MBA - било по модели сильнее, чем Virtualize + MBA** — авторы объясняют это тем, + било по модели сильнее, чем Virtualize + MBA** - авторы объясняют это тем, что флаттенинг сильнее раздувает размер функции. ### Три кастомные защиты, нацеленные на агента | защита | идея | результат | |---|---|---| -| Matryoshka Wall | crackme внутри 100 000 вложенных слоёв ChaCha20 (+ подделка под Salsa20) | v1 — агент сдался не расшифровав ($1.50); v2 (каждый ключ зависит от предыдущего слоя) — исчерпал бюджет $10 за 56 минут, 0 успехов | -| Double Fond | патч open-source крипто-библиотеки: скрытый загрузчик через изменённую lookup-таблицу, шифрованные указатели, RWX-сегмент | v7 — 1/5 у "осведомлённого" агента (12 мин, $5.20), 0/5 у "слепого" | -| Dispatch Maze | шифр раздроблен на 20 фрагментов по 3000 функций, большинство — ловушки | v1 решена за 12 минут ($2.56); v2 (разнообразные шаблоны кода + ложные переходы) — провал агента через 46 минут и $8.83 | +| Matryoshka Wall | crackme внутри 100 000 вложенных слоёв ChaCha20 (+ подделка под Salsa20) | v1 - агент сдался не расшифровав ($1.50); v2 (каждый ключ зависит от предыдущего слоя) - исчерпал бюджет $10 за 56 минут, 0 успехов | +| Double Fond | патч open-source крипто-библиотеки: скрытый загрузчик через изменённую lookup-таблицу, шифрованные указатели, RWX-сегмент | v7 - 1/5 у "осведомлённого" агента (12 мин, $5.20), 0/5 у "слепого" | +| Dispatch Maze | шифр раздроблен на 20 фрагментов по 3000 функций, большинство - ловушки | v1 решена за 12 минут ($2.56); v2 (разнообразные шаблоны кода + ложные переходы) - провал агента через 46 минут и $8.83 | ### Что из этого реально работает, по механизму 1. **Контекстное окно.** Длинные тупиковые пути (decoy-функции, ложные переходы) забивают контекст и портят рассуждение модели. 2. **Бюджет шагов/денег.** Каждый впустую потраченный цикл стоит агенту - реальных токенов; если он упирается в лимит и сдаётся — это победа + реальных токенов; если он упирается в лимит и сдаётся - это победа защиты, даже если код теоретически вскрываем. 3. **Shortcut bias.** Вводящая в заблуждение структура (ловушки в Dispatch Maze) заставляет модель остановиться раньше времени. @@ -59,14 +59,14 @@ indirect/regs и switch/stack). **Ключевое экономическое наблюдение авторов:** нативное исполнение занимает ~55 мс, а статический провал агента стоит **$1.50–$9.67**. Это и есть -реальный рычаг — не "нечитаемость навсегда", а разрыв между ценой атаки и +реальный рычаг - не "нечитаемость навсегда", а разрыв между ценой атаки и ценой сборки для нас. Авторы прямо предупреждают: преимущество схлопнется, когда у агентов появятся инструменты динамического исполнения (сейчас тестировался только статический анализ). ## 2. Acoda: атака на safety-alignment и токенизацию (ICSE 2026) -[Acoda](https://arxiv.org/pdf/2606.11755) — другая постановка задачи: не +[Acoda](https://arxiv.org/pdf/2606.11755) - другая постановка задачи: не агент с дизассемблером, а LLM, которой **в чат вставили исходный код** и попросили его проанализировать/деобфусцировать. Генетический алгоритм подбирает комбинацию из 8 трансформаций, до 70% ASR (attack success rate) @@ -81,7 +81,7 @@ indirect/regs и switch/stack). | до анализа | Vulnerability Code Injection | фрагмент с SQL-инъекцией/`pickle.loads`/command injection в заведомо мёртвой ветке (`while (сложное_но_ложное_условие)`) | | во время анализа | Misleading Summarization | модель сама просится описать код, затем это описание (намеренно неверное) вклеивается рядом с кодом | | во время анализа | String Obfuscation | `base64` + словарь, расшифровка даёт слова типа "virus"/"weapon" | -| во время анализа | Try-Except Wrapping | настоящая логика лежит в `except`-ветке, в `try` — правдоподобный, но бесполезный код | +| во время анализа | Try-Except Wrapping | настоящая логика лежит в `except`-ветке, в `try` - правдоподобный, но бесполезный код | | после анализа | EOS Token Insertion | в текст вставлены литералы токенов-разделителей (`<|EOT|>`), из-за чего генерация обрывается раньше | ### Честная оценка: что из этого применимо к нам @@ -93,24 +93,24 @@ indirect/regs и switch/stack). - `DebugInfo` уже вычищает всё, что могло бы стать комментарием или осмысленным описанием (`LineNumberTable`, `Signature`, исходные имена); в скомпилированном `.class` комментариев не бывает в принципе. -- Цель Acoda — заставить модель **отказаться анализировать свой же код**, +- Цель Acoda - заставить модель **отказаться анализировать свой же код**, вставленный в чат пользователем. У нас код анализирует **чужой** агент - чужого jar — нам не нужно, чтобы он отказался, если он не наш союзник; - и вызывать намеренно подозрительные паттерны в легитимном продукте — + чужого jar - нам не нужно, чтобы он отказался, если он не наш союзник; + и вызывать намеренно подозрительные паттерны в легитимном продукте - прямой риск получить ложное срабатывание антивируса/EDR на свой же jar. Это противоречит `README.md` → «Ответственное использование». - **EOS Token Insertion** не переносится: это свойство текстового стриминга токенов модели, а не байткода. Единственное, что частично переносится: **String Obfuscation** и -**Variable Renaming** — ровно то, что `Strings` и `Rename` уже делают, -просто дешевле (у Acoda — обратимый `base64`, у нас — ротация шифров с +**Variable Renaming** - ровно то, что `Strings` и `Rename` уже делают, +просто дешевле (у Acoda - обратимый `base64`, у нас - ротация шифров с выводом ключа в рантайме, см. [`../stack.md`](../stack.md) §4-5). Здесь мы уже на шаг впереди статьи, а не позади. **Vulnerability Code Injection** как идея (мёртвый код, похожий на уязвимый паттерн) пересекается с нашим `Noise`, но внедрять **реально похожие на -эксплойт сигнатуры** в выдаваемый клиентам jar — плохая идея для легального +эксплойт сигнатуры** в выдаваемый клиентам jar - плохая идея для легального продукта защиты: это именно то, от чего предостерегает [`info/evo-weaknesses.md`](../../info/evo-weaknesses.md) с другой стороны (не подставлять защищённый продукт под чужие red-листы). @@ -118,24 +118,24 @@ indirect/regs и switch/stack). ## 3. Что добавить в noxis практически Экономический рычаг из Elastic (цена агента > цены нашей сборки) ложится на -архитектуру без переделок, конкретные пункты — в [`../../todo.md`](../../todo.md) +архитектуру без переделок, конкретные пункты - в [`../../todo.md`](../../todo.md) §7: -1. **Вложенные слои шифрования строк** (аналог Matryoshka Wall) — расширить +1. **Вложенные слои шифрования строк** (аналог Matryoshka Wall) - расширить `StringsConfig` полем "глубина", decoder разворачивает N слоёв вместо - одного. Цена агента растёт с глубиной почти линейно, цена сборки — нет. -2. **Больше decoy-точек ветвления ключа** (аналог Dispatch Maze) — наш + одного. Цена агента растёт с глубиной почти линейно, цена сборки - нет. +2. **Больше decoy-точек ветвления ключа** (аналог Dispatch Maze) - наш декодер уже собирает мастер-секрет из `k1^k2` (`stack.md` §4); можно поднять число частей и раскидать их сборку по случайным junk-методам из `Noise`, а не держать в одном месте. -3. **MBA вместо простых тождеств в `Constants`** — то же решение, что ловит +3. **MBA вместо простых тождеств в `Constants`** - то же решение, что ловит и символьный исполнитель, и SMT-решатель, и, по данным Elastic, "Flatten + MBA" бьёт сильнее, чем просто Flatten. 4. **Не делать:** имитацию вредоносных паттернов, prompt injection в строках, EOS-токены. Либо не переносится на байткод, либо создаёт риск ложного срабатывания защитных систем на легитимном продукте. -Критерий "сработало/не сработало" для всего этого — как и для остальных +Критерий "сработало/не сработало" для всего этого - как и для остальных техник: прогон конкретного агента на конкретной сборке, а не вера в методичку. Эффект устаревает с каждой новой версией модели. @@ -151,17 +151,17 @@ indirect/regs и switch/stack). - **Для нас это ломает собственное допущение** из [`../stack.md`](../stack.md) §3: `noxis-verify` верифицирует метод через `java -Xverify:all`, потому что это *обычный* байткод с валидным - `StackMapTable`. Виртуализированный метод — уже не обычный байткод, + `StackMapTable`. Виртуализированный метод - уже не обычный байткод, верификатор проверит только обвязку интерпретатора, не тело. Big-ticket item, не для ближайшего спринта. -- **MBA** укладывается без архитектурных изменений — см. выше, §3. +- **MBA** укладывается без архитектурных изменений - см. выше, §3. ## Источники -- [Elastic Security Labs — The Cost of Understanding: LLM-Driven Reverse Engineering vs Iterative LLM Obfuscation](https://www.elastic.co/security-labs/llm-reversing-vs-llm-obfuscation) +- [Elastic Security Labs - The Cost of Understanding: LLM-Driven Reverse Engineering vs Iterative LLM Obfuscation](https://www.elastic.co/security-labs/llm-reversing-vs-llm-obfuscation) - [Acoda: Adversarial Code Obfuscation for Defending against LLM-based Analysis (arXiv 2606.11755, ICSE-Companion '26)](https://arxiv.org/pdf/2606.11755) - [A Systematic Study on Static Control Flow Obfuscation Techniques in Java (arXiv 1809.11037)](https://arxiv.org/pdf/1809.11037) - [Promon Shield: Virtual Machine obfuscation SDK protection](https://promon.io/security-news/virtual-machine-obfuscation-sdk-protection) - [CoTDeceptor: Adversarial Code Obfuscation Against CoT-Enhanced LLM Code Agents (arXiv 2512.21250)](https://arxiv.org/pdf/2512.21250) - [Deconstructing Obfuscation: a four-dimensional framework for evaluating LLM deobfuscation (arXiv 2505.19887)](https://arxiv.org/pdf/2505.19887) -- [Tigress — диверсифицирующий обфускатор для C, использованный в бенчмарке Elastic](https://tigress.wtf/) +- [Tigress - диверсифицирующий обфускатор для C, использованный в бенчмарке Elastic](https://tigress.wtf/) diff --git a/docs/learnings/lessons.md b/docs/learnings/lessons.md index dc2720d..fea68bb 100644 --- a/docs/learnings/lessons.md +++ b/docs/learnings/lessons.md @@ -1,11 +1,11 @@ # Уроки: что защита EvoVis сделала плохо -Источник: `info/evo-weaknesses.md` — полный разбор за один проход, без -рантайм-хаков. Здесь — что из этого следует для **нашей** архитектуры. +Источник: `info/evo-weaknesses.md` - полный разбор за один проход, без +рантайм-хаков. Здесь - что из этого следует для **нашей** архитектуры. Правило, которое стоит держать головой: **защита ломается не в рантайме, а на этапе, когда атакующий смотрит на статику.** Всё, что видно в `javap -p` -или в содержимом jar, — это утечка. +или в содержимом jar, - это утечка. ## Сводная таблица: провал → наше решение @@ -13,21 +13,21 @@ |---|---|---|---|---| | 1 | ключ лежит рядом с шифротекстом | вывод мастер-секрета в декодере, ключ из `master‖salt` | Strings | **готово** | | 2 | переименованы типы, но не члены | rename полей/методов + семейства virtual | Rename | **готово** | -| 3 | отладочная информация нетронута | strip + ложный `SourceFile`/номера строк | DebugInfo | **готово** (`SourceFile`; номера строк — план) | +| 3 | отладочная информация нетронута | strip + ложный `SourceFile`/номера строк | DebugInfo | **готово** (`SourceFile`; номера строк - план) | | 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | **готово** (4 схемы, приёмка `java`) | | 5 | opaque-предикаты = `A+B` | ротация тождеств + предикаты от состояния | Constants | **готово** (int/long) | -| 6 | junk-классы вне графа reachability | `weave_reachable` — обязательная вплетённость | Noise | **готово** (без аннотаций) | +| 6 | junk-классы вне графа reachability | `weave_reachable` - обязательная вплетённость | Noise | **готово** (без аннотаций) | | 7 | имена говорят «я защита» | все артефакты получают данные псевдонимы | Rename | **готово** | | 8 | вторичные компоненты без защиты | весь jar, а не только основной класс | Resources | **готово** | | 9 | шифр без MAC и без per-string salt | salt на строку, уникальный nonce, MAC | Strings | **готово** | ## 1. Ключ рядом с шифротекстом -`awc.d("", )` — ключ был **константным выражением -в вызове**. Свёртка выражения — ~200 строк любого парсера. Итог: 8993 вызова +`awc.d("", )` - ключ был **константным выражением +в вызове**. Свёртка выражения - ~200 строк любого парсера. Итог: 8993 вызова расшифрованы детерминированно, **0 обращений к JVM**. -Хуже: один шифр на весь продукт (LCG с фиксированными `a/c`) — зная одну +Хуже: один шифр на весь продукт (LCG с фиксированными `a/c`) - зная одну пару «шифротекст → текст», остальные раскрываются по обучению. **Что делаем иначе.** @@ -41,56 +41,56 @@ - MAC: подмена байта не «тихо» портит текст. Остаточный риск честно признан: целевой анализ декодера раскрывает текст. -Задача — поднять цену, а не сделать невозможным. +Задача - поднять цену, а не сделать невозможным. ## 2. Члены не переименованы -`onDraw`, `getProjectiles`, `isAuthorized()` — открытые имена. Класс `wb` +`onDraw`, `getProjectiles`, `isAuthorized()` - открытые имена. Класс `wb` опознаётся за секунду по трём понятным методам. **Что делаем иначе:** переименование полей и методов входит в базовый уровень, а не в «продвинутый». Уже готово, включая: -- **семейства virtual** — переименование на всю иерархию, чтобы не +- **семейства virtual** - переименование на всю иерархию, чтобы не разъединить переопределения и не сломать `invokevirtual`; -- **keep-список** — private/static/final можно сразу, `rename_public_members` +- **keep-список** - private/static/final можно сразу, `rename_public_members` по умолчанию выключен для библиотек, чтобы не сломать API; -- **автодетект рефлексии по имени** — `Class.forName`, `getMethod`, +- **автодетект рефлексии по имени** - `Class.forName`, `getMethod`, `getField` с литералами попадают в keep автоматически. ## 3. Отладочная информация -`SourceFile` оригинальные у 160 классов, `LineNumberTable` — 10 218, -`LocalVariableTable` — имена локальных переменных, `@rn(name, description)` — +`SourceFile` оригинальные у 160 классов, `LineNumberTable` - 10 218, +`LocalVariableTable` - имена локальных переменных, `@rn(name, description)` - каталог всех модулей с описаниями. -**Что делаем иначе:** пасс `DebugInfo` — strip `SourceFile`, +**Что делаем иначе:** пасс `DebugInfo` - strip `SourceFile`, `LineNumberTable`, `LocalVariableTable`, `Signature`, `InnerClasses` + **ложная** отладочная информация (чужие имена и номера строк, чтобы деобфускатор выдавал правдоподобный, но чужой код). -Отсюда обязательное требование: **маппинги в отчёте** — иначе мы сами не +Отсюда обязательное требование: **маппинги в отчёте** - иначе мы сами не читаем свои stack trace'и. ## 4. Шаблонный flattening -Один regex нашёл 3530 автоматов: `while(true) { switch(i + i2) }`, метки — -константы, переходы — присваивания в одном basic block, тела блоков не +Один regex нашёл 3530 автоматов: `while(true) { switch(i + i2) }`, метки - +константы, переходы - присваивания в одном basic block, тела блоков не тронуты. **Что делаем иначе:** не одна схема, а **четыре** (`state-spread`, `computed-switch`, `butterfly`, `exception-goto`), с ротацией между методами и с перемешиванием внутри метода. Плюс `butterfly` и `exception-goto` -ломают «все переходы — присваивания». +ломают «все переходы - присваивания». Это единственная техника с **высоким риском**: любая ошибка в CFG = `VerifyError`. Поэтому блокером Flow объявлен не «напишем потом», а -`StackMapTable` — см. [`../stack.md`](../stack.md) §3. Без верификатора +`StackMapTable` - см. [`../stack.md`](../stack.md) §3. Без верификатора Flow не включается. ## 5. Opaque-предикаты -`(A^B)+((A&B)<<1)` — школьное тождество, узнаётся регуляркой и даёт +`(A^B)+((A&B)<<1)` - школьное тождество, узнаётся регуляркой и даёт бесплатный фолдинг остальных констант. Настоящих предикатов (зависящих от состояния и всегда истинных) нет. @@ -103,26 +103,26 @@ Flow не включается. 42 файла, ни одного входящего ссылока, `dangling=0` → BFS от 4 entrypoint'ов, которые открыто лежат в `fabric.mod.json`. -**Что делаем иначе:** `weave_reachable` — junk попадает в граф +**Что делаем иначе:** `weave_reachable` - junk попадает в граф достижимости, создаёт ложные рёбра и участвует в вызовах. Это свойство пасса, а не настройка: без вплетённости junk не включается вовсе. ## 7. Самоописание защиты `StringRuntime`, `KeyAssembler`, `AttestClient`, `RuntimeGuard`, -`SealedBoot`, `NativeGuard` — названия указывают атакующему, куда смотреть. +`SealedBoot`, `NativeGuard` - названия указывают атакующему, куда смотреть. **Что делаем иначе:** псевдонимы генерируются одним `NameGenerator` для -всех сущностей — классы, пакеты, поля, методы, junk и даже имена атрибутов +всех сущностей - классы, пакеты, поля, методы, junk и даже имена атрибутов и схем выглядят как обычные данные. Отдельного «защитного» стиля не существует, поэтому отличить его от остального кода не по чему. ## 8. Вторичные компоненты -`evo-agent.jar` вообще без обфускации, .NET-лаунчер — статическая XOR-таблица. +`evo-agent.jar` вообще без обфускации, .NET-лаунчер - статическая XOR-таблица. **Что делаем иначе:** обфускация применяется ко **всему архиву**, включая -ресурсы (`fabric.mod.json`, mixins, refmap, access widener) — см. пасс +ресурсы (`fabric.mod.json`, mixins, refmap, access widener) - см. пасс `Resources`. Отдельного «ядро защищено, остальное нет» не бывает. ## 9. Шифр без аутентификации @@ -131,16 +131,16 @@ entrypoint'ов, которые открыто лежат в `fabric.mod.json`. работает. **Уже сделано:** `salt` на строку, уникальный `nonce`, FNV-1a MAC в -`noxis-runtime::scheme`. `cipher:tag` лежат рядом — порча обнаруживается. +`noxis-runtime::scheme`. `cipher:tag` лежат рядом - порча обнаруживается. ## Что подтвердилось и не нужно повторять в выводах -Разбор дал и **опровержения** — важно не строить защиту на мифах: +Разбор дал и **опровержения** - важно не строить защиту на мифах: -- «сдвиги в байткоде бывают отрицательными» — по 1277 классам **0** случаев +- «сдвиги в байткоде бывают отрицательными» - по 1277 классам **0** случаев (32 559 сдвигов, константные всегда `1`); - `awc.g(...)` (session-gate) в билде **не вызывался ни разу**. Вывод: проверять гипотезу на статистике **до** того, как вкладываться -в технику против неё. Отсюда требование к `noxis-verify` — не «красиво +в технику против неё. Отсюда требование к `noxis-verify` - не «красиво обфусцировано», а измеримые метрики ([`priorities.md`](priorities.md)). diff --git a/docs/learnings/priorities.md b/docs/learnings/priorities.md index fee361e..b188c97 100644 --- a/docs/learnings/priorities.md +++ b/docs/learnings/priorities.md @@ -1,18 +1,18 @@ # Что улучшим: стратегия и приоритеты Материал собран из `info/evo-improvements.md` (план после разбора EvoVis) и -`info/evo-weaknesses.md` (провалы). Здесь — что мы берём, в каком порядке +`info/evo-weaknesses.md` (провалы). Здесь - что мы берём, в каком порядке и **что осознанно не делаем**. ## Стратегия в одном абзаце Максимум защиты при минимуме риска сломать рантайм. Дешёвые техники, -дающие 80% результата, включаются по умолчанию; дорогие и опасные — +дающие 80% результата, включаются по умолчанию; дорогие и опасные - включаются **только** после того, как для них есть верификатор. Каждая техника имеет измеримый критерий «сработала/не сработала», иначе это не защита, а мнение. -## P0 — базовый уровень (дёшево, снимает главные утечки) +## P0 - базовый уровень (дёшево, снимает главные утечки) | пункт | что это | пасс | статус | |---|---|---|---| @@ -20,63 +20,63 @@ | A2 | вычистить `LineNumberTable`/`LocalVariableTable`/`SourceFile`/`InnerClasses`/`Signature`/`MethodParameters` | DebugInfo | ✅ готово | | A3 | никаких сигнатурных имён у защитного кода, защитный код смешан с обычным | Rename + Noise | ✅ готово | | A4 | обфусцировать **весь** архив, включая ресурсы, mixin-JSON, access widener, service loader'ы | Resources | ✅ готово | -| — | маппинг в отчёт (иначе не читаем свои же stack trace) | Rename | ✅ готово | +| - | маппинг в отчёт (иначе не читаем свои же stack trace) | Rename | ✅ готово | Это самый дешёвый блок и одновременно **самый большой утечка** в разобранном образце: оригинальные `SourceFile` у 160 классов, имена полей и методов, каталог модулей в атрибутах. -## P1 — строки и константы +## P1 - строки и константы | пункт | что это | пасс | статус | |---|---|---|---| | B1 | ключ на строку + ротация 3–5 схем + MAC + уникальный nonce | Strings | ✅ готово | | B2 | **вывод** ключа в рантайме, а не константа в вызове | Strings + генератор декодера | ✅ готово | -| B3 | константы — не тождества, а вычисления, разнесённые по блокам | Constants | ✅ готово (int/long) | +| B3 | константы - не тождества, а вычисления, разнесённые по блокам | Constants | ✅ готово (int/long) | | B4 | **ложная** отладочная информация: чужие имена переменных и строк | DebugInfo | ✅ `SourceFile` | -B2 — главное отличие от разобранного образца: у него ключ был константным +B2 - главное отличие от разобранного образца: у него ключ был константным выражением в вызове и расшифровывался статически, без JVM. Мастер-секрет -теперь собирается в рантайме из двух частей (`k1 ^ k2`) декодером класса — +теперь собирается в рантайме из двух частей (`k1 ^ k2`) декодером класса - см. [`../stack.md`](../stack.md) §4. B4 пока покрывает только `SourceFile`; -ложные номера строк (`fake_line_numbers`) — см. [`../../todo.md`](../../todo.md) §3.6. +ложные номера строк (`fake_line_numbers`) - см. [`../../todo.md`](../../todo.md) §3.6. -## P2 — контрольный поток +## P2 - контрольный поток | пункт | что это | статус | |---|---|---| | C1 | **несколько схем** флаттенинга вместо одной, с мешаниной внутри метода | план | | C2 | верификатор обязателен: ошибка CFG = `VerifyError` | ✅ готово (кадры + `java -Xverify:all`) | -| C3 | реальный объём ≠ раздувание: бюджет на размер и время | ✅ размер; время — план | +| C3 | реальный объём ≠ раздувание: бюджет на размер и время | ✅ размер; время - план | Приоритет здесь строго подчинён: **C2 раньше C1**. Кадры `StackMapTable` и приёмка `java -Xverify:all` уже готовы (см. [`../stack.md`](../stack.md) -§3) — единственное, чего не хватает, это сами схемы флаттенинга (C1). +§3) - единственное, чего не хватает, это сами схемы флаттенинга (C1). -## P3 — измеримость (метрики, без которых ничего не доказано) +## P3 - измеримость (метрики, без которых ничего не доказано) Обязательный блок, иначе фраза «обфускация есть» ничего не означает. -Крейт `noxis-verify` реализован — вот что из списка уже цифра, а что ещё мнение: +Крейт `noxis-verify` реализован - вот что из списка уже цифра, а что ещё мнение: -1. **Утечки имён.** ✅ `collect_names`/`find_leaks` — сверка идентификаторов +1. **Утечки имён.** ✅ `collect_names`/`find_leaks` - сверка идентификаторов с учётом keep-списка. 2. **Читаемость декомпилята.** ⬜ Прогон JADX/CFR → доля распознанных - структур — не начато. + структур - не начато. 3. **Противник.** ✅ `scan_secrets` + `RED_PATTERNS` держим в репозитории; - ⬜ полноценный скрипт-деобфускатор (свёртка констант) как тест — план. -4. **Семантика.** ⬜ Эталонные программы до/после — не начато + ⬜ полноценный скрипт-деобфускатор (свёртка констант) как тест - план. +4. **Семантика.** ⬜ Эталонные программы до/после - не начато (сейчас семантику подтверждает только `java -Xverify:all`). 5. **Бюджеты.** ✅ размер (`inflation_ratio`/`check_inflation`); - ⬜ время исполнения — не начато. -6. **Стабильность между сборками.** ✅ `mappings_changed` — маппинги + ⬜ время исполнения - не начато. +6. **Стабильность между сборками.** ✅ `mappings_changed` - маппинги обязаны отличаться между прогонами с разным seed. Итог: `noxis-verify` закрывает красные паттерны, утечки имён, `java -Xverify:all`, бюджет размера и стабильность маппингов. Остаются -декомпилят-метрика, бюджет времени и эталонная семантика — три пункта, +декомпилят-метрика, бюджет времени и эталонная семантика - три пункта, где «нам кажется защищённым» всё ещё мнение, а не цифра. -## P4 — долгий горизонт (пока не делаем) +## P4 - долгий горизонт (пока не делаем) | пункт | что это | почему не сразу | |---|---|---| @@ -86,35 +86,35 @@ B2 — главное отличие от разобранного образц | D4 | честный anti-tamper, а не «зашейрь ключ» | целая инженерная задача, не пасс | | E | собственный нативный/Rust-цель вместо JVM | другой продукт, не наша цель | -D1 — явно ограничен: включать только для действительно ценных функций, -остальное оправдания не имеет. E — отдельный продукт; если он когда-нибудь +D1 - явно ограничен: включать только для действительно ценных функций, +остальное оправдания не имеет. E - отдельный продукт; если он когда-нибудь понадобится, наш IR как раз делает его возможным (те же пассы, другой writer). ## Что мы добавили сверх исходного плана 1. **Структурный гейт при компиляции.** Лимиты «250 строк / 4 файла» - гоняются в `build.rs` — см. [`../README.md`](../README.md). Без этого + гоняются в `build.rs` - см. [`../README.md`](../README.md). Без этого план P0–P3 превращается в один гигантский файл через месяц. 2. **Собственный IR с метками.** Исходный план предполагал работу поверх готового парсера. `Insn::Mark(Label)` убирает пересчёт смещений из - всех пассов — это меняет цену каждой техники, меняющей CFG. + всех пассов - это меняет цену каждой техники, меняющей CFG. 3. **Детерминированный PRNG с доменным разделением.** Без этого пункт «стабильность между сборками» невозможно проверить тестом. 4. **Keep как отдельный крейт (`noxis-analysis`), а не флаг CLI.** - Рефлексия, JNI, сериализация и entrypoint'ы — это анализ иерархии, + Рефлексия, JNI, сериализация и entrypoint'ы - это анализ иерархии, а не настройка, которую надо помнить руками. ## Порядок выполнения Смотрите [`../../todo.md`](../../todo.md). P0–P3 и P3-измеримость уже -готовы; единственный крупный пункт, который остался, — схемы `Flow` (C1), +готовы; единственный крупный пункт, который остался, - схемы `Flow` (C1), потому что верификатор (C2) для них уже есть: ```text ✅ P0 DebugInfo + Resources → главные утечки сняты ✅ P1 Strings (B2) + Constants → ключ выводится, схемы ротируются ✅ P1 backend (ristretto) + frames -✅ P2 Flow (C1) — готов (09.10.2026): 4 схемы + ротация + мешанина, приёмка настоящим `java` +✅ P2 Flow (C1) - готов (09.10.2026): 4 схемы + ротация + мешанина, приёмка настоящим `java` ✅ P3 метрики, красные паттерны, CLI ``` diff --git a/docs/learnings/reference.md b/docs/learnings/reference.md index 33f0637..eac6d74 100644 --- a/docs/learnings/reference.md +++ b/docs/learnings/reference.md @@ -37,7 +37,7 @@ | `DebugInfoStripper` | 69 | strip отладки | пасс `debug` | | `StringFilter` | 35 | что считать строкой | функция внутри `strings` | -**Вывод:** структура совпадает с нашей почти один в один — это подтверждает +**Вывод:** структура совпадает с нашей почти один в один - это подтверждает раскладку крейтов. Ключевое отличие: у них всё живёт в одном jar'е и одном проекте; у нас конвейер отделён от I/O и от CLI. @@ -47,11 +47,11 @@ У них: remap → strings → numbers → flow → junk → debug. Тот же инвариант, что и у нас: **переименование раньше всех техник, порождающих новые ссылки**, -а `debug` — последним. +а `debug` - последним. ### 3.2 Наследование как условие безопасности -`InheritanceAnalyzer` — самый большой после remap'ера модуль. Совпадает с +`InheritanceAnalyzer` - самый большой после remap'ера модуль. Совпадает с нашим выводом: переименование virtual-методов **без** иерархии ломает `invokevirtual`. У нас это `hierarchy` + семейства в `rename`. @@ -63,13 +63,13 @@ ### 3.4 Подпись срезается -`*.SF/*.RSA/*.DSA/*.EC` удаляются при перепаковке — после записи подпись +`*.SF/*.RSA/*.DSA/*.EC` удаляются при перепаковке - после записи подпись всё равно невалидна, а её наличие заставляет JVM падать. Это входит в `noxis-jar`. ### 3.5 Маппинг в файл + ложные ограничения -`--mappings` пишет соответствие «новое → старое» — без него автор сам не +`--mappings` пишет соответствие «новое → старое» - без него автор сам не читает stack trace. Плюс честные off-ключи: `--no-strings`, `--no-flow`, `--no-junk`, `--keep-lines`, `--keep-source`, `--keep-vars`, `--no-widen`. @@ -78,7 +78,7 @@ ### 3.6 Seed -`--seed` — детерминированный прогон. У нас это базовое свойство +`--seed` - детерминированный прогон. У нас это базовое свойство `noxis-core`, без отдельного флага. ## 4. Что не берём @@ -86,24 +86,24 @@ ### 4.1 Java + ASM Главное отличие стека. Пара Java+ASM даёт родную JVM, готовые visitor'ы и -`CheckClassAdapter` — но и: +`CheckClassAdapter` - но и: - нужно раскатывать JDK для сборки и запуска тестов; -- наш проект — Rust (`deny(unsafe_code)`, свои лимиты, свои гейты); +- наш проект - Rust (`deny(unsafe_code)`, свои лимиты, свои гейты); - отсутствует возможность отдавать один бинарь. Цена, которую мы платим: `StackMapTable` придётся считать самим (через -inference-верификатор — [`../stack.md`](../stack.md) §3). Это осознанно. +inference-верификатор - [`../stack.md`](../stack.md) §3). Это осознанно. ### 4.2 Swing GUI Лишняя точка входа: GUI дублирует CLI и удваивает площадь тестирования. -У нас — только clap. +У нас - только clap. ### 4.3 `StringDecoderGenerator` как «генератор текста» Они генерируют Java-исходник декодера и компилируют. Для нас это означало бы -зависимость от `javac` на этапе обфускации. Наш путь — генерировать +зависимость от `javac` на этапе обфускации. Наш путь - генерировать **байткод напрямую** через наш IR (`noxis-runtime` держит схемы, генератор собирает `ClassFile`). @@ -120,14 +120,14 @@ inference-верификатор — [`../stack.md`](../stack.md) §3). Это ## 5. Что проверяем на их smoke-тестах -В `target/` референса лежат готовые проверочные кейсы — их стоит +В `target/` референса лежат готовые проверочные кейсы - их стоит переиспользовать как наш regression-набор: | кейс | что ломает | |---|---| | `smoke-safe` | базовый round-trip, `Sample`/`MyMixin` | -| `smoke-inheritance` | `Base`/`Child`/`InheritedChild`/`ExternalChild` — семейства virtual и мосты | -| `smoke-flow` | `LoadAll` — загрузка всех классов, ловит `VerifyError` | +| `smoke-inheritance` | `Base`/`Child`/`InheritedChild`/`ExternalChild` - семейства virtual и мосты | +| `smoke-flow` | `LoadAll` - загрузка всех классов, ловит `VerifyError` | | `smoke-fields` | переименование полей и статических полей | | `smoke-aes` | шифрование строк, `RunStrings` сверяет расшифровку | | `Probe.java` | точечная проверка одной гипотезы | @@ -144,7 +144,7 @@ inference-верификатор — [`../stack.md`](../stack.md) §3). Это | keep-сканер | генератор Java-исходника | | срез подписей, патч ресурсов | единственная схема шифра | | маппинг в отчёт, честные off-ключи | эмпирическая верификация flow | -| их smoke-кейсы как regression | — | +| их smoke-кейсы как regression | - | -Полный перечень провалов защиты, которую они **не** закрывают, — в +Полный перечень провалов защиты, которую они **не** закрывают, - в [`lessons.md`](lessons.md). diff --git a/docs/stack.md b/docs/stack.md index 2ca2069..9cac8fe 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -1,6 +1,6 @@ # Стек: почему именно так -Документ фиксирует решения, которые трудно отменить. Для каждого — +Документ фиксирует решения, которые трудно отменить. Для каждого - альтернативы, критерии, цена и что меняет решение. ## 1. Собственный IR, а не API чужой библиотеки @@ -10,12 +10,12 @@ Code, Insn}`, а не с типами парсера. **Почему не «просто использовать готовую библиотеку целиком»:** -- библиотека диктует API — каждый пасс привязывается к чужим миграциям +- библиотека диктует API - каждый пасс привязывается к чужим миграциям мажорной версии (наш лимит «≤4 файлов» легко разрывается «транзитивно»); - у нас есть конкретное требование, которого нет ни в одном парсере: `Insn::Mark(Label)` вместо смещений; - мы не знаём заранее, захочет ли пользователь backend'а ещё одну - библиотеку (например, для `StackMapTable`) — изоляция через IR даёт + библиотеку (например, для `StackMapTable`) - изоляция через IR даёт смену backend'а без переписывания пассов. **Что IR даёт на практике:** @@ -25,26 +25,26 @@ Code, Insn}`, а не с типами парсера. | метки вместо смещений | пассы никогда не пересчитывают jump-офсеты | | `Attribute::Raw` | round-trip без потерь: неизвестный атрибут возвращается как есть | | `Program` включает ресурсы архива | переименование и патч ресурсов в одном прогоне | -| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` — ровно один раз | +| одна точка записи | пересчёт смещений, `max_stack` и `StackMapTable` - ровно один раз | -**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) — +**Цена:** конвертация `model ↔ backend` на границах (чтение и запись) - примерно один файл на направление. Дёшево. -## 2. Backend для чтения/записи — `ristretto_classfile` +## 2. Backend для чтения/записи - `ristretto_classfile` **Решение:** `format::parse` / `format::write` реализуются поверх `ristretto_classfile 0.34`, наш IR остаётся единим API для пассов. -Реализация — этап P1, до него обе функции возвращают `Unimplemented`. +Реализация - этап P1, до него обе функции возвращают `Unimplemented`. ### Кандидаты | крейт | чтение | запись | верификация | активность | загрузки | MSRV | |---|---|---|---|---|---|---| | **`ristretto_classfile` 0.34** | ✓ | ✓ | ✓ (JVMS §4.10.2) | 2026-09 | 41k | **1.97.1** | -| `jclassfile` | ✓ | — (parser) | — | 2026-08 | 28k | — | -| `classfile-parser` | ✓ | — (parser) | — | 2024-10 (устарел) | 31k | — | -| `java-bytecode` | ✓ | ✓ | ? | 2026-10 | 41 | — | -| `jvm-assembler` | — | — | — | **2015, мёртв** | 3k | — | +| `jclassfile` | ✓ | - (parser) | - | 2026-08 | 28k | - | +| `classfile-parser` | ✓ | - (parser) | - | 2024-10 (устарел) | 31k | - | +| `java-bytecode` | ✓ | ✓ | ? | 2026-10 | 41 | - | +| `jvm-assembler` | - | - | - | **2015, мёртв** | 3k | - | ### Критерии и что подтвердил spike @@ -56,14 +56,14 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( ### Что отвергнуто -- **Писать парсер с нуля** — спецификация JVMS §4 по формату классов - плюс MUTF-8, constant pool, модули, records, переполнения — это месяцы +- **Писать парсер с нуля** - спецификация JVMS §4 по формату классов + плюс MUTF-8, constant pool, модули, records, переполнения - это месяцы работы ради того, что уже написано и покрыто тестами. Это нарушает KISS и не добавляет ни одной защиты. -- **`classfile-parser`, `jclassfile`** — только чтение, значит writer всё +- **`classfile-parser`, `jclassfile`** - только чтение, значит writer всё равно наш; `classfile-parser` не обновлялся с 2024-10. -- **`java-bytecode`** — 41 загрузка: у проекта нет инерции и нет экосистемы. -- **`jvm-assembler`** — последний коммит в 2015. +- **`java-bytecode`** - 41 загрузка: у проекта нет инерции и нет экосистемы. +- **`jvm-assembler`** - последний коммит в 2015. ## 3. `StackMapTable`: генерируем сами, пользуемся верификатором @@ -80,36 +80,36 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( → StackMapTable → запись ``` -`ristretto_classfile` содержит всё нужное **в исходном виде** — но +`ristretto_classfile` содержит всё нужное **в исходном виде** - но снаружи, к сожалению, отдаёт не всё (см. результат спайка ниже): -- `verifiers/bytecode/inference.rs` — итеративный dataflow, LUB, фикспоинт; -- `verifiers/bytecode/control_flow.rs` — `compute_successors`, worklist; -- `verifiers/bytecode/stackmap.rs` — декодер/кэш `StackMapTable`; -- `attributes/{max_stack,max_locals,instruction/stack_effect}.rs` — +- `verifiers/bytecode/inference.rs` - итеративный dataflow, LUB, фикспоинт; +- `verifiers/bytecode/control_flow.rs` - `compute_successors`, worklist; +- `verifiers/bytecode/stackmap.rs` - декодер/кэш `StackMapTable`; +- `attributes/{max_stack,max_locals,instruction/stack_effect}.rs` - пересчёт `max_stack`/`max_locals` из инструкций; -- `attributes/{stack_frame,verification_type}.rs` — типы для записи. +- `attributes/{stack_frame,verification_type}.rs` - типы для записи. **Почему это лучше, чем свои кадры:** это ровно та часть JVMS, в которой ошибка невидима в исходниках и всплывает только на `VerifyError` в чужом -рантайме. Писать такой верификатор — самая дорогая и самая опасная часть +рантайме. Писать такой верификатор - самая дорогая и самая опасная часть всего проекта. **Альтернативы, которые отвергнуты:** -- **Понижать версию класса до 49 и выкинуть `StackMapTable`** — ломает +- **Понижать версию класса до 49 и выкинуть `StackMapTable`** - ломает `invokedynamic`/лямбды и включает дорогой fallback-верификатор; на Java 9+ это заметно и противоречит цели. -- **Не делать Flow вообще** — лишает технику, которая даёт ★★★★ к +- **Не делать Flow вообще** - лишает технику, которая даёт ★★★★ к читаемости. Не вариант: вычислим кадры. -**Результат spike'а (`tests/spike_frames.rs`) — главный вывод раздела:** +**Результат spike'а (`tests/spike_frames.rs`) - главный вывод раздела:** | что проверяли | что получили | |---|---| -| `verify_class` + `FallbackStrategy::AlwaysInference` на классе с ветвлением и без `StackMapTable` | ✅ `success`, `path_used == Inference` — движок вывода типов работает | -| `FallbackStrategy::Strict` на том же классе | ✅ `Err("StackMapTable required ... with control flow")` — требование v50+ реально проверяется | -| `StackMapTable` с кадром **не на том** сдвиге | ✅ `Err("Merge point at offset N requires StackMapTable frame")` — ошибка локализована точно | +| `verify_class` + `FallbackStrategy::AlwaysInference` на классе с ветвлением и без `StackMapTable` | ✅ `success`, `path_used == Inference` - движок вывода типов работает | +| `FallbackStrategy::Strict` на том же классе | ✅ `Err("StackMapTable required ... with control flow")` - требование v50+ реально проверяется | +| `StackMapTable` с кадром **не на том** сдвиге | ✅ `Err("Merge point at offset N requires StackMapTable frame")` - ошибка локализована точно | | получить сам кадр из крейта | ❌ **нельзя** | Причина последней строки: в `ristretto_classfile/src/verifiers/bytecode.rs` @@ -118,7 +118,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( `verify_method`, `VerificationResult`, `VerifierConfig` и `VerificationContext`. Диагностика в `VerificationResult.diagnostic` заполняется лишь на отдельных путях отказа, а в строгом режиме крейт -возвращает `Err(VerifyError)` целиком — то есть **ожидаемый кадр до нас +возвращает `Err(VerifyError)` целиком - то есть **ожидаемый кадр до нас не доходит**, только его смещение. **Что это меняет для P1.** Кадры считаем сами, а крейт используем как @@ -132,7 +132,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( Три способа получить сами кадры, в порядке предпочтения: -1. ~~**Вендор `verifiers::bytecode::inference`.**~~ — **отвергнут после +1. ~~**Вендор `verifiers::bytecode::inference`.**~~ - **отвергнут после оценки объёма.** Один модуль не оторвать: в `inference.rs` 14 ссылок `crate::`, включая private-модули `frame`, `type_system`, `handlers`, `control_flow`, `diagnostics`. Вендорить пришлось бы весь @@ -140,19 +140,19 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( `src/`**, 2.3 MB) ради одной патчи `pub(crate)` → `pub`. Чужой код становится третьей по объёму частью репозитория, отстаёт от апстрима и не проходит наши правила (250 строк / 4 файла). -2. **Свой вывод на своём IR — принято.** Кадры считаются по `noxis::model`, +2. **Свой вывод на своём IR - принято.** Кадры считаются по `noxis::model`, а не по ристретто: `format::parse` → IR → пассы → `compute_frames(IR)` → `Attribute::StackMapTable` → `format::write`. Ристретто остаётся **парсером, писателем и приёмочным тестом** из crates.io, без форка. -3. ~~Итеративная починка через `diagnostic`~~ — **отвергнута спайком**: поля +3. ~~Итеративная починка через `diagnostic`~~ - **отвергнута спайком**: поля `expected`/`pre_frame` в этом пути недостижимы. **Почему 2, а не 1.** Наш dataflow живёт на типах, которые и так нужны пассам: `Insn`, метки вместо смещений, иерархия классов из -`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры — это ровно +`noxis-analysis::hierarchy` для LUB на merge-точках. Кадры - это ровно те же инструкции, что уже переписывает `rename::rewrite`, поэтому промежуточная конвертация `model → Instruction[] → model` (требование -вендора) добавляет ещё один источник расхождений. Цена — своя реализация +вендора) добавляет ещё один источник расхождений. Цена - своя реализация (~1 000–1 500 строк: границы блоков, worklist, merge, LUB, эмиссия `StackFrame`), но она покрывается двумя независимыми судьями: @@ -164,7 +164,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( java -Xverify:all jar ``` -Второй судья — ристретто `verify_class` со стратегией `Strict` — ловит +Второй судья - ристретто `verify_class` со стратегией `Strict` - ловит ровно тот класс ошибок, ради которого вендор и рассматривался: неверный сдвиг кадра, пропущенная merge-точка, неверный тип. @@ -173,12 +173,12 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( ## 4. Генерация рантайма, а не вшитая таблица -**Решение:** `noxis-runtime` — это **заготовки Rust-кода**, из которых +**Решение:** `noxis-runtime` - это **заготовки Rust-кода**, из которых собирается класс-декодер (P2), а не готовый бинарь. -**Почему:** ABI декодера — `d(Ljava/lang/String;I)Ljava/lang/String;`, +**Почему:** ABI декодера - `d(Ljava/lang/String;I)Ljava/lang/String;`, но схем шифрования несколько (`xor-splitmix`, `xtea-ctr`) и их число -растёт. Константная таблица селекторов рядом с текстом — ровно та ошибка, +растёт. Константная таблица селекторов рядом с текстом - ровно та ошибка, которую мы разбирали (`info/evo-weaknesses.md` §1): **ключ рядом с шифротекстом**. Генератор выбирает схемы на этапе сборки артефакта и кодирует выбор в самих вызовах, а мастер-секрет выводится в рантайме. @@ -193,7 +193,7 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( байт-в-байт одинаковый jar. Это свойство, а не удобство: без него воспроизводимая сборка, откат и сравнение версий невозможны. - **Доменное разделение:** `ctx.rng("rename/classes")`, - `ctx.rng("noise/junk")` — сид деривается из имени домена, поэтому + `ctx.rng("noise/junk")` - сид деривается из имени домена, поэтому случайности разных пассов не статистически коррелированы. - **Зависимость `rand` тянет** OS-энтропию и меняющееся поведение между версиями. Нам нужен ровно обратный эффект. @@ -201,17 +201,17 @@ MSRV ≤ 1.97.1 (наш тулчейн), лицензия `Apache-2.0 OR MIT` ( ## 6. ZIP: `zip` Критерии: чтение, запись, **сохранение порядка и сжатия**, контроль -дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` — +дубликатов. Берём `zip` (стабильный релиз, без `time`/`aes`/`zstd` - лишние фичи только раздувают бинарь; `default-features = false, features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) вырезаем на -уровне записей архива — после перепаковки она всё равно невалидна. +уровне записей архива - после перепаковки она всё равно невалидна. -Альтернатива `zip6` — форк того же кода; при равном API он даёт меньше +Альтернатива `zip6` - форк того же кода; при равном API он даёт меньше тестов и меньшую инерцию. Нет причины платить за это. -**MSRV воркспейса поднят до 1.97** — это ровно наш тулчейн (rustc 1.97.1) +**MSRV воркспейса поднят до 1.97** - это ровно наш тулчейн (rustc 1.97.1) и минимум `ristretto_classfile 0.34`. Заявлять 1.85, когда зависимость -требует 1.97, — враньё в `Cargo.toml`. +требует 1.97, - враньё в `Cargo.toml`. ## 7. CLI: `clap` (derive) @@ -221,11 +221,11 @@ features = ["deflate"]`). Подпись (`*.SF/*.RSA/*.DSA/*.EC`) выреза ## 8. Верификация: `java -Xverify:all` первым делом -Единственная честная проверка «я ничего не сломал» — запустить настоящую +Единственная честная проверка «я ничего не сломал» - запустить настоящую JVM. `noxis-verify` гоняет `java -Xverify:all` на обфусцированном jar и по red-паттернам ищет дыры в защите. -**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` — +**Запасной вариант, если JDK не гарантирован:** `ristretto_vm` - встраиваемый JVM на Rust из того же репозитория. Для нас это означает `noxis-verify` без внешних зависимостей. Стоит проверить в P3, пока используем системный `java` (в окружении есть OpenJDK 21). diff --git a/docs/techniques.md b/docs/techniques.md index 3948599..b1c7397 100644 --- a/docs/techniques.md +++ b/docs/techniques.md @@ -1,43 +1,43 @@ # Как работает: техники, плюсы, минусы Каждая техника описана одинаково: **как работает → что даёт → что стоит / -где ломается**. Цена указана честно: обфускация — это всегда обмен +где ломается**. Цена указана честно: обфускация - это всегда обмен читаемости и времени против аналитика. -Итоговая таблица — в конце. +Итоговая таблица - в конце. ## 1. Переименование (`Rename`) **Как работает.** Классы получают случайные имена в случайных пакетах (или в исходном пакете, если `spread_packages = false`). Поля и методы переименовываются, если `method_rename_safety` считает их безопасными -(private/static/final — сразу; virtual — только «семейством» на всю +(private/static/final - сразу; virtual - только «семейством» на всю иерархию, чтобы не разъединить переопределения и мостовые методы). Затем переписываются дескрипторы, `LDC`-классы, вызовы, поля, исключительная таблица, `Signature`/`Exceptions`/`InnerClasses`. **Плюсы.** - Самая дешёвая и самая отдающая техника: читает `javap -p` любой анализатор. -- Не меняет семантику — риск сломать код минимален. +- Не меняет семантику - риск сломать код минимален. - Маппиинг пишется в отчёт → stack trace'и переводятся обратно. **Минусы / цена.** - Keep-список обязателен: рефлексия по имени (`Class.forName("pkg.C")`), - JNI, `serialVersionUID`, сериализация, entrypoint'ы. Пропустил — получил + JNI, `serialVersionUID`, сериализация, entrypoint'ы. Пропустил - получил `ClassNotFoundException` в рантайме. - Рефлексия через строки мы ловим автоматически (анализ `Class.forName`, - `getMethod`/`getField` и т.п.), но динамическую конкатенацию — нет. + `getMethod`/`getField` и т.п.), но динамическую конкатенацию - нет. - JSON-(де)сериализация по именам полей (Gson/Jackson/`Codec`) не ловится: - биндинг идёт через `X.class`-литерал, не строку — см. `todo.md` §2. + биндинг идёт через `X.class`-литерал, не строку - см. `todo.md` §2. - Для библиотек `rename_public_members = false` по умолчанию: менять - публичный API библиотеки — сломать всех потребителей. + публичный API библиотеки - сломать всех потребителей. Шесть стилей имён (`ShortLatin`, `IlO0`, `Unicode`, `MixedLatin`, -`DecoyWords`, `Plausible`) — компромисс по-разному: `IlO0`/`Unicode` явно +`DecoyWords`, `Plausible`) - компромисс по-разному: `IlO0`/`Unicode` явно сигнализируют «это обфускация», `Plausible` маскируется под обычный прикладной код (`TextureCache`, `resolveToken`), ценой того, что при ручном чтении он менее заметно отличается от настоящей логики, чем -намеренный мусор — скрыть обфускацию и скрыть её цену ценой чуть большей +намеренный мусор - скрыть обфускацию и скрыть её цену ценой чуть большей путаницы для собственной команды при отладке маппингов. **Статус: готово.** @@ -51,13 +51,13 @@ уникален на строку, MAC ловит порчу. **Плюсы.** -- Строки — главный источник логики (`"admin"`, URL, SQL, regex). +- Строки - главный источник логики (`"admin"`, URL, SQL, regex). - Ротация схем: один скрипт деобфускации не покрывает весь jar. - MAC: подмена шифротекста не «молча» портит текст, а падает. **Минусы / цена.** - Декодер добавляет вызов на каждый доступ к строке → оверхед в рантайме. -- Целевой анализ декодера всё равно раскроет текст; задача — поднять цену, +- Целевой анализ декодера всё равно раскроет текст; задача - поднять цену, а не сделать невозможным. - Мастер-секрет собирается в декодере из двух частей (`k1 ^ k2`). @@ -68,7 +68,7 @@ **Как работает.** Числовая константа не хранится как есть, а собирается из разных выражений: `x ^ k`, `a + b`, `(a << 1) + c`. Плюс настоящие -opaque-предикаты — условия, истинность которых зависит от инварианта +opaque-предикаты - условия, истинность которых зависит от инварианта (например, `i*(i+1) % 2 == 0`), а не от константы. **Плюсы.** @@ -80,7 +80,7 @@ opaque-предикаты — условия, истинность которы (`executor` для `LCONST/ICONST`-цепочек). - Развдувание кода растёт по квадрату от числа вставок. - Шаблонный opaque-предикат (всегда один и тот же `A+B`) узнаётся - паттерном — отсюда требование нескольких схем. + паттерном - отсюда требование нескольких схем. **Статус: готово** для `int`/`long` (`xor`/`add`/`shift` + opaque-предикаты); `float`/`double` не трогаются. @@ -98,22 +98,22 @@ opaque-предикаты — условия, истинность которы **Минусы / цена.** - **Самая дорогая и самая опасная техника.** Требует пересчёта - `StackMapTable` для классов версии ≥ 50 — иначе JVM не грузит класс. + `StackMapTable` для классов версии ≥ 50 - иначе JVM не грузит класс. - Ломает профилировщики, coverage-инструменты и отладку. -- Нужен верификатор: любая ошибка в CFG — это `VerifyError` в рантайме. +- Нужен верификатор: любая ошибка в CFG - это `VerifyError` в рантайме. - Шаблонная схема (все методы перестроены одинаково) детектируется гистограммой длины блоков. **Статус: готов** (`noxis-passes/src/flow/`). Одна схема на метод (ротация через PRNG) плюс нарезка тела `goto`-сплайсами во всех схемах; кадр -`StackMapTable` пересчитывается после каждой перестройки. Приёмка — +`StackMapTable` пересчитывается после каждой перестройки. Приёмка - настоящий `java -Xverify:all` и исполнение под JVM (`calc` печатает 45 после всех схем). Ограничения: `` пропускается (форма super-call-first); методы с `jsr`/`ret`, короче 3 инструкций и длиннее `max_insns` пропускаются; `encrypt_block_bodies` добавляет opaque-guard на входе. Два правила, выученных на крови (см. `StackMapTable`): вставленная метка не должна делить байт-смещение с чужой (иначе кадры -мерджатся из несовместимых состояний — после каждой такой метки `nop`), +мерджатся из несовместимых состояний - после каждой такой метки `nop`), а инструкция после терминальной обязана иметь свой кадр (поэтому dead-блоки входят через opaque-ветвь и кончаются `athrow`, а не висят недостижимыми). @@ -129,32 +129,32 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц **Минусы / цена.** - Reachability-анализ отсеивает junk мгновенно: класс, на который никто - не ссылается, — мёртвый груз. Отсюда обязательное `weave_reachable`. -- Пустые методы/тела — признак, который ищут первым. + не ссылается, - мёртвый груз. Отсюда обязательное `weave_reachable`. +- Пустые методы/тела - признак, который ищут первым. - Раздувание jar. **Статус: готово** (junk-классы + `weave_reachable`; junk-аннотации -пропускаются — сырые атрибуты не несут свежие индексы пула). +пропускаются - сырые атрибуты не несут свежие индексы пула). ## 6. Отладочная информация (`DebugInfo`) **Как работает.** Strip `LineNumberTable`, `LocalVariableTable`; подмена `SourceFile` на ложное имя; опционально strip `Signature`/`InnerClasses`. -Плюс *ложная* отладочная информация — чужие имена переменных и номера строк. +Плюс *ложная* отладочная информация - чужие имена переменных и номера строк. **Плюсы.** - Практически бесплатно. -- Восстанавливает исходные имена переменных и файлов — прямая утечка. +- Восстанавливает исходные имена переменных и файлов - прямая утечка. **Минусы / цена.** - Убивает нормальные stack trace'и (поэтому маппинги в отчёте обязательны). - Слишком «чистый» класс (`LineNumberTable` есть, а `SourceFile` = `X.java`, - которого нет) — тоже признак. + которого нет) - тоже признак. **Статус: готово** (strip + `spoof_source_file`, детерминированная подмена от мастер-сида). Не реализовано: `fake_line_numbers` (ложные номера строк, а не только `SourceFile`) и `strip_source_file` (полное удаление вместо -подмены) — см. [`todo.md`](../todo.md) §3.6. +подмены) - см. [`todo.md`](../todo.md) §3.6. ## 7. Ресурсы (`Resources`) @@ -164,7 +164,7 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц **Плюсы.** - Без этого обфускация Fabric-мода просто не стартует. -- Убранные подписи — иначе jar считается подделанным. +- Убранные подписи - иначе jar считается подделанным. **Минусы / цена.** - Патч только текстовых ресурсов: бинарные (картинки, классы в `versions/`) @@ -189,6 +189,6 @@ super-call-first); методы с `jsr`/`ret`, короче 3 инструкц | Flow | ★★★★ | **высокий** | высокая | план, кадры готовы | Вывод, который стоит держать в голове: **Rename + Resources + DebugInfo** -дают большую часть защиты за небольшую цену. Flow — единственная техника, +дают большую часть защиты за небольшую цену. Flow - единственная техника, которая может сломать рантайм, и она же единственная, чья отдача не покрывает риск без верификатора. diff --git a/info/README.md b/info/README.md index 8029284..079770a 100644 --- a/info/README.md +++ b/info/README.md @@ -1,4 +1,4 @@ -# info/ — материал по обфускации EvoVis (для нашего обфускатора) +# info/ - материал по обфускации EvoVis (для нашего обфускатора) Источник: `LoVisual/ref/evo/` (реверс EvoVis / EvolutionDLC, Minecraft-клиент). Сюда перенесено только то, что касается защиты/обфускации кода. @@ -7,7 +7,7 @@ |---|---| | `evo-obfuscation.md` | базовое описание трёх уровней обфускации (перенесено as-is из `ref/evo/obfuscation.md`) | | `evo-obfuscation-full.md` | **сводный разбор**: все уровни + .NET-лаунчер + натив/антиотладка, с кодом и проверенными цифрами | -| `evo-weaknesses.md` | где защита провалилась при разборе — чего нам делать нельзя (+ опровержения) | +| `evo-weaknesses.md` | где защита провалилась при разборе - чего нам делать нельзя (+ опровержения) | | `evo-improvements.md` | что сделать в своём обфускаторе, чтобы этих дыр не было | | `evo-payload-pipeline.md` | доставка: AES-256-GCM payload + java-agent + подменённый JVM | | `evo-native-reverse.md` | реверс `evoguard.dll` / `java.dll`: HMAC-шейры, антиотладка, порча ключа | diff --git a/info/evo-improvements.md b/info/evo-improvements.md index 51af8a3..8f5339e 100644 --- a/info/evo-improvements.md +++ b/info/evo-improvements.md @@ -1,7 +1,7 @@ # Как сделать лучше: план улучшений для нашего обфускатора -Привязка: референс — EvoVis (`evo-obfuscation-full.md`), провалы — `evo-weaknesses.md`. -Ниже — что делаем иначе, в порядке отдачи/цены. Для каждой техники — уровень угрозы, +Привязка: референс - EvoVis (`evo-obfuscation-full.md`), провалы - `evo-weaknesses.md`. +Ниже - что делаем иначе, в порядке отдачи/цены. Для каждой техники - уровень угрозы, которую она закрывает (скрипт-деобфускатор / статический анализ / ручной реверс). --- @@ -14,20 +14,20 @@ - Индивидуальный алфавит на каждый символ (см. B1), запрет ключевых слов и одинаковых имён в одном scope. - Никаких «readable» ярлыков: у EvoVis `@rn(name=…)` и `description` отдали каталог - модулей целиком. Если метаданные нужны рантайму — шифруем их (C2). + модулей целиком. Если метаданные нужны рантайму - шифруем их (C2). ### A2. Вычистить отладочную информацию - `-g` → `LineNumberTable` / `LocalVariableTable` / `SourceFile` / `InnerClasses` - / `EnclosingMethod` / `MethodParameters` / `Signature` — по policy: либо полностью + / `EnclosingMethod` / `MethodParameters` / `Signature` - по policy: либо полностью выкинуть, либо заменить на шум (см. B4). - Ресурсы тоже часть кода: mixin-JSON, `fabric.mod.json`, access widener, service - loaders, SPI-файлы — шифруем/шифруем имена внутри, раскрываем в рантайме. + loaders, SPI-файлы - шифруем/шифруем имена внутри, раскрываем в рантайме. ### A3. Никаких сигнатурных имён у защитного кода `KeyAssembler`, `RuntimeGuard`, `NativeGuard` → рандомные имена, а сам защитный код **смешиваем с обычным** (секции, которые выглядят как мусорные утилиты). -Рефлексия по именам в открытом виде (`Class.forName("aln")`) — запрещена: -имена отдаются только из зашифрованной строки (C2), лучше — через indirection-таблицу. +Рефлексия по именам в открытом виде (`Class.forName("aln")`) - запрещена: +имена отдаются только из зашифрованной строки (C2), лучше - через indirection-таблицу. ### A4. Обфусцировать весь артефакт, а не только ядро Правило: **нет незащищённого компонента**. У EvoVis лаунчер и java-agent были @@ -42,9 +42,9 @@ Вместо общего LCG: - seed = `random_u64` на строку (храним неявно: derive из позиции/ид + master-seed); - алгоритм выбирается из набора (3–5 схем: RC4-like, chacha-с fragments, XTEA-блочный, - plain XOR с keystream от BLAKE3, base85+flate перед шифром) — **перемешивание схем + plain XOR с keystream от BLAKE3, base85+flate перед шифром) - **перемешивание схем между строками**, чтобы один скрипт не покрыл всё; -- AAD/MAC: `HMAC(fragment, plaintext_hash)` — повреждение ловится, подмена — тоже; +- AAD/MAC: `HMAC(fragment, plaintext_hash)` - повреждение ловится, подмена - тоже; - keystream не повторяется: nonce включает индекс строки. - **против обучения по частотам**: словарь/частотный анализ не работает, если схема и nonce разные. @@ -53,18 +53,18 @@ Никаких `d(cipher, literal_key)`: - ключ = `KDF(master_key, class_id, method_id, string_index)` либо приходит из защищённого контекста (см. D3); -- вычисление — через серию операций, **растворённых в теле вызывающего метода** - (не выделенная функция `decrypt(s)` — иначе её найдут и развернут); +- вычисление - через серию операций, **растворённых в теле вызывающего метода** + (не выделенная функция `decrypt(s)` - иначе её найдут и развернут); - мастер-секрет не в константах: собирается из нескольких мест + env/хост-фактора. -### B3. Константы — не тождества, а вычисления +### B3. Константы - не тождества, а вычисления Отказ от `(A^B)+((A&B)<<1)` (опознаётся regex'ом). Вместо этого: - константа собирается из **нескольких узлов**, разнесённых по разным basic block'ам и разным методам (возврат частей, сложение на месте использования); - включаем в цепочку **зависимые от окружения** величины, которые статически вычислимы не полностью: `hashCode()` строки-литерала, `System.identityHashCode` статической константы, значение поля, инициализируемого в ``, размер массива; -- для `int`/`long`/`float`/`double` — отдельные схемы (bit-cast, разложение по +- для `int`/`long`/`float`/`double` - отдельные схемы (bit-cast, разложение по мантиссе), потому что `double`-константы особенно заметны. - opaque-предикаты делать **настоящими**: всегда истинные, но зависящие от состояния, которое статически не выводится (напр. инвариант «массив отсортирован по заполнению», @@ -85,10 +85,10 @@ Минимум набор (применять **случайно и по-разному в разных методах**): 1. **Switch по вычислимому индексу**: `table[ (x*31 ^ y) & mask ]` → адрес возврата; переходы через индексы, а не метки. -2. **Распределённое состояние**: как у EvoVis (`i + i2`), но одно слагаемое — - из поля объекта/статики, второе — из параметра, обновление — через вызов метода; +2. **Распределённое состояние**: как у EvoVis (`i + i2`), но одно слагаемое - + из поля объекта/статики, второе - из параметра, обновление - через вызов метода; **метки case не константные**, а пересчитанные (opaque). -3. **Бинарное дерево/бабочка**: вместо линейного списка состояний — рандомизированный +3. **Бинарное дерево/бабочка**: вместо линейного списка состояний - рандомизированный граф переходов, эквивалентный исходному CFG (проверяется верификатором). 4. **Вызов через стек/ретурн-адрес**: эмуляция `goto` через `athrow`-как-прыжок (exception-as-goto), `finally`-цепочки, или virtual-dispatch по фейковому интерфейсу. @@ -101,9 +101,9 @@ Плюс автоматические метрики (см. F). ### C3. Реальный объём ≠ раздувание -У EvoVis выигрыш — только в размере декомпилята, но и цена (читаемость для нас же). +У EvoVis выигрыш - только в размере декомпилята, но и цена (читаемость для нас же). Оптимальнее: комбинировать обфускацию с **компиляцией в другой целевой код** -(см. E), где раздувание не надо — там и так нечем читать. +(см. E), где раздувание не надо - там и так нечем читать. --- @@ -111,7 +111,7 @@ ### D1. Маленький VM / interpreter для критичных кусков Свой bytecode (10–20 опкодов: арифметика, ветвление, вызов, доступ к полю), -инструкции в зашифрованном массиве, диспетчер — обычный код с обфусцированным CFG. +инструкции в зашифрованном массиве, диспетчер - обычный код с обфусцированным CFG. Экспортируем в VM только чувствительное: лицензирование, ключи, критическую логику. Это единственный уровень, который реально останавливает ручной реверс. @@ -122,38 +122,38 @@ ### D3. Анти-анализ в рантайме (мягкий) - self-check целостности: `SHA-256` своих классов/секций → при расхождении - **меняем поведение**, а не падаем (у EvoVis падение выдавало мусорный ключ — + **меняем поведение**, а не падаем (у EvoVis падение выдавало мусорный ключ - правильно: не сообщать атакующему, что он пойман); -- детект отладчика/трейсера/инструментов эмуляции — реакция: ложные данные, +- детект отладчика/трейсера/инструментов эмуляции - реакция: ложные данные, замедление, отключение функций; -- **не детектить и не ломать легитимные сценарии** (обновления, IDE, символы) — +- **не детектить и не ломать легитимные сценарии** (обновления, IDE, символы) - ложные срабатывания дороже, чем польза защиты. ### D4. Честный anti-tamper, а не «зашейрь ключ» У EvoVis сильная идея: ключ = изолированная доля в подменённом JVM + HMAC от hwid + -порча при детекте. Минус — вся схема работает только на своём окружении и падает +порча при детекте. Минус - вся схема работает только на своём окружении и падает на другой ОС/сборке. Для нас: секрет должен переживать **наши** легитимные платформы, а ломаться только при модификации кода/подмене целостности. --- -## E. Если цель — не JVM, а свой натив/Rust-бинарник +## E. Если цель - не JVM, а свой натив/Rust-бинарник Референс JVM-слабости здесь не работают автоматически, но появляются свои: -- **строки**: все plaintext-строки в `.rodata` — шифровать, раскрывать лениво; +- **строки**: все plaintext-строки в `.rodata` - шифровать, раскрывать лениво; не держать AES-ключ рядом с шифротекстом (та же ошибка, что у `awc.d`); -- **имена**: `stripped` бинарник + рандомизация символов; в Rust — макрос, который +- **имена**: `stripped` бинарник + рандомизация символов; в Rust - макрос, который переименовывает `pub`-символы и убирает `#[doc]`/Debug-имена; -- **panic/Debug-вывод**: `{:?}` отдаёт имена типов и полей — для чувствительных типов +- **panic/Debug-вывод**: `{:?}` отдаёт имена типов и полей - для чувствительных типов свой `Debug`, либо не выводить; - **CFG**: обфускация на уровне MIR/LLVM-IR (passes): bogus control flow, flattening на `switch` по вычисленному индексу, вставка недостижимых блоков, размытие границ функций (inlining + outlined); - **константы**: как B3, плюс вынос в вычисляемые таблицы; - **анти-анализ**: `ptrace`-selfcheck, проверка `/proc/self/maps` на свои секции, - timing-checks; реакция — молчаливая порча данных; + timing-checks; реакция - молчаливая порча данных; - **обфускация метаданных Rust**: `TypeId`/`Any`, `std::any::type_name`, логи - `tracing` — выключать в release. + `tracing` - выключать в release. --- @@ -161,25 +161,25 @@ Автотесты на каждую технику, гоняются в CI: -1. **Утечки имён**: `strings`/анализ констант — не должно остаться ни одного +1. **Утечки имён**: `strings`/анализ констант - не должно остаться ни одного осмысленного идентификатора из списка-эталона (ошибка = фейл). 2. **Читаемость декомпилята**: прогнать JADX/CFR на собранный артефакт → метрики: доля распознанных структур (try/catch, switch, циклы), доля классов с восстановленными именами, число строк «смыслового» кода. 3. **Автоматический деобфускатор-противник**: минимальный скрипт-анализатор - (тот же `expr`-свёртка, regex на паттерны) — если он снимает >X% строк/констант, + (тот же `expr`-свёртка, regex на паттерны) - если он снимает >X% строк/констант, техника провалена. Держим этот «красный список паттернов» в репозитории. -4. **Семантика**: тесты/эталонные программы до и после — 100% совпадение. +4. **Семантика**: тесты/эталонные программы до и после - 100% совпадение. 5. **Размер и скорость**: бюджет (≤ N% размера, ≤ M% времени исполнения), - отдельно — бюджет на рантайм-раскрытие строк (кэш, чтобы не платить на каждый вызов). + отдельно - бюджет на рантайм-раскрытие строк (кэш, чтобы не платить на каждый вызов). 6. **Стабильность между сборками**: имена/схемы меняются от билда к билду - (у EvoVis шифр один на все версии — окно для обучения). + (у EvoVis шифр один на все версии - окно для обучения). ## Приоритет -1. A1+A2+A4 (символы, отладка, всё артефакты) — дёшево, biggest leak у EvoVis. -2. B1+B2 (пер-строчные ключи, вывод ключа) — снимает главный провал `awc.d`. +1. A1+A2+A4 (символы, отладка, всё артефакты) - дёшево, biggest leak у EvoVis. +2. B1+B2 (пер-строчные ключи, вывод ключа) - снимает главный провал `awc.d`. 3. C1 несколько схем флаттенинга + C2 верификатор. -4. F метрики — чтобы не спорить на глаз. -5. C3/E (другой целевой код) — как долгосрочный путь. -6. D1 VM — только для действительно ценных кусков, остальное не оправдывает цену. +4. F метрики - чтобы не спорить на глаз. +5. C3/E (другой целевой код) - как долгосрочный путь. +6. D1 VM - только для действительно ценных кусков, остальное не оправдывает цену. diff --git a/info/evo-native-reverse.md b/info/evo-native-reverse.md index 53adcad..6852f9b 100644 --- a/info/evo-native-reverse.md +++ b/info/evo-native-reverse.md @@ -25,18 +25,18 @@ cd ref/evo/reverse |---|---|---|---| | `mod/jar/META-INF/evo/native/win-x64/evoguard.dll` | 51 321 | 18 | своя маленькая JNI-библиотека (MinGW, PE32+) | | `mod/evojdk_x/bin/java.dll` | 111 104 | 6 | стандартный `java.dll` из JDK **+ новая функция** | -| `mod/evojdk_x/bin/server/jvm.dll` | 15 463 936 | — | **патченный HotSpot** + новый экспорт | +| `mod/evojdk_x/bin/server/jvm.dll` | 15 463 936 | - | **патченный HotSpot** + новый экспорт | Отдельно (Java, не натив): `mod/evojdk_x/classes/java/lang/EvoShare.class`, -исходник — `mod/evoshare/sources/java/lang/EvoShare.java` (JADX), вызывающий код — +исходник - `mod/evoshare/sources/java/lang/EvoShare.java` (JADX), вызывающий код - `mod/agent-src/sources/evo/agent/key/NativeJvmShare.java`. --- -## 1. evoguard.dll — JNI-«нативный шейр» +## 1. evoguard.dll - JNI-«нативный шейр» Экспорт ровно один (плюс mingw-рантайм `_CRT_INIT`, `DllMainCRTStartup`, `atexit`, -`sha256_block/update/final`, `env_flags_debug`, `hmac_sha256.constprop.0`) — **`JNI_OnLoad` нет**, +`sha256_block/update/final`, `env_flags_debug`, `hmac_sha256.constprop.0`) - **`JNI_OnLoad` нет**, `nm`-имена не вычищены (COFF-символы остались). ### `Java_evo_systems_security_natives_NativeGuard_nativeShare(JNIEnv*, jclass, jstring hwid)` → `byte[32]` @@ -67,7 +67,7 @@ return res; d42394b1dbe62f2d466fe6f8c4ee3cf5ce46949a032627a9ba00e8c5ef533bd2 ``` -Итог: `NativeGuard.share(hwid) = HMAC-SHA256(key_eg, hwid_utf8)`, при детекте отладки — +Итог: `NativeGuard.share(hwid) = HMAC-SHA256(key_eg, hwid_utf8)`, при детекте отладки - `XOR 0xA5` (не ошибка, а **молчаливо неверный шейр**). ### Платформенный паритет (`evoguard`) @@ -83,9 +83,9 @@ d42394b1dbe62f2d466fe6f8c4ee3cf5ce46949a032627a9ba00e8c5ef533bd2 ровно `d42394b1dbe62f2d466fe6f8c4ee3cf5ce46949a032627a9ba00e8c5ef533bd2`; следующие 32 байта = тот же ключ с XOR `0x5c`. SHA-IV рядом (`0x180003090` = `67e6096a…`). Во всех сборках есть строки `IsDebuggerPresent`, `jdwp`, `agentlib`, `JAVA_TOOL_OPTIONS`, -`_JAVA_OPTIONS` — логика антиотладки одинаковая. **Linux-сборки натива нет вообще**. +`_JAVA_OPTIONS` - логика антиотладки одинаковая. **Linux-сборки натива нет вообще**. -Потребитель: `alm.java` (KeyAssembler) — шейр идёт в 4-of-4 сборку Шамира: +Потребитель: `alm.java` (KeyAssembler) - шейр идёт в 4-of-4 сборку Шамира: ```java byte[] nativeShare = NativeGuard.share(hwid); @@ -97,7 +97,7 @@ ama.combine(new int[]{share.x(), share.xIntegrity(), share.xNative(), share.xHwi --- -## 2. java.dll — `java.lang.EvoShare.computeRaw` (патченный JDK) +## 2. java.dll - `java.lang.EvoShare.computeRaw` (патченный JDK) Экспорт: `Java_java_lang_EvoShare_computeRaw` (@ `0x180001ca4`, 286 байт) + импорт `JVM_EvoForeignAgentPresent` **из `jvm.dll`** (см. §3). @@ -121,9 +121,9 @@ longlong Java_java_lang_EvoShare_computeRaw(JNIEnv* env, jclass cls, jbyteArray `FUN_180001e4c(input, len, out32)`: -1. `FUN_180001f1c(&DAT_180010e90, 0x20, input, len, out32)` — **стандартный HMAC-SHA256** +1. `FUN_180001f1c(&DAT_180010e90, 0x20, input, len, out32)` - **стандартный HMAC-SHA256** (внутри явные SHA-IV `0x6a09e667…`, копирование ключа в ipad/opad); -2. затем антиотладка, при срабатывании — `out32 ^= 0xA5…`: +2. затем антиотладка, при срабатывании - `out32 ^= 0xA5…`: - `IsDebuggerPresent()`; - `CheckRemoteDebuggerPresent()`; - загружены модули `jdwp.dll`, `dt_socket.dll`, `dt_shmem.dll` (транспорты JDWP); @@ -135,16 +135,16 @@ longlong Java_java_lang_EvoShare_computeRaw(JNIEnv* env, jclass cls, jbyteArray 70ea2f4857869bdc8606a29a6c8cdcedbe92c18bef1aad106024db104a8f25d1 ``` -Ключи `evoguard.dll` и `java.dll` **разные** — это два независимых шейра. +Ключи `evoguard.dll` и `java.dll` **разные** - это два независимых шейра. -Константы-маски: `0xA5` — антиотладка (натив), `0x3C` — «чужой агент» из HotSpot, -`0x5A` — Java-уровень (см. §4). +Константы-маски: `0xA5` - антиотладка (натив), `0x3C` - «чужой агент» из HotSpot, +`0x5A` - Java-уровень (см. §4). --- -## 3. jvm.dll — экспорт `JVM_EvoForeignAgentPresent` +## 3. jvm.dll - экспорт `JVM_EvoForeignAgentPresent` -`mod/evojdk_x/bin/server/jvm.dll` (15,4 МБ) — тот же HotSpot, что и в `java.dll`, но с +`mod/evojdk_x/bin/server/jvm.dll` (15,4 МБ) - тот же HotSpot, что и в `java.dll`, но с добавленным экспортом (в таблице экспортов: `[4281] +base[4282] 10b9 JVM_EvoForeignAgentPresent`). `java.dll` импортирует его и вызывает в `computeRaw`. @@ -171,7 +171,7 @@ undefined1 JVM_EvoForeignAgentPresent(void) { ### Откуда берётся список и что такое `+0x53` -- `DAT_180e2c160` — **глава глобального связного списка JVMTI-агентов** (stock-механизм HotSpot, +- `DAT_180e2c160` - **глава глобального связного списка JVMTI-агентов** (stock-механизм HotSpot, `+0x20` = `next`, `+0x58` размер элемента). Доказательство: рядом лежит функция с литералом `"Disabled all JVMTI agents during -XX:AOTMode=create"`, которая делает `DAT_180e2c160 = 0`; push-функция `FUN_1805f40e0(name, path, is_absolute)` аллоцирует `0x58` байт, инициализирует @@ -196,8 +196,8 @@ JVMTI-списке есть **хотя бы один не-instrument агент* Вызов из `EvoShare.computeRaw` → `XOR 0x3C` на HMAC-шейре (см. §2, §5). -Также в `jvm_focus.c` — две функции, содержащие доступы к полю `0x53` -(`FUN_180268640`, `FUN_1804a7920`), — это чтения в фильтрах классов/загрузчика, к +Также в `jvm_focus.c` - две функции, содержащие доступы к полю `0x53` +(`FUN_180268640`, `FUN_1804a7920`), - это чтения в фильтрах классов/загрузчика, к логике агентов отношения не имеют. Прямой записи `mov byte [reg+0x53]` в программе нет: поле инициализируется в конструкторе широкой записью через `strcmp`-результат. @@ -210,12 +210,12 @@ public static byte[] compute(byte[] input) { byte[] out = computeRaw(input); // натив, §2 if (out != null && out.length == 32 && agentMismatch()) for (int i = 0; i < out.length; i++) - out[i] = (byte) (out[i] ^ 90); // 0x5A — третий уровень порчи + out[i] = (byte) (out[i] ^ 90); // 0x5A - третий уровень порчи return out; } ``` -`agentMismatch()` — сканирует `VM.getRuntimeArguments()`: +`agentMismatch()` - сканирует `VM.getRuntimeArguments()`: - аргумент `-agentpath:` или `-agentlib:` (любой нативный агент, в т.ч. `-agentlib:jdwp`) → `true`; - `-javaagent:[=opts]`: берёт путь jar, читает файл, считает SHA-256 и сравнивает с @@ -230,11 +230,11 @@ AGENT_CORRUPT_MASK = 90 // 0x5A EXPECTED_AGENT_SHA256 = 374ace507bd0cc2d0ce6dbbe07e50ddb3173ff819e92470271e2bf0fccff2038 ``` -Проверено: `sha256sum mod/evo-agent.jar` = `374ace50…` — **пиннинг на свой агент +Проверено: `sha256sum mod/evo-agent.jar` = `374ace50…` - **пиннинг на свой агент `evo-agent.jar`**. То есть JVM должен быть запущен либо вообще без `-javaagent`, либо именно с их агентом; чужой агент тихо ломает шейр. -Потребитель — `evo.agent.key.NativeJvmShare` (в `evo-agent.jar`): +Потребитель - `evo.agent.key.NativeJvmShare` (в `evo-agent.jar`): ```java Method compute = Class.forName("java.lang.EvoShare").getMethod("compute", byte[].class); @@ -242,7 +242,7 @@ byte[] share = (byte[]) compute.invoke(null, hwid.getBytes(UTF_8)); // ClassNotFoundException → KeyProviderException("not running on the EvoVis custom JVM") ``` -То есть **payload-ключ требует их кастомного JDK** — на стоковом `java` шейр получить нельзя. +То есть **payload-ключ требует их кастомного JDK** - на стоковом `java` шейр получить нельзя. --- @@ -255,19 +255,19 @@ byte[] share = (byte[]) compute.invoke(null, hwid.getBytes(UTF_8)); | натив JDK | `EvoShare.computeRaw` | `JVM_EvoForeignAgentPresent() == 1`: есть `-agentlib:`/`-agentpath:` агент либо ≥2 `-javaagent:` | `XOR 0x3C` | | Java JDK | `EvoShare.compute` | `-agentpath:`/`-agentlib:` или `-javaagent:` с чужим sha256 | `XOR 0x5A` | -Во всех случаях результат остаётся валидным `byte[32]` — ошибка не сигнализируется, +Во всех случаях результат остаётся валидным `byte[32]` - ошибка не сигнализируется, ключ просто собирается неверно (в `alm.java` дальше идёт `combine` Шамира и `alz.wipe`). Две разные схемы HMAC (разные ключи) дают два разных шейра: -- `NativeGuard.share(hwid)` — в сборку ключа мода (`alm.java`, 4-of-4 Шамир); -- `EvoShare.compute(hwid)` — в цепочку ключа payload'а агента (`NativeJvmShare`, `EvoAgent`). +- `NativeGuard.share(hwid)` - в сборку ключа мода (`alm.java`, 4-of-4 Шамир); +- `EvoShare.compute(hwid)` - в цепочку ключа payload'а агента (`NativeJvmShare`, `EvoAgent`). --- ## 6. `evovis-audiotap` (macOS, Swift) -`mod/jar/assets/evolution/native/macos/evovis-audiotap` — **Mach-O universal (x86_64 + arm64), +`mod/jar/assets/evolution/native/macos/evovis-audiotap` - **Mach-O universal (x86_64 + arm64), 174 848 байт, исполняемый файл** (`_main` + `__mh_execute_header`, не dylib). Факты по `strings` / `r2`: @@ -275,17 +275,17 @@ byte[] share = (byte[]) compute.invoke(null, hwid.getBytes(UTF_8)); - написан на **Swift** (секции `__swift5_typeref/capture/entry/reflstr/fieldmd/types`), класс `main.AudioTap` (`_TtC4main8AudioTap`); - зависимости: **ScreenCaptureKit**, CoreMedia, AVFoundation, CoreAudio (swiftCoreAudio), - Foundation, IOKit — то есть захват системного звука/видеопотока через ScreenCaptureKit; + Foundation, IOKit - то есть захват системного звука/видеопотока через ScreenCaptureKit; - делегат `stream(_:didOutputSampleBuffer:of:)` (`SCStream` + `CMSampleBuffer`) и `stream(_:didStopWithError:)`; поля `output: NSFileHandle`, `mono: [Float]`; - логи/метки `evovis.audiotap.audio`, `evovis.audiotap.screen`; -- **сетевых строк нет** (ни `http`, ни сокетов, ни доменов) — данные пишутся в +- **сетевых строк нет** (ни `http`, ни сокетов, ни доменов) - данные пишутся в `NSFileHandle` (pipe/stdout), то есть процесс-потребитель мод запускает локально. Вывод: это локальный helper для тапа аудио (и экрана) на macOS, результат уходит пайпом в родительский процесс; в аудио/сеть наружу ничего не отдаётся. -Декомпиляция Ghidra для него не запускалась (предмет — Swift + macOS-фреймворки, на +Декомпиляция Ghidra для него не запускалась (предмет - Swift + macOS-фреймворки, на уровне строк/символов картина уже ясна); запускается по необходимости: ```bash diff --git a/info/evo-nobackend.md b/info/evo-nobackend.md index c9d96e2..131c110 100644 --- a/info/evo-nobackend.md +++ b/info/evo-nobackend.md @@ -22,25 +22,25 @@ - **jadx переименовал их класс `if` → `Cif`** (keyword): класс `Cif.class` в джаре не существует, поэтому весь мёртвый код с ссылками на `Cif` вырезан, а не пересобран. -- `?? r1;` (недообфусцированный фрагмент) и `throw th;` вне catch — в вырезанных методах. -- Обфускация `setDaemon((A^B)+((A&B)<<1))` — это int-эквивалент `true` (A+B=1), javac не берёт. +- `?? r1;` (недообфусцированный фрагмент) и `throw th;` вне catch - в вырезанных методах. +- Обфускация `setDaemon((A^B)+((A&B)<<1))` - это int-эквивалент `true` (A+B=1), javac не берёт. ## Что заменено ### `cp` = `Avatar2` (аккаунт) - `initialize()` → `setUser(new cr("Player", null, "user", 0, null))`: **нет** `POST /api/auth/me`, нет чтения `EVO_TOKEN`, нет лаунчер-сессии, нет `refreshMe` (HTTP). - Профиль — локальный dummy. -- `isAuthorized()` → `return true` — открываются все их шлюзы + Профиль - локальный dummy. +- `isAuthorized()` → `return true` - открываются все их шлюзы (`Panel`, `Camera2`, `First`, `Friends`, `Crosshair2`, `KeyAssembler`, `SessionHeartbeat`). - `user()` → lazy-dummy: если профиль не задан, создаёт тот же dummy. - **`token == null`** — принципиально: `KeyAssembler.token()` и `SessionHeartbeat.token()` + **`token == null`** - принципиально: `KeyAssembler.token()` и `SessionHeartbeat.token()` возвращают null → `AttestClient4` (`evovis-protect.vercel.app`: challenge/attest/heartbeat) **недостижим**, поток сборки Shamir-ключа крутится вхолостую (sleep 500 мс). - Удалён приватный `refreshMe()` (единственный источник HTTP на auth/me). ### `akj` = `Report` (телеметрия) -- `submit(String)` → no-op (единственный внешний вызов — `dn`/`Message2`, их команда отчёта). +- `submit(String)` → no-op (единственный внешний вызов - `dn`/`Message2`, их команда отчёта). - Удалены `feedback`, `buildBody`, `run` (достигались только из `submit`). ### `alr` = `SessionHeartbeat` (сессия/отключение модулей) @@ -50,12 +50,12 @@ ## Почему остальное работает без их сервера -- **Обычные строки мода** — `awc.d(String,int)`: статический LCG+XOR, ключа не требует. -- **`SecureStrings`** (5 API-блобов: probe/friends/messages/cosmetic_sync/config_share) — +- **Обычные строки мода** - `awc.d(String,int)`: статический LCG+XOR, ключа не требует. +- **`SecureStrings`** (5 API-блобов: probe/friends/messages/cosmetic_sync/config_share) - `KeyAssembler.key()` всегда null → `get()` возвращает null → их серверные фичи (синк друзей, сообщения, косметика, конфиги) **выключаются сами**, без крашей на старте (null проверяется в самом `SecureStrings.get`). -- `KeyAssembler`/`SealedBoot`/`RuntimeGuard`/JNI `NativeGuard` — живут только после сборки +- `KeyAssembler`/`SealedBoot`/`RuntimeGuard`/JNI `NativeGuard` - живут только после сборки ключа, то есть недостижимы при `token == null`. ## Оставлено (осознанно) @@ -82,7 +82,7 @@ OK: auth=local, report=no-op, heartbeat=off ## Ограничение -В игре не прогоняли: здесь Linux, а их `evojdk` только win-x64. Запускать патченный джар — -на Windows (их лаунчер или свой fabric-лодер); при проблемах — смотреть лог на загрузке +В игре не прогоняли: здесь Linux, а их `evojdk` только win-x64. Запускать патченный джар - +на Windows (их лаунчер или свой fabric-лодер); при проблемах - смотреть лог на загрузке модулей. Ожидаемо «пустым» (серверное) останется: список друзей, сообщения, косметика синк, аватарки, тикеты. diff --git a/info/evo-obfuscation-full.md b/info/evo-obfuscation-full.md index 6d4c0a9..315dcfc 100644 --- a/info/evo-obfuscation-full.md +++ b/info/evo-obfuscation-full.md @@ -1,8 +1,8 @@ -# Обфускация EvoVis / EvolutionDLC — полный сводный разбор +# Обфускация EvoVis / EvolutionDLC - полный сводный разбор Всё проверено по артефактам в `LoVisual/ref/evo/` (jadx-декомпиляция `evovis-dec.jar`, `javap -v` по 1277 классам корня, Rust-скрипты `scripts/`). Цифры, которые пересчитаны -вручную, помечены ✓; расхождения с исходным README — в конце. +вручную, помечены ✓; расхождения с исходным README - в конце. Пакеты: 1405 `.class` всего = **1277 в default package** (корень jar) + **16 в `a/`** + **111 в `b/`** + 128 вложенных (`evo/**`, зависимости). jadx даёт 6991 `.java` @@ -10,32 +10,32 @@ --- -## Уровень 1 — имена +## Уровень 1 - имена - Все классы мода вынесены в default package (`defpackage/` в jadx) с именами `a`…`zz` (1277 шт.); миксины разложены по `a/` (16) и `b/` (111). - Миксины в `evolutiondlc.mixins.json` / `holdmyitems.mixins.json` тоже закодированы: - `"package": "b"`, список `"y"`, `"z"`, `"al"`, … — смысловых имён в манифесте нет. -- Часть имён — **ключевые слова Java** (`if`, `do`…): декомпилятор вынужден их + `"package": "b"`, список `"y"`, `"z"`, `"al"`, … - смысловых имён в манифесте нет. +- Часть имён - **ключевые слова Java** (`if`, `do`…): декомпилятор вынужден их переименовывать (`Cif`, `Cdo`), в самом jar классов с такими именами нет. - **Члены (поля/методы) НЕ переименовываются** ✓: `wb.onDraw`, `getProjectiles`, `BreakingBad`, `aln.key()`, `setDaemon`, `REFRESH_THREAD` и т. д. читаются как есть. Обфусцируются только типы и пакеты. - **`SourceFile` частично сохранён** ✓ (27 классов в корне + все 127 миксинов): `javap` даёт `Compiled from "StringRuntime.java"`, `"KeyAssembler.java"`, - `"AttestClient.java"`, `"PayloadCrypto.java"`, … у миксинов — `"GameRendererMixin.java"`. - 160 файлов содержат `compiled from:` — по ним восстанавливаются оригинальные имена. + `"AttestClient.java"`, `"PayloadCrypto.java"`, … у миксинов - `"GameRendererMixin.java"`. + 160 файлов содержат `compiled from:` - по ним восстанавливаются оригинальные имена. - **Отладочная информация осталась**: 10 218 `LineNumberTable` и 151 `LocalVariableTable` ✓ (локальные имены переменных живут у security-классов: - `awc` — 5, `aln` — 11). + `awc` - 5, `aln` - 11). - Аннотации модулей не тронуты: `@rn(name = "HitFix", category = rl.COMBAT, - description = "Makes attacks land on the hitbox you actually see…")` — 57 классов ✓, + description = "Makes attacks land on the hitbox you actually see…")` - 57 классов ✓, то есть каталог модулей читается без всякой деобфускации. - **Enum-константы не переименованы**: `SCALE`, `OPACITY`, `SPAWN`, `KEYFRAME`. - При этом 102 enum jadx не восстановил (`Failed to restore enum class`) — обфускатор + При этом 102 enum jadx не восстановил (`Failed to restore enum class`) - обфускатор сломал декомпиляторский паттерн enum, но имена констант в байткоде остались. -## Уровень 2 — строки +## Уровень 2 - строки Класс `awc` = `evovis.security.StringRuntime` (раскрыт по `SourceFile`): @@ -48,16 +48,16 @@ for (i = 0; i < cipher.length(); i++) { return new String(bytes, UTF_8).intern(); // + ConcurrentHashMap CACHE ``` -- `awc.d(cipher, key)` — обычная строка. **Ключ передаётся как opaque-выражение прямо +- `awc.d(cipher, key)` - обычная строка. **Ключ передаётся как opaque-выражение прямо в вызове**, ни одного «настоящего» ключа в рантайме нет. -- `awc.g(cipher, key)` — то же, но session-gate: сначала +- `awc.g(cipher, key)` - то же, но session-gate: сначала `Class.forName("aln").getMethod("key").invoke(null)` (рефлексией, имя класса открытым - текстом), если ключа сессии нет — возвращает `null`. -- `SecureStrings` (5 API-блобов) — гейт по `KeyAssembler.key()`, при отсутствии ключа + текстом), если ключа сессии нет - возвращает `null`. +- `SecureStrings` (5 API-блобов) - гейт по `KeyAssembler.key()`, при отсутствии ключа `get()` возвращает `null` и серверные фичи просто выключаются. - Шифр одинаков для всех строк, кэш по шифротексту, `.intern()`. -## Уровень 3 — контрольный поток +## Уровень 3 - контрольный поток Тело метода разложено в конечный автомат **с раздвоенным состоянием**: @@ -75,11 +75,11 @@ while (true) { } ``` -- Переход — присваивание **двум** переменным (`i`, `i2`) и обратный `goto` в начало +- Переход - присваивание **двум** переменным (`i`, `i2`) и обратный `goto` в начало цикла. Состояние хранится не одной константой, а суммой двух opaque-полей: константная пропаганда обязана проследить пару, а не одно присваивание. -- Формы встреч ✓: `switch (i + i2)` — 1826, `switch (i2 + i3)` — 1067, - `switch (i3 + i4)` — 283, `switch (i4 + i5)` — 161, дальше хвост до `i15 + i16`. +- Формы встреч ✓: `switch (i + i2)` - 1826, `switch (i2 + i3)` - 1067, + `switch (i3 + i4)` - 283, `switch (i4 + i5)` - 161, дальше хвост до `i15 + i16`. В сумме ≈ **3530 автоматов в 505 файлах**. - `while (true)` + `switch` вместе встречаются в 726 файлах из 6991 ✓. - Из-за этого `defpackage/` раздувается до ~319k строк на ~1242 файла (реальный объём @@ -87,7 +87,7 @@ while (true) { ## Кодирование констант (opaque predicates) -Все целочисленные литералы — тождества по модулю 2³² (и 2⁶⁴): +Все целочисленные литералы - тождества по модулю 2³² (и 2⁶⁴): | паттерн | равен | |---|---| @@ -106,7 +106,7 @@ strArr[((-702410340) | 702410340) + ((-702410340) & 702410340)] = ... // ин // long-вариант (Duration.ofSeconds(10)) (703617214384230201L ^ (-703617214384230191L)) + ((703617214384230201L & (-703617214384230191L)) << 1) -// сдвиговые константы тоже закодированы — см. «опровержение» ниже +// сдвиговые константы тоже закодированы - см. «опровержение» ниже ``` ## Мёртвый код / декой-классы @@ -114,33 +114,33 @@ strArr[((-702410340) | 702410340) + ((-702410340) & 702410340)] = ... // ин Reachability-BFS от bootstrap/`@rn`/`a`+`b`/security: **1370 классов → 1316 живых, 42 unreachable** ✓ (`analysis/structure.md`). Среди них `apc` (299 LOC, `@Deprecated`, `implements aqg`), интерфейсы (`aa`), enum'ы (`bc`, `bd`, `be`). Ни один живой файл не -ссылается на отброшенный (`dangling=0`) — то есть это балласт, выключенный из графа, +ссылается на отброшенный (`dangling=0`) - то есть это балласт, выключенный из графа, а не случайные остатки. --- -## Лаунчер (.NET) — отдельная, очень слабая схема +## Лаунчер (.NET) - отдельная, очень слабая схема - Один байтовый блоб `byte[15927] _4` в `PrivateImplementationDetails`, в статическом конструкторе `_4[i] = (byte)(_4[i] ^ i ^ 0xAA)`; чтение через аксессоры `return _5[N] ?? _6(off, len)` → `Encoding.UTF8`. -- **911 аксессоров** ✓ (все в одном файле), вызовы по всему коду — итог 1226 замен +- **911 аксессоров** ✓ (все в одном файле), вызовы по всему коду - итог 1226 замен (по `scripts/deobfuscate_launcher`). - Имена сервисов не тронуты: `ModProvisioner`, `PayloadKeyClient`, `EvoVisAuth`, - `HardwareId`, `DeliveryMirror`. Никакой обфускации имён, никакой обфускации потока — + `HardwareId`, `DeliveryMirror`. Никакой обфускации имён, никакой обфускации потока - одна статическая XOR-таблица, восстанавливается одним проходом. -- `strings.txt` — 929 расшифрованных записей. +- `strings.txt` - 929 расшифрованных записей. ## Смежные слои защиты (не «обфускация кода», но часть контура) 1. **Payload**: `12B IV || AES-256-GCM(ct||tag)`, ключ из сервера, раскрывается цепочкой - HMAC/HKDF от `hwid` + `token` + доли из `java.dll` — см. `evo-payload-pipeline.md`. -2. **Антиотладка и порча ключа** — 3 независимых уровня XOR-масок (`0xA5`, `0x3C`, `0x5A`), - `IsDebuggerPresent`, `JAVA_TOOL_OPTIONS`, `JVM_EvoForeignAgentPresent` — + HMAC/HKDF от `hwid` + `token` + доли из `java.dll` - см. `evo-payload-pipeline.md`. +2. **Антиотладка и порча ключа** - 3 независимых уровня XOR-масок (`0xA5`, `0x3C`, `0x5A`), + `IsDebuggerPresent`, `JAVA_TOOL_OPTIONS`, `JVM_EvoForeignAgentPresent` - см. `evo-native-reverse.md`. -3. **HWID** = `sha256("evovis|" + MachineGuid)` — привязка к машине, Shamir-фрагменты +3. **HWID** = `sha256("evovis|" + MachineGuid)` - привязка к машине, Shamir-фрагменты ключа, аттестация (`evovis-protect.vercel.app`). -4. **`evo-agent.jar` вообще не обфусцирован** ✓ — читаемые строки, `SourceFile: +4. **`evo-agent.jar` вообще не обфусцирован** ✓ - читаемые строки, `SourceFile: "EvoAgent.java"`, т. е. расшифровщик payload открыт. 5. Скрипт-логирование с детектом `$ExecutionContext.SessionState.LanguageMode` (защита от поломки PowerShell-обфускации у себя). @@ -153,10 +153,10 @@ Reachability-BFS от bootstrap/`@rn`/`a`+`b`/security: **1370 классов |---|---| | 1277 классов в default package, 16 в `a/`, 111 в `b/` | ✓ подтверждено | | LCG `1103515245` / `12345`, XOR со старшим байтом | ✓ подтверждено (`awc.java`) | -| «все строковые литералы зашифрованы `awc.d`» | частично: 8993 вызова `awc.d(` в `jar-src` ✓, но `awc.g(` — **0** вызовов в этом билде (описанная ветка не используется) | -| 8924 вызова / 5367 уникальных строк | расхождение: в файлах 8993 вызовов, `mod_strings.txt` — 5665 строк, `tsv` — 9248 пар. README, видимо, от усечённого прогона | +| «все строковые литералы зашифрованы `awc.d`» | частично: 8993 вызова `awc.d(` в `jar-src` ✓, но `awc.g(` - **0** вызовов в этом билде (описанная ветка не используется) | +| 8924 вызова / 5367 уникальных строк | расхождение: в файлах 8993 вызовов, `mod_strings.txt` - 5665 строк, `tsv` - 9248 пар. README, видимо, от усечённого прогона | | opaque-тождества `(A^B)+((A&B)<<1)` и `(A\|B)+(A&B)` | ✓ подтверждено, есть и long-вариант | -| «сдвиговые константы в байткоде отрицательные (`>> -8` == `>> 24`)» | ✗ **не подтверждено**: по `javap` всех 1277 классов — 32 559 сдвигов, **ни одного** отрицательного непосредственного операнда (все константные сдвиги — это `iconst_1` из opaque-паттерна `<< 1`). В исходниках тоже 0 вхождений `>> -`. Скорее всего, наблюдение из другого билда/декомпилятора | -| «декой-классы — пустышки с бессмысленными членами» | ⚠ reachability (42 файла) подтверждается, но часть из них — осмысленный код (`apc`, 299 LOC, `@Deprecated`), часть — пустые интерфейсы/enum. «Пустышки» — интерпретация | +| «сдвиговые константы в байткоде отрицательные (`>> -8` == `>> 24`)» | ✗ **не подтверждено**: по `javap` всех 1277 классов - 32 559 сдвигов, **ни одного** отрицательного непосредственного операнда (все константные сдвиги - это `iconst_1` из opaque-паттерна `<< 1`). В исходниках тоже 0 вхождений `>> -`. Скорее всего, наблюдение из другого билда/декомпилятора | +| «декой-классы - пустышки с бессмысленными членами» | ⚠ reachability (42 файла) подтверждается, но часть из них - осмысленный код (`apc`, 299 LOC, `@Deprecated`), часть - пустые интерфейсы/enum. «Пустышки» - интерпретация | | «объём декомпилированного кода раздут ~25 раз» | ⚠ порядок величины верен (319k строк против, возможно, ~15k реальных), точный коэффициент не измерен | | «911 accessor'ов, 1226 вызовов» в лаунчере | ✓ 911 аксессоров подтверждено | diff --git a/info/evo-obfuscation.md b/info/evo-obfuscation.md index 0e174a2..2536309 100644 --- a/info/evo-obfuscation.md +++ b/info/evo-obfuscation.md @@ -2,20 +2,20 @@ ## Три уровня -1. **Имена** — 1277 классов вынесены в default package с именами вида `a…aa…`; +1. **Имена** - 1277 классов вынесены в default package с именами вида `a…aa…`; миксины разбросаны по пакетам `a/` и `b/`. Смысловых имён классов в коде нет. -2. **Строки** — все строковые литералы зашифрованы вызовом `awc.d(cipher, key)`: +2. **Строки** - все строковые литералы зашифрованы вызовом `awc.d(cipher, key)`: - LCG: `state = state * 1103515245 + 12345` (32-бит переполнение); - байт: `cipherChar ^ (state >>> 24)`; - - результат — UTF-8 строка. + - результат - UTF-8 строка. Ключи передаются «непрозрачными» целыми (см. ниже). Второй вариант `awc.g(...)` - — то же самое плюс session-gate: строка расшифровывается только после + - то же самое плюс session-gate: строка расшифровывается только после проверки сессии через рефлексию `aln.key()`. -3. **Контрольный поток** — тела методов разложены в машину состояний: +3. **Контрольный поток** - тела методов разложены в машину состояний: `while (true) { switch (состояние) { case <обфуск-константа>: ...; состояние = <следующая>; } }`. - Переходы между блоками — через присваивание целому и обратный `goto`. + Переходы между блоками - через присваивание целому и обратный `goto`. Из-за этого объём декомпилированного кода раздут примерно в 25 раз относительно реального. @@ -29,14 +29,14 @@ | `(A \| B) + (A & B)` | `A + B` | Так прячутся даже `0`, `1`, `16`, `255`. Например, `true`/`false` приходят как -выражение, равное `1`/`0` (javac такого не генерирует — это работа обфускатора). +выражение, равное `1`/`0` (javac такого не генерирует - это работа обфускатора). Сдвиги и маски тоже закодированы: сдвиговые константы в байткоде могут быть -отрицательными — JVM маскирует количество бит по младшим 5 битам +отрицательными - JVM маскирует количество бит по младшим 5 битам (`ishl/ishr` со сдвигом `-8` работает как сдвиг на `24`), поэтому `>> -8` в байткоде эквивалентен `>> 24` в исходнике. ## Прочее -- Декой-классы обфускатора — пустышки с бессмысленными членами, в логике не участвуют. +- Декой-классы обфускатора - пустышки с бессмысленными членами, в логике не участвуют. - Из-за имён-ключевых слов (`if`, `do`, …) декомпилятор переименовывает такие - классы (`Cif`, `Cdo`) — в самом jar классов с такими именами нет. + классы (`Cif`, `Cdo`) - в самом jar классов с такими именами нет. diff --git a/info/evo-payload-pipeline.md b/info/evo-payload-pipeline.md index 4c116a7..803d0d4 100644 --- a/info/evo-payload-pipeline.md +++ b/info/evo-payload-pipeline.md @@ -71,19 +71,19 @@ Fabric загружает мод → мод сам распаковывает ev Ключевые свойства: -1. **Мод не лежит на диске в открытом виде** — расшифровка в памяти агента, - `candidate` — временный mount-файл. +1. **Мод не лежит на диске в открытом виде** - расшифровка в памяти агента, + `candidate` - временный mount-файл. 2. **Ключ собирается из двух мест**: hex из env (лаунчер) + `java.lang.EvoShare.compute(hwid)` - в **подменённом JVM** — поэтому мод гоняется только на их evojdk. -3. **Инъекция в Fabric** — не мод в `mods/`, а байткод-трансформация внутреннего + в **подменённом JVM** - поэтому мод гоняется только на их evojdk. +3. **Инъекция в Fabric** - не мод в `mods/`, а байткод-трансформация внутреннего класса Fabric (`ArgumentModCandidateFinder`) через `java.lang.classfile` API. Классы: `EvoAgent`, `EvoAgentHook`, `FabricDiscoveryInjector`, `PreparedMod`, `NestedLibraries`. -4. Далее внутри мода — свой слой: `KeyAssembler` (Shamir: сервер + integrity + native + hwid), +4. Далее внутри мода - свой слой: `KeyAssembler` (Shamir: сервер + integrity + native + hwid), `SealedBoot` (sealed.bin в этом билде отсутствует → fail-open), `SessionHeartbeat`. ## Что здесь не проверено -- Тело `java.dll` / `EvoShare` — натив не реверсился (план в `todo.md`). +- Тело `java.dll` / `EvoShare` - натив не реверсился (план в `todo.md`). - Реальное поведение при запуске (нужен MC 26.2 + Fabric 0.19.5 + их evojdk + дисплей). -- `evoguard.dll` — не реверсился. +- `evoguard.dll` - не реверсился. diff --git a/info/evo-weaknesses.md b/info/evo-weaknesses.md index 6160989..4889445 100644 --- a/info/evo-weaknesses.md +++ b/info/evo-weaknesses.md @@ -1,47 +1,47 @@ -# Где защита EvoVis провалилась — чего нам делать нельзя +# Где защита EvoVis провалилась - чего нам делать нельзя -Разобрано полностью за один проход, без единого рантайм-хака. Каждый пункт — +Разобрано полностью за один проход, без единого рантайм-хака. Каждый пункт - что именно дало ключ атаки. ## 1. Ключ шифрования строк лежит рядом со шифротекстом -`awc.d("", )` — ключ это **константное выражение в вызове**. +`awc.d("", )` - ключ это **константное выражение в вызове**. Ни secrets, ни runtime-derivation: выражение просто сворачивается в `int` (любой парсер вроде `scripts/expr.rs`, ~200 строк). Итог: **8993 вызова расшифрованы детерминированно, 0 обращений к JVM**. Хуже: шифр один на весь продукт (LCG с фиксированными `a/c`), т. е. зная одну -пару «шифротекст → текст», остальные не нужно даже сворачивать — можно учиться. +пару «шифротекст → текст», остальные не нужно даже сворачивать - можно учиться. ## 2. Обфусцированы только типы, но не члены -`onDraw`, `getProjectiles`, `BreakingBad`, `key()`, `isAuthorized()`, `setUser` — +`onDraw`, `getProjectiles`, `BreakingBad`, `key()`, `isAuthorized()`, `setUser` - открыто. Семантика класса читается по одному `javap -p`. Класс с тремя понятными методами опознаётся за секунду, даже если имя `wb`. ## 3. Метаданные и отладочная информация не тронуты -- `SourceFile: "StringRuntime.java"` / `"KeyAssembler.java"` / `"PayloadCrypto.java"` — - оригинальные имена 160 классов (все миксины — 100%). -- `LineNumberTable` (10 218) + `LocalVariableTable` (151) — имена локальных переменных. -- `@rn(name=..., description=...)` — полный каталог всех 57 модулей с описаниями. +- `SourceFile: "StringRuntime.java"` / `"KeyAssembler.java"` / `"PayloadCrypto.java"` - + оригинальные имена 160 классов (все миксины - 100%). +- `LineNumberTable` (10 218) + `LocalVariableTable` (151) - имена локальных переменных. +- `@rn(name=..., description=...)` - полный каталог всех 57 модулей с описаниями. - `fabric.mod.json`, `*.mixins.json` (пути и состав), `accesswidener`, ресурсы. Все эти вещи дешевле всего чинить и дороже всего отдавать. ## 4. Flattening сделан «по учебнику» -Один шаблон на весь продукт: `while(true) { switch (i + i2) }`, номер состояния — -сумма двух opaque-полей, переходы — присваивание пары. Один regex находит 3530 +Один шаблон на весь продукт: `while(true) { switch (i + i2) }`, номер состояния - +сумма двух opaque-полей, переходы - присваивание пары. Один regex находит 3530 автоматов. Декомпилятор восстанавливает CFG, потому что: - `switch` по **константным** меткам (метки не закодированы); -- все переходы — прямые присваивания в одном basic block; +- все переходы - прямые присваивания в одном basic block; - нет вычислимых на входе предикатов, нет переходов через таблицы/массивы; -- нет обфускации самих тел блоков (тела — обычный код). +- нет обфускации самих тел блоков (тела - обычный код). ## 5. Opaque-предикаты сводятся к `A+B` -`(A^B)+((A&B)<<1)` и `(A|B)+(A&B)` — это школьные тождества. Распознаются +`(A^B)+((A&B)<<1)` и `(A|B)+(A&B)` - это школьные тождества. Распознаются регуляркой, сворачиваются в один проход, заодно выдают «бесплатный» фолдинг остальных констант. Ни одного настоящего opaque-предиката (зависящего от состояния памяти/окружения и всегда истинного) в коде нет. @@ -55,13 +55,13 @@ ## 7. Каждая защита живёт отдельно и называется своим именем `StringRuntime`, `KeyAssembler`, `AttestClient`, `RuntimeGuard`, `SealedBoot`, -`NativeGuard` — названия прямо говорят атакующему, куда смотреть. Плюс open-source -идентификаторы: `aln` упомянут в рефлексии строкой, `awc` — по имени вызова. +`NativeGuard` - названия прямо говорят атакующему, куда смотреть. Плюс open-source +идентификаторы: `aln` упомянут в рефлексии строкой, `awc` - по имени вызова. ## 8. Вторичные компоненты вообще без защиты -- `evo-agent.jar` (расшифровщик payload) — открытые строки, `SourceFile`, без обфускации. -- Лаунчер .NET — статическая XOR-таблица `b ^ i ^ 0xAA`, имена сервисов не тронуты. +- `evo-agent.jar` (расшифровщик payload) - открытые строки, `SourceFile`, без обфускации. +- Лаунчер .NET - статическая XOR-таблица `b ^ i ^ 0xAA`, имена сервисов не тронуты. - Защита ключа в нативе сильная, но **код мода** защищён слабее, чем ключ. ## 9. Шифр без аутентификации и без per-string salt @@ -71,6 +71,6 @@ LCG+XOR: нет MAC, нет случайного сида на строку, н ## Опровержение (чтобы не повторять чужие ошибки в выводах) -- «Сдвиги в байткоде могут быть отрицательными (`>> -8` == `>> 24`)» — по всем - 1277 классам **0** таких случаев (32 559 сдвигов, константные — всегда `1`). +- «Сдвиги в байткоде могут быть отрицательными (`>> -8` == `>> 24`)» - по всем + 1277 классам **0** таких случаев (32 559 сдвигов, константные - всегда `1`). - `awc.g(...)` (session-gate) в этом билде **не вызывается ни разу**.