跳转到内容
123xiao | 无名键客

《安卓逆向实战:基于 Frida 与 Jadx 的混淆 APK 关键登录流程定位与参数还原》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:基于 Frida 与 Jadx 的混淆 APK 关键登录流程定位与参数还原

很多人第一次分析混淆 APK 的登录流程时,都会遇到同一种挫败感:
Jadx 打开一看,全是 a.a.a()b.c.d(),字符串也不完整,网络请求参数看起来像是动态拼接,根本无从下手。

我自己刚开始做这类分析时,也走过弯路:一上来就盯着反编译代码逐行硬啃,最后浪费大量时间。后来我逐渐形成了一套更稳的套路:

  • 先用 Jadx 做静态“缩圈”
  • 再用 Frida 做动态“打点”
  • 最后把关键参数、签名来源、调用链还原出来

这篇文章就按这个思路,带你完整走一遍:
如何在一个经过混淆的 Android APK 中,定位关键登录流程,并还原请求参数构造逻辑。

说明:本文聚焦于授权测试、学习研究和企业内部安全审计场景。请勿用于未授权目标。


背景与问题

在现代 Android 应用里,登录流程往往不是简单的“用户名 + 密码”提交。你经常会看到:

  • 密码先做一层本地加密或摘要
  • 请求体中的字段名被混淆
  • 签名参数由多个设备信息、时间戳、随机数拼接
  • 关键逻辑藏在 JNI、工具类或统一网络拦截器里
  • 登录按钮点击后,会经过多层包装才真正发请求

所以我们真正要解决的问题,不是“找到一个登录接口”这么简单,而是这三个步骤:

  1. 找到登录动作最终落到哪里
  2. 找出参数是在什么位置被加工的
  3. 拿到可复现的原始参数与最终参数

可以把它理解成一次“从 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 相关接口定义
  • 登录页上控件的绑定逻辑

实际分析时,我通常优先搜这些关键词:

  • login
  • signin
  • password
  • username
  • 手机号
  • 验证码
  • /user/login
  • token
  • Authorization
  • OkHttpClient
  • Interceptor

2. Frida 的价值:在运行时拿到真实值

静态分析最大的问题是:
你看到的是代码结构,不一定能看到真正执行时的参数。

比如:

  • 明文密码先被 encrypt() 再提交
  • 参数在 HashMap.put() 阶段分批放入
  • 最终签名是在请求发出前才追加
  • 某些字段来自 native 层或运行时设备信息

这时 Frida 的作用就是:

  • Hook 方法入参
  • Hook 方法返回值
  • Hook 类实例字段
  • Hook 网络请求构造链

3. 为什么要先找“网络入口”而不是先找“加密函数”

很多人容易一上来就搜 md5sha1aes
但真实项目里:

  • 方法名可能全混淆
  • 加密逻辑可能自定义
  • 签名流程可能不止一层

更稳的办法是反过来:

  1. 先找到登录请求最终调用的 API
  2. 再向上追踪调用链
  3. 看请求对象是在哪里被填充的
  4. 最后再定位到参数变换函数

这个顺序成功率更高。


第一步:用 Jadx 定位登录流程

1. 从界面层入手

先在 Jadx 中搜索以下线索:

  • 登录页布局名,如 activity_login
  • 控件 id,如 btn_loginet_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,而是中间这几个节点。

优先级建议如下:

  1. 密码加工函数
  2. 签名函数
  3. 登录请求方法
  4. OkHttp 请求构造链

第三步:Frida Hook 关键参数

下面进入实战部分。我们用一套可以直接运行的 Frida 脚本来抓登录参数。

假设目标包名为 com.demo.app
假设通过 Jadx 已定位到:

  • com.demo.app.login.Presenter
  • com.demo.app.util.g
  • com.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) 可能是 MD5
  • h.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 性能下降
  • 更容易触发反调试

正确做法是:

  1. 先从登录入口缩圈
  2. 再 Hook 1~3 个怀疑点
  3. 必要时再下探到通用库

3. 日志要可控、可筛选

建议在脚本里给每段日志加固定前缀,例如:

  • [LOGIN]
  • [SIGN]
  • [REQ]

这样你后期整理证据会轻松很多。

4. 保留“原始输入”和“最终输出”

分析时不要只记录最终 sign,要同时记录:

  • 输入参数
  • 中间态
  • 输出结果
  • 时间点
  • 对应调用方法

否则你后面写复现脚本时,很容易忘掉某一步加工。

5. 先验证,再下结论

你看到一个 md5(),不代表它就是最终密码。
你看到一个 sign 字段,也不代表它覆盖全部验签逻辑。

最可靠的结论永远来自:

  • 动态打印
  • 抓包比对
  • 本地复现成功

一个完整定位框架

如果你希望以后分析类似 APK 时更高效,可以记住下面这个框架:

stateDiagram-v2
    [*] --> 界面入口定位
    界面入口定位 --> 请求方法定位
    请求方法定位 --> 参数对象识别
    参数对象识别 --> 加密函数确认
    加密函数确认 --> 签名函数确认
    签名函数确认 --> 动态Hook验证
    动态Hook验证 --> 本地复现
    本地复现 --> [*]

这个流程不是只能用于登录,注册、支付、下单、验证码校验,很多场景都适用。


总结

面对混淆 APK 的登录分析,最怕的不是代码复杂,而是没有路径感
本文的核心方法其实很朴素:

  • Jadx 负责从静态层面缩小范围
  • Frida 负责从动态层面拿到真实参数
  • 最终通过本地脚本验证参数还原是否正确

如果你准备自己上手,我建议按下面顺序执行:

  1. 在 Jadx 里先找登录 UI 与点击入口
  2. 锁定请求对象、密码处理函数、签名函数
  3. 用 Frida 先 Hook 登录入口,再 Hook 加密和签名
  4. 如果业务层不好跟,就直接下探到 OkHttp
  5. 拿到真实参数后,用 Python 本地复现
  6. 只有复现成功,才算真正分析完成

最后给一个边界条件上的提醒:
如果关键逻辑在 JNI、本地壳或强对抗环境里,本文这套方法依然适用,但你需要把 Hook 点进一步延伸到 native 层,或者先解决反调试问题。也就是说,这套方法不是万能钥匙,但几乎总是最合适的起点。

如果你现在手里就有一个混淆 APK,不妨别再盯着一堆 a() 发愁了。
照着这篇文章的流程,从登录入口往下打一遍点,你很快就能看到“原来参数是在这里变的”。


分享到:

上一篇
《大模型在企业知识库问答中的落地实践:从RAG架构设计到效果优化》
下一篇
《AI 应用性能优化实战:中级开发者的推理延迟、成本与效果平衡指南》