安卓逆向实战:基于 Frida 与 JADX 的登录参数签名分析与请求重放方法
很多同学学安卓逆向时,都会卡在同一个地方:App 抓到包了,但请求里的签名参数完全看不懂。尤其是登录接口,常常带着 sign、token、nonce、timestamp、deviceId 之类的字段,改一个参数就直接 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..."
}
表面看上去很正常,但问题通常出在这几个地方:
password不是明文,可能先做了 hash 或加密。sign不是简单拼接,可能混入设备信息、版本号、渠道号。- 算法可能不在 Java 层,而在 JNI so 里。
- 即使你知道了接口,也可能因为 Header、Cookie、证书校验、动态 token 没处理好,导致重放失败。
所以真正的目标不是“看懂几个字段”,而是建立一条完整链路:
- JADX 静态分析定位入口
- Frida 动态 Hook 还原参数
- 复刻签名逻辑
- 请求重放验证
- 遇到失败时快速排查
前置知识与环境准备
建议你先具备这些基础:
- 会用
jadx-gui看 Java 代码 - 知道 Android 常见网络库:OkHttp、Retrofit、Volley
- 会基本的 Frida Hook
- 知道 Burp / Charles / mitmproxy 抓包基础
环境清单
- Android 测试机或模拟器
jadx最新版frida-toolsadb- 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?
其实更关键的是先搞清楚:
- 签名前的原始字符串是什么
- 参数顺序是什么
- 有没有固定盐/动态盐
- 签名结果是否做了大小写或二次编码
如果把“原始入参”拿到了,后面大概率就不难了。
用 JADX 定位签名生成入口
第一步:从接口路径反查调用点
如果抓包里有 /api/user/login,先在 JADX 全局搜索:
/api/user/loginloginsigntimestampnonce
通常能找到 Retrofit 接口定义,例如:
public interface ApiService {
@POST("/api/user/login")
Call<LoginResp> login(@Body LoginReq req);
}
接着追 LoginReq、请求拦截器、仓储层、ViewModel 或 Presenter。
第二步:重点盯住这些位置
我一般优先看下面几类代码:
- OkHttp Interceptor
- 很多统一签名是在拦截器里加的
- 请求实体的 build 方法
LoginReq.build()、toMap()之类
- 工具类
SignUtil、SecurityUtil、EncryptUtils
- 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()
逐步验证清单
不要一口气写完重放脚本再看结果,推荐按下面顺序逐项验证:
- 接口路径一致
- 请求方法一致:GET/POST/PUT
- Content-Type 一致
- Body 序列化形式一致
- JSON
- form-urlencoded
- multipart
- 时间戳格式一致
- 秒级还是毫秒级
- nonce 长度和字符集一致
- 参数排序一致
- 空值过滤规则一致
- 大小写一致
- MD5 大写还是小写
- Header 一致
- token
- user-agent
- app-version
- Cookie / Session 一致
- 是否存在设备绑定字段
- 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);
这时做法有两条:
- 先 Hook Java 调用边界
- 看
genSign的入参和返回值
- 看
- 再决定是否深入 so
- 用 Frida Hook
JNI导出 - 或用 IDA / Ghidra 分析
- 用 Frida Hook
很多时候你根本不需要马上啃 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 分析要设边界
如果目标只是“验证签名与重放”,优先级应该是:
- Hook 输入输出
- 复刻算法
- 必要时再进 so
不要一开始就掉进底层细节黑洞。中级读者最容易在这里耗掉大量时间。
5. 保持分析结果可复现
建议把结果沉淀成三份材料:
- Hook 脚本
- 重放脚本
- 参数说明文档
这样过一周回来,你还能快速恢复上下文。
一个更稳的实战思路
如果你面对的是混淆严重、链路复杂的 App,我建议用这个“分层法”:
第一层:抓包确认现象
明确有哪些字段变化,哪些字段固定。
第二层:JADX 锁定模块
找登录接口、模型类、拦截器、工具类。
第三层:Frida 找真值
抓到:
- 明文密码是否被处理
- 签名前原串
- sign 返回值
- header 注入内容
第四层:Python 复刻
先追求一次成功,不要先追求“写得优雅”。
第五层:做差异比对
把 App 发出的请求与脚本请求逐项对齐。
这个顺序看起来朴素,但真的省时间。
总结
这篇文章的核心不是“某个固定签名算法”,而是一套可复用的方法:
- 从抓包入手,明确目标接口
- 用 JADX 先缩小代码范围
- 用 Frida 在关键点拿到真实参数
- 优先还原签名前原始字符串
- 再用 Python 重放并逐项比对
如果你只记住一句话,我建议是:
逆向登录签名时,先找“真实入参”和“最终输出”,不要一开始就沉迷猜算法。
最后给几个可执行建议:
- 初次分析时,优先 Hook 登录请求对象和签名函数
- 重放失败时,先查序列化、排序、大小写,再查算法
- 遇到 native,不一定马上下钻,先在 Java 边界拿输入输出
- 把每一步结果都记录下来,避免重复劳动
只要你把“静态定位 + 动态验证 + 重放比对”这条链跑顺,后面碰到同类登录签名问题,基本都能有条不紊地拆开。