安卓逆向实战:基于 Frida 与 Jadx 的混淆 APK 关键登录流程定位与参数还原
很多人第一次分析混淆 APK 的登录流程时,都会遇到同一种挫败感:
Jadx 打开一看,全是 a.a.a()、b.c.d(),字符串也不完整,网络请求参数看起来像是动态拼接,根本无从下手。
我自己刚开始做这类分析时,也走过弯路:一上来就盯着反编译代码逐行硬啃,最后浪费大量时间。后来我逐渐形成了一套更稳的套路:
- 先用 Jadx 做静态“缩圈”
- 再用 Frida 做动态“打点”
- 最后把关键参数、签名来源、调用链还原出来
这篇文章就按这个思路,带你完整走一遍:
如何在一个经过混淆的 Android APK 中,定位关键登录流程,并还原请求参数构造逻辑。
说明:本文聚焦于授权测试、学习研究和企业内部安全审计场景。请勿用于未授权目标。
背景与问题
在现代 Android 应用里,登录流程往往不是简单的“用户名 + 密码”提交。你经常会看到:
- 密码先做一层本地加密或摘要
- 请求体中的字段名被混淆
- 签名参数由多个设备信息、时间戳、随机数拼接
- 关键逻辑藏在 JNI、工具类或统一网络拦截器里
- 登录按钮点击后,会经过多层包装才真正发请求
所以我们真正要解决的问题,不是“找到一个登录接口”这么简单,而是这三个步骤:
- 找到登录动作最终落到哪里
- 找出参数是在什么位置被加工的
- 拿到可复现的原始参数与最终参数
可以把它理解成一次“从 UI 到网络层”的逆向路径追踪。
前置知识
如果你已经熟悉下面几项,会更顺手:
- Android 基础组件:Activity、Fragment、ViewModel
- Java / Kotlin 基本语法
- HTTP 请求基本结构
- Frida 基础 Hook 用法
- Jadx 浏览和搜索能力
如果不熟,也没关系,本文会尽量按“能落地”的方式展开。
环境准备
本文默认环境如下:
- 一台已开启 USB 调试的 Android 测试机 / 模拟器
- 安装
adb - 安装
jadx-gui - 安装 Python 3
- 安装 Frida 工具链:
pip install frida frida-tools
- 测试机中部署与 CPU 架构对应的
frida-server
查看架构:
adb shell getprop ro.product.cpu.abi
推送并启动 frida-server:
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
分析思路总览
先别急着 Hook。对混淆 APK,最有效的是“静态缩圈 + 动态验证”的组合拳。
flowchart TD
A[加载 APK 到 Jadx] --> B[搜索登录页特征]
B --> C[锁定点击事件/Presenter/ViewModel]
C --> D[追踪网络请求入口]
D --> E[识别参数构造/签名函数]
E --> F[Frida Hook 关键方法]
F --> G[抓取原始参数与返回值]
G --> H[复现登录请求]
这张图对应的是本文的主线。
关键点在于:Jadx 负责找方向,Frida 负责拿证据。
核心原理
1. Jadx 的价值:帮你快速缩小范围
混淆后的类名虽然没意义,但以下内容往往仍然有价值:
layout资源名R.id.xxx- Toast 文案、日志片段、错误提示
- URL 片段、域名、路径关键词
- Retrofit / OkHttp 相关接口定义
- 登录页上控件的绑定逻辑
实际分析时,我通常优先搜这些关键词:
loginsigninpasswordusername手机号验证码/user/logintokenAuthorizationOkHttpClientInterceptor
2. Frida 的价值:在运行时拿到真实值
静态分析最大的问题是:
你看到的是代码结构,不一定能看到真正执行时的参数。
比如:
- 明文密码先被
encrypt()再提交 - 参数在
HashMap.put()阶段分批放入 - 最终签名是在请求发出前才追加
- 某些字段来自 native 层或运行时设备信息
这时 Frida 的作用就是:
- Hook 方法入参
- Hook 方法返回值
- Hook 类实例字段
- Hook 网络请求构造链
3. 为什么要先找“网络入口”而不是先找“加密函数”
很多人容易一上来就搜 md5、sha1、aes。
但真实项目里:
- 方法名可能全混淆
- 加密逻辑可能自定义
- 签名流程可能不止一层
更稳的办法是反过来:
- 先找到登录请求最终调用的 API
- 再向上追踪调用链
- 看请求对象是在哪里被填充的
- 最后再定位到参数变换函数
这个顺序成功率更高。
第一步:用 Jadx 定位登录流程
1. 从界面层入手
先在 Jadx 中搜索以下线索:
- 登录页布局名,如
activity_login - 控件 id,如
btn_login、et_account - 登录失败提示文案,如“密码错误”“请输入账号”
假设我们在布局绑定代码里找到了一个按钮点击事件:
public void onClick(View view) {
if (view.getId() == R.id.btn_login) {
this.k.a(this.b.getText().toString(), this.c.getText().toString());
}
}
别看 k.a() 没意义,这里已经足够了。
它大概率就是登录入口,或者至少是上层入口。
2. 继续追到请求层
点进去后,可能看到类似结构:
public void a(String str, String str2) {
LoginReq req = new LoginReq();
req.a = str;
req.b = g.d(str2);
req.c = System.currentTimeMillis() + "";
req.d = h.a(req.a, req.b, req.c);
this.m.login(req).enqueue(new x(this));
}
这段代码即使字段名全混淆,也已经暴露了几个关键事实:
req.a是账号req.b是密码加工结果req.c是时间戳req.d是签名m.login(req)是接口请求点
这时你应该立刻记下:
g.d():疑似密码处理函数h.a():疑似签名函数LoginReq:请求对象结构m.login():真实请求接口
3. 查看接口定义
如果项目用了 Retrofit,通常能看到类似接口:
public interface ApiService {
@POST("/api/user/login")
Call<LoginResp> login(@Body LoginReq req);
}
到这里,静态分析已经把范围缩到很小了。
第二步:梳理调用链和参数流
为了防止只看单点导致误判,建议把调用链画出来。
sequenceDiagram
participant UI as LoginActivity
participant P as Presenter
participant E as EncryptUtil
participant S as SignUtil
participant API as ApiService
participant NET as OkHttp
UI->>P: a(username, password)
P->>E: d(password)
E-->>P: encPassword
P->>S: a(username, encPassword, ts)
S-->>P: sign
P->>API: login(LoginReq)
API->>NET: build request
NET-->>API: response
这张图的作用是提醒你:
真正应该 Hook 的,往往不是 Activity,而是中间这几个节点。
优先级建议如下:
- 密码加工函数
- 签名函数
- 登录请求方法
- OkHttp 请求构造链
第三步:Frida Hook 关键参数
下面进入实战部分。我们用一套可以直接运行的 Frida 脚本来抓登录参数。
假设目标包名为
com.demo.app
假设通过 Jadx 已定位到:
com.demo.app.login.Presentercom.demo.app.util.gcom.demo.app.util.h
实战代码(可运行)
方案一:Hook 登录入口、密码处理与签名函数
保存为 hook_login.js:
Java.perform(function () {
function logLine() {
console.log("--------------------------------------------------");
}
function safeToString(obj) {
try {
if (obj === null || obj === undefined) return "null";
return obj.toString();
} catch (e) {
return "[toString error] " + e;
}
}
// 1. Hook 登录入口
try {
var Presenter = Java.use("com.demo.app.login.Presenter");
Presenter.a.overload("java.lang.String", "java.lang.String").implementation = function (username, password) {
logLine();
console.log("[+] Presenter.a called");
console.log(" username =", username);
console.log(" password =", password);
var ret = this.a(username, password);
console.log("[+] Presenter.a finished");
logLine();
return ret;
};
console.log("[*] Hooked Presenter.a(String, String)");
} catch (e) {
console.log("[-] Hook Presenter.a failed:", e);
}
// 2. Hook 密码处理函数
try {
var G = Java.use("com.demo.app.util.g");
G.d.overload("java.lang.String").implementation = function (plainPwd) {
logLine();
console.log("[+] g.d called");
console.log(" plainPwd =", plainPwd);
var result = this.d(plainPwd);
console.log(" encPwd =", result);
logLine();
return result;
};
console.log("[*] Hooked g.d(String)");
} catch (e) {
console.log("[-] Hook g.d failed:", e);
}
// 3. Hook 签名函数
try {
var H = Java.use("com.demo.app.util.h");
H.a.overload("java.lang.String", "java.lang.String", "java.lang.String").implementation = function (u, encPwd, ts) {
logLine();
console.log("[+] h.a called");
console.log(" username =", u);
console.log(" encPwd =", encPwd);
console.log(" ts =", ts);
var sign = this.a(u, encPwd, ts);
console.log(" sign =", sign);
logLine();
return sign;
};
console.log("[*] Hooked h.a(String, String, String)");
} catch (e) {
console.log("[-] Hook h.a failed:", e);
}
});
运行命令:
frida -U -f com.demo.app -l hook_login.js --no-pause
当你在 App 中点击登录后,控制台应该能看到:
- 明文用户名
- 明文密码
- 密码处理后的结果
- 签名函数输入
- 最终签名结果
如果这一步已经拿到了完整参数链,基本就成功一半了。
方案二:Hook 请求对象字段,直接看最终提交值
有些 App 在中间层还会二次处理,这时只看加密函数不够。
更稳的是直接 Hook 请求对象或请求方法。
Java.perform(function () {
function showField(obj, fieldName) {
try {
return obj[fieldName].value;
} catch (e) {
return "[field error: " + fieldName + "]";
}
}
try {
var ApiServiceImpl = Java.use("com.demo.app.net.ApiServiceImpl");
var LoginReq = Java.use("com.demo.app.model.LoginReq");
ApiServiceImpl.login.overload("com.demo.app.model.LoginReq").implementation = function (req) {
console.log("============== login(req) ==============");
try {
console.log("req.a(username) =", showField(req, "a"));
console.log("req.b(encPwd) =", showField(req, "b"));
console.log("req.c(ts) =", showField(req, "c"));
console.log("req.d(sign) =", showField(req, "d"));
} catch (e) {
console.log("dump req error:", e);
}
var ret = this.login(req);
console.log("========================================");
return ret;
};
console.log("[*] Hooked ApiServiceImpl.login(LoginReq)");
} catch (e) {
console.log("[-] Hook request failed:", e);
}
});
如果你不确定 LoginReq 的字段名,可以先在 Jadx 中看类定义,或在 Frida 里遍历字段。
方案三:Hook OkHttp,抓最终 HTTP 请求
当业务代码特别绕、类名定位困难时,直接从网络层下手非常有效。
Java.perform(function () {
try {
var RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBuilder.build.implementation = function () {
var req = this.build();
try {
console.log("=========== OkHttp Request ===========");
console.log("URL :", req.url().toString());
console.log("Method :", req.method());
var headers = req.headers();
for (var i = 0; i < headers.size(); i++) {
console.log("Header :", headers.name(i) + " = " + headers.value(i));
}
console.log("======================================");
} catch (e) {
console.log("print request error:", e);
}
return req;
};
console.log("[*] Hooked okhttp3.Request$Builder.build()");
} catch (e) {
console.log("[-] Hook OkHttp failed:", e);
}
});
这个脚本能看到:
- 最终请求 URL
- HTTP 方法
- 关键请求头
如果你还想打印 body,可以继续 Hook okhttp3.RequestBody 的写入过程,不过不同版本兼容性略有差异,建议先确认路径,再决定是否深入。
第四步:参数还原与复现
一旦拿到以下几项,你就能做参数还原:
- 用户名原值
- 密码原值
- 密码加工结果
- 时间戳
- 签名结果
- 接口路径
例如,通过 Hook 你发现:
username = test001
password = 123456
encPwd = e10adc3949ba59abbe56e057f20f883e
ts = 1720000000000
sign = 9f8d7c6b5a...
此时你可以初步推断:
g.d(password)可能是 MD5h.a(username, encPwd, ts)是签名函数
然后回到 Jadx 检查 g.d() 和 h.a() 的实现。
如果 g.d() 看起来像:
public static String d(String str) {
return MD5Util.md5(str);
}
那密码还原就很清楚了。
如果 h.a() 的逻辑是:
public static String a(String u, String p, String ts) {
return MD5Util.md5(u + "|" + p + "|" + ts + "|fixed_key");
}
你就可以在本地完全复现这个签名。
用 Python 复现签名
import hashlib
def md5(s: str) -> str:
return hashlib.md5(s.encode("utf-8")).hexdigest()
username = "test001"
password = "123456"
ts = "1720000000000"
enc_pwd = md5(password)
sign = md5(username + "|" + enc_pwd + "|" + ts + "|fixed_key")
print("enc_pwd =", enc_pwd)
print("sign =", sign)
这一步的意义非常大:
不是“看懂了”,而是“验证我真的还原对了”。
逐步验证清单
实战里我建议你按这个检查顺序走,不容易乱:
- 在 Jadx 中确认登录按钮点击入口
- 找到登录请求对象
LoginReq - 找到密码处理函数
- 找到签名函数
- 用 Frida 打印明文输入
- 用 Frida 打印加密后密码
- 用 Frida 打印签名输入与输出
- 用 Frida 或抓包确认最终接口路径
- 用脚本本地复现签名
- 比对本地结果与 App 实际请求是否一致
做到最后两项,才算真正完成“参数还原”。
常见坑与排查
这一部分很重要。我踩过的坑,很多都重复出现。
1. Hook 不生效
常见原因:
- 类名找错了
- 方法重载选错了
- Hook 时机太晚
- App 有多进程
排查建议:
frida-ps -Uai
先确认包名和进程名。
如果怀疑类没加载,可以先枚举类名:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("login") !== -1 || name.indexOf("demo") !== -1) {
console.log(name);
}
},
onComplete: function () {}
});
});
2. 一 Hook 就闪退
可能原因:
- 目标 App 有反调试 / Frida 检测
- 你的实现破坏了原方法调用
- Hook 了过于底层且高频的方法
建议:
- 优先 Hook 业务方法,不要一开始就 Hook
HashMap.put() - 保证
return this.xxx(...)正常返回 - 尽量在打印时做异常保护
3. Kotlin 项目方法名对不上
Kotlin 常见情况:
- 顶层函数被编译到
xxxKt - 默认参数生成
foo$default - 内联函数不一定容易直接命中
建议优先找:
- 调用点
- 数据类字段
- Retrofit 接口
- ViewModel / Repository 层
4. 登录参数明明抓到了,但复现失败
这非常常见,通常不是“算法错了”,而是漏了上下文:
- 请求头里有动态 token
- body 外还有统一签名
- 设备标识参与签名
- 时间戳格式不一致
- 参数排序影响签名结果
我的经验是:
当本地复现差一点点时,不要只盯算法,多检查“输入全集”有没有漏。
5. 混淆太重,类看不懂
这时别死盯类名,改看这些“不会完全消失的东西”:
- 字符串常量
- 接口路径
- JSON key
- 资源 id
- 异常日志
- 第三方库调用关系
混淆的是名字,不是行为。
深入一点:什么时候该 Hook Map / JSON 构造
如果请求对象不是强类型,而是这种写法:
Map<String, String> map = new HashMap<>();
map.put("account", user);
map.put("password", encPwd);
map.put("sign", sign);
你可以 Hook HashMap.put(),但要有选择地过滤,否则日志会爆炸。
Java.perform(function () {
var HashMap = Java.use("java.util.HashMap");
HashMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
var key = k ? k.toString() : "null";
var val = v ? v.toString() : "null";
if (key.indexOf("password") !== -1 ||
key.indexOf("sign") !== -1 ||
key.indexOf("account") !== -1 ||
key.indexOf("token") !== -1) {
console.log("[HashMap.put] " + key + " = " + val);
}
return this.put(k, v);
};
});
但这招只建议在两种情况下用:
- 业务方法实在找不到
- 已经知道大致流程,只差确认最终字段
否则输出会非常吵,调试体验很差。
安全/性能最佳实践
这部分既是技术建议,也是边界提醒。
1. 只在授权环境中分析
无论是 Hook 登录流程,还是还原参数,本质都属于安全研究手段。
请确保目标是:
- 自有应用
- 企业授权测试对象
- 合法靶场 / 学习样本
2. 优先做最小化 Hook
不要一开始全量 Hook:
StringBuilder.append()HashMap.put()JSONObject.put()MessageDigest.digest()
这些方法调用频率极高,会导致:
- 日志淹没关键线索
- App 性能下降
- 更容易触发反调试
正确做法是:
- 先从登录入口缩圈
- 再 Hook 1~3 个怀疑点
- 必要时再下探到通用库
3. 日志要可控、可筛选
建议在脚本里给每段日志加固定前缀,例如:
[LOGIN][SIGN][REQ]
这样你后期整理证据会轻松很多。
4. 保留“原始输入”和“最终输出”
分析时不要只记录最终 sign,要同时记录:
- 输入参数
- 中间态
- 输出结果
- 时间点
- 对应调用方法
否则你后面写复现脚本时,很容易忘掉某一步加工。
5. 先验证,再下结论
你看到一个 md5(),不代表它就是最终密码。
你看到一个 sign 字段,也不代表它覆盖全部验签逻辑。
最可靠的结论永远来自:
- 动态打印
- 抓包比对
- 本地复现成功
一个完整定位框架
如果你希望以后分析类似 APK 时更高效,可以记住下面这个框架:
stateDiagram-v2
[*] --> 界面入口定位
界面入口定位 --> 请求方法定位
请求方法定位 --> 参数对象识别
参数对象识别 --> 加密函数确认
加密函数确认 --> 签名函数确认
签名函数确认 --> 动态Hook验证
动态Hook验证 --> 本地复现
本地复现 --> [*]
这个流程不是只能用于登录,注册、支付、下单、验证码校验,很多场景都适用。
总结
面对混淆 APK 的登录分析,最怕的不是代码复杂,而是没有路径感。
本文的核心方法其实很朴素:
- Jadx 负责从静态层面缩小范围
- Frida 负责从动态层面拿到真实参数
- 最终通过本地脚本验证参数还原是否正确
如果你准备自己上手,我建议按下面顺序执行:
- 在 Jadx 里先找登录 UI 与点击入口
- 锁定请求对象、密码处理函数、签名函数
- 用 Frida 先 Hook 登录入口,再 Hook 加密和签名
- 如果业务层不好跟,就直接下探到 OkHttp
- 拿到真实参数后,用 Python 本地复现
- 只有复现成功,才算真正分析完成
最后给一个边界条件上的提醒:
如果关键逻辑在 JNI、本地壳或强对抗环境里,本文这套方法依然适用,但你需要把 Hook 点进一步延伸到 native 层,或者先解决反调试问题。也就是说,这套方法不是万能钥匙,但几乎总是最合适的起点。
如果你现在手里就有一个混淆 APK,不妨别再盯着一堆 a() 发愁了。
照着这篇文章的流程,从登录入口往下打一遍点,你很快就能看到“原来参数是在这里变的”。