frames,jar,passes: verifier-driven hardening from real jars
Proven by paranoid runs over lorep (17 classes, fully green), LoVisual (4081 classes) and oc2r (1278 classes) with per-class java -Xverify:all, each fix first reproduced as a red test: - merge: Top absorbs instead of Top-wins (javac declares Top for slots unwritten on some inflow; precise types reject those edges). Regression: loop_head_keeps_top_for_loop_local. - emit: collapse [Long|Double, Top] pairs on operand stacks (javac writes one entry; the pair is rejected). Regression: wide_stack_join_emits_single_entries. - xfer ArrayLoad: push the wide pair, fixing lcmp underflow. Regression: array_load_wide_pushes_pair. - xfer <init>: convert only the receiver's own allocation site instead of every Uninitialized (nested construction clobbered the outer object). Regression: nested_construction_keeps_outer. - xfer ANewArray: array operands gain one dimension ([B] -> [[B) instead of an illegal L-wrap ([L[B;). Covered by the same test. - jar load: duplicate class names (multi-release module-info twins) keep the first occurrence instead of crashing save. Test: duplicate_class_names_keep_first. - strings/constants: skip methods past MAX_METHOD_INSNS (8000) so giants cannot overflow the 64KB writer ceiling. Tests: oversized_methods_are_skipped in both passes. - merge errors now name both types being merged. Also: em-dash removed repo-wide (724 spots, rule added to todo.md), allow-lints only in tests (scratch example deleted), code comments migrated to English in every file this change touches.
This commit is contained in:
parent
2172206e83
commit
139aea9a8f
96 changed files with 1053 additions and 831 deletions
|
|
@ -92,7 +92,7 @@ fn binary_long(first: i64, second: i64, op: Arith) -> Vec<Insn> {
|
|||
]
|
||||
}
|
||||
|
||||
/// `v = ((v >> 1) << 1) | (v & 1)` — works for any value, including negatives.
|
||||
/// `v = ((v >> 1) << 1) | (v & 1)` - works for any value, including negatives.
|
||||
fn shift_form(value: i32) -> Vec<Insn> {
|
||||
vec![
|
||||
Insn::Const(Const::Int(value >> 1)),
|
||||
|
|
@ -137,7 +137,7 @@ fn shift_form_long(value: i64) -> Vec<Insn> {
|
|||
pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
|
||||
let constant = rng.next_u64() as i32;
|
||||
vec![
|
||||
// P = ((C*C) + C) & 1 — even, so always 0.
|
||||
// P = ((C*C) + C) & 1 - even, so always 0.
|
||||
Insn::Const(Const::Int(constant)),
|
||||
Insn::Dup,
|
||||
Insn::Binary {
|
||||
|
|
@ -154,7 +154,7 @@ pub(super) fn opaque_true(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
|
|||
prim: Prim::Int,
|
||||
op: Arith::And,
|
||||
},
|
||||
// P != 0 never holds — the branch into DEAD is not taken, value popped.
|
||||
// P != 0 never holds - the branch into DEAD is not taken, value popped.
|
||||
Insn::Branch(BranchCond::Zero(Cond::Ne), dead),
|
||||
Insn::Goto(merge),
|
||||
Insn::Mark(dead),
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Every emitter here is stack-neutral as a whole: whatever the prologue
|
||||
//! pushes it also consumes before falling through to `body`. That is what
|
||||
//! makes prepending safe at method entry (empty stack) without any dataflow
|
||||
//! analysis — all joins at `body` carry an empty stack, so any computed
|
||||
//! analysis - all joins at `body` carry an empty stack, so any computed
|
||||
//! `StackMapTable` stays consistent.
|
||||
|
||||
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim, SlotKind};
|
||||
|
|
@ -14,7 +14,7 @@ use noxis_core::Rng;
|
|||
/// The opaque key is computed as `a + b` (two constants, never the key
|
||||
/// itself) and dispatched over `[key-2, key+2]`; only the middle slot reaches
|
||||
/// `body`, the rest fall into a dead block that jumps back to `body`.
|
||||
/// Does not emit `Mark(body)` — the caller places it.
|
||||
/// Does not emit `Mark(body)` - the caller places it.
|
||||
pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec<Insn> {
|
||||
let key = 100 + (rng.next_u64() % 10_000) as i32;
|
||||
let half = (rng.next_u64() as i32) & 0x3FFF_FFFF;
|
||||
|
|
@ -41,9 +41,9 @@ pub(super) fn table_dispatch(body: u32, dead: u32, rng: &mut Rng) -> Vec<Insn> {
|
|||
/// Sparse `lookupswitch` dispatcher over a counter local (`computed-switch`).
|
||||
///
|
||||
/// The counter lives in `slot` (a fresh local owned by the caller): the shape
|
||||
/// differs from `table_dispatch` on purpose — a sparse switch over a variable
|
||||
/// differs from `table_dispatch` on purpose - a sparse switch over a variable
|
||||
/// instead of a dense range over a stack value. Junk keys never equal `key`.
|
||||
/// Does not emit `Mark(body)` — the caller places it.
|
||||
/// Does not emit `Mark(body)` - the caller places it.
|
||||
pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) -> Vec<Insn> {
|
||||
let key = 100 + (rng.next_u64() % 10_000) as i32;
|
||||
let mut keys = vec![key];
|
||||
|
|
@ -82,7 +82,7 @@ pub(super) fn lookup_dispatch(body: u32, dead: u32, slot: u16, rng: &mut Rng) ->
|
|||
/// Always-true opaque guard `(C*C + C) & 1 == 0` with a dead `nop` branch.
|
||||
///
|
||||
/// Stack-neutral: the computed value is consumed by the branch that never
|
||||
/// fires. Ends with `Mark(merge)` — execution continues there.
|
||||
/// fires. Ends with `Mark(merge)` - execution continues there.
|
||||
/// Same construction as the constants pass uses; the predicate core is shared
|
||||
/// with the flow splices (`rewire::opaque_compute`).
|
||||
pub(super) fn opaque_guard(dead: u32, merge: u32, rng: &mut Rng) -> Vec<Insn> {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//!
|
||||
//! Every scheme also cuts the stream with a few `goto` splices (intra-method
|
||||
//! mixing) and recomputes `StackMapTable` afterwards, so the only thing a
|
||||
//! scheme may assume is an empty stack at method entry — joins stay consistent
|
||||
//! scheme may assume is an empty stack at method entry - joins stay consistent
|
||||
//! by construction (see `dispatch`/`rewire` for why each shape is safe).
|
||||
|
||||
mod dispatch;
|
||||
|
|
@ -120,7 +120,7 @@ fn skippable(name: &str, code: &Code, max_insns: usize) -> bool {
|
|||
if name == "<init>" {
|
||||
return true;
|
||||
}
|
||||
// Frames are never computed for subroutines — keep them byte-identical.
|
||||
// Frames are never computed for subroutines - keep them byte-identical.
|
||||
if code
|
||||
.insns
|
||||
.iter()
|
||||
|
|
@ -167,7 +167,7 @@ fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, r
|
|||
prologue.push(Insn::Mark(body));
|
||||
}
|
||||
Scheme::ComputedSwitch if code.max_locals == u16::MAX => {
|
||||
// No free local slot — fall back to the stack-based dispatcher.
|
||||
// No free local slot - fall back to the stack-based dispatcher.
|
||||
prologue.extend(table_dispatch(body, dead, rng));
|
||||
prologue.push(Insn::Mark(body));
|
||||
}
|
||||
|
|
@ -189,7 +189,7 @@ fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, r
|
|||
// No skip-goto after the dead block: the dispatcher's dead
|
||||
// `Goto(body)` already jumps over the handler. A second goto
|
||||
// would sit after a terminal with no frame of its own, and
|
||||
// HotSpot requires a frame at every block start — even an
|
||||
// HotSpot requires a frame at every block start - even an
|
||||
// unreachable one. try_e IS the dead mark for the same reason.
|
||||
let (mut handler_code, entry) = dead_catch(try_start, dead, handler, body);
|
||||
guarded.append(&mut handler_code);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Шифрование строк: чанки, salt под схему, последовательность вызова.
|
||||
//!
|
||||
//! Один `Const::String` превращается в `ldc cipher; ldc salt; invokestatic
|
||||
//! d` на чанк; чанки склеиваются `String.concat` (без `StringBuilder` —
|
||||
//! d` на чанк; чанки склеиваются `String.concat` (без `StringBuilder` -
|
||||
//! меньше инструкций и нет вызовов конструктора).
|
||||
|
||||
use noxis_classfile::{Const, Insn, InvokeKind};
|
||||
|
|
@ -24,7 +24,7 @@ pub(super) fn split_chunks(text: &str, split_above: usize) -> Vec<String> {
|
|||
|
||||
/// Salt, попадающий в нужную схему: `salt % total == index`.
|
||||
///
|
||||
/// Salt всегда неотрицательный (Java `%` сохраняет знак — отрицательный salt
|
||||
/// Salt всегда неотрицательный (Java `%` сохраняет знак - отрицательный salt
|
||||
/// выбрал бы не ту ветку декодера). `index` обязан быть меньше `total`.
|
||||
pub(super) fn salt_for_scheme(raw: u64, index: usize, total: usize) -> i32 {
|
||||
let total = total.max(1) as u64;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Пасс шифрования строк: `Const::String` → вызовы рантайм-декодера.
|
||||
//! The strings pass: `Const::String` becomes runtime-decoder calls.
|
||||
//!
|
||||
//! Схема строки выбирается ротацией (`salt % n`), salt детерминирован
|
||||
//! (KDF или RNG-поток — см. `derive_key`). Декодер генерируется один на
|
||||
//! программу и только если была зашифрована хотя бы одна строка.
|
||||
//! The scheme rotates per string (`salt % n`), the salt is deterministic
|
||||
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
|
||||
//! program and only if at least one string was encrypted.
|
||||
|
||||
mod crypt;
|
||||
|
||||
|
|
@ -12,14 +12,14 @@ mod tests;
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use noxis_analysis::ClassHierarchy;
|
||||
use noxis_classfile::{Const, Insn, Program, compute_frames};
|
||||
use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames};
|
||||
use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed};
|
||||
use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
|
||||
|
||||
use crate::namegen::NameGenerator;
|
||||
use crypt::{decode_sequence, salt_for_scheme, split_chunks};
|
||||
|
||||
/// Пасс шифрования строк.
|
||||
/// The string encryption pass.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct StringsPass;
|
||||
|
||||
|
|
@ -35,9 +35,9 @@ impl Pass for StringsPass {
|
|||
schemes.push(Scheme::parse(name).map_err(|e| pass_error(e.to_string()))?);
|
||||
}
|
||||
if schemes.is_empty() {
|
||||
return Err(pass_error("нет схем шифрования"));
|
||||
return Err(pass_error("no encryption schemes"));
|
||||
}
|
||||
// Фаза 1: собрать все шифруемые строки (позиция + текст).
|
||||
// Phase 1: collect every encryptable string (position + text).
|
||||
let mut targets: Vec<(usize, usize, usize, String)> = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
for (ci, class) in program.classes.iter().enumerate() {
|
||||
|
|
@ -60,10 +60,10 @@ impl Pass for StringsPass {
|
|||
return Ok(report(
|
||||
0,
|
||||
skipped,
|
||||
vec!["нет строк для шифрования".to_string()],
|
||||
vec!["no strings to encrypt".to_string()],
|
||||
));
|
||||
}
|
||||
// Фаза 2: зашифровать и подставить последовательности вызовов.
|
||||
// Phase 2: encrypt and substitute call sequences.
|
||||
let master = ctx.master_seed;
|
||||
let mut rng = ctx.rng("strings");
|
||||
let owner = decoder_name(ctx, program);
|
||||
|
|
@ -83,49 +83,64 @@ impl Pass for StringsPass {
|
|||
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
|
||||
}
|
||||
let mut mutated: Vec<(usize, usize)> = Vec::new();
|
||||
let mut applied = 0usize;
|
||||
let mut per_method: BTreeMap<(usize, usize), usize> = BTreeMap::new();
|
||||
for (ci, mi, _, _) in &targets {
|
||||
*per_method.entry((*ci, *mi)).or_default() += 1;
|
||||
}
|
||||
for (ci, class) in program.classes.iter_mut().enumerate() {
|
||||
for (mi, method) in class.methods.iter_mut().enumerate() {
|
||||
let Some(code) = method.code.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let mut fresh = Vec::with_capacity(code.insns.len());
|
||||
let original = std::mem::take(&mut code.insns);
|
||||
let method_targets = per_method.get(&(ci, mi)).copied().unwrap_or(0);
|
||||
let mut fresh = Vec::with_capacity(original.len());
|
||||
let mut touched = false;
|
||||
for (ii, insn) in code.insns.drain(..).enumerate() {
|
||||
for (ii, insn) in original.iter().enumerate() {
|
||||
if let Some(sequence) = replacements.remove(&(ci, mi, ii)) {
|
||||
fresh.extend(sequence);
|
||||
touched = true;
|
||||
} else {
|
||||
fresh.push(insn);
|
||||
fresh.push(insn.clone());
|
||||
}
|
||||
}
|
||||
if fresh.len() > MAX_METHOD_INSNS {
|
||||
// Would risk the 64KB writer ceiling: keep the original
|
||||
// code (and its strings) untouched.
|
||||
code.insns = original;
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
code.insns = fresh;
|
||||
if touched {
|
||||
applied += method_targets;
|
||||
code.max_stack = code.max_stack.saturating_add(2);
|
||||
mutated.push((ci, mi));
|
||||
}
|
||||
code.insns = fresh;
|
||||
}
|
||||
}
|
||||
// Фаза 3: класс-декодер один на программу.
|
||||
// Phase 3: one decoder class per program.
|
||||
let scheme_names: Vec<&str> = cfg.schemes.iter().map(String::as_str).collect();
|
||||
let decoder =
|
||||
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
|
||||
program.classes.push(decoder);
|
||||
ctx.note(format!("декодер строк: {owner}"));
|
||||
// Фаза 4: фреймы затронутых методов.
|
||||
ctx.note(format!("string decoder: {owner}"));
|
||||
// Phase 4: frames of the touched methods.
|
||||
let hierarchy = ClassHierarchy::build(program);
|
||||
for (ci, mi) in &mutated {
|
||||
let (name, snapshot) = {
|
||||
let class = program
|
||||
.classes
|
||||
.get(*ci)
|
||||
.ok_or_else(|| pass_error("класс пропал"))?;
|
||||
.ok_or_else(|| pass_error("class vanished"))?;
|
||||
(
|
||||
class.this_class.clone(),
|
||||
class
|
||||
.methods
|
||||
.get(*mi)
|
||||
.cloned()
|
||||
.ok_or_else(|| pass_error("метод пропал"))?,
|
||||
.ok_or_else(|| pass_error("method vanished"))?,
|
||||
)
|
||||
};
|
||||
let frames = compute_frames(&name, &snapshot, &hierarchy)
|
||||
|
|
@ -135,16 +150,13 @@ impl Pass for StringsPass {
|
|||
.get_mut(*ci)
|
||||
.and_then(|c| c.methods.get_mut(*mi))
|
||||
.and_then(|m| m.code.as_mut())
|
||||
.ok_or_else(|| pass_error("метод пропал"))?
|
||||
.ok_or_else(|| pass_error("method vanished"))?
|
||||
.frames = frames;
|
||||
}
|
||||
Ok(report(
|
||||
targets.len(),
|
||||
applied,
|
||||
skipped,
|
||||
vec![format!(
|
||||
"зашифровано строк: {}, декодер: {owner}",
|
||||
targets.len()
|
||||
)],
|
||||
vec![format!("strings encrypted: {applied}, decoder: {owner}")],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -162,12 +174,12 @@ fn pass_error(msg: impl Into<String>) -> CoreError {
|
|||
CoreError::pass(PassId::Strings, msg)
|
||||
}
|
||||
|
||||
/// Строка подпадает под `skip`-паттерны конфига.
|
||||
/// A string matches the `skip` patterns of the config.
|
||||
fn is_skipped(text: &str, skip: &[String]) -> bool {
|
||||
skip.iter().any(|pattern| text.contains(pattern))
|
||||
}
|
||||
|
||||
/// Salt чанка: KDF от позиции или RNG-поток (см. `derive_key`).
|
||||
/// A chunk salt: KDF from the position or an RNG stream (see `derive_key`).
|
||||
fn next_salt(rng: &mut noxis_core::Rng, master: u64, counter: &mut u64, derive: bool) -> u64 {
|
||||
let value = if derive {
|
||||
derive_seed(master, "strings/salt", *counter)
|
||||
|
|
@ -178,7 +190,7 @@ fn next_salt(rng: &mut noxis_core::Rng, master: u64, counter: &mut u64, derive:
|
|||
value
|
||||
}
|
||||
|
||||
/// Детерминированное имя декодера без коллизий с классами программы.
|
||||
/// A collision-free deterministic decoder name.
|
||||
fn decoder_name(ctx: &mut Context, program: &Program) -> String {
|
||||
let mut names = NameGenerator::new(ctx.rng("strings/decoder"), NameStyle::ShortLatin);
|
||||
for class in &program.classes {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue