Initial commit: noxis JVM bytecode obfuscator

Workspace of 9 crates (noxis, noxis-core, noxis-classfile, noxis-analysis,
noxis-passes, noxis-runtime, noxis-jar, noxis-verify, noxis-structure-check).
AGPL-3.0-only license, Rust 2024 edition, unsafe_code denied.
This commit is contained in:
Loki 2026-10-08 22:01:03 +02:00
commit a641e69455
140 changed files with 17197 additions and 0 deletions

View file

@ -0,0 +1,68 @@
//! Команда `analyze`: разбор jar без изменений.
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use noxis_analysis::Analysis;
use noxis_core::Config;
/// Аргументы анализа.
#[derive(Debug, clap::Args)]
pub struct Args {
/// Входной jar.
pub input: PathBuf,
/// TOML-конфиг с keep-паттернами (без него — пустой keep).
#[arg(short, long)]
pub config: Option<PathBuf>,
}
/// Показать классы, entrypoint'ы и keep-правила.
pub fn run(args: Args) -> Result<()> {
let config = match &args.config {
Some(path) => {
let text = std::fs::read_to_string(path)
.with_context(|| format!("конфиг {}", path.display()))?;
Config::from_toml(&text).map_err(|error| anyhow::anyhow!("{error}"))?
}
None => Config::default(),
};
let entries = noxis_jar::read_file(&args.input)
.with_context(|| format!("чтение {}", args.input.display()))?;
let program =
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("разбор jar: {error}"))?;
let analysis =
Analysis::run(&program, &config).map_err(|error| anyhow::anyhow!("анализ: {error}"))?;
println!("классов: {}", program.classes.len());
println!("ресурсов: {}", program.resources.len());
println!("locked классов: {}", analysis.locked_classes.len());
println!(
"entrypoints.main: {}",
analysis.entrypoints.main_classes.len()
);
for name in &analysis.entrypoints.main_classes {
println!(" main: {name}");
}
println!(
"reflective_names: {}",
analysis.entrypoints.reflective_names.len()
);
println!("keep-правил: {}", analysis.keep.len());
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn analyze_missing_jar_fails() {
let args = Args {
input: PathBuf::from("definitely-not-here.jar"),
config: None,
};
assert!(run(args).is_err());
}
}

View file

@ -0,0 +1,5 @@
//! Команды CLI.
pub mod analyze;
pub mod obfuscate;
pub mod patterns;

View file

@ -0,0 +1,232 @@
//! Команда `obfuscate`: jar → конвейер → jar + маппинги + отчёт.
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use noxis_core::{Config, Context, PassId};
/// Аргументы обфускации.
#[derive(Debug, clap::Args)]
pub struct Args {
/// Входной jar.
pub input: PathBuf,
/// Выходной jar.
#[arg(short, long)]
pub output: PathBuf,
/// TOML-конфиг (без него — профиль Standard).
#[arg(short, long)]
pub config: Option<PathBuf>,
/// Мастер-сид (детерминированность по умолчанию — из конфига).
#[arg(long)]
pub seed: Option<u64>,
/// Куда положить JSON-отчёт (по умолчанию рядом с выходом).
#[arg(long)]
pub report: Option<PathBuf>,
/// Выключить пасс переименования.
#[arg(long)]
pub no_rename: bool,
/// Выключить пасс ресурсов.
#[arg(long)]
pub no_resources: bool,
/// Выключить пасс строк.
#[arg(long)]
pub no_strings: bool,
/// Выключить пасс констант.
#[arg(long)]
pub no_constants: bool,
/// Выключить пасс шума.
#[arg(long)]
pub no_noise: bool,
/// Выключить пасс отладочной информации.
#[arg(long)]
pub no_debug_info: bool,
/// Выключить пасс control-flow (ещё не реализован — честная ошибка).
#[arg(long)]
pub no_flow: bool,
}
/// Прогнать обфускацию.
pub fn run(args: Args) -> Result<()> {
if args.no_flow {
anyhow::bail!("пасс flow ещё не реализован (см. todo.md §3.4)");
}
let mut config = match &args.config {
Some(path) => {
let text = std::fs::read_to_string(path)
.with_context(|| format!("конфиг {}", path.display()))?;
Config::from_toml(&text).map_err(|error| anyhow::anyhow!("{error}"))?
}
None => Config::default(),
};
if let Some(seed) = args.seed {
config.seed = Some(seed);
}
for (flag, id) in [
(args.no_rename, PassId::Rename),
(args.no_resources, PassId::Resources),
(args.no_strings, PassId::Strings),
(args.no_constants, PassId::Constants),
(args.no_noise, PassId::Noise),
(args.no_debug_info, PassId::DebugInfo),
] {
if flag && !config.disable.contains(&id) {
config.disable.push(id);
}
}
let entries = noxis_jar::read_file(&args.input)
.with_context(|| format!("чтение {}", args.input.display()))?;
let (entries, stripped) = noxis_jar::strip_signatures(entries);
let mut program =
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("разбор jar: {error}"))?;
let mut ctx = Context::new(config);
let report = noxis_passes::standard_pipeline()
.run(&mut ctx, &mut program)
.map_err(|error| anyhow::anyhow!("конвейер: {error}"))?;
let entries =
noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("сборка jar: {error}"))?;
noxis_jar::write_file(&args.output, &entries)
.with_context(|| format!("запись {}", args.output.display()))?;
let report_path = args
.report
.unwrap_or_else(|| args.output.with_extension("report.json"));
std::fs::write(
&report_path,
serde_json::to_string_pretty(&SerializableReport::from(&report, &ctx))?,
)
.with_context(|| format!("отчёт {}", report_path.display()))?;
std::fs::write(
args.output.with_extension("mapping.txt"),
ctx.mapping.to_text(),
)?;
println!(
"noxis: {} → {} (классов: {}, подписей срезано: {}, отчёт: {})",
args.input.display(),
args.output.display(),
report.counters.get("classes").copied().unwrap_or(0),
stripped,
report_path.display()
);
Ok(())
}
/// Отчёт в JSON: сид, пассы, счётчики, маппинги текстом.
#[derive(Debug, serde::Serialize)]
struct SerializableReport {
seed: u64,
passes: Vec<PassEntry>,
counters: std::collections::BTreeMap<String, usize>,
notes: Vec<String>,
mapping: String,
}
#[derive(Debug, serde::Serialize)]
struct PassEntry {
pass: String,
transformed: usize,
skipped: usize,
notes: Vec<String>,
}
impl SerializableReport {
fn from(report: &noxis_core::Report, ctx: &Context) -> Self {
Self {
seed: report.seed,
passes: report
.passes
.iter()
.map(|pass| PassEntry {
pass: pass.pass.to_string(),
transformed: pass.transformed,
skipped: pass.skipped,
notes: pass.notes.clone(),
})
.collect(),
counters: report.counters.clone(),
notes: report.notes.clone(),
mapping: ctx.mapping.to_text(),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
fn sample_jar(path: &std::path::Path) {
use noxis_classfile::{ClassBuilder, Code, Insn, Program, Version};
let code = Code {
max_stack: 0,
max_locals: 0,
insns: vec![Insn::Return(None)],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let 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 entries = noxis_jar::save(&program).expect("save");
noxis_jar::write_file(path, &entries).expect("write jar");
}
#[test]
fn obfuscate_end_to_end() {
let dir = std::env::temp_dir().join(format!("noxis-cli-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let input = dir.join("in.jar");
let output = dir.join("out.jar");
sample_jar(&input);
run(Args {
input: input.clone(),
output: output.clone(),
config: None,
seed: Some(5),
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_noise: false,
no_debug_info: false,
no_flow: false,
})
.expect("obfuscate");
assert!(output.exists(), "нет выходного jar");
assert!(dir.join("out.report.json").exists(), "нет отчёта");
assert!(dir.join("out.mapping.txt").exists(), "нет маппингов");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_flow_is_honest_error() {
let args = Args {
input: PathBuf::from("in.jar"),
output: PathBuf::from("out.jar"),
config: None,
seed: None,
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_noise: false,
no_debug_info: false,
no_flow: true,
};
assert!(run(args).is_err());
}
}

View file

@ -0,0 +1,38 @@
//! Команда `patterns`: красный список и его прогон по jar'у.
use std::path::PathBuf;
use anyhow::{Context as _, Result};
/// Аргументы просмотра/проверки паттернов.
#[derive(Debug, clap::Args)]
pub struct Args {
/// Показать только список (без проверки).
#[arg(long)]
pub list: bool,
/// Jar для проверки на секреты открытым текстом.
#[arg(short, long)]
pub input: Option<PathBuf>,
}
/// Показать список или прогнать его по jar'у.
pub fn run(args: Args) -> Result<()> {
if args.list || args.input.is_none() {
for pattern in noxis_verify::RED_PATTERNS {
println!("{pattern}");
}
}
let Some(input) = args.input else {
return Ok(());
};
let entries =
noxis_jar::read_file(&input).with_context(|| format!("чтение {}", input.display()))?;
let program =
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("разбор jar: {error}"))?;
let hits = noxis_verify::scan_secrets(&program);
for hit in &hits {
println!("{}: [{}] {:.48}", hit.location, hit.pattern, hit.sample);
}
println!("совпадений: {}", hits.len());
Ok(())
}

3
crates/noxis/src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
//! Библиотека CLI: команды как вызываемые функции (тонкий `main` — в `main.rs`).
pub mod cmd;

35
crates/noxis/src/main.rs Normal file
View file

@ -0,0 +1,35 @@
//! CLI обфускатора: `obfuscate`, `analyze`, `patterns`.
use anyhow::Result;
use clap::{Parser, Subcommand};
use noxis::cmd::{analyze, obfuscate, patterns};
/// Обфускатор JVM-байткода на Rust.
#[derive(Debug, Parser)]
#[command(name = "noxis", version, about)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Обфусцировать jar: конвейер, маппинги, JSON-отчёт.
Obfuscate(obfuscate::Args),
/// Разобрать jar: классы, entrypoint'ы, keep-правила.
Analyze(analyze::Args),
/// Красный список паттернов: показать или прогнать по jar'у.
Patterns(patterns::Args),
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
match Cli::parse().command {
Commands::Obfuscate(args) => obfuscate::run(args),
Commands::Analyze(args) => analyze::run(args),
Commands::Patterns(args) => patterns::run(args),
}
}