use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::{json, Value};
use droidsaw_dex::DexFile;
use droidsaw_dex::decode::parse_class_data;
use droidsaw_dex::xrefs::{method_key_for_idx, MethodKey, Xrefs};
use crate::context::CrossLayerContext;
use super::meta;
const ACCESS_FLAG_NATIVE: u32 = 0x100;
const ACCESS_FLAG_ABSTRACT: u32 = 0x400;
#[derive(Serialize)]
struct FridaHook {
layer: String,
function: String,
function_id: u32,
matched_string: String,
hook: String,
}
pub fn proto_to_overload(proto: &str) -> &str {
let Some(rest) = proto.strip_prefix('(') else {
return "";
};
let Some(end) = rest.find(')') else {
return "";
};
rest.get(..end).unwrap_or("")
}
pub fn smali_split_args(overload_sig: &str) -> Vec<&str> {
let bytes = overload_sig.as_bytes();
let mut out: Vec<&str> = Vec::new();
let mut i: usize = 0;
while let Some(&b) = bytes.get(i) {
let start = i;
while let Some(&c) = bytes.get(i) {
if c == b'[' {
i = i.saturating_add(1);
continue;
}
break;
}
match bytes.get(i).copied() {
Some(b'L') => {
i = i.saturating_add(1);
while let Some(&c) = bytes.get(i) {
i = i.saturating_add(1);
if c == b';' {
break;
}
}
}
Some(_) => {
i = i.saturating_add(1);
}
None => break,
}
if let Some(slice) = overload_sig.get(start..i) {
out.push(slice);
}
let _ = b; }
out
}
pub fn smali_field_to_dotted(field: &str) -> String {
let mut depth: usize = 0;
let mut rest = field;
while let Some(stripped) = rest.strip_prefix('[') {
depth = depth.saturating_add(1);
rest = stripped;
}
let prefix: String = "[".repeat(depth);
let body = match rest {
"B" => "byte".to_string(),
"C" => "char".to_string(),
"D" => "double".to_string(),
"F" => "float".to_string(),
"I" => "int".to_string(),
"J" => "long".to_string(),
"S" => "short".to_string(),
"V" => "void".to_string(),
"Z" => "boolean".to_string(),
ref_field if ref_field.starts_with('L') && ref_field.ends_with(';') => {
let inner = ref_field
.strip_prefix('L')
.and_then(|s| s.strip_suffix(';'))
.unwrap_or("");
if inner.is_empty() {
ref_field.to_string()
} else {
let dotted = inner.replace('/', ".");
if depth == 0 {
dotted
} else {
format!("L{dotted};")
}
}
}
other => other.to_string(),
};
if depth > 0 {
match rest {
"B" | "C" | "D" | "F" | "I" | "J" | "S" | "V" | "Z" => format!("{prefix}{rest}"),
_ => format!("{prefix}{body}"),
}
} else {
body
}
}
fn js_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn emit_class_body(class_methods: &[(&MethodKey, Option<u32>)]) -> String {
let mut body = String::new();
for (mk, flags) in class_methods {
let flags = match flags {
Some(f) => *f,
None => {
body.push_str(&format!(
" // method {name}: access_flags unknown (parse_class_data failed for owning class); hook manually after verifying it is concrete (not native/abstract).\n",
name = mk.name,
));
continue;
}
};
if flags & ACCESS_FLAG_NATIVE != 0 {
body.push_str(&format!(
" // native method {name}: hook via Interceptor.attach at the JNI symbol (Java.use overload is unreliable on native bodies).\n",
name = mk.name,
));
continue;
}
if flags & ACCESS_FLAG_ABSTRACT != 0 {
body.push_str(&format!(
" // abstract method {name}: no body to intercept (hook a concrete subclass override instead).\n",
name = mk.name,
));
continue;
}
let sig = proto_to_overload(&mk.proto);
let dotted_args: Vec<String> = smali_split_args(sig)
.into_iter()
.map(smali_field_to_dotted)
.collect();
let overload_args = dotted_args
.iter()
.map(|a| format!("'{a}'"))
.collect::<Vec<_>>()
.join(", ");
let arg_names: Vec<String> = (0..dotted_args.len()).map(|i| format!("arg{i}")).collect();
let formals = arg_names.join(", ");
let logged = if arg_names.is_empty() {
String::new()
} else {
arg_names
.iter()
.map(|a| format!("' + {a}"))
.collect::<Vec<_>>()
.join(" + ', '")
+ " + '"
};
let log_call = if arg_names.is_empty() {
format!("'{name}()'", name = mk.name)
} else {
format!("'{name}({logged})'", name = mk.name)
};
body.push_str(&format!(
" cls.{name}.overload({overload_args}).implementation = function({formals}) {{\n \
console.log({log_call});\n \
var ret = this.{name}.overload({overload_args}).apply(this, arguments);\n \
console.log('-> ' + ret);\n \
return ret;\n \
}};\n",
name = mk.name,
));
}
body
}
fn build_method_flags(dex: &DexFile, raw: &[u8]) -> BTreeMap<MethodKey, u32> {
let mut method_flags: BTreeMap<MethodKey, u32> = BTreeMap::new();
for (class_defs_idx, cd) in dex.class_defs.iter().enumerate() {
if dex.class_def_is_shadowed(class_defs_idx) {
continue;
}
if cd.class_data_off == 0 {
continue;
}
let class_data = match parse_class_data(raw, cd.class_data_off) {
Ok(c) => c,
Err(_) => continue,
};
for em in class_data
.direct_methods
.iter()
.chain(class_data.virtual_methods.iter())
{
if let Some(mk) = method_key_for_idx(dex, em.method_idx) {
method_flags.insert(mk, em.access_flags);
}
}
}
method_flags
}
#[doc(hidden)]
pub fn __fuzz_build_method_flags(dex: &DexFile, raw: &[u8]) {
let _ = build_method_flags(dex, raw);
}
#[allow(
clippy::arithmetic_side_effects,
clippy::as_conversions,
reason = "`i + 1` is usize+1 bounded by ctx.dex.len() ≤ isize::MAX."
)]
pub fn frida(ctx: &CrossLayerContext, search: &str) -> anyhow::Result<Value> {
let _drain_guard = crate::context::HermesFindingDrainGuard::install_discard();
let re = regex::Regex::new(search)?;
let mut hooks: Vec<FridaHook> = Vec::new();
if let Some(hbc_owned) = ctx.hbc.as_ref() {
let hbc = hbc_owned.hbc();
let hbc_data = hbc_owned.bytes();
let scan = droidsaw_hermes::scanner::scan_parsed(hbc, hbc_data);
for i in 0..hbc.string_count {
let value = hbc.string_as_str_or_empty(i);
if !re.is_match(&value) {
continue;
}
if let Some(func_ids) = scan.string_refs.get(&i) {
for &fid in func_ids {
if fid >= hbc.function_count {
continue;
}
let f = hbc.function_get(fid);
let fname = if f.name_id < hbc.string_count {
hbc.string_as_str_or_empty(f.name_id).into_owned()
} else {
format!("anon_{fid}")
};
let safe = js_escape(&value);
let hook = format!(
"// Hermes function {fname} (id {fid}) references '{safe}'\n\
// Offset 0x{offset:x} is HBC-blob-relative (start of the function's bytecode body inside the parsed HBC blob); it is NOT a libhermes.so address. Do not pass it to Module.findBaseAddress('libhermes.so').add(...).\n\
// Disassemble: feed the HBC blob to hermes-dec.\n\
// Live interception (RN < 0.74 / Old Architecture): hook the React Native bridge JVM layer via Java.use('com.facebook.react.bridge.CatalystInstanceImpl').callFunction.overload(...).\n\
// Live interception (RN >= 0.74 Bridgeless / New Architecture): CatalystInstanceImpl is not on the call path; JS<->native goes through JSI. Hook a TurboModule's JNI entry point or invokeFunction on the JSI runtime (no stable single Java symbol).",
offset = f.offset,
);
hooks.push(FridaHook {
layer: "hbc".to_string(),
function: fname,
function_id: fid,
matched_string: value.clone().into_owned(),
hook,
});
}
}
}
}
for (i, dex) in ctx.dex.iter().enumerate() {
let label = format!("dex{}", i + 1);
let Some(raw) = ctx.dex_bytes(i) else {
continue;
};
let xrefs = match Xrefs::build(dex, raw) {
Ok(x) => x,
Err(_) => continue,
};
let method_flags = build_method_flags(dex, raw);
for (matched_str, method_keys) in &xrefs.string_to_methods {
if !re.is_match(matched_str) {
continue;
}
let mut by_class: BTreeMap<&str, Vec<&MethodKey>> = BTreeMap::new();
for mk in method_keys {
by_class.entry(mk.class.as_ref()).or_default().push(mk);
}
for (class_desc, class_methods) in by_class {
let java_class = class_desc
.trim_start_matches('L')
.trim_end_matches(';')
.replace('/', ".");
let mut emit_inputs: Vec<(&MethodKey, Option<u32>)> =
Vec::with_capacity(class_methods.len());
for mk in &class_methods {
emit_inputs.push((mk, method_flags.get(*mk).copied()));
}
let body = emit_class_body(&emit_inputs);
let safe_match = js_escape(matched_str);
let hook = format!(
"// matched string: '{safe_match}'\n\
Java.perform(function() {{\n \
var cls = Java.use({java_class:?});\n\
{body}\
}});",
);
hooks.push(FridaHook {
layer: label.clone(),
function: java_class,
function_id: 0,
matched_string: matched_str.clone(),
hook,
});
}
}
}
let count = hooks.len();
let out = json!({
"hooks": hooks,
"_meta": meta(
count,
false,
"DEX hook lines are runnable Frida JS (paste into a file and run `frida -U -f <package> -l hooks.js`); Hermes entries are reference comments for hermes-aware tools (offsets are HBC-blob-relative, not libhermes.so-relative).",
&["xrefs", "strings", "decompile"],
),
});
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn proto_to_overload_normal_two_args() {
assert_eq!(proto_to_overload("(Ljava/lang/String;I)V"), "Ljava/lang/String;I");
}
#[test]
fn proto_to_overload_empty_params() {
assert_eq!(proto_to_overload("()V"), "");
}
#[test]
fn proto_to_overload_two_primitives() {
assert_eq!(proto_to_overload("(II)Z"), "II");
}
#[test]
fn proto_to_overload_two_references() {
assert_eq!(
proto_to_overload("(Ljava/util/List;Ljava/lang/Object;)V"),
"Ljava/util/List;Ljava/lang/Object;"
);
}
#[test]
fn proto_to_overload_array_param() {
assert_eq!(proto_to_overload("([B)V"), "[B");
assert_eq!(
proto_to_overload("([Ljava/lang/String;)V"),
"[Ljava/lang/String;"
);
}
#[test]
fn proto_to_overload_malformed_no_open_paren() {
assert_eq!(proto_to_overload("V"), "");
}
#[test]
fn proto_to_overload_malformed_no_close_paren() {
assert_eq!(proto_to_overload("(Ljava/lang/String;"), "");
}
#[test]
fn smali_split_args_zero() {
assert!(smali_split_args("").is_empty());
}
#[test]
fn smali_split_args_two_primitives() {
assert_eq!(smali_split_args("II"), vec!["I", "I"]);
}
#[test]
fn smali_split_args_mixed_reference_and_primitive() {
assert_eq!(
smali_split_args("Ljava/lang/String;I"),
vec!["Ljava/lang/String;", "I"]
);
}
#[test]
fn smali_split_args_two_references() {
assert_eq!(
smali_split_args("Ljava/util/List;Ljava/lang/Object;"),
vec!["Ljava/util/List;", "Ljava/lang/Object;"]
);
}
#[test]
fn smali_split_args_array_of_reference() {
assert_eq!(smali_split_args("[Ljava/lang/String;"), vec!["[Ljava/lang/String;"]);
}
#[test]
fn smali_split_args_array_of_primitive() {
assert_eq!(smali_split_args("[B"), vec!["[B"]);
assert_eq!(smali_split_args("[[I"), vec!["[[I"]);
}
#[test]
fn smali_split_args_long_and_double_are_one_each() {
assert_eq!(smali_split_args("JD"), vec!["J", "D"]);
}
#[test]
fn smali_split_args_complex_combination() {
assert_eq!(
smali_split_args("Ljava/lang/String;[ILjava/lang/Object;J"),
vec!["Ljava/lang/String;", "[I", "Ljava/lang/Object;", "J"]
);
}
#[test]
fn smali_field_to_dotted_primitives() {
assert_eq!(smali_field_to_dotted("B"), "byte");
assert_eq!(smali_field_to_dotted("C"), "char");
assert_eq!(smali_field_to_dotted("D"), "double");
assert_eq!(smali_field_to_dotted("F"), "float");
assert_eq!(smali_field_to_dotted("I"), "int");
assert_eq!(smali_field_to_dotted("J"), "long");
assert_eq!(smali_field_to_dotted("S"), "short");
assert_eq!(smali_field_to_dotted("V"), "void");
assert_eq!(smali_field_to_dotted("Z"), "boolean");
}
#[test]
fn smali_field_to_dotted_reference() {
assert_eq!(
smali_field_to_dotted("Ljava/lang/String;"),
"java.lang.String"
);
assert_eq!(
smali_field_to_dotted("Lcom/foo/bar/Baz;"),
"com.foo.bar.Baz"
);
}
#[test]
fn smali_field_to_dotted_array_of_primitive_keeps_jni_form() {
assert_eq!(smali_field_to_dotted("[B"), "[B");
assert_eq!(smali_field_to_dotted("[I"), "[I");
assert_eq!(smali_field_to_dotted("[[I"), "[[I");
}
#[test]
fn smali_field_to_dotted_array_of_reference_uses_dotted_inner() {
assert_eq!(
smali_field_to_dotted("[Ljava/lang/String;"),
"[Ljava.lang.String;"
);
assert_eq!(
smali_field_to_dotted("[[Ljava/lang/Object;"),
"[[Ljava.lang.Object;"
);
}
#[test]
fn smali_field_to_dotted_empty_class_name_preserves_verbatim() {
assert_eq!(smali_field_to_dotted("L;"), "L;");
assert_eq!(smali_field_to_dotted("[L;"), "[L;");
assert_eq!(smali_field_to_dotted("[[L;"), "[[L;");
}
#[test]
fn js_escape_handles_quotes_and_backslashes() {
assert_eq!(js_escape("a'b"), "a\\'b");
assert_eq!(js_escape("a\\b"), "a\\\\b");
assert_eq!(js_escape("a\nb"), "a\\nb");
}
fn mk(class: &str, name: &str, proto: &str) -> MethodKey {
MethodKey {
class: class.into(),
name: name.into(),
proto: proto.into(),
}
}
#[test]
fn emit_class_body_native_method_emits_comment_only() {
let m = mk("LFoo;", "doNative", "(I)V");
let body = emit_class_body(&[(&m, Some(ACCESS_FLAG_NATIVE))]);
assert!(
body.contains("// native method doNative:"),
"native branch must emit the named comment; got:\n{body}"
);
assert!(
body.contains("Interceptor.attach"),
"native comment must reference Interceptor.attach JNI route; got:\n{body}"
);
assert!(
!body.contains(".overload("),
"native branch must NOT emit overload codegen; got:\n{body}"
);
assert!(
!body.contains(".implementation"),
"native branch must NOT emit implementation codegen; got:\n{body}"
);
}
#[test]
fn emit_class_body_abstract_method_emits_comment_only() {
let m = mk("LFoo;", "doAbstract", "()V");
let body = emit_class_body(&[(&m, Some(ACCESS_FLAG_ABSTRACT))]);
assert!(
body.contains("// abstract method doAbstract:"),
"abstract branch must emit the named comment; got:\n{body}"
);
assert!(
body.contains("no body to intercept"),
"abstract comment must call out the no-body reason; got:\n{body}"
);
assert!(
!body.contains(".overload("),
"abstract branch must NOT emit overload codegen; got:\n{body}"
);
assert!(
!body.contains(".implementation"),
"abstract branch must NOT emit implementation codegen; got:\n{body}"
);
}
#[test]
fn emit_class_body_unknown_flags_emits_loud_comment_and_skips_binding() {
let m = mk("LFoo;", "mystery", "(I)V");
let body = emit_class_body(&[(&m, None)]);
assert!(
body.contains("// method mystery: access_flags unknown"),
"unknown-flags branch must emit a loud comment; got:\n{body}"
);
assert!(
!body.contains(".overload("),
"unknown-flags branch must NOT bind overload (could be native and throw); got:\n{body}"
);
assert!(
!body.contains(".implementation"),
"unknown-flags branch must NOT emit implementation codegen; got:\n{body}"
);
}
#[test]
fn emit_class_body_concrete_method_emits_comma_separated_dotted_overload() {
let m = mk("LFoo;", "doConcrete", "(Ljava/lang/String;I)V");
let body = emit_class_body(&[(&m, Some(0))]);
assert!(
body.contains("cls.doConcrete.overload('java.lang.String', 'int').implementation = function(arg0, arg1)"),
"concrete method must overload-bind with comma-separated dotted args; got:\n{body}"
);
assert!(
body.contains("this.doConcrete.overload('java.lang.String', 'int').apply(this, arguments)"),
"concrete method must re-dispatch via apply; got:\n{body}"
);
assert!(
!body.contains(".overload('Ljava/lang/String;I')"),
"concrete method must NOT use smali-concat form (frida-java-bridge rejects it); got:\n{body}"
);
}
#[test]
fn emit_class_body_zero_arg_method_emits_empty_overload_call() {
let m = mk("LFoo;", "noArgs", "()V");
let body = emit_class_body(&[(&m, Some(0))]);
assert!(
body.contains("cls.noArgs.overload().implementation = function()"),
"zero-arg method must use bare overload() not overload(''); got:\n{body}"
);
}
#[test]
fn emit_class_body_array_param_uses_jni_form() {
let m = mk("LFoo;", "withArray", "([B[Ljava/lang/String;)V");
let body = emit_class_body(&[(&m, Some(0))]);
assert!(
body.contains("cls.withArray.overload('[B', '[Ljava.lang.String;').implementation"),
"array params must use JNI bracket form with dotted inner; got:\n{body}"
);
}
#[test]
fn emit_class_body_mixed_class_keeps_per_method_routing() {
let n = mk("LFoo;", "doNative", "()V");
let a = mk("LFoo;", "doAbstract", "()V");
let c = mk("LFoo;", "doConcrete", "()V");
let body = emit_class_body(&[
(&n, Some(ACCESS_FLAG_NATIVE)),
(&a, Some(ACCESS_FLAG_ABSTRACT)),
(&c, Some(0)),
]);
assert!(body.contains("// native method doNative:"));
assert!(body.contains("// abstract method doAbstract:"));
assert!(body.contains("cls.doConcrete.overload().implementation = function()"));
}
#[test]
fn build_method_flags_uses_canonical_row_flags() {
const ACCESS_FLAG_NATIVE: u32 = 0x100;
let fx = crate::analysis::dup_class_fixture::with_native_method_rows(
0, false, 0, true,
);
let flags = build_method_flags(&fx.dex, &fx.raw);
assert_eq!(flags.len(), 1, "the duplicated method must map to one key");
let (_key, value) = flags.iter().next().expect("one entry");
assert_eq!(
*value & ACCESS_FLAG_NATIVE,
0,
"shadow row's ACC_NATIVE flag must not last-win over the canonical row's flags"
);
}
}