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** 数组。我们需要:

  1. 先 Hook 一个能稳定获取目标对象实例的 Java 方法(如构造函数、工厂方法);
  2. onEnter 中用 this.handle 获取 Java 对象句柄;
  3. Java.vm.tryGetJniEnv() 获取当前线程 JNIEnv;
  4. 调用 env->FromReflectedMethod() env->GetObjectClass() 获取 Class*;
  5. Memory.readPointer() 逐级解引用,定位到 vtable 起始地址;
  6. 计算 encrypt 方法在接口中的索引(需提前用 Jadx 或 jadx-gui 查看接口方法顺序);
  7. 将 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 打乱了这个顺序。

抢救方案分三步:

  1. 确定当前架构的 JNIEnv 存储位置 :查阅 ART 源码 art/runtime/jni/jni_internal.h ,找到 Thread::tls_ptr_.jni_env_ 的偏移。ARM64 为 0x1000 ,x86_64 为 0x1020 (不同 ART 版本有差异,需实测);
  2. 获取当前线程 Thread 指针 *: Thread* 存于 TLS 的固定槽位,ARM64 为 tpidr_el0 寄存器值,x86_64 为 gs:0x10
  3. 解引用得到 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。

正确流程:

  1. 在 Java 层 Hook 一个稳定方法(如 System.loadLibrary ),保存 JNIEnv* 到全局变量;
  2. 在 Native Hook 中,用 Java.vm.getEnv() 获取当前线程 JNIEnv* (Frida 15.1.17+ 支持);
  3. 调用 env->GetStringUTFChars(jstr, &isCopy) ,获取 C 字符串指针;
  4. 必须在 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

更多推荐