安卓逆向实战:基于 Frida 与 JADX 的登录参数加密链路定位与复现
很多同学学安卓逆向时,最容易卡住的不是“怎么抓包”,而是抓到了包,却发现登录参数根本看不懂:明明输入的是手机号和密码,发出去却变成了一长串加密串、签名值、时间戳和设备指纹。
这篇文章我想带你完整走一遍:如何借助 JADX 做静态分析,用 Frida 做动态验证,最终定位登录参数加密链路,并在脚本里复现出来。重点不是某个 App 的特例,而是一套可以迁移到大多数 Android 登录场景的方法。
说明:本文内容仅用于授权测试、教学研究与自有应用安全分析,请勿用于未授权目标。
背景与问题
在真实 App 中,登录接口通常不会直接把明文密码发给后端,常见做法包括:
- 前端先做一次
MD5/SHA/AES/RSA处理 - 混入时间戳、随机数、设备信息
- 对整个请求体做签名
- 使用 native so 层参与加密
- 请求参数在 Retrofit / OkHttp 拦截器里二次加工
所以我们面对的典型问题通常不是“这个参数是什么”,而是下面这几个更棘手的问题:
- 加密逻辑在哪一层发生?
- 是 Java 层还是 native 层?
- 加密前原文是什么?
- 参与签名的字段有哪些?顺序如何?
- 如何脱离 App,独立复现这条链路?
如果只靠抓包,通常只能看到结果;如果只靠反编译,常常会被混淆、跳转和壳绕晕。所以我更推荐的套路是:
- JADX 找入口和候选函数
- Frida 在关键节点动态打印参数
- 逐步缩小范围,锁定真正的加密链路
- 最后在 Python/JS 中独立复现
前置知识
建议你至少熟悉这些内容:
- Android 基础组件调用关系
- HTTP 抓包基础
- Java 常见加密类:
MessageDigest、Cipher、Mac - Frida 基本 Hook 语法
- JADX 搜索、交叉引用与调用链查看
如果这些还不熟,也没关系,文中会尽量按“带着做一遍”的方式写。
环境准备
本文默认环境如下:
- Android 测试机或模拟器
- 已安装目标 App
jadx-guifrida-tools- Python 3
adb- 可选:Burp Suite / Charles
安装 Frida 工具:
pip install frida-tools
查看设备:
adb devices
frida-ps -U
如果能正常列出进程,说明基本环境没问题。
核心原理
先别急着上代码,先把思路建立起来。
一个登录请求从“点击按钮”到“发出网络包”,中间通常会经历以下阶段:
- UI 层收集输入
- ViewModel / Presenter / Controller 组装登录模型
- 参数预处理
- trim
- 拼接固定盐值
- 加时间戳、nonce
- 密码或请求体加密
- 统一签名
- 交给 Retrofit / OkHttp 发送
也就是说,我们真正要找的不是某个“神秘加密函数”,而是一条参数流转链路。
flowchart TD
A[输入手机号/密码] --> B[点击登录按钮]
B --> C[登录业务方法]
C --> D[组装请求对象]
D --> E[密码加密/请求体加密]
E --> F[签名生成]
F --> G[Retrofit/OkHttp发送]
G --> H[服务端校验]
静态分析和动态分析怎么配合?
- JADX 适合回答:
“可能在哪里做了加密?” - Frida 适合回答:
“运行时到底是不是这里?传入和传出值是什么?”
这是我平时最常用的分工方式:
sequenceDiagram
participant U as 分析者
participant J as JADX
participant F as Frida
participant A as 目标App
U->>J: 搜索 login/sign/encrypt/md5/aes
J-->>U: 返回候选类与调用链
U->>F: Hook 候选方法
F->>A: 动态拦截运行时调用
A-->>F: 返回入参与结果
F-->>U: 确认真实加密链路
U->>U: 独立复现参数生成
第一步:用 JADX 找登录链路入口
打开 APK 到 JADX 后,我一般先做三类搜索。
1. 搜索登录文案和接口路径
关键词示例:
loginsignpassword/user/login手机号验证码密码登录
如果 App 没混淆彻底,往往能直接搜到 Retrofit 接口:
@POST("/user/login")
Call<LoginResp> login(@Body LoginReq req);
或者:
@FormUrlEncoded
@POST("/api/auth/login")
Call<ResponseBody> login(
@Field("mobile") String mobile,
@Field("pwd") String pwd,
@Field("sign") String sign
);
2. 搜索常见加密 API
这些类经常是突破口:
java.security.MessageDigestjavax.crypto.Cipherjavax.crypto.MacSecretKeySpecIvParameterSpecBase64RSA/ECB/PKCS1PaddingAES/CBC/PKCS5Padding
比如在 JADX 里全局搜 MessageDigest.getInstance,常常能拉出一批摘要函数。
3. 搜索签名拼接特征
很多签名逻辑并不复杂,常见模式:
key=value&key2=value2TreeMap排序后拼接- 末尾追加
secret md5(str).toUpperCase()
例如你可能看到类似代码:
public static String sign(Map<String, String> map, String secret) {
TreeMap<String, String> treeMap = new TreeMap<>(map);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : treeMap.entrySet()) {
sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
}
sb.append("secret=").append(secret);
return MD5Util.md5(sb.toString()).toUpperCase();
}
看到这种代码,基本就能判断:登录参数可能不是单独加密,而是“密码先处理 + 请求整体签名”。
第二步:缩小候选范围
只靠静态分析,容易遇到两个问题:
- 搜到十几个 MD5/AES 方法,不知道哪个是真的
- 调用链被混淆,方法名像
a.a.a()、b.c.d()
这时我会从网络发送前往回找,而不是从 UI 层一直往下翻。
常见回溯点
- Retrofit 接口定义
- OkHttp 拦截器
- 请求对象
LoginReq - 序列化前的模型加工方法
- 通用签名工具类
一个很实用的判断标准是:
离发包越近的方法,越可能拿到“最终有效参数”。
比如如果你在 Interceptor 中看到:
String sign = SignUtil.sign(params, AppConfig.SECRET);
params.put("sign", sign);
那么 SignUtil.sign 就必须动态验证。
第三步:用 Frida 动态确认加密点
下面开始进入实战。
目标
我们要回答三个问题:
- 登录密码加密前原文是什么?
- 加密后结果是什么?
- 签名字符串的拼接原文是什么?
方案
优先 Hook 这些点:
- 登录请求对象构造函数
- 加密工具方法
- 签名方法
MessageDigest.digestCipher.doFinal- OkHttp 请求发送前
实战代码:Hook Java 层常见加密链路
下面这份 Frida 脚本可以直接运行,适合作为通用模板。
1. Hook 常见摘要与 AES/RSA 调用
Java.perform(function () {
var MessageDigest = Java.use('java.security.MessageDigest');
var Cipher = Java.use('javax.crypto.Cipher');
var StringCls = Java.use('java.lang.String');
var Base64 = Java.use('android.util.Base64');
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('');
}
function safeStr(bytes) {
try {
return StringCls.$new(bytes);
} catch (e) {
return '[binary data]';
}
}
MessageDigest.digest.overload('[B').implementation = function (input) {
var algo = this.getAlgorithm();
console.log('\n[MessageDigest.digest]');
console.log('algo = ' + algo);
console.log('input(str) = ' + safeStr(input));
console.log('input(hex) = ' + bytesToHex(input));
var ret = this.digest(input);
console.log('output(hex) = ' + bytesToHex(ret));
return ret;
};
Cipher.doFinal.overload('[B').implementation = function (input) {
console.log('\n[Cipher.doFinal]');
console.log('algorithm = ' + this.getAlgorithm());
console.log('input(str) = ' + safeStr(input));
console.log('input(hex) = ' + bytesToHex(input));
var ret = this.doFinal(input);
console.log('output(Base64) = ' + Base64.encodeToString(ret, 2));
console.log('output(hex) = ' + bytesToHex(ret));
return ret;
};
});
运行方式:
frida -U -f com.example.app -l hook_crypto.js
如果 App 已经启动,也可以附加:
frida -U com.example.app -l hook_crypto.js
这份脚本能解决什么?
它适合快速判断:
- App 是否在 Java 层做摘要/对称加密/非对称加密
- 明文输入是什么
- 输出结果是否与抓包字段一致
如果你发现打印出的输出和请求里的 password、sign、data 字段高度一致,就说明你找对方向了。
实战代码:精准 Hook 登录请求构造与签名函数
通用 Hook 能帮你发现“有加密”,但要想复现,就必须知道哪个业务方法在处理登录参数。
假设我们在 JADX 中找到了如下可疑类:
com.demo.auth.LoginRequestcom.demo.security.SignUtilcom.demo.security.CryptoUtil
那么可以精准 Hook。
Java.perform(function () {
var LoginRequest = Java.use('com.demo.auth.LoginRequest');
var SignUtil = Java.use('com.demo.security.SignUtil');
var CryptoUtil = Java.use('com.demo.security.CryptoUtil');
LoginRequest.$init.overload('java.lang.String', 'java.lang.String').implementation = function (mobile, password) {
console.log('\n[LoginRequest.$init]');
console.log('mobile = ' + mobile);
console.log('password(raw) = ' + password);
return this.$init(mobile, password);
};
CryptoUtil.encryptPassword.overload('java.lang.String').implementation = function (pwd) {
console.log('\n[CryptoUtil.encryptPassword]');
console.log('input = ' + pwd);
var ret = this.encryptPassword(pwd);
console.log('output = ' + ret);
return ret;
};
SignUtil.sign.overload('java.util.Map').implementation = function (map) {
console.log('\n[SignUtil.sign]');
var iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
console.log(entry.getKey() + ' = ' + entry.getValue());
}
var ret = this.sign(map);
console.log('sign = ' + ret);
return ret;
};
});
判断是否命中
当你点击登录时,理想输出链路应该类似这样:
[LoginRequest.$init]
mobile = 13800138000
password(raw) = 123456
[CryptoUtil.encryptPassword]
input = 123456
output = e10adc3949ba59abbe56e057f20f883e
[SignUtil.sign]
mobile = 13800138000
password = e10adc3949ba59abbe56e057f20f883e
timestamp = 1700000000
nonce = abcdef
sign = 8F3A2A...
一旦看到这种日志,复现就基本进入收尾阶段了。
第四步:必要时 Hook OkHttp,拿最终出站参数
很多人会踩一个坑:你 Hook 到了业务层的参数,但真正发出去的请求又被拦截器改了一次。
比如:
- 增加公共参数
- 对 body 整体加密
- 重新计算签名
- Header 里塞 token 和设备信息
所以还需要在网络层做最终确认。
Hook RequestBody 写出内容
Java.perform(function () {
var Buffer = Java.use('okio.Buffer');
var RequestBody = Java.use('okhttp3.RequestBody');
RequestBody.writeTo.overload('okio.BufferedSink').implementation = function (sink) {
var buffer = Buffer.$new();
this.writeTo(buffer);
try {
console.log('\n[RequestBody.writeTo]');
console.log(buffer.readUtf8());
} catch (e) {
console.log('[RequestBody.writeTo] binary body');
}
this.writeTo(sink);
};
});
如果请求体是 JSON/Form,一般可以直接看到最终内容。
逐步验证清单
为了避免“以为自己找到了,其实还差一步”,我建议按下面清单核对:
- 输入密码明文已拿到
- 密码处理函数已定位
- 请求签名函数已定位
- 时间戳/nonce/设备号来源已确认
- 最终出站请求体已抓到
- 本地复现结果与 App 发包一致
这个过程里我最常见的失误是:只复现了密码加密,却忘了整体签名。结果服务端仍然返回签名错误。
第五步:用 Python 复现加密链路
下面给一个典型复现示例。假设通过 Hook 我们已经确认:
- 密码先做
MD5 - 所有参数按 key 排序拼接
- 末尾加
secret - 再做一次
MD5().upper()
Python 复现代码
import hashlib
import time
import uuid
def md5_hex(s: str) -> str:
return hashlib.md5(s.encode("utf-8")).hexdigest()
def encrypt_password(password: str) -> str:
return md5_hex(password)
def build_sign(params: dict, secret: str) -> str:
items = sorted(params.items(), key=lambda x: x[0])
raw = "&".join(f"{k}={v}" for k, v in items)
raw = raw + f"&secret={secret}"
print("[sign raw]", raw)
return md5_hex(raw).upper()
def build_login_payload(mobile: str, password: str) -> dict:
ts = str(int(time.time()))
nonce = uuid.uuid4().hex[:8]
payload = {
"mobile": mobile,
"password": encrypt_password(password),
"timestamp": ts,
"nonce": nonce,
"platform": "android"
}
payload["sign"] = build_sign(payload, "test_secret_123")
return payload
if __name__ == "__main__":
data = build_login_payload("13800138000", "123456")
print(data)
如果你的 Hook 日志与 Python 输出一致,说明链路已经成功复现。
一种更复杂的情况:密码加密在 Java 层,签名在 native 层
现实里还有一种很常见的情况:
- Java 代码里只能看到
native String sign(String raw); - 真正签名逻辑在
.so里
这时候不要慌,定位方法还是一样,只是切换到边界点思维:
- Hook Java 层调用 native 前的参数
- Hook native 返回结果
- 对比抓包字段
- 再决定要不要深入 so 层
flowchart LR
A[Java层组装原始串] --> B[native sign(raw)]
B --> C[返回签名结果]
C --> D[写入请求参数]
D --> E[发包]
在很多场景下,如果你的目标只是“复现请求”,其实未必需要完全还原 so 内部逻辑。
只要能在边界处拿到原始输入和输出,就已经足够做代理调用或做进一步分析。
常见坑与排查
这一部分我尽量写得“接地气”一点,因为很多问题真的不是理论问题,而是环境和细节问题。
1. Hook 不生效
常见原因:
- 包名写错
- Hook 时机太晚
- 方法重载没选对
- App 多进程,挂错进程了
排查建议:
frida-ps -Uai
看清楚进程名,必要时加 -f 冷启动:
frida -U -f com.example.app -l hook.js
如果方法有重载,先枚举:
Java.perform(function () {
var Cls = Java.use('com.demo.security.SignUtil');
console.log(Cls.sign.overloads);
});
2. 打印出来是乱码或二进制
原因通常是:
- 输入不是 UTF-8 文本
- 已经是压缩/加密后的字节流
- 是 protobuf、gzip 或自定义二进制协议
排查建议:
- 同时打印
hex和Base64 - 不要只相信字符串输出
- 对 OkHttp body 再做一次确认
3. 以为找到了加密函数,其实只是辅助函数
比如你看到一个 md5(),很兴奋,结果它只是算设备指纹,不是登录密码。
排查思路:
- 看调用栈
- 看调用时机:是否在点击登录后触发
- 看输出是否进入最终请求参数
必要时直接打印栈:
Java.perform(function () {
var Exception = Java.use('java.lang.Exception');
var Log = Java.use('android.util.Log');
var CryptoUtil = Java.use('com.demo.security.CryptoUtil');
CryptoUtil.encryptPassword.overload('java.lang.String').implementation = function (s) {
console.log(Log.getStackTraceString(Exception.$new()));
return this.encryptPassword(s);
};
});
4. 参数总是对不上
这个问题我自己踩过很多次,常见漏项包括:
- 时间戳格式不对:秒还是毫秒
- nonce 长度不对
- 签名前是否 URL 编码
- 参数是否按字典序排序
- 空值字段是否参与签名
sign自己是否排除在签名外- 大小写不同:
md5().upper()vsmd5().lower()
我的建议是:
不要凭感觉猜,逐字段比对 App 运行时日志。
5. App 有反 Frida 检测
常见现象:
- 一注入就闪退
- 某些页面打不开
- 登录按钮无响应
思路一般有两种:
- 先过检测,再做业务 Hook
- 尽量用更隐蔽的注入方式
如果只是学习研究,建议先选择防护弱一些的样本练手。不要一上来就跟重保护 App 硬碰硬,不然容易把时间都耗在环境对抗上。
安全/性能最佳实践
这一节既是给做分析的人,也是给做客户端安全的人。
对逆向分析过程的实践建议
1. 先定位边界,再追实现
不要一开始就死磕每一行代码。优先找:
- 登录入口
- 参数构造点
- 发包前最终形态
这样效率高很多。
2. 小步验证,不要一次 Hook 一大片
很多同学喜欢把几十个类全 Hook 上,结果日志爆炸,反而更难看。
建议顺序是:
- 先 Hook 通用加密 API
- 再 Hook 候选业务类
- 最后 Hook 网络出站
3. 日志只保留关键字段
打印太多会拖慢 App,甚至导致卡顿或 ANR。
特别是循环内 Hook、频繁 digest 的场景,要控制输出量。
对 App 安全设计的建议
如果你站在开发或安全加固视角,单纯把密码做一次 MD5,其实防护价值很有限。
更稳妥的思路包括:
- 使用 TLS,不依赖前端“伪加密”代替传输安全
- 登录凭证避免可重放
- 签名绑定时间戳、nonce、设备上下文
- 密钥不要硬编码在 Java 层
- 核心逻辑下沉 native 或服务端
- 配合反调试、完整性校验、环境检测
但也要有边界意识:
只要密钥和算法在客户端可执行,就原则上可以被分析。
所以客户端安全更像是“提高成本”,而不是“绝对不可逆”。
一套实战落地流程
如果你想把今天这篇文章的方法用到自己的样本上,可以直接按这个顺序执行:
stateDiagram-v2
[*] --> 安装并启动App
安装并启动App --> 抓包观察登录接口
抓包观察登录接口 --> JADX搜索接口与加密关键词
JADX搜索接口与加密关键词 --> 锁定候选类
锁定候选类 --> Frida通用Hook验证
Frida通用Hook验证 --> Frida精准Hook业务方法
Frida精准Hook业务方法 --> Hook最终请求体
Hook最终请求体 --> Python复现
Python复现 --> 与抓包结果比对
与抓包结果比对 --> [*]
这套流程的核心优点是:每一步都可验证。
一旦某一步不通,马上回退,不会一头扎进错误方向。
总结
这篇文章的重点,其实不是某一段 Hook 代码,而是这套分析方法:
- 先用 JADX 找候选入口
- 再用 Frida 动态确认真实调用
- 从“最终出站参数”反推回加密链路
- 最后在 Python 中独立复现
如果你只记住一句话,我希望是这句:
登录参数加密定位,本质上是在追踪“参数从明文到出站密文”的流转过程。
给你的可执行建议
- 第一次练手,优先选 Java 层加密明显、无重保护的样本
- 先 Hook
MessageDigest.digest、Cipher.doFinal和RequestBody.writeTo - 一旦发现请求字段对应关系,马上做最小复现
- 参数复现失败时,优先排查排序、时间戳、nonce、大小写和编码
- 如果遇到 native,先抓边界输入输出,不要急着进 so
边界条件
本文主要覆盖:
- Java 层加密
- Java/native 混合但可在边界处观察的场景
- 基于 Retrofit/OkHttp 的常见登录链路
如果目标使用了:
- 强对抗壳
- 深度反调试
- 全 native 网络栈
- 自定义 VM 或解释执行
那就需要额外的对抗与底层分析手段,不能只靠本文这套模板直接解决。
如果你已经能把文中的 Hook 跑起来,并成功对上抓包中的一个关键字段,那说明你已经真正迈进“能做实战”的阶段了。剩下的,就是多练几个样本,把这套路径走熟。