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

《安卓逆向实战:基于 Frida 与 JADX 的 App 登录签名算法定位与复现》

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

安卓逆向实战:基于 Frida 与 JADX 的 App 登录签名算法定位与复现

很多同学学安卓逆向时,都会卡在一个非常具体但又高频的问题上:登录接口明明抓到了,参数也看到了,可一发请求就提示签名错误

这篇文章我就用一个比较贴近真实工作的思路,带你从 JADX 静态分析Frida 动态调试 两条线并行推进,最后把 App 的登录签名算法定位出来,并在本地脚本里完成复现。

说明:本文用于安全研究、接口联调、自有 App 调试与学习,请勿用于未授权目标。


背景与问题

在移动端接口里,登录通常不是简单地把用户名密码发出去。常见还会带上:

  • timestamp
  • nonce
  • deviceId
  • sign
  • token
  • 某种加密后的 password

其中最麻烦的往往就是 sign。它可能是:

  • 明文参数排序后拼接,再做 MD5/SHA
  • 参数 + 固定盐值 + 时间戳混合后做摘要
  • Java 层组装后交给 native 层处理
  • 甚至先 AES,再 Base64,再摘要

我们的问题通常表现为:

  1. 能抓包看到请求结构;
  2. 也能在 JADX 里找到一些“像签名”的代码;
  3. 但实际复现时,签名总是不一致。

这时只靠静态分析很容易误判,动态观察真实入参与返回值 才是关键。也因此,JADX 和 Frida 的组合特别适合这个场景。


前置知识

如果你已经有一些基础,可以快速跳过这一节。否则建议先确认你至少了解:

  • APK 基本结构:classes.dexAndroidManifest.xml
  • Java/Kotlin 基础调用关系
  • HTTP 抓包基础
  • Frida 常见 Hook 方式
  • Python 或 JS 的基础脚本编写

环境准备

本文默认环境如下:

  • Android 7~13 真机或模拟器
  • 已安装 frida-server,且版本与本机 Frida 一致
  • 本机工具:
    • jadx-gui
    • frida-tools
    • adb
    • python3

安装示例:

pip install frida-tools
adb devices
frida-ps -U

如果 frida-ps -U 能列出进程,说明设备连通基本没问题。


整体分析路线

我建议不要一上来就盯着某个可疑函数狂 Hook,而是走一条更稳的路线:

  1. 先抓包确认登录请求长什么样
  2. 用 JADX 找“签名相关关键词”
  3. 缩小到核心调用链
  4. 用 Frida Hook 入参、返回值、调用堆栈
  5. 确认排序规则、拼接规则、盐值来源
  6. 在本地 Python 中复现
  7. 用真实请求比对验证

这个顺序很重要。很多人失败不是不会 Hook,而是没有建立“请求字段 → 代码位置 → 算法细节”的映射关系


flowchart TD
    A[抓包观察登录请求] --> B[识别 sign/timestamp/nonce 等字段]
    B --> C[JADX 全局搜索 sign md5 sha encrypt]
    C --> D[定位网络层与参数组装代码]
    D --> E[Frida Hook 可疑方法]
    E --> F[记录入参/返回值/调用栈]
    F --> G[提炼排序 拼接 盐值 时间戳规则]
    G --> H[Python 复现签名]
    H --> I[与真实请求对比验证]

核心原理

1. 静态分析解决“在哪里”

JADX 的价值不只是“看代码”,更重要的是:

  • 找关键字符串
  • 看调用关系
  • 判断签名发生在哪一层

常见搜索关键词:

  • sign
  • signature
  • md5
  • sha1
  • sha256
  • digest
  • encrypt
  • timestamp
  • nonce
  • token
  • okhttp
  • Interceptor

很多 App 会在以下位置处理签名:

  • Retrofit 请求前的 Interceptor
  • 某个工具类,比如 SignUtils
  • 登录仓库类 / Repository
  • 通用请求参数构建器
  • JNI 桥接类

2. 动态调试解决“实际怎么跑”

静态代码有几个常见陷阱:

  • 反编译后变量名失真
  • Kotlin 内联/匿名类不直观
  • 重载函数太多,容易 Hook 错
  • 实际执行路径带有条件分支
  • 某些值是运行时才注入的

Frida 的优势就是:在运行时看真实参数、真实返回值、真实类加载情况

3. 签名算法通常关注三件事

定位算法时,我一般先确认下面三件事:

参数来源

例如:

  • username
  • password 是不是先做了 MD5
  • timestamp 是不是服务端下发或本地生成
  • deviceId 是否参与签名

拼接规则

比如:

  • 按 key 字典序排序
  • 排除空值和 sign 自身
  • key=value&key2=value2
  • 末尾加固定盐 app_secret
  • 再整体做 MD5

编码细节

这是最容易翻车的点:

  • UTF-8 还是默认编码
  • 十六进制大小写
  • Base64 是否带换行
  • URL 编码是在签名前还是签名后
  • 时间戳单位是秒还是毫秒

sequenceDiagram
    participant U as 用户点击登录
    participant A as LoginActivity/ViewModel
    participant R as Repository
    participant S as SignUtil
    participant N as OkHttp/Retrofit
    participant API as 服务端

    U->>A: 输入用户名密码
    A->>R: login(username, password)
    R->>S: buildSign(params)
    S-->>R: sign
    R->>N: 组装请求体
    N->>API: POST /login
    API-->>N: 登录结果
    N-->>R: response
    R-->>A: success/fail

背景样例:一个典型的登录签名

为了让过程完整、代码可运行,下面我用一个非常常见的例子来演示。假设抓包看到请求如下:

POST /api/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=test
&password=e10adc3949ba59abbe56e057f20f883e
&timestamp=1700000000
&nonce=8f34ab12
&deviceId=android_123456
&sign=5f4dcc3b5aa765d61d8327deb882cf99

通过观察,我们怀疑签名规则大概是:

  1. 去掉 sign
  2. 参数按 key 升序排序
  3. 拼成 deviceId=...&nonce=...&password=...&timestamp=...&username=...
  4. 末尾追加固定盐,比如 &key=AppSecret123
  5. 进行 MD5,小写输出

接下来我们就一步步证实它。


用 JADX 定位可疑代码

第一步:先从网络层入手

打开 APK 到 jadx-gui 后,我通常先搜:

  • /login
  • login
  • sign
  • Interceptor

如果项目用了 OkHttp,常见会在拦截器里统一加参数。比如你可能看到类似代码:

public class SignInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        // 读取原始参数
        // 生成 sign
        // 构造新请求
        return chain.proceed(newRequest);
    }
}

如果不是统一拦截器,也可能在登录方法里直接调用:

Map<String, String> params = new HashMap<>();
params.put("username", username);
params.put("password", md5(password));
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("nonce", UUID.randomUUID().toString().replace("-", "").substring(0, 8));
params.put("deviceId", DeviceUtil.getId(context));
params.put("sign", SignUtil.sign(params));

第二步:看调用链,不只看单个函数

例如你搜到:

public static String sign(Map<String, String> map) {
    TreeMap treeMap = new TreeMap(map);
    StringBuilder sb = new StringBuilder();
    for (Map.Entry entry : treeMap.entrySet()) {
        if (!TextUtils.isEmpty((CharSequence) entry.getValue()) && !"sign".equals(entry.getKey())) {
            sb.append((String) entry.getKey());
            sb.append("=");
            sb.append((String) entry.getValue());
            sb.append("&");
        }
    }
    sb.append("key=");
    sb.append(BuildConfig.API_SECRET);
    return Md5Util.md5(sb.toString()).toLowerCase();
}

看到这里先别急着说“结束了”。还要继续确认:

  • password 是否提前 MD5 过
  • BuildConfig.API_SECRET 是否是真值,还是会被运行时替换
  • Md5Util.md5 是否是标准 MD5
  • TextUtils.isEmpty 是否会导致空参数被剔除
  • Map 里是否有额外参数你抓包时没注意到

这些都要靠动态验证。


用 Frida 动态确认真实执行逻辑

场景目标

我们想知道:

  • SignUtil.sign(Map) 传入的真实参数是什么
  • 返回的 sign 是什么
  • password 在传入前有没有处理过
  • 是否存在多次签名、二次加工

Frida Hook 脚本

下面的脚本演示如何 Hook 一个典型的 SignUtil.sign(Map) 方法,并把 Map 展开打印。

把类名改成你实际 App 里的类名。

Java.perform(function () {
    var SignUtil = Java.use("com.demo.app.utils.SignUtil");
    var HashMap = Java.use("java.util.HashMap");
    var Set = Java.use("java.util.Set");
    var Iterator = Java.use("java.util.Iterator");
    var Thread = Java.use("java.lang.Thread");

    function printMap(map) {
        var entrySet = map.entrySet();
        var iterator = entrySet.iterator();
        var result = [];
        while (iterator.hasNext()) {
            var entry = iterator.next();
            result.push(entry.getKey().toString() + "=" + entry.getValue());
        }
        return result.join("&");
    }

    SignUtil.sign.overload("java.util.Map").implementation = function (map) {
        console.log("========== SignUtil.sign called ==========");
        console.log("[*] map = " + printMap(map));
        console.log("[*] thread = " + Thread.currentThread().getName());

        var ret = this.sign(map);

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

运行方式:

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

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

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

如果怀疑密码也做了预处理

可以顺手 Hook MD5 工具类:

Java.perform(function () {
    var Md5Util = Java.use("com.demo.app.utils.Md5Util");

    Md5Util.md5.overload("java.lang.String").implementation = function (s) {
        console.log("[MD5 input] " + s);
        var ret = this.md5(s);
        console.log("[MD5 ret] " + ret);
        return ret;
    };
});

如果类名不好找

可以先枚举已加载类,按关键词筛选:

Java.perform(function () {
    Java.enumerateLoadedClasses({
        onMatch: function (name) {
            if (name.indexOf("sign") !== -1 || name.indexOf("Sign") !== -1) {
                console.log(name);
            }
        },
        onComplete: function () {
            console.log("done");
        }
    });
});

逐步验证清单

我建议你按下面的顺序验证,不容易乱:

  • 登录接口路径是否确认
  • sign 字段是否每次变化
  • timestamp 是秒还是毫秒
  • nonce 长度和生成规则
  • password 是否明文、MD5、SHA、AES
  • sign 是否由统一拦截器生成
  • 参数排序是否按字典序
  • 空字符串参数是否参与签名
  • 固定盐值来自常量、配置还是 native
  • 最终摘要输出是否小写

这是我自己实战里很常用的一套 checklist,能减少很多重复试错。


实战代码:本地复现登录签名

下面给出一份可运行的 Python 代码,用于复现本文示例中的签名算法。

Python 复现版

import hashlib
from collections import OrderedDict

API_SECRET = "AppSecret123"

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

def build_sign(params: dict, secret: str) -> str:
    filtered = {}
    for k, v in params.items():
        if k == "sign":
            continue
        if v is None or str(v) == "":
            continue
        filtered[k] = str(v)

    sorted_items = sorted(filtered.items(), key=lambda x: x[0])
    sign_str = "&".join([f"{k}={v}" for k, v in sorted_items])
    sign_str = f"{sign_str}&key={secret}"
    print("[sign_str]", sign_str)
    return md5_hex(sign_str).lower()

def build_login_payload(username: str, password_plain: str):
    params = {
        "username": username,
        "password": md5_hex(password_plain),
        "timestamp": "1700000000",
        "nonce": "8f34ab12",
        "deviceId": "android_123456",
    }
    params["sign"] = build_sign(params, API_SECRET)
    return params

if __name__ == "__main__":
    payload = build_login_payload("test", "123456")
    print("[payload]")
    for k, v in payload.items():
        print(f"{k}={v}")

输出示意

[sign_str] deviceId=android_123456&nonce=8f34ab12&password=e10adc3949ba59abbe56e057f20f883e&timestamp=1700000000&username=test&key=AppSecret123
[payload]
username=test
password=e10adc3949ba59abbe56e057f20f883e
timestamp=1700000000
nonce=8f34ab12
deviceId=android_123456
sign=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

发起请求验证

你可以继续用 requests 验证:

import requests
import hashlib

API_SECRET = "AppSecret123"

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

def build_sign(params: dict, secret: str) -> str:
    filtered = {}
    for k, v in params.items():
        if k == "sign":
            continue
        if v is None or str(v) == "":
            continue
        filtered[k] = str(v)
    sign_str = "&".join([f"{k}={v}" for k, v in sorted(filtered.items())])
    sign_str += f"&key={secret}"
    return md5_hex(sign_str).lower()

url = "https://example.com/api/login"

data = {
    "username": "test",
    "password": md5_hex("123456"),
    "timestamp": "1700000000",
    "nonce": "8f34ab12",
    "deviceId": "android_123456",
}
data["sign"] = build_sign(data, API_SECRET)

resp = requests.post(url, data=data, timeout=10)
print(resp.status_code)
print(resp.text)

结果比对:如何确认你真的复现成功

不要只看“请求能不能通”,还要做精确比对。

建议比对三层

第一层:签名前字符串

这是最关键的一层。比如:

deviceId=android_123456&nonce=8f34ab12&password=e10adc3949ba59abbe56e057f20f883e&timestamp=1700000000&username=test&key=AppSecret123

必须和 App 内部一模一样。

第二层:摘要结果

比如:

5f4dcc3b5aa765d61d8327deb882cf99

如果这里不同,大概率是编码、排序、拼接符号出了问题。

第三层:最终请求体

有时你签名算对了,但发送前又被网络层改了,比如:

  • URL encode
  • 自动补公共参数
  • Content-Type 不同
  • timestamp 被重新生成

所以,最终发出去的请求体也要和抓包尽量一致。


stateDiagram-v2
    [*] --> 抓包识别字段
    抓包识别字段 --> 静态定位方法
    静态定位方法 --> 动态Hook验证
    动态Hook验证 --> 提取签名规则
    提取签名规则 --> 本地复现
    本地复现 --> 对比请求
    对比请求 --> 成功复现
    对比请求 --> 回退排查
    回退排查 --> 动态Hook验证

常见坑与排查

这一节很重要。我自己做这类分析时,80% 时间其实花在排坑上。

1. Hook 到了函数,但没输出

可能原因:

  • 类还没加载
  • Hook 了错误的重载
  • App 有壳,代码还没解出
  • 目标方法在子进程执行

排查建议:

Java.perform(function () {
    console.log("Java ready");
});

如果 Java 环境正常,再用 overloads 枚举方法签名:

Java.perform(function () {
    var SignUtil = Java.use("com.demo.app.utils.SignUtil");
    SignUtil.sign.overloads.forEach(function (o) {
        console.log(o);
    });
});

2. Hook 后 App 闪退

常见原因:

  • 打印对象太复杂,触发 toString() 异常
  • 在主线程做了太多日志输出
  • Hook 实现里递归调用自己

比如这段是错的:

SignUtil.sign.overload("java.util.Map").implementation = function (map) {
    return SignUtil.sign(map);
};

会递归。应该写成:

SignUtil.sign.overload("java.util.Map").implementation = function (map) {
    return this.sign(map);
};

3. 签名总差一点

这是最常见的“最烦但最常见”问题。重点检查:

  • 参数是否按 ASCII 排序
  • 是否过滤空值
  • 是否包含公共参数
  • timestamp 是否秒级
  • 十六进制是否小写
  • password 是否先加密
  • 拼接末尾是否多了一个 &

举个例子:

错误串:

a=1&b=2&key=secret&

正确串:

a=1&b=2&key=secret

别小看最后一个 &,它足够让你怀疑人生一小时。

4. 明明看到 Java 代码了,但结果对不上

这说明很可能:

  • 还有 native 参与
  • 常量值在运行时替换
  • App 启用了多渠道配置
  • 线上和测试环境盐值不同

可以继续 Hook JNI 桥接方法,比如:

Java.perform(function () {
    var NativeBridge = Java.use("com.demo.app.security.NativeBridge");
    NativeBridge.getSign.overload("java.lang.String").implementation = function (s) {
        console.log("[NativeBridge input] " + s);
        var ret = this.getSign(s);
        console.log("[NativeBridge ret] " + ret);
        return ret;
    };
});

5. 抓包和 Hook 看到的值不一致

这说明参数可能被多次加工。常见路径:

  1. 业务层组一次
  2. 拦截器再补公共参数
  3. 序列化时再做编码

解决办法是 沿调用链逐层 Hook,不要只盯着一个函数。


安全/性能最佳实践

虽然本文是逆向分析教程,但也顺便说说做研究时的边界和实践。

1. 仅对授权目标操作

这是底线。建议只在这些场景中使用:

  • 自研 App 联调
  • 企业内部安全测试
  • 教学实验环境
  • 已授权的漏洞研究

2. 优先做最小化 Hook

不要上来就全量枚举、全局打印。否则容易:

  • 影响性能
  • 产生大量噪声
  • 触发 App 风控或反调试逻辑

更好的方式是:

  • 先静态定位 1~3 个核心类
  • 再精准 Hook
  • 必要时只打印关键字段

3. 控制日志量

尤其在登录页面,很多方法高频触发。建议:

  • 只在目标线程打印
  • 只打印目标方法入参和返回值
  • 对重复日志做去重

4. 注意敏感数据脱敏

日志里常会有:

  • 用户名
  • 手机号
  • token
  • deviceId
  • password hash

如果要保存分析记录,建议脱敏后再落盘。

5. 复现脚本要明确边界条件

本地复现成功,不代表所有版本都适用。至少要记录:

  • App 版本号
  • 接口环境
  • secret 来源
  • timestamp 规则
  • 是否依赖设备信息

否则过一段时间再看,自己都很难复盘。


一个更稳的分析模板

如果你今后要分析别的 App 登录签名,我建议按下面模板走:

Step 1:抓包确认字段

先明确:

  • 哪些字段固定
  • 哪些字段动态变化
  • 哪些字段疑似签名相关

Step 2:JADX 搜关键词

围绕以下词搜索:

sign / md5 / sha / digest / token / nonce / timestamp / interceptor

Step 3:锁定调用链

重点看:

  • 登录接口调用方
  • 公共请求构造器
  • OkHttp 拦截器
  • 加密工具类

Step 4:Frida 验证

优先 Hook:

  • 登录请求构造方法
  • 签名函数
  • MD5/SHA 工具函数
  • native 桥接类

Step 5:本地复现

先复现“签名前字符串”,再复现“sign”。

Step 6:请求验证

最后用脚本发一遍请求,和真实流量比对。

这个流程看起来朴素,但非常稳,尤其适合中级读者继续往实战推进。


总结

这篇文章的核心并不只是“写一个 Frida 脚本”或者“在 JADX 里找到某个 sign 方法”,而是建立一种 静态 + 动态结合的定位方式

  • JADX 负责告诉你:算法可能在哪、调用链怎么走;
  • Frida 负责告诉你:真实参数是什么、实际执行了哪条路径;
  • Python 复现 负责最终闭环:证明你真的掌握了算法。

如果你只记住三条建议,我希望是这三条:

  1. 先抓包,再看代码,再 Hook,不要反过来。
  2. 先比对签名前字符串,再比对摘要结果。
  3. 签名复现失败时,优先查排序、空值过滤、编码和时间戳单位。

最后再强调一次边界:本文方法适用于授权测试、接口联调和学习研究。面对有壳、native 深度参与、强反调试的目标时,流程仍然适用,但你需要额外处理脱壳、JNI 跟踪和反检测问题。

如果你已经能顺着这篇文章走完一遍,下一次再遇到“登录参数都有了,但 sign 老不对”的场景,心里就不会慌了。因为你知道,不是瞎猜算法,而是有方法地把它一点点钉出来


分享到:

上一篇
《前端性能优化实战:从 Core Web Vitals 指标出发定位并修复渲染瓶颈》
下一篇
《大模型应用中的 RAG 落地实践:从知识库构建到检索增强效果优化》