安卓逆向实战:基于 Frida 与 JADX 的 App 登录签名算法定位与复现
很多同学学安卓逆向时,都会卡在一个非常具体但又高频的问题上:登录接口明明抓到了,参数也看到了,可一发请求就提示签名错误。
这篇文章我就用一个比较贴近真实工作的思路,带你从 JADX 静态分析 和 Frida 动态调试 两条线并行推进,最后把 App 的登录签名算法定位出来,并在本地脚本里完成复现。
说明:本文用于安全研究、接口联调、自有 App 调试与学习,请勿用于未授权目标。
背景与问题
在移动端接口里,登录通常不是简单地把用户名密码发出去。常见还会带上:
timestampnoncedeviceIdsigntoken- 某种加密后的
password
其中最麻烦的往往就是 sign。它可能是:
- 明文参数排序后拼接,再做 MD5/SHA
- 参数 + 固定盐值 + 时间戳混合后做摘要
- Java 层组装后交给 native 层处理
- 甚至先 AES,再 Base64,再摘要
我们的问题通常表现为:
- 能抓包看到请求结构;
- 也能在 JADX 里找到一些“像签名”的代码;
- 但实际复现时,签名总是不一致。
这时只靠静态分析很容易误判,动态观察真实入参与返回值 才是关键。也因此,JADX 和 Frida 的组合特别适合这个场景。
前置知识
如果你已经有一些基础,可以快速跳过这一节。否则建议先确认你至少了解:
- APK 基本结构:
classes.dex、AndroidManifest.xml - Java/Kotlin 基础调用关系
- HTTP 抓包基础
- Frida 常见 Hook 方式
- Python 或 JS 的基础脚本编写
环境准备
本文默认环境如下:
- Android 7~13 真机或模拟器
- 已安装
frida-server,且版本与本机 Frida 一致 - 本机工具:
jadx-guifrida-toolsadbpython3
安装示例:
pip install frida-tools
adb devices
frida-ps -U
如果 frida-ps -U 能列出进程,说明设备连通基本没问题。
整体分析路线
我建议不要一上来就盯着某个可疑函数狂 Hook,而是走一条更稳的路线:
- 先抓包确认登录请求长什么样
- 用 JADX 找“签名相关关键词”
- 缩小到核心调用链
- 用 Frida Hook 入参、返回值、调用堆栈
- 确认排序规则、拼接规则、盐值来源
- 在本地 Python 中复现
- 用真实请求比对验证
这个顺序很重要。很多人失败不是不会 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 的价值不只是“看代码”,更重要的是:
- 找关键字符串
- 看调用关系
- 判断签名发生在哪一层
常见搜索关键词:
signsignaturemd5sha1sha256digestencrypttimestampnoncetokenokhttpInterceptor
很多 App 会在以下位置处理签名:
Retrofit请求前的Interceptor- 某个工具类,比如
SignUtils - 登录仓库类 /
Repository - 通用请求参数构建器
- JNI 桥接类
2. 动态调试解决“实际怎么跑”
静态代码有几个常见陷阱:
- 反编译后变量名失真
- Kotlin 内联/匿名类不直观
- 重载函数太多,容易 Hook 错
- 实际执行路径带有条件分支
- 某些值是运行时才注入的
Frida 的优势就是:在运行时看真实参数、真实返回值、真实类加载情况。
3. 签名算法通常关注三件事
定位算法时,我一般先确认下面三件事:
参数来源
例如:
usernamepassword是不是先做了 MD5timestamp是不是服务端下发或本地生成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
×tamp=1700000000
&nonce=8f34ab12
&deviceId=android_123456
&sign=5f4dcc3b5aa765d61d8327deb882cf99
通过观察,我们怀疑签名规则大概是:
- 去掉
sign - 参数按 key 升序排序
- 拼成
deviceId=...&nonce=...&password=...×tamp=...&username=... - 末尾追加固定盐,比如
&key=AppSecret123 - 进行 MD5,小写输出
接下来我们就一步步证实它。
用 JADX 定位可疑代码
第一步:先从网络层入手
打开 APK 到 jadx-gui 后,我通常先搜:
/loginloginsignInterceptor
如果项目用了 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是否是标准 MD5TextUtils.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×tamp=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×tamp=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 看到的值不一致
这说明参数可能被多次加工。常见路径:
- 业务层组一次
- 拦截器再补公共参数
- 序列化时再做编码
解决办法是 沿调用链逐层 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 复现 负责最终闭环:证明你真的掌握了算法。
如果你只记住三条建议,我希望是这三条:
- 先抓包,再看代码,再 Hook,不要反过来。
- 先比对签名前字符串,再比对摘要结果。
- 签名复现失败时,优先查排序、空值过滤、编码和时间戳单位。
最后再强调一次边界:本文方法适用于授权测试、接口联调和学习研究。面对有壳、native 深度参与、强反调试的目标时,流程仍然适用,但你需要额外处理脱壳、JNI 跟踪和反检测问题。
如果你已经能顺着这篇文章走完一遍,下一次再遇到“登录参数都有了,但 sign 老不对”的场景,心里就不会慌了。因为你知道,不是瞎猜算法,而是有方法地把它一点点钉出来。