安卓逆向实战:从 Frida Hook 到 JNI 层跟踪,定位 App 登录签名生成逻辑
很多人刚开始做安卓逆向时,会卡在一个非常具体、也非常常见的问题上:登录接口的签名到底是怎么生成的。
表面上看,请求里只有一个 sign 字段;抓包也能抓到;参数似乎也都看得见。但真要复现时,往往会遇到下面这些情况:
- Java 层根本找不到完整的签名逻辑
- 关键计算藏在 Native so 里
- 参数在多个函数之间被拼装、编码、排序
- Hook 到一个方法,只看到中间值,看不到最终签名
这篇文章我就按一次真实排查的思路,带你从 Frida Hook Java 层 出发,一步步追到 JNI 层 Native 方法,最终定位登录签名生成逻辑。重点不是“某个 App 的答案”,而是一套可复用的方法。
适合人群:已经会基本抓包、会用 adb、了解一点 Frida 的中级读者。
边界说明:本文仅用于安全研究、协议分析与自有应用调试,请勿用于未授权目标。
背景与问题
我们先抽象一个典型场景。
某 App 登录请求大致长这样:
POST /api/login
Content-Type: application/json
{
"username": "alice",
"password": "123456",
"timestamp": "1701480000",
"nonce": "8f3c2a",
"sign": "9e2d9c4f..."
}
抓包后你能看到:
- 用户名、密码、时间戳、随机数都明文可见
sign每次都变- 改一个字段,服务端就提示签名错误
这说明签名生成逻辑至少满足以下一种或多种特征:
- 参与字段不止抓包里看到的这些
- 字段顺序有要求
- 有固定盐值或动态设备信息参与
- Java 层只是入口,真正运算在 JNI 层
- 结果可能经过二次编码,比如 Base64、Hex、小写化等
我当时踩过一个坑:以为抓到 MD5(username + timestamp) 就结束了,结果跑不通。后来发现真正逻辑是:
- Java 层组装 map
- Native 层排序并拼接
- 再附加一个 so 内部硬编码 salt
- 最后做 SHA256 再转 Hex
也就是说,只看某一层,常常会误判。
前置知识与环境准备
开始前建议准备这些工具:
adbfridafrida-toolsjadx:看 Java / Kotlin 代码apktool:辅助看资源与清单JEB/Ghidra/IDA:分析 somitmproxy/Charles/Burp:抓包- 一台已 root 或可调试的测试机更方便
安装 Frida 工具:
pip install frida frida-tools
确认设备连通:
adb devices
frida-ps -U
找到目标包名:
adb shell pm list packages | grep demo
假设包名为:
com.demo.app
核心原理
这类问题的核心不是“会不会 Hook”,而是如何缩小搜索范围。我的经验是按下面的路径走:
- 先抓包,明确签名出现在哪个请求里
- 再看 Java 层,找请求发起点、参数组装点、sign 写入点
- 如果 sign 来自 native 方法,转去 Hook JNI 调用入口
- 必要时双向验证:一边 Hook 输入输出,一边静态分析 so
- 最后复现算法,验证是否能离线生成正确 sign
可以把它理解成一条“从外到内”的漏斗。
flowchart TD
A[抓包定位登录请求] --> B[在 Java 层找 sign 字段写入位置]
B --> C{签名逻辑是否在 Java 层}
C -- 是 --> D[Hook 关键方法并还原算法]
C -- 否 --> E[定位 native 方法/JNI 入口]
E --> F[Hook Native 调用输入输出]
F --> G[结合 so 静态分析确认细节]
G --> H[离线复现并校验]
常见签名链路
在 Android App 里,登录签名通常会经历这几层:
sequenceDiagram
participant U as 用户操作
participant J as Java/Kotlin层
participant N as Native so层
participant H as Hash算法
participant S as 服务端
U->>J: 点击登录
J->>J: 组装用户名/时间戳/nonce/设备信息
J->>N: 调用 nativeSign(...)
N->>H: 拼接、排序、加盐、摘要
H-->>N: 返回摘要结果
N-->>J: 返回 sign
J->>S: 发送登录请求
S-->>J: 校验通过/失败
我们要观察的关键点
定位签名时,最有价值的不是“这个方法名字像不像加密”,而是这些信息:
- 输入参数是什么
- 参数有没有被预处理
- 输出是否就是最终 sign
- Native 方法签名是什么
- so 何时加载
- 加密结果是否又经过编码或大小写转换
背景分析:先从 Java 层缩小范围
1. 用 jadx 看调用链
打开 APK 后,先搜索:
signsignaturetokenloginnativeSystem.loadLibraryHashMapTreeMap
经常能搜到类似代码:
public class LoginApi {
public void login(String username, String password) {
Map<String, String> params = new HashMap<>();
params.put("username", username);
params.put("password", password);
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("nonce", DeviceUtil.randomNonce());
String sign = SecurityBridge.genSign(params);
params.put("sign", sign);
httpClient.post("/api/login", params);
}
}
继续点进去,有可能看到:
public class SecurityBridge {
static {
System.loadLibrary("sec");
}
public static native String genSign(Map<String, String> params);
}
如果你看到这一步,基本已经很明确了:
Java 层只是入口,真正逻辑在 libsec.so 的 JNI 实现中。
实战代码(可运行)
下面我按一个完整排查流程来写,代码都可以直接拿去改。
第一步:Hook Java 层,确认 sign 来源
先写一个 Frida 脚本,观察登录参数和 genSign 的输入输出。
hook_java_sign.js
Java.perform(function () {
var SecurityBridge = Java.use("com.demo.app.security.SecurityBridge");
var HashMap = Java.use("java.util.HashMap");
var MapEntry = Java.use("java.util.Map$Entry");
function dumpMap(mapObj) {
var result = {};
var entrySet = mapObj.entrySet();
var iterator = entrySet.iterator();
while (iterator.hasNext()) {
var entry = Java.cast(iterator.next(), MapEntry);
result[entry.getKey().toString()] = entry.getValue().toString();
}
return JSON.stringify(result);
}
SecurityBridge.genSign.overload("java.util.Map").implementation = function (params) {
console.log("[*] SecurityBridge.genSign called");
console.log(" params = " + dumpMap(params));
var ret = this.genSign(params);
console.log(" ret = " + ret);
return ret;
};
console.log("[*] Java hook installed");
});
运行:
frida -U -f com.demo.app -l hook_java_sign.js
如果 App 有反调试或启动即退出,可以先附加:
frida -U com.demo.app -l hook_java_sign.js
预期输出
[*] Java hook installed
[*] SecurityBridge.genSign called
params = {"username":"alice","password":"123456","timestamp":"1701480000","nonce":"8f3c2a"}
ret = 9e2d9c4f5f0a...
这一步能确认三件事:
genSign确实是签名入口- 输入参数到底有哪些
- 返回值是不是最终请求里的
sign
如果请求里的 sign 和这里返回值一致,就说明我们方向没错。
第二步:补 Hook 请求发送点,确认无二次加工
很多人会在这一步掉坑:以为 genSign 返回值就是最终上传值,但实际发送前可能还做了:
toLowerCase()URLEncoder.encode- Base64
- 添加前缀
- 再拼设备字段
建议顺手把请求构建点也 Hook 一下。
hook_request_builder.js
Java.perform(function () {
var RequestBuilder = Java.use("com.demo.app.network.RequestBuilder");
var MapEntry = Java.use("java.util.Map$Entry");
function dumpMap(mapObj) {
var out = {};
var entrySet = mapObj.entrySet();
var iterator = entrySet.iterator();
while (iterator.hasNext()) {
var entry = Java.cast(iterator.next(), MapEntry);
out[entry.getKey().toString()] = entry.getValue().toString();
}
return JSON.stringify(out);
}
RequestBuilder.buildPostBody.overload("java.util.Map").implementation = function (params) {
console.log("[*] buildPostBody called");
console.log(" final params = " + dumpMap(params));
return this.buildPostBody(params);
};
console.log("[*] RequestBuilder hook installed");
});
如果这里看到的 sign 与 genSign 返回值完全一致,就可以放心转到 JNI 层。
第三步:定位 so 加载与 JNI 符号
当 Java 中出现:
System.loadLibrary("sec");
说明目标 so 大概率是:
libsec.so
先确认模块是否已加载。可以用 Frida Hook android_dlopen_ext。
hook_dlopen.js
setImmediate(function () {
var dlopen = Module.findExportByName(null, "android_dlopen_ext");
if (!dlopen) {
console.log("[-] android_dlopen_ext not found");
return;
}
Interceptor.attach(dlopen, {
onEnter: function (args) {
this.path = args[0].readCString();
},
onLeave: function (retval) {
if (this.path && this.path.indexOf("libsec.so") !== -1) {
console.log("[*] Loaded: " + this.path);
}
}
});
console.log("[*] dlopen hook installed");
});
运行后如果看到:
[*] Loaded: /data/app/.../lib/arm64/libsec.so
就说明 so 已经被正确装载。
第四步:Hook RegisterNatives,拿到 JNI 动态注册信息
不少 so 不会直接导出 Java_com_xxx_xxx_genSign 这类符号,而是通过 RegisterNatives 动态注册。
这时,Hook RegisterNatives 非常有用。
hook_register_natives.js
function hookRegisterNatives() {
var addr = Module.findExportByName("libart.so", "_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi");
if (!addr) {
console.log("[-] RegisterNatives not found");
return;
}
Interceptor.attach(addr, {
onEnter: function (args) {
var env = args[0];
var jclass = args[1];
var methods = args[2];
var count = parseInt(args[3]);
console.log("[*] RegisterNatives called, count = " + count);
var pointerSize = Process.pointerSize;
for (var i = 0; i < count; i++) {
var methodPtr = methods.add(i * pointerSize * 3);
var namePtr = methodPtr.readPointer();
var sigPtr = methodPtr.add(pointerSize).readPointer();
var fnPtr = methodPtr.add(pointerSize * 2).readPointer();
var name = namePtr.readCString();
var sig = sigPtr.readCString();
console.log(" name = " + name + ", sig = " + sig + ", fnPtr = " + fnPtr);
}
}
});
console.log("[*] RegisterNatives hook installed");
}
setImmediate(hookRegisterNatives);
可能输出
[*] RegisterNatives called, count = 3
name = genSign, sig = (Ljava/util/Map;)Ljava/lang/String;, fnPtr = 0x7ab34c1234
name = init, sig = ()V, fnPtr = 0x7ab34c1010
name = getToken, sig = ()Ljava/lang/String;, fnPtr = 0x7ab34c2000
这一步很关键。因为你已经拿到了:
- JNI 方法名:
genSign - Java 方法签名:
(Ljava/util/Map;)Ljava/lang/String; - Native 函数地址:
fnPtr
接下来就能直接 Hook 这个 Native 地址。
第五步:Hook JNI Native 函数入口
因为 Native 函数参数里会带 JNIEnv*、jclass/jobject、以及 Java 传入的 Map 对象,
最稳妥的做法通常是:
- 先在 Java 层拿输入输出
- 再在 Native 层确认调用时机和调用栈
- 必要时配合
Java.cast或主动调 JNI 辅助函数取对象内容
中级阶段,不建议一上来就硬读 jobject 结构体。更实际的做法是:
在 Java 层拿参数,在 Native 层拿地址与调用栈。
hook_native_genSign.js
var targetPtr = ptr("0x7ab34c1234"); // 用 RegisterNatives 打印出来的地址替换
Interceptor.attach(targetPtr, {
onEnter: function (args) {
console.log("[*] Native genSign entered");
console.log(" JNIEnv* = " + args[0]);
console.log(" jclass/jobject = " + args[1]);
console.log(" map jobject = " + args[2]);
console.log(" backtrace:");
console.log(
Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress)
.join("\n")
);
},
onLeave: function (retval) {
console.log("[*] Native genSign leave, retval = " + retval);
}
});
这一步的意义
它虽然未必直接告诉你签名字符串内容,但能确认:
- 这个地址就是你要找的 Native 实现
- 它的调用时机和链路
- 它是否会进一步调用其他内部函数
很多时候,真正的摘要运算不在 JNI 入口,而是在 JNI 入口内部再调一个纯 C/C++ 函数。
所以这一步是为了继续向下钻。
第六步:在 so 内继续跟踪摘要函数
接下来有两种常见情况。
情况 A:so 内直接调用系统加密函数
比如会调用:
MD5SHA1SHA256- OpenSSL / BoringSSL 相关接口
这时可以尝试枚举导入符号,或直接 Hook 常见导出。
情况 B:so 内自己实现拼接逻辑,再调用一个内部函数
这时需要结合静态分析工具查看 fnPtr 附近函数流程。
用 Ghidra / IDA 看 JNI 实现时应该关注什么
当你打开 fnPtr 对应函数后,不要一开始就试图“完全读懂汇编/伪代码”。
中级阶段更高效的方法是只盯这几类特征:
- 有没有遍历
Map的 JNI 调用 - 有没有字符串拼接
- 有没有排序行为
- 有没有固定盐值
- 有没有调用 MD5/SHA 系函数
- 输出有没有做 Hex/Base64 转换
可以把一个典型 Native 签名逻辑抽象成下面这样:
flowchart LR
A[读取Map参数] --> B[提取key/value]
B --> C[按key排序]
C --> D[拼接成规范字符串]
D --> E[追加salt/设备信息]
E --> F[SHA256或MD5]
F --> G[Hex或Base64编码]
G --> H[返回sign]
第七步:如果怀疑是排序拼接,直接在 Java 层做验证
很多 App 的 Native 层并不复杂,真正难点在于“拼接规则”。
这时最省时间的办法不是继续深挖汇编,而是先做假设验证。
比如根据 Hook 到的参数:
{
"username": "alice",
"password": "123456",
"timestamp": "1701480000",
"nonce": "8f3c2a"
}
你可以尝试以下规则:
- 按 key 字典序排序
- 拼成
key=value&... - 末尾加固定 salt
- 做 SHA256
- 输出小写 hex
Python 验证脚本
import hashlib
params = {
"username": "alice",
"password": "123456",
"timestamp": "1701480000",
"nonce": "8f3c2a"
}
salt = "app_secret_123"
base = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
raw = base + salt
print("base =", base)
print("raw =", raw)
print("sign =", hashlib.sha256(raw.encode()).hexdigest())
如果结果和 App 里的 sign 一致,就说明你已经还原成功。
如果不一致,再逐项排查:
- 是否密码先做了 MD5
- 是否 salt 在前面
- 是否有设备 ID 参与
- 是否 value 做了 URL 编码
- 是否用了大写 Hex
- 是否空值字段被忽略
一套更完整的 Frida 联合脚本
下面给一个更接近实战的脚本:同时 Hook Java 层入口、请求发送点、so 加载和 Native 注册。
all_in_one.js
function hookDlopen() {
var dlopen = Module.findExportByName(null, "android_dlopen_ext");
if (!dlopen) return;
Interceptor.attach(dlopen, {
onEnter: function (args) {
this.path = args[0].readCString();
},
onLeave: function (retval) {
if (this.path && this.path.indexOf("libsec.so") !== -1) {
console.log("[*] libsec.so loaded: " + this.path);
}
}
});
}
function hookRegisterNatives() {
var symbols = [
"_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi",
"_ZN3art9JNIImpl15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi"
];
var addr = null;
for (var i = 0; i < symbols.length; i++) {
addr = Module.findExportByName("libart.so", symbols[i]);
if (addr) break;
}
if (!addr) {
console.log("[-] RegisterNatives symbol not found");
return;
}
Interceptor.attach(addr, {
onEnter: function (args) {
var methods = args[2];
var count = parseInt(args[3]);
var pointerSize = Process.pointerSize;
for (var i = 0; i < count; i++) {
var item = methods.add(i * pointerSize * 3);
var name = item.readPointer().readCString();
var sig = item.add(pointerSize).readPointer().readCString();
var fnPtr = item.add(pointerSize * 2).readPointer();
if (name.indexOf("sign") !== -1 || sig.indexOf("Map") !== -1) {
console.log("[*] Native method => " + name + " " + sig + " @ " + fnPtr);
}
}
}
});
}
function hookJava() {
Java.perform(function () {
var MapEntry = Java.use("java.util.Map$Entry");
var SecurityBridge = Java.use("com.demo.app.security.SecurityBridge");
function dumpMap(mapObj) {
var out = {};
var iterator = mapObj.entrySet().iterator();
while (iterator.hasNext()) {
var entry = Java.cast(iterator.next(), MapEntry);
out[entry.getKey().toString()] = entry.getValue().toString();
}
return JSON.stringify(out);
}
SecurityBridge.genSign.overload("java.util.Map").implementation = function (params) {
console.log("[*] Java genSign params = " + dumpMap(params));
var ret = this.genSign(params);
console.log("[*] Java genSign ret = " + ret);
return ret;
};
console.log("[*] Java layer hooked");
});
}
setImmediate(function () {
hookDlopen();
hookRegisterNatives();
if (Java.available) {
hookJava();
}
});
运行:
frida -U -f com.demo.app -l all_in_one.js
逐步验证清单
这部分我建议你真照着做,效率会高很多。
第 1 轮:确认请求与字段
- 抓到登录请求
- 明确
sign字段位置 - 确认时间戳、nonce 是否每次变化
第 2 轮:确认 Java 入口
- 找到
sign写入请求的地方 - 找到
genSign或可疑方法 - Hook 到输入参数与输出结果
第 3 轮:确认 JNI 入口
- 找到
System.loadLibrary - 确认目标 so 名称
- Hook
RegisterNatives - 拿到 Native 函数地址
第 4 轮:确认算法细节
- 排查排序规则
- 排查 salt
- 排查编码方式
- 排查设备字段参与
- 用 Python 离线复现
第 5 轮:最终校验
- 用自己生成的 sign 发请求
- 服务端返回成功
- 多组样本都成立
常见坑与排查
这一节非常重要。很多时候不是“不会”,而是被细节卡住。
1. Hook 不到方法
常见原因:
- 类名写错
- 重载没选对
- App 用了壳或动态加载
- Hook 时机太早/太晚
排查建议:
Java.perform(function () {
var SecurityBridge = Java.use("com.demo.app.security.SecurityBridge");
console.log(SecurityBridge.genSign.overloads);
});
先打印重载列表,再精确指定参数签名。
2. App 一启动就闪退
可能是:
- Frida 被检测
- 调试环境被检测
- root 被检测
- 多进程导致你挂错进程
排查思路:
- 先
frida-ps -Uai看进程 - 尝试附加而不是 spawn
- 先 Hook 常见反调试点,如
ptrace、fgets、syscall - 先在模拟器和真机都试一遍
3. Java 层拿到的参数不完整
这很常见。因为真正参与签名的值,可能在 Native 层动态补进去,例如:
- Android ID
- 设备型号
- 安装时间
- app version
- so 内固定盐值
这时你要做两件事:
- 看请求最终 body
- 看 Native 函数内部是否调用了设备信息相关 API
4. Native 地址每次变
这是 ASLR 正常现象。
不要把静态地址硬写死,应该用:
RegisterNatives动态打印- 或
Module.findBaseAddress("libsec.so").add(offset)
例如:
var base = Module.findBaseAddress("libsec.so");
var target = base.add(0x1234);
console.log("target =", target);
前提是你已经从静态分析中知道偏移量。
5. Hook 到返回值,但看不懂
Native 返回 jstring 时,onLeave 里看到的只是对象指针,不是字符串内容。
这时最简单的办法仍然是:在 Java 层拿字符串值。
中级阶段没必要一开始就在 Native 层自己解 JNI 字符串。
6. 算法明明看对了,但结果总差一点
我见过最常见的几个原因:
- 参数顺序不对
- 某个字段被 trim 了
- 时间戳单位是毫秒不是秒
- 拼接前 password 先做了一次 MD5
- Hex 输出大小写不一致
- 最终字符串末尾多了一个
& - 空值字段被跳过了
- 使用了
TreeMap自动排序
这个时候别盲猜,至少准备 3 组样本,对比输入与输出变化规律。
安全/性能最佳实践
虽然这是逆向分析文章,但很多做法本身也要讲边界。
1. 只在授权范围内分析
请仅用于:
- 自有 App 调试
- 安全研究
- 合法授权测试
- 教学实验环境
不要对未授权生产系统做协议复现或绕过尝试。
2. 优先 Hook 关键节点,不要全量轰炸
Frida 很方便,但过度 Hook 会带来:
- 日志爆炸
- 性能下降
- 线程时序变化
- App 行为异常
更稳妥的策略是:
- 先抓登录链路
- 再 Hook 1~3 个关键方法
- 必要时加条件判断,只打印目标请求
例如只在登录用户名出现时打印:
if (dumpMap(params).indexOf("alice") !== -1) {
console.log("target login request");
}
3. 日志中避免泄露真实账号密码
分析真实业务时,日志里往往会出现:
- 手机号
- 密码
- token
- 设备标识
建议脱敏处理,例如:
function mask(s) {
if (!s || s.length < 4) return s;
return s.substring(0, 2) + "****" + s.substring(s.length - 2);
}
4. 静态分析与动态分析要结合
只靠静态分析,容易看不出真实运行路径;
只靠动态 Hook,又容易漏掉隐藏常量和边界分支。
比较稳的做法是:
- Java Hook 看输入输出
- JNI Hook 看边界
- so 静态分析 看常量、流程和偏移
- Python 复现 做闭环验证
5. 对 Native 层优先关注“规则”,不是“汇编细节”
中级读者最容易陷入一个误区:花很多时间抠寄存器,却忽略了真正决定签名成败的是:
- 排序
- 拼接格式
- 盐值
- 编码
真正影响复现成功率的,往往不是反汇编看懂了多少,而是样本对比和验证做得够不够细。
一个简化的离线复现示例
假设通过 Hook 和静态分析,我们最终确认规则如下:
- 参数按 key 升序
- 拼接为
key=value&key2=value2 - 末尾追加
&salt=native_9f2b - 计算 SHA256
- 输出小写十六进制
那就可以这样复现:
import hashlib
def gen_sign(params, salt):
items = sorted(params.items(), key=lambda x: x[0])
base = "&".join(f"{k}={v}" for k, v in items)
raw = f"{base}&salt={salt}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
if __name__ == "__main__":
params = {
"username": "alice",
"password": "123456",
"timestamp": "1701480000",
"nonce": "8f3c2a"
}
print(gen_sign(params, "native_9f2b"))
如果生成结果能和 App 一致,说明整个定位链路已经闭环。
总结
定位 App 登录签名逻辑,真正有效的方法不是一上来猛啃 so,而是按层推进:
- 抓包确定目标请求
- Java 层找到 sign 的生成入口
- Hook 输入输出,确认是否在 Native
- 用 RegisterNatives 锁定 JNI 函数地址
- 结合 Native Hook 与静态分析还原规则
- 最后用 Python 离线复现并验证
如果你现在就准备动手,我建议按这个最小执行路径开始:
- 先用
jadx搜sign和loadLibrary - 用 Frida Hook
genSign(Map) - 再 Hook
RegisterNatives - 拿到 Native 地址后,只追“排序、拼接、盐值、摘要、编码”这五件事
这套方法对登录签名、请求验签、设备指纹摘要,基本都通用。
最后再强调一次:
逆向的关键不是“看懂所有代码”,而是构建证据链。
能稳定拿到输入、输出、规则和校验结果,你就已经完成了最有价值的部分。