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:
commit
a641e69455
140 changed files with 17197 additions and 0 deletions
22
crates/noxis-verify/Cargo.toml
Normal file
22
crates/noxis-verify/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "noxis-verify"
|
||||
description = "Проверка обфускации: утечки имён, секреты в строках, JVM-верификация, метрики"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
noxis-analysis = { workspace = true }
|
||||
noxis-classfile = { workspace = true }
|
||||
noxis-core = { workspace = true }
|
||||
noxis-jar = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
noxis-structure-check = { workspace = true }
|
||||
3
crates/noxis-verify/build.rs
Normal file
3
crates/noxis-verify/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
noxis_structure_check::check();
|
||||
}
|
||||
126
crates/noxis-verify/src/java.rs
Normal file
126
crates/noxis-verify/src/java.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! Проверка байтов настоящим `java -Xverify:all`.
|
||||
//!
|
||||
//! Каждый класс грузится отдельной JVM в дереве пакетов: изоляция важнее
|
||||
//! скорости (один битый класс не тянет за собой остальные). Классы, которые
|
||||
//! не грузятся из-за отсутствующих зависимостей, — `Skipped`, а не провал:
|
||||
//! судить о них верификатор не может.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Исход проверки одного класса.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JavaVerdict {
|
||||
/// Загрузился без ошибок верификации (`main` при этом может отсутствовать).
|
||||
Verified,
|
||||
/// Верификатор отверг класс (текст ошибки — для отчёта).
|
||||
Failed(String),
|
||||
/// Судить нельзя: нет `java` или не хватает зависимостей.
|
||||
Skipped(String),
|
||||
}
|
||||
|
||||
/// Есть ли `java` для проверки.
|
||||
pub fn java_available() -> bool {
|
||||
Command::new("java")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success())
|
||||
}
|
||||
|
||||
/// Проверить байты одного класса настоящим верификатором.
|
||||
pub fn verify_class_bytes(internal_name: &str, bytes: &[u8]) -> JavaVerdict {
|
||||
if !java_available() {
|
||||
return JavaVerdict::Skipped("нет java".to_string());
|
||||
}
|
||||
let scratch = std::env::temp_dir().join(format!("noxis-verify-{}", std::process::id()));
|
||||
let mut path = scratch.clone();
|
||||
for part in internal_name.split('/') {
|
||||
path.push(part);
|
||||
}
|
||||
path.set_extension("class");
|
||||
if let Err(error) = path
|
||||
.parent()
|
||||
.map(std::fs::create_dir_all)
|
||||
.unwrap_or(Ok(()))
|
||||
.and_then(|()| std::fs::write(&path, bytes))
|
||||
{
|
||||
return JavaVerdict::Skipped(format!("не записался: {error}"));
|
||||
}
|
||||
let verdict = run_java(&scratch, &internal_name.replace('/', "."));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
verdict
|
||||
}
|
||||
|
||||
/// Проверить все классы jar'а (фильтр — префиксы внутренних имён).
|
||||
pub fn verify_jar(jar: &Path, scope: &[String]) -> Vec<(String, JavaVerdict)> {
|
||||
let entries = match noxis_jar::read_file(jar) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
return vec![(
|
||||
jar.display().to_string(),
|
||||
JavaVerdict::Skipped(format!("jar не читается: {error}")),
|
||||
)];
|
||||
}
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.name.ends_with(".class"))
|
||||
.map(|entry| entry.name.trim_end_matches(".class").to_string())
|
||||
.filter(|name| scope.is_empty() || scope.iter().any(|prefix| name.starts_with(prefix)))
|
||||
.map(|name| {
|
||||
let bytes = entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == format!("{name}.class"))
|
||||
.map(|entry| entry.data.as_slice())
|
||||
.unwrap_or_default();
|
||||
(name.clone(), verify_class_bytes(&name, bytes))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Запуск `java -Xverify:all -cp <dir> <class>` и разбор исхода.
|
||||
fn run_java(dir: &Path, dotted: &str) -> JavaVerdict {
|
||||
let output = match Command::new("java")
|
||||
.arg("-Xverify:all")
|
||||
.arg("-cp")
|
||||
.arg(dir)
|
||||
.arg(dotted)
|
||||
.output()
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) => return JavaVerdict::Skipped(format!("запуск java: {error}")),
|
||||
};
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("VerifyError") || stderr.contains("ClassFormatError") {
|
||||
return JavaVerdict::Failed(stderr.into_owned());
|
||||
}
|
||||
if stderr.contains("NoClassDefFoundError") || stderr.contains("ClassNotFoundException") {
|
||||
return JavaVerdict::Skipped(format!("не хватает зависимостей: {dotted}"));
|
||||
}
|
||||
JavaVerdict::Verified
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_java_is_skip_not_fail() {
|
||||
if java_available() {
|
||||
return;
|
||||
}
|
||||
assert!(matches!(
|
||||
verify_class_bytes("a/B", &[]),
|
||||
JavaVerdict::Skipped(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_paths_are_exhaustive() {
|
||||
let ok = JavaVerdict::Verified;
|
||||
assert_eq!(ok, JavaVerdict::Verified);
|
||||
assert_ne!(ok, JavaVerdict::Skipped(String::new()));
|
||||
}
|
||||
}
|
||||
146
crates/noxis-verify/src/leaks.rs
Normal file
146
crates/noxis-verify/src/leaks.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! Утечки имён и секреты открытым текстом.
|
||||
//!
|
||||
//! Два независимых вопроса: (1) пережило ли исходное имя обфускацию
|
||||
//! (сверка эталона до/после с учётом keep); (2) остались ли в строках
|
||||
//! секреты, которые обязан был съесть пасс `strings`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use noxis_analysis::KeepRules;
|
||||
use noxis_classfile::{Const, Insn, Program};
|
||||
|
||||
/// Красный список: паттерны секретов, которых не должно быть в выходе.
|
||||
///
|
||||
/// Это generic-признаки (пароли, ключи, URL, приватные ключи), а не имена
|
||||
/// конкретного проекта: проектные секреты ловятся тем, что строка вообще
|
||||
/// осталась незашифрованной (см. `scan_secrets` + keep-список пасса).
|
||||
pub const RED_PATTERNS: &[&str] = &[
|
||||
r"(?i)password\s*=\s*[^\s;]+",
|
||||
r"(?i)\bapi[_-]?key\b",
|
||||
r#"jdbc:[a-z]+://[^\s"']+"#,
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
||||
r#"https?://[^\s"']+"#,
|
||||
];
|
||||
|
||||
/// Все идентификаторы программы: классы, члены, строки кода.
|
||||
///
|
||||
/// Члены — по простому имени (без владельца): сверка строгая и дешёвая,
|
||||
/// для точечной проверки есть `find_leaks` с keep-фильтром.
|
||||
pub fn collect_names(program: &Program) -> BTreeSet<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
for class in &program.classes {
|
||||
names.insert(class.this_class.clone());
|
||||
for field in &class.fields {
|
||||
names.insert(field.name.clone());
|
||||
}
|
||||
for method in &class.methods {
|
||||
names.insert(method.name.clone());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Имена из эталона, пережившие обфускацию (минус keep).
|
||||
///
|
||||
/// Пустой результат — норма; непустой — тест падает с этим списком.
|
||||
pub fn find_leaks(before: &BTreeSet<String>, after: &Program, keep: &KeepRules) -> Vec<String> {
|
||||
let names = collect_names(after);
|
||||
before
|
||||
.iter()
|
||||
.filter(|name| names.contains(*name))
|
||||
.filter(|name| !keep.keeps_class(name) && !keep.keeps_member(name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Какие красные паттерны сработали на тексте.
|
||||
pub fn check_patterns(text: &str) -> Vec<&'static str> {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static COMPILED: OnceLock<Vec<(&'static str, regex::Regex)>> = OnceLock::new();
|
||||
let compiled = COMPILED.get_or_init(|| {
|
||||
RED_PATTERNS
|
||||
.iter()
|
||||
.filter_map(|pattern| {
|
||||
regex::Regex::new(pattern)
|
||||
.ok()
|
||||
.map(|regex| (*pattern, regex))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
compiled
|
||||
.iter()
|
||||
.filter(|(_, regex)| regex.is_match(text))
|
||||
.map(|(pattern, _)| *pattern)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Секрет открытым текстом: где (`класс/метод` или `ресурс:путь`) и что.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SecretHit {
|
||||
pub location: String,
|
||||
pub pattern: &'static str,
|
||||
pub sample: String,
|
||||
}
|
||||
|
||||
/// Все строки кода и текстовых ресурсов против красного списка.
|
||||
pub fn scan_secrets(program: &Program) -> Vec<SecretHit> {
|
||||
let mut hits = Vec::new();
|
||||
for class in &program.classes {
|
||||
for method in &class.methods {
|
||||
let Some(code) = method.code.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
for insn in &code.insns {
|
||||
if let Insn::Const(Const::String(text)) = insn {
|
||||
let location = format!("{}/{}", class.this_class, method.name);
|
||||
hits.extend(check_text(&location, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for resource in &program.resources {
|
||||
if let Ok(text) = std::str::from_utf8(&resource.data) {
|
||||
hits.extend(check_text(&format!("ресурс:{}", resource.path), text));
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
fn check_text(location: &str, text: &str) -> Vec<SecretHit> {
|
||||
check_patterns(text)
|
||||
.into_iter()
|
||||
.map(|pattern| SecretHit {
|
||||
location: location.to_string(),
|
||||
pattern,
|
||||
sample: text.chars().take(48).collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn red_patterns_all_compile() {
|
||||
assert_eq!(check_patterns("password=hunter2").len(), 1);
|
||||
assert_eq!(RED_PATTERNS.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_patterns_catch_obvious_secrets() {
|
||||
assert!(!check_patterns("password=hunter2").is_empty());
|
||||
assert!(!check_patterns("api_key=abc").is_empty());
|
||||
assert!(!check_patterns("jdbc:mysql://host/db").is_empty());
|
||||
assert!(!check_patterns("https://example.com/hook").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_text_passes() {
|
||||
assert!(check_patterns("hello world").is_empty());
|
||||
assert!(check_patterns("aB3_foO").is_empty());
|
||||
}
|
||||
}
|
||||
17
crates/noxis-verify/src/lib.rs
Normal file
17
crates/noxis-verify/src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! # noxis-verify
|
||||
//!
|
||||
//! Проверка результата обфускации, а не процесса:
|
||||
//!
|
||||
//! * [`leaks`] — утечки исходных имён + секреты открытым текстом;
|
||||
//! * [`java`] — настоящий `java -Xverify:all` по байтам на диске;
|
||||
//! * [`metrics`] — счётчики программы, раздувание, стабильность маппингов.
|
||||
|
||||
pub mod java;
|
||||
pub mod leaks;
|
||||
pub mod metrics;
|
||||
|
||||
pub use java::{JavaVerdict, java_available, verify_class_bytes, verify_jar};
|
||||
pub use leaks::{RED_PATTERNS, SecretHit, check_patterns, collect_names, find_leaks, scan_secrets};
|
||||
pub use metrics::{
|
||||
ProgramStats, check_inflation, inflation_ratio, mappings_changed, program_stats,
|
||||
};
|
||||
93
crates/noxis-verify/src/metrics.rs
Normal file
93
crates/noxis-verify/src/metrics.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Метрики программы: счётчики, раздувание, стабильность маппингов.
|
||||
|
||||
use serde::Serialize;
|
||||
use noxis_classfile::{Const, Insn, Program};
|
||||
use noxis_core::Mapping;
|
||||
|
||||
/// Счётчики программы (для отчёта и бюджетов).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ProgramStats {
|
||||
pub classes: usize,
|
||||
pub methods: usize,
|
||||
pub fields: usize,
|
||||
pub insns: usize,
|
||||
pub strings: usize,
|
||||
pub resources: usize,
|
||||
}
|
||||
|
||||
/// Посчитать статистику по программе.
|
||||
pub fn program_stats(program: &Program) -> ProgramStats {
|
||||
let mut stats = ProgramStats {
|
||||
classes: program.classes.len(),
|
||||
resources: program.resources.len(),
|
||||
..ProgramStats::default()
|
||||
};
|
||||
for class in &program.classes {
|
||||
stats.fields += class.fields.len();
|
||||
stats.methods += class.methods.len();
|
||||
for method in &class.methods {
|
||||
let Some(code) = method.code.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
stats.insns += code.insns.len();
|
||||
stats.strings += code
|
||||
.insns
|
||||
.iter()
|
||||
.filter(|insn| matches!(insn, Insn::Const(Const::String(_))))
|
||||
.count();
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Коэффициент раздувания (`after / before`).
|
||||
pub fn inflation_ratio(before_bytes: u64, after_bytes: u64) -> f64 {
|
||||
if before_bytes == 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
after_bytes as f64 / before_bytes as f64
|
||||
}
|
||||
|
||||
/// Раздувание в бюджете (`max_ratio`, например `1.5` = +50%).
|
||||
pub fn check_inflation(before_bytes: u64, after_bytes: u64, max_ratio: f64) -> Result<(), String> {
|
||||
let ratio = inflation_ratio(before_bytes, after_bytes);
|
||||
if ratio > max_ratio {
|
||||
return Err(format!(
|
||||
"раздувание {ratio:.2} сверх бюджета {max_ratio:.2}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Маппинги обязаны меняться между сборками (иначе сид не работает).
|
||||
pub fn mappings_changed(before: &Mapping, after: &Mapping) -> bool {
|
||||
before != after
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_program_stats_are_zero() {
|
||||
let stats = program_stats(&Program::default());
|
||||
assert_eq!(stats, ProgramStats::default());
|
||||
assert_eq!(stats.classes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflation_math() {
|
||||
assert!((inflation_ratio(100, 150) - 1.5).abs() < f64::EPSILON);
|
||||
assert!(inflation_ratio(0, 10).is_infinite());
|
||||
assert!(check_inflation(100, 120, 1.5).is_ok());
|
||||
assert!(check_inflation(100, 200, 1.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_mappings_are_stable() {
|
||||
let mapping = Mapping::default();
|
||||
assert!(!mappings_changed(&mapping, &mapping));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue