背景与问题
在 Android 应用逆向里,登录接口几乎是最常见、也最容易“卡壳”的分析点。表面上看,请求体里只是用户名、密码和几个设备参数;真正困难的地方在于:
- 请求参数可能被二次封装
- 密码可能不是明文传输
- 请求头里往往有时间戳、nonce、token、sign
- sign 通常不是一个单纯的 MD5,而是“拼接 + 排序 + 混淆 + native/Java 混合计算”
很多同学一上来就抓包,看到接口返回 sign error、invalid request,就开始盲猜算法。这个阶段最容易浪费时间。
这篇文章我想换一个更实战的角度:先用 JADX 建静态认知,再用 Frida 在运行时把关键参数流“钉住”,最终定位登录流程里的参数签名生成逻辑。你可以把它理解成一套可重复的工作流,而不是某个 App 的一次性技巧。
文章默认用于授权测试、开发调试与安全研究场景。请勿将文中方法用于未授权目标。
前置知识
如果你已经做过 Android 基础逆向,可以快速略过这一节。需要用到的知识点主要有:
- 能看懂基础 Java/Kotlin 反编译代码
- 知道 Android 常见网络栈:
OkHttp、HttpURLConnection、Retrofit - 了解 Frida 的基本 Hook 方式
- 知道常见摘要算法:
MD5、SHA1、SHA256、HMAC
环境准备
建议准备以下环境:
- 一台 Android 测试机或模拟器
adbjadx-guifrida-toolsobjection(可选)- 抓包工具:Charles / Fiddler / mitmproxy(可选但推荐)
安装示例:
pip install frida-tools
确认设备连通:
adb devices
frida-ps -U
如果是较新的 Android 设备,建议提前处理这些基础问题:
- 安装好对应架构的
frida-server - 确保设备与 PC 在同一调试链路
- 确认目标 App 不是多进程误附加
- 先确认是否有 Root / Magisk / 虚拟环境需求
问题拆解:为什么要“静态 + 动态”结合
只靠静态分析,常见问题是:
- 混淆严重,类名方法名不可读
- 调用链太长,难以判断哪个参数最终上送
- 某些关键逻辑在运行时才会分支
- native 层参与签名时,Java 层只能看到入口
只靠动态分析,也有问题:
- 不知道该 Hook 哪些点
- 容易打在太底层,日志巨大
- 无法建立参数语义,只能看到“字符串流过”
所以更稳的方式是:
- JADX 找登录入口和网络调用层
- 确定请求对象、参数封装对象、签名工具类候选
- Frida Hook 参数构造、Map 写入、摘要计算、请求发起
- 把“输入参数”与“最终 sign”关联起来
- 必要时继续追到 native 层入口
核心原理
登录流程里,参数签名定位的关键不是“猜算法”,而是恢复参数流向。你可以把它抽象成一条数据链:
flowchart LR
A[登录按钮点击] --> B[ViewModel/Presenter]
B --> C[请求参数对象构造]
C --> D[公共参数注入]
D --> E[签名函数计算 sign]
E --> F[OkHttp/Retrofit 发起请求]
F --> G[服务端校验]
我们真正要找的是两件事:
- sign 在哪里生成
- sign 的输入材料是什么
典型输入包括:
- 用户名 / 密码 / 手机号
- 时间戳
- nonce / salt
- appKey / secret
- 设备号 / 渠道号 / 版本号
- 请求路径 / body 原文
- 参数排序后的 canonical string
常见签名实现模式
1. 纯 Java 工具类
最容易定位,通常长这样:
String sign = Md5Util.md5(a + b + c + secret);
2. Map 排序后拼接
很多接口会先把请求参数放进 Map,排序后拼接:
TreeMap<String, String> map = new TreeMap<>(params);
再生成:
key1=value1&key2=value2&...&secret=xxx
3. JSON 串整体签名
有些项目直接对 body 的 JSON 字符串做摘要:
sign = sha256(json + timestamp + secret);
4. Java 调 native
Java 层只是个壳:
public native String sign(String data);
这时要先 Hook Java 入口,把传入 native 的内容抓出来,必要时再继续看 so 层。
分析视角:从“请求发起前”倒推
如果你不知道从哪里下手,我通常建议先从请求发起前一跳倒推,因为这里最接近真实上送数据。
sequenceDiagram
participant U as 用户
participant A as App逻辑层
participant S as 签名模块
participant N as 网络层
participant R as 服务端
U->>A: 输入账号密码并点击登录
A->>A: 构造业务参数
A->>S: 传入待签名数据
S-->>A: 返回 sign
A->>N: 组装请求头/请求体
N->>R: POST /login
R-->>N: 响应结果
N-->>A: 登录成功/失败
所以我们在实战里,一般按这条路线定位:
- 先看
OkHttp请求体 / 请求头 - 再看谁给这些字段赋值
- 再往上找 sign 生成函数
- 最后确认输入参数、拼接顺序和边界条件
用 JADX 做第一轮静态定位
1. 搜关键字
打开 APK 到 jadx-gui 后,先搜这些关键字:
loginsigntokentimestampnoncemd5sha1sha256digestInterceptorRequestBodyRetrofitOkHttpClient
中级阶段最重要的不是搜到“sign”这个词,而是找出登录请求的具体调用链。
2. 找网络栈入口
重点看这些类:
okhttp3.Interceptorretrofit2.RetrofitRequest.Builder- 项目自定义的
ApiClient/HttpManager - 各种
*Service/*Repository
很多项目会在统一拦截器里补充公共参数和签名,这里命中率非常高。
3. 识别“可疑工具类”
一个签名工具类常见特征:
- 类名混淆但方法集中做字符串操作
- 出现
MessageDigest - 使用
StringBuilder - 出现
TreeMap、Collections.sort - 出现固定盐值、常量 key
- 有 JNI 声明
4. 建一个最小调用图
不用一上来把整个项目看完,只要把登录相关路径画出来即可:
flowchart TD
A[LoginActivity] --> B[LoginPresenter]
B --> C[UserRepository]
C --> D[ApiService.login]
C --> E[CommonParamBuilder]
E --> F[SignUtil.getSign]
D --> G[OkHttp Interceptor]
这一步的目的,是给后面的 Frida Hook 找落点。
实战:一步步定位登录参数签名
下面给出一套可以直接运行和改造的示例脚本。为了通用性,我会优先 Hook 常见基础类,而不是依赖具体业务类名。
第一步:确认目标进程
frida-ps -Uai | grep 目标包名
附加进程:
frida -U -f com.example.app -l hook_login.js --no-pause
第二步:Hook 常见摘要算法
先不要急着 Hook 业务类。我的经验是:摘要算法是最稳定的观察点。哪怕业务代码混淆,MessageDigest 基本跑不掉。
hook_login.js
Java.perform(function () {
var MessageDigest = Java.use("java.security.MessageDigest");
var StringCls = Java.use("java.lang.String");
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
function bytesToHex(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var b = bytes[i];
if (b < 0) b += 256;
var h = b.toString(16);
if (h.length < 2) h = "0" + h;
result.push(h);
}
return result.join("");
}
MessageDigest.getInstance.overload("java.lang.String").implementation = function (alg) {
var ins = this.getInstance(alg);
console.log("[MessageDigest.getInstance] alg=" + alg);
return ins;
};
MessageDigest.digest.overload("[B").implementation = function (input) {
var alg = this.getAlgorithm();
var inStr = "";
try {
inStr = StringCls.$new(input);
} catch (e) {
inStr = "<binary>";
}
console.log("\n[MessageDigest.digest]");
console.log("algorithm = " + alg);
console.log("input(str)= " + inStr);
console.log("input(hex)= " + bytesToHex(input));
var stack = Log.getStackTraceString(Exception.$new());
console.log("stack = \n" + stack);
var out = this.digest(input);
console.log("output(hex)= " + bytesToHex(out));
return out;
};
});
这一步能看到什么
你通常能看到:
- 用的是
MD5还是SHA-256 - digest 前的原始输入内容
- 调用栈里是谁发起的
如果输入看起来像:
username=alice&password=123456×tamp=1700000000&key=abc
那基本已经离答案不远了。
第三步:Hook Map / 参数构造过程
如果摘要输入已经被编码得很厉害,或者看不清字段来源,就继续往上 Hook 参数容器。
Java.perform(function () {
var HashMap = Java.use("java.util.HashMap");
var TreeMap = Java.use("java.util.TreeMap");
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
function printPut(mapName, k, v) {
console.log("[" + mapName + ".put] " + k + " = " + v);
var stack = Log.getStackTraceString(Exception.$new());
console.log(stack);
}
HashMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
if (k && v) {
printPut("HashMap", k.toString(), v.toString());
}
return this.put(k, v);
};
TreeMap.put.overload("java.lang.Object", "java.lang.Object").implementation = function (k, v) {
if (k && v) {
printPut("TreeMap", k.toString(), v.toString());
}
return this.put(k, v);
};
});
观察重点
你要重点看这些字段何时出现:
usernamepasswordpwdtimestampnoncesigndeviceIdappVersion
如果你发现:
- 先
put(username) - 再
put(password) - 再
put(timestamp) - 最后某处出现
put(sign)
那说明 sign 很可能是在这个参数 Map 完整后计算出来的。
我当时踩过一个坑:全局 Hook
HashMap.put日志会非常爆炸,App 启动阶段几千行很正常。解决办法是先手动触发登录,再只关注相关栈和字段名。
第四步:Hook OkHttp 请求,确认最终上送值
这一步非常关键,因为它可以验证“你看到的 sign”是否就是发出去的 sign。
Java.perform(function () {
var RequestBuilder = Java.use("okhttp3.Request$Builder");
RequestBuilder.addHeader.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
console.log("[Request.addHeader] " + k + " = " + v);
return this.addHeader(k, v);
};
RequestBuilder.header.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
console.log("[Request.header] " + k + " = " + v);
return this.header(k, v);
};
});
如果目标 App 使用表单或 JSON 请求体,还可以 Hook RequestBody 创建过程。
Java.perform(function () {
try {
var RequestBody = Java.use("okhttp3.RequestBody");
var MediaType = Java.use("okhttp3.MediaType");
var StringCls = Java.use("java.lang.String");
RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mt, content) {
console.log("[RequestBody.create]");
console.log("mediaType = " + mt);
console.log("content = " + content);
return this.create(mt, content);
};
} catch (e) {
console.log("Hook RequestBody.create failed: " + e);
}
});
验证目标
到这一步,你应该能确认以下事实:
- 登录接口路径是什么
- 请求头里是否有签名
- 请求体里是否有签名
- 最终发送的时间戳、nonce、token 值是什么
第五步:定点 Hook 业务签名函数
通过前面的静态分析和调用栈,你通常已经能锁定某个业务类,比如:
com.xxx.util.SignUtilcom.xxx.common.Securitycom.xxx.network.AuthInterceptor- 或混淆类如
a.b.c.d
假设你在 JADX 里找到了一个可疑方法:
public static String a(Map<String, String> map)
那就可以定点 Hook:
Java.perform(function () {
var SignUtil = Java.use("com.xxx.util.SignUtil");
SignUtil.a.overload("java.util.Map").implementation = function (map) {
console.log("[SignUtil.a] called");
var iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
console.log(" " + entry.getKey() + " = " + entry.getValue());
}
var ret = this.a(map);
console.log("[SignUtil.a] result = " + ret);
return ret;
};
});
如果方法签名复杂,先用 frida-trace 辅助探路:
frida-trace -U -f com.example.app -j 'com.xxx.util.SignUtil!*'
一套更完整的排查思路
为了避免中途迷路,建议按下面这个清单逐步验证。
逐步验证清单
阶段 1:登录入口确认
- 找到登录按钮点击事件
- 找到登录接口方法
- 确认使用的网络库
阶段 2:请求结构确认
- 确认请求路径
- 确认请求头字段
- 确认请求体格式:Form / JSON / Protobuf
阶段 3:签名生成定位
- 是否存在
MessageDigest - 是否存在排序逻辑
- 是否存在固定盐值或 appSecret
- 是否存在 JNI 调用
阶段 4:结果验证
- Hook 到 sign 输入
- Hook 到 sign 输出
- Hook 到最终发包值
- 三者一致
典型场景拆解
场景一:密码先加密,再参与签名
有些 App 会这样做:
- 明文密码输入
- 先做一次
MD5(password) - 再把这个结果和其他参数一起参与 sign
- 最终请求里只传密文密码和 sign
这时你可能在请求体里看不到明文密码,但在早一点的摘要函数或业务加密函数里能看到。
建议做法
- 先 Hook
EditText.getText()或登录按钮响应,确认用户输入 - 再 Hook 字符串摘要函数,观察密码何时变化
- 最后关联 sign 的输入
场景二:sign 放在拦截器统一生成
这种情况最常见。业务层只传原始参数,统一拦截器补:
timestampnoncesign- 公共 headers
这种设计对开发很方便,对逆向来说也很友好,因为逻辑集中。
识别特征
- 在
Interceptor.intercept()里看到newBuilder() - 请求发出前修改
Request - 有统一的设备信息注入
场景三:Java 层只负责组装,真正签名在 so
JADX 里你可能看到:
public class NativeSign {
static {
System.loadLibrary("sec");
}
public static native String sign(String src);
}
这时别急着直接上 so 分析。先做两件事:
- Hook
NativeSign.sign()的 Java 入口 - 把传入
src和返回值抓出来
Java.perform(function () {
var NativeSign = Java.use("com.xxx.security.NativeSign");
NativeSign.sign.overload("java.lang.String").implementation = function (src) {
console.log("[NativeSign.sign] input = " + src);
var ret = this.sign(src);
console.log("[NativeSign.sign] output = " + ret);
return ret;
};
});
很多时候,仅靠这一层你就足够复现请求了,没必要继续深挖 so。
常见坑与排查
1. Hook 太底层,日志被淹没
现象
HashMap.put一秒刷几千行- 根本分不清哪个是登录相关
排查思路
- 先触发登录动作再观察
- 打印调用栈,只留业务包名
- 增加关键字过滤,例如只打印
sign/timestamp/password/login
示例过滤:
function needPrint(k, v) {
var s = (k + "=" + v).toLowerCase();
return s.indexOf("sign") >= 0 ||
s.indexOf("timestamp") >= 0 ||
s.indexOf("password") >= 0 ||
s.indexOf("login") >= 0;
}
2. App 启动即闪退,疑似反调试/反 Frida
现象
- 一附加就崩
frida -f失败- 日志里出现反注入检测
排查思路
- 尝试
spawn改attach,或反过来 - 先用简化脚本验证是否是某段 Hook 引发崩溃
- 排查
ptrace、TracerPid、端口扫描、字符串检测 - 必要时先做反检测绕过,再上主脚本
3. Hook 不到目标方法
原因常见于
- 方法重载没选对
- Kotlin 生成了桥接方法
- 类在延迟加载
- 多 ClassLoader 场景
- 混淆后真实类名判断错误
解决办法
- 用
overloads枚举所有签名 - 用
Java.enumerateLoadedClasses()先确认类是否存在 - 针对动态加载场景,Hook
ClassLoader.loadClass - 借助
frida-trace初步探路
4. 请求体看不到明文参数
可能原因
- 使用了 gzip
- 使用 protobuf / 自定义二进制协议
- 请求体先加密后上传
建议
- 不要只盯最终请求体,回到上游参数构造
- Hook
RequestBody.create - Hook 加密前的数据构造函数
- 必要时 dump 原始字节数组
5. 时间戳/nonce 每次都变,复现总失败
这个坑非常常见
你以为自己把算法搞清楚了,但脚本重放仍然失败。通常是漏了这些动态字段:
- 当前时间戳
- 服务器下发 token
- 会话随机数
- 某个安装期生成的设备密钥
处理建议
- 把动态值和 sign 同时抓
- 确认 sign 是否包含 URL path 或 body 原文
- 对比两次登录样本,找出变化项
安全/性能最佳实践
这部分很重要,尤其是做实战时,很多人脚本能跑,但不稳定、不可维护。
1. 先粗后细,逐步收窄
推荐顺序:
- Hook 摘要算法
- Hook 参数 Map
- Hook 请求头/请求体
- 最后 Hook 具体业务类
这样效率通常最高,也不容易一开始就陷入混淆泥潭。
2. 日志一定要做过滤
不要默认打印所有内容。建议至少按以下维度过滤:
- 包名
- 字段名
- 接口路径
- 时间窗口
- 当前线程
否则 App 稍复杂一点,你的终端就没法看了。
3. 优先抓“输入”和“输出”,少抓中间态
定位签名时,最有价值的是:
- 签名前原文
- 签名结果
- 最终发包值
中间几十步字符串转换,除非必要,否则只会增加噪音。
4. 注意敏感数据处理
即使是在测试环境,也尽量避免:
- 长期保存真实账号密码
- 把生产 token 写进公开日志
- 在共享仓库提交带敏感信息的脚本输出
建议做简单脱敏:
function mask(s) {
if (!s) return s;
s = s.toString();
if (s.length <= 6) return "***";
return s.substring(0, 3) + "***" + s.substring(s.length - 3);
}
5. 明确边界:不是所有 sign 都值得完全还原
有些场景下,目标只是:
- 确认登录失败原因
- 验证某参数是否参与签名
- 为开发联调定位客户端 bug
这时候只要抓到 sign 输入输出即可,不一定要把 native 或混淆细节全部还原。能解决问题,比“全看懂”更重要。
可运行的组合脚本示例
下面给一个更贴近实战的组合版 Frida 脚本,把摘要、请求体、请求头一起观察。你可以在此基础上加过滤。
Java.perform(function () {
var StringCls = Java.use("java.lang.String");
var MessageDigest = Java.use("java.security.MessageDigest");
var RequestBuilder = Java.use("okhttp3.Request$Builder");
function bytesToHex(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var b = bytes[i];
if (b < 0) b += 256;
var h = b.toString(16);
if (h.length < 2) h = "0" + h;
result.push(h);
}
return result.join("");
}
function safeToString(bytes) {
try {
return StringCls.$new(bytes);
} catch (e) {
return "<binary>";
}
}
MessageDigest.digest.overload("[B").implementation = function (input) {
var alg = this.getAlgorithm();
var plain = safeToString(input);
if (plain.indexOf("login") >= 0 ||
plain.indexOf("password") >= 0 ||
plain.indexOf("timestamp") >= 0 ||
plain.indexOf("nonce") >= 0) {
console.log("\n[Digest Hit]");
console.log("alg = " + alg);
console.log("plain = " + plain);
console.log("hex = " + bytesToHex(input));
}
var out = this.digest(input);
console.log("[Digest Out] " + bytesToHex(out));
return out;
};
RequestBuilder.addHeader.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
if (k && v) {
var ks = k.toString().toLowerCase();
if (ks.indexOf("sign") >= 0 || ks.indexOf("token") >= 0 || ks.indexOf("time") >= 0) {
console.log("[Header] " + k + " = " + v);
}
}
return this.addHeader(k, v);
};
RequestBuilder.header.overload("java.lang.String", "java.lang.String").implementation = function (k, v) {
if (k && v) {
var ks = k.toString().toLowerCase();
if (ks.indexOf("sign") >= 0 || ks.indexOf("token") >= 0 || ks.indexOf("time") >= 0) {
console.log("[Header override] " + k + " = " + v);
}
}
return this.header(k, v);
};
try {
var RequestBody = Java.use("okhttp3.RequestBody");
RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mt, content) {
if (content && (content.indexOf("login") >= 0 || content.indexOf("password") >= 0 || content.indexOf("sign") >= 0)) {
console.log("\n[RequestBody]");
console.log("mediaType = " + mt);
console.log("content = " + content);
}
return this.create(mt, content);
};
} catch (e) {
console.log("RequestBody hook skipped: " + e);
}
console.log("hook ready");
});
运行方式:
frida -U -f com.example.app -l hook_login.js --no-pause
一次完整实战的思维路径示例
假设你抓包看到登录请求如下特征:
POST /api/user/login- body 有
username、password - header 有
x-sign、x-ts
你可以这样走:
-
JADX 搜
/api/user/login- 找到对应
ApiService.login()
- 找到对应
-
看调用方
- 找到
LoginRepository.submitLogin()
- 找到
-
看请求组装
- 发现先构造
HashMap - 再调用
CommonSigner.sign(map)
- 发现先构造
-
Hook
CommonSigner.sign(map)- 打印 map 内容和返回值
-
Hook
Request.Builder.addHeader()- 确认
x-sign的值就是上一步返回值
- 确认
-
如果
CommonSigner里只调用 native- 再 Hook Java native 入口
- 抓
src与ret
到这里,定位任务通常就完成了。
总结
分析 Android 登录流程里的参数签名,最怕的不是算法复杂,而是没有路径感。真正高效的方法通常是:
- 用 JADX 建立登录链路的静态地图
- 用 Frida 在运行时截住参数构造、摘要计算和最终发包
- 通过“输入 -> 签名 -> 请求”三点闭环确认结果
如果你只记住一个原则,我建议是这个:
先找最终请求,再倒推 sign;先抓输入输出,再研究内部细节。
最后给几个可执行建议:
- 第一次分析时,优先盯
MessageDigest、Interceptor、RequestBody - 遇到海量日志,马上加过滤,不要硬看
- 遇到 native,不一定非得深挖 so,先抓 Java 入口参数
- 如果目标只是联调或复现,抓到 sign 输入输出就够了
这套方法不只适用于登录,注册、下单、支付预提交、风控校验接口同样适用。只要你能把“参数从哪里来、在哪里签、最后怎么发”这条链路串起来,复杂度就会一下子降很多。