背景与问题
做 Android 安全分析时,登录鉴权几乎总是最先要碰的模块。原因很现实:你想理解一个 App 的业务入口、请求签名、设备标识、风控参数,往往都得从登录流程下手。
但实际分析时,常见情况并不“友好”:
- Java 层代码被混淆,类名方法名全是
a/b/c - 请求参数里混着时间戳、随机串、摘要签名、设备指纹
- 关键逻辑不一定在一个函数里,可能跨 UI、ViewModel、网络层、JNI
- 抓包能看到请求,但看不出参数是怎么生成的
这篇文章我会从一个中级实战视角来带你走一遍:
先用 JADX 静态定位登录入口与可疑方法,再用 Frida 动态 Hook 核心参数生成点,最后还原 APK 的登录鉴权流程。
目标不是“全自动秒杀”,而是建立一套可复用的方法论。
前置知识与环境准备
你需要了解什么
建议你至少具备以下基础:
- Android APK 的基本结构
- Java/Kotlin 常见语法
- HTTP 请求基本组成
- Frida 的基础 Hook 用法
- JADX 的搜索与交叉引用能力
环境准备
建议环境如下:
- Android 模拟器或测试机(Root 更方便,但不是必须)
adbjadx-guifrida,frida-tools- 与目标设备匹配的
frida-server - 抓包工具:Charles / Fiddler / mitmproxy 任选其一
安装示例:
pip install frida frida-tools
adb devices
frida --version
如果你使用真机:
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
查看进程:
frida-ps -U
分析目标与整体思路
先说一下这类问题最有效的路径。我一般采用“四步走”:
- 抓包确认登录请求
- JADX 静态定位登录相关类
- Frida 动态 Hook 参数构造与加密函数
- 交叉验证请求字段来源,画出完整链路
这套思路的好处是:
不会一上来就在混淆代码海里乱翻,也不会只靠抓包“猜”。
分析流程图
flowchart TD
A[启动 App 并抓包] --> B[定位登录接口 URL]
B --> C[在 JADX 搜索接口路径/字段名]
C --> D[找到登录 Presenter/ViewModel/Repository]
D --> E[追踪参数组装函数]
E --> F[定位签名/摘要/时间戳生成逻辑]
F --> G[使用 Frida Hook 关键函数]
G --> H[还原完整鉴权流程]
核心原理
这一部分很关键。你如果只会“搜字符串 + Hook 一把梭”,很快就会卡住。真正稳定的做法是理解 APK 登录鉴权通常长什么样。
登录鉴权链路通常包含什么
一个典型登录请求,常见字段包括:
- 用户名/手机号
- 密码或加密后的密码
- 时间戳
ts - 随机数
nonce - 设备标识
deviceId - App 版本
appVersion - 签名
sign - Token / 临时票据 / challenge
其中最值得关注的是:
- 密码是否本地加密
- sign 的输入参数集合
- sign 的算法与密钥来源
- 是否调用 JNI / so
- 是否有双重编码(如 JSON→Base64→AES)
静态分析与动态分析的分工
JADX 适合做什么
- 找登录入口 Activity / Fragment / ViewModel
- 看网络层封装,比如 Retrofit/OkHttp
- 找请求字段名、接口路径、常量字符串
- 追踪哪个类负责拼接参数
Frida 适合做什么
- 在运行时拿到真实参数
- 看混淆方法的入参与返回值
- Hook
MessageDigest、Mac、Cipher - Hook
OkHttp请求体 - 绕过“看得懂代码但跑起来才知道值”的问题
一个常见的登录鉴权调用关系
sequenceDiagram
participant U as 用户
participant A as LoginActivity
participant VM as LoginViewModel
participant R as LoginRepository
participant S as SignUtil
participant N as OkHttp/Retrofit
participant API as Server
U->>A: 输入账号密码并点击登录
A->>VM: submit(username, password)
VM->>R: login(...)
R->>S: buildSign(ts, nonce, deviceId, ...)
S-->>R: sign
R->>N: 发起 POST /login
N->>API: username/password/ts/nonce/sign
API-->>N: token/session
N-->>R: 响应
R-->>VM: 登录结果
VM-->>A: 更新 UI
背景与问题:如何从 APK 中找到登录鉴权入口
很多人第一次打开 JADX,会被一堆混淆类劝退。这里我建议你别从类名入手,而是从请求特征入手。
第一步:先抓包,不要盲看代码
先登录一次,关注这些信息:
- 请求 URL,比如
/user/login、/passport/auth - 请求方法:
POST/GET - 请求体格式:JSON / Form / Protobuf
- 字段名:
sign,ts,nonce,pwd,token - Header:
Authorization,X-Sign,X-Device-Id
如果 HTTPS 抓不到内容,也没关系,至少先拿到:
- 域名
- 路径
- 请求频率
- 发包时机
这些信息足够帮助你在 JADX 里缩小范围。
第二步:在 JADX 中搜索这些锚点
重点搜索:
- 登录接口路径片段,如
login - 固定字段名,如
sign,nonce,deviceId - Retrofit 注解:
@POST,@GET,@Headers - OkHttp 相关类:
Request.Builder,Interceptor
一个常见定位路径
- 找到
ApiService接口 - 找到调用它的
Repository - 找到调用
Repository.login()的 ViewModel/Presenter - 找到参数拼接函数
你会发现,真正有价值的函数名即使被混淆,也常有这些特征:
- 参数很多
- 有
Map<String, String>或JSONObject - 拼接字符串后做
md5/sha1/sha256 - 调用了
Base64.encodeToString - 返回 sign 或 request body
用 JADX 做静态定位
这一节,我们从“定位登录鉴权链路”这个角度来拆。
1. 找 Retrofit 接口定义
示例代码可能长这样:
@POST("/api/user/login")
Call<LoginResp> login(@Body RequestBody body);
或者:
@FormUrlEncoded
@POST("/passport/login")
Call<LoginResp> login(
@Field("mobile") String mobile,
@Field("password") String password,
@Field("ts") String ts,
@Field("sign") String sign
);
如果接口被混淆,看不到明确名字,就搜索:
@POST(/loginRequestBodyMultipartBody
2. 找参数组装点
常见样子:
HashMap map = new HashMap();
map.put("mobile", mobile);
map.put("password", encrypt(password));
map.put("ts", String.valueOf(System.currentTimeMillis()));
map.put("nonce", UUID.randomUUID().toString().replace("-", ""));
map.put("deviceId", DeviceUtil.getId(context));
map.put("sign", SignUtil.sign(map));
这时候要重点盯住:
encrypt(password)DeviceUtil.getId(context)SignUtil.sign(map)
3. 找签名算法
通常可以通过以下关键词搜索:
MessageDigest.getInstanceMac.getInstanceCipher.getInstanceSHA-256MD5HmacSHA256
例如:
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(src.getBytes("UTF-8"));
return bytesToHex(md.digest());
或者:
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "HmacSHA256");
mac.init(keySpec);
return hex(mac.doFinal(data.getBytes("UTF-8")));
4. 判断是否进了 Native 层
如果你看到:
System.loadLibrary("sec");
public static native String sign(String data);
说明核心逻辑可能在 so 里。
这时不要强行先啃 so,优先用 Frida 在 Java 调用点把参数和返回值截出来,往往效率最高。
实战代码(可运行)
下面给出一套我常用的 Frida 脚本组合。思路是:
- 先 Hook Java 常见加密类
- 再 Hook 目标 App 的可疑签名类
- 最后 Hook OkHttp 请求,验证参数是否一致
说明:类名需要按你的目标 App 实际修改。
代码可以直接运行,必要时替换包名和可疑类名。
脚本一:通用摘要/加密函数监控
文件:hook_crypto.js
Java.perform(function () {
function bytesToHex(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
if (v < 0) v += 256;
result.push(('0' + v.toString(16)).slice(-2));
}
return result.join('');
}
var MessageDigest = Java.use('java.security.MessageDigest');
MessageDigest.getInstance.overload('java.lang.String').implementation = function (alg) {
var ret = this.getInstance(alg);
console.log('[MessageDigest.getInstance] alg = ' + alg);
return ret;
};
MessageDigest.digest.overload().implementation = function () {
var ret = this.digest();
console.log('[MessageDigest.digest] ret = ' + bytesToHex(ret));
return ret;
};
MessageDigest.digest.overload('[B').implementation = function (input) {
var ret = this.digest(input);
console.log('[MessageDigest.digest(bytes)] inputHex = ' + bytesToHex(input));
console.log('[MessageDigest.digest(bytes)] ret = ' + bytesToHex(ret));
return ret;
};
var Mac = Java.use('javax.crypto.Mac');
Mac.getInstance.overload('java.lang.String').implementation = function (alg) {
var ret = this.getInstance(alg);
console.log('[Mac.getInstance] alg = ' + alg);
return ret;
};
Mac.doFinal.overload('[B').implementation = function (input) {
var ret = this.doFinal(input);
console.log('[Mac.doFinal] inputHex = ' + bytesToHex(input));
console.log('[Mac.doFinal] retHex = ' + bytesToHex(ret));
return ret;
};
});
运行方式:
frida -U -f com.target.app -l hook_crypto.js --no-pause
这个脚本适合快速判断:
- 登录签名是不是 MD5/SHA/HMAC
- 输入数据是不是你猜测的参数串
- 返回值是不是请求里的
sign
脚本二:Hook 可疑签名类
假设你在 JADX 中找到了类似下面的类:
public class SignUtil {
public static String sign(String data) { ... }
}
那么可以写:
文件:hook_sign.js
Java.perform(function () {
var SignUtil = Java.use('com.target.app.utils.SignUtil');
SignUtil.sign.overload('java.lang.String').implementation = function (data) {
console.log('================ sign() called ================');
console.log('[input] ' + data);
var ret = this.sign(data);
console.log('[output] ' + ret);
console.log('================================================');
return ret;
};
});
运行:
frida -U -f com.target.app -l hook_sign.js --no-pause
如果方法重载较多,可以先枚举:
Java.perform(function () {
var SignUtil = Java.use('com.target.app.utils.SignUtil');
SignUtil.sign.overloads.forEach(function (o) {
console.log(o);
});
});
脚本三:Hook HashMap 参数组装
很多登录参数是先放进 HashMap 再统一签名的。这个点非常适合观察字段来源。
文件:hook_hashmap_put.js
Java.perform(function () {
var HashMap = Java.use('java.util.HashMap');
HashMap.put.overload('java.lang.Object', 'java.lang.Object').implementation = function (k, v) {
var ret = this.put(k, v);
var key = k ? k.toString() : 'null';
var val = v ? v.toString() : 'null';
if (
key.indexOf('sign') >= 0 ||
key.indexOf('ts') >= 0 ||
key.indexOf('nonce') >= 0 ||
key.indexOf('password') >= 0 ||
key.indexOf('device') >= 0
) {
console.log('[HashMap.put] ' + key + ' = ' + val);
}
return ret;
};
});
这个脚本很“笨”,但在实战里经常有效。
尤其当类名混淆严重、还没找到精确入口时,它能帮你快速看出登录参数是在什么时机被写入的。
脚本四:Hook OkHttp 请求体
如果目标使用 OkHttp,可以在发包前把 body 打出来。
文件:hook_okhttp.js
Java.perform(function () {
var RequestBuilder = Java.use('okhttp3.Request$Builder');
RequestBuilder.build.implementation = function () {
var request = this.build();
try {
var url = request.url().toString();
var method = request.method();
console.log('========== OkHttp Request ==========');
console.log('[URL] ' + url);
console.log('[Method] ' + method);
var headers = request.headers();
console.log('[Headers] ' + headers.toString());
var body = request.body();
if (body) {
var Buffer = Java.use('okio.Buffer');
var buffer = Buffer.$new();
body.writeTo(buffer);
console.log('[Body] ' + buffer.readUtf8());
}
console.log('====================================');
} catch (e) {
console.log('hook okhttp error: ' + e);
}
return request;
};
});
运行:
frida -U -f com.target.app -l hook_okhttp.js --no-pause
这个脚本的价值在于:
你可以把“函数返回值”和“最终发包内容”对应起来。
组合验证:还原 sign 生成逻辑
很多 App 的 sign 逻辑大概是这样:
sign = md5(username + password_md5 + ts + nonce + secret)
或者:
sign = hmac_sha256(sortedQueryString, appKey)
你可以这样验证:
- Hook
HashMap.put,拿到原始字段 - Hook
SignUtil.sign,拿到输入字符串 - Hook
MessageDigest/Mac,拿到实际摘要值 - Hook OkHttp,确认请求中的 sign 是否一致
当四处信息能对上时,你就基本还原了整个登录鉴权过程。
一个完整的定位案例思路
这里给一个更贴近真实场景的“推演模板”。
场景设定
抓包发现登录请求:
POST /api/v2/login
Content-Type: application/json
{
"mobile": "13800000000",
"password": "6f1ed002ab5595859014ebf0951522d9",
"ts": "1720000000000",
"nonce": "4f3a8d...",
"deviceId": "A1B2C3D4",
"sign": "d41d8cd98f00b204e9800998ecf8427e"
}
静态分析推断
JADX 中搜索:
/api/v2/logindeviceIdnonce
找到:
LoginRepositoryDeviceManager.getDeviceId()EncryptUtil.md5()SignManager.buildSign(Map)
动态验证链路
flowchart LR
A[抓包发现 sign/ts/nonce] --> B[JADX 搜索字段名]
B --> C[找到 LoginRepository.login]
C --> D[Hook HashMap.put]
D --> E[Hook SignManager.buildSign]
E --> F[Hook MessageDigest.digest]
F --> G[Hook OkHttp 请求体]
G --> H[确认 sign 与请求一致]
还原出的可能流程
- 明文密码先做一次 MD5
mobile/password/ts/nonce/deviceId放进 Map- 按 key 排序拼成查询字符串
- 末尾拼接固定 secret
- 再做一次 MD5 作为 sign
- 组装 JSON 发到
/api/v2/login
这个过程如果只看抓包,你只能看到结果;
如果只看静态代码,你可能猜不准排序和 secret;
两者结合,才容易把细节补齐。
逐步验证清单
建议按下面清单执行,不容易乱。
第 1 轮:只做静态定位
- 找到登录接口路径
- 找到网络请求入口类
- 找到参数组装类
- 标记可疑函数:密码处理、sign、deviceId、nonce
第 2 轮:只看运行时值
- Hook
HashMap.put - Hook
JSONObject.put - Hook
Request.Builder.build - Hook
MessageDigest/Mac
第 3 轮:精确命中业务函数
- Hook 目标签名类
- 打印函数入参与返回值
- 打印调用栈确认来源
- 复核请求体字段是否一致
第 4 轮:形成可复现结论
- 明确 password 是否本地加密
- 明确 sign 的输入参数顺序
- 明确是否存在 secret/appKey
- 明确设备标识来源
- 明确是否涉及 Native 层
常见坑与排查
这部分我想多说几句,因为实战里真正耗时间的,往往不是“不会 Hook”,而是“为什么没 Hook 到”。
1. App 启动即闪退,Frida 注入后崩溃
常见原因:
- Frida 版本与设备架构不匹配
- 目标 App 有反调试/反注入
- 使用
-f冷启动时触发时序问题
排查建议:
frida-ps -U
adb logcat | grep -i frida
adb logcat | grep -i crash
可以尝试:
- 先手动启动 App,再
-n附加 - 降低 Hook 范围,先只 Hook 一个类
- 延迟执行 Hook
示例:
setTimeout(function () {
Java.perform(function () {
console.log('delayed hook start');
});
}, 3000);
2. Hook 到了 MessageDigest,但日志太多看不出登录流程
这是高频问题。因为很多 App 到处都在做摘要,比如缓存、埋点、资源校验。
解决方式:
- 结合 URL 或调用栈过滤
- 只在登录动作前后操作一次
- 给可疑线程打标记
- 优先 Hook 业务类,而不是全局基础类
打印调用栈:
Java.perform(function () {
var Exception = Java.use('java.lang.Exception');
function printStack() {
console.log(
Java.use('android.util.Log')
.getStackTraceString(Exception.$new())
);
}
var SignUtil = Java.use('com.target.app.utils.SignUtil');
SignUtil.sign.overload('java.lang.String').implementation = function (data) {
console.log('[input] ' + data);
printStack();
var ret = this.sign(data);
console.log('[output] ' + ret);
return ret;
};
});
3. OkHttp Body 打不出来
原因通常有:
- 请求体是二进制
- 读取一次后流被消费
- 混淆后实际不是标准 OkHttp 版本
- 请求经过自定义拦截器二次包装
建议:
- 优先 Hook
RequestBody.writeTo - 或 Hook 自定义 Interceptor
- 如果是 gzip/protobuf,需要额外解码
4. 类名找到了,但 Hook 报错 ClassNotFoundException
可能原因:
- 类还没加载
- 多 Dex 动态加载
- 包名看错
- Kotlin 内部类名称不一致
排查方法:
先枚举相关类:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf('Sign') >= 0 || name.indexOf('login') >= 0) {
console.log(name);
}
},
onComplete: function () {}
});
});
如果是多 Dex,等类加载后再 Hook。
5. 关键逻辑在 Native 层
这是很多人会卡住的地方,但也不用一上来就硬上 IDA。
先做两件事:
- Hook Java 的 native 调用入口
- 记录 native 方法的入参和返回值
例如:
Java.perform(function () {
var NativeSign = Java.use('com.target.app.security.NativeSign');
NativeSign.sign.overload('java.lang.String').implementation = function (data) {
console.log('[NativeSign.sign input] ' + data);
var ret = this.sign(data);
console.log('[NativeSign.sign output] ' + ret);
return ret;
};
});
很多时候,你并不需要马上知道 so 里每一行代码在干什么,只要能拿到输入输出,就足够还原协议。
安全/性能最佳实践
逆向分析不仅是“搞明白”,还要控制影响面。尤其在动态 Hook 时,日志量和稳定性都很关键。
1. 优先最小化 Hook 范围
不要一上来 Hook 全部:
HashMap.putJSONObject.putMessageDigestCipherMacOkHttp- 所有业务类
这样很容易把 App 卡死,或者日志刷到你根本看不懂。
建议顺序:
- 先 Hook 发包点
- 再 Hook 签名类
- 最后才补全局基础类
2. 对日志做过滤
例如只看登录相关 URL:
if (url.indexOf('/login') >= 0) {
console.log('[URL] ' + url);
}
只看特定字段:
if (key === 'sign' || key === 'ts' || key === 'nonce') {
console.log('[HashMap.put] ' + key + '=' + val);
}
3. 注意数据脱敏
如果你是在企业内部做安全测试,日志中可能包含:
- 手机号
- 密码摘要
- token
- 设备标识
- 用户隐私数据
建议至少对以下字段脱敏后再保存:
mobilepasswordtokensessionId
4. 不要在生产环境直接做高强度动态分析
Frida Hook 本身会引入额外开销:
- 方法调用变慢
- 日志 IO 放大
- 某些对象被重复序列化
- 大量 Hook 可能触发 ANR
最佳实践是:
- 用测试账号
- 在测试机/隔离环境分析
- 控制日志量
- 每次只验证一个问题
5. 明确边界:分析协议,不等于破坏系统
这类技术适用于:
- 自研 App 安全测试
- 企业授权评估
- 漏洞复现与协议加固
- 学习 Android 安全机制
不适用于任何未授权的数据抓取、接口滥用、绕过业务控制等行为。
这一点边界一定要清楚。
方法总结:如何稳定定位“关键参数”
如果你只记住一句话,我建议记这个:
字段锚点 + 调用链追踪 + 运行时验证,比单纯搜代码或单纯抓包都更靠谱。
我推荐的固定套路
套路 1:从包到代码
- 抓包找到
/login - 搜 URL 和字段
- 找到 Repository / Interceptor / SignUtil
套路 2:从字段到函数
- 搜
sign,nonce,deviceId - 找
put()/JSONObject.put() - 找谁在最终写入这些值
套路 3:从算法到结果
- Hook
MessageDigest/Mac - 比对输入输出
- 确认 sign 是否由这些字段生成
套路 4:从结果回溯来源
- Hook OkHttp body
- 看到最终请求字段
- 反推每个字段的生成函数
总结
这篇文章围绕一个核心目标展开:
如何用 JADX + Frida 分析 APK 的登录鉴权流程,并定位关键参数生成逻辑。
我们重点做了几件事:
- 用抓包先确定登录请求特征
- 用 JADX 静态定位登录入口、参数组装点和签名类
- 用 Frida Hook 加密函数、业务签名函数、请求体构造过程
- 通过交叉验证,还原
password、ts、nonce、deviceId、sign的来源与关系
如果你准备真正上手,我建议从最小目标开始:
- 先确认最终请求长什么样
- 再确认 sign 是谁生成的
- 最后再拆 password、deviceId、native 等细节
不要试图第一轮就把所有逻辑一次性看穿。
我自己做这类分析时,最有效的方式也是:先缩小问题,再局部击穿。
如果目标 App 混淆严重、逻辑分散,记住这条实战经验:
JADX 负责告诉你“可能在哪里”,Frida 负责告诉你“运行时到底发生了什么”。
把这两者配合起来,登录鉴权流程通常都能比较快地理清。