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

《安卓逆向实战:从 Frida Hook 到协议还原分析 App 登录鉴权流程》

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

安卓逆向实战:从 Frida Hook 到协议还原分析 App 登录鉴权流程

很多人学安卓逆向时,都会先从“Hook 一个 Toast”开始,但真到业务场景,往往第一道门槛就是:App 登录到底怎么做鉴权的?请求参数从哪来?签名怎么算?加密在什么位置?

这篇文章我想从一个更接近真实工作的角度,带你完整走一遍:

  • 如何定位登录入口
  • 如何用 Frida Hook Java 层关键函数
  • 如何追到 Native 或加密封装前的明文
  • 如何还原登录请求协议
  • 如何判断 token、时间戳、签名的生成链路

这不是一篇只讲概念的文章,我会尽量按“实战排查”的节奏组织,适合已经会一点 adb、jadx、Frida 基础,但还没有系统做过登录鉴权分析的同学。

声明:本文仅用于授权测试、安全研究与学习分析,请勿用于未授权的生产 App 逆向、抓包、绕过鉴权等行为。


背景与问题

在实际分析一个 App 登录流程时,我们常见到的现象不是“一个明文 POST 请求”这么简单,而是下面这种组合拳:

  1. 用户输入账号密码
  2. Java 层做参数拼装
  3. 某个工具类追加时间戳、设备 ID、版本号
  4. 进入签名函数
  5. 可能再做一次 AES / RSA / 自定义编码
  6. 请求通过 OkHttp / Retrofit 发出
  7. 服务端返回 token、refreshToken、sessionKey 等

问题在于:

  • 抓包时你可能看不到明文
  • 请求体可能被 gzip、protobuf、AES 包裹
  • 签名逻辑可能被混淆
  • SSL Pinning 让你抓不到 HTTPS
  • 真正关键的参数不在网络层,而是在加密前序列化前生成

所以,分析登录鉴权流程最有效的思路通常不是一上来死磕抓包,而是:

静态分析定位入口 + 动态 Hook 观察关键数据 + 回溯协议字段含义


前置知识

建议你至少具备这些基础:

  • 会使用 jadx 看 Java / Kotlin 反编译代码
  • 了解安卓常见网络栈:OkHttpRetrofit
  • 会用 adb 连设备
  • 会写基本 Frida Hook 脚本
  • 知道 MD5 / SHA256 / HMAC / AES / RSA 的基本区别

如果这些你已经有一些概念,那这篇文章就可以直接跟着做。


环境准备

这里我按比较常见的组合来准备:

  • Android 真机或模拟器
  • 已安装目标 App
  • PC 已安装:
    • adb
    • jadx
    • frida-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 MessageDigestCipher,日志多得看不完,最后连哪个是登录请求都分不清。


核心原理

要把登录鉴权分析清楚,核心其实就三件事。

1. 登录请求不是“一个包”,而是一条调用链

登录按钮点击以后,请求通常会经过:

  • UI 层:收集用户名密码
  • 业务层:封装 LoginRequest
  • 安全层:签名、加密、补充设备参数
  • 网络层:序列化并发送

你真正要还原的是这条链上的关键节点,而不是只盯着最终流量。

2. 协议还原的重点是“字段来源”而不是“字段长什么样”

例如一个请求体长这样:

{
  "username": "alice",
  "password": "xxxxxx",
  "ts": 1670000000,
  "nonce": "A1B2C3",
  "sign": "9f2d..."
}

你需要搞清楚:

  • password 是明文、MD5、还是 AES 后的值?
  • ts 是秒级、毫秒级,还是服务端时间偏移?
  • nonce 是随机数,还是设备绑定值?
  • sign 的输入串是什么?字段顺序如何?是否包含固定盐值?

只知道“包长这样”还不够,必须知道“它为什么这样”。

3. Hook 要尽量贴近“业务关键点”

常见优先级一般是:

  1. 登录按钮点击后调用的 ViewModel / Presenter 方法
  2. 登录接口对应的 Retrofit 方法
  3. 参数对象构造函数 / setter
  4. 签名函数
  5. 加密函数
  6. OkHttp 请求构建位置
  7. Native 层导出函数(必要时)

静态分析:先把调用链摸出来

拿到 APK 后,先用 jadx 看:

1. 搜索登录关键词

常见关键词包括:

  • login
  • sign
  • token
  • auth
  • passport
  • session
  • password

如果混淆严重,可以反过来找:

  • 登录页面的文本资源 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: 登录成功

分析时建议按这个顺序逐步验证:

  1. 输入参数是否原样传入业务层
  2. 密码是否先变换
  3. sign 输入值是否可见
  4. 请求头是否有额外鉴权字段
  5. 响应中的 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. 业务字段

  • username
  • mobile
  • password
  • smsCode

这些通常和用户输入直接相关。

2. 环境字段

  • deviceId
  • oaid
  • imei(旧版)
  • brand
  • model
  • appVersion

这些一般用于风控、灰度、统计或设备绑定。

3. 安全字段

  • ts
  • nonce
  • sign
  • token
  • sessionKey

这些是登录鉴权的核心,优先分析其生成和校验方式。


常见坑与排查

这一部分非常重要。我把自己和同事经常踩的坑,集中写一下。

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.doFinal
  • MessageDigest.digest
  • Base64.encodeToString

虽然这些方法很通用,但噪音巨大。更好的做法是:

  1. 先静态定位登录调用链
  2. 再局部 Hook 登录相关类
  3. 最后必要时补通用 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 热路径

OkHttpMessageDigestCipher 这类方法如果全量打印,可能导致:

  • 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”,而是建立一条稳定的方法论:

  1. 静态分析定位调用链
  2. 动态 Hook 观察关键参数
  3. 锁定签名/加密函数
  4. 对照网络请求还原字段含义
  5. 用脚本复现并交叉验证

如果你只记住一个结论,那就是:

登录鉴权分析最重要的不是抓到最终密文包,而是找到“明文参数到签名结果”之间的生成路径。

最后给几个可执行建议:

  • 第一次做时,不要同时 Hook 太多点,先抓入口再扩展
  • 遇到 SSL Pinning,不要硬扛抓包,优先转向明文构造阶段
  • 遇到 Native 签名,不要慌,先确认 Java 到 so 的边界
  • 协议还原必须做 Python 复现,否则容易“看懂了但验证不了”

如果你已经能独立完成:

  • 登录明文参数定位
  • sign 输入输出确认
  • 请求头/请求体字段归因
  • Python 复现签名

那你就不只是“会用 Frida”,而是已经具备了比较扎实的安卓登录鉴权逆向分析能力


分享到:

上一篇
《自动化测试中的测试数据管理实战:构建可复用、可维护的数据驱动测试体系》
下一篇
《集群架构实战:从单点故障到高可用的负载均衡与故障转移设计》