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

《安卓逆向实战:基于 Frida 与 JADX 的登录参数加密流程定位与 Hook 分析》

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

安卓逆向实战:基于 Frida 与 JADX 的登录参数加密流程定位与 Hook 分析

很多人做安卓逆向时,最先卡住的不是“怎么反编译”,而是“参数到底在哪里加密的”。尤其是登录接口:抓包只能看到一串密文,APP 里类名又被混淆,直接搜 encryptsignmd5 常常一无所获。

这篇文章我就按一个真实实战思路来带你走一遍:先用 JADX 做静态分析缩小范围,再用 Frida 动态 Hook把登录参数加密流程抓出来,最后验证“明文输入 -> 加密函数 -> 请求发出”的完整链路。

这篇文章默认用于授权测试、学习研究和自有应用安全分析。不要用于未授权目标。


背景与问题

我们面对的典型场景通常是这样的:

  • 抓包能看到 /login 请求
  • 用户名、密码、时间戳、签名等参数中,至少一部分已经被加密或摘要
  • 直接改包重放失败
  • 想知道:
    • 明文参数从哪里进入加密逻辑?
    • 参与加密的字段有哪些?
    • 算法是标准算法还是自定义拼接?
    • 盐值、固定 token、设备指纹是否参与了签名?

如果只靠抓包,很难回答这些问题;如果只靠静态分析,又常被混淆和调用链绕晕。JADX + Frida 的组合,本质上是把“看代码”和“看运行时”拼起来。


前置知识与环境准备

建议你至少准备这些工具:

  • jadx-gui:静态查看 APK 代码
  • adb:连接手机/模拟器
  • frida-tools
  • objection(可选)
  • 一台已能运行目标 APP 的 Android 设备或模拟器

Python / Frida 安装

pip install frida frida-tools

常用命令确认

查看设备进程:

frida-ps -U

附加到目标应用:

frida -U -f com.example.app -l hook_login.js

如果目标 APP 有反调试或闪退,后面“常见坑与排查”会专门说。


核心原理

这一类问题,核心不是“记住某个 API”,而是理解登录参数加密链路通常长什么样。

常见链路可以抽象为:

flowchart LR
A[登录按钮点击] --> B[收集用户名/密码]
B --> C[业务层组装请求对象]
C --> D[加密/签名函数]
D --> E[网络层发包]
E --> F[服务端验签]

逆向时,我们要回答两个问题:

  1. 入口在哪里?

    • 登录按钮点击
    • ViewModel / Presenter 的登录方法
    • Retrofit / OkHttp 发包前封装
  2. 加密点在哪里?

    • 业务代码里的工具类
    • JNI so 层
    • 通用加密 API,如 MessageDigestCipher
    • 网络拦截器中统一签名

用 JADX 缩小范围:先找“登录”,再找“参数加工”

静态分析阶段,我一般不急着搜算法,而是先找业务入口

1. 搜关键字

优先搜这些词:

  • login
  • username
  • password
  • /login
  • sign
  • token
  • timestamp
  • nonce

如果接口路径被常量化,也可以搜:

  • Retrofit 注解:@POST, @FormUrlEncoded, @Body
  • OkHttp:newCall, Request.Builder
  • JSON 构造:JSONObject, HashMap, TreeMap

2. 从登录按钮倒推调用链

通常会看到类似结构:

  • LoginActivity
  • LoginFragment
  • LoginViewModel
  • UserRepository
  • ApiService

一个典型的调用关系如下:

sequenceDiagram
participant UI as LoginActivity
participant VM as LoginViewModel
participant Repo as UserRepository
participant Util as SignUtil
participant Net as ApiService

UI->>VM: onLoginClick(username, password)
VM->>Repo: login(username, password)
Repo->>Util: buildSign(params)
Util-->>Repo: sign
Repo->>Net: login(data, sign)
Net-->>Repo: response
Repo-->>VM: result
VM-->>UI: success/fail

3. 重点观察三类代码

第一类:Map/JSON 组装点

例如:

HashMap<String, String> map = new HashMap<>();
map.put("username", username);
map.put("password", pwd);
map.put("timestamp", ts);
map.put("sign", SignUtil.sign(map));

这类地方非常关键,因为它告诉你:

  • 哪些字段参与签名
  • 签名发生在参数组装前还是后
  • 密码是否先二次处理

第二类:工具类调用

比如:

SignUtil.sign(...)
EncryptUtils.encrypt(...)
SecurityManager.getToken(...)
NativeBridge.encode(...)

即便类名被混淆,只要某个方法被反复调用、入参又像字符串/Map,就值得怀疑。

第三类:标准加密 API

搜这些类名常常有惊喜:

  • java.security.MessageDigest
  • javax.crypto.Cipher
  • javax.crypto.spec.SecretKeySpec
  • android.util.Base64
  • java.net.URLEncoder

很多 APP 所谓“自定义加密”,其实是:

  • 字段排序
  • 拼接固定盐值
  • MD5 / SHA1 / SHA256
  • Base64
  • AES
  • 再 URL encode

定位思路:从“可见输入”向“不可见加密”推进

如果你在 JADX 中已经找到一个疑似登录流程,可以按下面路径逐步验证:

flowchart TD
A[找到登录按钮或登录接口] --> B[确认用户名密码传递方法]
B --> C[查看请求对象构造]
C --> D[定位 sign/encrypt/native 方法]
D --> E[Hook 入参和返回值]
E --> F[对照抓包结果验证]
F --> G[确认最终加密链路]

这个过程有个经验点:不要一上来就 Hook 全世界。先缩小到“登录时一定会经过的几个方法”,否则日志会多到没法看。


实战代码(可运行)

下面给一套可直接改造的 Frida 脚本。思路是分层 Hook:

  1. Hook 登录入口方法
  2. Hook 参数组装类
  3. Hook 标准摘要/加密 API
  4. Hook 网络层请求

包名、类名需要你根据实际 APK 调整。代码是可运行模板,不是伪代码。


示例 1:Hook 登录业务方法

假设 JADX 中看到了:

  • com.example.app.ui.LoginViewModel
  • 方法:doLogin(java.lang.String, java.lang.String)
Java.perform(function () {
    var LoginViewModel = Java.use("com.example.app.ui.LoginViewModel");

    LoginViewModel.doLogin.overload("java.lang.String", "java.lang.String").implementation = function (username, password) {
        console.log("========== doLogin called ==========");
        console.log("[+] username = " + username);
        console.log("[+] password = " + password);

        var ret = this.doLogin(username, password);

        console.log("[+] doLogin return = " + ret);
        return ret;
    };
});

运行:

frida -U -f com.example.app -l hook_login.js

这个阶段的目的不是立刻拿到密文,而是确认:

  • 你 Hook 的方法是不是登录真实入口
  • 用户输入有没有被提前处理

示例 2:Hook 参数组装与签名方法

假设你在 JADX 中发现:

  • com.example.app.security.SignUtil
  • 方法:sign(java.util.Map)
Java.perform(function () {
    var SignUtil = Java.use("com.example.app.security.SignUtil");
    var HashMap = Java.use("java.util.HashMap");
    var MapEntry = Java.use("java.util.Map$Entry");

    function printMap(mapObj) {
        var entrySet = mapObj.entrySet();
        var iterator = entrySet.iterator();
        while (iterator.hasNext()) {
            var entry = Java.cast(iterator.next(), MapEntry);
            console.log("    " + entry.getKey() + " = " + entry.getValue());
        }
    }

    SignUtil.sign.overload("java.util.Map").implementation = function (map) {
        console.log("========== SignUtil.sign called ==========");
        console.log("[+] input map:");
        printMap(map);

        var ret = this.sign(map);

        console.log("[+] sign result = " + ret);
        return ret;
    };
});

如果签名函数用的是 HashMapTreeMap,这个脚本通常就能把参与签名的字段一把抓出来。


示例 3:Hook MessageDigest,判断是否用了 MD5/SHA

如果你还没找到业务签名类,可以退一步 Hook 标准摘要 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 (algorithm) {
        console.log("[+] MessageDigest.getInstance -> " + algorithm);
        return this.getInstance(algorithm);
    };

    MessageDigest.digest.overload("[B").implementation = function (input) {
        var result = this.digest(input);

        try {
            var plain = StringCls.$new(input);
            console.log("========== MessageDigest.digest ==========");
            console.log("[+] algorithm = " + this.getAlgorithm());
            console.log("[+] input(str) = " + plain);
            console.log("[+] input(len) = " + input.length);
        } catch (e) {
            console.log("[+] digest input decode failed");
        }

        return result;
    };
});

这个脚本特别适合判断:

  • 是否是 MD5
  • 是否是 SHA-1 / SHA-256
  • 摘要前是否有固定拼接串

但要注意:byte[] 不一定能直接转字符串,遇到乱码是正常的。


示例 4:Hook Cipher,判断是否用了 AES

Java.perform(function () {
    var Cipher = Java.use("javax.crypto.Cipher");

    Cipher.getInstance.overload("java.lang.String").implementation = function (transformation) {
        console.log("[+] Cipher.getInstance -> " + transformation);
        return this.getInstance(transformation);
    };

    Cipher.doFinal.overload("[B").implementation = function (input) {
        console.log("========== Cipher.doFinal called ==========");
        console.log("[+] algorithm = " + this.getAlgorithm());
        console.log("[+] input length = " + input.length);

        var ret = this.doFinal(input);
        console.log("[+] output length = " + ret.length);
        return ret;
    };
});

适合快速判断:

  • 是否用了 AES/ECB/PKCS5Padding
  • 是否存在对称加密而不只是摘要签名

示例 5:Hook OkHttp 请求体,关联最终发包参数

很多时候你已经知道签名函数了,但还需要确认“最终发出去的是不是这份数据”。这时 Hook 网络层最直接。

下面示例 Hook OkHttp 的 Request.Builder

Java.perform(function () {
    var RequestBuilder = Java.use("okhttp3.Request$Builder");

    RequestBuilder.url.overload("java.lang.String").implementation = function (url) {
        console.log("[+] Request URL = " + url);
        return this.url(url);
    };

    RequestBuilder.method.overload("java.lang.String", "okhttp3.RequestBody").implementation = function (method, body) {
        console.log("[+] HTTP Method = " + method);
        console.log("[+] RequestBody = " + body);
        return this.method(method, body);
    };
});

如果你需要进一步打印请求体内容,可以配合 okio.Buffer 去读,但不同版本 OkHttp 实现差异较大。教程里我建议先确认 URL 和方法,避免一开始把网络层 Hook 得太重。


逐步验证清单

做这类分析,我建议按这个顺序一项项打勾,不容易乱:

阶段 1:确认入口

  • 登录按钮点击能定位到方法
  • 用户名、密码能在业务层方法中打印出来

阶段 2:确认参数构造

  • 找到 Map / JSONObject / 请求体组装位置
  • 确认是否有 timestamp / nonce / deviceId

阶段 3:确认加密算法

  • 找到 sign / encrypt / native 方法
  • 或通过 MessageDigest / Cipher Hook 识别算法
  • 记录入参与出参

阶段 4:确认最终发包

  • 将 Hook 得到的 sign 与抓包中的 sign 对比
  • 确认是否有 URL 编码或二次封装
  • 确认是否存在拦截器统一补签

一个完整案例思路

假设你最终在 JADX 中看到类似逻辑:

public String login(String user, String pwd) {
    String ts = String.valueOf(System.currentTimeMillis());
    HashMap<String, String> map = new HashMap<>();
    map.put("username", user);
    map.put("password", md5(pwd));
    map.put("timestamp", ts);
    String sign = SignUtil.sign(map);
    map.put("sign", sign);
    return api.login(map);
}

那么真实链路很可能是:

  1. 用户输入原始密码
  2. 先做一次 md5(pwd)
  3. 再把 username + password + timestamp 这些字段做排序/拼接
  4. SignUtil.sign(map) 生成 sign
  5. 发包

此时你就不该只盯着“密码加密”,而要意识到:

  • password 字段和 sign 字段是两套逻辑
  • 登录失败时服务端可能校验的是整体签名,而不是单独密码
  • 仅复现 md5(pwd) 远远不够

常见坑与排查

这一部分很重要,我自己当时踩坑最多的地方基本都在这。

1. Hook 了方法,但没输出

常见原因:

  • 方法重载选错了
  • 类名是混淆后的,不是你以为的那个
  • 目标方法还没被类加载
  • APP 走的是另一条登录路径

排查建议:

Java.perform(function () {
    var cls = Java.use("com.example.app.security.SignUtil");
    console.log(cls.sign.overloads);
});

先把 overloads 打出来,确认参数签名。


2. Frida 附加后闪退

常见原因:

  • 有 Frida 检测
  • 有 ptrace / 反调试
  • 启动时校验环境
  • Root 检测

可尝试:

  • -f 启动而不是 attach
  • 先 spawn 再 resume
  • 用更早时机注入
  • 配合反检测脚本
  • 在模拟器和真机之间切换验证

3. 找到 MessageDigest 了,但日志太多

因为 APP 全局很多地方都会用摘要,比如:

  • 图片缓存 key
  • 埋点签名
  • token 校验
  • 文件校验

建议增加过滤条件:

if (this.getAlgorithm().toUpperCase().indexOf("MD5") >= 0) {
    // 只打印 MD5
}

或者只在登录页面点击后打印几秒钟内的调用。


4. Map 打印出来了,但字段顺序和服务端不一致

这是个高频坑。很多签名逻辑会:

  • 对 key 排序
  • 过滤空值
  • 去掉 sign 本身
  • 在尾部拼固定 salt

也就是说,你看到的原始 HashMap,不代表真正参与摘要时的拼接顺序。

解决办法:

  • Hook 真正的 sign(Map) 方法
  • 再往里跟,找到“拼接成字符串”的那一步
  • 如果有 StringBuilder.append() 集中出现,也值得重点看

5. 明明拿到了 sign,重放还是失败

别急,这很常见。原因可能有:

  • timestamp 失效
  • nonce 一次性使用
  • deviceId / androidId 参与签名
  • 请求头也参与了校验
  • TLS 层有证书绑定,不是单纯参数问题

你要补查的点包括:

  • Header 是否有动态 token
  • Body 外层是否又加了一层统一签名
  • Native 层是否参与
  • 是否存在服务端风控字段

6. Java 层找不到,可能在 Native 层

如果你看到类似:

public native String encode(String data);

或者签名逻辑一进方法就跳 JNI,那说明关键流程可能在 so 里。这时策略要切换:

  • 先 Hook Java 层 native 调用入口,看入参与返回值
  • 再决定是否深入 libxxx.so

对于教程场景,先把 Java 层入口和结果拿到,通常已经足够还原协议的大部分行为。


安全/性能最佳实践

虽然这是逆向分析文章,但实际操作时仍然建议守住边界。

安全边界

  • 仅分析自有应用获得明确授权的目标
  • 不要在生产用户环境中随意注入脚本
  • 对抓到的账号、token、设备标识做脱敏
  • 记录实验数据时,避免把密钥和敏感参数直接公开

Hook 性能控制

Frida 很强,但日志打多了会明显拖慢 APP,甚至触发异常。

建议:

  • 先 Hook 少量高价值方法,不要全局扫射
  • 对高频方法加过滤条件
  • 不要长时间打印大对象、完整字节数组
  • 遇到加密 API 高频调用时,只在登录动作触发后短时采样

例如可以加个简单的开关:

var enableLog = false;

Java.perform(function () {
    var LoginActivity = Java.use("com.example.app.ui.LoginActivity");
    LoginActivity.onLoginClick.implementation = function () {
        enableLog = true;
        console.log("[+] login click, enable hook log");
        return this.onLoginClick();
    };
});

然后在其他 Hook 点中判断 enableLog 再输出。

分层记录,比一把梭更稳

我比较推荐的节奏是:

  1. 先确认登录入口
  2. 再确认参数组装
  3. 再确认 sign 函数
  4. 最后补网络层验证

这样每一步都有结果,不容易把自己绕进去。


一个更稳的 Hook 组合建议

如果你只想快速拿结果,我建议从下面这组最小闭环开始:

  1. Hook 登录业务入口
  2. Hook sign(Map) 或疑似工具类
  3. Hook MessageDigest.getInstance
  4. Hook 请求 URL

这样通常就能回答 80% 的问题:

  • 登录时传入了什么明文
  • 哪些字段参与签名
  • 用了什么算法
  • 最终请求去了哪个接口

只有在这些信息还不够时,再去补:

  • Cipher.doFinal
  • Native 方法
  • OkHttp 请求体细节
  • 拦截器统一加签

总结

做“登录参数加密流程定位”,真正有效的方法不是盲猜算法,而是建立一条可验证的调用链

  • JADX 找到登录业务入口和参数组装点
  • Frida Hook 关键方法的入参与返回值
  • 用标准 API Hook 辅助识别摘要/加密算法
  • 用网络层 Hook 对照最终发包结果

一句话概括这套方法:先缩小范围,再动态确认,不要一开始就试图看懂所有代码。

如果你现在正卡在某个 APP 的登录接口,我建议你立刻做这三件事:

  1. 在 JADX 中先找到登录入口和 /login 请求位置
  2. 优先 Hook 业务层的 loginsign(Map)
  3. 再用 MessageDigest / Cipher 判断到底是摘要、对称加密还是混合流程

边界条件也要记住:

  • 如果 Java 层看不到核心逻辑,要考虑 JNI
  • 如果 sign 对得上但仍失败,要补查 header、timestamp、deviceId
  • 如果 Frida 一附加就崩,要先处理反调试和反注入

你不用一次把整套链路全拿下。只要先抓到**“明文长什么样、在哪一步变成密文”**,后面的分析就会轻松很多。


分享到:

上一篇
《前端开发中的模块联邦实战:在中型项目中落地微前端架构的拆分、共享与部署策略》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建提速到安全优化的完整方案》