passes: implement control-flow pass (4 schemes + rotation)

FlowPass rebuilds one method per scheme picked from FlowConfig:
state-spread (dense tableswitch dispatcher), computed-switch
(sparse lookupswitch over a counter local), butterfly (branch
inversion), exception-goto (dispatcher under try with a dead
catch-all), plus opaque goto-splices inside every method and
StackMapTable recomputation afterwards. Now in standard_pipeline
(between constants and noise); --no-flow is an honest off-flag.

Acceptance is real JVM, not shapes: per-scheme verify, rotation
spread, calc-prints-45 execution, paranoid smoke on lorep-1.1.0
(131 methods rebuilt, no new failures over standard).

Two verifier rules learned the hard way are now construction
invariants: no inserted mark shares a bytecode offset with a
foreign one (nop pads), and no frameless gap follows a terminal
(dead blocks are opaque-entered and throw).

Also fixes a latent frame-emitter bug the pass exposed: wide
stack values were written as slot pairs ([Double, Top]) while
javac writes one entry ([Double]); HotSpot rejects the pair.
Stacks now collapse like locals (emit::stack_vars), with a
regression test in classfile/tests/frames.rs.
This commit is contained in:
Loki 2026-10-09 11:11:55 +02:00
parent 39178420e4
commit 2172206e83
Signed by: boba
GPG key ID: 253067914055423B
15 changed files with 1360 additions and 103 deletions

View file

@ -29,7 +29,7 @@
| пассы: rename, resources, strings, constants, noise, debug | ✅ |
| верификация: red-паттерны, `java -Xverify:all`, метрики | ✅ |
| CLI (`obfuscate` / `analyze` / `patterns`) | ✅ |
| пасс flow (кадры готовы, схем флаттенинга пока нет) | 🚧 |
| пасс flow (кадры готовы, 4 схемы: state-spread, computed-switch, butterfly, exception-goto) | ✅ |
Подробности в [`docs/architecture.md`](docs/architecture.md), план в [`todo.md`](todo.md).
@ -50,12 +50,8 @@ cargo run -p noxis -- patterns --input output.jar
```
`obfuscate` по умолчанию включает все готовые пассы. Флаги `--no-rename`,
`--no-strings`, `--no-constants`, `--no-noise`, `--no-resources`, `--no-debug-info`
выключают их по отдельности. `--no-flow` честно падает с ошибкой: пасса пока нет.
Одно `cargo build` уже гоняет проверку структуры кода: `.rs`-файл длиннее
250 строк или директория с больше чем 4 `.rs`-файлами ломают сборку.
Документация (`docs/**`) под это правило не попадает. Правила в [`docs/README.md`](docs/README.md).
`--no-strings`, `--no-constants`, `--no-noise`, `--no-resources`, `--no-debug-info`,
`--no-flow` выключают их по отдельности.
## Структура

View file

@ -35,7 +35,7 @@ pub(super) fn emit_frames(
Some(previous) => target
.checked_sub(previous)
.and_then(|delta| delta.checked_sub(1))
.ok_or_else(|| merger.invalid("кадры не по порядку"))?,
.ok_or_else(|| merger.invalid("frames out of order"))?,
};
out.push(select(delta, &state.locals, &previous_locals, &state.stack));
previous_locals = state.locals.clone();
@ -54,13 +54,17 @@ fn select(
// otherwise halves would produce `Append{[Top, Long]}`.
let current = vars(locals);
let base = vars(previous);
// The operand stack needs the same collapsing: a `long`/`double` is one
// entry (`javac` writes `[Double, Integer]`, not `[Double, Top, Integer]`);
// an explicit half without its value is rejected by HotSpot.
let stack = stack_vars(stack);
if current == base {
if stack.is_empty() {
return StackMapFrame::Same {
offset_delta: delta,
};
}
if let [local] = stack {
if let [local] = stack.as_slice() {
return StackMapFrame::SameLocals {
offset_delta: delta,
local: local.clone(),
@ -94,6 +98,30 @@ fn select(
}
}
/// 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.
fn stack_vars(stack: &[VerificationType]) -> Vec<VerificationType> {
let mut out = Vec::with_capacity(stack.len());
let mut index = 0;
while index < stack.len() {
match &stack[index] {
VerificationType::Long | VerificationType::Double
if stack.get(index + 1) == Some(&VerificationType::Top) =>
{
out.push(stack[index].clone());
index += 2;
}
other => {
out.push(other.clone());
index += 1;
}
}
}
out
}
/// 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

View file

@ -1,9 +1,9 @@
//! Приёмка `compute_frames` на фикстурах (`tests/data`).
//! Acceptance for `compute_frames`: fixtures plus emitter edge cases.
//!
//! Кадры сносятся и считаются заново. Судьи: детерминизм + round-trip IR +
//! настоящий `java -Xverify:all` для классов без внешних зависимостей.
//! (`verify_class` backend'а для приёмки не годится: он ждёт кадры в байтах,
//! а `from_bytes` отдаёт в индексах — см. `todo.md`.)
//! 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`.)
#![allow(clippy::expect_used, clippy::unwrap_used)]
@ -11,9 +11,12 @@ use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use noxis_classfile::{Hierarchy, compute_frames, format};
use noxis_classfile::{
Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, Prim, SlotKind, Version,
{Hierarchy, compute_frames, format},
};
/// Карта `класс → суперкласс` для LUB.
/// A `class → superclass` map for LUB.
#[derive(Default)]
struct MapHierarchy {
supers: BTreeMap<String, String>,
@ -25,13 +28,13 @@ impl Hierarchy for MapHierarchy {
}
}
/// Классы только на JDK — их `java` проверит полностью.
/// JDK-only classes — `java` checks them fully.
const JDK_ONLY: [&str; 2] = [
"dev/loki/lorep/util/TimeUtil",
"dev/loki/lorep/util/PaginationUtil",
];
/// Фикстуры: снести кадры, посчитать заново, проверить выход.
/// Fixtures: drop frames, recompute, check the output.
#[test]
fn fixtures_recompute_and_verify() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data");
@ -41,7 +44,7 @@ fn fixtures_recompute_and_verify() {
.filter(|path| path.extension().is_some_and(|ext| ext == "class"))
.collect();
names.sort();
assert!(!names.is_empty(), "нет фикстур в tests/data");
assert!(!names.is_empty(), "no fixtures in tests/data");
let mut supers = BTreeMap::new();
let mut parsed = Vec::new();
@ -50,7 +53,7 @@ fn fixtures_recompute_and_verify() {
supers.insert(class.this_class.clone(), class.super_class.clone());
parsed.push(class);
}
// Без суперкласса — только `java/lang/Object`.
// Only `java/lang/Object` has no superclass.
supers.entry("java/lang/Object".to_string()).or_insert(None);
let hierarchy = MapHierarchy {
supers: supers
@ -71,11 +74,11 @@ fn fixtures_recompute_and_verify() {
}
let first = compute_frames(&class.this_class, method, &hierarchy).expect("recompute");
method.code.as_mut().expect("code").frames = first.clone();
// Детерминизм: повторный прогон даёт те же кадры.
// Determinism: a second run yields the same frames.
let second = compute_frames(&class.this_class, method, &hierarchy).expect("recompute");
assert_eq!(first, second, "недетерминированные кадры");
assert_eq!(first, second, "nondeterministic frames");
}
// Round-trip: записанное читается в тот же IR (включая кадры).
// Round-trip: written bytes read back into the same IR (frames included).
let written = format::write(class).expect("write");
assert_eq!(
format::parse(&written).expect("re-parse"),
@ -89,7 +92,85 @@ fn fixtures_recompute_and_verify() {
let _ = std::fs::remove_dir_all(&scratch);
}
/// Есть ли `java` для проверки настоящим верификатором.
/// 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
/// bare `[Double]`, and HotSpot rejects the pair with
/// `Inconsistent stackmap frames`.
#[test]
fn wide_stack_join_emits_single_entries() {
// iload_0, i2d, iload_1, <opaque join>, i2d, ddiv, d2i, ireturn.
// At the join the stack holds a double plus an int.
let (dead, into) = (100, 101);
let mut insns = vec![
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Convert(noxis_classfile::Convert::I2D),
Insn::Load {
slot: 1,
kind: SlotKind::Int,
},
];
insns.extend([
Insn::Const(Const::Int(7)),
Insn::Dup,
Insn::Binary {
prim: Prim::Int,
op: Arith::Mul,
},
Insn::Const(Const::Int(7)),
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
Insn::Const(Const::Int(1)),
Insn::Binary {
prim: Prim::Int,
op: Arith::And,
},
Insn::Branch(BranchCond::Zero(Cond::Ne), dead),
Insn::Goto(into),
Insn::Mark(dead),
Insn::Nop,
Insn::Mark(into),
Insn::Convert(noxis_classfile::Convert::I2D),
Insn::Binary {
prim: Prim::Double,
op: Arith::Div,
},
Insn::Convert(noxis_classfile::Convert::D2I),
Insn::Return(Some(Prim::Int)),
]);
let code = Code {
max_stack: 6,
max_locals: 2,
insns,
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let class = ClassBuilder::new("demo/Wide", Some("java/lang/Object"))
.version(Version::JAVA_21)
.method_with_code(0x0009, "calc", "(II)I", code)
.build();
let hierarchy = MapHierarchy::default();
let frames = compute_frames(&class.this_class, &class.methods[0], &hierarchy).expect("frames");
let mut class = class;
class.methods[0].code.as_mut().expect("code").frames = frames;
if !java_available() {
eprintln!("SKIP: no java");
return;
}
let scratch = std::env::temp_dir().join(format!("noxis-wide-{}", std::process::id()));
let bytes = format::write(&class).expect("write");
java_verify(&scratch, &class, &bytes).expect("java -Xverify:all");
let _ = std::fs::remove_dir_all(&scratch);
}
/// Whether `java` is available for checks with the real verifier.
fn java_available() -> bool {
Command::new("java")
.arg("-version")
@ -97,8 +178,8 @@ fn java_available() -> bool {
.is_ok_and(|out| out.status.success())
}
/// Записать класс в дерево пакетов и прогнать `java -Xverify:all`.
/// Успех — загрузка без ошибок верификации (`main` при этом отсутствует).
/// Write a class into a package tree and run `java -Xverify:all`.
/// Success means loading without verification errors (`main` may be missing).
fn java_verify(
scratch: &Path,
class: &noxis_classfile::ClassFile,
@ -118,7 +199,7 @@ fn java_verify(
.arg(scratch)
.arg(&dotted)
.output()
.map_err(|e| format!("запуск java: {e}"))?;
.map_err(|e| format!("running java: {e}"))?;
let stderr = String::from_utf8_lossy(&out.stderr);
for bad in [
"VerifyError",

View file

@ -0,0 +1,96 @@
//! Entry dispatchers: opaque `switch` prologues and opaque guards.
//!
//! 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
//! `StackMapTable` stays consistent.
use noxis_classfile::{Arith, BranchCond, Cond, Const, Insn, Prim, SlotKind};
use noxis_core::Rng;
/// Dense `tableswitch` dispatcher (`state-spread`).
///
/// 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.
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;
let low = key - 2;
vec![
Insn::Const(Const::Int(half)),
Insn::Const(Const::Int(key.wrapping_sub(half))),
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
Insn::TableSwitch {
default: dead,
low,
high: key + 2,
targets: vec![dead, dead, body, dead, dead],
},
Insn::Mark(dead),
Insn::Nop,
Insn::Goto(body),
]
}
/// 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
/// instead of a dense range over a stack value. Junk keys never equal `key`.
/// 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];
while keys.len() < 4 {
let candidate = (rng.next_u64() % 20_000) as i32 - 5_000;
if !keys.contains(&candidate) {
keys.push(candidate);
}
}
let mut pairs: Vec<(i32, u32)> = keys
.into_iter()
.map(|k| (k, if k == key { body } else { dead }))
.collect();
// The JVM requires lookupswitch pairs sorted by key.
pairs.sort_by_key(|(k, _)| *k);
vec![
Insn::Const(Const::Int(key)),
Insn::Store {
slot,
kind: SlotKind::Int,
},
Insn::Load {
slot,
kind: SlotKind::Int,
},
Insn::LookupSwitch {
default: dead,
pairs,
},
Insn::Mark(dead),
Insn::Nop,
Insn::Goto(body),
]
}
/// 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.
/// 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> {
let mut guard = super::rewire::opaque_compute(rng);
guard.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead));
guard.push(Insn::Goto(merge));
guard.push(Insn::Mark(dead));
guard.push(Insn::Nop);
guard.push(Insn::Mark(merge));
guard
}

View file

@ -0,0 +1,270 @@
//! Control-flow obfuscation: dispatcher prologues plus CFG rewiring.
//!
//! One scheme per method (rotation), picked from `FlowConfig::schemes`:
//! - `state-spread`: dense `tableswitch` entry dispatcher;
//! - `computed-switch`: sparse `lookupswitch` over a counter local;
//! - `butterfly`: every branch inverted, stream cut with `goto` splices;
//! - `exception-goto`: dispatcher inside `try`, dead catch-all rejoins body.
//!
//! 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
//! by construction (see `dispatch`/`rewire` for why each shape is safe).
mod dispatch;
mod rewire;
#[cfg(test)]
mod tests;
use dispatch::{lookup_dispatch, opaque_guard, table_dispatch};
use noxis_analysis::ClassHierarchy;
use noxis_classfile::{Code, Insn, Label, Program, compute_frames};
use noxis_core::{Context, CoreError, Pass, PassId, PassReport, Rng};
use rewire::{dead_catch, invert_branches, split_blocks};
/// Control-flow obfuscation pass.
#[derive(Debug, Default, Clone, Copy)]
pub struct FlowPass;
/// Dispatcher scheme (names from `FlowConfig::schemes`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scheme {
StateSpread,
ComputedSwitch,
Butterfly,
ExceptionGoto,
}
impl Scheme {
fn parse(name: &str) -> Option<Self> {
match name {
"state-spread" => Some(Scheme::StateSpread),
"computed-switch" => Some(Scheme::ComputedSwitch),
"butterfly" => Some(Scheme::Butterfly),
"exception-goto" => Some(Scheme::ExceptionGoto),
_ => None,
}
}
}
/// Fresh-label allocator over one method body.
pub(crate) struct LabelGen {
next: Label,
}
impl LabelGen {
fn of(code: &Code) -> Self {
let top = code.labels().into_iter().max().unwrap_or(0);
Self {
next: top.saturating_add(1),
}
}
fn next(&mut self) -> Label {
let label = self.next;
self.next = self.next.saturating_add(1);
label
}
}
impl Pass for FlowPass {
fn id(&self) -> PassId {
PassId::Flow
}
fn apply(&self, ctx: &mut Context, program: &mut Program) -> Result<PassReport, CoreError> {
let cfg = ctx.config.flow.clone();
let mut schemes = Vec::with_capacity(cfg.schemes.len());
for name in &cfg.schemes {
schemes.push(
Scheme::parse(name)
.ok_or_else(|| pass_error(format!("unknown scheme `{name}`")))?,
);
}
if schemes.is_empty() {
return Err(pass_error("no control-flow schemes configured"));
}
let mut rng = ctx.rng("flow");
let mut mutated: Vec<(usize, usize)> = Vec::new();
let mut per_scheme = [0usize; 4];
let mut skipped = 0usize;
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;
};
if skippable(&method.name, code, cfg.max_insns) {
skipped += 1;
continue;
}
let scheme = schemes[rng.below(schemes.len() as u64) as usize];
apply_scheme(code, scheme, &cfg, &mut rng);
per_scheme[scheme_index(scheme)] += 1;
mutated.push((ci, mi));
}
}
let hierarchy = ClassHierarchy::build(program);
for (ci, mi) in &mutated {
refresh_frames(program, *ci, *mi, &hierarchy)?;
}
Ok(report(&per_scheme, mutated.len(), skipped))
}
}
/// Whether a method body is left alone.
fn skippable(name: &str, code: &Code, max_insns: usize) -> bool {
// The super-call-first shape of a constructor must survive: no prologue
// may run before it.
if name == "<init>" {
return true;
}
// Frames are never computed for subroutines — keep them byte-identical.
if code
.insns
.iter()
.any(|insn| matches!(insn, Insn::Jsr(_) | Insn::Ret(_)))
{
return true;
}
let real = code
.insns
.iter()
.filter(|insn| !matches!(insn, Insn::Mark(_)))
.count();
if real < 3 {
return true;
}
if code.insns.len() > max_insns {
return true;
}
false
}
/// Rebuild one method body with the given scheme; prologue stays first.
fn apply_scheme(code: &mut Code, scheme: Scheme, cfg: &noxis_core::FlowConfig, rng: &mut Rng) {
let mut labels = LabelGen::of(code);
let body = labels.next();
let dead = labels.next();
if scheme == Scheme::Butterfly {
invert_branches(&mut code.insns, &mut labels, rng, 100);
}
let real = code
.insns
.iter()
.filter(|insn| !matches!(insn, Insn::Mark(_)))
.count();
let mut prologue = Vec::new();
if cfg.encrypt_block_bodies {
let guard_dead = labels.next();
let guard_merge = labels.next();
prologue.extend(opaque_guard(guard_dead, guard_merge, rng));
}
match scheme {
Scheme::StateSpread => {
prologue.extend(table_dispatch(body, dead, rng));
prologue.push(Insn::Mark(body));
}
Scheme::ComputedSwitch if code.max_locals == u16::MAX => {
// No free local slot — fall back to the stack-based dispatcher.
prologue.extend(table_dispatch(body, dead, rng));
prologue.push(Insn::Mark(body));
}
Scheme::ComputedSwitch => {
let slot = code.max_locals;
prologue.extend(lookup_dispatch(body, dead, slot, rng));
prologue.push(Insn::Mark(body));
code.max_locals = code.max_locals.saturating_add(1);
}
Scheme::Butterfly => {
prologue.push(Insn::Mark(body));
}
Scheme::ExceptionGoto => {
let try_start = labels.next();
let handler = labels.next();
let mut guarded = vec![Insn::Mark(try_start)];
guarded.append(&mut prologue);
guarded.extend(table_dispatch(body, dead, rng));
// 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
// 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);
guarded.push(Insn::Mark(body));
prologue = guarded;
code.exception_table.push(entry);
}
}
// A `nop` pads the body mark: the original stream may start with its own
// mark (even a handler entry), and two marks on one offset would merge
// frames from inconsistent states.
prologue.push(Insn::Nop);
let cuts = (real / 16).clamp(1, 8);
let start = prologue.len();
let mut insns = prologue;
insns.append(&mut code.insns);
split_blocks(&mut insns, &mut labels, rng, start, cuts);
code.insns = insns;
code.max_stack = code.max_stack.saturating_add(2);
}
fn scheme_index(scheme: Scheme) -> usize {
match scheme {
Scheme::StateSpread => 0,
Scheme::ComputedSwitch => 1,
Scheme::Butterfly => 2,
Scheme::ExceptionGoto => 3,
}
}
/// Recompute the frames of a touched method.
fn refresh_frames(
program: &mut Program,
ci: usize,
mi: usize,
hierarchy: &ClassHierarchy,
) -> Result<(), CoreError> {
let (name, snapshot) = {
let class = program
.classes
.get(ci)
.ok_or_else(|| pass_error("class vanished mid-pass"))?;
(
class.this_class.clone(),
class
.methods
.get(mi)
.cloned()
.ok_or_else(|| pass_error("method vanished mid-pass"))?,
)
};
let frames =
compute_frames(&name, &snapshot, hierarchy).map_err(|e| pass_error(e.to_string()))?;
program
.classes
.get_mut(ci)
.and_then(|c| c.methods.get_mut(mi))
.and_then(|m| m.code.as_mut())
.ok_or_else(|| pass_error("method vanished mid-pass"))?
.frames = frames;
Ok(())
}
fn report(per_scheme: &[usize; 4], mutated: usize, skipped: usize) -> PassReport {
PassReport {
pass: PassId::Flow,
transformed: mutated,
skipped,
notes: vec![format!(
"methods rebuilt: {mutated} (state-spread: {}, computed-switch: {}, butterfly: {}, exception-goto: {})",
per_scheme[0], per_scheme[1], per_scheme[2], per_scheme[3]
)],
}
}
fn pass_error(msg: impl Into<String>) -> CoreError {
CoreError::pass(PassId::Flow, msg)
}

View file

@ -0,0 +1,230 @@
//! CFG rewiring: branch inversion, block splitting, exception routing.
//!
//! All rewrites preserve semantics without dataflow analysis:
//! - inverting a branch negates its condition and swaps target/fallthrough;
//! - a `goto` + dead `nop` splice is stack-neutral at any cut point, because
//! both joins carry the same stack the original stream had;
//! - the dead catch-all rejoins `body` with an empty stack after popping the
//! exception, so it merges with the dispatcher's empty-stack edges.
use noxis_classfile::{BranchCond, Cond, ExceptionHandler, Insn};
use noxis_core::Rng;
/// Logical negation of a branch condition.
pub(super) fn negate(cond: BranchCond) -> BranchCond {
match cond {
BranchCond::Zero(c) => BranchCond::Zero(negate_cond(c)),
BranchCond::Int(c) => BranchCond::Int(negate_cond(c)),
BranchCond::Ref { eq } => BranchCond::Ref { eq: !eq },
BranchCond::Null { is_null } => BranchCond::Null { is_null: !is_null },
}
}
fn negate_cond(cond: Cond) -> Cond {
match cond {
Cond::Eq => Cond::Ne,
Cond::Ne => Cond::Eq,
Cond::Lt => Cond::Ge,
Cond::Le => Cond::Gt,
Cond::Gt => Cond::Le,
Cond::Ge => Cond::Lt,
}
}
/// Turn `if-cond T` into `if-not SKIP; goto T; SKIP:` (`butterfly`).
///
/// `rate` is a 0–100 percentage applied per branch. Semantics are unchanged:
/// the inverted branch skips a `goto` to the original target, so the taken /
/// not-taken destinations are exactly the old ones. A `nop` pads the fresh
/// mark: whatever follows (an original mark, even a handler entry with its
/// own stack) must not share a bytecode offset with it, or their frames
/// would merge from inconsistent states.
pub(super) fn invert_branches(
insns: &mut Vec<Insn>,
fresh: &mut crate::flow::LabelGen,
rng: &mut Rng,
rate: u64,
) {
let mut index = 0;
while index < insns.len() {
let (cond, target) = match &insns[index] {
Insn::Branch(cond, target) => (*cond, *target),
_ => {
index += 1;
continue;
}
};
if rng.below(100) >= rate {
index += 1;
continue;
}
let skip = fresh.next();
insns.splice(
index..=index,
[
Insn::Branch(negate(cond), skip),
Insn::Goto(target),
Insn::Mark(skip),
Insn::Nop,
],
);
index += 4;
}
}
/// Cut the stream with opaque-entered splices.
///
/// 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
/// branch, which HotSpot requires even for code that never executes.
///
/// Cuts land at or after `start` (the entry prologue must stay first) and
/// only at reachable positions: a splice in an unreachable gap would leave
/// its `into` mark without a computed state, and the verifier rejects jump
/// targets without frames.
///
/// A cut is also pushed past a run of consecutive marks. Inserting *before* a
/// targeted mark would reroute its incoming branch edges over the splice,
/// orphaning the splice itself: the mark must keep its edges, so the splice
/// goes after it and inherits its reachability through fallthrough.
pub(super) fn split_blocks(
insns: &mut Vec<Insn>,
fresh: &mut crate::flow::LabelGen,
rng: &mut Rng,
start: usize,
count: usize,
) {
let live = reachable(insns);
let mut cuts = Vec::with_capacity(count);
for _ in 0..count {
let allowed: Vec<usize> = (start..insns.len())
.filter(|c| live.get(*c).copied().unwrap_or(false))
.collect();
if allowed.is_empty() {
break;
}
let mut cut = allowed[rng.below(allowed.len() as u64) as usize];
while cut < insns.len() && matches!(insns[cut], Insn::Mark(_)) {
cut += 1;
}
// A mark run at the very end leaves no stream to cut into.
if cut < insns.len() {
cuts.push(cut);
}
}
cuts.sort_unstable();
cuts.dedup();
for cut in cuts.into_iter().rev() {
let into = fresh.next();
let dead = fresh.next();
let mut splice = opaque_compute(rng);
splice.push(Insn::Branch(BranchCond::Zero(Cond::Ne), dead));
splice.push(Insn::Goto(into));
splice.push(Insn::Mark(dead));
splice.push(Insn::Nop);
splice.push(Insn::AconstNull);
splice.push(Insn::AThrow);
splice.push(Insn::Mark(into));
insns.splice(cut..cut, splice);
}
}
/// The opaque predicate core: computes `(C*C + C) & 1` (always 0) on top of
/// the stack without disturbing what is below it.
pub(super) fn opaque_compute(rng: &mut Rng) -> Vec<Insn> {
use noxis_classfile::{Arith, Const, Prim};
let constant = rng.next_u64() as i32;
vec![
Insn::Const(Const::Int(constant)),
Insn::Dup,
Insn::Binary {
prim: Prim::Int,
op: Arith::Mul,
},
Insn::Const(Const::Int(constant)),
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
Insn::Const(Const::Int(1)),
Insn::Binary {
prim: Prim::Int,
op: Arith::And,
},
]
}
/// Vec positions reachable from method entry via fallthrough and branch
/// targets. Marks are transparent: control flows through them to the next
/// instruction. Deliberately exception-oblivious (handler-only code reads as
/// unreachable): cuts there are skipped rather than risked.
pub(super) fn reachable(insns: &[Insn]) -> Vec<bool> {
use std::collections::{BTreeMap, VecDeque};
let mut at_label: BTreeMap<u32, usize> = BTreeMap::new();
for (index, insn) in insns.iter().enumerate() {
if let Insn::Mark(label) = insn {
at_label.entry(*label).or_insert(index);
}
}
let resolve = |label: u32| at_label.get(&label).copied();
let terminal = |insn: &Insn| {
matches!(
insn,
Insn::Goto(_)
| Insn::Jsr(_)
| Insn::Ret(_)
| Insn::TableSwitch { .. }
| Insn::LookupSwitch { .. }
| Insn::Return(_)
| Insn::AReturn
| Insn::AThrow
)
};
let mut live = vec![false; insns.len()];
let mut queue = VecDeque::new();
if !live.is_empty() {
queue.push_back(0);
}
while let Some(index) = queue.pop_front() {
if live[index] {
continue;
}
live[index] = true;
let insn = &insns[index];
if !terminal(insn) && index + 1 < insns.len() {
queue.push_back(index + 1);
}
for target in insn.targets() {
if let Some(resolved) = resolve(target) {
queue.push_back(resolved);
}
}
}
live
}
/// Dead catch-all over `try_start..try_end`, rejoining `body`.
///
/// The covered prologue cannot throw (constants, switches, gotos), so the
/// handler never fires; if it ever did, popping the exception and jumping to
/// `body` behaves exactly like the fallthrough path.
pub(super) fn dead_catch(
try_start: u32,
try_end: u32,
handler: u32,
body: u32,
) -> (Vec<Insn>, ExceptionHandler) {
let code = vec![Insn::Mark(handler), Insn::Pop, Insn::Nop, Insn::Goto(body)];
let entry = ExceptionHandler {
start: try_start,
end: try_end,
handler,
catch_type: None,
};
(code, entry)
}

View file

@ -0,0 +1,264 @@
//! Unit tests for the control-flow pass: shapes, skips, determinism.
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{
Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, Prim, Program, SlotKind, Version,
};
use noxis_core::{Config, Context, FlowConfig, Pass, PassId};
use super::{FlowPass, LabelGen};
/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`.
fn branchy_code() -> Code {
let (head, end) = (100, 101);
Code {
max_stack: 2,
max_locals: 2,
insns: vec![
Insn::Const(Const::Int(0)),
Insn::Store {
slot: 0,
kind: SlotKind::Int,
},
Insn::Const(Const::Int(0)),
Insn::Store {
slot: 1,
kind: SlotKind::Int,
},
Insn::Mark(head),
Insn::Load {
slot: 1,
kind: SlotKind::Int,
},
Insn::Const(Const::Int(10)),
Insn::Branch(BranchCond::Int(Cond::Ge), end),
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Load {
slot: 1,
kind: SlotKind::Int,
},
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
Insn::Store {
slot: 0,
kind: SlotKind::Int,
},
Insn::Iinc { index: 1, delta: 1 },
Insn::Goto(head),
Insn::Mark(end),
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Return(Some(Prim::Int)),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
}
}
fn program_with(methods: Vec<(String, String, Code)>) -> Program {
let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object"));
for (name, descriptor, code) in methods {
builder = builder.method_with_code(0x0009, name, descriptor, code);
}
Program {
classes: vec![builder.version(Version::JAVA_21).build()],
resources: Vec::new(),
}
}
fn run(program: &mut Program, schemes: &[&str]) -> noxis_core::PassReport {
let config = Config {
seed: Some(42),
flow: FlowConfig {
schemes: schemes.iter().map(|s| s.to_string()).collect(),
max_insns: 4000,
encrypt_block_bodies: false,
},
..Config::default()
};
let mut ctx = Context::new(config);
FlowPass.apply(&mut ctx, program).expect("flow pass")
}
fn code_of(program: &Program, name: &str) -> Code {
program.classes[0]
.method(name, "()I")
.expect("method")
.code
.clone()
.expect("code")
}
fn has_switch(insns: &[Insn]) -> bool {
insns
.iter()
.any(|i| matches!(i, Insn::TableSwitch { .. } | Insn::LookupSwitch { .. }))
}
#[test]
fn state_spread_emits_table_dispatch() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
let before = code_of(&program, "calc").insns.len();
let report = run(&mut program, &["state-spread"]);
let code = code_of(&program, "calc");
assert!(
code.insns
.iter()
.any(|i| matches!(i, Insn::TableSwitch { .. })),
"no tableswitch in {code:?}"
);
assert!(code.insns.len() > before, "stream did not grow");
assert!(!code.frames.is_empty(), "frames were not recomputed");
assert_eq!(report.pass, PassId::Flow);
assert_eq!(report.transformed, 1);
}
#[test]
fn computed_switch_uses_lookup_and_fresh_local() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
run(&mut program, &["computed-switch"]);
let code = code_of(&program, "calc");
let pairs = code.insns.iter().find_map(|i| match i {
Insn::LookupSwitch { pairs, .. } => Some(pairs.clone()),
_ => None,
});
let pairs = pairs.expect("no lookupswitch");
let mut sorted = pairs.clone();
sorted.sort_by_key(|(k, _)| *k);
assert_eq!(pairs, sorted, "lookupswitch pairs must be sorted");
assert_eq!(code.max_locals, 3, "counter local was not allocated");
}
#[test]
fn butterfly_inverts_without_switch() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
run(&mut program, &["butterfly"]);
let code = code_of(&program, "calc");
assert!(!has_switch(&code.insns), "butterfly must not dispatch");
let gotos = code
.insns
.iter()
.filter(|i| matches!(i, Insn::Goto(_)))
.count();
assert!(
gotos >= 3,
"expected inverted branches + splits, got {gotos}"
);
let labels = code.labels();
for insn in &code.insns {
for target in insn.targets() {
assert!(labels.contains(&target), "dangling label {target}");
}
}
// Every branch target must also be reachable: an orphaned splice leaves
// its marks without computed frames and HotSpot rejects the class.
let live = super::rewire::reachable(&code.insns);
let mut at_mark = std::collections::BTreeMap::new();
for (index, insn) in code.insns.iter().enumerate() {
if let Insn::Mark(label) = insn {
at_mark.entry(*label).or_insert(index);
}
}
for insn in &code.insns {
for target in insn.targets() {
let index = at_mark.get(&target).expect("resolved above");
assert!(live[*index], "unreachable branch target {target}");
}
}
}
#[test]
fn exception_goto_adds_catch_all() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
run(&mut program, &["exception-goto"]);
let code = code_of(&program, "calc");
assert_eq!(code.exception_table.len(), 1);
assert_eq!(code.exception_table[0].catch_type, None);
assert!(
code.insns
.iter()
.any(|i| matches!(i, Insn::TableSwitch { .. })),
"dispatcher missing inside try"
);
}
#[test]
fn skips_init_and_tiny_methods() {
let tiny = Code {
max_stack: 0,
max_locals: 0,
insns: vec![Insn::Return(None)],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let mut program = program_with(vec![
("<init>".to_string(), "()V".to_string(), tiny.clone()),
("run".to_string(), "()V".to_string(), tiny),
("calc".to_string(), "()I".to_string(), branchy_code()),
]);
let report = run(
&mut program,
&[
"state-spread",
"computed-switch",
"butterfly",
"exception-goto",
],
);
assert_eq!(report.transformed, 1);
assert_eq!(report.skipped, 2);
}
#[test]
fn unknown_scheme_is_rejected() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
let config = Config {
seed: Some(1),
flow: FlowConfig {
schemes: vec!["death-star".to_string()],
max_insns: 4000,
encrypt_block_bodies: false,
},
..Config::default()
};
let mut ctx = Context::new(config);
assert!(FlowPass.apply(&mut ctx, &mut program).is_err());
}
#[test]
fn labels_are_allocated_above_existing_ones() {
let code = branchy_code();
let top = code.labels().into_iter().max().unwrap_or(0);
let mut labels = LabelGen::of(&code);
assert!(labels.next() > top);
}

View file

@ -1,10 +1,11 @@
//! # noxis-passes
//!
//! Трансформации байткода. Сборка конвейера из пассов (`standard_pipeline`)
//! живёт здесь же: ядро (`noxis-core`) знает только контракт [`Pass`].
//! Bytecode transformations. The pipeline assembly (`standard_pipeline`)
//! lives here as well: the core (`noxis-core`) only knows the [`Pass`] contract.
pub mod constants;
pub mod debug;
pub mod flow;
pub mod namegen;
pub mod noise;
pub mod rename;
@ -15,24 +16,22 @@ use noxis_core::Pass;
pub use constants::ConstantsPass;
pub use debug::DebugInfoPass;
pub use flow::FlowPass;
pub use namegen::NameGenerator;
pub use noise::NoisePass;
pub use rename::RenamePass;
pub use resources::ResourcesPass;
pub use strings::StringsPass;
/// Стандартный конвейер в порядке [`docs/architecture.md`](../../docs/architecture.md):
/// Standard pipeline in [`docs/architecture.md`](../../docs/architecture.md) order:
/// `rename → resources → strings → constants → flow → noise → debug`.
///
/// Нереализованные пассы просто не добавлены — порядок уже соблюдён,
/// поэтому каждый следующий пасс вставляется на своё место, а не
/// дописывается в конец.
pub fn standard_pipeline() -> noxis_core::Pipeline {
noxis_core::Pipeline::new()
.push(Box::new(RenamePass) as Box<dyn Pass>)
.push(Box::new(ResourcesPass) as Box<dyn Pass>)
.push(Box::new(StringsPass) as Box<dyn Pass>)
.push(Box::new(ConstantsPass) as Box<dyn Pass>)
.push(Box::new(FlowPass) as Box<dyn Pass>)
.push(Box::new(NoisePass) as Box<dyn Pass>)
.push(Box::new(DebugInfoPass) as Box<dyn Pass>)
}
@ -70,6 +69,7 @@ mod tests {
fn standard_pipeline_runs_end_to_end_in_order() {
let mut ctx = Context::new(Config {
seed: Some(1),
enable: vec![PassId::Flow],
..Config::default()
});
let mut program = sample();
@ -86,6 +86,7 @@ mod tests {
PassId::Resources,
PassId::Strings,
PassId::Constants,
PassId::Flow,
PassId::Noise,
PassId::DebugInfo
]

View file

@ -0,0 +1,263 @@
//! Flow acceptance: scheme rotation plus a real-JVM execution check.
//!
//! rotation: 30 methods through the full scheme set must not collapse onto
//! one shape. execution: a branchy `calc` still prints 45 after the pass
//! under `java -Xverify:all` (graceful skip without `java`).
#![allow(clippy::expect_used, clippy::unwrap_used)]
use noxis_classfile::{
Arith, BranchCond, ClassBuilder, Code, Cond, Const, Insn, InvokeKind, Prim, Program, SlotKind,
Version, format,
};
use noxis_core::{Config, Context, FlowConfig, Pass};
use noxis_passes::FlowPass;
const ALL_SCHEMES: [&str; 4] = [
"state-spread",
"computed-switch",
"butterfly",
"exception-goto",
];
/// A small branchy method: `acc=0; for(i=0;i<10;i++) acc+=i; return acc`.
fn branchy_code() -> Code {
let (head, end) = (100, 101);
Code {
max_stack: 2,
max_locals: 2,
insns: vec![
Insn::Const(Const::Int(0)),
Insn::Store {
slot: 0,
kind: SlotKind::Int,
},
Insn::Const(Const::Int(0)),
Insn::Store {
slot: 1,
kind: SlotKind::Int,
},
Insn::Mark(head),
Insn::Load {
slot: 1,
kind: SlotKind::Int,
},
Insn::Const(Const::Int(10)),
Insn::Branch(BranchCond::Int(Cond::Ge), end),
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Load {
slot: 1,
kind: SlotKind::Int,
},
Insn::Binary {
prim: Prim::Int,
op: Arith::Add,
},
Insn::Store {
slot: 0,
kind: SlotKind::Int,
},
Insn::Iinc { index: 1, delta: 1 },
Insn::Goto(head),
Insn::Mark(end),
Insn::Load {
slot: 0,
kind: SlotKind::Int,
},
Insn::Return(Some(Prim::Int)),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
}
}
fn program_with(methods: Vec<(String, String, Code)>) -> Program {
let mut builder = ClassBuilder::new("demo/Flow", Some("java/lang/Object"));
for (name, descriptor, code) in methods {
builder = builder.method_with_code(0x0009, name, descriptor, code);
}
Program {
classes: vec![builder.version(Version::JAVA_21).build()],
resources: Vec::new(),
}
}
fn run(program: &mut Program, schemes: &[&str]) {
let config = Config {
seed: Some(42),
flow: FlowConfig {
schemes: schemes.iter().map(|s| s.to_string()).collect(),
max_insns: 4000,
encrypt_block_bodies: false,
},
..Config::default()
};
let mut ctx = Context::new(config);
FlowPass.apply(&mut ctx, program).expect("flow pass");
}
fn code_of(program: &Program, name: &str) -> Code {
program.classes[0]
.method(name, "()I")
.expect("method")
.code
.clone()
.expect("code")
}
#[test]
fn rotation_spreads_schemes_across_methods() {
let methods: Vec<(String, String, Code)> = (0..30)
.map(|i| (format!("m{i}"), "()I".to_string(), branchy_code()))
.collect();
let mut program = program_with(methods);
run(&mut program, &ALL_SCHEMES);
let mut shapes = [false; 4];
for method in &program.classes[0].methods {
let code = method.code.as_ref().expect("code");
if code
.insns
.iter()
.any(|i| matches!(i, Insn::TableSwitch { .. }))
{
shapes[0] = true;
}
if code
.insns
.iter()
.any(|i| matches!(i, Insn::LookupSwitch { .. }))
{
shapes[1] = true;
}
if !code.exception_table.is_empty() {
shapes[3] = true;
}
}
let distinct = shapes.iter().filter(|s| **s).count();
assert!(distinct >= 2, "rotation did not spread: {shapes:?}");
}
/// Every split splice must be verifier-safe: the dead block is entered
/// through an opaque branch (so it owns a frame) and ends with `athrow`
/// (so it joins nothing). A dead `nop` with no incoming edge and no frame
/// is rejected by HotSpot (`Expecting a stack map frame`).
#[test]
fn splits_are_opaque_entered_and_throw() {
let mut program = program_with(vec![(
"calc".to_string(),
"()I".to_string(),
branchy_code(),
)]);
run(&mut program, &["butterfly"]);
let code = code_of(&program, "calc");
let insns = &code.insns;
let throws = insns.iter().filter(|i| matches!(i, Insn::AThrow)).count();
assert!(throws >= 1, "splits must end dead blocks with athrow");
for (index, insn) in insns.iter().enumerate() {
if !matches!(insn, Insn::AThrow) {
continue;
}
let head = &insns[..index];
assert!(
matches!(head.last(), Some(Insn::AconstNull)),
"athrow without a null on top"
);
let dead = head
.iter()
.rev()
.find_map(|i| match i {
Insn::Mark(label) => Some(*label),
_ => None,
})
.expect("dead block without a mark");
assert!(
insns
.iter()
.any(|i| matches!(i, Insn::Branch(_, target) if *target == dead)),
"dead mark {dead} has no opaque entry branch"
);
}
}
/// `calc` returns the sum 0+..+9 = 45; `main` prints it. After the pass the
/// class must still verify *and* print 45 under a real JVM.
#[test]
fn flow_preserves_semantics_under_java() {
if std::process::Command::new("java")
.arg("-version")
.output()
.is_err()
{
eprintln!("SKIP: no java");
return;
}
let main = Code {
max_stack: 2,
max_locals: 1,
insns: vec![
Insn::GetStatic {
owner: "java/lang/System".to_string(),
name: "out".to_string(),
descriptor: "Ljava/io/PrintStream;".to_string(),
},
Insn::Invoke {
kind: InvokeKind::Static,
owner: "demo/Flow".to_string(),
name: "calc".to_string(),
descriptor: "()I".to_string(),
itf: false,
},
Insn::Invoke {
kind: InvokeKind::Virtual,
owner: "java/io/PrintStream".to_string(),
name: "println".to_string(),
descriptor: "(I)V".to_string(),
itf: false,
},
Insn::Return(None),
],
exception_table: Vec::new(),
frames: Vec::new(),
attributes: Vec::new(),
};
let mut program = program_with(vec![
("calc".to_string(), "()I".to_string(), branchy_code()),
(
"main".to_string(),
"([Ljava/lang/String;)V".to_string(),
main,
),
]);
run(&mut program, &ALL_SCHEMES);
let dir = std::env::temp_dir().join(format!("noxis-flow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for class in &program.classes {
let bytes = format::write(class).expect("write");
let mut path = dir.clone();
for part in class.this_class.split('/') {
path.push(part);
}
path.set_extension("class");
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, bytes).expect("write class");
}
let output = std::process::Command::new("java")
.arg("-Xverify:all")
.arg("-cp")
.arg(&dir)
.arg("demo.Flow")
.output()
.expect("run java");
let _ = std::fs::remove_dir_all(&dir);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"java failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(stdout.trim(), "45", "semantics changed: {stdout:?}");
}

View file

@ -1,59 +1,56 @@
//! Команда `obfuscate`: jar → конвейер → jar + маппинги + отчёт.
//! The `obfuscate` command: jar → pipeline → jar + mappings + report.
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use noxis_core::{Config, Context, PassId};
/// Аргументы обфускации.
/// Obfuscation arguments.
#[derive(Debug, clap::Args)]
pub struct Args {
/// Входной jar.
/// Input jar.
pub input: PathBuf,
/// Выходной jar.
/// Output jar.
#[arg(short, long)]
pub output: PathBuf,
/// TOML-конфиг (без него — профиль Standard).
/// TOML config (without it — the Standard profile).
#[arg(short, long)]
pub config: Option<PathBuf>,
/// Мастер-сид (детерминированность по умолчанию — из конфига).
/// Master seed (deterministic by default — taken from the config).
#[arg(long)]
pub seed: Option<u64>,
/// Куда положить JSON-отчёт (по умолчанию рядом с выходом).
/// Where to write the JSON report (next to the output by default).
#[arg(long)]
pub report: Option<PathBuf>,
/// Выключить пасс переименования.
/// Disable the rename pass.
#[arg(long)]
pub no_rename: bool,
/// Выключить пасс ресурсов.
/// Disable the resources pass.
#[arg(long)]
pub no_resources: bool,
/// Выключить пасс строк.
/// Disable the strings pass.
#[arg(long)]
pub no_strings: bool,
/// Выключить пасс констант.
/// Disable the constants pass.
#[arg(long)]
pub no_constants: bool,
/// Выключить пасс шума.
#[arg(long)]
pub no_noise: bool,
/// Выключить пасс отладочной информации.
#[arg(long)]
pub no_debug_info: bool,
/// Выключить пасс control-flow (ещё не реализован — честная ошибка).
/// Disable the control-flow pass.
#[arg(long)]
pub no_flow: bool,
/// Disable the noise pass.
#[arg(long)]
pub no_noise: bool,
/// Disable the debug-info pass.
#[arg(long)]
pub no_debug_info: bool,
}
/// Прогнать обфускацию.
/// Run the obfuscation.
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()))?;
.with_context(|| format!("config {}", path.display()))?;
Config::from_toml(&text).map_err(|error| anyhow::anyhow!("{error}"))?
}
None => Config::default(),
@ -66,6 +63,7 @@ pub fn run(args: Args) -> Result<()> {
(args.no_resources, PassId::Resources),
(args.no_strings, PassId::Strings),
(args.no_constants, PassId::Constants),
(args.no_flow, PassId::Flow),
(args.no_noise, PassId::Noise),
(args.no_debug_info, PassId::DebugInfo),
] {
@ -75,19 +73,19 @@ pub fn run(args: Args) -> Result<()> {
}
let entries = noxis_jar::read_file(&args.input)
.with_context(|| format!("чтение {}", args.input.display()))?;
.with_context(|| format!("reading {}", 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}"))?;
noxis_jar::load(entries).map_err(|error| anyhow::anyhow!("parsing 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}"))?;
.map_err(|error| anyhow::anyhow!("pipeline: {error}"))?;
let entries =
noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("сборка jar: {error}"))?;
noxis_jar::save(&program).map_err(|error| anyhow::anyhow!("assembling jar: {error}"))?;
noxis_jar::write_file(&args.output, &entries)
.with_context(|| format!("запись {}", args.output.display()))?;
.with_context(|| format!("writing {}", args.output.display()))?;
let report_path = args
.report
@ -96,14 +94,14 @@ pub fn run(args: Args) -> Result<()> {
&report_path,
serde_json::to_string_pretty(&SerializableReport::from(&report, &ctx))?,
)
.with_context(|| format!("отчёт {}", report_path.display()))?;
.with_context(|| format!("report {}", report_path.display()))?;
std::fs::write(
args.output.with_extension("mapping.txt"),
ctx.mapping.to_text(),
)?;
println!(
"noxis: {} → {} (классов: {}, подписей срезано: {}, отчёт: {})",
"noxis: {} → {} (classes: {}, signatures stripped: {}, report: {})",
args.input.display(),
args.output.display(),
report.counters.get("classes").copied().unwrap_or(0),
@ -113,7 +111,7 @@ pub fn run(args: Args) -> Result<()> {
Ok(())
}
/// Отчёт в JSON: сид, пассы, счётчики, маппинги текстом.
/// JSON report: seed, passes, counters, mappings as text.
#[derive(Debug, serde::Serialize)]
struct SerializableReport {
seed: u64,
@ -182,6 +180,23 @@ mod tests {
noxis_jar::write_file(path, &entries).expect("write jar");
}
fn sample_args(input: PathBuf, output: PathBuf) -> Args {
Args {
input,
output,
config: None,
seed: Some(5),
report: None,
no_rename: false,
no_resources: false,
no_strings: false,
no_constants: false,
no_flow: false,
no_noise: false,
no_debug_info: false,
}
}
#[test]
fn obfuscate_end_to_end() {
let dir = std::env::temp_dir().join(format!("noxis-cli-{}", std::process::id()));
@ -190,43 +205,44 @@ mod tests {
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(), "нет маппингов");
run(sample_args(input.clone(), output.clone())).expect("obfuscate");
assert!(output.exists(), "missing output jar");
assert!(dir.join("out.report.json").exists(), "missing report");
assert!(dir.join("out.mapping.txt").exists(), "missing mappings");
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,
fn no_flow_disables_the_pass() {
let dir = std::env::temp_dir().join(format!("noxis-cli-flow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let input = dir.join("in.jar");
sample_jar(&input);
let config_path = dir.join("paranoid.toml");
std::fs::write(&config_path, "profile = \"paranoid\"\n").expect("write config");
let run_with_flag = |name: &str, no_flow: bool| {
let output = dir.join(format!("{name}.jar"));
let report_path = dir.join(format!("{name}.report.json"));
let mut args = sample_args(input.clone(), output);
args.config = Some(config_path.clone());
args.report = Some(report_path.clone());
args.no_flow = no_flow;
run(args).expect("obfuscate");
std::fs::read_to_string(&report_path).expect("read report")
};
assert!(run(args).is_err());
let with_flow = run_with_flag("with", false);
assert!(
with_flow.contains("\"flow\""),
"flow pass missing under paranoid: {with_flow}"
);
let without_flow = run_with_flag("without", true);
assert!(
!without_flow.contains("\"flow\""),
"flow pass ran despite --no-flow: {without_flow}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -123,6 +123,7 @@ Rename → Resources → Strings → Constants → Flow → Noise → DebugInfo
| **готово** | IR, модель, конфиг, seed/PRNG, маппинги, анализ, keep, гейт правил, `format::parse`/`write` поверх ристретто, вывод кадров `StackMapTable` |
| **готово** | пассы rename/resources/strings/constants/noise/debug, генератор декодера, вывод мастер-секрета в рантайме |
| **готово** | `noxis-verify` (red-паттерны, `java -Xverify:all`, метрики), CLI (`obfuscate`/`analyze`/`patterns`) |
| **план** | схемы пасса `flow` (кадры для них уже считаются), фикстуры и regression-тесты |
| **готово** | пасс `flow` (4 схемы + ротация + мешанина, приёмка настоящим `java`) |
| **план** | фикстуры и regression-тесты |
Детали выбора — [`stack.md`](stack.md), план — [`../todo.md`](../todo.md).

View file

@ -14,7 +14,7 @@
| 1 | ключ лежит рядом с шифротекстом | вывод мастер-секрета в декодере, ключ из `master‖salt` | Strings | **готово** |
| 2 | переименованы типы, но не члены | rename полей/методов + семейства virtual | Rename | **готово** |
| 3 | отладочная информация нетронута | strip + ложный `SourceFile`/номера строк | DebugInfo | **готово** (`SourceFile`; номера строк — план) |
| 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | план (кадры готовы) |
| 4 | шаблонный flattening | 4 схемы CFG + мешанина внутри метода | Flow | **готово** (4 схемы, приёмка `java`) |
| 5 | opaque-предикаты = `A+B` | ротация тождеств + предикаты от состояния | Constants | **готово** (int/long) |
| 6 | junk-классы вне графа reachability | `weave_reachable` — обязательная вплетённость | Noise | **готово** (без аннотаций) |
| 7 | имена говорят «я защита» | все артефакты получают данные псевдонимы | Rename | **готово** |

View file

@ -115,6 +115,6 @@ writer).
✅ P0 DebugInfo + Resources → главные утечки сняты
✅ P1 Strings (B2) + Constants → ключ выводится, схемы ротируются
✅ P1 backend (ristretto) + frames
⬜ P2 Flow (C1) — единственное, что ждёт реализации; верификатор готов
✅ P2 Flow (C1) — готов (09.10.2026): 4 схемы + ротация + мешанина, приёмка настоящим `java`
✅ P3 метрики, красные паттерны, CLI
```

View file

@ -26,7 +26,7 @@
| `StringDecoderGenerator` | 205 | генерит класс-декодер | `noxis-runtime` + P2 генератор |
| `ObfuscatorRunner` | 202 | конвейер, оркестрация | `noxis-passes::standard_pipeline` ✅ |
| `NumberObfuscator` | 185 | константы / opaque | пасс `constants` |
| `ControlFlowObfuscator` | 183 | flattening | пасс `flow` (ждёт `StackMapTable`) |
| `ControlFlowObfuscator` | 183 | flattening | пасс `flow` ✅ (4 схемы, свои кадры) |
| `FabricKeepRuleScanner` | 147 | keep из конфига Fabric | `noxis-analysis::keep` ✅ |
| `Main` | 134 | CLI-точки | `noxis` (clap) |
| `StringEncryptor` | 132 | AES-128 строк + chunk'и | `noxis-runtime::scheme` ✅ |

View file

@ -104,7 +104,18 @@ opaque-предикаты — условия, истинность которы
- Шаблонная схема (все методы перестроены одинаково) детектируется
гистограммой длины блоков.
**Статус: не реализован; блокер — `StackMapTable` (см. [`stack.md`](stack.md)).**
**Статус: готов** (`noxis-passes/src/flow/`). Одна схема на метод (ротация
через PRNG) плюс нарезка тела `goto`-сплайсами во всех схемах; кадр
`StackMapTable` пересчитывается после каждой перестройки. Приёмка —
настоящий `java -Xverify:all` и исполнение под JVM (`calc` печатает 45
после всех схем). Ограничения: `<init>` пропускается (форма
super-call-first); методы с `jsr`/`ret`, короче 3 инструкций и длиннее
`max_insns` пропускаются; `encrypt_block_bodies` добавляет opaque-guard
на входе. Два правила, выученных на крови (см. `StackMapTable`):
вставленная метка не должна делить байт-смещение с чужой (иначе кадры
мерджатся из несовместимых состояний — после каждой такой метки `nop`),
а инструкция после терминальной обязана иметь свой кадр (поэтому dead-блоки
входят через opaque-ветвь и кончаются `athrow`, а не висят недостижимыми).
## 5. Шум (`Noise`)