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

《安卓逆向实战:基于 Frida 与 JADX 的登录参数加密链路定位与复现》

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

安卓逆向实战:基于 Frida 与 JADX 的登录参数加密链路定位与复现

很多同学学安卓逆向时,最容易卡住的不是“怎么抓包”,而是抓到了包,却发现登录参数根本看不懂:明明输入的是手机号和密码,发出去却变成了一长串加密串、签名值、时间戳和设备指纹。

这篇文章我想带你完整走一遍:如何借助 JADX 做静态分析,用 Frida 做动态验证,最终定位登录参数加密链路,并在脚本里复现出来。重点不是某个 App 的特例,而是一套可以迁移到大多数 Android 登录场景的方法。

说明:本文内容仅用于授权测试、教学研究与自有应用安全分析,请勿用于未授权目标。


背景与问题

在真实 App 中,登录接口通常不会直接把明文密码发给后端,常见做法包括:

  • 前端先做一次 MD5/SHA/AES/RSA 处理
  • 混入时间戳、随机数、设备信息
  • 对整个请求体做签名
  • 使用 native so 层参与加密
  • 请求参数在 Retrofit / OkHttp 拦截器里二次加工

所以我们面对的典型问题通常不是“这个参数是什么”,而是下面这几个更棘手的问题:

  1. 加密逻辑在哪一层发生?
  2. 是 Java 层还是 native 层?
  3. 加密前原文是什么?
  4. 参与签名的字段有哪些?顺序如何?
  5. 如何脱离 App,独立复现这条链路?

如果只靠抓包,通常只能看到结果;如果只靠反编译,常常会被混淆、跳转和壳绕晕。所以我更推荐的套路是:

  • JADX 找入口和候选函数
  • Frida 在关键节点动态打印参数
  • 逐步缩小范围,锁定真正的加密链路
  • 最后在 Python/JS 中独立复现

前置知识

建议你至少熟悉这些内容:

  • Android 基础组件调用关系
  • HTTP 抓包基础
  • Java 常见加密类:MessageDigestCipherMac
  • Frida 基本 Hook 语法
  • JADX 搜索、交叉引用与调用链查看

如果这些还不熟,也没关系,文中会尽量按“带着做一遍”的方式写。


环境准备

本文默认环境如下:

  • Android 测试机或模拟器
  • 已安装目标 App
  • jadx-gui
  • frida-tools
  • Python 3
  • adb
  • 可选:Burp Suite / Charles

安装 Frida 工具:

pip install frida-tools

查看设备:

adb devices
frida-ps -U

如果能正常列出进程,说明基本环境没问题。


核心原理

先别急着上代码,先把思路建立起来。

一个登录请求从“点击按钮”到“发出网络包”,中间通常会经历以下阶段:

  1. UI 层收集输入
  2. ViewModel / Presenter / Controller 组装登录模型
  3. 参数预处理
    • trim
    • 拼接固定盐值
    • 加时间戳、nonce
  4. 密码或请求体加密
  5. 统一签名
  6. 交给 Retrofit / OkHttp 发送

也就是说,我们真正要找的不是某个“神秘加密函数”,而是一条参数流转链路

flowchart TD
    A[输入手机号/密码] --> B[点击登录按钮]
    B --> C[登录业务方法]
    C --> D[组装请求对象]
    D --> E[密码加密/请求体加密]
    E --> F[签名生成]
    F --> G[Retrofit/OkHttp发送]
    G --> H[服务端校验]

静态分析和动态分析怎么配合?

  • JADX 适合回答:
    “可能在哪里做了加密?”
  • Frida 适合回答:
    “运行时到底是不是这里?传入和传出值是什么?”

这是我平时最常用的分工方式:

sequenceDiagram
    participant U as 分析者
    participant J as JADX
    participant F as Frida
    participant A as 目标App

    U->>J: 搜索 login/sign/encrypt/md5/aes
    J-->>U: 返回候选类与调用链
    U->>F: Hook 候选方法
    F->>A: 动态拦截运行时调用
    A-->>F: 返回入参与结果
    F-->>U: 确认真实加密链路
    U->>U: 独立复现参数生成

第一步:用 JADX 找登录链路入口

打开 APK 到 JADX 后,我一般先做三类搜索。

1. 搜索登录文案和接口路径

关键词示例:

  • login
  • sign
  • password
  • /user/login
  • 手机号
  • 验证码
  • 密码登录

如果 App 没混淆彻底,往往能直接搜到 Retrofit 接口:

@POST("/user/login")
Call<LoginResp> login(@Body LoginReq req);

或者:

@FormUrlEncoded
@POST("/api/auth/login")
Call<ResponseBody> login(
    @Field("mobile") String mobile,
    @Field("pwd") String pwd,
    @Field("sign") String sign
);

2. 搜索常见加密 API

这些类经常是突破口:

  • java.security.MessageDigest
  • javax.crypto.Cipher
  • javax.crypto.Mac
  • SecretKeySpec
  • IvParameterSpec
  • Base64
  • RSA/ECB/PKCS1Padding
  • AES/CBC/PKCS5Padding

比如在 JADX 里全局搜 MessageDigest.getInstance,常常能拉出一批摘要函数。

3. 搜索签名拼接特征

很多签名逻辑并不复杂,常见模式:

  • key=value&key2=value2
  • TreeMap 排序后拼接
  • 末尾追加 secret
  • md5(str).toUpperCase()

例如你可能看到类似代码:

public static String sign(Map<String, String> map, String secret) {
    TreeMap<String, String> treeMap = new TreeMap<>(map);
    StringBuilder sb = new StringBuilder();
    for (Map.Entry<String, String> e : treeMap.entrySet()) {
        sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
    }
    sb.append("secret=").append(secret);
    return MD5Util.md5(sb.toString()).toUpperCase();
}

看到这种代码,基本就能判断:登录参数可能不是单独加密,而是“密码先处理 + 请求整体签名”


第二步:缩小候选范围

只靠静态分析,容易遇到两个问题:

  • 搜到十几个 MD5/AES 方法,不知道哪个是真的
  • 调用链被混淆,方法名像 a.a.a()b.c.d()

这时我会从网络发送前往回找,而不是从 UI 层一直往下翻。

常见回溯点

  1. Retrofit 接口定义
  2. OkHttp 拦截器
  3. 请求对象 LoginReq
  4. 序列化前的模型加工方法
  5. 通用签名工具类

一个很实用的判断标准是:

离发包越近的方法,越可能拿到“最终有效参数”。

比如如果你在 Interceptor 中看到:

String sign = SignUtil.sign(params, AppConfig.SECRET);
params.put("sign", sign);

那么 SignUtil.sign 就必须动态验证。


第三步:用 Frida 动态确认加密点

下面开始进入实战。

目标

我们要回答三个问题:

  1. 登录密码加密前原文是什么?
  2. 加密后结果是什么?
  3. 签名字符串的拼接原文是什么?

方案

优先 Hook 这些点:

  • 登录请求对象构造函数
  • 加密工具方法
  • 签名方法
  • MessageDigest.digest
  • Cipher.doFinal
  • OkHttp 请求发送前

实战代码:Hook Java 层常见加密链路

下面这份 Frida 脚本可以直接运行,适合作为通用模板。

1. Hook 常见摘要与 AES/RSA 调用

Java.perform(function () {
    var MessageDigest = Java.use('java.security.MessageDigest');
    var Cipher = Java.use('javax.crypto.Cipher');
    var StringCls = Java.use('java.lang.String');
    var Base64 = Java.use('android.util.Base64');

    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('');
    }

    function safeStr(bytes) {
        try {
            return StringCls.$new(bytes);
        } catch (e) {
            return '[binary data]';
        }
    }

    MessageDigest.digest.overload('[B').implementation = function (input) {
        var algo = this.getAlgorithm();
        console.log('\n[MessageDigest.digest]');
        console.log('algo = ' + algo);
        console.log('input(str) = ' + safeStr(input));
        console.log('input(hex) = ' + bytesToHex(input));

        var ret = this.digest(input);

        console.log('output(hex) = ' + bytesToHex(ret));
        return ret;
    };

    Cipher.doFinal.overload('[B').implementation = function (input) {
        console.log('\n[Cipher.doFinal]');
        console.log('algorithm = ' + this.getAlgorithm());
        console.log('input(str) = ' + safeStr(input));
        console.log('input(hex) = ' + bytesToHex(input));

        var ret = this.doFinal(input);

        console.log('output(Base64) = ' + Base64.encodeToString(ret, 2));
        console.log('output(hex) = ' + bytesToHex(ret));
        return ret;
    };
});

运行方式:

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

如果 App 已经启动,也可以附加:

frida -U com.example.app -l hook_crypto.js

这份脚本能解决什么?

它适合快速判断:

  • App 是否在 Java 层做摘要/对称加密/非对称加密
  • 明文输入是什么
  • 输出结果是否与抓包字段一致

如果你发现打印出的输出和请求里的 passwordsigndata 字段高度一致,就说明你找对方向了。


实战代码:精准 Hook 登录请求构造与签名函数

通用 Hook 能帮你发现“有加密”,但要想复现,就必须知道哪个业务方法在处理登录参数

假设我们在 JADX 中找到了如下可疑类:

  • com.demo.auth.LoginRequest
  • com.demo.security.SignUtil
  • com.demo.security.CryptoUtil

那么可以精准 Hook。

Java.perform(function () {
    var LoginRequest = Java.use('com.demo.auth.LoginRequest');
    var SignUtil = Java.use('com.demo.security.SignUtil');
    var CryptoUtil = Java.use('com.demo.security.CryptoUtil');

    LoginRequest.$init.overload('java.lang.String', 'java.lang.String').implementation = function (mobile, password) {
        console.log('\n[LoginRequest.$init]');
        console.log('mobile = ' + mobile);
        console.log('password(raw) = ' + password);
        return this.$init(mobile, password);
    };

    CryptoUtil.encryptPassword.overload('java.lang.String').implementation = function (pwd) {
        console.log('\n[CryptoUtil.encryptPassword]');
        console.log('input = ' + pwd);
        var ret = this.encryptPassword(pwd);
        console.log('output = ' + ret);
        return ret;
    };

    SignUtil.sign.overload('java.util.Map').implementation = function (map) {
        console.log('\n[SignUtil.sign]');
        var iterator = map.entrySet().iterator();
        while (iterator.hasNext()) {
            var entry = iterator.next();
            console.log(entry.getKey() + ' = ' + entry.getValue());
        }
        var ret = this.sign(map);
        console.log('sign = ' + ret);
        return ret;
    };
});

判断是否命中

当你点击登录时,理想输出链路应该类似这样:

[LoginRequest.$init]
mobile = 13800138000
password(raw) = 123456

[CryptoUtil.encryptPassword]
input = 123456
output = e10adc3949ba59abbe56e057f20f883e

[SignUtil.sign]
mobile = 13800138000
password = e10adc3949ba59abbe56e057f20f883e
timestamp = 1700000000
nonce = abcdef
sign = 8F3A2A...

一旦看到这种日志,复现就基本进入收尾阶段了。


第四步:必要时 Hook OkHttp,拿最终出站参数

很多人会踩一个坑:你 Hook 到了业务层的参数,但真正发出去的请求又被拦截器改了一次。

比如:

  • 增加公共参数
  • 对 body 整体加密
  • 重新计算签名
  • Header 里塞 token 和设备信息

所以还需要在网络层做最终确认。

Hook RequestBody 写出内容

Java.perform(function () {
    var Buffer = Java.use('okio.Buffer');
    var RequestBody = Java.use('okhttp3.RequestBody');

    RequestBody.writeTo.overload('okio.BufferedSink').implementation = function (sink) {
        var buffer = Buffer.$new();
        this.writeTo(buffer);
        try {
            console.log('\n[RequestBody.writeTo]');
            console.log(buffer.readUtf8());
        } catch (e) {
            console.log('[RequestBody.writeTo] binary body');
        }
        this.writeTo(sink);
    };
});

如果请求体是 JSON/Form,一般可以直接看到最终内容。


逐步验证清单

为了避免“以为自己找到了,其实还差一步”,我建议按下面清单核对:

  • 输入密码明文已拿到
  • 密码处理函数已定位
  • 请求签名函数已定位
  • 时间戳/nonce/设备号来源已确认
  • 最终出站请求体已抓到
  • 本地复现结果与 App 发包一致

这个过程里我最常见的失误是:只复现了密码加密,却忘了整体签名。结果服务端仍然返回签名错误。


第五步:用 Python 复现加密链路

下面给一个典型复现示例。假设通过 Hook 我们已经确认:

  1. 密码先做 MD5
  2. 所有参数按 key 排序拼接
  3. 末尾加 secret
  4. 再做一次 MD5().upper()

Python 复现代码

import hashlib
import time
import uuid


def md5_hex(s: str) -> str:
    return hashlib.md5(s.encode("utf-8")).hexdigest()


def encrypt_password(password: str) -> str:
    return md5_hex(password)


def build_sign(params: dict, secret: str) -> str:
    items = sorted(params.items(), key=lambda x: x[0])
    raw = "&".join(f"{k}={v}" for k, v in items)
    raw = raw + f"&secret={secret}"
    print("[sign raw]", raw)
    return md5_hex(raw).upper()


def build_login_payload(mobile: str, password: str) -> dict:
    ts = str(int(time.time()))
    nonce = uuid.uuid4().hex[:8]

    payload = {
        "mobile": mobile,
        "password": encrypt_password(password),
        "timestamp": ts,
        "nonce": nonce,
        "platform": "android"
    }
    payload["sign"] = build_sign(payload, "test_secret_123")
    return payload


if __name__ == "__main__":
    data = build_login_payload("13800138000", "123456")
    print(data)

如果你的 Hook 日志与 Python 输出一致,说明链路已经成功复现。


一种更复杂的情况:密码加密在 Java 层,签名在 native 层

现实里还有一种很常见的情况:

  • Java 代码里只能看到 native String sign(String raw);
  • 真正签名逻辑在 .so

这时候不要慌,定位方法还是一样,只是切换到边界点思维

  1. Hook Java 层调用 native 前的参数
  2. Hook native 返回结果
  3. 对比抓包字段
  4. 再决定要不要深入 so 层
flowchart LR
    A[Java层组装原始串] --> B[native sign(raw)]
    B --> C[返回签名结果]
    C --> D[写入请求参数]
    D --> E[发包]

在很多场景下,如果你的目标只是“复现请求”,其实未必需要完全还原 so 内部逻辑。
只要能在边界处拿到原始输入和输出,就已经足够做代理调用或做进一步分析。


常见坑与排查

这一部分我尽量写得“接地气”一点,因为很多问题真的不是理论问题,而是环境和细节问题。

1. Hook 不生效

常见原因:

  • 包名写错
  • Hook 时机太晚
  • 方法重载没选对
  • App 多进程,挂错进程了

排查建议:

frida-ps -Uai

看清楚进程名,必要时加 -f 冷启动:

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

如果方法有重载,先枚举:

Java.perform(function () {
    var Cls = Java.use('com.demo.security.SignUtil');
    console.log(Cls.sign.overloads);
});

2. 打印出来是乱码或二进制

原因通常是:

  • 输入不是 UTF-8 文本
  • 已经是压缩/加密后的字节流
  • 是 protobuf、gzip 或自定义二进制协议

排查建议:

  • 同时打印 hexBase64
  • 不要只相信字符串输出
  • 对 OkHttp body 再做一次确认

3. 以为找到了加密函数,其实只是辅助函数

比如你看到一个 md5(),很兴奋,结果它只是算设备指纹,不是登录密码。

排查思路:

  • 看调用栈
  • 看调用时机:是否在点击登录后触发
  • 看输出是否进入最终请求参数

必要时直接打印栈:

Java.perform(function () {
    var Exception = Java.use('java.lang.Exception');
    var Log = Java.use('android.util.Log');

    var CryptoUtil = Java.use('com.demo.security.CryptoUtil');
    CryptoUtil.encryptPassword.overload('java.lang.String').implementation = function (s) {
        console.log(Log.getStackTraceString(Exception.$new()));
        return this.encryptPassword(s);
    };
});

4. 参数总是对不上

这个问题我自己踩过很多次,常见漏项包括:

  • 时间戳格式不对:秒还是毫秒
  • nonce 长度不对
  • 签名前是否 URL 编码
  • 参数是否按字典序排序
  • 空值字段是否参与签名
  • sign 自己是否排除在签名外
  • 大小写不同:md5().upper() vs md5().lower()

我的建议是:
不要凭感觉猜,逐字段比对 App 运行时日志。

5. App 有反 Frida 检测

常见现象:

  • 一注入就闪退
  • 某些页面打不开
  • 登录按钮无响应

思路一般有两种:

  • 先过检测,再做业务 Hook
  • 尽量用更隐蔽的注入方式

如果只是学习研究,建议先选择防护弱一些的样本练手。不要一上来就跟重保护 App 硬碰硬,不然容易把时间都耗在环境对抗上。


安全/性能最佳实践

这一节既是给做分析的人,也是给做客户端安全的人。

对逆向分析过程的实践建议

1. 先定位边界,再追实现

不要一开始就死磕每一行代码。优先找:

  • 登录入口
  • 参数构造点
  • 发包前最终形态

这样效率高很多。

2. 小步验证,不要一次 Hook 一大片

很多同学喜欢把几十个类全 Hook 上,结果日志爆炸,反而更难看。
建议顺序是:

  1. 先 Hook 通用加密 API
  2. 再 Hook 候选业务类
  3. 最后 Hook 网络出站

3. 日志只保留关键字段

打印太多会拖慢 App,甚至导致卡顿或 ANR。
特别是循环内 Hook、频繁 digest 的场景,要控制输出量。


对 App 安全设计的建议

如果你站在开发或安全加固视角,单纯把密码做一次 MD5,其实防护价值很有限。

更稳妥的思路包括:

  • 使用 TLS,不依赖前端“伪加密”代替传输安全
  • 登录凭证避免可重放
  • 签名绑定时间戳、nonce、设备上下文
  • 密钥不要硬编码在 Java 层
  • 核心逻辑下沉 native 或服务端
  • 配合反调试、完整性校验、环境检测

但也要有边界意识:
只要密钥和算法在客户端可执行,就原则上可以被分析。
所以客户端安全更像是“提高成本”,而不是“绝对不可逆”。


一套实战落地流程

如果你想把今天这篇文章的方法用到自己的样本上,可以直接按这个顺序执行:

stateDiagram-v2
    [*] --> 安装并启动App
    安装并启动App --> 抓包观察登录接口
    抓包观察登录接口 --> JADX搜索接口与加密关键词
    JADX搜索接口与加密关键词 --> 锁定候选类
    锁定候选类 --> Frida通用Hook验证
    Frida通用Hook验证 --> Frida精准Hook业务方法
    Frida精准Hook业务方法 --> Hook最终请求体
    Hook最终请求体 --> Python复现
    Python复现 --> 与抓包结果比对
    与抓包结果比对 --> [*]

这套流程的核心优点是:每一步都可验证
一旦某一步不通,马上回退,不会一头扎进错误方向。


总结

这篇文章的重点,其实不是某一段 Hook 代码,而是这套分析方法:

  1. 先用 JADX 找候选入口
  2. 再用 Frida 动态确认真实调用
  3. 从“最终出站参数”反推回加密链路
  4. 最后在 Python 中独立复现

如果你只记住一句话,我希望是这句:

登录参数加密定位,本质上是在追踪“参数从明文到出站密文”的流转过程。

给你的可执行建议

  • 第一次练手,优先选 Java 层加密明显、无重保护的样本
  • 先 Hook MessageDigest.digestCipher.doFinalRequestBody.writeTo
  • 一旦发现请求字段对应关系,马上做最小复现
  • 参数复现失败时,优先排查排序、时间戳、nonce、大小写和编码
  • 如果遇到 native,先抓边界输入输出,不要急着进 so

边界条件

本文主要覆盖:

  • Java 层加密
  • Java/native 混合但可在边界处观察的场景
  • 基于 Retrofit/OkHttp 的常见登录链路

如果目标使用了:

  • 强对抗壳
  • 深度反调试
  • 全 native 网络栈
  • 自定义 VM 或解释执行

那就需要额外的对抗与底层分析手段,不能只靠本文这套模板直接解决。

如果你已经能把文中的 Hook 跑起来,并成功对上抓包中的一个关键字段,那说明你已经真正迈进“能做实战”的阶段了。剩下的,就是多练几个样本,把这套路径走熟。


分享到:

上一篇
《Spring Boot 中基于 Redis + 注解实现接口幂等性的实战方案》
下一篇
《Node.js 中级实战:基于 Worker Threads 与队列机制构建高并发任务处理服务》