安卓逆向实战:从 Frida Hook 到协议还原分析 App 登录鉴权流程
很多人学安卓逆向时,都会先从“Hook 一个 Toast”开始,但真到业务场景,往往第一道门槛就是:App 登录到底怎么做鉴权的?请求参数从哪来?签名怎么算?加密在什么位置?
这篇文章我想从一个更接近真实工作的角度,带你完整走一遍:
- 如何定位登录入口
- 如何用 Frida Hook Java 层关键函数
- 如何追到 Native 或加密封装前的明文
- 如何还原登录请求协议
- 如何判断 token、时间戳、签名的生成链路
这不是一篇只讲概念的文章,我会尽量按“实战排查”的节奏组织,适合已经会一点 adb、jadx、Frida 基础,但还没有系统做过登录鉴权分析的同学。
声明:本文仅用于授权测试、安全研究与学习分析,请勿用于未授权的生产 App 逆向、抓包、绕过鉴权等行为。
背景与问题
在实际分析一个 App 登录流程时,我们常见到的现象不是“一个明文 POST 请求”这么简单,而是下面这种组合拳:
- 用户输入账号密码
- Java 层做参数拼装
- 某个工具类追加时间戳、设备 ID、版本号
- 进入签名函数
- 可能再做一次 AES / RSA / 自定义编码
- 请求通过 OkHttp / Retrofit 发出
- 服务端返回 token、refreshToken、sessionKey 等
问题在于:
- 抓包时你可能看不到明文
- 请求体可能被 gzip、protobuf、AES 包裹
- 签名逻辑可能被混淆
- SSL Pinning 让你抓不到 HTTPS
- 真正关键的参数不在网络层,而是在加密前、序列化前生成
所以,分析登录鉴权流程最有效的思路通常不是一上来死磕抓包,而是:
静态分析定位入口 + 动态 Hook 观察关键数据 + 回溯协议字段含义
前置知识
建议你至少具备这些基础:
- 会使用
jadx看 Java / Kotlin 反编译代码 - 了解安卓常见网络栈:
OkHttp、Retrofit - 会用
adb连设备 - 会写基本 Frida Hook 脚本
- 知道 MD5 / SHA256 / HMAC / AES / RSA 的基本区别
如果这些你已经有一些概念,那这篇文章就可以直接跟着做。
环境准备
这里我按比较常见的组合来准备:
- Android 真机或模拟器
- 已安装目标 App
- PC 已安装:
adbjadxfrida-tools- Python 3
- 设备端:
- 与目标架构匹配的
frida-server
- 与目标架构匹配的
1. 查看设备架构
adb shell getprop ro.product.cpu.abi
2. 推送并启动 frida-server
adb push frida-server /data/local/tmp/
adb shell "chmod +x /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
3. 确认 Frida 可见进程
frida-ps -U
如果这里已经报错,比如 unable to connect to remote frida-server,先不要急着写 Hook,优先把环境打通。
分析总流程
先给出一张全局图,明确我们这一套分析方法的路径。
flowchart TD
A[静态分析 APK] --> B[定位登录 Activity / ViewModel / Presenter]
B --> C[找到网络请求入口 Retrofit / OkHttp]
C --> D[定位参数构造函数]
D --> E[Hook 签名/加密前关键方法]
E --> F[抓取明文参数与签名输入]
F --> G[对照网络请求还原协议字段]
G --> H[验证是否可复现登录请求]
这张图里最关键的一点是:先找调用链,再下 Hook。
我自己早期踩过一个坑,就是对着整个 App 到处 Hook MessageDigest、Cipher,日志多得看不完,最后连哪个是登录请求都分不清。
核心原理
要把登录鉴权分析清楚,核心其实就三件事。
1. 登录请求不是“一个包”,而是一条调用链
登录按钮点击以后,请求通常会经过:
- UI 层:收集用户名密码
- 业务层:封装
LoginRequest - 安全层:签名、加密、补充设备参数
- 网络层:序列化并发送
你真正要还原的是这条链上的关键节点,而不是只盯着最终流量。
2. 协议还原的重点是“字段来源”而不是“字段长什么样”
例如一个请求体长这样:
{
"username": "alice",
"password": "xxxxxx",
"ts": 1670000000,
"nonce": "A1B2C3",
"sign": "9f2d..."
}
你需要搞清楚:
password是明文、MD5、还是 AES 后的值?ts是秒级、毫秒级,还是服务端时间偏移?nonce是随机数,还是设备绑定值?sign的输入串是什么?字段顺序如何?是否包含固定盐值?
只知道“包长这样”还不够,必须知道“它为什么这样”。
3. Hook 要尽量贴近“业务关键点”
常见优先级一般是:
- 登录按钮点击后调用的 ViewModel / Presenter 方法
- 登录接口对应的 Retrofit 方法
- 参数对象构造函数 / setter
- 签名函数
- 加密函数
- OkHttp 请求构建位置
- Native 层导出函数(必要时)
静态分析:先把调用链摸出来
拿到 APK 后,先用 jadx 看:
1. 搜索登录关键词
常见关键词包括:
loginsigntokenauthpassportsessionpassword
如果混淆严重,可以反过来找:
- 登录页面的文本资源 ID
Retrofit接口注解,如@POST("/login")OkHttpClient拦截器- 数据类字段,如
username,pwd,mobile
2. 找网络接口定义
例如你可能在 jadx 里看到类似代码:
@POST("/api/v1/user/login")
Call<LoginResp> login(@Body LoginReq req);
这时别急着只盯着这个接口,更关键的是顺着调用找到:
LoginReq是谁构造的sign是在哪里塞进去的- 发请求前有没有统一拦截器追加 header
3. 找请求体构造点
常见样子:
LoginReq req = new LoginReq();
req.setUsername(username);
req.setPassword(Security.encode(password));
req.setTs(System.currentTimeMillis());
req.setNonce(DeviceUtil.randomNonce());
req.setSign(SignUtil.sign(req));
api.login(req);
这时候线索已经很清楚了:
Security.encode(password)SignUtil.sign(req)
这两个点就是优先 Hook 目标。
关键时序:登录鉴权链路怎么观察
下面用一张时序图把常见调用链串起来。
sequenceDiagram
participant U as 用户
participant A as LoginActivity
participant VM as LoginViewModel
participant S as Security/SignUtil
participant N as OkHttp/Retrofit
participant R as 服务端
U->>A: 输入账号密码并点击登录
A->>VM: login(username, password)
VM->>S: encode(password)
VM->>S: sign(params)
VM->>N: 发起 /login 请求
N->>R: HTTPS 请求
R-->>N: token / session / userInfo
N-->>VM: 解析响应
VM-->>A: 登录成功
分析时建议按这个顺序逐步验证:
- 输入参数是否原样传入业务层
- 密码是否先变换
- sign 输入值是否可见
- 请求头是否有额外鉴权字段
- 响应中的 token 是否被二次处理再落库
实战代码(可运行)
下面进入 Frida 实战。为了让代码更可复用,我用几个常见 Hook 点来组合。
说明:类名、方法名需要按你的目标 App 实际替换。
用法示例:frida -U -f 包名 -l hook_login.js --no-pause
1. Hook 登录方法入口
先从业务入口抓明文输入。这个阶段的目标只有一个:确定账号密码有没有被改写。
Java.perform(function () {
var LoginViewModel = Java.use("com.demo.app.login.LoginViewModel");
LoginViewModel.login.overload("java.lang.String", "java.lang.String").implementation = function (username, password) {
console.log("====== LoginViewModel.login ======");
console.log("username = " + username);
console.log("password = " + password);
var ret = this.login(username, password);
console.log("login() called.");
return ret;
};
});
如果这里拿到了原始用户名密码,说明:
- UI 到业务层这一段是明文
- 后续重点应该放在编码、签名、网络构造阶段
2. Hook 签名函数
这是登录鉴权分析里最有价值的一步。我们要看的是:
- sign 的输入值
- sign 的输出值
- 是否有固定盐、字段排序规则
Java.perform(function () {
var SignUtil = Java.use("com.demo.app.security.SignUtil");
SignUtil.sign.overload("java.util.Map").implementation = function (map) {
console.log("====== SignUtil.sign(Map) ======");
console.log("input map = " + map.toString());
var result = this.sign(map);
console.log("sign result = " + result);
return result;
};
});
如果 sign 不是 Map,而是业务对象,也可以这样打印对象内容:
Java.perform(function () {
var Gson = Java.use("com.google.gson.Gson");
var gson = Gson.$new();
var SignUtil = Java.use("com.demo.app.security.SignUtil");
SignUtil.sign.overload("com.demo.app.net.LoginReq").implementation = function (req) {
console.log("====== SignUtil.sign(LoginReq) ======");
console.log("req json = " + gson.toJson(req));
var result = this.sign(req);
console.log("sign result = " + result);
return result;
};
});
3. Hook 密码编码或加密函数
很多 App 并不会直接传密码明文,而是先做一次散列或加密。
Java.perform(function () {
var Security = Java.use("com.demo.app.security.Security");
Security.encode.overload("java.lang.String").implementation = function (plain) {
console.log("====== Security.encode ======");
console.log("plain password = " + plain);
var result = this.encode(plain);
console.log("encoded password = " + result);
return result;
};
});
如果这里发现:
- 输入是密码明文
- 输出是固定长度十六进制串
那大概率是 MD5 / SHA1 / SHA256 之类的散列。
如果输出是 Base64,可能是 AES / RSA 加密结果,继续追内部实现。
4. Hook OkHttp 请求,抓最终 URL、Header、Body
业务层看完后,要确认最终发出去的请求是什么样。
Java.perform(function () {
var RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBuilder.build.implementation = function () {
var request = this.build();
console.log("====== OkHttp Request ======");
console.log("URL = " + request.url().toString());
console.log("Method = " + request.method());
var headers = request.headers();
for (var i = 0; i < headers.size(); i++) {
console.log("Header: " + headers.name(i) + " = " + headers.value(i));
}
return request;
};
});
但这里有个现实问题:直接打印 request body 不太方便,因为 RequestBody 往往需要写入 buffer 才能取出内容。下面给一个更实用的版本。
Java.perform(function () {
var Buffer = Java.use("okio.Buffer");
var RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBuilder.build.implementation = function () {
var request = this.build();
console.log("====== OkHttp Request ======");
console.log("URL = " + request.url().toString());
console.log("Method = " + request.method());
var headers = request.headers();
for (var i = 0; i < headers.size(); i++) {
console.log("Header: " + headers.name(i) + " = " + headers.value(i));
}
var body = request.body();
if (body) {
try {
var buffer = Buffer.$new();
body.writeTo(buffer);
console.log("Body = " + buffer.readUtf8());
} catch (e) {
console.log("Body read error: " + e);
}
}
return request;
};
});
如果这里打印出来的是乱码、二进制、Base64 或压缩后内容,那么就说明真正需要观察的位置还得继续往前找。
5. 通用 Hook:MessageDigest 观察散列输入输出
当你还没找到具体签名函数时,可以先从通用加密 API 入手。
Java.perform(function () {
var MessageDigest = Java.use("java.security.MessageDigest");
var StringCls = Java.use("java.lang.String");
MessageDigest.getInstance.overload("java.lang.String").implementation = function (algo) {
console.log("MessageDigest algo = " + algo);
return this.getInstance(algo);
};
MessageDigest.digest.overload("[B").implementation = function (input) {
var result = this.digest(input);
try {
var str = StringCls.$new(input);
console.log("digest input = " + str);
} catch (e) {
console.log("digest input = [binary]");
}
console.log("digest called.");
return result;
};
});
这段脚本更适合“扫雷”,不适合长期挂着看全部日志。
因为很多 App 不止登录会调散列,图片缓存、配置校验、埋点都可能调用。
6. Python 复现签名逻辑
当你已经从 Hook 里拿到 sign 输入规律,就可以尝试协议复现。比如某 App 的签名逻辑是:
- 参数按 key 字典序排序
- 以
k=v&k=v... - 末尾拼接固定盐值
- 做 MD5
可以用 Python 这样验证:
import hashlib
def make_sign(params, salt):
items = sorted(params.items(), key=lambda x: x[0])
raw = "&".join(f"{k}={v}" for k, v in items)
raw = raw + salt
return hashlib.md5(raw.encode("utf-8")).hexdigest()
params = {
"username": "alice",
"password": "e10adc3949ba59abbe56e057f20f883e",
"ts": "1670000000",
"nonce": "A1B2C3"
}
salt = "demo_salt_123456"
print(make_sign(params, salt))
如果算出来的结果和 App 内部 Hook 到的一致,恭喜你,签名还原已经成功了一大半。
逐步验证清单
做这类分析时,我建议按下面这个清单一步一步勾,不容易乱。
第 1 步:确认登录入口
- 找到登录按钮对应方法
- 找到 ViewModel / Presenter 的登录函数
- 确认用户名密码参数流向
第 2 步:确认请求接口
- 找到 Retrofit / OkHttp 请求入口
- 确认
/login或等价接口路径 - 识别请求方法:POST / GET / GraphQL / protobuf
第 3 步:确认参数构造
- 用户名字段名
- 密码字段名
- 时间戳字段
- nonce / deviceId / appVersion 等附加字段
第 4 步:确认鉴权逻辑
- sign 的输入是什么
- sign 的算法是什么
- header 是否还有 token / x-sign / x-ts
第 5 步:确认响应处理
- token 从哪个字段返回
- token 是否持久化到 SharedPreferences / DB
- 后续请求如何携带 token
协议还原:如何判断字段含义
实际工作中,协议还原不只是“把 JSON 打出来”,而是要对字段做归类。下面是一个简化示意。
classDiagram
class LoginReq {
+String username
+String password
+long ts
+String nonce
+String sign
+String deviceId
+String appVersion
}
class SignSource {
+sortFields()
+concatKV()
+appendSalt()
+md5()
}
class AuthHeader {
+String xToken
+String xTs
+String xSign
}
LoginReq --> SignSource : sign generated by
AuthHeader --> SignSource : may reuse
你可以按下面思路判断:
1. 业务字段
usernamemobilepasswordsmsCode
这些通常和用户输入直接相关。
2. 环境字段
deviceIdoaidimei(旧版)brandmodelappVersion
这些一般用于风控、灰度、统计或设备绑定。
3. 安全字段
tsnoncesigntokensessionKey
这些是登录鉴权的核心,优先分析其生成和校验方式。
常见坑与排查
这一部分非常重要。我把自己和同事经常踩的坑,集中写一下。
1. Hook 了方法但没日志
可能原因:
- 方法重载写错了
- 类名不是最终运行时类
- App 用了 Kotlin suspend / 内联函数,调用点和你看到的不完全一致
- 类尚未加载
- 进程 attach 错了(多进程 App 特别常见)
排查建议:
frida-ps -Uai
确认包名、进程名后,再考虑是否需要 spawn 模式:
frida -U -f com.demo.app -l hook_login.js --no-pause
如果类加载时机有问题,可以枚举类:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("login") >= 0 || name.indexOf("security") >= 0) {
console.log(name);
}
},
onComplete: function () {}
});
});
2. OkHttp Body 打印为空
这通常有几种情况:
- 请求是表单流,不是 UTF-8 文本
- Body 被加密了
- 读取方式影响了一次性流
- 实际请求构建发生在更底层拦截器中
建议:
- 改 Hook 应用层参数构造函数
- Hook 自定义
Interceptor - Hook 序列化前的
toJson()或Gson.toJson()
3. 抓包抓不到,怀疑 SSL Pinning
你可能已经装了证书,但流量还是空白。很可能 App 做了证书绑定。
处理思路:
- Hook
X509TrustManager - Hook
HostnameVerifier - 直接 Hook 常见 SSL Pinning 检查逻辑
- 或者绕到明文构造阶段,不依赖抓包
我个人经验是:登录鉴权分析里,明文 Hook 往往比抓包更稳定。
因为即便抓到了密文,你还是得回到加密前。
4. 签名在 Native 层
如果 Java 层只看到:
public native String sign(String input);
那就说明关键逻辑进了 so。此时可以:
- Hook
System.loadLibrary - 用 Frida
Interceptor.attach盯 JNI 导出 - 配合
nm/strings/ghidra/IDA看 so
比如先 Hook 库加载:
Java.perform(function () {
var System = Java.use("java.lang.System");
System.loadLibrary.overload("java.lang.String").implementation = function (lib) {
console.log("loadLibrary: " + lib);
return this.loadLibrary(lib);
};
});
如果确认 so 已加载,再进一步定位 native 符号。
5. 混淆后方法名看不懂
这是常态,不是意外。
混淆后别死盯类名,换几个维度定位:
- 看字符串常量
- 看 Retrofit 注解 URL
- 看字段结构
- 看调用关系
- 看谁在登录按钮点击后被调用
有时候一个叫 a.a.a.b.c() 的方法,实际上就是登录入口。
安全/性能最佳实践
这部分既是为了逆向分析更高效,也是在提醒边界。
1. 优先 Hook 业务关键节点,减少全局噪音
不建议一开始就全局 Hook:
Cipher.doFinalMessageDigest.digestBase64.encodeToString
虽然这些方法很通用,但噪音巨大。更好的做法是:
- 先静态定位登录调用链
- 再局部 Hook 登录相关类
- 最后必要时补通用 API Hook
2. 对日志做“最小化输出”
账号密码、token、设备标识都属于敏感信息。做研究时应:
- 避免保存完整生产数据
- 脚本里可只打印长度、前后缀
- 分析完成后清理日志
例如:
function mask(s) {
if (!s || s.length < 6) return s;
return s.substring(0, 3) + "****" + s.substring(s.length - 3);
}
3. 不要长时间高频 Hook 热路径
像 OkHttp、MessageDigest、Cipher 这类方法如果全量打印,可能导致:
- App 卡顿
- 日志淹没关键信息
- 某些场景下触发反调试或超时
建议在登录操作前后短时间启用,完成验证就停止。
4. 对还原结果做交叉验证
一个签名逻辑即便你“看起来理解了”,也最好用三种方式确认:
- Hook 输出
- 抓包字段
- Python 复现结果
三者一致,才说明还原比较可靠。
5. 明确合法边界
只对自己有授权的 App 或测试环境做分析。
特别是登录鉴权流程,往往直接接触账号、密码、token、设备指纹,这些都属于高敏感数据。
一套可落地的分析策略
如果你现在手上就有一个待分析的 App,我建议按这个顺序开工:
flowchart LR
A[先用 jadx 找登录入口] --> B[Hook login 方法拿明文输入]
B --> C[Hook SignUtil / Security 类]
C --> D[Hook OkHttp 看最终请求]
D --> E[对照字段来源]
E --> F[Python 复现 sign]
F --> G[验证协议是否可还原]
这套顺序的好处是:
- 不容易被抓包问题卡死
- 更容易看清参数来源
- 一旦签名还原成功,后续接口分析会快很多
总结
回到本文主题:从 Frida Hook 到协议还原分析 App 登录鉴权流程,核心并不是“会几个 Hook API”,而是建立一条稳定的方法论:
- 静态分析定位调用链
- 动态 Hook 观察关键参数
- 锁定签名/加密函数
- 对照网络请求还原字段含义
- 用脚本复现并交叉验证
如果你只记住一个结论,那就是:
登录鉴权分析最重要的不是抓到最终密文包,而是找到“明文参数到签名结果”之间的生成路径。
最后给几个可执行建议:
- 第一次做时,不要同时 Hook 太多点,先抓入口再扩展
- 遇到 SSL Pinning,不要硬扛抓包,优先转向明文构造阶段
- 遇到 Native 签名,不要慌,先确认 Java 到 so 的边界
- 协议还原必须做 Python 复现,否则容易“看懂了但验证不了”
如果你已经能独立完成:
- 登录明文参数定位
- sign 输入输出确认
- 请求头/请求体字段归因
- Python 复现签名
那你就不只是“会用 Frida”,而是已经具备了比较扎实的安卓登录鉴权逆向分析能力。