diff --git a/crates/noxis-analysis/src/JdkHierarchy.java b/crates/noxis-analysis/src/JdkHierarchy.java new file mode 100644 index 0000000..65f9707 --- /dev/null +++ b/crates/noxis-analysis/src/JdkHierarchy.java @@ -0,0 +1,34 @@ +import java.util.Scanner; + +/** + * Answers superclass queries from the obfuscator's frame analysis. + * + *

Protocol: one internal class name per line on stdin + * ({@code java/io/BufferedReader}); one line back on stdout - the direct + * superclass as an internal name, or an empty line when unknown + * (missing dependency, interface, primitive, array). A single process + * serves the whole analysis run; EOF on stdin ends it. + */ +public class JdkHierarchy { + public static void main(String[] args) { + Scanner in = new Scanner(System.in); + while (in.hasNextLine()) { + String name = in.nextLine().trim().replace('/', '.'); + String sup = ""; + if (!name.isEmpty()) { + try { + Class loaded = + Class.forName(name, false, JdkHierarchy.class.getClassLoader()); + Class parent = loaded.getSuperclass(); + if (parent != null) { + sup = parent.getName().replace('.', '/'); + } + } catch (Throwable ignored) { + // Unknown: not on the JDK classpath, an interface with + // no superclass, a primitive or an array - empty line. + } + } + System.out.println(sup); + } + } +} diff --git a/crates/noxis-analysis/src/jdk.rs b/crates/noxis-analysis/src/jdk.rs new file mode 100644 index 0000000..1e10d95 --- /dev/null +++ b/crates/noxis-analysis/src/jdk.rs @@ -0,0 +1,195 @@ +//! JDK superclass fallback for frame merging. +//! +//! [`ClassHierarchy`] only knows program classes - deliberately so: rename +//! safety must stay conservative outside the program. Frame merging has the +//! opposite need: joins over JDK types (`Reader`/`BufferedReader`, a +//! multi-catch over `NoSuchMethodException`/`SecurityException`) degrade to +//! `Object` without the true superclass chain, and the verifier rejects the +//! poisoned frame at the first typed use (`putfield Reader`, a `Throwable` +//! parameter). This wrapper answers program classes from the built +//! hierarchy and asks the running JDK about the rest: one `java` process +//! (single-file launch, no `javac` needed), a line protocol, answers cached. +//! Without `java` every answer is `None` - exactly the old behaviour. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use noxis_classfile::Hierarchy; + +use super::hierarchy::ClassHierarchy; + +const HELPER: &str = include_str!("JdkHierarchy.java"); + +/// Frame-time hierarchy: program first, running JDK second. +pub struct JdkHierarchy<'a> { + inner: &'a ClassHierarchy, + shared: RefCell, +} + +#[derive(Default)] +struct Shared { + cache: HashMap>, + child: Option, + dead: bool, +} + +struct Pipes { + rx: BufReader, + tx: BufWriter, + proc: Child, +} + +impl<'a> JdkHierarchy<'a> { + /// Wrap a built program hierarchy (spawns nothing yet). + pub fn wrap(inner: &'a ClassHierarchy) -> Self { + Self { + inner, + shared: RefCell::new(Shared::default()), + } + } + + fn jdk_superclass(&self, name: &str) -> Option { + let mut shared = self.shared.borrow_mut(); + if let Some(cached) = shared.cache.get(name) { + return cached.clone(); + } + if shared.dead { + return None; + } + if shared.child.is_none() { + match spawn_helper() { + Some(pipes) => shared.child = Some(pipes), + None => { + shared.dead = true; + return None; + } + } + } + let answer = match query(shared.child.as_mut(), name) { + // Transport broke: stop spawning, keep the old behaviour. + None => { + shared.dead = true; + return None; + } + Some(answer) => answer, + }; + shared.cache.insert(name.to_string(), answer.clone()); + answer + } +} + +impl Hierarchy for JdkHierarchy<'_> { + fn superclass(&self, class: &str) -> Option { + match self.inner.superclass(class) { + Some(known) => Some(known), + None => self.jdk_superclass(class), + } + } +} + +impl Drop for JdkHierarchy<'_> { + fn drop(&mut self) { + if let Ok(mut shared) = self.shared.try_borrow_mut() + && let Some(mut pipes) = shared.child.take() + { + let _ = pipes.proc.kill(); + let _ = pipes.proc.wait(); + } + } +} + +/// Whether a `java` runtime exists for the helper. +fn java_available() -> bool { + Command::new("java") + .arg("-version") + .output() + .is_ok_and(|out| out.status.success()) +} + +/// Write the helper next to itself atomically-ish (every writer emits +/// identical bytes) and launch it; a handshake query proves it runs. +fn spawn_helper() -> Option { + if !java_available() { + return None; + } + let path = std::env::temp_dir().join("noxis-jdk-hierarchy.java"); + let staging = path.with_extension(format!("java.{}", std::process::id())); + std::fs::write(&staging, HELPER).ok()?; + std::fs::rename(&staging, &path).ok()?; + let mut proc = Command::new("java") + .arg(&path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let rx = BufReader::new(proc.stdout.take()?); + let tx = BufWriter::new(proc.stdin.take()?); + let mut pipes = Pipes { rx, tx, proc }; + // Handshake: `Object` has no superclass; any answer at all means a wrong + // runtime (or no source-launch support) - stay silent instead. + match query(Some(&mut pipes), "java/lang/Object") { + // The child must still be alive: EOF also reads as an empty line. + Some(None) if pipes.proc.try_wait().is_ok_and(|exit| exit.is_none()) => Some(pipes), + _ => None, + } +} + +/// One round trip: `None` - dead transport (child reaped); +/// `Some(answer)` - the reply, empty string for unknown superclasses. +fn query(pipes: Option<&mut Pipes>, name: &str) -> Option> { + let pipes = pipes?; + if writeln!(pipes.tx, "{name}").is_err() || pipes.tx.flush().is_err() { + kill(pipes); + return None; + } + let mut line = String::new(); + if pipes.rx.read_line(&mut line).is_err() { + kill(pipes); + return None; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + return Some(None); + } + Some(Some(trimmed.to_string())) +} + +fn kill(pipes: &mut Pipes) { + let _ = pipes.proc.kill(); + let _ = pipes.proc.wait(); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use noxis_classfile::Program; + + use super::*; + + #[test] + fn jdk_superclasses_come_from_the_runtime() { + if !java_available() { + eprintln!("SKIP: no java"); + return; + } + let program = Program::default(); + let base = ClassHierarchy::build(&program); + let jdk = JdkHierarchy::wrap(&base); + // Real, stable JDK facts (unchanged for decades). + assert_eq!( + jdk.superclass("java/io/BufferedReader"), + Some("java/io/Reader".to_string()) + ); + assert_eq!( + jdk.superclass("java/lang/NoSuchMethodException"), + Some("java/lang/ReflectiveOperationException".to_string()) + ); + assert_eq!(jdk.superclass("java/lang/Object"), None); + // Outside the JDK: honest miss, like before. + assert_eq!(jdk.superclass("demo/Nope"), None); + } +} diff --git a/crates/noxis-analysis/src/lib.rs b/crates/noxis-analysis/src/lib.rs index 6b4457a..3ba8b8c 100644 --- a/crates/noxis-analysis/src/lib.rs +++ b/crates/noxis-analysis/src/lib.rs @@ -10,8 +10,10 @@ pub mod entrypoints; pub mod hierarchy; +pub mod jdk; pub mod keep; pub use entrypoints::Entrypoints; pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety}; +pub use jdk::JdkHierarchy; pub use keep::{Analysis, KeepRules, RuleError}; diff --git a/crates/noxis-classfile/src/format/frames/merge/descs.rs b/crates/noxis-classfile/src/format/frames/merge/descs.rs index 18c41b6..e978c7b 100644 --- a/crates/noxis-classfile/src/format/frames/merge/descs.rs +++ b/crates/noxis-classfile/src/format/frames/merge/descs.rs @@ -129,11 +129,22 @@ pub(crate) fn method_slots(descriptor: &str) -> Result { } /// Element type from an array descriptor on the stack. +/// +/// A `Null` arrayref throws (`NullPointerException`) - no value flows from +/// it, so `Null` (bottom) is the precise answer for reference loads. +/// Yielding `Object` instead poisons downstream joins permanently: the +/// poison arrives through transient worklist states (before upstream slots +/// refine) and `lub(Object, X)` can never narrow back. Primitive loads stay +/// exact: the opcode determines the result regardless of the arrayref. pub(crate) fn array_element( array: &VerificationType, kind: crate::insn::ArrayKind, ) -> VerificationType { + use crate::format::VerificationType::Null; use crate::insn::ArrayKind::{Boolean, Byte, Char, Double, Float, Int, Long, Reference, Short}; + if *array == Null && kind == Reference { + return Null; + } if let VerificationType::Object(descriptor) = array && let Some((dims, elem)) = split_array(descriptor) { diff --git a/crates/noxis-classfile/src/format/frames/tests/joins/arrays.rs b/crates/noxis-classfile/src/format/frames/tests/joins/arrays.rs new file mode 100644 index 0000000..773ea67 --- /dev/null +++ b/crates/noxis-classfile/src/format/frames/tests/joins/arrays.rs @@ -0,0 +1,155 @@ +//! Join-shape tests: loop heads, wide array loads, nested construction. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::super::super::{StackMapFrame, VerificationType, compute_frames}; +use super::super::{NoHierarchy, code_method, load_ref, store_ref}; +use crate::insn::{ArrayKind, BranchCond, Cond, Const, Insn}; +#[test] +fn anewarray_of_array_keeps_descriptor() { + let method = code_method( + "make", + "()Ljava/lang/Object;", + 2, + 1, + vec![ + Insn::Mark(0), + Insn::Const(Const::Int(3)), + Insn::ANewArray("[B".to_string()), + store_ref(0), + load_ref(0), + Insn::Branch(BranchCond::Null { is_null: false }, 1), + Insn::AconstNull, + store_ref(0), + Insn::Goto(2), + Insn::Mark(1), + Insn::Mark(2), + load_ref(0), + Insn::AReturn, + ], + vec![], + ); + let frames = compute_frames("demo/Make", &method, &NoHierarchy).expect("frames"); + assert_eq!( + frames, + vec![StackMapFrame::Append { + offset_delta: 8, + locals: vec![VerificationType::Object("[[B".to_string())], + },] + ); +} + +/// A loop head keeps `Top` for a slot written only inside the loop. +/// +/// Inflows: entry (never wrote it) and back edge (wrote `Integer`). Like +/// `javac`, the join declares `Top`: a precise type would reject the inflow +/// that never wrote the slot (`Type top is not assignable`). +/// `aaload` on a one-dimensional reference array yields the element type. +/// +/// Real-world shape (`V8ValueObject.bind` family): one join inflow carries +/// the loaded element (`Method`), the other the array itself (`Method[]`). +/// The join must declare their LUB (`Object`); declaring the array rejects +/// the element inflow (`Type ... is not assignable to ...`). +#[test] +fn aaload_on_ref_array_yields_element() { + let method = code_method( + "pick", + "([Ljava/lang/reflect/Method;)Ljava/lang/Object;", + 2, + 2, + vec![ + Insn::Mark(0), + Insn::Const(Const::Int(0)), + Insn::Branch(BranchCond::Zero(Cond::Eq), 1), + load_ref(0), + Insn::Const(Const::Int(0)), + Insn::ArrayLoad(ArrayKind::Reference), + store_ref(1), + Insn::Goto(2), + Insn::Mark(1), + load_ref(0), + store_ref(1), + Insn::Mark(2), + load_ref(1), + Insn::AReturn, + ], + vec![], + ); + let frames = compute_frames("demo/Pick", &method, &NoHierarchy).expect("frames"); + // The join declares locals[1]; whatever the frame kind, it must be the + // LUB (`Object`), never the array the other inflow carried. + let mut saw_join = false; + for frame in &frames { + let locals: Vec = match frame { + StackMapFrame::SameLocals { local, .. } => vec![local.clone()], + StackMapFrame::Append { locals, .. } | StackMapFrame::Full { locals, .. } => { + locals.clone() + } + StackMapFrame::Same { .. } | StackMapFrame::Chop { .. } => vec![], + }; + for local in &locals { + assert_ne!( + local, + &VerificationType::Object("[Ljava/lang/reflect/Method;".to_string()) + ); + if local == &VerificationType::Object("java/lang/Object".to_string()) { + saw_join = true; + } + } + } + assert!(saw_join, "expected the join LUB in {frames:?}"); +} + +/// `aaload` on a `Null` arrayref yields `Null`, not `Object`. +/// +/// The `Null` path throws (`NullPointerException`) - no value flows from it, +/// so `Null` (bottom) is the precise answer. Yielding `Object` poisons the +/// join permanently: `lub(Object, String)` stays `Object`, while the true +/// merge (`lub(Null, String)`) is `String`. Worse, the poison arrives +/// through *transient* worklist states (before upstream slots refine) and +/// can never leave - the transfer is non-monotone in the arrayref. +#[test] +fn aaload_on_null_yields_null() { + let method = code_method( + "pick", + "(Ljava/lang/String;)Ljava/lang/Object;", + 2, + 2, + vec![ + Insn::Mark(0), + Insn::AconstNull, + store_ref(1), + Insn::Const(Const::Int(0)), + Insn::Branch(BranchCond::Zero(Cond::Eq), 1), + load_ref(1), + Insn::Const(Const::Int(0)), + Insn::ArrayLoad(ArrayKind::Reference), + Insn::Goto(2), + Insn::Mark(1), + load_ref(0), + Insn::Mark(2), + Insn::AReturn, + ], + vec![], + ); + let frames = compute_frames("demo/Pick", &method, &NoHierarchy).expect("frames"); + let mut saw_string = false; + for frame in &frames { + let stack: Vec = match frame { + StackMapFrame::SameLocals { local, .. } => vec![local.clone()], + StackMapFrame::Full { stack, .. } => stack.clone(), + _ => vec![], + }; + for item in &stack { + assert_ne!( + item, + &VerificationType::Object("java/lang/Object".to_string()), + "poisoned join in {frames:?}" + ); + if item == &VerificationType::Object("java/lang/String".to_string()) { + saw_string = true; + } + } + } + assert!(saw_string, "expected the String join in {frames:?}"); +} diff --git a/crates/noxis-classfile/src/format/frames/tests/joins.rs b/crates/noxis-classfile/src/format/frames/tests/joins/mod.rs similarity index 55% rename from crates/noxis-classfile/src/format/frames/tests/joins.rs rename to crates/noxis-classfile/src/format/frames/tests/joins/mod.rs index ebf8462..ea82b9f 100644 --- a/crates/noxis-classfile/src/format/frames/tests/joins.rs +++ b/crates/noxis-classfile/src/format/frames/tests/joins/mod.rs @@ -3,51 +3,11 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] use super::super::{StackMapFrame, VerificationType, compute_frames}; + use super::{NoHierarchy, code_method, load, load_ref, store, store_ref}; -use crate::insn::{ArrayKind, BranchCond, Cond, Const, Insn, Prim}; +use crate::insn::{BranchCond, Cond, Const, Insn, Prim}; -/// `anewarray [B` (a partially bound multi-dim array) yields its descriptor -/// verbatim: wrapping it again (`[L[B;`) is not a class and HotSpot rejects -/// the pool entry. -#[test] -fn anewarray_of_array_keeps_descriptor() { - let method = code_method( - "make", - "()Ljava/lang/Object;", - 2, - 1, - vec![ - Insn::Mark(0), - Insn::Const(Const::Int(3)), - Insn::ANewArray("[B".to_string()), - store_ref(0), - load_ref(0), - Insn::Branch(BranchCond::Null { is_null: false }, 1), - Insn::AconstNull, - store_ref(0), - Insn::Goto(2), - Insn::Mark(1), - Insn::Mark(2), - load_ref(0), - Insn::AReturn, - ], - vec![], - ); - let frames = compute_frames("demo/Make", &method, &NoHierarchy).expect("frames"); - assert_eq!( - frames, - vec![StackMapFrame::Append { - offset_delta: 8, - locals: vec![VerificationType::Object("[[B".to_string())], - },] - ); -} - -/// A loop head keeps `Top` for a slot written only inside the loop. -/// -/// Inflows: entry (never wrote it) and back edge (wrote `Integer`). Like -/// `javac`, the join declares `Top`: a precise type would reject the inflow -/// that never wrote the slot (`Type top is not assignable`). +mod arrays; #[test] fn loop_head_keeps_top_for_loop_local() { let method = code_method( @@ -189,59 +149,3 @@ fn array_load_wide_pushes_pair() { ] ); } - -/// `aaload` on a one-dimensional reference array yields the element type. -/// -/// Real-world shape (`V8ValueObject.bind` family): one join inflow carries -/// the loaded element (`Method`), the other the array itself (`Method[]`). -/// The join must declare their LUB (`Object`); declaring the array rejects -/// the element inflow (`Type ... is not assignable to ...`). -#[test] -fn aaload_on_ref_array_yields_element() { - let method = code_method( - "pick", - "([Ljava/lang/reflect/Method;)Ljava/lang/Object;", - 2, - 2, - vec![ - Insn::Mark(0), - Insn::Const(Const::Int(0)), - Insn::Branch(BranchCond::Zero(Cond::Eq), 1), - load_ref(0), - Insn::Const(Const::Int(0)), - Insn::ArrayLoad(ArrayKind::Reference), - store_ref(1), - Insn::Goto(2), - Insn::Mark(1), - load_ref(0), - store_ref(1), - Insn::Mark(2), - load_ref(1), - Insn::AReturn, - ], - vec![], - ); - let frames = compute_frames("demo/Pick", &method, &NoHierarchy).expect("frames"); - // The join declares locals[1]; whatever the frame kind, it must be the - // LUB (`Object`), never the array the other inflow carried. - let mut saw_join = false; - for frame in &frames { - let locals: Vec = match frame { - StackMapFrame::SameLocals { local, .. } => vec![local.clone()], - StackMapFrame::Append { locals, .. } | StackMapFrame::Full { locals, .. } => { - locals.clone() - } - StackMapFrame::Same { .. } | StackMapFrame::Chop { .. } => vec![], - }; - for local in &locals { - assert_ne!( - local, - &VerificationType::Object("[Ljava/lang/reflect/Method;".to_string()) - ); - if local == &VerificationType::Object("java/lang/Object".to_string()) { - saw_join = true; - } - } - } - assert!(saw_join, "expected the join LUB in {frames:?}"); -} diff --git a/crates/noxis-passes/src/constants/mod.rs b/crates/noxis-passes/src/constants/mod.rs index 6b8e77b..a88b517 100644 --- a/crates/noxis-passes/src/constants/mod.rs +++ b/crates/noxis-passes/src/constants/mod.rs @@ -9,7 +9,7 @@ mod forms; #[cfg(test)] mod tests; -use noxis_analysis::ClassHierarchy; +use noxis_analysis::{ClassHierarchy, JdkHierarchy}; use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames}; use noxis_core::{Context, CoreError, Pass, PassId, PassReport}; @@ -100,8 +100,9 @@ impl Pass for ConstantsPass { &mut predicates, )?; let hierarchy = ClassHierarchy::build(program); + let frames = JdkHierarchy::wrap(&hierarchy); for (ci, mi) in &mutated { - refresh_frames(program, *ci, *mi, &hierarchy)?; + refresh_frames(program, *ci, *mi, &frames)?; } Ok(report(replaced, predicates, skipped)) } @@ -187,7 +188,7 @@ fn refresh_frames( program: &mut Program, ci: usize, mi: usize, - hierarchy: &ClassHierarchy, + hierarchy: &JdkHierarchy<'_>, ) -> Result<(), CoreError> { let (name, snapshot) = { let class = program diff --git a/crates/noxis-passes/src/flow/mod.rs b/crates/noxis-passes/src/flow/mod.rs index 0b17ad5..9bbc32e 100644 --- a/crates/noxis-passes/src/flow/mod.rs +++ b/crates/noxis-passes/src/flow/mod.rs @@ -18,7 +18,7 @@ mod rewire; mod tests; use dispatch::{lookup_dispatch, opaque_guard, table_dispatch}; -use noxis_analysis::ClassHierarchy; +use noxis_analysis::{ClassHierarchy, JdkHierarchy}; 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}; @@ -106,8 +106,9 @@ impl Pass for FlowPass { } } let hierarchy = ClassHierarchy::build(program); + let frames = JdkHierarchy::wrap(&hierarchy); for (ci, mi) in &mutated { - refresh_frames(program, *ci, *mi, &hierarchy)?; + refresh_frames(program, *ci, *mi, &frames)?; } Ok(report(&per_scheme, mutated.len(), skipped)) } @@ -225,7 +226,7 @@ fn refresh_frames( program: &mut Program, ci: usize, mi: usize, - hierarchy: &ClassHierarchy, + hierarchy: &JdkHierarchy<'_>, ) -> Result<(), CoreError> { let (name, snapshot) = { let class = program diff --git a/crates/noxis-passes/src/noise/mod.rs b/crates/noxis-passes/src/noise/mod.rs index 4f8e8a0..3101142 100644 --- a/crates/noxis-passes/src/noise/mod.rs +++ b/crates/noxis-passes/src/noise/mod.rs @@ -15,7 +15,7 @@ mod tests; use crate::namegen::NameGenerator; use crate::rename::assign::parent_package; use body::{entry_body, helper_body}; -use noxis_analysis::ClassHierarchy; +use noxis_analysis::{ClassHierarchy, JdkHierarchy}; use noxis_classfile::{ClassBuilder, Field, Program, Version, compute_frames}; use noxis_core::{Context, CoreError, Pass, PassId, PassReport}; use weave::weave_first_reachable; @@ -132,8 +132,9 @@ impl Pass for NoisePass { // Frames for every generated branching method. let hierarchy = ClassHierarchy::build(program); + let frames = JdkHierarchy::wrap(&hierarchy); for (ci, mi) in &fresh_methods { - refresh_frames(program, *ci, *mi, &hierarchy)?; + refresh_frames(program, *ci, *mi, &frames)?; } let mut woven = 0usize; @@ -177,7 +178,7 @@ fn refresh_frames( program: &mut Program, ci: usize, mi: usize, - hierarchy: &ClassHierarchy, + hierarchy: &JdkHierarchy<'_>, ) -> Result<(), CoreError> { let (name, snapshot) = { let class = program diff --git a/crates/noxis-passes/src/noise/weave.rs b/crates/noxis-passes/src/noise/weave.rs index ce3be2f..fcaa940 100644 --- a/crates/noxis-passes/src/noise/weave.rs +++ b/crates/noxis-passes/src/noise/weave.rs @@ -1,6 +1,6 @@ //! Weaving junk classes into the reachability graph of live methods. -use noxis_analysis::ClassHierarchy; +use noxis_analysis::{ClassHierarchy, JdkHierarchy}; use noxis_classfile::{Insn, InvokeKind, Program, compute_frames}; use noxis_core::{CoreError, PassId}; @@ -68,7 +68,8 @@ pub(super) fn weave_first_reachable( .cloned() .ok_or_else(missing)?; let hierarchy = ClassHierarchy::build(program); - let frames = compute_frames(&class_name, &snapshot, &hierarchy) + let frames_hierarchy = JdkHierarchy::wrap(&hierarchy); + let frames = compute_frames(&class_name, &snapshot, &frames_hierarchy) .map_err(|e| CoreError::pass(PassId::Noise, e.to_string()))?; program .classes diff --git a/crates/noxis-passes/src/rename/rewrite/code.rs b/crates/noxis-passes/src/rename/rewrite/code.rs index d453f22..1dd2f6c 100644 --- a/crates/noxis-passes/src/rename/rewrite/code.rs +++ b/crates/noxis-passes/src/rename/rewrite/code.rs @@ -69,14 +69,25 @@ pub(super) fn rewrite_code( } *descriptor = rewrite_descriptor(descriptor, &maps.classes); } - Insn::New(name) - | Insn::CheckCast(name) - | Insn::InstanceOf(name) - | Insn::ANewArray(name) => { + Insn::New(name) => { if let Some(mapped) = maps.classes.get(name.as_str()) { *name = mapped.clone(); } } + // Type operands may be array descriptors (`[Lpkg/Cls;`, `[B`): + // the element class inside follows the mapping, while a plain + // name takes the exact entry. A stale descriptor is worse than + // useless - later frame merges treat it as a ghost supertype. + Insn::CheckCast(name) | Insn::InstanceOf(name) | Insn::ANewArray(name) => { + if name.starts_with('[') { + *name = rewrite_descriptor(name, &maps.classes); + } else if let Some(mapped) = maps.classes.get(name.as_str()) { + *name = mapped.clone(); + } + } + Insn::MultiANewArray { descriptor, .. } => { + *descriptor = rewrite_descriptor(descriptor, &maps.classes); + } _ => {} } } diff --git a/crates/noxis-passes/src/rename/tests.rs b/crates/noxis-passes/src/rename/tests.rs index 2bbd508..93a411f 100644 --- a/crates/noxis-passes/src/rename/tests.rs +++ b/crates/noxis-passes/src/rename/tests.rs @@ -191,3 +191,77 @@ fn subclass_gets_alias_for_inherited_member() { // Чужого члена не выдумываем. assert_eq!(maps.method("p/Sub", "other", "()V"), None); } + +/// Array type operands (`checkcast`, `instanceof`, `anewarray`, +/// `multianewarray`) carry descriptors, not plain names: the element class +/// inside must follow the mapping, or the reference silently goes stale +/// (and the next frame computation merges against a ghost type). +#[test] +fn array_type_operands_follow_their_element() { + let mut program = sample(); + let main = program + .classes + .iter_mut() + .find(|c| c.this_class == "app/Main") + .expect("main"); + let probe = main + .methods + .iter_mut() + .find(|m| m.name == "main") + .expect("main method"); + let code = probe.code.as_mut().expect("code"); + code.insns.extend([ + Insn::CheckCast("[Lapp/Helper;".to_string()), + Insn::Pop, + Insn::InstanceOf("[Lapp/Helper;".to_string()), + Insn::Pop, + Insn::ANewArray("app/Helper".to_string()), + Insn::Pop, + Insn::MultiANewArray { + descriptor: "[[Lapp/Helper;".to_string(), + dims: 2, + }, + Insn::Pop, + ]); + let ctx = run_rename(&mut program); + let helper = ctx.mapping.class("app/Helper").expect("renamed"); + let main = program.class("app/Main").expect("kept"); + let method = main + .methods + .iter() + .find(|m| m.name == "main") + .expect("main"); + let insns = &method.code.as_ref().expect("code").insns; + let cast = insns + .iter() + .find_map(|i| match i { + Insn::CheckCast(name) => Some(name), + _ => None, + }) + .expect("checkcast"); + assert_eq!(cast, &format!("[L{helper};")); + let inst = insns + .iter() + .find_map(|i| match i { + Insn::InstanceOf(name) => Some(name), + _ => None, + }) + .expect("instanceof"); + assert_eq!(inst, &format!("[L{helper};")); + let anew = insns + .iter() + .find_map(|i| match i { + Insn::ANewArray(name) => Some(name), + _ => None, + }) + .expect("anewarray"); + assert_eq!(anew, helper); + let multi = insns + .iter() + .find_map(|i| match i { + Insn::MultiANewArray { descriptor, .. } => Some(descriptor), + _ => None, + }) + .expect("multianewarray"); + assert_eq!(multi, &format!("[[L{helper};")); +} diff --git a/crates/noxis-passes/src/strings/mod.rs b/crates/noxis-passes/src/strings/mod.rs index cbe1702..e06cb15 100644 --- a/crates/noxis-passes/src/strings/mod.rs +++ b/crates/noxis-passes/src/strings/mod.rs @@ -11,7 +11,7 @@ mod tests; use std::collections::BTreeMap; -use noxis_analysis::ClassHierarchy; +use noxis_analysis::{ClassHierarchy, JdkHierarchy}; use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames}; use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed}; use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt}; @@ -128,6 +128,7 @@ impl Pass for StringsPass { ctx.note(format!("string decoder: {owner}")); // Phase 4: frames of the touched methods. let hierarchy = ClassHierarchy::build(program); + let frames_hierarchy = JdkHierarchy::wrap(&hierarchy); for (ci, mi) in &mutated { let (name, snapshot) = { let class = program @@ -143,7 +144,7 @@ impl Pass for StringsPass { .ok_or_else(|| pass_error("method vanished"))?, ) }; - let frames = compute_frames(&name, &snapshot, &hierarchy) + let frames = compute_frames(&name, &snapshot, &frames_hierarchy) .map_err(|e| pass_error(e.to_string()))?; program .classes diff --git a/crates/noxis/examples/minimizer.rs b/crates/noxis/examples/minimizer.rs index f1470c0..1f3e6f2 100644 --- a/crates/noxis/examples/minimizer.rs +++ b/crates/noxis/examples/minimizer.rs @@ -38,6 +38,7 @@ struct Args { seed: u64, config: Option, bisect: bool, + recompute: bool, } fn parse_args() -> Result { @@ -47,6 +48,7 @@ fn parse_args() -> Result { seed: 1, config: None, bisect: false, + recompute: false, }; let mut iter = std::env::args().skip(1); while let Some(flag) = iter.next() { @@ -62,6 +64,7 @@ fn parse_args() -> Result { } "--config" => args.config = Some(iter.next().ok_or("--config needs a value")?), "--bisect" => args.bisect = true, + "--recompute" => args.recompute = true, other => return Err(format!("unknown flag {other}")), } } @@ -212,9 +215,61 @@ fn bisect(cp: &Path, class: &ClassFile) -> Option> { Some(keep) } +/// Recompute frames for a method as written: proves whether the stored +/// table is stale (code grew after compute) or compute itself truncates. +fn recompute(jar_path: &Path, target: &str) -> Result { + use noxis_analysis::{ClassHierarchy, JdkHierarchy}; + use noxis_classfile::compute_frames; + + let entries = read_file(jar_path).map_err(|e| e.to_string())?; + let program = load(entries).map_err(|e| e.to_string())?; + let (class_name, method_name) = target + .rsplit_once('#') + .ok_or("usage: --recompute --class #")?; + let class = program + .class(class_name) + .ok_or_else(|| format!("{class_name} not in jar"))?; + let method = class + .methods + .iter() + .find(|m| m.name == method_name) + .ok_or_else(|| format!("{method_name} not in {class_name}"))?; + let base = ClassHierarchy::build(&program); + let hierarchy = JdkHierarchy::wrap(&base); + let fresh = + compute_frames(class_name, method, &hierarchy).map_err(|e| format!("compute: {e}"))?; + let stored = method.code.as_ref().map_or(0, |c| c.frames.len()); + println!("stored frames: {stored}, recomputed: {}", fresh.len()); + // Last covered bci of each table. + for (label, frames) in [ + ( + "stored", + method.code.as_ref().map_or(&[][..], |c| &c.frames[..]), + ), + ("fresh", &fresh[..]), + ] { + let mut bci: i64 = -1; + for frame in frames { + let d = match frame { + noxis_classfile::StackMapFrame::Same { offset_delta } + | noxis_classfile::StackMapFrame::SameLocals { offset_delta, .. } + | noxis_classfile::StackMapFrame::Chop { offset_delta, .. } + | noxis_classfile::StackMapFrame::Append { offset_delta, .. } + | noxis_classfile::StackMapFrame::Full { offset_delta, .. } => *offset_delta as i64, + }; + bci = if bci < 0 { d } else { bci + d + 1 }; + } + println!("{label}: last bci {bci}"); + } + Ok(false) +} + fn run() -> Result { let args = parse_args()?; let jar_path = PathBuf::from(&args.jar); + if args.recompute { + return recompute(&jar_path, &args.class); + } let config = load_config(&args)?; let entries = read_file(&jar_path).map_err(|e| e.to_string())?; if !entries