From 6a68c9d69b9d0854402ca72d4f6fbf7a1b78011c Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 9 Oct 2026 14:08:08 +0200 Subject: [PATCH] passes,core: debug fakes - line numbers and source removal DebugConfig gains fake_line_numbers (deterministic upward walk replacing LineNumberTable rows when stripping is off; strip wins) and strip_source_file (drops SourceFile entirely, beating spoof). Debug tests moved to passes/tests/debug_info.rs with new cases for both flags and their precedence. --- crates/noxis-core/src/config/sections.rs | 7 + crates/noxis-passes/src/debug.rs | 197 +++++++---------------- crates/noxis-passes/tests/debug_info.rs | 189 ++++++++++++++++++++++ 3 files changed, 258 insertions(+), 135 deletions(-) create mode 100644 crates/noxis-passes/tests/debug_info.rs diff --git a/crates/noxis-core/src/config/sections.rs b/crates/noxis-core/src/config/sections.rs index a153d96..8379889 100644 --- a/crates/noxis-core/src/config/sections.rs +++ b/crates/noxis-core/src/config/sections.rs @@ -175,10 +175,15 @@ impl Default for NoiseConfig { pub struct DebugConfig { /// Strip `LineNumberTable`. pub strip_line_numbers: bool, + /// Replace line numbers with plausible-but-wrong ones. Applies only + /// when `strip_line_numbers` is off (stripping wins). + pub fake_line_numbers: bool, /// Strip `LocalVariableTable`. pub strip_local_variables: bool, /// Replace `SourceFile` with a fake name. pub spoof_source_file: bool, + /// Remove `SourceFile` entirely instead of spoofing it. + pub strip_source_file: bool, /// Strip `Signature`/`InnerClasses`/`EnclosingMethod`. pub strip_signatures: bool, } @@ -187,8 +192,10 @@ impl Default for DebugConfig { fn default() -> Self { Self { strip_line_numbers: true, + fake_line_numbers: false, strip_local_variables: true, spoof_source_file: true, + strip_source_file: false, strip_signatures: false, } } diff --git a/crates/noxis-passes/src/debug.rs b/crates/noxis-passes/src/debug.rs index a4eb0ad..9fb8759 100644 --- a/crates/noxis-passes/src/debug.rs +++ b/crates/noxis-passes/src/debug.rs @@ -1,26 +1,28 @@ -//! Пасс отладочной информации: удаление и ложная подмена. +//! The debug-info pass: removal and false replacement. //! -//! Источник требований - `info/evo-weaknesses.md` §3: у разобранного -//! образца оригинальные `SourceFile` у 160 классов, 10 218 `LineNumberTable` -//! и имена локальных переменных. Это дешевле всего чинить и дороже всего -//! отдавать. +//! Requirements come from `info/evo-weaknesses.md` §3: the dissected sample +//! kept original `SourceFile`s on 160 classes, 10 218 `LineNumberTable`s and +//! local variable names. This is the cheapest thing to fix and the most +//! expensive to give away. //! -//! Важно: удалять недостаточно. Инструменты (JADX/CFR) верят отладке сильнее, -//! чем байткоду, поэтому `spoof_source_file` подставляет **ложное** имя - -//! консистентное, но не связанное с реальным исходником. +//! Removal alone is not enough. Tools (JADX/CFR) trust debug info more than +//! bytecode, so `spoof_source_file` substitutes a **false** name - +//! consistent, but unrelated to the real sources. `fake_line_numbers` does +//! the same to line numbers (a plausible walk instead of the truth), and +//! `strip_source_file` drops the attribute entirely. use noxis_classfile::{Attribute, ClassFile, Code, Method, Program}; -use noxis_core::{Context, CoreError, DebugConfig, Pass, PassId, PassReport}; +use noxis_core::{Context, CoreError, DebugConfig, Pass, PassId, PassReport, Rng}; use crate::namegen::NameGenerator; -/// Атрибут уровня класса/метода, который мы выкидываем как «сырой». +/// A class/method-level attribute dropped as "raw". const ENCLOSING_METHOD: &str = "EnclosingMethod"; -/// Атрибут уровня метода с именами параметров (`-parameters`). +/// A method-level attribute holding parameter names (`-parameters`). const METHOD_PARAMETERS: &str = "MethodParameters"; -/// Пасс отладочной информации. +/// The debug-info pass. #[derive(Debug, Default, Clone, Copy)] pub struct DebugInfoPass; @@ -34,11 +36,14 @@ impl Pass for DebugInfoPass { let style = ctx.config.rename.class_style; let mut source_names = NameGenerator::new(ctx.rng("debug/source_file"), style).for_classes(); + let mut line_rng = ctx.rng("debug/lines"); let mut stripped = 0usize; let mut spoofed = 0usize; + let mut faked = 0usize; for class in &mut program.classes { + stripped += strip_source_file(class, &cfg); spoofed += spoof_source_file(class, &mut source_names, &cfg); stripped += strip_signatures(&mut class.attributes, &cfg); @@ -47,6 +52,7 @@ impl Pass for DebugInfoPass { stripped += strip_parameters(method, &cfg); if let Some(code) = method.code.as_mut() { stripped += strip_code(code, &cfg); + faked += fake_line_numbers(code, &mut line_rng, &cfg); } } @@ -57,29 +63,32 @@ impl Pass for DebugInfoPass { let mut notes = Vec::new(); if stripped > 0 { - notes.push(format!("удалено атрибутов: {stripped}")); + notes.push(format!("attributes removed: {stripped}")); } if spoofed > 0 { - notes.push(format!("подменено SourceFile: {spoofed}")); + notes.push(format!("SourceFile spoofed: {spoofed}")); + } + if faked > 0 { + notes.push(format!("line numbers faked: {faked}")); } Ok(PassReport { pass: PassId::DebugInfo, - transformed: stripped + spoofed, + transformed: stripped + spoofed + faked, skipped: 0, notes, }) } } -/// Оставляет только атрибуты, для которых `keep` вернул `true`. +/// Keep only the attributes for which `keep` returns true. fn retain(attrs: &mut Vec, keep: impl FnMut(&Attribute) -> bool) -> usize { let before = attrs.len(); attrs.retain(keep); before - attrs.len() } -/// Тела методов: `LineNumberTable`, `LocalVariableTable`. +/// Method bodies: `LineNumberTable`, `LocalVariableTable`. fn strip_code(code: &mut Code, cfg: &DebugConfig) -> usize { retain(&mut code.attributes, |attr| match attr { Attribute::LineNumberTable(_) => !cfg.strip_line_numbers, @@ -90,7 +99,7 @@ fn strip_code(code: &mut Code, cfg: &DebugConfig) -> usize { }) } -/// Уровень класса/метода/поля: `Signature`, `InnerClasses`, `EnclosingMethod`. +/// Class/method/field level: `Signature`, `InnerClasses`, `EnclosingMethod`. fn strip_signatures(attrs: &mut Vec, cfg: &DebugConfig) -> usize { if !cfg.strip_signatures { return 0; @@ -103,7 +112,7 @@ fn strip_signatures(attrs: &mut Vec, cfg: &DebugConfig) -> usize { }) } -/// Имена параметров метода (`MethodParameters`). +/// Method parameter names (`MethodParameters`). fn strip_parameters(method: &mut Method, cfg: &DebugConfig) -> usize { if !cfg.strip_local_variables { return 0; @@ -115,7 +124,40 @@ fn strip_parameters(method: &mut Method, cfg: &DebugConfig) -> usize { }) } -/// Подменяет `SourceFile` ложным, но правдоподобным именем. +/// Replace `LineNumberTable` rows with plausible-but-wrong numbers. +/// +/// Runs only when stripping is off (a removed table has nothing to fake): +/// a deterministic upward walk, so stack traces still look like traces +/// instead of pointing at the real lines. +fn fake_line_numbers(code: &mut Code, rng: &mut Rng, cfg: &DebugConfig) -> usize { + if !cfg.fake_line_numbers || cfg.strip_line_numbers { + return 0; + } + let mut faked = 0usize; + let mut line = 1 + rng.below(200) as u16; + for attr in &mut code.attributes { + if let Attribute::LineNumberTable(rows) = attr { + for row in rows.iter_mut() { + row.line = line; + line = line.saturating_add(1 + rng.below(8) as u16); + faked += 1; + } + } + } + faked +} + +/// Remove `SourceFile` entirely instead of spoofing it. +fn strip_source_file(class: &mut ClassFile, cfg: &DebugConfig) -> usize { + if !cfg.strip_source_file { + return 0; + } + retain(&mut class.attributes, |attr| { + !matches!(attr, Attribute::SourceFile(_)) + }) +} + +/// Replace `SourceFile` with a false but plausible name. fn spoof_source_file(class: &mut ClassFile, names: &mut NameGenerator, cfg: &DebugConfig) -> usize { if !cfg.spoof_source_file { return 0; @@ -129,118 +171,3 @@ fn spoof_source_file(class: &mut ClassFile, names: &mut NameGenerator, cfg: &Deb } replaced } - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use noxis_classfile::{ClassBuilder, Insn, LineNumber, LocalVariable, Resource, Version}; - use noxis_core::{Config, Context, RenameConfig}; - - use super::*; - - fn sample() -> Program { - let code = Code { - max_stack: 0, - max_locals: 1, - insns: vec![Insn::Return(None)], - exception_table: Vec::new(), - frames: Vec::new(), - attributes: vec![ - Attribute::LineNumberTable(vec![LineNumber { start: 0, line: 12 }]), - Attribute::LocalVariableTable(vec![LocalVariable { - start: 0, - end: 0, - name: "this".into(), - descriptor: "Ldemo/App;".into(), - index: 0, - }]), - ], - }; - - let mut class = ClassBuilder::new("demo/App", Some("java/lang/Object")) - .version(Version::JAVA_21) - .method_with_code(0x0001, "run", "()V", code) - .attribute(Attribute::SourceFile("RealApp.java".into())) - .attribute(Attribute::Signature("Ljava/lang/Object;".into())) - .attribute(Attribute::InnerClasses(Vec::new())) - .build(); - class.methods[0].attributes.push(Attribute::Raw { - name: METHOD_PARAMETERS.into(), - data: vec![], - }); - - Program { - classes: vec![class], - resources: vec![Resource { - path: "app.json".into(), - data: br#"{}"#.to_vec(), - }], - } - } - - fn run(program: &mut Program, debug: DebugConfig) -> PassReport { - let config = Config { - seed: Some(7), - debug, - rename: RenameConfig { - class_style: noxis_core::NameStyle::ShortLatin, - ..RenameConfig::default() - }, - ..Config::default() - }; - let mut ctx = Context::new(config); - DebugInfoPass.apply(&mut ctx, program).expect("debug pass") - } - - #[test] - fn default_config_strips_and_spoofs() { - let mut program = sample(); - let report = run(&mut program, DebugConfig::default()); - - let class = &program.classes[0]; - let code = class.methods[0].code.as_ref().expect("code"); - assert!(code.attributes.is_empty(), "атрибуты кода должны уйти"); - assert!( - class.methods[0].attributes.is_empty(), - "MethodParameters уходит" - ); - - match class.attributes[0] { - Attribute::SourceFile(ref name) => assert_ne!(name, "RealApp.java"), - _ => panic!("SourceFile должен быть первым"), - } - assert_eq!(class.attributes.len(), 3, "Signature/InnerClasses остаются"); - assert!(report.transformed > 0); - } - - #[test] - fn signatures_and_parameters_survive_when_disabled() { - let debug = DebugConfig { - strip_line_numbers: false, - strip_local_variables: false, - spoof_source_file: false, - strip_signatures: false, - }; - let mut program = sample(); - run(&mut program, debug); - - let class = &program.classes[0]; - assert_eq!(class.methods[0].code.as_ref().unwrap().attributes.len(), 2); - assert_eq!(class.methods[0].attributes.len(), 1); - assert_eq!(class.attributes.len(), 3); - assert_eq!( - class.attributes[0], - Attribute::SourceFile("RealApp.java".into()) - ); - } - - #[test] - fn same_seed_gives_the_same_spoofed_name() { - let mut first = sample(); - let mut second = sample(); - run(&mut first, DebugConfig::default()); - run(&mut second, DebugConfig::default()); - assert_eq!(first.classes[0].attributes, second.classes[0].attributes); - } -} diff --git a/crates/noxis-passes/tests/debug_info.rs b/crates/noxis-passes/tests/debug_info.rs new file mode 100644 index 0000000..8fb7df5 --- /dev/null +++ b/crates/noxis-passes/tests/debug_info.rs @@ -0,0 +1,189 @@ +//! Debug-info pass tests: strip, spoof, fake lines, source removal. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use noxis_classfile::{ + Attribute, ClassBuilder, Code, Insn, LineNumber, LocalVariable, Program, Resource, Version, +}; +use noxis_core::{Config, Context, DebugConfig, Pass, PassReport, RenameConfig}; +use noxis_passes::DebugInfoPass; + +fn sample() -> Program { + let code = Code { + max_stack: 0, + max_locals: 1, + insns: vec![Insn::Return(None)], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: vec![ + Attribute::LineNumberTable(vec![ + LineNumber { start: 0, line: 12 }, + LineNumber { start: 0, line: 15 }, + ]), + Attribute::LocalVariableTable(vec![LocalVariable { + start: 0, + end: 0, + name: "this".into(), + descriptor: "Ldemo/App;".into(), + index: 0, + }]), + ], + }; + + let mut class = ClassBuilder::new("demo/App", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0001, "run", "()V", code) + .attribute(Attribute::SourceFile("RealApp.java".into())) + .attribute(Attribute::Signature("Ljava/lang/Object;".into())) + .attribute(Attribute::InnerClasses(Vec::new())) + .build(); + class.methods[0].attributes.push(Attribute::Raw { + name: "MethodParameters".into(), + data: vec![], + }); + + Program { + classes: vec![class], + resources: vec![Resource { + path: "app.json".into(), + data: br#"{}"#.to_vec(), + }], + } +} + +fn run(program: &mut Program, debug: DebugConfig) -> PassReport { + let config = Config { + seed: Some(7), + debug, + rename: RenameConfig { + class_style: noxis_core::NameStyle::ShortLatin, + ..RenameConfig::default() + }, + ..Config::default() + }; + let mut ctx = Context::new(config); + DebugInfoPass.apply(&mut ctx, program).expect("debug pass") +} + +#[test] +fn default_config_strips_and_spoofs() { + let mut program = sample(); + let report = run(&mut program, DebugConfig::default()); + + let class = &program.classes[0]; + let code = class.methods[0].code.as_ref().expect("code"); + assert!(code.attributes.is_empty(), "code attributes must go"); + assert!( + class.methods[0].attributes.is_empty(), + "MethodParameters goes" + ); + + match class.attributes[0] { + Attribute::SourceFile(ref name) => assert_ne!(name, "RealApp.java"), + _ => panic!("SourceFile must come first"), + } + assert_eq!(class.attributes.len(), 3, "Signature/InnerClasses stay"); + assert!(report.transformed > 0); +} + +#[test] +fn signatures_and_parameters_survive_when_disabled() { + let debug = DebugConfig { + strip_line_numbers: false, + fake_line_numbers: false, + strip_local_variables: false, + spoof_source_file: false, + strip_source_file: false, + strip_signatures: false, + }; + let mut program = sample(); + run(&mut program, debug); + + let class = &program.classes[0]; + assert_eq!(class.methods[0].code.as_ref().unwrap().attributes.len(), 2); + assert_eq!(class.methods[0].attributes.len(), 1); + assert_eq!(class.attributes.len(), 3); + assert_eq!( + class.attributes[0], + Attribute::SourceFile("RealApp.java".into()) + ); +} + +#[test] +fn same_seed_gives_the_same_spoofed_name() { + let mut first = sample(); + let mut second = sample(); + run(&mut first, DebugConfig::default()); + run(&mut second, DebugConfig::default()); + assert_eq!(first.classes[0].attributes, second.classes[0].attributes); +} + +fn line_numbers(program: &Program) -> Vec { + let code = program.classes[0].methods[0].code.as_ref().expect("code"); + code.attributes + .iter() + .find_map(|attr| match attr { + Attribute::LineNumberTable(rows) => Some(rows.iter().map(|r| r.line).collect()), + _ => None, + }) + .unwrap_or_default() +} + +/// Fake lines replace the table instead of removing it, deterministically. +#[test] +fn fake_line_numbers_replace_not_remove() { + let debug = DebugConfig { + strip_line_numbers: false, + fake_line_numbers: true, + ..DebugConfig::default() + }; + let mut first = sample(); + let mut second = sample(); + run(&mut first, debug.clone()); + run(&mut second, debug); + + for program in [&first, &second] { + let lines = line_numbers(program); + assert_eq!(lines.len(), 2, "table must survive with 2 rows"); + assert_ne!(lines, vec![12, 15], "numbers must change"); + assert!(lines.iter().all(|l| *l > 0), "numbers stay plausible"); + } + assert_eq!( + line_numbers(&first), + line_numbers(&second), + "same seed, same fake lines" + ); +} + +/// Stripping the source removes the attribute even with spoofing on. +#[test] +fn strip_source_file_removes() { + let debug = DebugConfig { + strip_source_file: true, + ..DebugConfig::default() + }; + let mut program = sample(); + run(&mut program, debug); + + assert!( + program.classes[0] + .attributes + .iter() + .all(|attr| !matches!(attr, Attribute::SourceFile(_))), + "no SourceFile may survive" + ); +} + +/// Stripping wins over faking: a removed table has nothing to fake. +#[test] +fn strip_wins_over_fake() { + let debug = DebugConfig { + strip_line_numbers: true, + fake_line_numbers: true, + ..DebugConfig::default() + }; + let mut program = sample(); + run(&mut program, debug); + + assert!(line_numbers(&program).is_empty()); +}