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

《安卓逆向实战:基于 Frida 与 JADX 的登录参数签名分析与请求重放方法》

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

安卓逆向实战:基于 Frida 与 JADX 的登录参数签名分析与请求重放方法

很多同学学安卓逆向时,都会卡在同一个地方:App 抓到包了,但请求里的签名参数完全看不懂。尤其是登录接口,常常带着 signtokennoncetimestampdeviceId 之类的字段,改一个参数就直接 401 或 403。

这篇文章我换一个更偏“落地”的角度来讲:不是泛泛说 Frida 和 JADX 能做什么,而是带你从“看到一个登录请求”开始,一步步定位签名生成逻辑,并最终完成请求重放验证

先提醒一句:本文内容适用于授权测试、教学研究、自有应用分析等合法场景。不要用于未授权目标。


背景与问题

假设你已经抓到一个登录请求,大致像这样:

POST /api/user/login HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "username": "test01",
  "password": "123456",
  "timestamp": 1669150000,
  "nonce": "4d2c1a9f",
  "sign": "6f3a0b7d..."
}

表面看上去很正常,但问题通常出在这几个地方:

  1. password 不是明文,可能先做了 hash 或加密。
  2. sign 不是简单拼接,可能混入设备信息、版本号、渠道号。
  3. 算法可能不在 Java 层,而在 JNI so 里。
  4. 即使你知道了接口,也可能因为 Header、Cookie、证书校验、动态 token 没处理好,导致重放失败。

所以真正的目标不是“看懂几个字段”,而是建立一条完整链路:

  • JADX 静态分析定位入口
  • Frida 动态 Hook 还原参数
  • 复刻签名逻辑
  • 请求重放验证
  • 遇到失败时快速排查

前置知识与环境准备

建议你先具备这些基础:

  • 会用 jadx-gui 看 Java 代码
  • 知道 Android 常见网络库:OkHttp、Retrofit、Volley
  • 会基本的 Frida Hook
  • 知道 Burp / Charles / mitmproxy 抓包基础

环境清单

  • Android 测试机或模拟器
  • jadx 最新版
  • frida-tools
  • adb
  • Python 3
  • 抓包工具(Burp 或 mitmproxy)

安装 Frida 工具:

pip install frida-tools

查看设备:

adb devices
frida-ps -U

如果是 USB 真机,记得确认:

  • 已开启 USB 调试
  • Frida server 版本与本机 Frida 版本一致
  • App 可正常启动

分析路径总览

先看整体流程,避免做着做着迷路。

flowchart TD
    A[抓到登录请求] --> B[JADX 搜索接口路径/参数名]
    B --> C[定位网络请求封装层]
    C --> D[追踪 sign/timestamp/nonce 来源]
    D --> E[Frida Hook 关键方法]
    E --> F[拿到明文参数与返回签名]
    F --> G[Python 复刻请求]
    G --> H[重放验证]
    H --> I{是否成功}
    I -- 是 --> J[固化脚本]
    I -- 否 --> K[排查 Header Token TLS Native]

核心原理

1. 为什么要把 JADX 和 Frida 结合起来

单独用 JADX,常见问题是:

  • 混淆后类名方法名难读
  • 代码分支多,找不到实际运行路径
  • 某些值是运行时拼出来的

单独用 Frida,也有问题:

  • 不知道该 Hook 哪个类、哪个方法
  • Hook 点太泛,输出一堆无效日志
  • 遇到重载、匿名内部类、动态代理容易乱

所以更高效的做法是:

  • 先用 JADX 缩小范围
  • 再用 Frida 在运行时验证

这是我自己最常用的套路,效率比“盲 Hook”高很多。

2. 登录签名通常长什么样

典型签名生成逻辑一般是:

sign = hash(secret + username + passwordHash + timestamp + nonce + deviceId)

或者:

sign = HMAC_SHA256(sort(params) + appKey, appSecret)

再复杂一点会有:

  • 参数排序
  • URL 编码
  • 空值过滤
  • 固定盐值
  • 版本号参与签名
  • native 层计算

3. 我们要找的不是“算法名”,而是“真实入参”

很多人分析卡住,是因为一上来就想判断:

  • 这是 MD5?
  • 这是 SHA1?
  • 这是 AES?
  • 这是 RSA?

其实更关键的是先搞清楚:

  1. 签名前的原始字符串是什么
  2. 参数顺序是什么
  3. 有没有固定盐/动态盐
  4. 签名结果是否做了大小写或二次编码

如果把“原始入参”拿到了,后面大概率就不难了。


用 JADX 定位签名生成入口

第一步:从接口路径反查调用点

如果抓包里有 /api/user/login,先在 JADX 全局搜索:

  • /api/user/login
  • login
  • sign
  • timestamp
  • nonce

通常能找到 Retrofit 接口定义,例如:

public interface ApiService {
    @POST("/api/user/login")
    Call<LoginResp> login(@Body LoginReq req);
}

接着追 LoginReq、请求拦截器、仓储层、ViewModel 或 Presenter。

第二步:重点盯住这些位置

我一般优先看下面几类代码:

  1. OkHttp Interceptor
    • 很多统一签名是在拦截器里加的
  2. 请求实体的 build 方法
    • LoginReq.build()toMap() 之类
  3. 工具类
    • SignUtilSecurityUtilEncryptUtils
  4. JNI 调用
    • nativeSign()getToken()

第三步:识别“像签名”的代码

比如你可能会看到:

public static String sign(Map<String, String> params) {
    TreeMap<String, String> sorted = new TreeMap<>(params);
    StringBuilder sb = new StringBuilder();
    for (Map.Entry<String, String> e : sorted.entrySet()) {
        if (!TextUtils.isEmpty(e.getValue())) {
            sb.append(e.getKey()).append("=").append(e.getValue()).append("&");
        }
    }
    sb.append("key=").append("A1B2C3D4");
    return md5(sb.toString()).toLowerCase();
}

到这里先别急着“宣布破案”,因为你还要确认:

  • 这个方法是不是登录接口实际调用的
  • params 里到底有哪些字段
  • password 在传进来之前是不是已经变了

Frida 动态调试:把关键参数钉死

接下来进入动态分析。我们的目标是:

  • Hook 登录请求构造点
  • Hook 签名函数
  • 打印签名前字符串和返回值
  • 必要时 Hook 网络层直接看请求体

方案一:直接 Hook 签名方法

假设在 JADX 里看到一个类:

com.demo.security.SignUtil.sign(java.util.Map)

可以这样 Hook:

Java.perform(function () {
    var SignUtil = Java.use("com.demo.security.SignUtil");
    var Map = Java.use("java.util.Map");

    SignUtil.sign.overload('java.util.Map').implementation = function (params) {
        console.log("==== SignUtil.sign called ====");
        console.log("params => " + params.toString());

        var result = this.sign(params);

        console.log("sign => " + result);
        console.log("==============================");
        return result;
    };
});

运行:

frida -U -f com.demo.app -l hook_sign.js

方案二:Hook 请求体构造

如果签名方法不好找,可以先 Hook 登录参数对象。

例如 LoginReq

Java.perform(function () {
    var LoginReq = Java.use("com.demo.net.model.LoginReq");

    LoginReq.$init.overload('java.lang.String', 'java.lang.String').implementation = function (u, p) {
        console.log("[LoginReq] username=" + u + ", password=" + p);
        return this.$init(u, p);
    };
});

如果 password 已经不是明文,这说明加密发生在更前面;如果这里还是明文,而抓包里变了,说明加密发生在更后面。

方案三:Hook OkHttp 拦截器

实际项目里,统一加签经常在拦截器里。Hook 这个点很稳。

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

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

如果签名放在 Body 而不是 Header,可以 Hook RequestBody.writeTo 或序列化层。


动态调用关系图

下面这张图能帮助你理解一次登录点击后,签名可能经过哪些层。

sequenceDiagram
    participant UI as LoginActivity
    participant VM as ViewModel/Presenter
    participant REQ as LoginReq
    participant SIG as SignUtil
    participant INT as OkHttp Interceptor
    participant NET as Server

    UI->>VM: 输入用户名/密码并点击登录
    VM->>REQ: 构造请求对象
    REQ->>SIG: 生成 sign/timestamp/nonce
    SIG-->>REQ: 返回签名
    REQ->>INT: 进入网络拦截器
    INT->>INT: 添加 Header/Token
    INT->>NET: 发起 POST /api/user/login
    NET-->>UI: 返回登录结果

实战代码(可运行)

下面给一套可以直接上手的“组合拳”:Hook 登录签名 + Python 重放

注意:类名、包名、接口地址请替换成你自己的测试目标。


1)Frida:打印签名前参数、签名结果、时间戳

Java.perform(function () {
    console.log("[*] Frida hook started");

    var SignUtil = Java.use("com.demo.security.SignUtil");
    var System = Java.use("java.lang.System");

    SignUtil.sign.overload('java.util.Map').implementation = function (params) {
        console.log("\n==== sign() called ====");
        console.log("params: " + params.toString());

        var now = System.currentTimeMillis();
        console.log("currentTimeMillis: " + now);

        var result = this.sign(params);

        console.log("result sign: " + result);
        console.log("========================\n");
        return result;
    };
});

启动:

frida -U -f com.demo.app -l hook_sign.js --no-pause

2)Frida:辅助打印登录对象字段

如果 Map.toString() 信息不够,可以直接读对象字段。

Java.perform(function () {
    var LoginReq = Java.use("com.demo.net.model.LoginReq");

    LoginReq.build.overload().implementation = function () {
        var obj = this.build();

        try {
            console.log("username = " + this.username.value);
            console.log("password = " + this.password.value);
            console.log("timestamp = " + this.timestamp.value);
            console.log("nonce = " + this.nonce.value);
            console.log("sign = " + this.sign.value);
        } catch (e) {
            console.log("read field error: " + e);
        }

        return obj;
    };
});

3)Python:重放登录请求

假设你已经通过 Frida 确认签名逻辑是:

  • 参数按 key 排序
  • 拼接成 k=v&k=v
  • 最后追加 &key=A1B2C3D4
  • 再做 MD5 小写

下面是一个可运行的 Python 版本:

import hashlib
import time
import uuid
import requests


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


def build_sign(params: dict, secret: str) -> str:
    items = sorted((k, v) for k, v in params.items() if v is not None and v != "")
    raw = "&".join(f"{k}={v}" for k, v in items)
    raw = f"{raw}&key={secret}"
    print("[raw string]", raw)
    return md5_hex(raw)


def login(base_url: str, username: str, password: str):
    timestamp = int(time.time())
    nonce = uuid.uuid4().hex[:8]

    # 如果密码在 App 内先做了 md5,请在这里同步处理
    password_md5 = md5_hex(password)

    params = {
        "username": username,
        "password": password_md5,
        "timestamp": timestamp,
        "nonce": nonce,
    }

    sign = build_sign(params, "A1B2C3D4")
    data = {
        **params,
        "sign": sign
    }

    headers = {
        "User-Agent": "okhttp/4.9.0",
        "Content-Type": "application/json",
    }

    resp = requests.post(
        f"{base_url}/api/user/login",
        json=data,
        headers=headers,
        timeout=10
    )

    print("[status]", resp.status_code)
    print("[body]", resp.text)


if __name__ == "__main__":
    login("https://example.com", "test01", "123456")

4)如果签名在 Header 中

有些接口是这样的:

X-Sign: xxx
X-Timestamp: xxx
X-Nonce: xxx

那就改成这样:

import hashlib
import time
import uuid
import requests


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


def make_header_sign(path: str, body: str, timestamp: str, nonce: str, secret: str) -> str:
    raw = f"{path}|{body}|{timestamp}|{nonce}|{secret}"
    print("[header raw]", raw)
    return sha256_hex(raw)


def replay():
    url = "https://example.com/api/user/login"
    path = "/api/user/login"
    body = '{"username":"test01","password":"123456"}'
    timestamp = str(int(time.time()))
    nonce = uuid.uuid4().hex[:8]

    sign = make_header_sign(path, body, timestamp, nonce, "A1B2C3D4")

    headers = {
        "Content-Type": "application/json",
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Sign": sign,
    }

    r = requests.post(url, data=body, headers=headers, timeout=10)
    print(r.status_code, r.text)


if __name__ == "__main__":
    replay()

逐步验证清单

不要一口气写完重放脚本再看结果,推荐按下面顺序逐项验证:

  1. 接口路径一致
  2. 请求方法一致:GET/POST/PUT
  3. Content-Type 一致
  4. Body 序列化形式一致
    • JSON
    • form-urlencoded
    • multipart
  5. 时间戳格式一致
    • 秒级还是毫秒级
  6. nonce 长度和字符集一致
  7. 参数排序一致
  8. 空值过滤规则一致
  9. 大小写一致
    • MD5 大写还是小写
  10. Header 一致
    • token
    • user-agent
    • app-version
  11. Cookie / Session 一致
  12. 是否存在设备绑定字段
    • androidId
    • imei
    • oaid

这份清单真的很重要。我自己排查签名失败时,很多次不是算法错了,而是序列化细节不一致


常见坑与排查

这一节我尽量讲“实战里真会撞上的坑”。

1. Hook 了方法,但日志完全没输出

可能原因:

  • 类名写错
  • 方法重载没选对
  • App 还没加载到这个类
  • 实际调用的是别的实现类

排查建议:

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

看一下所有重载,再逐个试。


2. Hook 后 App 崩溃

常见原因:

  • 在 implementation 里递归调用自己
  • 返回值类型不匹配
  • 打印了过大的对象导致性能问题

错误写法:

this.sign(params); // 如果处理不当可能递归

更稳妥的做法是确保调用的是原方法当前重载,并且别改返回类型。


3. Python 重放签名正确,但服务端还是拒绝

这种情况特别常见。优先检查:

  • 是否缺少登录前置接口返回的 token
  • 是否需要 Cookie
  • 是否校验设备指纹
  • 是否服务端限制时间窗口
  • 是否有 TLS/证书相关二次校验

可以从抓包和 Frida 双向确认:

  • 抓包看“线上实际发了什么”
  • Frida 看“代码里到底怎么构造的”

4. 在 JADX 里看到了 native,Java 层没有算法

这说明签名可能在 so 里。

例如:

public native String genSign(String content);

这时做法有两条:

  1. 先 Hook Java 调用边界
    • genSign 的入参和返回值
  2. 再决定是否深入 so
    • 用 Frida Hook JNI 导出
    • 或用 IDA / Ghidra 分析

很多时候你根本不需要马上啃 so,只要先拿到 content -> sign 的映射,就已经能做重放了。


5. 请求体签名和抓包内容不一致

原因通常是:

  • 签名时用的是未压缩正文
  • 发送时经过 Gzip
  • JSON 字段顺序不同
  • 某些默认字段是运行时自动补的

这个坑我踩过不止一次。最有效的办法是同时 Hook:

  • 签名前字符串
  • 最终发送体

做一个对照。


排查决策图

当重放失败时,可以按这张图快速定位。

flowchart TD
    A[请求重放失败] --> B{HTTP 状态码}
    B -- 401/403 --> C[优先查 sign token timestamp]
    B -- 400 --> D[优先查 body 格式与字段缺失]
    B -- 500 --> E[可能触发服务端异常或参数边界]
    C --> F{签名是否与 App 一致}
    F -- 否 --> G[核对排序/编码/大小写/盐值]
    F -- 是 --> H[检查 Header Cookie 设备指纹]
    D --> I[核对 JSON 与 form 序列化]
    H --> J[检查前置接口返回值是否缺失]

安全/性能最佳实践

这部分虽然经常被忽略,但很重要。

1. 不要无差别全局 Hook

很多新手喜欢一上来 Hook 所有字符串相关类、所有网络类,结果:

  • 日志刷爆
  • App 卡顿
  • 关键日志被淹没

更好的方式是:

  • 先用 JADX 锁定范围
  • 只 Hook 1~3 个关键点
  • 打印最小必要信息

2. 日志里避免泄露敏感数据

即使是测试环境,也建议:

  • 对密码打码
  • 对 token 截断显示
  • 不把完整密钥写进公开笔记

例如:

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

3. 重放脚本要控制频率

登录接口通常有:

  • 风控
  • 限流
  • 验证码触发
  • IP 频率限制

所以测试时:

  • 设置合理间隔
  • 使用测试账号
  • 不要高并发压登录口

4. 对 native 分析要设边界

如果目标只是“验证签名与重放”,优先级应该是:

  1. Hook 输入输出
  2. 复刻算法
  3. 必要时再进 so

不要一开始就掉进底层细节黑洞。中级读者最容易在这里耗掉大量时间。

5. 保持分析结果可复现

建议把结果沉淀成三份材料:

  • Hook 脚本
  • 重放脚本
  • 参数说明文档

这样过一周回来,你还能快速恢复上下文。


一个更稳的实战思路

如果你面对的是混淆严重、链路复杂的 App,我建议用这个“分层法”:

第一层:抓包确认现象

明确有哪些字段变化,哪些字段固定。

第二层:JADX 锁定模块

找登录接口、模型类、拦截器、工具类。

第三层:Frida 找真值

抓到:

  • 明文密码是否被处理
  • 签名前原串
  • sign 返回值
  • header 注入内容

第四层:Python 复刻

先追求一次成功,不要先追求“写得优雅”。

第五层:做差异比对

把 App 发出的请求与脚本请求逐项对齐。

这个顺序看起来朴素,但真的省时间。


总结

这篇文章的核心不是“某个固定签名算法”,而是一套可复用的方法:

  1. 从抓包入手,明确目标接口
  2. 用 JADX 先缩小代码范围
  3. 用 Frida 在关键点拿到真实参数
  4. 优先还原签名前原始字符串
  5. 再用 Python 重放并逐项比对

如果你只记住一句话,我建议是:

逆向登录签名时,先找“真实入参”和“最终输出”,不要一开始就沉迷猜算法。

最后给几个可执行建议:

  • 初次分析时,优先 Hook 登录请求对象和签名函数
  • 重放失败时,先查序列化、排序、大小写,再查算法
  • 遇到 native,不一定马上下钻,先在 Java 边界拿输入输出
  • 把每一步结果都记录下来,避免重复劳动

只要你把“静态定位 + 动态验证 + 重放比对”这条链跑顺,后面碰到同类登录签名问题,基本都能有条不紊地拆开。


分享到:

上一篇
《分布式架构中基于消息队列实现最终一致性的实战设计与排障指南》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-362》