安卓逆向实战:从 Frida 动态插桩到 SO 层关键参数定位与加密流程还原
很多同学做 Android 逆向时,最难受的不是“看不懂代码”,而是“明明知道加密在 SO 里,偏偏抓不到关键参数”。Java 层看起来只是简单调了一个 native 方法,真正的明文、密钥、时间戳、随机盐、设备信息,全在 JNI 边界前后瞬间完成拼装。你如果只盯着 Java,往往会差一口气;只盯着 SO,又容易陷进汇编细节里出不来。
这篇文章我换一个更偏“实战排障”的角度来讲:**先用 Frida 在 Java 层卡住调用路径,再逐步下探到 SO 层,最后把关键参数和加密流程串起来还原。**重点不是炫技,而是让你能自己跑通一遍。
说明:本文内容用于安全研究、应用自测、协议分析与防护验证,请勿用于未授权目标。
背景与问题
一个典型场景是这样的:
- App 登录、请求签名、设备校验都正常;
- 抓包能看到请求体,但关键字段是密文;
- Java 层只有类似
SignManager.sign(data)这样的入口; - 真正逻辑在
libxxx.so; - 静态看 JNI 导出函数,参数很多、命名混乱;
- 即使反编译出伪代码,也很难确认哪个参数才是最终参与加密的原始输入。
中级阶段最容易卡住的点通常有三个:
- 找不到 native 调用链入口
- 找到了导出函数,但不知道哪个参数重要
- 看到了加密算法片段,却还原不出完整流程
所以本文的目标非常明确:
- 定位 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-toolsjadx用于静态查看 JavaIDA/Ghidra用于静态查看 SO(可选但强烈推荐)
安装检查
adb devices
frida-ps -U
如果能列出进程,说明 Frida 通路基本没问题。
核心原理
这一类分析,本质上是在调用边界做文章。
1. Java 层是“线索层”
Java 层通常负责:
- 参数收集
- 设备信息拼接
- Map/JSON 序列化
- 调用
native方法
也就是说,虽然真正加密可能不在 Java 里,但输入材料往往在 Java 层最完整。
2. JNI 层是“转换层”
JNI 常见操作:
jstring转char *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() 就完事,尤其是 jstring、jbyteArray 这种对象类型。
最稳的方式,是在当前线程附加到 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 里到底哪一步才是真正加密?
通常可以从两条线索入手:
- 静态反编译看
nativeSign内部调用了哪些函数 - 动态追踪常见加密 API 或内部短小函数
常见线索
如果 SO 用 OpenSSL/BoringSSL,一般会看到:
MD5_InitMD5_UpdateMD5_FinalSHA1_UpdateAES_set_encrypt_keyAES_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 = 1478912345nonce = abc123
-
MD5_Update前明文为:{"user":"alice"}|1478912345|abc123|com.demo.app|1.2.3
-
最终返回:
4f6c1a2b...
那说明流程可能是:
data + "|" + ts + "|" + nonce- 拼入包名和版本号
- MD5
- 输出 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 字符串
排查顺序建议:
- 先确认 JNI 函数签名
- 再看
args[n]对应参数类型 - 先在 Java 层确认同一个参数值
- 必要时改成 hexdump 看原始内容
4. Hook 到系统加密 API 但看不到明文
这很常见。原因可能是:
- 数据已提前分块
- 入参是二进制,不是文本
- 明文在更早函数中已处理完
解决办法:
- 往上游多 Hook 一层
- 同时 Hook
memcpy,strlen,sprintf一类拼接函数 - 在可疑内部函数入口和返回值处都打点
5. 明明算法知道了,结果就是复现不对
这往往不是算法错了,而是输入细节错了:
- 字段顺序不同
- JSON 序列化空格不同
- 字符编码不是 UTF-8
- 时间戳单位是秒不是毫秒
- Hex 大小写不一致
- Base64 是 URL-safe 版本
- 补位方式不同
我自己的经验是:先对比“进入加密函数前的最终明文”,再谈复现。
不要一上来就写 Python 猜流程,那样很容易陷入假设套假设。
安全/性能最佳实践
Frida 很强,但乱 Hook 也很容易把目标搞崩或者把自己绕晕。
1. 优先最小化 Hook 范围
建议顺序:
- 先 Hook 单个 Java 入口
- 再 Hook 单个 JNI 导出
- 最后再 Hook 系统加密 API
不要开局就全局 Hook libc、openssl 一堆函数,日志会爆炸,性能也会明显下降。
2. 日志要带上下文
推荐打印这些信息:
- 线程 ID
- 调用时间
- 参数长度
- 模块基址
- 偏移地址
这样你回头对照 IDA/Ghidra 时会省很多事。
例如:
console.log("[TID " + Process.getCurrentThreadId() + "] hit sub_xxx");
3. 二进制数据优先 hexdump
不要默认所有东西都是字符串。对于密钥、IV、字节数组、padding 后数据,hexdump 比 readUtf8String 靠谱得多。
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 里”走到“把关键参数和流程完整还原”,就不再是运气问题了。