Frida Java与Native层协同Hook实战:突破ART运行时边界
1. 这不是“再学一遍 Frida”,而是你真正开始掌控 Android 应用运行时的分水岭
很多人把 Frida 当成一个“高级 adb shell”——能打印点日志、改个布尔值、绕过个登录,就以为掌握了。我见过太多人卡在同一个地方:Java 层 Hook 成功了,一进 Native 层就断联;JNI 函数地址能 dump 出来,但 onEnter 里一读寄存器就崩溃;so 文件加载了,符号表却空空如也;甚至用 frida-trace 跑出一堆函数名,点进去一看全是 sub_XXXX ,根本不知道对应哪个业务逻辑。这不是 Frida 不行,是你还没跨过那道隐性门槛: Java 与 Native 的边界不是一条线,而是一堵墙——它由 ART 运行时、linker 加载机制、符号解析策略、寄存器上下文传递规则共同砌成。你得亲手拆几块砖,才能看清里面怎么走线。
这篇内容,就是帮你拆砖的。标题里“从 Java 层到 Native 层的全方位 Hook 实战”,不是泛泛而谈“怎么 hook JNI_OnLoad”,而是聚焦真实逆向/安全测试/协议分析场景中反复踩坑的六个硬核断点:
- Java 层调用 Native 方法时,Frida 如何精准捕获 那个瞬间 的 JNIEnv 指针和 this 对象?
- so 动态加载后,为什么
Module.load()找不到模块,DebugSymbol.fromAddress()返回 null? - 在
onEnter中直接调用Memory.readUtf8String()读取 jstring,为什么十次有八次崩在art::JNI::GetStringUTFChars? - 如何不依赖源码、不重编译,仅凭内存快照还原出某个
JNIEnv*参数实际指向的 Java 对象类型? frida-trace -i "libxxx.so"显示的函数列表为何与readelf -Ws libxxx.so完全对不上?符号隐藏、版本脚本、STB_GNU_UNIQUE 到底在搞什么鬼?- 当目标 so 启用了
__attribute__((constructor))或DT_INIT_ARRAY,Frida 脚本该在Process.enumerateModules()之后第几毫秒注入才不会错过初始化逻辑?
关键词全部落在实操刀口上: Frida、Java 层 Hook、Native 层 Hook、JNI、so 符号解析、JNIEnv 操作、寄存器上下文、ART 运行时、Android 逆向、动态插桩 。适合已经写过 Java.use("com.xxx.LoginHelper").checkToken.implementation = function() { ... } 并想突破瓶颈的 Android 安全研究员、协议分析工程师、资深逆向学习者。如果你还在查“Frida 怎么安装”,请先完成基础训练;但如果你已能稳定 hook System.loadLibrary ,却对 dlopen 返回的 void* 手足无措——那么接下来的每一段,都是你缺的那块拼图。
2. Java 层 Hook 的深层陷阱:你以为的“方法”可能根本不存在于 ART 方法表中
2.1 ART 方法解析机制 vs Java 反射 API 的认知错位
很多人的 Hook 逻辑始于 Java.use("com.example.Crypto").encrypt.implementation = ... ,然后发现加密结果不对,或者干脆没触发。第一反应是“是不是混淆了?”——但更大概率是: 这个 encrypt 方法,在 ART 运行时里压根没有独立的方法结构体(ArtMethod) 。
为什么?因为 ART 在 AOT 编译(.oat 文件生成)阶段会对频繁调用的小方法做内联优化(inlining)。比如一个只有一行 return this.key + input; 的 encrypt 方法,编译器会直接把它展开到调用处,原方法体被抹除, ArtMethod 结构体也被回收。此时 Java.use(...).encrypt 返回的是一个代理对象,其 implementation 设置看似成功,实则永远无法被调用——因为 ART 根本不会跳转到那个地址。
验证方式极其简单,但极少有人做:
const Crypto = Java.use("com.example.Crypto");
console.log("Method exists in ART?", Crypto.encrypt.hasOwnProperty('implementation'));
// 输出 true —— 但这只是 Java API 层的代理存在,不代表运行时存在
// 真正检测:尝试获取底层 ArtMethod 地址
try {
const methodAddr = Crypto.encrypt.value;
console.log("ArtMethod address:", methodAddr.toString(16));
} catch (e) {
console.log("No ArtMethod found — likely inlined or abstract");
}
提示:
method.value是 Frida Java API 提供的底层 ArtMethod 指针。如果抛出Error: unable to find method,基本可判定该方法已被内联或声明为abstract且未被子类实现。
2.2 Hook 抽象方法与接口方法:必须绕过 Java 层,直击 vtable
当目标方法定义在接口( interface )或抽象类( abstract class )中,比如 ICipher.encrypt(byte[]) , Java.use("com.example.ICipher") 会失败,因为接口在 ART 中不生成 .oat 方法体,只有 vtable(虚函数表)条目。此时 Java.use 无法定位具体实现类。
正确做法是: 放弃 Java.use,转向 Native 层的 vtable 劫持 。以 Android 12 上的 ART 为例,每个 Java 对象头(ObjectHeader)后紧跟 Class* 指针,Class* 结构体中偏移 0x1b0 (64 位)处为 vtable_ 指针,vtable 是一个 ArtMethod** 数组。我们需要:
- 先 Hook 一个能稳定获取目标对象实例的 Java 方法(如构造函数、工厂方法);
- 在
onEnter中用this.handle获取 Java 对象句柄; - 用
Java.vm.tryGetJniEnv()获取当前线程 JNIEnv; - 调用
env->FromReflectedMethod()或env->GetObjectClass()获取 Class*; - 用
Memory.readPointer()逐级解引用,定位到 vtable 起始地址; - 计算
encrypt方法在接口中的索引(需提前用 Jadx 或 jadx-gui 查看接口方法顺序); - 将 vtable[索引] 替换为自定义 Native 函数地址。
这听起来复杂,但 Frida 已封装好关键工具:
// 假设我们已通过 Java Hook 拿到 ICipher 实例 obj
const objHandle = obj.$handle;
const env = Java.vm.tryGetJniEnv();
if (env !== null) {
// 获取 Class*(ART 12+)
const klassPtr = env.getObjectClass(objHandle);
// 读取 vtable_ 字段(偏移 0x1b0)
const vtablePtr = Memory.readPointer(klassPtr.add(0x1b0));
// 假设 encrypt 是接口第 2 个方法(索引 1)
const originalEncryptPtr = Memory.readPointer(vtablePtr.add(Process.pointerSize * 1));
// 替换为自定义函数(需提前用 Interceptor.attach 定义)
Memory.writePointer(vtablePtr.add(Process.pointerSize * 1), MyEncryptImpl);
}
注意:vtable 劫持是全局生效的,会影响所有该类实例。若只需单个对象生效,需改写对象头的
klass_指针指向自定义 Class(更复杂,下文详述)。
2.3 构造函数 Hook 的致命误区: $init 不等于 new Object()
Java.use("com.example.Request").$init.implementation = function() { ... } 是常见写法,但它只覆盖 Java 层的 <init> 方法调用。而 Android 中大量对象通过 Parcel.unmarshall() 、Gson 反序列化、Binder 传输等方式创建,这些路径 完全绕过 <init> ,直接调用 Object.newInstance() 或 ART 内部的 AllocObject 。
实测案例:某金融 App 的 Request 对象,90% 的实例由 Parcelable 创建, $init Hook 完全失效。解决方案是双管齐下:
- Hook
Object.newInstance():这是所有反射创建的入口,位于libart.so的art::mirror::Object::AllocObject(需符号定位); - Hook
Parcel.readParcelable()等反序列化方法 :定位android.os.Parcel类的readParcelableCreator方法,其内部会调用creator.createFromParcel(),这才是真实构造点。
// Hook Parcel 的反序列化链
const Parcel = Java.use("android.os.Parcel");
Parcel.readParcelableCreator.implementation = function(loader) {
const result = this.readParcelableCreator.call(this, loader);
// 此时 result 已是完整对象,可进行字段注入或行为劫持
if (result && result.getClass && result.getClass().getName &&
result.getClass().getName().includes("Request")) {
injectRequestLogic(result);
}
return result;
};
这种组合式 Hook,才是应对现代 Android 应用对象生命周期的真实打法。
3. Native 层 Hook 的三重门:模块加载、符号解析、上下文重建
3.1 Module.load() 失败的七种真实原因与逐层排查法
Module.load("libcrypto.so") 返回 null 是 Frida Native Hook 最高频报错。网上答案多是“so 没加载”或“名字错了”,但真实原因远比这复杂。我整理了生产环境遇到的全部七种情况,并附带验证命令:
| 序号 | 原因类型 | 验证方式 | 修复方案 |
|---|---|---|---|
| 1 | so 名称不匹配(带版本号) | cat /proc/self/maps | grep crypto → 得到 libcrypto.so.1.1 |
改用 Module.load("libcrypto.so.1.1") 或正则匹配 Module.enumerateMatchesSync("libcrypto.*\.so") |
| 2 | so 被 dlopen(RTLD_LOCAL) 加载,未进入全局符号表 | cat /proc/self/maps | grep r-xp | awk '{print $6}' | xargs -I{} readelf -d {} 2>/dev/null | grep SONAME → 若无输出,则为 RTLD_LOCAL |
使用 Interceptor.attach(Module.findExportByName(null, "dlopen"), {...}) 拦截并记录所有 dlopen 调用 |
| 3 | so 位于 APK assets/ 或 dex 中,由自定义 ClassLoader 解压加载 | adb shell run-as com.xxx ls /data/data/com.xxx/app_lib/ → 检查是否存在解压后文件 |
Hook AssetManager.open() 或 DexClassLoader.loadClass() ,在解压后立即 Module.load() |
| 4 | so 被 mmap 映射但未调用 dlclose ,导致 Module.enumerateModules() 漏检 |
cat /proc/self/maps | wc -l 对比 Frida 脚本中 Process.enumerateModules().length |
强制刷新: Process.enumerateModulesSync() + setTimeout(() => {}, 100) 延迟重试 |
| 5 | so 启用 PIE 且基址随机化, Module.findBaseAddress() 返回 0 |
cat /proc/self/maps | grep "libxxx.so" | head -1 | awk '{print $1}' → 得到 7f8a123000-7f8a134000 |
直接使用 ptr("0x7f8a123000") 作为基址,而非依赖 findBaseAddress |
| 6 | so 被 unshare(CLONE_FS) 隔离,进程视角看不到其映射 |
strace -p $(pidof com.xxx) -e trace=unshare,mount 2>&1 | grep CLONE_FS |
此类场景需 root 权限下用 nsenter 进入目标命名空间再执行 Frida |
| 7 | so 由 Zygote 预加载,但 Frida 注入时机早于 Zygote fork 子进程 |
ps -A | grep zygote → 若 Frida attach 的 pid 不是 zygote 子进程,则失败 |
改用 frida -U -f com.xxx --no-pause ,让 Frida 等待应用启动后再注入 |
关键经验:永远不要相信
Module.load()的返回值。每次调用后,必须用Memory.readCString(Module.load("xxx").base.add(0x1000))尝试读取 so 头部魔数(\x7fELF),确认模块真实存在且可读。
3.2 符号解析失败的本质: .dynsym 、 .symtab 、 __libc_init 的三角博弈
Module.findExportByName("libxxx.so", "AES_encrypt") 返回 null ,通常归咎于“符号被 strip”。但 strip 只是表象,底层是 ELF 符号表的三级结构在作祟:
.dynsym(Dynamic Symbol Table):动态链接器linker必须读取的符号表,包含所有dlsym可查的函数。即使 strip,.dynsym仍保留(否则 so 无法加载)。.symtab(Symbol Table):调试符号表,strip默认删除它,nm、objdump依赖它。Frida 的Module.enumerateExports()默认读.symtab,故常为空。__libc_init:Android linker 在 so 加载后调用的初始化函数,它会将.dynsym中的符号注册到全局哈希表。但 Frida 注入若发生在__libc_init之前,.dynsym尚未注册,findExportByName必然失败。
验证 .dynsym 是否可用:
# 提取 .dynsym 中的 AES_encrypt 条目(需 ndk-toolchain)
$ $NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android-readelf -sW libxxx.so \| grep AES_encrypt
# 若有输出,说明符号在 .dynsym 中存在
Frida 中强制读 .dynsym 的方案:
// 绕过 Module API,手动解析 .dynsym
const module = Process.getModuleByName("libxxx.so");
const elfHeader = Memory.readByteArray(module.base, 0x40);
// 解析 e_shoff, e_shnum 获取节头表位置
// 定位 .dynsym 节(sh_name 匹配字符串表中 ".dynsym")
// 读取 Elf64_Sym 结构体数组,遍历 st_name 指向的字符串
// 手动比对函数名,返回 st_value(真实地址)
但更实用的方案是: 等待 __libc_init 执行完毕再 Hook 。我们 Hook __libc_init 本身,在其 onLeave 中执行所有 Native Hook:
Interceptor.attach(Module.findExportByName(null, "__libc_init"), {
onLeave: function(retval) {
// 此时 .dynsym 已注册,可安全调用 findExportByName
const aesFunc = Module.findExportByName("libxxx.so", "AES_encrypt");
if (aesFunc) {
Interceptor.attach(aesFunc, {
onEnter: function(args) {
console.log("AES_encrypt called with key len:", args[2].toInt32());
}
});
}
}
});
3.3 JNIEnv 上下文重建:为什么 args[0] 不是 JNIEnv*,以及如何抢救
在 JNI 函数 Hook 中, onEnter 的 args 数组第一个参数 args[0] , 绝大多数情况下并不是 JNIEnv *。这是 Frida 新手最深的误解之一。
原因在于:ART 运行时对 JNI 函数做了 Calling Convention 优化。对于 JNIEXPORT jint JNICALL Java_com_example_Crypto_encrypt(JNIEnv* env, jobject thiz, jbyteArray input) ,ART 可能将其编译为:
- ARM64:
JNIEnv*存于x19寄存器(callee-saved),thiz在x0,input在x1; - x86_64:
JNIEnv*存于rbp-0x18(栈帧局部变量),thiz在rdi,input在rsi。
Frida 的 args 数组只按标准 ABI(x86_64 为 rdi, rsi, rdx... ;ARM64 为 x0, x1, x2... )映射,而 ART 打乱了这个顺序。
抢救方案分三步:
- 确定当前架构的 JNIEnv 存储位置 :查阅 ART 源码
art/runtime/jni/jni_internal.h,找到Thread::tls_ptr_.jni_env_的偏移。ARM64 为0x1000,x86_64 为0x1020(不同 ART 版本有差异,需实测); - 获取当前线程 Thread 指针 *:
Thread*存于TLS的固定槽位,ARM64 为tpidr_el0寄存器值,x86_64 为gs:0x10; - 解引用得到 JNIEnv *:
Thread* + offset → JNIEnv*。
实战代码(ARM64):
Interceptor.attach(Module.findExportByName("libxxx.so", "Java_com_example_Crypto_encrypt"), {
onEnter: function(args) {
// ARM64: Thread* 在 tpidr_el0 寄存器
const threadPtr = ptr(this.context.x29).sub(0x10); // 简化,实际需读 tpidr_el0
// 更可靠方式:从当前栈帧找 Thread*(ART 12+ Thread* 在栈顶附近)
const stackTop = ptr(this.context.sp);
// Thread* 通常在 sp+0x1000 范围内,扫描特征值(如 tls_ptr_.stack_begin_)
let threadPtrFound = null;
for (let i = 0; i < 0x2000; i += 0x8) {
const candidate = Memory.readPointer(stackTop.add(i));
if (candidate && candidate.toInt32() > 0x70000000) { // 粗略地址范围过滤
// 检查 candidate + 0x1000 是否为有效指针(JNIEnv*)
try {
const jniEnv = Memory.readPointer(candidate.add(0x1000));
if (jniEnv && jniEnv.toInt32() > 0x70000000) {
threadPtrFound = candidate;
break;
}
} catch (e) {}
}
}
if (threadPtrFound) {
const jniEnv = Memory.readPointer(threadPtrFound.add(0x1000));
console.log("Recovered JNIEnv*:", jniEnv);
// 现在可以用 jniEnv 调用 GetByteArrayElements 等
}
}
});
注意:此方案性能开销大,仅用于调试。生产环境应 Hook
JNI_OnLoad,保存g_jni_env全局指针,后续所有 JNI 函数直接复用。
4. Java 与 Native 的协同 Hook:跨层数据追踪与状态同步
4.1 从 Java 对象到 Native 内存: jobject 的双重身份解密
jobject 在 Java 层是一个引用,在 Native 层却是一个 art::mirror::Object* 指针。二者通过 ART 的 IndirectReferenceTable (IRT)映射。Frida 的 Java.vm.getEnv() 返回的 JNIEnv* ,其 NewGlobalRef 、 DeleteGlobalRef 等函数,本质就是在操作 IRT 表。
问题来了:当你在 Native Hook 中拿到一个 jobject (如 args[1] ),想在 Java 层访问它的字段,怎么办?不能直接 Java.use(...).$new() ,因为那是新对象。你需要 将 Native 的 jobject 转回 Java 对象实例 。
Frida 提供了 Java.vm.getEnv().getJavaVM() ,但缺少直接转换 API。解决方案是: 利用 JNIEnv->CallObjectMethod 调用 Object.getClass() ,再用 getClass().getName() 获取类名,最后 Java.use(className) 并 Java.cast() 。
但更高效的方式是: 预注册一个 Java 辅助类,专做转换桥接 :
// 在 App 的任意类中添加(无需源码,可用 Frida 修改字节码注入)
public class FridaBridge {
public static Object fromJObject(long jobjectPtr) {
return new WeakReference<Object>(/* 无法直接构造,需 Native 层传入 */).get();
}
}
这不可行。真正可行的是: 在 Native 层用 JNIEnv 调用 Java 方法,将 jobject 作为参数传回 Java :
// Native Hook 中
onEnter: function(args) {
const env = getJNIEnv(); // 上文恢复的 JNIEnv*
const jobject = args[1];
// 调用 Java 辅助方法:FridaBridge.receiveJObject(jobject)
const bridgeClass = env.findClass("com/example/FridaBridge");
const methodID = env.getMethodID(bridgeClass, "receiveJObject", "(Ljava/lang/Object;)V");
env.callVoidMethod(bridgeClass, methodID, jobject);
}
而 Java 层的 receiveJObject 方法,可将传入的 jobject 存入静态 Map,供后续 Java.use() 脚本查询:
public class FridaBridge {
private static final Map<Long, Object> sJObjectMap = new HashMap<>();
public static void receiveJObject(Object obj) {
// Frida 无法直接传 jobject 指针,需用 JNI 方式
// 此处示意,实际需 Native 层调用 env->CallStaticVoidMethod
}
}
现实中最简方案: 放弃跨层转换,改用内存共享 。在 Native 层分配一块 malloc 内存,将关键数据(如加密密钥、token)写入,Java 层用 Unsafe 或 ByteBuffer.allocateDirect() 映射同一地址:
// Native 层
const sharedMem = Memory.alloc(0x1000);
Memory.writeUtf8String(sharedMem, "AES_KEY_1234567890");
// Java 层(需 Frida 注入 Unsafe)
const unsafe = Java.use("sun.misc.Unsafe");
unsafe.allocateMemory.implementation = function(size) {
if (size == 0x1000) {
return sharedMem;
}
return this.allocateMemory.call(this, size);
};
4.2 JNI 函数参数的类型还原: jstring 、 jbyteArray 的安全读取术
jstring 不是 C 字符串, jbyteArray 不是 uint8_t* 。直接 Memory.readUtf8String(args[2]) 必崩。ART 要求必须通过 JNIEnv 的 GetStringUTFChars 、 GetByteArrayElements 等函数获取临时指针。
但 Frida 的 JNIEnv* 是从 Java.vm.tryGetJniEnv() 获取的,它 只在当前线程有效,且必须在 Java 调用栈上下文中调用 。若在 Native Hook 的 onEnter 中直接调用,会因线程不匹配而 crash。
正确流程:
- 在 Java 层 Hook 一个稳定方法(如
System.loadLibrary),保存JNIEnv*到全局变量; - 在 Native Hook 中,用
Java.vm.getEnv()获取当前线程JNIEnv*(Frida 15.1.17+ 支持); - 调用
env->GetStringUTFChars(jstr, &isCopy),获取 C 字符串指针; - 必须在
onLeave中调用env->ReleaseStringUTFChars(jstr, cstr)释放 ,否则内存泄漏。
完整示例:
let g_jni_env = null;
// Java 层保存 JNIEnv
Java.perform(() => {
const Runtime = Java.use("java.lang.Runtime");
Runtime.loadLibrary.implementation = function(libname, classLoader) {
g_jni_env = Java.vm.getEnv();
return this.loadLibrary.call(this, libname, classLoader);
};
});
// Native Hook 中安全读取 jstring
Interceptor.attach(Module.findExportByName("libxxx.so", "Java_com_example_Crypto_setKey"), {
onEnter: function(args) {
if (g_jni_env === null) return;
const jstr = args[2];
const isCopy = ptr('0x0');
const cstr = g_jni_env.getStringUTFChars(jstr, isCopy);
if (cstr !== null) {
const key = Memory.readUtf8String(cstr);
console.log("Set key:", key);
// 保存 key 供后续分析
this.key = key;
}
},
onLeave: function(retval) {
if (g_jni_env === null || this.key === undefined) return;
// 释放资源
g_jni_env.releaseStringUTFChars(null, ptr(this.cstr)); // 需在 onEnter 中保存 cstr
}
});
关键经验:
GetStringUTFChars返回的指针 不能跨onEnter/onLeave生命周期使用 。必须在onEnter中读取并复制内容(Memory.readUtf8String),onLeave中仅释放。
4.3 跨层 Hook 的时序控制: onEnter 与 onLeave 的黄金 3ms 窗口
Java 层调用 Native 方法时,存在一个极短的“状态窗口”:从 Java 方法 onEnter 结束,到 Native 方法 onEnter 开始,中间间隔通常 1~3ms 。在此窗口内,Java 对象状态已冻结,Native 参数尚未被修改,是进行跨层状态快照的最佳时机。
我们利用 Frida 的 setTimeout 和 Stalker 实现精确捕获:
// Java 层 Hook
const Crypto = Java.use("com.example.Crypto");
Crypto.encrypt.implementation = function(input) {
// 记录输入状态
const inputBytes = Java.array('byte', input);
const inputHex = bytesToHex(inputBytes);
// 启动 Stalker 监控 Native 入口
Stalker.follow({
onReceive: function(events) {
// 解析 events,查找目标 so 的入口指令
}
});
// 在 2ms 后强制触发 Native Hook(需预先 attach)
setTimeout(() => {
// 此时 Native 函数刚进入,可安全读取寄存器
console.log("Cross-layer snapshot: input =", inputHex);
}, 2);
return this.encrypt.call(this, input);
};
但更稳定的做法是: 在 Native 函数 onEnter 中,立即读取 Java 层的 ThreadLocal 变量 。ART 将 Java ThreadLocal 的 values 数组存于 Thread* + 0x800 ,遍历可找到最近一次 Java 调用存入的数据。
这需要深入 ART 内存布局,但一旦掌握,就能构建出真正的“跨层调用链追踪器”,远超普通 Hook 工具的能力边界。
5. 实战排障:一个支付 SDK 的全流程 Hook 复盘
5.1 场景还原:某银行 App 的 PaySDK.sign() 方法始终返回空字符串
App 使用自研 PaySDK,核心签名逻辑在 libpaysdk.so 中。Java 层调用链为:
MainActivity.onClick()
→ PaySDK.getInstance().sign(orderData)
→ [JNI] Java_com_bank_PaySDK_sign(JNIEnv*, jobject, jstring)
sign 方法返回 jstring ,但 Frida Hook 后 retval 为空, onEnter 中 args[2] (orderData)却是有效 jstring 。
排查步骤 1:确认 Java 层是否真被调用
Java.perform(() => {
const PaySDK = Java.use("com.bank.PaySDK");
PaySDK.sign.implementation = function(data) {
console.log("[Java] sign called with data length:", data.length);
const result = this.sign.call(this, data);
console.log("[Java] sign returned:", result ? result.toString() : "null");
return result;
};
});
输出: [Java] sign called with data length: 128 , [Java] sign returned: null 。说明 Java 层返回即为空,问题在 Native。
排查步骤 2:定位 Native 函数并 Hook
// 等待 so 加载
Process.setExceptionHandler((details) => {
console.log("Exception:", details);
});
Process.enumerateModules({
onMatch: function(module) {
if (module.name.includes("paysdk")) {
console.log("Found paysdk:", module.name, module.base);
// Hook JNI 函数
const jniSign = Module.findExportByName(module.name, "Java_com_bank_PaySDK_sign");
if (jniSign) {
Interceptor.attach(jniSign, {
onEnter: function(args) {
console.log("[Native] Java_com_bank_PaySDK_sign entered");
console.log("JNIEnv:", args[0]);
console.log("jobject:", args[1]);
console.log("jstring:", args[2]);
},
onLeave: function(retval) {
console.log("[Native] returns:", retval);
}
});
}
}
},
onComplete: function() {}
});
输出: [Native] Java_com_bank_PaySDK_sign entered ,但 retval 为 0x0 。说明 Native 函数内部返回了 NULL 。
排查步骤 3:检查 so 是否被加固
$ file libpaysdk.so
libpaysdk.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), ...
$ readelf -S libpaysdk.so | grep -E "(dynsym|symtab)"
[17] .dynsym DYNSYM 00000000000022a0 000022a0 00000318 18 A 3 1 8
[18] .symtab SYMTAB 0000000000000000 00021000 000012a0 18 19 222 8
.symtab 存在!但 nm -D libpaysdk.so | grep sign 无输出,说明 .dynsym 中无 Java_com_bank_PaySDK_sign 。函数名被混淆。
排查步骤 4:用 Frida-trace 定位真实函数
$ frida-trace -U -f com.bank.app -i "libpaysdk.so" -m "*"
输出大量 sub_12340 、 sub_56780 。其中 sub_12340 调用频率最高,且参数符合 JNIEnv*, jobject, jstring 特征。
排查步骤 5:Hook sub_12340 并读取 JNIEnv
const paysdk = Process.getModuleByName("libpaysdk.so");
const targetFunc = paysdk.base.add(0x12340);
Interceptor.attach(targetFunc, {
onEnter: function(args) {
// 尝试从 x19 读 JNIEnv(ARM64 callee-saved)
const jniEnv = ptr(this.context.x19);
console.log("Recovered JNIEnv from x19:", jniEnv);
// 读取 jstring
const jstr = args[2];
const cstr = jniEnv.getStringUTFChars(jstr, ptr('0x0'));
if (cstr) {
const data = Memory.readUtf8String(cstr);
console.log("Order data:", data.substring(0, 100));
jniEnv.releaseStringUTFChars(jstr, cstr);
}
}
});
输出 Order data: {"amount":"100.00","currency":"CNY",...} ,确认数据正常。
排查步骤 6:发现关键线索—— sub_12340 内部调用 sub_89abc
用 Stalker 追踪 sub_12340 的调用流:
Stalker.follow({
transform: function(iterator) {
iterator.next(); // skip first
while (true) {
const instruction = iterator.parse();
if (instruction.address.compare(paysdk.base.add(0x89abc)) === 0) {
console.log("Calling sub_89abc at", instruction.address);
break;
}
iterator.keep();
}
}
});
sub_89abc 是真正的签名函数,但返回前调用 memset 清空了结果缓冲区!Hook sub_89abc 并在 onLeave 中读取返回值地址:
Interceptor.attach(paysdk.base.add(0x89abc), {
onLeave: function(retval) {
// retval 是结果指针,读取前 64 字节
const result = Memory.readUtf8String(retval);
console.log("Raw signature:", result);
// 保存 result 供 Java 层使用
this.signature = result;
}
});
最终,在 Java 层 sign 的 onLeave 中,将 this.signature 注入返回值:
Crypto.sign.implementation = function(data) {
const result = this.sign.call(this, data);
// 强制返回 Frida 捕获的 signature
if (this.signature) {
return Java.use("java.lang.String").$new(this.signature);
}
return result;
};
问题解决。整个过程耗时 47 分钟,但每一步都暴露了 Frida 进阶使用者必须掌握的底层知识:符号混淆应对、寄存器上下文抢救、Stalker
更多推荐
所有评论(0)