安卓逆向实战:基于 Frida 与 JADX 的登录接口参数签名分析与还原
做安卓逆向时,最常见也最“卡脖子”的场景之一,就是接口参数里多了一个看不懂的 sign / token / nonce / digest。抓包看到了请求,却复现不出来;接口路径、请求体、Header 都抄对了,服务端还是回你一句“签名错误”。
这篇文章我不打算讲得太虚,而是按照一条能落地的路径来做:
- 先用 JADX 静态定位登录接口与签名生成点
- 再用 Frida 动态确认真实参与签名的参数
- 最后把签名逻辑还原成可运行脚本,用于本地复现
这类问题的关键不在“工具会不会点”,而在于:你能不能把静态分析和动态分析串成闭环。如果这个闭环打通了,登录接口、风控参数、时间戳签名,思路基本都能迁移。
说明:本文内容用于安全研究、授权测试与个人学习,不应用于未授权目标。
背景与问题
我们先明确问题模型。
假设你抓到一个安卓 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 常见加密类有基础认识:
MessageDigestMacCipherBase64StringBuilder
一条验证命令
先确认 Frida 是否可用:
frida-ps -U
如果能列出设备进程,说明动态分析基础环境没问题。
核心原理
这类登录签名分析,核心是“三层定位法”:
- 接口定位:先找到登录请求代码在哪
- 签名定位:找到 sign 生成函数在哪
- 输入还原:搞清楚 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/loginsignnoncetimestampmd5sha256MessageDigestCipherBase64OkHttpRetrofit
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 中全局搜索:
/loginloginsignpasswordmobile
通常你会找到类似代码:
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.md5SignUtil.signLoginapi.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 还是 HMACpassword在传入前是否已经被加密
用 Frida 做动态确认
静态代码看到的“像答案”,未必就是答案。下面我们直接 Hook。
目标
我们优先确认这几件事:
- 登录函数收到的
password是明文还是密文 signLogin的入参到底是什么md5/sha256/hmac之前的原始字符串是什么- 返回的
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 还原签名逻辑
假设你最终通过静态 + 动态确认,签名规则是:
password_md5 = md5(password)- 构造原始串:
mobile={mobile}&password={password_md5}&ts={ts}&nonce={nonce}&key=app_secret - 最终:
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()
那么你应做到以下闭环:
- Frida 打印的
pwd与本地一致 - Frida 打印的
raw与本地一致 - Frida 打印的
sign与本地一致 - 抓包里的
sign与本地一致 - 使用本地脚本重放登录成功
当这 5 步都成立时,说明你不是“猜出来了”,而是真正还原了签名逻辑。
总结
登录接口参数签名还原,最容易误入的误区是:
只盯算法,不盯输入。
真正高效的路径应该是:
- 用 JADX 找到接口调用、参数组装和可疑签名函数
- 用 Frida 验证运行时真实入参、原始拼接串和最终 sign
- 用 Python 做本地复现,最后以抓包结果和接口重放做闭环
如果你是中级读者,我建议你把这篇文章里的方法记成一句话:
静态分析负责找范围,动态分析负责定真值,还原脚本负责验结果。
最后给几个可执行建议:
- 先确认
password是否被二次处理,再谈sign - 优先拿“哈希前原始串”,不要只拿最终摘要值
- 时间戳单位、参数顺序、大小写,是最常见的三个误差源
- Java 层看不到时,尽快判断是否转到 Native 层
- 分析过程中每一步都做“对齐验证”,别一次猜完整链路
只要你把这套闭环跑熟了,后面再遇到 token、x-sign、anti-bot、风控校验,处理起来会顺很多。