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
|
|
@ -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_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)]
|
||||
|
|
@ -78,9 +83,38 @@ pub fn decoder_class(
|
|||
major: 49,
|
||||
})
|
||||
.method_with_code(0x0009, DECODER_METHOD, DECODER_DESC, code)
|
||||
.method_with_code(
|
||||
0x0009,
|
||||
BOOTSTRAP_METHOD,
|
||||
BOOTSTRAP_DESC,
|
||||
bootstrap_code(internal_name),
|
||||
)
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -6,5 +6,7 @@ pub mod codec;
|
|||
pub mod decoder;
|
||||
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};
|
||||
|
|
|
|||
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