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

《安卓逆向实战:基于 Frida 与 JADX 的应用登录流程分析与参数签名定位》

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

背景与问题

在 Android 应用逆向里,登录接口几乎是最常见、也最容易“卡壳”的分析点。表面上看,请求体里只是用户名、密码和几个设备参数;真正困难的地方在于:

  • 请求参数可能被二次封装
  • 密码可能不是明文传输
  • 请求头里往往有时间戳、nonce、token、sign
  • sign 通常不是一个单纯的 MD5,而是“拼接 + 排序 + 混淆 + native/Java 混合计算”

很多同学一上来就抓包,看到接口返回 sign errorinvalid request,就开始盲猜算法。这个阶段最容易浪费时间。

这篇文章我想换一个更实战的角度:先用 JADX 建静态认知,再用 Frida 在运行时把关键参数流“钉住”,最终定位登录流程里的参数签名生成逻辑。你可以把它理解成一套可重复的工作流,而不是某个 App 的一次性技巧。

文章默认用于授权测试、开发调试与安全研究场景。请勿将文中方法用于未授权目标。


前置知识

如果你已经做过 Android 基础逆向,可以快速略过这一节。需要用到的知识点主要有:

  • 能看懂基础 Java/Kotlin 反编译代码
  • 知道 Android 常见网络栈:OkHttpHttpURLConnectionRetrofit
  • 了解 Frida 的基本 Hook 方式
  • 知道常见摘要算法:MD5SHA1SHA256HMAC

环境准备

建议准备以下环境:

  • 一台 Android 测试机或模拟器
  • adb
  • jadx-gui
  • frida-tools
  • objection(可选)
  • 抓包工具:Charles / Fiddler / mitmproxy(可选但推荐)

安装示例:

pip install frida-tools

确认设备连通:

adb devices
frida-ps -U

如果是较新的 Android 设备,建议提前处理这些基础问题:

  • 安装好对应架构的 frida-server
  • 确保设备与 PC 在同一调试链路
  • 确认目标 App 不是多进程误附加
  • 先确认是否有 Root / Magisk / 虚拟环境需求

问题拆解:为什么要“静态 + 动态”结合

只靠静态分析,常见问题是:

  • 混淆严重,类名方法名不可读
  • 调用链太长,难以判断哪个参数最终上送
  • 某些关键逻辑在运行时才会分支
  • native 层参与签名时,Java 层只能看到入口

只靠动态分析,也有问题:

  • 不知道该 Hook 哪些点
  • 容易打在太底层,日志巨大
  • 无法建立参数语义,只能看到“字符串流过”

所以更稳的方式是:

  1. JADX 找登录入口和网络调用层
  2. 确定请求对象、参数封装对象、签名工具类候选
  3. Frida Hook 参数构造、Map 写入、摘要计算、请求发起
  4. 把“输入参数”与“最终 sign”关联起来
  5. 必要时继续追到 native 层入口

核心原理

登录流程里,参数签名定位的关键不是“猜算法”,而是恢复参数流向。你可以把它抽象成一条数据链:

flowchart LR
    A[登录按钮点击] --> B[ViewModel/Presenter]
    B --> C[请求参数对象构造]
    C --> D[公共参数注入]
    D --> E[签名函数计算 sign]
    E --> F[OkHttp/Retrofit 发起请求]
    F --> G[服务端校验]

我们真正要找的是两件事:

  1. sign 在哪里生成
  2. sign 的输入材料是什么

典型输入包括:

  • 用户名 / 密码 / 手机号
  • 时间戳
  • nonce / salt
  • appKey / secret
  • 设备号 / 渠道号 / 版本号
  • 请求路径 / body 原文
  • 参数排序后的 canonical string

常见签名实现模式

1. 纯 Java 工具类

最容易定位,通常长这样:

String sign = Md5Util.md5(a + b + c + secret);

2. Map 排序后拼接

很多接口会先把请求参数放进 Map,排序后拼接:

TreeMap<String, String> map = new TreeMap<>(params);

再生成:

key1=value1&key2=value2&...&secret=xxx

3. JSON 串整体签名

有些项目直接对 body 的 JSON 字符串做摘要:

sign = sha256(json + timestamp + secret);

4. Java 调 native

Java 层只是个壳:

public native String sign(String data);

这时要先 Hook Java 入口,把传入 native 的内容抓出来,必要时再继续看 so 层。


分析视角:从“请求发起前”倒推

如果你不知道从哪里下手,我通常建议先从请求发起前一跳倒推,因为这里最接近真实上送数据。

sequenceDiagram
    participant U as 用户
    participant A as App逻辑层
    participant S as 签名模块
    participant N as 网络层
    participant R as 服务端

    U->>A: 输入账号密码并点击登录
    A->>A: 构造业务参数
    A->>S: 传入待签名数据
    S-->>A: 返回 sign
    A->>N: 组装请求头/请求体
    N->>R: POST /login
    R-->>N: 响应结果
    N-->>A: 登录成功/失败

所以我们在实战里,一般按这条路线定位:

  • 先看 OkHttp 请求体 / 请求头
  • 再看谁给这些字段赋值
  • 再往上找 sign 生成函数
  • 最后确认输入参数、拼接顺序和边界条件

用 JADX 做第一轮静态定位

1. 搜关键字

打开 APK 到 jadx-gui 后,先搜这些关键字:

  • login
  • sign
  • token
  • timestamp
  • nonce
  • md5
  • sha1
  • sha256
  • digest
  • Interceptor
  • RequestBody
  • Retrofit
  • OkHttpClient

中级阶段最重要的不是搜到“sign”这个词,而是找出登录请求的具体调用链


2. 找网络栈入口

重点看这些类:

  • okhttp3.Interceptor
  • retrofit2.Retrofit
  • Request.Builder
  • 项目自定义的 ApiClient / HttpManager
  • 各种 *Service / *Repository

很多项目会在统一拦截器里补充公共参数和签名,这里命中率非常高。


3. 识别“可疑工具类”

一个签名工具类常见特征:

  • 类名混淆但方法集中做字符串操作
  • 出现 MessageDigest
  • 使用 StringBuilder
  • 出现 TreeMapCollections.sort
  • 出现固定盐值、常量 key
  • 有 JNI 声明

4. 建一个最小调用图

不用一上来把整个项目看完,只要把登录相关路径画出来即可:

flowchart TD
    A[LoginActivity] --> B[LoginPresenter]
    B --> C[UserRepository]
    C --> D[ApiService.login]
    C --> E[CommonParamBuilder]
    E --> F[SignUtil.getSign]
    D --> G[OkHttp Interceptor]

这一步的目的,是给后面的 Frida Hook 找落点。


实战:一步步定位登录参数签名

下面给出一套可以直接运行和改造的示例脚本。为了通用性,我会优先 Hook 常见基础类,而不是依赖具体业务类名。

第一步:确认目标进程

frida-ps -Uai | grep 目标包名

附加进程:

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

第二步:Hook 常见摘要算法

先不要急着 Hook 业务类。我的经验是:摘要算法是最稳定的观察点。哪怕业务代码混淆,MessageDigest 基本跑不掉。

hook_login.js

Java.perform(function () {
    var MessageDigest = Java.use("java.security.MessageDigest");
    var StringCls = Java.use("java.lang.String");
    var Exception = Java.use("java.lang.Exception");
    var Log = Java.use("android.util.Log");

    function bytesToHex(bytes) {
        var result = [];
        for (var i = 0; i < bytes.length; i++) {
            var b = bytes[i];
            if (b < 0) b += 256;
            var h = b.toString(16);
            if (h.length < 2) h = "0" + h;
            result.push(h);
        }
        return result.join("");
    }

    MessageDigest.getInstance.overload("java.lang.String").implementation = function (alg) {
        var ins = this.getInstance(alg);
        console.log("[MessageDigest.getInstance] alg=" + alg);
        return ins;
    };

    MessageDigest.digest.overload("[B").implementation = function (input) {
        var alg = this.getAlgorithm();
        var inStr = "";
        try {
            inStr = StringCls.$new(input);
        } catch (e) {
            inStr = "<binary>";
        }

        console.log("\n[MessageDigest.digest]");
        console.log("algorithm = " + alg);
        console.log("input(str)= " + inStr);
        console.log("input(hex)= " + bytesToHex(input));

        var stack = Log.getStackTraceString(Exception.$new());
        console.log("stack = \n" + stack);

        var out = this.digest(input);
        console.log("output(hex)= " + bytesToHex(out));
        return out;
    };
});

这一步能看到什么

你通常能看到:

  • 用的是 MD5 还是 SHA-256
  • digest 前的原始输入内容
  • 调用栈里是谁发起的

如果输入看起来像:

username=alice&password=123456&timestamp=1700000000&key=abc

那基本已经离答案不远了。


第三步:Hook Map / 参数构造过程

如果摘要输入已经被编码得很厉害,或者看不清字段来源,就继续往上 Hook 参数容器。

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

    function printPut(mapName, k, v) {
        console.log("[" + mapName + ".put] " + k + " = " + v);
        var stack = Log.getStackTraceString(Exception.$new());
        console.log(stack);
    }

    HashMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
        if (k && v) {
            printPut("HashMap", k.toString(), v.toString());
        }
        return this.put(k, v);
    };

    TreeMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
        if (k && v) {
            printPut("TreeMap", k.toString(), v.toString());
        }
        return this.put(k, v);
    };
});

观察重点

你要重点看这些字段何时出现:

  • username
  • password
  • pwd
  • timestamp
  • nonce
  • sign
  • deviceId
  • appVersion

如果你发现:

  1. put(username)
  2. put(password)
  3. put(timestamp)
  4. 最后某处出现 put(sign)

那说明 sign 很可能是在这个参数 Map 完整后计算出来的。

我当时踩过一个坑:全局 Hook HashMap.put 日志会非常爆炸,App 启动阶段几千行很正常。解决办法是先手动触发登录,再只关注相关栈和字段名。


第四步:Hook OkHttp 请求,确认最终上送值

这一步非常关键,因为它可以验证“你看到的 sign”是否就是发出去的 sign。

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

    RequestBuilder.addHeader.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
        console.log("[Request.addHeader] " + k + " = " + v);
        return this.addHeader(k, v);
    };

    RequestBuilder.header.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
        console.log("[Request.header] " + k + " = " + v);
        return this.header(k, v);
    };
});

如果目标 App 使用表单或 JSON 请求体,还可以 Hook RequestBody 创建过程。

Java.perform(function () {
    try {
        var RequestBody = Java.use("okhttp3.RequestBody");
        var MediaType = Java.use("okhttp3.MediaType");
        var StringCls = Java.use("java.lang.String");

        RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mt, content) {
            console.log("[RequestBody.create]");
            console.log("mediaType = " + mt);
            console.log("content   = " + content);
            return this.create(mt, content);
        };
    } catch (e) {
        console.log("Hook RequestBody.create failed: " + e);
    }
});

验证目标

到这一步,你应该能确认以下事实:

  • 登录接口路径是什么
  • 请求头里是否有签名
  • 请求体里是否有签名
  • 最终发送的时间戳、nonce、token 值是什么

第五步:定点 Hook 业务签名函数

通过前面的静态分析和调用栈,你通常已经能锁定某个业务类,比如:

  • com.xxx.util.SignUtil
  • com.xxx.common.Security
  • com.xxx.network.AuthInterceptor
  • 或混淆类如 a.b.c.d

假设你在 JADX 里找到了一个可疑方法:

public static String a(Map<String, String> map)

那就可以定点 Hook:

Java.perform(function () {
    var SignUtil = Java.use("com.xxx.util.SignUtil");

    SignUtil.a.overload("java.util.Map").implementation = function (map) {
        console.log("[SignUtil.a] called");

        var iterator = map.entrySet().iterator();
        while (iterator.hasNext()) {
            var entry = iterator.next();
            console.log("  " + entry.getKey() + " = " + entry.getValue());
        }

        var ret = this.a(map);
        console.log("[SignUtil.a] result = " + ret);
        return ret;
    };
});

如果方法签名复杂,先用 frida-trace 辅助探路:

frida-trace -U -f com.example.app -j 'com.xxx.util.SignUtil!*'

一套更完整的排查思路

为了避免中途迷路,建议按下面这个清单逐步验证。

逐步验证清单

阶段 1:登录入口确认

  • 找到登录按钮点击事件
  • 找到登录接口方法
  • 确认使用的网络库

阶段 2:请求结构确认

  • 确认请求路径
  • 确认请求头字段
  • 确认请求体格式:Form / JSON / Protobuf

阶段 3:签名生成定位

  • 是否存在 MessageDigest
  • 是否存在排序逻辑
  • 是否存在固定盐值或 appSecret
  • 是否存在 JNI 调用

阶段 4:结果验证

  • Hook 到 sign 输入
  • Hook 到 sign 输出
  • Hook 到最终发包值
  • 三者一致

典型场景拆解

场景一:密码先加密,再参与签名

有些 App 会这样做:

  1. 明文密码输入
  2. 先做一次 MD5(password)
  3. 再把这个结果和其他参数一起参与 sign
  4. 最终请求里只传密文密码和 sign

这时你可能在请求体里看不到明文密码,但在早一点的摘要函数或业务加密函数里能看到。

建议做法

  • 先 Hook EditText.getText() 或登录按钮响应,确认用户输入
  • 再 Hook 字符串摘要函数,观察密码何时变化
  • 最后关联 sign 的输入

场景二:sign 放在拦截器统一生成

这种情况最常见。业务层只传原始参数,统一拦截器补:

  • timestamp
  • nonce
  • sign
  • 公共 headers

这种设计对开发很方便,对逆向来说也很友好,因为逻辑集中。

识别特征

  • Interceptor.intercept() 里看到 newBuilder()
  • 请求发出前修改 Request
  • 有统一的设备信息注入

场景三:Java 层只负责组装,真正签名在 so

JADX 里你可能看到:

public class NativeSign {
    static {
        System.loadLibrary("sec");
    }

    public static native String sign(String src);
}

这时别急着直接上 so 分析。先做两件事:

  1. Hook NativeSign.sign() 的 Java 入口
  2. 把传入 src 和返回值抓出来
Java.perform(function () {
    var NativeSign = Java.use("com.xxx.security.NativeSign");

    NativeSign.sign.overload("java.lang.String").implementation = function (src) {
        console.log("[NativeSign.sign] input = " + src);
        var ret = this.sign(src);
        console.log("[NativeSign.sign] output = " + ret);
        return ret;
    };
});

很多时候,仅靠这一层你就足够复现请求了,没必要继续深挖 so。


常见坑与排查

1. Hook 太底层,日志被淹没

现象

  • HashMap.put 一秒刷几千行
  • 根本分不清哪个是登录相关

排查思路

  • 先触发登录动作再观察
  • 打印调用栈,只留业务包名
  • 增加关键字过滤,例如只打印 sign/timestamp/password/login

示例过滤:

function needPrint(k, v) {
    var s = (k + "=" + v).toLowerCase();
    return s.indexOf("sign") >= 0 ||
           s.indexOf("timestamp") >= 0 ||
           s.indexOf("password") >= 0 ||
           s.indexOf("login") >= 0;
}

2. App 启动即闪退,疑似反调试/反 Frida

现象

  • 一附加就崩
  • frida -f 失败
  • 日志里出现反注入检测

排查思路

  • 尝试 spawnattach,或反过来
  • 先用简化脚本验证是否是某段 Hook 引发崩溃
  • 排查 ptraceTracerPid、端口扫描、字符串检测
  • 必要时先做反检测绕过,再上主脚本

3. Hook 不到目标方法

原因常见于

  • 方法重载没选对
  • Kotlin 生成了桥接方法
  • 类在延迟加载
  • 多 ClassLoader 场景
  • 混淆后真实类名判断错误

解决办法

  • overloads 枚举所有签名
  • Java.enumerateLoadedClasses() 先确认类是否存在
  • 针对动态加载场景,Hook ClassLoader.loadClass
  • 借助 frida-trace 初步探路

4. 请求体看不到明文参数

可能原因

  • 使用了 gzip
  • 使用 protobuf / 自定义二进制协议
  • 请求体先加密后上传

建议

  • 不要只盯最终请求体,回到上游参数构造
  • Hook RequestBody.create
  • Hook 加密前的数据构造函数
  • 必要时 dump 原始字节数组

5. 时间戳/nonce 每次都变,复现总失败

这个坑非常常见

你以为自己把算法搞清楚了,但脚本重放仍然失败。通常是漏了这些动态字段:

  • 当前时间戳
  • 服务器下发 token
  • 会话随机数
  • 某个安装期生成的设备密钥

处理建议

  • 把动态值和 sign 同时抓
  • 确认 sign 是否包含 URL path 或 body 原文
  • 对比两次登录样本,找出变化项

安全/性能最佳实践

这部分很重要,尤其是做实战时,很多人脚本能跑,但不稳定、不可维护。

1. 先粗后细,逐步收窄

推荐顺序:

  1. Hook 摘要算法
  2. Hook 参数 Map
  3. Hook 请求头/请求体
  4. 最后 Hook 具体业务类

这样效率通常最高,也不容易一开始就陷入混淆泥潭。


2. 日志一定要做过滤

不要默认打印所有内容。建议至少按以下维度过滤:

  • 包名
  • 字段名
  • 接口路径
  • 时间窗口
  • 当前线程

否则 App 稍复杂一点,你的终端就没法看了。


3. 优先抓“输入”和“输出”,少抓中间态

定位签名时,最有价值的是:

  • 签名前原文
  • 签名结果
  • 最终发包值

中间几十步字符串转换,除非必要,否则只会增加噪音。


4. 注意敏感数据处理

即使是在测试环境,也尽量避免:

  • 长期保存真实账号密码
  • 把生产 token 写进公开日志
  • 在共享仓库提交带敏感信息的脚本输出

建议做简单脱敏:

function mask(s) {
    if (!s) return s;
    s = s.toString();
    if (s.length <= 6) return "***";
    return s.substring(0, 3) + "***" + s.substring(s.length - 3);
}

5. 明确边界:不是所有 sign 都值得完全还原

有些场景下,目标只是:

  • 确认登录失败原因
  • 验证某参数是否参与签名
  • 为开发联调定位客户端 bug

这时候只要抓到 sign 输入输出即可,不一定要把 native 或混淆细节全部还原。能解决问题,比“全看懂”更重要。


可运行的组合脚本示例

下面给一个更贴近实战的组合版 Frida 脚本,把摘要、请求体、请求头一起观察。你可以在此基础上加过滤。

Java.perform(function () {
    var StringCls = Java.use("java.lang.String");
    var MessageDigest = Java.use("java.security.MessageDigest");
    var RequestBuilder = Java.use("okhttp3.Request$Builder");

    function bytesToHex(bytes) {
        var result = [];
        for (var i = 0; i < bytes.length; i++) {
            var b = bytes[i];
            if (b < 0) b += 256;
            var h = b.toString(16);
            if (h.length < 2) h = "0" + h;
            result.push(h);
        }
        return result.join("");
    }

    function safeToString(bytes) {
        try {
            return StringCls.$new(bytes);
        } catch (e) {
            return "<binary>";
        }
    }

    MessageDigest.digest.overload("[B").implementation = function (input) {
        var alg = this.getAlgorithm();
        var plain = safeToString(input);

        if (plain.indexOf("login") >= 0 ||
            plain.indexOf("password") >= 0 ||
            plain.indexOf("timestamp") >= 0 ||
            plain.indexOf("nonce") >= 0) {
            console.log("\n[Digest Hit]");
            console.log("alg   = " + alg);
            console.log("plain = " + plain);
            console.log("hex   = " + bytesToHex(input));
        }

        var out = this.digest(input);
        console.log("[Digest Out] " + bytesToHex(out));
        return out;
    };

    RequestBuilder.addHeader.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
        if (k && v) {
            var ks = k.toString().toLowerCase();
            if (ks.indexOf("sign") >= 0 || ks.indexOf("token") >= 0 || ks.indexOf("time") >= 0) {
                console.log("[Header] " + k + " = " + v);
            }
        }
        return this.addHeader(k, v);
    };

    RequestBuilder.header.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
        if (k && v) {
            var ks = k.toString().toLowerCase();
            if (ks.indexOf("sign") >= 0 || ks.indexOf("token") >= 0 || ks.indexOf("time") >= 0) {
                console.log("[Header override] " + k + " = " + v);
            }
        }
        return this.header(k, v);
    };

    try {
        var RequestBody = Java.use("okhttp3.RequestBody");
        RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mt, content) {
            if (content && (content.indexOf("login") >= 0 || content.indexOf("password") >= 0 || content.indexOf("sign") >= 0)) {
                console.log("\n[RequestBody]");
                console.log("mediaType = " + mt);
                console.log("content   = " + content);
            }
            return this.create(mt, content);
        };
    } catch (e) {
        console.log("RequestBody hook skipped: " + e);
    }

    console.log("hook ready");
});

运行方式:

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

一次完整实战的思维路径示例

假设你抓包看到登录请求如下特征:

  • POST /api/user/login
  • body 有 usernamepassword
  • header 有 x-signx-ts

你可以这样走:

  1. JADX 搜 /api/user/login

    • 找到对应 ApiService.login()
  2. 看调用方

    • 找到 LoginRepository.submitLogin()
  3. 看请求组装

    • 发现先构造 HashMap
    • 再调用 CommonSigner.sign(map)
  4. Hook CommonSigner.sign(map)

    • 打印 map 内容和返回值
  5. Hook Request.Builder.addHeader()

    • 确认 x-sign 的值就是上一步返回值
  6. 如果 CommonSigner 里只调用 native

    • 再 Hook Java native 入口
    • srcret

到这里,定位任务通常就完成了。


总结

分析 Android 登录流程里的参数签名,最怕的不是算法复杂,而是没有路径感。真正高效的方法通常是:

  • JADX 建立登录链路的静态地图
  • Frida 在运行时截住参数构造、摘要计算和最终发包
  • 通过“输入 -> 签名 -> 请求”三点闭环确认结果

如果你只记住一个原则,我建议是这个:

先找最终请求,再倒推 sign;先抓输入输出,再研究内部细节。

最后给几个可执行建议:

  • 第一次分析时,优先盯 MessageDigestInterceptorRequestBody
  • 遇到海量日志,马上加过滤,不要硬看
  • 遇到 native,不一定非得深挖 so,先抓 Java 入口参数
  • 如果目标只是联调或复现,抓到 sign 输入输出就够了

这套方法不只适用于登录,注册、下单、支付预提交、风控校验接口同样适用。只要你能把“参数从哪里来、在哪里签、最后怎么发”这条链路串起来,复杂度就会一下子降很多。


分享到:

上一篇
《Web3 中级实战:基于智能合约与钱包登录构建去中心化会员积分系统》
下一篇
《Docker Compose 实战:为中型项目构建可复用的多环境本地开发与部署方案》