From a4450b69bae00111ac17d80a0359333c7b78c635 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 15 Oct 2026 12:53:22 +0200 Subject: [PATCH] classfile: fix condy ldc category (ldc_w vs ldc2_w by descriptor), split attrs tests --- .../src/format/write/code/ops.rs | 14 +- .../tests/roundtrip/attrs/condy.rs | 154 ++++++++++++++++++ .../roundtrip/{attrs.rs => attrs/mod.rs} | 2 + 3 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 crates/noxis-classfile/tests/roundtrip/attrs/condy.rs rename crates/noxis-classfile/tests/roundtrip/{attrs.rs => attrs/mod.rs} (99%) diff --git a/crates/noxis-classfile/src/format/write/code/ops.rs b/crates/noxis-classfile/src/format/write/code/ops.rs index f8566c6..e2c5621 100644 --- a/crates/noxis-classfile/src/format/write/code/ops.rs +++ b/crates/noxis-classfile/src/format/write/code/ops.rs @@ -31,11 +31,23 @@ pub(super) fn int_const(value: &Const, pool: &mut Pool) -> Result RtInsn::Dconst_0, Const::Double(v) if *v == 1.0 => RtInsn::Dconst_1, Const::Double(_) => RtInsn::Ldc2_w(pool.constant(value)?), + // JVMS §4.4.10: a `CONSTANT_Dynamic`'s category comes from its own + // descriptor, not from being `Dynamic` - `J`/`D` are category 2 + // (`ldc2_w`), everything else (references, `I`/`F`/...) is + // category 1 (`ldc_w`). Using `ldc2_w` unconditionally here was a + // bug: a condy-encrypted `String` would push the wrong stack shape. Const::Dynamic { name, descriptor, bootstrap, - } => RtInsn::Ldc2_w(pool.dynamic(*bootstrap, name, descriptor)?), + } => { + let index = pool.dynamic(*bootstrap, name, descriptor)?; + if matches!(descriptor.as_str(), "J" | "D") { + RtInsn::Ldc2_w(index) + } else { + RtInsn::Ldc_w(index) + } + } _ => RtInsn::Ldc_w(pool.constant(value)?), }) } diff --git a/crates/noxis-classfile/tests/roundtrip/attrs/condy.rs b/crates/noxis-classfile/tests/roundtrip/attrs/condy.rs new file mode 100644 index 0000000..5c8341b --- /dev/null +++ b/crates/noxis-classfile/tests/roundtrip/attrs/condy.rs @@ -0,0 +1,154 @@ +//! `CONSTANT_Dynamic` (condy) round trips: the writer must be able to emit a +//! brand-new `ldc` on a `Const::Dynamic`, not just pass one through on read. +//! +//! Why this matters: a generic sandbox-based deobfuscator (narumii/SSVM) +//! confirmed empirically (`todo.md`, 2026-10-14) resolves plain arithmetic +//! and runtime-decrypt calls, but does not resolve `invokedynamic`/condy at +//! all - 0 changes on a condy-only test class. Strings/Constants moving to +//! condy is the first concrete fix that follows from that finding. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::super::round_trip; +use noxis_classfile::{ + Attribute, BootstrapMethod, ClassBuilder, Code, Const, Insn, Version, format, +}; +use ristretto_classfile::attributes::{ + Attribute as RtAttr, BootstrapMethod as RtBsm, Instruction as Rt, +}; +use ristretto_classfile::{ + ClassAccessFlags, ClassFile as RtClass, ConstantPool, JAVA_21, Method as RtMethod, + MethodAccessFlags, ReferenceKind, +}; + +/// `CONSTANT_Dynamic` (condy, `ldc`) survives the round trip - needed for +/// passes that emit strings/constants through a bootstrap method instead of +/// a plain `ldc` literal. +#[test] +fn condy_ldc_round_trip() { + let mut pool = ConstantPool::default(); + let this = pool.add_class("demo/Condy").expect("add_class"); + let object = pool.add_class("java/lang/Object").expect("add_object"); + let owner = pool.add_class("demo/RT").expect("owner"); + let method_ref = pool + .add_method_ref( + owner, + "boot", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/String;", + ) + .expect("ref"); + let handle = pool + .add_method_handle(ReferenceKind::InvokeStatic, method_ref) + .expect("handle"); + let bsm_name = pool.add_utf8("BootstrapMethods").expect("attr"); + let name = pool.add_utf8("run").expect("name"); + let desc = pool.add_utf8("()Ljava/lang/String;").expect("desc"); + let code_name = pool.add_utf8("Code").expect("attr"); + let dynamic = pool + .add_dynamic(0, "_", "Ljava/lang/String;") + .expect("condy"); + + let rt = RtClass { + version: JAVA_21, + constant_pool: pool, + access_flags: ClassAccessFlags::PUBLIC, + this_class: this, + super_class: object, + interfaces: vec![], + fields: vec![], + methods: vec![RtMethod { + access_flags: MethodAccessFlags::PUBLIC | MethodAccessFlags::STATIC, + name_index: name, + descriptor_index: desc, + attributes: vec![RtAttr::Code { + name_index: code_name, + max_stack: 1, + max_locals: 0, + code: vec![Rt::Ldc_w(dynamic), Rt::Areturn], + exception_table: vec![], + attributes: vec![], + }], + }], + attributes: vec![RtAttr::BootstrapMethods { + name_index: bsm_name, + methods: vec![RtBsm { + bootstrap_method_ref: handle, + arguments: vec![], + }], + }], + ..RtClass::default() + }; + + let ir = round_trip(&rt); + assert_eq!(ir.attributes.len(), 1); + assert!(format!("{:?}", ir.attributes[0]).contains("BootstrapMethods")); + let code = ir.methods[0].code.as_ref().expect("code"); + assert!( + format!("{:?}", code.insns).contains("Dynamic"), + "condy ldc должен пережить round-trip как Const::Dynamic: {:?}", + code.insns + ); +} + +/// Регрессия: писатель раньше эмитил `Const::Dynamic` ВСЕГДА через +/// `ldc2_w` (категория 2 - long/double, 2 слота стека), даже когда BSM +/// возвращает `String` (категория 1). `ldc2_w` на ссылочном типе - +/// категория инструкции не совпадает с категорией значения, кадры стека +/// и верификатор на реальной JVM это не пропустят. Категория должна +/// читаться из дескриптора condy-константы (JVMS §4.4.10), не быть +/// захардкожена. +#[test] +fn condy_string_writes_ldc_w_not_ldc2_w() { + let class = ClassBuilder::new("demo/CondyOp", None) + .version(Version::JAVA_21) + .attribute(Attribute::BootstrapMethods(vec![BootstrapMethod { + kind: 6, // InvokeStatic + owner: "demo/RT".to_string(), + name: "boot".to_string(), + descriptor: "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/String;" + .to_string(), + itf: false, + args: vec![], + }])) + .method_with_code( + 0x0009, + "run", + "()Ljava/lang/String;", + Code { + max_stack: 1, + max_locals: 0, + insns: vec![ + Insn::Const(Const::Dynamic { + name: "_".to_string(), + descriptor: "Ljava/lang/String;".to_string(), + bootstrap: 0, + }), + Insn::AReturn, + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }, + ) + .build(); + + let bytes = format::write(&class).expect("write"); + let written = RtClass::from_bytes(&bytes).expect("parse back"); + let method = &written.methods[0]; + let RtAttr::Code { code, .. } = method + .attributes + .iter() + .find(|a| matches!(a, RtAttr::Code { .. })) + .expect("Code attribute") + else { + unreachable!() + }; + assert!( + matches!(code.first(), Some(Rt::Ldc_w(_))), + "condy String должен писаться через ldc_w (категория 1), не ldc2_w: {code:?}" + ); + assert!( + !code.iter().any(|insn| matches!(insn, Rt::Ldc2_w(_))), + "ldc2_w не должен появляться для condy-строки: {code:?}" + ); +} diff --git a/crates/noxis-classfile/tests/roundtrip/attrs.rs b/crates/noxis-classfile/tests/roundtrip/attrs/mod.rs similarity index 99% rename from crates/noxis-classfile/tests/roundtrip/attrs.rs rename to crates/noxis-classfile/tests/roundtrip/attrs/mod.rs index 2251e84..7156f13 100644 --- a/crates/noxis-classfile/tests/roundtrip/attrs.rs +++ b/crates/noxis-classfile/tests/roundtrip/attrs/mod.rs @@ -2,6 +2,8 @@ #![allow(clippy::expect_used, clippy::unwrap_used)] +mod condy; + use super::round_trip; use ristretto_classfile::attributes::{ Attribute as RtAttr, BootstrapMethod as RtBsm, Instruction as Rt,