passes: emit strings as CONSTANT_Dynamic (condy) - BSM-resolved, invisible to concrete-execution deobfuscators
This commit is contained in:
parent
1ddc4afbfc
commit
aac7571bbc
10 changed files with 517 additions and 5 deletions
|
|
@ -69,7 +69,14 @@ pub(super) fn build_rename(t: &Table) -> Res<RenameConfig> {
|
||||||
pub(super) fn build_strings(t: &Table) -> Res<StringsConfig> {
|
pub(super) fn build_strings(t: &Table) -> Res<StringsConfig> {
|
||||||
check_known_keys(
|
check_known_keys(
|
||||||
t,
|
t,
|
||||||
&["schemes", "min_len", "split_above", "skip", "derive_key"],
|
&[
|
||||||
|
"schemes",
|
||||||
|
"min_len",
|
||||||
|
"split_above",
|
||||||
|
"skip",
|
||||||
|
"derive_key",
|
||||||
|
"condy",
|
||||||
|
],
|
||||||
"strings",
|
"strings",
|
||||||
)?;
|
)?;
|
||||||
let mut c = StringsConfig::default();
|
let mut c = StringsConfig::default();
|
||||||
|
|
@ -88,6 +95,9 @@ pub(super) fn build_strings(t: &Table) -> Res<StringsConfig> {
|
||||||
if let Some(v) = t.get("derive_key") {
|
if let Some(v) = t.get("derive_key") {
|
||||||
c.derive_key = expect_bool(v, "strings.derive_key")?;
|
c.derive_key = expect_bool(v, "strings.derive_key")?;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = t.get("condy") {
|
||||||
|
c.condy = expect_bool(v, "strings.condy")?;
|
||||||
|
}
|
||||||
Ok(c)
|
Ok(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,10 @@ fn parses_name_style_in_rename_section() {
|
||||||
let cfg = build_from("[rename]\nclass_style = \"il-o0\"\n").unwrap();
|
let cfg = build_from("[rename]\nclass_style = \"il-o0\"\n").unwrap();
|
||||||
assert_eq!(cfg.rename.class_style, NameStyle::IlO0);
|
assert_eq!(cfg.rename.class_style, NameStyle::IlO0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_condy_flag_in_strings_section() {
|
||||||
|
let cfg = build_from("[strings]\ncondy = true\n").unwrap();
|
||||||
|
assert!(cfg.strings.condy);
|
||||||
|
assert!(!Config::default().strings.condy);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ pub struct StringsConfig {
|
||||||
/// Derive the key from KDF(class, method, index) instead of placing it
|
/// Derive the key from KDF(class, method, index) instead of placing it
|
||||||
/// next to the ciphertext.
|
/// next to the ciphertext.
|
||||||
pub derive_key: bool,
|
pub derive_key: bool,
|
||||||
|
/// Emit strings as `CONSTANT_Dynamic` (condy) resolved by the decoder's
|
||||||
|
/// bootstrap method instead of direct `d(cipher, salt)` calls. A
|
||||||
|
/// concrete-execution deobfuscator cannot follow the condy resolution
|
||||||
|
/// (todo.md, 2026-10-14: SSVM made 0 changes on a condy-only class).
|
||||||
|
/// Requires classfile >= 55; older classes keep direct calls.
|
||||||
|
pub condy: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StringsConfig {
|
impl Default for StringsConfig {
|
||||||
|
|
@ -89,6 +95,7 @@ impl Default for StringsConfig {
|
||||||
split_above: 64,
|
split_above: 64,
|
||||||
skip: Vec::new(),
|
skip: Vec::new(),
|
||||||
derive_key: true,
|
derive_key: true,
|
||||||
|
condy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
crates/noxis-passes/src/strings/condy.rs
Normal file
76
crates/noxis-passes/src/strings/condy.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
//! Condensed emission (`CONSTANT_Dynamic`): the JVM resolves the string
|
||||||
|
//! itself through the decoder's bootstrap method instead of us calling `d`.
|
||||||
|
//!
|
||||||
|
//! Why: a concrete-execution deobfuscator (narumii/SSVM, todo.md 2026-10-14)
|
||||||
|
//! follows `invokestatic d` and recovers the plaintext, but made **0
|
||||||
|
//! changes** on a condy-only class - it does not resolve `CONSTANT_Dynamic`
|
||||||
|
//! at all. The plaintext then lives only inside JVM constant caching.
|
||||||
|
|
||||||
|
use noxis_classfile::{Attribute, BootstrapMethod, ClassFile, Const, Insn};
|
||||||
|
use noxis_runtime::{BOOTSTRAP_DESC, BOOTSTRAP_METHOD};
|
||||||
|
|
||||||
|
/// `REF_invokeStatic` (JVMS §5.4.3.5).
|
||||||
|
const INVOKE_STATIC: u8 = 6;
|
||||||
|
|
||||||
|
/// Class-file version introducing `CONSTANT_Dynamic` (Java 11).
|
||||||
|
pub(super) const CONDY_MIN_MAJOR: u16 = 55;
|
||||||
|
|
||||||
|
/// The class supports condy constants at all.
|
||||||
|
pub(super) fn supported(class: &ClassFile) -> bool {
|
||||||
|
class.version.major >= CONDY_MIN_MAJOR
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where new bootstrap entries would start: right after the existing ones
|
||||||
|
/// (Kotlin lambdas etc. already own the front of the attribute).
|
||||||
|
pub(super) fn base_index(class: &ClassFile) -> u16 {
|
||||||
|
class
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
Attribute::BootstrapMethods(list) => Some(list.len() as u16),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bootstrap entry per chunk: the decoder's `b` with `cipher` + `salt`
|
||||||
|
/// as static arguments. The BSM prefix (`lookup`, `name`, `type`) is filled
|
||||||
|
/// in by the JVM (JVMS §5.4.3.5).
|
||||||
|
pub(super) fn entry(owner: &str, cipher: &str, salt: i32) -> BootstrapMethod {
|
||||||
|
BootstrapMethod {
|
||||||
|
kind: INVOKE_STATIC,
|
||||||
|
owner: owner.to_string(),
|
||||||
|
name: BOOTSTRAP_METHOD.to_string(),
|
||||||
|
descriptor: BOOTSTRAP_DESC.to_string(),
|
||||||
|
itf: false,
|
||||||
|
args: vec![Const::String(cipher.to_string()), Const::Int(salt)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Condensed replacements for one string's chunks: a `ldc` per chunk
|
||||||
|
/// (bootstrap indices `base..base+n`) joined by `String.concat`.
|
||||||
|
pub(super) fn sequence(base: u16, chunks: usize) -> Vec<Insn> {
|
||||||
|
let mut out = Vec::with_capacity(chunks * 2);
|
||||||
|
for position in 0..chunks {
|
||||||
|
out.push(Insn::Const(Const::Dynamic {
|
||||||
|
name: "_".to_string(),
|
||||||
|
descriptor: "Ljava/lang/String;".to_string(),
|
||||||
|
bootstrap: base + position as u16,
|
||||||
|
}));
|
||||||
|
if position > 0 {
|
||||||
|
out.push(concat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `String.concat` - the same chunk join the direct path uses.
|
||||||
|
fn concat() -> Insn {
|
||||||
|
Insn::Invoke {
|
||||||
|
kind: noxis_classfile::InvokeKind::Virtual,
|
||||||
|
owner: "java/lang/String".to_string(),
|
||||||
|
name: "concat".to_string(),
|
||||||
|
descriptor: "(Ljava/lang/String;)Ljava/lang/String;".to_string(),
|
||||||
|
itf: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
|
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
|
||||||
//! program and only if at least one string was encrypted.
|
//! program and only if at least one string was encrypted.
|
||||||
|
|
||||||
|
mod condy;
|
||||||
mod crypt;
|
mod crypt;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -12,7 +13,9 @@ mod tests;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use noxis_analysis::{ClassHierarchy, JdkHierarchy};
|
use noxis_analysis::{ClassHierarchy, JdkHierarchy};
|
||||||
use noxis_classfile::{Const, Insn, MAX_METHOD_INSNS, Program, compute_frames};
|
use noxis_classfile::{
|
||||||
|
Attribute, BootstrapMethod, Const, Insn, MAX_METHOD_INSNS, Program, compute_frames,
|
||||||
|
};
|
||||||
use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed};
|
use noxis_core::{Context, CoreError, NameStyle, Pass, PassId, PassReport, derive_seed};
|
||||||
use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
|
use noxis_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
|
||||||
|
|
||||||
|
|
@ -69,6 +72,17 @@ impl Pass for StringsPass {
|
||||||
let owner = decoder_name(ctx, program);
|
let owner = decoder_name(ctx, program);
|
||||||
let mut chunk_counter = 0usize;
|
let mut chunk_counter = 0usize;
|
||||||
let mut salt_counter = 0u64;
|
let mut salt_counter = 0u64;
|
||||||
|
// Condy mode: per-class next bootstrap index + accumulated entries
|
||||||
|
// (appended to the class attribute after the rewrite below).
|
||||||
|
let mut bsm_next: BTreeMap<usize, u16> = BTreeMap::new();
|
||||||
|
let mut bsm_entries: BTreeMap<usize, Vec<BootstrapMethod>> = BTreeMap::new();
|
||||||
|
if cfg.condy {
|
||||||
|
for (ci, class) in program.classes.iter().enumerate() {
|
||||||
|
if condy::supported(class) {
|
||||||
|
bsm_next.insert(ci, condy::base_index(class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut replacements: BTreeMap<(usize, usize, usize), Vec<Insn>> = BTreeMap::new();
|
let mut replacements: BTreeMap<(usize, usize, usize), Vec<Insn>> = BTreeMap::new();
|
||||||
for (ci, mi, ii, text) in &targets {
|
for (ci, mi, ii, text) in &targets {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
|
|
@ -80,8 +94,20 @@ impl Pass for StringsPass {
|
||||||
parts.push((blob.cipher, salt));
|
parts.push((blob.cipher, salt));
|
||||||
chunk_counter += 1;
|
chunk_counter += 1;
|
||||||
}
|
}
|
||||||
|
if let Some(next) = bsm_next.get_mut(ci) {
|
||||||
|
let base = *next;
|
||||||
|
for (cipher, salt) in &parts {
|
||||||
|
bsm_entries
|
||||||
|
.entry(*ci)
|
||||||
|
.or_default()
|
||||||
|
.push(condy::entry(&owner, cipher, *salt));
|
||||||
|
}
|
||||||
|
*next = base + parts.len() as u16;
|
||||||
|
replacements.insert((*ci, *mi, *ii), condy::sequence(base, parts.len()));
|
||||||
|
} else {
|
||||||
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
|
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let mut mutated: Vec<(usize, usize)> = Vec::new();
|
let mut mutated: Vec<(usize, usize)> = Vec::new();
|
||||||
let mut applied = 0usize;
|
let mut applied = 0usize;
|
||||||
let mut per_method: BTreeMap<(usize, usize), usize> = BTreeMap::new();
|
let mut per_method: BTreeMap<(usize, usize), usize> = BTreeMap::new();
|
||||||
|
|
@ -126,6 +152,21 @@ impl Pass for StringsPass {
|
||||||
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
|
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
|
||||||
program.classes.push(decoder);
|
program.classes.push(decoder);
|
||||||
ctx.note(format!("string decoder: {owner}"));
|
ctx.note(format!("string decoder: {owner}"));
|
||||||
|
// Phase 3b: condy bootstrap entries land on their classes (appended
|
||||||
|
// after existing lambdas - the indices computed above assumed that).
|
||||||
|
for (ci, entries) in bsm_entries {
|
||||||
|
let class = program
|
||||||
|
.classes
|
||||||
|
.get_mut(ci)
|
||||||
|
.ok_or_else(|| pass_error("class vanished"))?;
|
||||||
|
match class.attributes.iter_mut().find_map(|a| match a {
|
||||||
|
Attribute::BootstrapMethods(list) => Some(list),
|
||||||
|
_ => None,
|
||||||
|
}) {
|
||||||
|
Some(list) => list.extend(entries),
|
||||||
|
None => class.attributes.push(Attribute::BootstrapMethods(entries)),
|
||||||
|
}
|
||||||
|
}
|
||||||
// Phase 4: frames of the touched methods.
|
// Phase 4: frames of the touched methods.
|
||||||
let hierarchy = ClassHierarchy::build(program);
|
let hierarchy = ClassHierarchy::build(program);
|
||||||
let frames_hierarchy = JdkHierarchy::wrap(&hierarchy);
|
let frames_hierarchy = JdkHierarchy::wrap(&hierarchy);
|
||||||
|
|
|
||||||
201
crates/noxis-passes/src/strings/tests/condy.rs
Normal file
201
crates/noxis-passes/src/strings/tests/condy.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
//! Condy-mode tests: `CONSTANT_Dynamic` emission, version gate, attribute
|
||||||
|
//! merging.
|
||||||
|
|
||||||
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use noxis_classfile::{Attribute, BootstrapMethod, Const, Insn, Version};
|
||||||
|
use noxis_core::{Config, Context, Pass, StringsConfig};
|
||||||
|
use noxis_runtime::{Scheme, encrypt};
|
||||||
|
|
||||||
|
use super::super::StringsPass;
|
||||||
|
use super::sample;
|
||||||
|
|
||||||
|
/// Condensed mode: strings become `CONSTANT_Dynamic` resolved by the
|
||||||
|
/// decoder's bootstrap method - a concrete-execution deobfuscator sees no
|
||||||
|
/// decrypt call to follow (todo.md, 2026-10-14: 0 condy changes from SSVM).
|
||||||
|
#[test]
|
||||||
|
fn condy_mode_emits_dynamic_constants() {
|
||||||
|
let mut program = sample();
|
||||||
|
let config = Config {
|
||||||
|
seed: Some(7),
|
||||||
|
strings: StringsConfig {
|
||||||
|
condy: true,
|
||||||
|
..StringsConfig::default()
|
||||||
|
},
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let mut ctx = Context::new(config);
|
||||||
|
let report = StringsPass.apply(&mut ctx, &mut program).expect("strings");
|
||||||
|
|
||||||
|
assert_eq!(report.transformed, 2);
|
||||||
|
let class = &program.classes[0];
|
||||||
|
let code = class.methods[0].code.as_ref().expect("code");
|
||||||
|
// Both encrypted strings are condy constants, no direct decoder calls.
|
||||||
|
let dynamics: Vec<&Const> = code
|
||||||
|
.insns
|
||||||
|
.iter()
|
||||||
|
.filter_map(|insn| match insn {
|
||||||
|
Insn::Const(c @ Const::Dynamic { .. }) => Some(c),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(dynamics.len(), 3, "1 chunk + 2 chunks: {code:?}");
|
||||||
|
for value in &dynamics {
|
||||||
|
let Const::Dynamic { descriptor, .. } = value else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(descriptor, "Ljava/lang/String;");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!code
|
||||||
|
.insns
|
||||||
|
.iter()
|
||||||
|
.any(|insn| matches!(insn, Insn::Invoke { name, .. } if name == "d")),
|
||||||
|
"condy mode must not emit direct decoder calls: {code:?}"
|
||||||
|
);
|
||||||
|
// The bootstrap entries live on the class and point at the decoder's `b`.
|
||||||
|
let decoder = program
|
||||||
|
.classes
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.methods.iter().any(|m| m.name == "b"))
|
||||||
|
.expect("decoder with bootstrap method");
|
||||||
|
let Attribute::BootstrapMethods(bsm) = class
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| matches!(a, Attribute::BootstrapMethods(_)))
|
||||||
|
.expect("BootstrapMethods attribute")
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(bsm.len(), 3, "one entry per chunk");
|
||||||
|
for entry in bsm {
|
||||||
|
assert_eq!(entry.kind, 6, "REF_invokeStatic");
|
||||||
|
assert_eq!(entry.owner, decoder.this_class);
|
||||||
|
assert_eq!(entry.name, "b");
|
||||||
|
assert!(
|
||||||
|
matches!(entry.args.as_slice(), [Const::String(_), Const::Int(_)]),
|
||||||
|
"args are cipher + salt: {:?}",
|
||||||
|
entry.args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Bootstrap indices in the constants match the entry positions.
|
||||||
|
let indices: Vec<u16> = dynamics
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let Const::Dynamic { bootstrap, .. } = c else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
*bootstrap
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(indices, vec![0, 1, 2]);
|
||||||
|
// Ciphertext is still the direct encryption of the originals.
|
||||||
|
let schemes = [Scheme::XorSplitmix, Scheme::XteaCtr];
|
||||||
|
let originals = ["hello world".to_string(), "y".repeat(50), "y".repeat(50)];
|
||||||
|
let ciphers: Vec<String> = bsm
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e.args[0] {
|
||||||
|
Const::String(ref s) => s.clone(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let salts: Vec<i32> = bsm
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e.args[1] {
|
||||||
|
Const::Int(s) => s,
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for ((cipher, salt), original) in ciphers.iter().zip(&salts).zip(originals) {
|
||||||
|
let scheme = schemes[*salt as usize % schemes.len()];
|
||||||
|
assert_eq!(cipher, &encrypt(scheme, 7, *salt, &original).cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Condy is a Java 11+ feature (classfile 55): older classes keep the direct
|
||||||
|
/// decoder calls even when the mode is on.
|
||||||
|
#[test]
|
||||||
|
fn condy_falls_back_for_old_class_versions() {
|
||||||
|
let mut program = sample();
|
||||||
|
program.classes[0].version = Version::JAVA_8;
|
||||||
|
let config = Config {
|
||||||
|
seed: Some(7),
|
||||||
|
strings: StringsConfig {
|
||||||
|
condy: true,
|
||||||
|
..StringsConfig::default()
|
||||||
|
},
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let mut ctx = Context::new(config);
|
||||||
|
StringsPass.apply(&mut ctx, &mut program).expect("strings");
|
||||||
|
|
||||||
|
let code = program.classes[0].methods[0].code.as_ref().expect("code");
|
||||||
|
assert!(
|
||||||
|
code.insns
|
||||||
|
.iter()
|
||||||
|
.any(|insn| matches!(insn, Insn::Invoke { name, .. } if name == "d")),
|
||||||
|
"pre-11 class must keep direct calls: {code:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!code
|
||||||
|
.insns
|
||||||
|
.iter()
|
||||||
|
.any(|insn| matches!(insn, Insn::Const(Const::Dynamic { .. }))),
|
||||||
|
"no condy constants below classfile 55: {code:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A class that already has `BootstrapMethods` (Kotlin lambdas) keeps its
|
||||||
|
/// entries: new condy ones are appended and referenced by the right index.
|
||||||
|
#[test]
|
||||||
|
fn condy_appends_to_existing_bootstrap_methods() {
|
||||||
|
let mut program = sample();
|
||||||
|
let existing = BootstrapMethod {
|
||||||
|
kind: 6,
|
||||||
|
owner: "java/lang/invoke/StringConcatFactory".to_string(),
|
||||||
|
name: "makeConcatWithConstants".to_string(),
|
||||||
|
descriptor: "([Ljava/lang/Object;)Ljava/lang/String;".to_string(),
|
||||||
|
itf: false,
|
||||||
|
args: vec![Const::String("\u{1}".to_string())],
|
||||||
|
};
|
||||||
|
program.classes[0]
|
||||||
|
.attributes
|
||||||
|
.push(Attribute::BootstrapMethods(vec![existing]));
|
||||||
|
let config = Config {
|
||||||
|
seed: Some(7),
|
||||||
|
strings: StringsConfig {
|
||||||
|
condy: true,
|
||||||
|
..StringsConfig::default()
|
||||||
|
},
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let mut ctx = Context::new(config);
|
||||||
|
StringsPass.apply(&mut ctx, &mut program).expect("strings");
|
||||||
|
|
||||||
|
let class = &program.classes[0];
|
||||||
|
let Attribute::BootstrapMethods(bsm) = class
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| matches!(a, Attribute::BootstrapMethods(_)))
|
||||||
|
.expect("BootstrapMethods attribute")
|
||||||
|
else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
// 1 lambda entry + 3 chunk entries.
|
||||||
|
assert_eq!(bsm.len(), 4);
|
||||||
|
assert_eq!(bsm[0].name, "makeConcatWithConstants");
|
||||||
|
let code = class.methods[0].code.as_ref().expect("code");
|
||||||
|
let indices: Vec<u16> = code
|
||||||
|
.insns
|
||||||
|
.iter()
|
||||||
|
.filter_map(|insn| match insn {
|
||||||
|
Insn::Const(Const::Dynamic { bootstrap, .. }) => Some(*bootstrap),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
indices,
|
||||||
|
vec![1, 2, 3],
|
||||||
|
"condy entries start after the lambda"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
|
mod condy;
|
||||||
|
|
||||||
use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Program, Version};
|
use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Program, Version};
|
||||||
use noxis_core::{Config, Context};
|
use noxis_core::{Config, Context};
|
||||||
use noxis_runtime::{Scheme, encrypt};
|
use noxis_runtime::{Scheme, encrypt};
|
||||||
|
|
@ -10,7 +12,7 @@ use super::StringsPass;
|
||||||
use super::crypt::{decode_sequence, salt_for_scheme, split_chunks};
|
use super::crypt::{decode_sequence, salt_for_scheme, split_chunks};
|
||||||
use noxis_core::Pass;
|
use noxis_core::Pass;
|
||||||
|
|
||||||
fn sample() -> Program {
|
pub(super) fn sample() -> Program {
|
||||||
let code = Code {
|
let code = Code {
|
||||||
max_stack: 1,
|
max_stack: 1,
|
||||||
max_locals: 0,
|
max_locals: 0,
|
||||||
|
|
@ -31,6 +31,11 @@ use kdf::{Gen, c_int, c_long, c_str, call, load, store};
|
||||||
pub const DECODER_METHOD: &str = "d";
|
pub const DECODER_METHOD: &str = "d";
|
||||||
/// Дескриптор метода декодера.
|
/// Дескриптор метода декодера.
|
||||||
pub const DECODER_DESC: &str = "(Ljava/lang/String;I)Ljava/lang/String;";
|
pub const DECODER_DESC: &str = "(Ljava/lang/String;I)Ljava/lang/String;";
|
||||||
|
/// Имя bootstrap-метода для `CONSTANT_Dynamic` (condy-строки).
|
||||||
|
pub const BOOTSTRAP_METHOD: &str = "b";
|
||||||
|
/// Дескриптор bootstrap-метода: стандартный префикс BSM
|
||||||
|
/// (`Lookup`, `name`, `type`) плюс наши аргументы `cipher` и `salt`.
|
||||||
|
pub const BOOTSTRAP_DESC: &str = "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;I)Ljava/lang/String;";
|
||||||
|
|
||||||
/// Ошибка генерации рантайма.
|
/// Ошибка генерации рантайма.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|
@ -78,9 +83,38 @@ pub fn decoder_class(
|
||||||
major: 49,
|
major: 49,
|
||||||
})
|
})
|
||||||
.method_with_code(0x0009, DECODER_METHOD, DECODER_DESC, code)
|
.method_with_code(0x0009, DECODER_METHOD, DECODER_DESC, code)
|
||||||
|
.method_with_code(
|
||||||
|
0x0009,
|
||||||
|
BOOTSTRAP_METHOD,
|
||||||
|
BOOTSTRAP_DESC,
|
||||||
|
bootstrap_code(internal_name),
|
||||||
|
)
|
||||||
.build())
|
.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Тело bootstrap-метода: `b(lookup, name, type, cipher, salt) = d(cipher, salt)`.
|
||||||
|
///
|
||||||
|
/// JVM вызывает его сама при первом `ldc` condy-константы и кеширует
|
||||||
|
/// результат - метод должен быть `public static` и возвращать значение
|
||||||
|
/// дескриптора константы (`Ljava/lang/String;`).
|
||||||
|
fn bootstrap_code(owner: &str) -> Code {
|
||||||
|
Code {
|
||||||
|
max_stack: 2,
|
||||||
|
// Слоты 0-2 (lookup/name/type) заняты стандартным префиксом BSM,
|
||||||
|
// cipher и salt живут в 3 и 4.
|
||||||
|
max_locals: 5,
|
||||||
|
insns: vec![
|
||||||
|
load(3, SlotKind::Ref),
|
||||||
|
load(4, SlotKind::Int),
|
||||||
|
call(owner, DECODER_METHOD, DECODER_DESC, InvokeKind::Static),
|
||||||
|
Insn::AReturn,
|
||||||
|
],
|
||||||
|
exception_table: Vec::new(),
|
||||||
|
frames: Vec::new(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Имя класса-декодера для прогона: детерминированно от сида, но выглядит
|
/// Имя класса-декодера для прогона: детерминированно от сида, но выглядит
|
||||||
/// как обычный обфусцированный класс.
|
/// как обычный обфусцированный класс.
|
||||||
pub fn decoder_owner(seed_name: &str) -> String {
|
pub fn decoder_owner(seed_name: &str) -> String {
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,7 @@ pub mod codec;
|
||||||
pub mod decoder;
|
pub mod decoder;
|
||||||
pub mod scheme;
|
pub mod scheme;
|
||||||
|
|
||||||
pub use decoder::{DECODER_DESC, DECODER_METHOD, decoder_class, decoder_owner};
|
pub use decoder::{
|
||||||
|
BOOTSTRAP_DESC, BOOTSTRAP_METHOD, DECODER_DESC, DECODER_METHOD, decoder_class, decoder_owner,
|
||||||
|
};
|
||||||
pub use scheme::{Blob, Scheme, decrypt, encrypt};
|
pub use scheme::{Blob, Scheme, decrypt, encrypt};
|
||||||
|
|
|
||||||
132
crates/noxis-runtime/tests/exec_condy.rs
Normal file
132
crates/noxis-runtime/tests/exec_condy.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
//! Condy-приёмка: шифруем в Rust, расшифровывает настоящая JVM через
|
||||||
|
//! bootstrap-метод декодера (`CONSTANT_Dynamic`).
|
||||||
|
//!
|
||||||
|
//! Это приёмка приоритета 1 из todo.md (2026-10-14): sandbox-деобфускатор
|
||||||
|
//! (narumii/SSVM) не резолвит condy вообще - 0 изменений на condy-классе,
|
||||||
|
//! тогда как прямой вызов декодера он исполняет и снимает строку. Метод `b`
|
||||||
|
//! живёт в декодере и вызывается самой JVM в момент `ldc`.
|
||||||
|
|
||||||
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use noxis_classfile::{Attribute, BootstrapMethod, ClassBuilder, Code, Const, Insn, format};
|
||||||
|
use noxis_runtime::{BOOTSTRAP_DESC, BOOTSTRAP_METHOD, Scheme, decoder_class, encrypt};
|
||||||
|
|
||||||
|
const MASTER: u64 = 0x1234_5678_9ABC_DEF0;
|
||||||
|
const DECODER: &str = "test/CondDecoder";
|
||||||
|
const CALLER: &str = "test/CondCall";
|
||||||
|
|
||||||
|
/// Строки обоих схем - тот же набор, что у прямого вызова.
|
||||||
|
fn samples() -> Vec<(String, Scheme)> {
|
||||||
|
vec![
|
||||||
|
("hi".to_string(), Scheme::XorSplitmix),
|
||||||
|
("Hello, мир!".to_string(), Scheme::XorSplitmix),
|
||||||
|
("x".repeat(100), Scheme::XorSplitmix),
|
||||||
|
("hi".to_string(), Scheme::XteaCtr),
|
||||||
|
("Hello, мир!".to_string(), Scheme::XteaCtr),
|
||||||
|
("x".repeat(100), Scheme::XteaCtr),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Класс-вызыватель condy-режима: `main` печатает `ldc CONSTANT_Dynamic`.
|
||||||
|
///
|
||||||
|
/// Ровно то, что эмитит пасс `strings` при `condy = true`: по одной
|
||||||
|
/// bootstrap-записи на чанк (cipher + salt как статические аргументы),
|
||||||
|
/// константа со стандартным дескриптором `Ljava/lang/String;`.
|
||||||
|
fn condy_caller_class(parts: &[(String, i32)]) -> noxis_classfile::ClassFile {
|
||||||
|
let mut insns = Vec::new();
|
||||||
|
let mut bootstrap: Vec<BootstrapMethod> = Vec::new();
|
||||||
|
for (cipher, salt) in parts {
|
||||||
|
bootstrap.push(BootstrapMethod {
|
||||||
|
kind: 6, // REF_invokeStatic
|
||||||
|
owner: DECODER.to_string(),
|
||||||
|
name: BOOTSTRAP_METHOD.to_string(),
|
||||||
|
descriptor: BOOTSTRAP_DESC.to_string(),
|
||||||
|
itf: false,
|
||||||
|
args: vec![Const::String(cipher.clone()), Const::Int(*salt)],
|
||||||
|
});
|
||||||
|
insns.push(Insn::GetStatic {
|
||||||
|
owner: "java/lang/System".to_string(),
|
||||||
|
name: "out".to_string(),
|
||||||
|
descriptor: "Ljava/io/PrintStream;".to_string(),
|
||||||
|
});
|
||||||
|
insns.push(Insn::Const(Const::Dynamic {
|
||||||
|
name: "_".to_string(),
|
||||||
|
descriptor: "Ljava/lang/String;".to_string(),
|
||||||
|
bootstrap: (bootstrap.len() - 1) as u16,
|
||||||
|
}));
|
||||||
|
insns.push(Insn::Invoke {
|
||||||
|
kind: noxis_classfile::InvokeKind::Virtual,
|
||||||
|
owner: "java/io/PrintStream".to_string(),
|
||||||
|
name: "println".to_string(),
|
||||||
|
descriptor: "(Ljava/lang/String;)V".to_string(),
|
||||||
|
itf: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
insns.push(Insn::Return(None));
|
||||||
|
let code = Code {
|
||||||
|
max_stack: 2,
|
||||||
|
max_locals: 1,
|
||||||
|
insns,
|
||||||
|
exception_table: Vec::new(),
|
||||||
|
frames: Vec::new(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
};
|
||||||
|
ClassBuilder::new(CALLER, None)
|
||||||
|
.attribute(Attribute::BootstrapMethods(bootstrap))
|
||||||
|
.method_with_code(0x0009, "main", "([Ljava/lang/String;)V", code)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condy_bootstrap_executes_under_java() {
|
||||||
|
if std::process::Command::new("java")
|
||||||
|
.arg("-version")
|
||||||
|
.output()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("SKIP: нет java");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for (index, (text, scheme)) in samples().iter().enumerate() {
|
||||||
|
let salt = (index as i32)
|
||||||
|
.saturating_mul(2)
|
||||||
|
.saturating_add(match scheme {
|
||||||
|
Scheme::XorSplitmix => 0,
|
||||||
|
Scheme::XteaCtr => 1,
|
||||||
|
});
|
||||||
|
let blob = encrypt(*scheme, MASTER, salt, text);
|
||||||
|
parts.push((blob.cipher, salt));
|
||||||
|
expected.push(text.to_string());
|
||||||
|
}
|
||||||
|
let decoder = decoder_class(DECODER, MASTER, &["xor-splitmix", "xtea-ctr"]).expect("decoder");
|
||||||
|
let caller = condy_caller_class(&parts);
|
||||||
|
|
||||||
|
let dir: PathBuf = std::env::temp_dir().join(format!("noxis-condy-{}", std::process::id()));
|
||||||
|
for (name, class) in [(DECODER, &decoder), (CALLER, &caller)] {
|
||||||
|
let mut path = dir.clone();
|
||||||
|
for part in name.split('/') {
|
||||||
|
path.push(part);
|
||||||
|
}
|
||||||
|
path.set_extension("class");
|
||||||
|
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
||||||
|
std::fs::write(&path, format::write(class).expect("write")).expect("write class");
|
||||||
|
}
|
||||||
|
let out = std::process::Command::new("java")
|
||||||
|
.arg("-Xverify:all")
|
||||||
|
.arg("-cp")
|
||||||
|
.arg(&dir)
|
||||||
|
.arg(CALLER.replace('/', "."))
|
||||||
|
.output()
|
||||||
|
.expect("запуск java");
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
assert!(out.status.success(), "java упал:\n{stderr}\n{stdout}");
|
||||||
|
let lines: Vec<&str> = stdout.lines().collect();
|
||||||
|
let want: Vec<&str> = expected.iter().map(String::as_str).collect();
|
||||||
|
assert_eq!(lines, want, "condy-расшифровка не сошлась");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue