背景与问题
做 Android 逆向时,登录接口几乎总是“第一现场”:
- 请求参数里有
sign、token、timestamp、nonce - 明文账号密码并不直接上传
- Java 层看起来逻辑简单,但关键实现藏在 so、反射、壳代码或动态注册方法里
- 抓包能看到请求,却很难知道参数到底怎么拼出来的
这篇文章我换一个更贴近实战的角度:不从“找算法”入手,而是从“找链路”入手。
核心目标不是直接爆破某个签名函数,而是回答下面几个问题:
- 登录按钮点击后,参数生成链路经过了哪些类和方法?
- 哪一层完成了摘要、加密、拼接和设备信息注入?
- App 有加固、反调试、证书检测时,Frida 应该 Hook 哪些点,才能稳定拿到明文入参与最终签名?
- 如果 Java 层什么都看不出来,如何判断是否已经下沉到 Native?
注意:本文内容仅用于安全研究、协议分析、企业自测与授权测试场景。不要用于未授权的目标。
前置知识与环境准备
本文默认你已经有这些基础:
- 会安装 Android 模拟器或真机调试环境
- 知道如何使用
adb - 了解基础抓包流程
- 知道 Frida / JADX 是什么
建议环境:
- Android 8 ~ 13 任一测试机
jadx-guifrida-toolsobjection(可选)adb- 抓包工具(Charles、mitmproxy、Burp 均可)
安装示例:
pip install frida-tools
adb devices
frida-ps -U
如果要在真机上跑,记得准备匹配架构的 frida-server。
分析目标与总体思路
在加固 App 里,单靠 JADX 反编译通常只能看到“表层”:
- 壳 Application
- 一堆混淆类名
- 关键逻辑被抽走
- native 方法没有实现体
- 通过反射或代理调用真实代码
所以实战上更稳的路径通常是:
- 抓包定位登录请求
- 从请求字段反推 Java 层调用入口
- JADX 找按钮、Presenter、Repository、OkHttp 封装
- Frida Hook 请求构造点、加密函数、Native 入口
- 必要时绕过 SSL Pinning / Root / Debug 检测
- 比对 Hook 输出,画出完整参数生成链路
可以把它理解成:静态分析负责缩小范围,动态 Hook 负责拿真值。
核心原理
1. 登录参数不是“一个函数算出来的”,而是一条链路
很多中级同学一上来就搜索 md5、sha1、aes,其实经常会走偏。
真实 App 的登录参数更像这样生成:
flowchart TD
A[点击登录按钮] --> B[ViewModel/Presenter 收集账号密码]
B --> C[请求对象 RequestBody 构建]
C --> D[注入设备信息/版本号/时间戳]
D --> E[字段排序/拼接 canonical string]
E --> F[摘要或 HMAC]
F --> G[必要时再做 AES/RSA]
G --> H[OkHttp/Retrofit 发起请求]
也就是说:
sign可能只对部分字段做签名password可能先做一次本地摘要token可能来自先前设备注册接口- 真正关键的不是算法名,而是输入集合与顺序
2. 加固 App 常见障碍
常见阻碍主要有四类:
壳与代码延迟加载
你在 jadx 里看到的入口 Application 未必是真实业务 Application。
很多壳会在运行时解密 dex,再通过自定义 ClassLoader 加载。
混淆与反射
方法名变成 a() / b() / c(),但数据流仍在。
这时要抓“调用位置”和“参数形态”,而不是执着于类名。
Native 下沉
Java 层只有:
public static native String sign(String input);
真正实现藏在 so 里,甚至是动态注册,JADX 根本看不到。
运行时对抗
包括:
- Frida 检测
- ptrace / 调试检测
- root 检测
- 证书锁定
- 代理检测
所以静态分析只是起点,动态对抗能力决定你能不能走到最后。
第一步:从抓包反推参数结构
先抓登录包,重点看这几类字段:
- 固定字段:
appVersion、platform、channel - 时变字段:
timestamp、nonce - 敏感字段:
password、sign - 关联字段:
deviceId、oaid、token
一个很常见的登录请求体大概长这样:
{
"username": "test",
"password": "8f14e45fceea167a5a36dedd4bea2543",
"timestamp": "1711111111",
"nonce": "f2ab91c3",
"deviceId": "aabbccdd",
"sign": "A6D9C2E7..."
}
这时不要急着猜 sign 是怎么来的,先做三件事:
- 改一次用户名,看哪些字段变化
- 固定用户名改密码,看哪些字段变化
- 多发几次同样输入,看
nonce/timestamp/sign是否同步变化
这样可以快速判断:
password是否已本地摘要sign是否覆盖账号密码- 是否存在随机盐或设备绑定
第二步:JADX 静态定位调用入口
1. 先找登录按钮触发链路
在 jadx-gui 里优先搜:
- 登录页文案:
登录/login - 接口路径片段:
/login - 字段名:
password、sign、timestamp - Retrofit 注解:
@POST
如果 App 混淆严重,先从布局、资源和字符串入手通常更快。
典型链路如下:
sequenceDiagram
participant UI as LoginActivity
participant P as LoginPresenter
participant R as AuthRepository
participant S as SignUtil
participant N as NativeLib
participant O as OkHttp
UI->>P: onLoginClick(user, pwd)
P->>R: login(user, hashPwd)
R->>S: buildSign(params)
S->>N: nativeSign(canonical)
N-->>S: sign
S-->>R: signedParams
R->>O: POST /api/login
2. 优先盯住这些关键点
请求模型类
例如:
class LoginRequest {
String username;
String password;
String timestamp;
String nonce;
String sign;
}
请求构造器
例如:
Map<String, String> map = new HashMap<>();
map.put("username", user);
map.put("password", pwd);
map.put("timestamp", ts);
map.put("nonce", nonce);
map.put("sign", SignUtil.a(map));
通用签名工具类
即使类名混淆,常见特征仍然明显:
- 遍历
Map Collections.sortStringBuilder.appendMessageDigestMac.getInstance("HmacSHA256")- native 方法调用
3. 遇到“看得见调用,看不见实现”怎么办
比如看到:
public class x {
public static native String a(String s);
}
这就说明关键逻辑可能在 so。此时要记录:
- Java 调用类名和方法签名
- 入参类型
- 调用前后数据形态
- so 文件名加载位置
后面 Frida 就可以从这些点切。
第三步:Frida 动态 Hook 还原参数生成链路
下面进入重点。
我的经验是:别一上来 Hook 全世界,先 Hook 关键节点,确认链路,再逐步下钻。
1. Hook Java 层登录入口
先拿到原始用户名密码输入。
Java.perform(function () {
var LoginPresenter = Java.use("com.demo.auth.LoginPresenter");
LoginPresenter.login.overload("java.lang.String", "java.lang.String").implementation = function (u, p) {
console.log("[LoginPresenter.login] user=" + u + ", pwd=" + p);
return this.login(u, p);
};
});
运行:
frida -U -f com.demo.app -l hook_login.js
如果类找不到,不一定是你写错了,可能是:
- 壳尚未释放真实 dex
- 类被延迟加载
- 包名版本不一致
这时先用 Java.enumerateLoadedClasses() 或延迟 Hook。
2. Hook 摘要与签名函数
如果静态分析里看到是 Java 层 MessageDigest,直接 Hook:
Java.perform(function () {
var MessageDigest = Java.use("java.security.MessageDigest");
var StringCls = Java.use("java.lang.String");
MessageDigest.digest.overload("[B").implementation = function (input) {
var algo = this.getAlgorithm();
var inStr = "";
try {
inStr = StringCls.$new(input);
} catch (e) {
inStr = "<binary>";
}
var out = this.digest(input);
var outHex = "";
for (var i = 0; i < out.length; i++) {
var v = out[i];
if (v < 0) v += 256;
outHex += ("0" + v.toString(16)).slice(-2);
}
console.log("[MessageDigest] algo=" + algo + " input=" + inStr + " output=" + outHex);
return out;
};
});
这个 Hook 很实用,因为它不依赖业务类名。
哪怕业务层全混淆,只要最后走了 MD5/SHA,你就能看到输入。
3. Hook Map 拼接与 sign 生成点
如果你已经在 JADX 中定位到签名工具类,比如 com.demo.sec.SignUtil:
Java.perform(function () {
var SignUtil = Java.use("com.demo.sec.SignUtil");
SignUtil.buildSign.overload("java.util.Map").implementation = function (map) {
console.log("====== buildSign begin ======");
var entrySet = map.entrySet();
var iterator = entrySet.iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
console.log(entry.getKey() + " = " + entry.getValue());
}
var ret = this.buildSign(map);
console.log("[buildSign result] " + ret);
console.log("====== buildSign end ======");
return ret;
};
});
这一步价值极高,因为你能直接拿到:
- 签名前参与的字段集合
- 各字段的最终值
- 最终生成出来的
sign
很多时候只靠这一层,你就已经可以把算法复现到 Python 了。
4. Hook OkHttp 最终请求体
如果前面都定位不到,就从网络发包出口兜底。
Java.perform(function () {
var RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBuilder.build.implementation = function () {
var req = this.build();
try {
console.log("[HTTP] " + req.method() + " " + req.url().toString());
var headers = req.headers().toString();
console.log("[Headers]\n" + headers);
} catch (e) {
console.log("[HTTP] parse error: " + e);
}
return req;
};
});
更进一步可以 Hook RequestBody.writeTo(),但不同版本 OkHttp 适配略有差异,建议根据目标版本调整。
第四步:Java 层无果时,Hook Native 签名函数
很多加固 App 的 sign 最终会下沉到 so。
这时要先确认两件事:
- so 是否已加载
- native 方法是静态注册还是动态注册
1. 监听 so 加载
setImmediate(function () {
var dlopen = Module.findExportByName(null, "android_dlopen_ext");
if (dlopen) {
Interceptor.attach(dlopen, {
onEnter: function (args) {
this.path = args[0].readCString();
},
onLeave: function (retval) {
if (this.path && this.path.indexOf(".so") !== -1) {
console.log("[dlopen] " + this.path);
}
}
});
}
});
2. Hook JNI 动态注册
很多 so 不导出 Java_com_xxx_xxx 符号,而是通过 RegisterNatives 动态注册。
setImmediate(function () {
var addr = Module.findExportByName("libart.so", "_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi");
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function (args) {
var methods = args[2];
var count = args[3].toInt32();
console.log("[RegisterNatives] count=" + count);
for (var i = 0; i < count; i++) {
var base = methods.add(i * Process.pointerSize * 3);
var namePtr = base.readPointer();
var sigPtr = base.add(Process.pointerSize).readPointer();
var fnPtr = base.add(Process.pointerSize * 2).readPointer();
var name = namePtr.readCString();
var sig = sigPtr.readCString();
console.log(" name=" + name + " sig=" + sig + " fn=" + fnPtr);
}
}
});
});
一旦看到类似:
signencodedoCommandNativegetToken
这类名字,就可以继续定点 Hook 对应函数地址。
3. Hook Native 导出函数
假设你已经拿到函数地址:
var base = Module.findBaseAddress("libsign.so");
var target = base.add(0x1234);
Interceptor.attach(target, {
onEnter: function (args) {
console.log("[native sign] called");
this.arg0 = args[0];
this.arg1 = args[1];
},
onLeave: function (retval) {
console.log("[native sign] ret=" + retval);
}
});
如果入参是 jstring,就不能直接 readCString(),要回到 Java 环境转换,或者 Hook Java 声明层通常更省事。
第五步:Hook 绕过常见对抗
这部分往往决定你脚本是否“能稳定活着”。
1. SSL Pinning 绕过
很多登录接口即使抓到请求,也会因为证书锁定看不到明文。
常见绕过点:
okhttp3.CertificatePinner- 自定义
X509TrustManager HostnameVerifier
示例:
Java.perform(function () {
try {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overload("java.lang.String", "java.util.List").implementation = function (a, b) {
console.log("[Bypass] CertificatePinner.check: " + a);
return;
};
} catch (e) {
console.log("CertificatePinner hook failed: " + e);
}
});
2. Root 检测绕过
很多 App 会查这些东西:
su文件test-keyswhich sugetprop ro.debuggable
示例 Hook:
Java.perform(function () {
var File = Java.use("java.io.File");
File.exists.implementation = function () {
var path = this.getAbsolutePath();
if (path.indexOf("/su") !== -1 || path.indexOf("magisk") !== -1) {
console.log("[Bypass] File.exists -> false : " + path);
return false;
}
return this.exists();
};
});
3. Debug / Frida 检测绕过
常见检查:
android.os.Debug.isDebuggerConnected()- 遍历进程端口
- 搜索
frida字符串 - 检测
/proc/self/maps
最基础的先把 Java 层调试检测掐掉:
Java.perform(function () {
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
console.log("[Bypass] isDebuggerConnected -> false");
return false;
};
});
逐步验证清单
建议你按下面顺序验证,不要一次堆太多 Hook:
阶段 1:链路存在性确认
- App 能正常启动
- 能进入登录页
- 抓包能看到登录接口
- 登录按钮触发方法可被 Hook
阶段 2:参数流确认
- Hook 到用户名密码原始输入
- Hook 到密码摘要前或摘要后值
- Hook 到
Map或请求体构造 - 拿到最终
sign
阶段 3:下沉确认
- Java 层找不到完整算法
- 发现 native 方法声明
- 监听到目标 so 加载
- RegisterNatives 中看到疑似签名函数
阶段 4:对抗绕过
- SSL Pinning 绕过后抓包恢复
- Root 检测不再影响流程
- Frida 附加后 App 不闪退
- 重启多次脚本仍稳定
一套更完整的可运行 Frida 脚本
下面给一份更偏实战的组合脚本,覆盖:
- 登录入口
- 摘要算法
- 签名工具
- SSL Pinning 绕过
你需要把类名改成你目标 App 的实际值。
function bytesToHex(bytes) {
var hex = "";
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
if (v < 0) v += 256;
hex += ("0" + v.toString(16)).slice(-2);
}
return hex;
}
Java.perform(function () {
var StringCls = Java.use("java.lang.String");
// 1. Hook 登录入口
try {
var LoginPresenter = Java.use("com.demo.auth.LoginPresenter");
LoginPresenter.login.overload("java.lang.String", "java.lang.String").implementation = function (u, p) {
console.log("\n[+] LoginPresenter.login");
console.log(" username = " + u);
console.log(" password = " + p);
return this.login(u, p);
};
} catch (e) {
console.log("[-] LoginPresenter hook failed: " + e);
}
// 2. Hook MessageDigest
try {
var MessageDigest = Java.use("java.security.MessageDigest");
MessageDigest.digest.overload("[B").implementation = function (input) {
var algo = this.getAlgorithm();
var inputStr = "<binary>";
try {
inputStr = StringCls.$new(input);
} catch (e) {}
var out = this.digest(input);
console.log("\n[+] MessageDigest.digest");
console.log(" algo = " + algo);
console.log(" input = " + inputStr);
console.log(" output = " + bytesToHex(out));
return out;
};
} catch (e) {
console.log("[-] MessageDigest hook failed: " + e);
}
// 3. Hook 签名工具类
try {
var SignUtil = Java.use("com.demo.sec.SignUtil");
SignUtil.buildSign.overload("java.util.Map").implementation = function (map) {
console.log("\n[+] SignUtil.buildSign");
var iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
console.log(" " + entry.getKey() + " = " + entry.getValue());
}
var ret = this.buildSign(map);
console.log(" sign = " + ret);
return ret;
};
} catch (e) {
console.log("[-] SignUtil hook failed: " + e);
}
// 4. SSL Pinning 绕过
try {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overload("java.lang.String", "java.util.List").implementation = function (host, list) {
console.log("\n[+] Bypass CertificatePinner for " + host);
return;
};
} catch (e) {
console.log("[-] CertificatePinner hook failed: " + e);
}
console.log("[*] Hooks installed");
});
启动方式:
frida -U -f com.demo.app -l all_in_one.js --no-pause
参数生成链路的复原方法
当你拿到 Hook 输出后,建议按下面方式整理,不然信息很快会乱。
示例整理模板
1. 用户输入
username = test
password = 123456
2. 密码预处理
md5(password) = e10adc3949ba59abbe56e057f20f883e
3. 请求字段构造
username = test
password = e10adc3949ba59abbe56e057f20f883e
timestamp = 1711111111
nonce = abcd1234
deviceId = 7f9a...
4. 签名原文
deviceId=7f9a...&nonce=abcd1234&password=e10...×tamp=1711111111&username=test
5. 签名结果
sign = 8C71D0...
6. 最终请求
POST /api/login
把这六步串起来,你就得到了一条完整链路。
这比只知道“它用了 MD5”有价值得多。
常见坑与排查
1. 类名明明在 JADX 里,Frida 却 Hook 不到
原因通常有:
- 类还没加载
- 壳未释放
- 多 dex / 动态 dex
- 类名被你抄错了内部类符号
排查建议:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("demo") !== -1) {
console.log(name);
}
},
onComplete: function () {}
});
});
必要时改成 spawn 模式并延迟执行 Hook。
2. Hook 了方法却没有输出
通常是重载签名不对。
例如同名方法有多个重载,参数类型必须精确匹配。
先打印重载:
Java.perform(function () {
var Cls = Java.use("com.demo.sec.SignUtil");
console.log(Cls.buildSign.overloads);
});
3. App 一附加 Frida 就闪退
大概率是 Frida 检测或反调试。
处理顺序建议是:
- 先
spawn再注入 - 只保留最小 Hook
- Hook
isDebuggerConnected - 再补
/proc、端口、字符串检测绕过
4. 抓包还是看不到明文
不要只怀疑 SSL Pinning,也可能是:
- App 自己二次加密请求体
- 使用 protobuf
- gzip / deflate 压缩
- 请求体在 Native 层构造
这时就去 Hook:
okio.BufferRequestBody.writeTo- 业务序列化方法
- Native 入口
5. MessageDigest Hook 输出乱码
很正常,因为输入不一定是文本。
处理办法:
- 同时打印十六进制
- 不要强行转字符串
- 关注调用栈和前后文
安全/性能最佳实践
这部分很容易被忽略,但在真实项目里非常重要。
1. 只 Hook 关键路径,避免全局扫射
全局 Hook HashMap.put、StringBuilder.append 这种方法虽然“信息很多”,但也非常容易:
- 造成日志爆炸
- 拖慢 App
- 引发卡顿甚至 ANR
- 淹没真正有价值的调用
更好的做法是:
- 先静态定位业务类
- 再小范围定点 Hook
- 最后才考虑底层兜底
2. 日志输出要结构化
建议统一格式:
[模块][方法][时间] 关键信息
例如:
[sign][buildSign][12:01:03] username=test
这样你后面对照抓包时间线会轻松很多。
3. 优先在测试机、隔离网络中进行
原因很现实:
- 防止误触正式账号风控
- 便于抓包和证书安装
- 可自由 root、改 hosts、重启服务
- 更容易复现问题
4. 遵守授权边界
即使技术上能分析,也不代表可以随便分析。
请确保是:
- 自研 App
- 企业授权测试目标
- 合法课程实验环境
- 明确许可的研究对象
5. Native Hook 注意稳定性
Native 层 Hook 一旦地址算错,App 直接崩。
建议:
- 先确认 so 基址
- 注意 32/64 位差异
- 结合
RegisterNatives确认目标函数 - 一次只挂一个点
一个常用的分析决策图
当你不知道该从哪下手时,可以按这张图走:
flowchart TD
A[抓到登录请求] --> B{JADX 能否定位登录入口?}
B -- 能 --> C[Hook Presenter/Repository]
B -- 不能 --> D[Hook OkHttp 请求出口]
C --> E{能否看到 sign 构造?}
E -- 能 --> F[记录字段与顺序并复现]
E -- 不能 --> G[查 native 方法/反射调用]
D --> G
G --> H[监听 so 加载与 RegisterNatives]
H --> I{存在对抗?}
I -- 是 --> J[绕过 SSL/Root/Debug 检测]
I -- 否 --> K[定点 Hook Native 函数]
J --> K
K --> L[还原完整参数链路]
总结
这类“加固 App 登录参数分析”最容易犯的错,就是把注意力过早集中在某个算法上。
但实战里真正决定成败的是:你有没有把整条参数生成链路走通。
建议你记住这套顺序:
- 抓包看字段变化
- JADX 找登录入口和请求构造
- Frida Hook Java 层输入、摘要、签名工具
- 必要时下钻 Native 与 RegisterNatives
- 同步处理 SSL Pinning、Root、Debug 对抗
- 把链路整理成“输入 → 处理 → 拼接 → 签名 → 发包”
如果你已经是中级读者,我给一个很实用的建议:
以后碰到新目标,不要先问“它是 MD5 还是 AES”,而要先问:
- 参数在哪一层定型?
- 参与签名的字段有哪些?
- 字段顺序是否固定?
- 哪一步开始下沉到 Native?
- 对抗是阻止抓包,还是阻止 Hook?
当你能稳定回答这些问题,登录参数分析基本就进入可控状态了。