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

《安卓逆向实战:基于 Frida 与 JADX 的 APK 登录鉴权流程分析与关键参数定位》

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

背景与问题

做 Android 安全分析时,登录鉴权几乎总是最先要碰的模块。原因很现实:你想理解一个 App 的业务入口、请求签名、设备标识、风控参数,往往都得从登录流程下手。

但实际分析时,常见情况并不“友好”:

  • Java 层代码被混淆,类名方法名全是 a/b/c
  • 请求参数里混着时间戳、随机串、摘要签名、设备指纹
  • 关键逻辑不一定在一个函数里,可能跨 UI、ViewModel、网络层、JNI
  • 抓包能看到请求,但看不出参数是怎么生成的

这篇文章我会从一个中级实战视角来带你走一遍:
先用 JADX 静态定位登录入口与可疑方法,再用 Frida 动态 Hook 核心参数生成点,最后还原 APK 的登录鉴权流程。

目标不是“全自动秒杀”,而是建立一套可复用的方法论


前置知识与环境准备

你需要了解什么

建议你至少具备以下基础:

  • Android APK 的基本结构
  • Java/Kotlin 常见语法
  • HTTP 请求基本组成
  • Frida 的基础 Hook 用法
  • JADX 的搜索与交叉引用能力

环境准备

建议环境如下:

  • Android 模拟器或测试机(Root 更方便,但不是必须)
  • adb
  • jadx-gui
  • frida, 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

分析目标与整体思路

先说一下这类问题最有效的路径。我一般采用“四步走”:

  1. 抓包确认登录请求
  2. JADX 静态定位登录相关类
  3. Frida 动态 Hook 参数构造与加密函数
  4. 交叉验证请求字段来源,画出完整链路

这套思路的好处是:
不会一上来就在混淆代码海里乱翻,也不会只靠抓包“猜”。

分析流程图

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 MessageDigestMacCipher
  • 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

一个常见定位路径

  1. 找到 ApiService 接口
  2. 找到调用它的 Repository
  3. 找到调用 Repository.login() 的 ViewModel/Presenter
  4. 找到参数拼接函数

你会发现,真正有价值的函数名即使被混淆,也常有这些特征:

  • 参数很多
  • 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(
  • /login
  • RequestBody
  • MultipartBody

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.getInstance
  • Mac.getInstance
  • Cipher.getInstance
  • SHA-256
  • MD5
  • HmacSHA256

例如:

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 脚本组合。思路是:

  1. 先 Hook Java 常见加密类
  2. 再 Hook 目标 App 的可疑签名类
  3. 最后 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)

你可以这样验证:

  1. Hook HashMap.put,拿到原始字段
  2. Hook SignUtil.sign,拿到输入字符串
  3. Hook MessageDigest / Mac,拿到实际摘要值
  4. 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/login
  • deviceId
  • nonce

找到:

  • LoginRepository
  • DeviceManager.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 与请求一致]

还原出的可能流程

  1. 明文密码先做一次 MD5
  2. mobile/password/ts/nonce/deviceId 放进 Map
  3. 按 key 排序拼成查询字符串
  4. 末尾拼接固定 secret
  5. 再做一次 MD5 作为 sign
  6. 组装 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.put
  • JSONObject.put
  • MessageDigest
  • Cipher
  • Mac
  • OkHttp
  • 所有业务类

这样很容易把 App 卡死,或者日志刷到你根本看不懂。

建议顺序:

  1. 先 Hook 发包点
  2. 再 Hook 签名类
  3. 最后才补全局基础类

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
  • 设备标识
  • 用户隐私数据

建议至少对以下字段脱敏后再保存:

  • mobile
  • password
  • token
  • sessionId

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 加密函数、业务签名函数、请求体构造过程
  • 通过交叉验证,还原 passwordtsnoncedeviceIdsign 的来源与关系

如果你准备真正上手,我建议从最小目标开始:

  1. 先确认最终请求长什么样
  2. 再确认 sign 是谁生成的
  3. 最后再拆 password、deviceId、native 等细节

不要试图第一轮就把所有逻辑一次性看穿。
我自己做这类分析时,最有效的方式也是:先缩小问题,再局部击穿。

如果目标 App 混淆严重、逻辑分散,记住这条实战经验:

JADX 负责告诉你“可能在哪里”,Frida 负责告诉你“运行时到底发生了什么”。

把这两者配合起来,登录鉴权流程通常都能比较快地理清。


分享到:

上一篇
《区块链节点数据同步与状态存储优化实战:从全量同步到快照加速的工程方案》
下一篇
《前端性能实战:基于 Web Vitals 的页面加载优化与定位方案》