analysis: lock DTO fields passed as X.class to databind calls
Gson/Jackson/fastjson2/Moshi bind fields by reflection from a Class literal, with no field-name string for the string-based detection: renaming such a field silently breaks deserialization. Absorb_databind finds the nearest Const::Class before fromJson/readValue/parseObject/ adapter calls (10-insn window, control-flow boundaries stop the search) and keeps every field of that class; the class itself still renames. Tests: noxis-analysis/tests/dto.rs (red first), keep unit tests moved to tests/keep.rs (file budget). Code comments migrated to English.
This commit is contained in:
parent
139aea9a8f
commit
c3acc0baf4
3 changed files with 215 additions and 61 deletions
|
|
@ -1,12 +1,14 @@
|
||||||
//! Keep-правила: что нельзя переименовывать (glob-паттерны + entrypoint'ы).
|
//! Keep rules: what must not be renamed (glob patterns + entrypoints).
|
||||||
|
|
||||||
use noxis_classfile::{Attribute, Const, Program};
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use noxis_classfile::{Attribute, Const, Insn, Program};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use crate::entrypoints::Entrypoints;
|
use crate::entrypoints::Entrypoints;
|
||||||
use noxis_core::Config;
|
use noxis_core::Config;
|
||||||
|
|
||||||
/// Скомпилированный glob: `*` - любые символы кроме `/`, `**` - всё, `?` - один.
|
/// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char.
|
||||||
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
||||||
let mut out = String::from("^");
|
let mut out = String::from("^");
|
||||||
let chars: Vec<char> = pattern.chars().collect();
|
let chars: Vec<char> = pattern.chars().collect();
|
||||||
|
|
@ -36,19 +38,41 @@ fn compile(pattern: &str) -> Result<Regex, regex::Error> {
|
||||||
Regex::new(&out)
|
Regex::new(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Правила «не трогать»: явные паттерны конфига + entrypoint'ы.
|
/// Databinding entry points: `(owner, method names)` taking a `Class` literal.
|
||||||
|
/// Gson, Jackson, fastjson2 and Moshi bind DTO fields by reflection from
|
||||||
|
/// `X.class`, with no field-name string for the string-based detection.
|
||||||
|
const DATABIND_CALLS: [(&str, &[&str]); 5] = [
|
||||||
|
("com/google/gson/Gson", &["fromJson"]),
|
||||||
|
(
|
||||||
|
"com/fasterxml/jackson/databind/ObjectMapper",
|
||||||
|
&["readValue", "readValues", "convertValue"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"com/fasterxml/jackson/databind/ObjectReader",
|
||||||
|
&["readValue", "readValues"],
|
||||||
|
),
|
||||||
|
("com/alibaba/fastjson2/JSON", &["parseObject"]),
|
||||||
|
("com/squareup/moshi/Moshi", &["adapter"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How far back an `X.class` literal may sit before the databind call.
|
||||||
|
/// Argument setup is straight-line: a few loads between the literal and the
|
||||||
|
/// call at most.
|
||||||
|
const CLASS_LITERAL_WINDOW: usize = 10;
|
||||||
|
|
||||||
|
/// "Do not touch" rules: explicit config patterns + entrypoints.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct KeepRules {
|
pub struct KeepRules {
|
||||||
class_patterns: Vec<Regex>,
|
class_patterns: Vec<Regex>,
|
||||||
resource_patterns: Vec<Regex>,
|
resource_patterns: Vec<Regex>,
|
||||||
/// Точные имена, которые нельзя трогать (entrypoint'ы, рефлексия, ...).
|
/// Exact names to keep (entrypoints, reflection, ...).
|
||||||
fixed: std::collections::BTreeSet<String>,
|
fixed: BTreeSet<String>,
|
||||||
/// Имена членов, защищённые рефлексией.
|
/// Member names protected by reflection.
|
||||||
fixed_members: std::collections::BTreeSet<String>,
|
fixed_members: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeepRules {
|
impl KeepRules {
|
||||||
/// Собрать правила из конфига.
|
/// Build the rules from the config.
|
||||||
pub fn from_config(config: &Config) -> Result<Self, RuleError> {
|
pub fn from_config(config: &Config) -> Result<Self, RuleError> {
|
||||||
let mut class_patterns = Vec::with_capacity(config.keep.len());
|
let mut class_patterns = Vec::with_capacity(config.keep.len());
|
||||||
for pattern in &config.keep {
|
for pattern in &config.keep {
|
||||||
|
|
@ -67,22 +91,22 @@ impl KeepRules {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
class_patterns,
|
class_patterns,
|
||||||
resource_patterns,
|
resource_patterns,
|
||||||
fixed: std::collections::BTreeSet::new(),
|
fixed: BTreeSet::new(),
|
||||||
fixed_members: std::collections::BTreeSet::new(),
|
fixed_members: BTreeSet::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Запретить переименование члена с таким именем (в любом классе).
|
/// Forbid renaming a member with this name (in any class).
|
||||||
pub fn keep_member(&mut self, name: impl Into<String>) {
|
pub fn keep_member(&mut self, name: impl Into<String>) {
|
||||||
self.fixed_members.insert(name.into());
|
self.fixed_members.insert(name.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Добавить точное имя класса (из анализа программы).
|
/// Add an exact class name (from program analysis).
|
||||||
pub fn keep_exact(&mut self, internal_name: impl Into<String>) {
|
pub fn keep_exact(&mut self, internal_name: impl Into<String>) {
|
||||||
self.fixed.insert(internal_name.into());
|
self.fixed.insert(internal_name.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Объединить с найденными entrypoint'ами.
|
/// Absorb found entrypoints.
|
||||||
pub fn absorb(&mut self, entrypoints: &Entrypoints) {
|
pub fn absorb(&mut self, entrypoints: &Entrypoints) {
|
||||||
for name in &entrypoints.main_classes {
|
for name in &entrypoints.main_classes {
|
||||||
self.keep_exact(name.clone());
|
self.keep_exact(name.clone());
|
||||||
|
|
@ -104,10 +128,10 @@ impl KeepRules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Методы, на которые ссылаются `BootstrapMethods` (`invokedynamic`).
|
/// Methods referenced by `BootstrapMethods` (`invokedynamic`).
|
||||||
///
|
///
|
||||||
/// Переименование такого метода без правки bootstrap-записи ломает класс,
|
/// Renaming such a method without patching the bootstrap record breaks
|
||||||
/// а правка - задача P2 (см. `todo.md`): пока просто не трогаем имена.
|
/// the class, and patching is a P2 task (see `todo.md`): keep the names.
|
||||||
pub fn absorb_bootstrap(&mut self, program: &Program) {
|
pub fn absorb_bootstrap(&mut self, program: &Program) {
|
||||||
for class in &program.classes {
|
for class in &program.classes {
|
||||||
for method in &class.methods {
|
for method in &class.methods {
|
||||||
|
|
@ -127,7 +151,36 @@ impl KeepRules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Нужно ли оставить класс с данным внутренним именем.
|
/// Lock every field of every class passed as an `X.class` literal to a
|
||||||
|
/// databinding call. Gson/Jackson bind fields by reflection from the
|
||||||
|
/// literal; renaming them silently breaks deserialization, while the
|
||||||
|
/// class itself (never named in JSON) still renames freely.
|
||||||
|
pub fn absorb_databind(&mut self, program: &Program) {
|
||||||
|
for class in &program.classes {
|
||||||
|
for method in &class.methods {
|
||||||
|
let Some(code) = method.code.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (index, insn) in code.insns.iter().enumerate() {
|
||||||
|
let Insn::Invoke { owner, name, .. } = insn else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_databind_call(owner, name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(dto) = nearest_class_literal(&code.insns[..index])
|
||||||
|
&& let Some(dto_class) = program.class(&dto)
|
||||||
|
{
|
||||||
|
for field in &dto_class.fields {
|
||||||
|
self.keep_member(field.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the class with this internal name stays.
|
||||||
pub fn keeps_class(&self, internal_name: &str) -> bool {
|
pub fn keeps_class(&self, internal_name: &str) -> bool {
|
||||||
self.fixed.contains(internal_name)
|
self.fixed.contains(internal_name)
|
||||||
|| self
|
|| self
|
||||||
|
|
@ -136,17 +189,17 @@ impl KeepRules {
|
||||||
.any(|re| re.is_match(internal_name))
|
.any(|re| re.is_match(internal_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Нужно ли оставить член с таким именем (не переименовывать).
|
/// Whether a member with this name stays (is not renamed).
|
||||||
pub fn keeps_member(&self, name: &str) -> bool {
|
pub fn keeps_member(&self, name: &str) -> bool {
|
||||||
self.fixed_members.contains(name)
|
self.fixed_members.contains(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Нужно ли оставить ресурс с данным путём.
|
/// Whether the resource at this path stays unrewritten.
|
||||||
pub fn keeps_resource(&self, path: &str) -> bool {
|
pub fn keeps_resource(&self, path: &str) -> bool {
|
||||||
self.resource_patterns.iter().any(|re| re.is_match(path))
|
self.resource_patterns.iter().any(|re| re.is_match(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Число правил (для отчёта).
|
/// Rule count (for the report).
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.class_patterns.len()
|
self.class_patterns.len()
|
||||||
+ self.resource_patterns.len()
|
+ self.resource_patterns.len()
|
||||||
|
|
@ -154,35 +207,65 @@ impl KeepRules {
|
||||||
+ self.fixed_members.len()
|
+ self.fixed_members.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Есть ли вообще правила.
|
/// Whether there are any rules at all.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.len() == 0
|
self.len() == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ошибка компиляции паттерна.
|
/// Whether an invoke is a databinding call taking a `Class` literal.
|
||||||
|
fn is_databind_call(owner: &str, name: &str) -> bool {
|
||||||
|
DATABIND_CALLS
|
||||||
|
.iter()
|
||||||
|
.any(|(candidate, names)| *candidate == owner && names.contains(&name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest `X.class` literal before a call site (control-flow
|
||||||
|
/// boundaries stop the search: argument setup is straight-line).
|
||||||
|
fn nearest_class_literal(before: &[Insn]) -> Option<String> {
|
||||||
|
let start = before.len().saturating_sub(CLASS_LITERAL_WINDOW);
|
||||||
|
for insn in before[start..].iter().rev() {
|
||||||
|
match insn {
|
||||||
|
Insn::Const(Const::Class(name)) => return Some(name.clone()),
|
||||||
|
Insn::Branch(..)
|
||||||
|
| Insn::Goto(_)
|
||||||
|
| Insn::TableSwitch { .. }
|
||||||
|
| Insn::LookupSwitch { .. }
|
||||||
|
| Insn::Jsr(_)
|
||||||
|
| Insn::Ret(_)
|
||||||
|
| Insn::Return(_)
|
||||||
|
| Insn::AReturn
|
||||||
|
| Insn::AThrow => return None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pattern compilation error.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuleError {
|
pub enum RuleError {
|
||||||
#[error("некорректный паттерн `{pattern}`: {msg}")]
|
#[error("malformed pattern `{pattern}`: {msg}")]
|
||||||
Pattern { pattern: String, msg: String },
|
Pattern { pattern: String, msg: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Полный анализ перед обфускацией.
|
/// The full pre-obfuscation analysis.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Analysis {
|
pub struct Analysis {
|
||||||
pub entrypoints: Entrypoints,
|
pub entrypoints: Entrypoints,
|
||||||
pub keep: KeepRules,
|
pub keep: KeepRules,
|
||||||
/// Классы, которые не переименовываются.
|
/// Classes that are not renamed.
|
||||||
pub locked_classes: std::collections::BTreeSet<String>,
|
pub locked_classes: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Analysis {
|
impl Analysis {
|
||||||
/// Прогнать весь анализ по программе.
|
/// Run the whole analysis over the program.
|
||||||
pub fn run(program: &Program, config: &Config) -> Result<Self, RuleError> {
|
pub fn run(program: &Program, config: &Config) -> Result<Self, RuleError> {
|
||||||
let entrypoints = Entrypoints::collect(program);
|
let entrypoints = Entrypoints::collect(program);
|
||||||
let mut keep = KeepRules::from_config(config)?;
|
let mut keep = KeepRules::from_config(config)?;
|
||||||
keep.absorb(&entrypoints);
|
keep.absorb(&entrypoints);
|
||||||
keep.absorb_bootstrap(program);
|
keep.absorb_bootstrap(program);
|
||||||
|
keep.absorb_databind(program);
|
||||||
|
|
||||||
let locked_classes = program
|
let locked_classes = program
|
||||||
.classes
|
.classes
|
||||||
|
|
@ -198,40 +281,8 @@ impl Analysis {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Класс запрещено переименовывать.
|
/// Renaming this class is forbidden.
|
||||||
pub fn is_locked(&self, internal_name: &str) -> bool {
|
pub fn is_locked(&self, internal_name: &str) -> bool {
|
||||||
self.locked_classes.contains(internal_name)
|
self.locked_classes.contains(internal_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn glob_semantics() {
|
|
||||||
let rules = KeepRules {
|
|
||||||
class_patterns: vec![
|
|
||||||
compile("com/example/*").unwrap(),
|
|
||||||
compile("**/internal/**").unwrap(),
|
|
||||||
],
|
|
||||||
resource_patterns: vec![compile("META-INF/services/*").unwrap()],
|
|
||||||
fixed: std::collections::BTreeSet::new(),
|
|
||||||
fixed_members: std::collections::BTreeSet::new(),
|
|
||||||
};
|
|
||||||
assert!(rules.keeps_class("com/example/Main"));
|
|
||||||
assert!(!rules.keeps_class("com/example/sub/Main"));
|
|
||||||
assert!(rules.keeps_class("x/internal/y/Z"));
|
|
||||||
assert!(rules.keeps_resource("META-INF/services/a.b.C"));
|
|
||||||
assert!(!rules.keeps_resource("assets/mc/mod.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn exact_names_win() {
|
|
||||||
let mut rules = KeepRules::default();
|
|
||||||
rules.keep_exact("app/Main");
|
|
||||||
assert!(rules.keeps_class("app/Main"));
|
|
||||||
assert!(!rules.keeps_class("app/Other"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
70
crates/noxis-analysis/tests/dto.rs
Normal file
70
crates/noxis-analysis/tests/dto.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
//! Databinding keep: `X.class` passed to Gson/Jackson locks the fields of `X`.
|
||||||
|
//!
|
||||||
|
//! JSON libraries bind fields by reflection from a `Class` literal, with no
|
||||||
|
//! field-name string in the bytecode for the existing string-based detection
|
||||||
|
//! to catch. Renaming such a field silently breaks deserialization, so every
|
||||||
|
//! field of the literal's class stays.
|
||||||
|
|
||||||
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use noxis_analysis::Analysis;
|
||||||
|
use noxis_classfile::{ClassBuilder, Code, Const, Field, Insn, InvokeKind, Program, Version};
|
||||||
|
use noxis_core::Config;
|
||||||
|
|
||||||
|
/// `gson.fromJson(reader, State.class)`: `State` has a private field.
|
||||||
|
fn sample() -> Program {
|
||||||
|
let caller = Code {
|
||||||
|
max_stack: 3,
|
||||||
|
max_locals: 2,
|
||||||
|
insns: vec![
|
||||||
|
Insn::Load {
|
||||||
|
slot: 1,
|
||||||
|
kind: noxis_classfile::SlotKind::Ref,
|
||||||
|
},
|
||||||
|
Insn::Const(Const::Class("demo/State".to_string())),
|
||||||
|
Insn::Invoke {
|
||||||
|
kind: InvokeKind::Virtual,
|
||||||
|
owner: "com/google/gson/Gson".to_string(),
|
||||||
|
name: "fromJson".to_string(),
|
||||||
|
descriptor: "(Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object;".to_string(),
|
||||||
|
itf: false,
|
||||||
|
},
|
||||||
|
Insn::Pop,
|
||||||
|
Insn::Return(None),
|
||||||
|
],
|
||||||
|
exception_table: Vec::new(),
|
||||||
|
frames: Vec::new(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut state = ClassBuilder::new("demo/State", Some("java/lang/Object"))
|
||||||
|
.version(Version::JAVA_21)
|
||||||
|
.build();
|
||||||
|
state.fields.push(Field {
|
||||||
|
access_flags: 0x0002,
|
||||||
|
name: "nickname".to_string(),
|
||||||
|
descriptor: "Ljava/lang/String;".to_string(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
});
|
||||||
|
let app = ClassBuilder::new("demo/App", Some("java/lang/Object"))
|
||||||
|
.version(Version::JAVA_21)
|
||||||
|
.method_with_code(0x0009, "load", "()V", caller)
|
||||||
|
.build();
|
||||||
|
Program {
|
||||||
|
classes: vec![state, app],
|
||||||
|
resources: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn databind_class_literal_locks_fields() {
|
||||||
|
let program = sample();
|
||||||
|
let analysis = Analysis::run(&program, &Config::default()).expect("analysis");
|
||||||
|
assert!(
|
||||||
|
analysis.keep.keeps_member("nickname"),
|
||||||
|
"DTO field must survive renaming"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!analysis.keep.keeps_member("load"),
|
||||||
|
"unrelated members must still rename"
|
||||||
|
);
|
||||||
|
}
|
||||||
33
crates/noxis-analysis/tests/keep.rs
Normal file
33
crates/noxis-analysis/tests/keep.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! Keep-rule unit tests: globs and exact names.
|
||||||
|
|
||||||
|
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use noxis_analysis::KeepRules;
|
||||||
|
use noxis_core::Config;
|
||||||
|
|
||||||
|
fn rules_with_patterns() -> KeepRules {
|
||||||
|
KeepRules::from_config(&Config {
|
||||||
|
keep: vec!["com/example/*".to_string(), "**/internal/**".to_string()],
|
||||||
|
keep_resources: vec!["META-INF/services/*".to_string()],
|
||||||
|
..Config::default()
|
||||||
|
})
|
||||||
|
.expect("patterns")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glob_semantics() {
|
||||||
|
let rules = rules_with_patterns();
|
||||||
|
assert!(rules.keeps_class("com/example/Main"));
|
||||||
|
assert!(!rules.keeps_class("com/example/sub/Main"));
|
||||||
|
assert!(rules.keeps_class("x/internal/y/Z"));
|
||||||
|
assert!(rules.keeps_resource("META-INF/services/a.b.C"));
|
||||||
|
assert!(!rules.keeps_resource("assets/mc/mod.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_names_win() {
|
||||||
|
let mut rules = KeepRules::default();
|
||||||
|
rules.keep_exact("app/Main");
|
||||||
|
assert!(rules.keeps_class("app/Main"));
|
||||||
|
assert!(!rules.keeps_class("app/Other"));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue