passes: emit strings as CONSTANT_Dynamic (condy) - BSM-resolved, invisible to concrete-execution deobfuscators

This commit is contained in:
Loki 2026-10-15 13:57:19 +02:00
parent 1ddc4afbfc
commit aac7571bbc
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 517 additions and 5 deletions

View 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,
}
}

View file

@ -4,6 +4,7 @@
//! (KDF or RNG stream - see `derive_key`). The decoder is generated once per
//! program and only if at least one string was encrypted.
mod condy;
mod crypt;
#[cfg(test)]
@ -12,7 +13,9 @@ mod tests;
use std::collections::BTreeMap;
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_runtime::{Scheme, decoder_class, decoder_owner, encrypt};
@ -69,6 +72,17 @@ impl Pass for StringsPass {
let owner = decoder_name(ctx, program);
let mut chunk_counter = 0usize;
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();
for (ci, mi, ii, text) in &targets {
let mut parts = Vec::new();
@ -80,7 +94,19 @@ impl Pass for StringsPass {
parts.push((blob.cipher, salt));
chunk_counter += 1;
}
replacements.insert((*ci, *mi, *ii), decode_sequence(&owner, &parts));
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));
}
}
let mut mutated: Vec<(usize, usize)> = Vec::new();
let mut applied = 0usize;
@ -126,6 +152,21 @@ impl Pass for StringsPass {
decoder_class(&owner, master, &scheme_names).map_err(|e| pass_error(e.to_string()))?;
program.classes.push(decoder);
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.
let hierarchy = ClassHierarchy::build(program);
let frames_hierarchy = JdkHierarchy::wrap(&hierarchy);

View 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"
);
}

View file

@ -2,6 +2,8 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
mod condy;
use noxis_classfile::{ClassBuilder, Code, Const, Insn, MAX_METHOD_INSNS, Program, Version};
use noxis_core::{Config, Context};
use noxis_runtime::{Scheme, encrypt};
@ -10,7 +12,7 @@ use super::StringsPass;
use super::crypt::{decode_sequence, salt_for_scheme, split_chunks};
use noxis_core::Pass;
fn sample() -> Program {
pub(super) fn sample() -> Program {
let code = Code {
max_stack: 1,
max_locals: 0,