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

《安卓逆向实战:基于 Frida 与 JADX 的登录接口参数签名分析与还原》

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

安卓逆向实战:基于 Frida 与 JADX 的登录接口参数签名分析与还原

做安卓逆向时,最常见也最“卡脖子”的场景之一,就是接口参数里多了一个看不懂的 sign / token / nonce / digest。抓包看到了请求,却复现不出来;接口路径、请求体、Header 都抄对了,服务端还是回你一句“签名错误”。

这篇文章我不打算讲得太虚,而是按照一条能落地的路径来做:

  1. 先用 JADX 静态定位登录接口与签名生成点
  2. 再用 Frida 动态确认真实参与签名的参数
  3. 最后把签名逻辑还原成可运行脚本,用于本地复现

这类问题的关键不在“工具会不会点”,而在于:你能不能把静态分析和动态分析串成闭环。如果这个闭环打通了,登录接口、风控参数、时间戳签名,思路基本都能迁移。

说明:本文内容用于安全研究、授权测试与个人学习,不应用于未授权目标。


背景与问题

我们先明确问题模型。

假设你抓到一个安卓 App 的登录请求,形如:

POST /api/login
Content-Type: application/json

{
  "mobile": "13800138000",
  "password": "123456",
  "ts": "1726580000",
  "nonce": "A1B2C3D4",
  "sign": "8f3d7f..."
}

表面上看,参数很普通。但你自己写 Python 重放时,往往会遇到这些情况:

  • sign 算不出来
  • 本地算出来的 sign 和 App 发出的不一样
  • 看起来是 MD5 / SHA256,结果总差一点
  • Hook 到了某个哈希函数,但发现只是中间步骤
  • 明明看到 password 明文,服务端却要求的是“加密后再签名”

这说明真实逻辑大概率是下面这类之一:

  • 参数排序后拼接再哈希
  • 密码先加密,再和时间戳、设备号一起签名
  • Java 层只做组装,真正签名在 Native 层
  • 同名参数会被二次处理
  • 某些字段来自运行时,例如 UUID、Android ID、Header、Build 信息

所以,问题不是“sign 是什么算法”,而是:

登录请求到底由哪些数据构成、在哪一层完成处理、最终如何生成 sign。


前置知识与环境准备

这篇文章默认你已经会一些基础操作,但我还是把环境列清楚,避免中途卡住。

需要的工具

  • JADX:用于反编译 APK,查看 Java/Kotlin 代码
  • Frida:用于运行时 Hook Java/Native 方法
  • adb:安装 APK、查看日志、转发端口
  • mitmproxy / Charles / Burp:抓登录请求
  • Python 3:还原签名逻辑

建议环境

  • Android 8 ~ 13 真机或模拟器
  • 已安装 frida-server,且版本与本地 Frida 一致
  • APK 已完成基础抓包验证
  • 对 Java 常见加密类有基础认识:
    • MessageDigest
    • Mac
    • Cipher
    • Base64
    • StringBuilder

一条验证命令

先确认 Frida 是否可用:

frida-ps -U

如果能列出设备进程,说明动态分析基础环境没问题。


核心原理

这类登录签名分析,核心是“三层定位法”:

  1. 接口定位:先找到登录请求代码在哪
  2. 签名定位:找到 sign 生成函数在哪
  3. 输入还原:搞清楚 sign 的原始输入是什么

我自己做这类分析时,基本都是围绕下面这条链路:

flowchart TD
    A[抓包获取登录请求] --> B[JADX定位登录接口调用]
    B --> C[搜索 sign/nonce/ts/mobile 等关键词]
    C --> D[追踪参数组装代码]
    D --> E[定位哈希/加密函数]
    E --> F[Frida Hook 关键方法]
    F --> G[确认真实输入与调用顺序]
    G --> H[Python 还原签名]
    H --> I[重放验证]

1. 静态分析负责“缩小范围”

JADX 擅长解决的问题是:

  • 登录 API 在哪个类里
  • 请求体是怎么组装的
  • 哪个方法最后设置了 sign
  • 是否存在工具类统一生成签名

典型搜索关键词:

  • login
  • /api/login
  • sign
  • nonce
  • timestamp
  • md5
  • sha256
  • MessageDigest
  • Cipher
  • Base64
  • OkHttp
  • Retrofit

2. 动态分析负责“确认真值”

静态分析很容易看错,原因包括:

  • 混淆后方法名全是 a() / b() / c()
  • Kotlin 内联让调用链很绕
  • 有些变量运行时才有值
  • 某些函数表面一样,实际输入不同
  • Java 层看到的是“结果”,原始数据却在 Native 层

所以必须用 Frida 去验证:

  • 方法是否真的被调用
  • 调用顺序是什么
  • 入参与返回值分别是什么
  • 中间字符串拼接结果是什么

3. 最终目标不是“看懂”,而是“复现”

真正的闭环是:

  • App 发出的请求里 sign = X
  • 你本地脚本算出来也 sign = X

如果还原结果和抓包一致,才说明分析完成。


典型调用链拆解

很多 App 的登录签名都大致长这样:

sequenceDiagram
    participant U as 用户点击登录
    participant L as LoginActivity
    participant R as Repository/Service
    participant S as SignUtil
    participant H as Hash函数
    participant API as 服务端接口

    U->>L: 输入手机号/密码
    L->>R: 发起登录请求
    R->>S: 组装 mobile/password/ts/nonce/deviceId
    S->>H: 拼接字符串并摘要
    H-->>S: 返回 sign
    S-->>R: 请求参数附带 sign
    R->>API: POST /api/login
    API-->>R: 登录响应

常见签名套路有三种:

方案 A:排序拼接

mobile=13800138000&nonce=A1B2C3D4&password=123456&ts=1726580000&key=secret

然后:

MD5(上面字符串)

方案 B:密码先处理

pwd1 = MD5(password)
sign = SHA256(mobile + pwd1 + ts + nonce + secret)

方案 C:JSON 原文参与签名

body = {"mobile":"13800138000","password":"...","ts":"..."}
sign = HMACSHA256(body + nonce, secret)

所以你不能只盯着最终哈希函数,真正需要找的是:哈希前的原始输入字符串。


用 JADX 做静态定位

下面按一条常见路径来。

第一步:定位登录接口

在 JADX 中全局搜索:

  • /login
  • login
  • sign
  • password
  • mobile

通常你会找到类似代码:

public Observable<LoginResp> login(String mobile, String password) {
    long ts = System.currentTimeMillis() / 1000;
    String nonce = DeviceUtil.randomNonce();
    String pwd = EncryptUtil.md5(password);
    String sign = SignUtil.signLogin(mobile, pwd, ts + "", nonce);
    return api.login(mobile, pwd, ts + "", nonce, sign);
}

这时先别急着下结论。你要继续点进去看:

  • EncryptUtil.md5
  • SignUtil.signLogin
  • api.login

第二步:确认请求参数映射

有些项目用了 Retrofit:

@FormUrlEncoded
@POST("/api/login")
Observable<LoginResp> login(
    @Field("mobile") String mobile,
    @Field("password") String password,
    @Field("ts") String ts,
    @Field("nonce") String nonce,
    @Field("sign") String sign
);

这一步的价值在于确认:

  • 最终到底传了哪些字段
  • 字段名是不是抓包里看到的那个
  • 是否存在 Header 签名,而不是 Body 签名

第三步:追踪 sign 函数

你可能会看到类似逻辑:

public static String signLogin(String mobile, String pwd, String ts, String nonce) {
    String raw = "mobile=" + mobile + "&password=" + pwd + "&ts=" + ts + "&nonce=" + nonce + "&key=app_secret";
    return md5(raw).toLowerCase();
}

如果你运气好,到这里已经结束了。

但现实里更常见的是混淆代码,例如:

public static String a(String p0, String p1, String p2, String p3) {
    TreeMap map = new TreeMap();
    map.put("mobile", p0);
    map.put("password", p1);
    map.put("ts", p2);
    map.put("nonce", p3);
    return b(map);
}

再进去看:

public static String b(Map<String, String> map) {
    StringBuilder sb = new StringBuilder();
    for (Map.Entry<String, String> e : map.entrySet()) {
        sb.append(e.getKey()).append('=').append(e.getValue()).append('&');
    }
    sb.append(c());
    return d(sb.toString());
}

此时你已经很接近答案,但还差几个关键点:

  • c() 返回的是什么,是固定密钥还是动态设备值
  • d() 是 MD5、SHA1、SHA256 还是 HMAC
  • password 在传入前是否已经被加密

用 Frida 做动态确认

静态代码看到的“像答案”,未必就是答案。下面我们直接 Hook。

目标

我们优先确认这几件事:

  1. 登录函数收到的 password 是明文还是密文
  2. signLogin 的入参到底是什么
  3. md5/sha256/hmac 之前的原始字符串是什么
  4. 返回的 sign 与抓包是否一致

实战代码(可运行)

下面给一套可以直接改造使用的 Frida 脚本。假设目标包名为:

com.example.app

1. Hook 登录与签名函数

Java.perform(function () {
    function showStack(tag) {
        var Exception = Java.use("java.lang.Exception");
        var Log = Java.use("android.util.Log");
        console.log("[" + tag + "] stack:\n" + Log.getStackTraceString(Exception.$new()));
    }

    try {
        var SignUtil = Java.use("com.example.app.utils.SignUtil");

        SignUtil.signLogin.overload(
            "java.lang.String",
            "java.lang.String",
            "java.lang.String",
            "java.lang.String"
        ).implementation = function (mobile, pwd, ts, nonce) {
            console.log("=== SignUtil.signLogin called ===");
            console.log("mobile =", mobile);
            console.log("pwd    =", pwd);
            console.log("ts     =", ts);
            console.log("nonce  =", nonce);

            var ret = this.signLogin(mobile, pwd, ts, nonce);
            console.log("sign   =", ret);
            return ret;
        };
    } catch (e) {
        console.log("[!] Hook SignUtil.signLogin failed:", e);
    }

    try {
        var EncryptUtil = Java.use("com.example.app.utils.EncryptUtil");
        EncryptUtil.md5.overload("java.lang.String").implementation = function (s) {
            console.log("=== EncryptUtil.md5 called ===");
            console.log("input  =", s);
            var ret = this.md5(s);
            console.log("output =", ret);
            return ret;
        };
    } catch (e) {
        console.log("[!] Hook EncryptUtil.md5 failed:", e);
    }
});

运行方式:

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

如果 App 有反调试或启动太快,也可以先启动 App,再附加:

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

2. 通杀式 Hook MessageDigest

如果你还没找到具体工具类,直接 Hook Java 标准摘要类,效率很高。

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) {
        var instance = this.getInstance(algo);
        console.log("[MessageDigest.getInstance] algo =", algo);
        return instance;
    };

    MessageDigest.update.overload("[B").implementation = function (bytes) {
        try {
            var s = StringCls.$new(bytes);
            console.log("[MessageDigest.update] data =", s);
        } catch (e) {
            console.log("[MessageDigest.update] data = <binary>");
        }
        return this.update(bytes);
    };

    MessageDigest.digest.overload().implementation = function () {
        var ret = this.digest();
        console.log("[MessageDigest.digest] called, ret length =", ret.length);
        return ret;
    };
});

这个 Hook 的好处是:
即便项目里方法名都混淆了,只要它最终走到 Java 层摘要实现,你都能看到。

不过有个现实问题:日志会很多。所以更稳妥的做法是结合调用栈筛选特定路径。


3. Hook HashMap/TreeMap 参数拼接过程

很多签名逻辑是“先放进 Map,再排序拼接”。这时可以 Hook put 来观察字段进入顺序。

Java.perform(function () {
    var TreeMap = Java.use("java.util.TreeMap");

    TreeMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
        console.log("[TreeMap.put]", k, "=", v);
        return this.put(k, v);
    };
});

如果日志过多,可以只在登录按钮点击后再触发,或者加包名 / 调用栈条件过滤。


4. 更实用:Hook OkHttp 请求体

如果你已经知道是 OkHttp 发请求,直接在发送前截获请求内容,能快速验证签名结果。

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

        RequestBuilder.build.implementation = function () {
            var request = this.build();
            try {
                console.log("=== OkHttp Request ===");
                console.log("URL:", request.url().toString());
                console.log("Method:", request.method().toString());

                var headers = request.headers();
                console.log("Headers:\n" + headers.toString());
            } catch (e) {
                console.log("[!] print request failed:", e);
            }
            return request;
        };
    } catch (e) {
        console.log("[!] Hook okhttp3.Request$Builder.build failed:", e);
    }
});

注意:请求体打印在 OkHttp 里要复杂一些,通常需要进一步 Hook RequestBody.writeTo,不同版本实现有差异。实战中如果只是还原 sign,很多时候把签名函数本身 Hook 出来就够了。


Python 还原签名逻辑

假设你最终通过静态 + 动态确认,签名规则是:

  1. password_md5 = md5(password)
  2. 构造原始串: mobile={mobile}&password={password_md5}&ts={ts}&nonce={nonce}&key=app_secret
  3. 最终: sign = md5(raw).lower()

那么还原脚本如下:

import hashlib


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


def sign_login(mobile: str, password: str, ts: str, nonce: str) -> dict:
    password_md5 = md5_hex(password)
    raw = f"mobile={mobile}&password={password_md5}&ts={ts}&nonce={nonce}&key=app_secret"
    sign = md5_hex(raw).lower()
    return {
        "mobile": mobile,
        "password": password_md5,
        "ts": ts,
        "nonce": nonce,
        "sign": sign,
        "raw": raw,
    }


if __name__ == "__main__":
    result = sign_login(
        mobile="13800138000",
        password="123456",
        ts="1726580000",
        nonce="A1B2C3D4"
    )
    for k, v in result.items():
        print(f"{k}: {v}")

如果目标是直接重放登录接口,你可以继续加上请求代码:

import hashlib
import requests


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


def build_login_payload(mobile: str, password: str, ts: str, nonce: str) -> dict:
    password_md5 = md5_hex(password)
    raw = f"mobile={mobile}&password={password_md5}&ts={ts}&nonce={nonce}&key=app_secret"
    sign = md5_hex(raw).lower()
    return {
        "mobile": mobile,
        "password": password_md5,
        "ts": ts,
        "nonce": nonce,
        "sign": sign,
    }


if __name__ == "__main__":
    payload = build_login_payload(
        mobile="13800138000",
        password="123456",
        ts="1726580000",
        nonce="A1B2C3D4",
    )

    resp = requests.post(
        "https://example.com/api/login",
        json=payload,
        timeout=10
    )
    print(resp.status_code)
    print(resp.text)

逐步验证清单

这里给你一个我自己很常用的验证顺序。别一上来就闷头写还原脚本,先一层层对齐。

flowchart LR
    A[抓包得到请求参数] --> B[确认 password 是否已变形]
    B --> C[Hook signLogin 入参]
    C --> D[Hook 摘要前原始字符串]
    D --> E[对齐 App 端 sign]
    E --> F[Python 本地复现]
    F --> G[重放接口验证]

清单项 1:对齐 password

先确认请求里的 password

  • 是明文
  • 是 MD5
  • 是 AES 后再 Base64
  • 还是 RSA 加密结果

如果这一步错了,后面 sign 必然全错。

清单项 2:对齐时间戳单位

很多人会忽略这个细节:

  • 秒级:1726580000
  • 毫秒级:1726580000123

只差 3 位,结果完全不同。

清单项 3:对齐大小写

常见差异:

  • MD5(...).lower()
  • MD5(...).upper()

以及十六进制字符串输出时是否补零。

清单项 4:对齐参数顺序

即使字段完全一样,顺序不同也会导致结果不同:

a=1&b=2&c=3

b=2&a=1&c=3

签名结果必然不同。

清单项 5:对齐空值与 null

有些代码会:

  • 跳过空字符串
  • 保留空字符串
  • null 转成 "null"

这一步特别容易被忽略。


常见坑与排查

这部分很重要。我当时第一次做登录签名还原时,卡得最久的不是算法,而是这些“低级但隐蔽”的坑。

坑 1:Hook 了方法,但没有日志

常见原因:

  • Hook 时机太晚,方法在启动时已调用完
  • 类名写错,混淆后真实路径不同
  • 方法重载没选对
  • App 多进程,Hook 到了错误进程

排查建议:

frida-ps -Uai

看清楚目标进程名,再附加正确进程。
如果是启动即调用的逻辑,优先用:

frida -U -f 包名 -l hook.js

坑 2:JADX 看到一个方法,运行时根本不走

这通常是:

  • 同名备份逻辑
  • debug 分支
  • 多渠道差异
  • Kotlin 生成了桥接方法
  • 真正逻辑走的是 Native

建议做法:

  • 直接 Hook 你怀疑的方法
  • 同时 Hook MessageDigest / Mac
  • 对比调用栈确认真实链路

坑 3:签名函数没问题,但重放仍失败

这时候别只盯着 sign,还要看:

  • Header 是否包含额外鉴权字段
  • Cookie / Session 是否必要
  • User-Agent / deviceId 是否参与风控
  • 请求体 JSON 字段顺序是否固定
  • 是否有 TLS Pinning 导致抓包内容不完整

坑 4:Frida Hook this.xxx() 导致递归

比如下面这种写法容易踩坑:

someMethod.implementation = function (a) {
    return this.someMethod(a);
};

如果 Frida 处理不当,可能递归进自己。稳妥些可以保留原方法引用,或者使用明确的 overload 调用。

坑 5:字符串打印乱码

原因一般是:

  • 实际是二进制字节
  • 编码不是 UTF-8
  • 数据经过 Base64/压缩

排查思路:

  • 先打印 hex
  • 再尝试 Base64
  • 最后看是否是 gzip / protobuf / 自定义序列化

坑 6:Native 层签名

如果 Java 层始终只看到:

return NativeBridge.sign(xxx);

那就说明重点要转到 so 层。此时你可以:

  • 先 Hook System.loadLibrary
  • 确认加载了哪个 so
  • 用 Frida 拦截 JNI 导出函数或 RegisterNatives
  • 配合 IDA/Ghidra 看 Native 实现

安全/性能最佳实践

逆向分析本身也有“工程质量”问题。脚本写得太粗暴,往往自己先把 App 打挂了。

1. 只 Hook 关键路径,别全量扫

例如直接全局 Hook StringBuilder.append,日志会爆炸,而且性能抖动明显。
更稳的方法是:

  • 先用 JADX 缩小到可疑类
  • 再定向 Hook
  • 最后才做通杀式兜底

2. 日志要结构化

建议输出固定格式,方便你后续对比:

console.log(JSON.stringify({
    tag: "signLogin",
    mobile: mobile,
    pwd: pwd,
    ts: ts,
    nonce: nonce,
    sign: ret
}));

这样后面直接复制到 Python 里做校验,效率很高。

3. 优先记录“原始输入串”

比起只看最终 sign,更应该拿到:

  • 哈希前字符串
  • 参与签名的参数全集
  • 排序结果
  • 密钥来源

因为一旦原始串明确,算法还原通常只是几行代码。

4. 留意敏感数据边界

做授权测试时,建议:

  • 使用测试账号
  • 脱敏手机号、设备号
  • 不在日志中长期保存明文密码
  • 不把生产密钥、私有接口随意传播

5. 对 Hook 做开关控制

例如只在 URL 包含 /login 时打印,避免海量噪声:

if (request.url().toString().indexOf("/login") !== -1) {
    console.log("login request hit");
}

6. 本地还原脚本要可复核

建议把签名逻辑拆成小函数:

  • encrypt_password()
  • build_raw_string()
  • calc_sign()

这样一旦哪步不一致,很容易定位。


一套更稳的分析策略

如果你面对的是混淆严重、逻辑复杂的 App,我更推荐下面这套顺序,而不是“先乱 Hook 一通”。

第 1 阶段:抓包定目标

先拿到一次成功登录请求,确定:

  • URL
  • Method
  • Header
  • Body
  • 响应码

第 2 阶段:JADX 定位接口调用点

从接口路径倒推调用类和请求参数对象。

第 3 阶段:围绕 sign 做引用追踪

找到:

  • sign 字段赋值点
  • 相关工具类
  • 哈希与加密调用点

第 4 阶段:Frida 验证真实输入

至少确认三样:

  • 签名前原始串
  • password 处理结果
  • sign 最终值

第 5 阶段:Python 还原

先离线计算,再联网重放。
不要一边猜逻辑一边重放接口,这样很难定位到底是哪一步错了。


一个最小闭环示例

假设你通过分析确认了以下事实:

  • 明文密码:123456
  • 先 MD5:e10adc3949ba59abbe56e057f20f883e
  • 原始串: mobile=13800138000&password=e10adc3949ba59abbe56e057f20f883e&ts=1726580000&nonce=A1B2C3D4&key=app_secret
  • 最终 sign: md5(raw).lower()

那么你应做到以下闭环:

  1. Frida 打印的 pwd 与本地一致
  2. Frida 打印的 raw 与本地一致
  3. Frida 打印的 sign 与本地一致
  4. 抓包里的 sign 与本地一致
  5. 使用本地脚本重放登录成功

当这 5 步都成立时,说明你不是“猜出来了”,而是真正还原了签名逻辑


总结

登录接口参数签名还原,最容易误入的误区是:
只盯算法,不盯输入。

真正高效的路径应该是:

  • JADX 找到接口调用、参数组装和可疑签名函数
  • Frida 验证运行时真实入参、原始拼接串和最终 sign
  • Python 做本地复现,最后以抓包结果和接口重放做闭环

如果你是中级读者,我建议你把这篇文章里的方法记成一句话:

静态分析负责找范围,动态分析负责定真值,还原脚本负责验结果。

最后给几个可执行建议:

  • 先确认 password 是否被二次处理,再谈 sign
  • 优先拿“哈希前原始串”,不要只拿最终摘要值
  • 时间戳单位、参数顺序、大小写,是最常见的三个误差源
  • Java 层看不到时,尽快判断是否转到 Native 层
  • 分析过程中每一步都做“对齐验证”,别一次猜完整链路

只要你把这套闭环跑熟了,后面再遇到 token、x-sign、anti-bot、风控校验,处理起来会顺很多。


分享到:

上一篇
《Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证授权实战》
下一篇
《从抓包到还原签名链路:一次典型 Web 逆向中前端加密参数定位与复现实战》