跳转到内容
123xiao | 无名键客

《安卓逆向实战:从 Frida 动态插桩到 SO 层关键参数定位与加密流程还原》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:从 Frida 动态插桩到 SO 层关键参数定位与加密流程还原

很多同学做 Android 逆向时,最难受的不是“看不懂代码”,而是“明明知道加密在 SO 里,偏偏抓不到关键参数”。Java 层看起来只是简单调了一个 native 方法,真正的明文、密钥、时间戳、随机盐、设备信息,全在 JNI 边界前后瞬间完成拼装。你如果只盯着 Java,往往会差一口气;只盯着 SO,又容易陷进汇编细节里出不来。

这篇文章我换一个更偏“实战排障”的角度来讲:**先用 Frida 在 Java 层卡住调用路径,再逐步下探到 SO 层,最后把关键参数和加密流程串起来还原。**重点不是炫技,而是让你能自己跑通一遍。

说明:本文内容用于安全研究、应用自测、协议分析与防护验证,请勿用于未授权目标。


背景与问题

一个典型场景是这样的:

  • App 登录、请求签名、设备校验都正常;
  • 抓包能看到请求体,但关键字段是密文;
  • Java 层只有类似 SignManager.sign(data) 这样的入口;
  • 真正逻辑在 libxxx.so
  • 静态看 JNI 导出函数,参数很多、命名混乱;
  • 即使反编译出伪代码,也很难确认哪个参数才是最终参与加密的原始输入

中级阶段最容易卡住的点通常有三个:

  1. 找不到 native 调用链入口
  2. 找到了导出函数,但不知道哪个参数重要
  3. 看到了加密算法片段,却还原不出完整流程

所以本文的目标非常明确:

  • 定位 Java -> JNI -> SO 的调用路径
  • 在运行时抓出关键参数
  • 判断参数转换关系
  • 还原出一个可复现的加密流程样例

前置知识与环境准备

建议你至少具备这些基础:

  • 会用 adb
  • 知道 Android Java 层与 JNI 的基本关系
  • 用过 Frida attach 或 spawn
  • 看得懂简单的 C / JNI 函数签名
  • 对常见加密(MD5、SHA、AES、HMAC)有基本概念

实验环境

下面这套组合比较稳:

  • Android 7.0+ 真机或模拟器
  • Frida server 与 Frida client 版本一致
  • Python 3
  • adb, frida-tools
  • jadx 用于静态查看 Java
  • IDA / Ghidra 用于静态查看 SO(可选但强烈推荐)

安装检查

adb devices
frida-ps -U

如果能列出进程,说明 Frida 通路基本没问题。


核心原理

这一类分析,本质上是在调用边界做文章。

1. Java 层是“线索层”

Java 层通常负责:

  • 参数收集
  • 设备信息拼接
  • Map/JSON 序列化
  • 调用 native 方法

也就是说,虽然真正加密可能不在 Java 里,但输入材料往往在 Java 层最完整

2. JNI 层是“转换层”

JNI 常见操作:

  • jstringchar *
  • jbyteArray 转原始字节数组
  • 结构体组装
  • 调用内部 C/C++ 函数

很多“你在 Java 看不见的细节”,比如字符串再拼接、补位、版本号混入、盐值附加,都发生在这里。

3. SO 内部函数是“落地层”

真正的流程一般像这样:

  • 参数标准化
  • 混入常量/盐值
  • 选择算法分支
  • 调用 MD5_Update / AES_cbc_encrypt / 自研函数
  • 输出 hex/base64

分析思路图

flowchart TD
    A[Java 业务函数] --> B[native 方法]
    B --> C[JNI 导出函数]
    C --> D[SO 内部参数整理]
    D --> E[加密核心函数]
    E --> F[hex/base64 输出]

动态 + 静态配合关系

sequenceDiagram
    participant U as Analyst
    participant J as Java层
    participant N as JNI层
    participant S as SO层

    U->>J: Hook Java入口方法
    J-->>U: 获取原始参数/返回值
    U->>N: Hook native导出符号
    N-->>U: 捕获JNI参数
    U->>S: Hook内部加密函数/关键API
    S-->>U: 获取明文/密钥/输出
    U->>U: 对照静态反编译结果
    U-->>U: 还原完整流程

逐步实战:从入口到 SO 参数定位

下面我用一个常见案例模型来演示。假设 App 中存在如下 Java 代码:

public class SignManager {
    static {
        System.loadLibrary("signer");
    }

    public native String nativeSign(String data, String ts, String nonce);

    public String sign(String data) {
        String ts = String.valueOf(System.currentTimeMillis());
        String nonce = "abc123";
        return nativeSign(data, ts, nonce);
    }
}

我们的目标是还原 nativeSign 里的加密流程。


第一步:定位 Java 层调用链

先用 Frida 抓 Java 侧参数,这一步很值钱,因为它能帮你确认:

  • 传入 native 的到底是什么
  • 时间戳、随机数是不是在 Java 层生成
  • 返回值长什么样

Frida 脚本:Hook Java 方法

Java.perform(function () {
    var SignManager = Java.use("com.demo.app.SignManager");

    SignManager.sign.overload("java.lang.String").implementation = function (data) {
        console.log("[+] SignManager.sign called");
        console.log("    data = " + data);

        var ret = this.sign(data);

        console.log("    ret  = " + ret);
        return ret;
    };

    SignManager.nativeSign.overload("java.lang.String", "java.lang.String", "java.lang.String").implementation = function (data, ts, nonce) {
        console.log("[+] nativeSign called");
        console.log("    data  = " + data);
        console.log("    ts    = " + ts);
        console.log("    nonce = " + nonce);

        var ret = this.nativeSign(data, ts, nonce);

        console.log("    sign  = " + ret);
        return ret;
    };
});

运行方式

frida -U -f com.demo.app -l hook_java.js --no-pause

这一步你应该验证什么

  • data 是否就是请求体原文
  • ts 是否每次变化
  • nonce 是否固定或伪随机
  • sign 返回格式是 hex、base64 还是别的编码

我当时踩过一个坑:看到 nativeSign(data, ts, nonce) 就以为只有这三个参数参与计算,结果 SO 里还偷偷拼了 app 版本和设备 ID。别太早下结论,Java 层只是入口,不一定是全量输入。


第二步:枚举 SO 导出符号,找到 JNI 入口

如果 native 方法没有明显 Java Hook 收获,或者你想继续下探 SO,就要定位 JNI 函数。

Android 的 JNI 导出名常见格式类似:

  • Java_com_demo_app_SignManager_nativeSign

也可能是动态注册,这时不一定能直接看到导出符号。

Frida 脚本:枚举模块导出

setImmediate(function () {
    var moduleName = "libsigner.so";
    var module = Process.findModuleByName(moduleName);
    if (!module) {
        console.log("[-] module not found: " + moduleName);
        return;
    }

    console.log("[+] module base: " + module.base);

    var exports = Module.enumerateExportsSync(moduleName);
    exports.forEach(function (e) {
        if (e.name.indexOf("Java_") >= 0 || e.name.indexOf("RegisterNatives") >= 0) {
            console.log("[EXPORT] " + e.name + " @ " + e.address);
        }
    });
});

如果能找到 Java_xxx,说明是静态注册;如果找不到,就重点盯 RegisterNatives


第三步:Hook JNI 导出函数,抓 jstring 参数

这里是实战的关键点之一:JNI 参数不是普通 C 字符串,不能直接 readCString() 就完事,尤其是 jstringjbyteArray 这种对象类型。

最稳的方式,是在当前线程附加到 Java VM,用 JNI API 把对象转成可读字符串

可运行脚本:Hook nativeSign 导出函数

function hookNativeSign() {
    var symbol = Module.findExportByName("libsigner.so", "Java_com_demo_app_SignManager_nativeSign");
    if (!symbol) {
        console.log("[-] nativeSign export not found");
        return;
    }

    console.log("[+] hooking " + symbol);

    Interceptor.attach(symbol, {
        onEnter: function (args) {
            // JNI函数签名通常为:
            // JNIEnv *env, jobject thiz, jstring data, jstring ts, jstring nonce
            var env = Java.vm.tryGetEnv();
            if (!env) {
                console.log("[-] failed to get JNIEnv");
                return;
            }

            this.data = args[2];
            this.ts = args[3];
            this.nonce = args[4];

            try {
                var dataStr = env.getStringUtfChars(this.data, null).readCString();
                var tsStr = env.getStringUtfChars(this.ts, null).readCString();
                var nonceStr = env.getStringUtfChars(this.nonce, null).readCString();

                console.log("[+] JNI nativeSign enter");
                console.log("    data  = " + dataStr);
                console.log("    ts    = " + tsStr);
                console.log("    nonce = " + nonceStr);
            } catch (e) {
                console.log("[-] parse jstring failed: " + e);
            }
        },
        onLeave: function (retval) {
            var env = Java.vm.tryGetEnv();
            if (!env) return;

            try {
                var retStr = env.getStringUtfChars(retval, null).readCString();
                console.log("[+] JNI nativeSign ret = " + retStr);
            } catch (e) {
                console.log("[-] parse return jstring failed: " + e);
            }
        }
    });
}

setImmediate(hookNativeSign);

不同 Frida 版本在 JNI 辅助 API 上有些差异。如果这里报错,不要死磕,退一步先 Hook Java 层,然后再通过 hexdump(args[n]) 和静态分析配合确认类型。


第四步:定位 SO 内部关键函数

接下来要回答一个更核心的问题:

nativeSign 里到底哪一步才是真正加密?

通常可以从两条线索入手:

  1. 静态反编译看 nativeSign 内部调用了哪些函数
  2. 动态追踪常见加密 API 或内部短小函数

常见线索

如果 SO 用 OpenSSL/BoringSSL,一般会看到:

  • MD5_Init
  • MD5_Update
  • MD5_Final
  • SHA1_Update
  • AES_set_encrypt_key
  • AES_cbc_encrypt

如果是自研算法,常见特征是:

  • 一个函数反复循环处理字节
  • 末尾转 hex/base64
  • 常量表很多
  • 调用链较短但参数复杂

Frida 脚本:Hook MD5_Update

function hookMd5() {
    var target = Module.findExportByName(null, "MD5_Update");
    if (!target) {
        console.log("[-] MD5_Update not found");
        return;
    }

    console.log("[+] hooking MD5_Update @ " + target);

    Interceptor.attach(target, {
        onEnter: function (args) {
            var buf = args[1];
            var len = args[2].toInt32();

            console.log("[+] MD5_Update len = " + len);
            if (len > 0 && len < 512) {
                try {
                    console.log(hexdump(buf, {
                        offset: 0,
                        length: len,
                        header: true,
                        ansi: true
                    }));
                    console.log("    as string = " + Memory.readUtf8String(buf, len));
                } catch (e) {
                    console.log("    read failed: " + e);
                }
            }
        }
    });
}

setImmediate(hookMd5);

如果你在 MD5_Update 里直接看到:

data=xxx&ts=1478912345&nonce=abc123&key=K1...

那就非常接近真相了。


第五步:还原参数拼接关系

动态抓到数据后,不要急着写脚本复现,先整理出参数关系图

例子:某次动态观察结果

假设你抓到:

  • Java 层传入:

    • data = {"user":"alice"}
    • ts = 1478912345
    • nonce = abc123
  • MD5_Update 前明文为:

    • {"user":"alice"}|1478912345|abc123|com.demo.app|1.2.3
  • 最终返回:

    • 4f6c1a2b...

那说明流程可能是:

  1. data + "|" + ts + "|" + nonce
  2. 拼入包名和版本号
  3. MD5
  4. 输出 hex 小写

用图梳理比脑补更稳

flowchart LR
    A[data原文] --> E[字符串拼接]
    B[ts时间戳] --> E
    C[nonce随机串] --> E
    D[包名/版本号] --> E
    E --> F[MD5]
    F --> G[hex小写输出]

实战代码:本地复现加密流程

假设经过动态分析后,我们确认算法如下:

sign = md5(data + "|" + ts + "|" + nonce + "|" + packageName + "|" + versionName)

下面给出一个 Python 复现脚本。

Python 复现代码

import hashlib

def calc_sign(data, ts, nonce, package_name, version_name):
    plain = f"{data}|{ts}|{nonce}|{package_name}|{version_name}"
    print("[+] plain =", plain)
    return hashlib.md5(plain.encode("utf-8")).hexdigest()

if __name__ == "__main__":
    data = '{"user":"alice"}'
    ts = '1478912345'
    nonce = 'abc123'
    package_name = 'com.demo.app'
    version_name = '1.2.3'

    sign = calc_sign(data, ts, nonce, package_name, version_name)
    print("[+] sign =", sign)

如果 SO 返回的是 Base64

也很常见,比如:

  • 先 AES
  • 再 Base64 编码

那就按动态抓到的输入输出补逻辑,不要凭感觉乱猜填充模式和 key 长度。


一个更贴近实战的 Frida 联合脚本

下面给一个“Java + SO 双层观察”的脚本框架,实战里很好用。

Java.perform(function () {
    var SignManager = Java.use("com.demo.app.SignManager");

    SignManager.nativeSign.overload("java.lang.String", "java.lang.String", "java.lang.String").implementation = function (data, ts, nonce) {
        console.log("\n[Java] nativeSign called");
        console.log("  data  = " + data);
        console.log("  ts    = " + ts);
        console.log("  nonce = " + nonce);

        var ret = this.nativeSign(data, ts, nonce);

        console.log("  ret   = " + ret);
        return ret;
    };
});

function hookNativeInternal() {
    var moduleName = "libsigner.so";
    var module = Process.findModuleByName(moduleName);
    if (!module) {
        console.log("[-] " + moduleName + " not loaded yet");
        return;
    }

    console.log("[+] " + moduleName + " base = " + module.base);

    var md5Update = Module.findExportByName(null, "MD5_Update");
    if (md5Update) {
        Interceptor.attach(md5Update, {
            onEnter: function (args) {
                var len = args[2].toInt32();
                if (len > 0 && len < 256) {
                    try {
                        var s = Memory.readUtf8String(args[1], len);
                        console.log("[SO] MD5_Update data = " + s);
                    } catch (e) {
                        console.log("[SO] MD5_Update raw len = " + len);
                    }
                }
            }
        });
    } else {
        console.log("[-] MD5_Update not found");
    }
}

setImmediate(function () {
    hookNativeInternal();
});

常见坑与排查

这一节很重要。很多人不是不会,而是卡在一些“很 Frida”的问题上。

1. SO 还没加载,Hook 失败

现象:

module not found: libsigner.so

原因:

  • 你 attach 时机太早
  • App 延迟加载 SO

解决办法:

Interceptor.attach(Module.findExportByName(null, "dlopen"), {
    onEnter: function (args) {
        this.path = Memory.readCString(args[0]);
    },
    onLeave: function (retval) {
        if (this.path.indexOf("libsigner.so") >= 0) {
            console.log("[+] loaded: " + this.path);
        }
    }
});

或者直接在 Java 层 System.loadLibrary 后再挂。


2. 动态注册 JNI,找不到 Java_xxx

现象:

  • enumerateExportsSync 里没有 JNI 导出名

原因:

  • 使用 RegisterNatives 动态注册

思路:

  • Hook RegisterNatives
  • 打印注册的方法名、签名、函数地址

示例:Hook RegisterNatives

function hookRegisterNatives() {
    var addr = Module.findExportByName(null, "RegisterNatives");
    if (!addr) {
        console.log("[-] RegisterNatives not found");
        return;
    }

    Interceptor.attach(addr, {
        onEnter: function (args) {
            console.log("[+] RegisterNatives called");
            console.log("    methods ptr = " + args[2]);
            console.log("    method count = " + args[3].toInt32());
        }
    });
}

setImmediate(hookRegisterNatives);

不同 Android 版本上这个符号不一定能直接拿到,必要时从 libart.so 里定位。


3. jstring 读取崩溃或乱码

原因一般有:

  • 当前线程没正确拿到 JNIEnv
  • 目标不是 jstring
  • 字符串已释放
  • 实际是 UTF-16 / 字节数组,不是 C 字符串

排查顺序建议:

  1. 先确认 JNI 函数签名
  2. 再看 args[n] 对应参数类型
  3. 先在 Java 层确认同一个参数值
  4. 必要时改成 hexdump 看原始内容

4. Hook 到系统加密 API 但看不到明文

这很常见。原因可能是:

  • 数据已提前分块
  • 入参是二进制,不是文本
  • 明文在更早函数中已处理完

解决办法:

  • 往上游多 Hook 一层
  • 同时 Hook memcpy, strlen, sprintf 一类拼接函数
  • 在可疑内部函数入口和返回值处都打点

5. 明明算法知道了,结果就是复现不对

这往往不是算法错了,而是输入细节错了

  • 字段顺序不同
  • JSON 序列化空格不同
  • 字符编码不是 UTF-8
  • 时间戳单位是秒不是毫秒
  • Hex 大小写不一致
  • Base64 是 URL-safe 版本
  • 补位方式不同

我自己的经验是:先对比“进入加密函数前的最终明文”,再谈复现。
不要一上来就写 Python 猜流程,那样很容易陷入假设套假设。


安全/性能最佳实践

Frida 很强,但乱 Hook 也很容易把目标搞崩或者把自己绕晕。

1. 优先最小化 Hook 范围

建议顺序:

  1. 先 Hook 单个 Java 入口
  2. 再 Hook 单个 JNI 导出
  3. 最后再 Hook 系统加密 API

不要开局就全局 Hook libcopenssl 一堆函数,日志会爆炸,性能也会明显下降。


2. 日志要带上下文

推荐打印这些信息:

  • 线程 ID
  • 调用时间
  • 参数长度
  • 模块基址
  • 偏移地址

这样你回头对照 IDA/Ghidra 时会省很多事。

例如:

console.log("[TID " + Process.getCurrentThreadId() + "] hit sub_xxx");

3. 二进制数据优先 hexdump

不要默认所有东西都是字符串。对于密钥、IV、字节数组、padding 后数据,hexdumpreadUtf8String 靠谱得多。

console.log(hexdump(ptr, {
    offset: 0,
    length: 64,
    header: true,
    ansi: false
}));

4. 注意目标稳定性

某些 App 有简单反调试或反注入:

  • 检测 Frida 端口
  • 枚举进程映射
  • 校验 so 完整性
  • 检查 TracerPid

这时建议:

  • 先在测试环境验证
  • 分步注入,不要一次性 Hook 太多
  • 必要时先处理反调试点,再做主流程分析

5. 复现脚本要保留“中间态”

本地还原时,不要只输出最终 sign。最好把这些中间信息也打出来:

  • 拼接前各字段
  • 拼接后的完整明文
  • 编码前后字节
  • 加密中间结果
  • 最终编码结果

这样一旦和 App 结果不一致,很容易看出差在哪一步。


逐步验证清单

如果你准备自己动手,建议按这个清单走:

  • 确认目标 SO 名称
  • 找到 Java 调用入口
  • 抓到 native 方法参数与返回值
  • 确认 JNI 注册方式(静态/动态)
  • 定位 native 对应 SO 函数
  • 观察 SO 内部拼接/转换逻辑
  • 确认最终进入加密函数的明文
  • 识别算法与输出编码
  • 用 Python/JS 本地复现
  • 用多组样本交叉验证结果

能把这 10 步走通,你基本就不是“碰运气逆向”了,而是在做有证据链的分析。


总结

这类 Android SO 加密分析,真正有效的方法不是死盯某一层,而是建立一条完整链路:

Java 层找入口,JNI 层抓边界,SO 层看落地,加密函数前确认最终输入,最后本地复现。

如果你只记住一条经验,我建议记这句:

先确认“进入加密核心前的最终明文”,再谈算法还原。

因为很多时候,难点根本不是 MD5、AES 本身,而是:

  • 明文怎么拼的
  • 哪些隐藏参数参与了计算
  • 编码与格式在什么时候发生了变化

可执行建议

  • 初次分析时,优先 Hook Java 入口和 JNI 导出
  • 对 SO 内部,不要一开始就啃大函数,先从常见加密 API 反推
  • 本地复现一定要打印中间态,别只比最终结果
  • 动态与静态结果必须交叉验证,单边证据很容易误判

边界条件

本文更适合这类目标:

  • 有明确 Java -> native 调用路径
  • SO 没有特别重的壳保护
  • 加密流程仍可在运行时截获中间数据

如果目标有这些特征,难度会明显上升:

  • 全动态注册且符号混淆严重
  • 自研 VM/解释器
  • 强反调试、反 Frida、反内存读取
  • 加密参数分散在多个线程中组装

但即便如此,方法论仍然成立:找边界、抓中间态、做对照、再复现。

只要你把这套路径练熟,从“知道它在 SO 里”走到“把关键参数和流程完整还原”,就不再是运气问题了。


分享到:

上一篇
《微服务架构下的分布式事务落地:基于 Saga 模式的设计、实现与故障处理实践》
下一篇
《微服务架构中的服务拆分与边界治理:从领域建模到生产环境落地实践》