安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑
声明:本文内容仅用于安全测试、应用加固验证、教学研究与授权场景。不要将文中方法用于未授权目标。逆向和动态插桩都很强,强也意味着要谨慎。
很多人学安卓逆向时,最容易卡住的并不是“工具不会用”,而是知道有校验,但不知道该从哪里切进去。登录逻辑尤其典型:Java 层有输入检查、接口层有签名、Native 层可能再补一刀,最后还夹杂着混淆、反调试和多线程。
这篇文章我不想只堆命令,而是带你按一条更接近实战的路径走一遍:
- 先用 JADX 做静态定位,找出登录入口和关键判断点。
- 再用 Frida 做动态验证,确认谁在真正决定“登录成功/失败”。
- 最后演示如何在授权测试环境下,绕过常见的客户端登录校验逻辑,并验证效果。
文章面向中级读者:默认你已经知道 APK、ADB、基本 Java/Kotlin 语法,但未必已经形成一套稳定的逆向排查方法。
背景与问题
在安卓 App 里,“登录失败”未必意味着服务端拒绝了你。很多时候,失败发生在更前面的客户端逻辑里,比如:
- 用户名或密码格式不合法,直接 return
- 本地 token 判空失败
- 客户端时间戳、签名、设备指纹检查不通过
- 某个
isVip()、isDebuggable()、isRooted()之类的分支拦截了流程 - 接口其实返回成功了,但 UI 层又做了二次判断
这类问题如果只抓包,常常会误判;如果只看代码,也容易被混淆和调用链绕晕。JADX + Frida 的组合好用就在于:
- JADX:适合回答“可能在哪”
- Frida:适合回答“到底是不是这里”
我自己踩过一个坑:明明抓包看到服务端返回了 {"code":0,"msg":"ok"},但页面就是提示“登录异常”。最后发现并不是接口失败,而是客户端在收到响应后,还检查了一个 data.userStatus == 1,否则本地直接 toast 失败。这个时候静态看一眼流程图,再用 Frida 验一下返回值,定位就非常快。
前置知识与环境准备
你需要准备什么
- 一台安卓测试机或模拟器
adbjadx-gui或jadxfrida-toolsobjection(可选)- Python 3 环境
- 已授权的测试 APK
安装示例
pip install frida-tools
验证 Frida 版本:
frida --version
查看设备连接:
adb devices
确认目标包名:
adb shell pm list packages | grep demo
导出 APK(如果设备上已安装):
adb shell pm path com.demo.app
adb pull /data/app/~~xxxx/base.apk ./demo.apk
用 JADX 打开:
jadx-gui demo.apk
核心原理
先把思路建立起来,后面的代码就不容易迷路。
1. 登录校验通常分几层
flowchart TD
A[用户点击登录] --> B[UI层输入校验]
B -->|通过| C[ViewModel/Presenter组装请求]
B -->|失败| X[提示错误并返回]
C --> D[签名/设备/环境校验]
D -->|通过| E[发起网络请求]
D -->|失败| Y[本地拦截]
E --> F[解析响应]
F --> G[业务状态二次判断]
G -->|通过| H[保存Token并跳转首页]
G -->|失败| Z[提示登录失败]
你真正要找的不是“登录按钮在哪里”,而是:
- 谁决定是否发请求?
- 谁决定响应是不是“成功”?
- 谁在最终控制页面跳转?
2. 静态分析负责“缩小范围”
JADX 常见切入点:
- 搜索
"login" - 搜索
"用户名"、"密码错误"、"登录失败"等提示文案 - 搜索接口路径,如
/user/login - 搜索 JSON 字段,如
token、code、status - 搜索点击事件:
setOnClickListener - 搜索 Kotlin lambda、协程回调、Retrofit 接口
3. 动态分析负责“证据闭环”
Frida 的价值不只是“改返回值”,更重要的是:
- 打印入参
- 打印返回值
- 打印调用栈
- Hook 多个候选函数,逐层排除
- 最后只改最小必要点
4. 典型可绕过点
常见客户端登录校验绕过点有这些:
| 类型 | 典型函数/逻辑 | 绕过方式 |
|---|---|---|
| 输入校验 | checkAccount(), TextUtils.isEmpty() | 修改返回值或参数 |
| 环境校验 | Root/模拟器/调试检测 | Hook 检测函数返回 false |
| 签名校验 | genSign(), verifySign() | 记录参数,必要时修改返回值 |
| 响应判断 | response.code == 0 | 替换字段或修改判断函数 |
| 登录态保存 | saveToken(), isLogin() | 强制写入或返回 true |
用 JADX 先把登录链路摸清楚
这一节我们假设有一个测试应用,其登录主流程大致如下:
LoginActivity:接收输入,点击登录LoginViewModel:调用仓库层AuthRepository:发起网络请求LoginChecker:做本地格式和环境校验LoginResponse:解析服务端响应
第一步:找登录入口
在 JADX 中搜索:
setOnClickListener"登录""login""密码不能为空"
你通常能找到类似代码:
public final class LoginActivity extends AppCompatActivity {
private void initView() {
this.btnLogin.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String user = etUser.getText().toString();
String pass = etPass.getText().toString();
if (!LoginChecker.check(user, pass, LoginActivity.this)) {
Toast.makeText(LoginActivity.this, "参数不合法", 0).show();
return;
}
viewModel.doLogin(user, pass);
}
});
}
}
一眼看上去,LoginChecker.check() 就很关键。
第二步:追 LoginChecker
可能看到这样的代码:
public final class LoginChecker {
public static boolean check(String user, String pass, Context ctx) {
if (TextUtils.isEmpty(user) || TextUtils.isEmpty(pass)) {
return false;
}
if (user.length() < 6 || pass.length() < 8) {
return false;
}
if (DeviceUtil.isRooted() || DeviceUtil.isEmulator()) {
return false;
}
return true;
}
}
这里已经有几个候选绕过点:
LoginChecker.checkDeviceUtil.isRootedDeviceUtil.isEmulator
第三步:追网络与响应判断
继续追 doLogin(),你也许会看到:
public void doLogin(String user, String pass) {
repository.login(user, pass, new Callback<LoginResponse>() {
@Override
public void onSuccess(LoginResponse resp) {
if (resp != null && resp.getCode() == 0 && resp.getData() != null && resp.getData().getUserStatus() == 1) {
SessionManager.saveToken(resp.getData().getToken());
navigator.goHome();
} else {
view.showError("登录失败");
}
}
});
}
这一步很重要:即使服务端 code=0,userStatus != 1 也会失败。这就是很多人只抓包会漏掉的地方。
建立定位路线图
在真正写 Hook 前,建议把候选点按优先级整理一下:
flowchart LR
A[登录按钮点击] --> B[LoginChecker.check]
B --> C[DeviceUtil.isRooted/isEmulator]
B --> D[ViewModel.doLogin]
D --> E[Repository.login]
E --> F[onSuccess]
F --> G[resp.getCode]
F --> H[resp.getData.getUserStatus]
H --> I[SessionManager.saveToken]
经验上,我会按这个顺序验证:
- 先验证 UI 层:本地有没有提前拦截?
- 再验证环境层:是否被 Root/模拟器检测卡住?
- 再验证响应层:服务端成功后是否被客户端二次判断卡住?
- 最后才考虑 Native/加固层
因为越靠前的点,修改成本越低,验证越快。
实战代码(可运行)
下面给一套能直接改造使用的 Frida 脚本。你需要把类名替换成你实际 APK 里的类名。
场景一:绕过本地输入与环境校验
文件名:hook_login_check.js
Java.perform(function () {
console.log("[*] Frida attached");
try {
var LoginChecker = Java.use("com.demo.app.auth.LoginChecker");
LoginChecker.check.overload("java.lang.String", "java.lang.String", "android.content.Context").implementation = function (user, pass, ctx) {
console.log("[+] LoginChecker.check called");
console.log(" user =", user);
console.log(" pass =", pass);
var original = this.check(user, pass, ctx);
console.log(" original result =", original);
console.log(" patched result = true");
return true;
};
console.log("[+] Hooked LoginChecker.check");
} catch (e) {
console.log("[-] Hook LoginChecker.check failed:", e);
}
try {
var DeviceUtil = Java.use("com.demo.app.util.DeviceUtil");
DeviceUtil.isRooted.implementation = function () {
console.log("[+] DeviceUtil.isRooted -> false");
return false;
};
DeviceUtil.isEmulator.implementation = function () {
console.log("[+] DeviceUtil.isEmulator -> false");
return false;
};
console.log("[+] Hooked DeviceUtil checks");
} catch (e) {
console.log("[-] Hook DeviceUtil failed:", e);
}
});
启动方式:
frida -U -f com.demo.app -l hook_login_check.js
如果你不需要 spawn,想附加到已启动进程:
frida -U com.demo.app -l hook_login_check.js
场景二:打印登录响应,确认真正失败点
文件名:hook_login_response.js
Java.perform(function () {
try {
var LoginResponse = Java.use("com.demo.app.network.model.LoginResponse");
var LoginData = Java.use("com.demo.app.network.model.LoginData");
LoginResponse.getCode.implementation = function () {
var ret = this.getCode();
console.log("[+] LoginResponse.getCode() =", ret);
return ret;
};
LoginResponse.getData.implementation = function () {
var data = this.getData();
console.log("[+] LoginResponse.getData() =", data);
return data;
};
LoginData.getUserStatus.implementation = function () {
var ret = this.getUserStatus();
console.log("[+] LoginData.getUserStatus() =", ret);
return ret;
};
LoginData.getToken.implementation = function () {
var ret = this.getToken();
console.log("[+] LoginData.getToken() =", ret);
return ret;
};
console.log("[+] Hooked LoginResponse/LoginData");
} catch (e) {
console.log("[-] Hook failed:", e);
}
});
启动:
frida -U -f com.demo.app -l hook_login_response.js
这一步的目标不是马上绕过,而是先看日志确认:
code是否为 0userStatus是否不是 1- token 是否存在
场景三:强制通过响应层判断
如果你已经确认问题出在 getUserStatus(),可以定点改它。
文件名:bypass_login_status.js
Java.perform(function () {
try {
var LoginData = Java.use("com.demo.app.network.model.LoginData");
LoginData.getUserStatus.implementation = function () {
var original = this.getUserStatus();
console.log("[+] Original getUserStatus() =", original, "-> patched to 1");
return 1;
};
console.log("[+] Hooked LoginData.getUserStatus");
} catch (e) {
console.log("[-] Hook LoginData.getUserStatus failed:", e);
}
try {
var SessionManager = Java.use("com.demo.app.auth.SessionManager");
SessionManager.saveToken.overload("java.lang.String").implementation = function (token) {
console.log("[+] SessionManager.saveToken called with token =", token);
return this.saveToken(token);
};
console.log("[+] Hooked SessionManager.saveToken");
} catch (e) {
console.log("[-] Hook SessionManager.saveToken failed:", e);
}
});
场景四:直接 Hook 登录态判断
有些 App 不依赖单一登录响应,而是在首页或启动页调用 isLogin()。这种情况直接盯登录态更高效。
文件名:force_islogin.js
Java.perform(function () {
try {
var SessionManager = Java.use("com.demo.app.auth.SessionManager");
SessionManager.isLogin.implementation = function () {
var original = this.isLogin();
console.log("[+] SessionManager.isLogin() original =", original, "-> patched to true");
return true;
};
console.log("[+] Hooked SessionManager.isLogin");
} catch (e) {
console.log("[-] Hook SessionManager.isLogin failed:", e);
}
});
一个更完整的动态验证流程
如果你不想上来就“硬改”,建议按这个顺序来。这样出问题时更容易回退。
sequenceDiagram
participant U as 用户
participant A as App
participant F as Frida
participant S as 服务端
U->>A: 输入账号密码并点击登录
F->>A: Hook LoginChecker.check 打印参数
A->>A: 本地校验
F->>A: Hook DeviceUtil.* 环境检测
A->>S: 发起登录请求
S-->>A: 返回响应
F->>A: Hook getCode/getUserStatus/getToken
A->>A: 二次业务判断
F->>A: 定点修改返回值
A-->>U: 进入首页/显示成功
逐步验证清单
这部分很实用,尤其在你面对一个新 APK 时。
第 1 步:确认是客户端拦截还是服务端拒绝
- 登录按钮点下去后,有没有出网请求?
- 抓包里请求是否发出?
- 如果没发出,大概率是客户端前置校验
- 如果发出了但仍失败,看响应后是否又被本地逻辑拦截
第 2 步:验证环境检测
Hook 这些高频点:
isRootedisEmulatorisDebuggablecheckSignaturecheckXposedcheckFrida
第 3 步:验证响应层字段
重点盯:
codestatussuccesstokenroleuserStatusneedBindPhoneneedRealName
第 4 步:验证登录态持久化
检查:
SharedPreferences是否写入 token- token 是否被加密存储
isLogin()是否仅判断 token 非空- 启动页是否还有额外用户态校验
常见坑与排查
这一节是我觉得最容易节省时间的部分。很多“脚本没生效”,其实不是 Frida 不行,而是点没选对。
1. Hook 了,但函数根本没走到
现象:
- 脚本加载成功
- 日志一个都没打印
排查方向:
- 类名是不是混淆后真实名字?
- 方法是不是重载了,签名不对?
- 逻辑是不是在 Kotlin 顶层函数、匿名内部类、lambda 里?
- App 是否用了多 dex,JADX 搜索是否漏类?
建议:
Java.perform(function () {
var Clz = Java.use("com.demo.app.auth.LoginChecker");
console.log(Clz.check.overloads);
});
先把 overload 打出来,再决定 Hook 哪个签名。
2. 一 Hook 就崩
常见原因:
- 返回类型不匹配
- Hook 到了抽象类/接口而不是具体实现
- 在构造函数或关键线程里做了太重的日志操作
- 递归调用了原方法
比如这个错误写法:
SomeClass.someMethod.implementation = function () {
return this.someMethod();
};
如果这是当前被替换后的实现,就会递归。更稳妥的方式是先只打印参数,少做复杂操作。
3. spawn 模式有效,attach 模式无效
原因通常是:
- 关键类在应用启动早期已执行
- 你附加时机太晚了
建议优先:
frida -U -f com.demo.app -l script.js
如果 App 启动立刻闪退,也可能有反调试或 Frida 检测。
4. Java 层怎么改都没用
这往往说明校验不在 Java 层,而在:
- JNI / SO
- 服务端
- WebView JS
- 加固壳动态加载代码中
排查思路:
- 搜索
System.loadLibrary - 用
frida-trace跟 JNI 导出函数 - 观察 Java 方法是否只是 native 壳包装
5. 明明改了返回值,UI 还是没跳
可能原因:
- UI 线程和业务线程分离
- 跳转条件不止一个
saveToken()没成功- 启动页再次检查了登录态
- 数据类字段被多次读取,不是只改一个 getter 就够
这时建议同时 Hook:
getCodegetUserStatussaveTokenisLoginstartActivity
把整条链路补齐。
6. 混淆后类名难找
JADX 里如果满屏都是 a.a.a.a,我一般会这样找:
- 搜字符串常量:
"登录失败"、"token"、"/login" - 搜布局 ID 对应的点击逻辑
- 搜 Retrofit 接口路径
- 从
AndroidManifest.xml找登录 Activity - 用运行时枚举类辅助确认
示例脚本:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("login") !== -1 || name.indexOf("auth") !== -1) {
console.log(name);
}
},
onComplete: function () {
console.log("[*] done");
}
});
});
安全/性能最佳实践
逆向不只是“能 Hook 到”,还要尽量做到小范围、可回退、低扰动。
1. 优先最小修改点
不建议一上来就全局改 TextUtils.isEmpty() 这类系统函数,因为副作用太大。更推荐:
- 先改业务类里的
check() - 再改具体数据对象的 getter
- 最后才考虑全局函数
2. 先观察,后篡改
我的习惯是分两轮:
- 第一轮:只打印,不改返回值
- 第二轮:在确认目标点后再精确改
这样能减少误判,也便于写报告时给出证据链。
3. 控制日志量
高频函数里 console.log() 太多会拖慢 App,甚至导致卡顿或 ANR。建议:
- 给日志加条件
- 只打印关键参数
- 完成定位后删除大部分调试输出
4. 做好版本适配
同一 App 不同版本里:
- 类名可能变
- 方法签名可能变
- Kotlin 编译结果可能变
所以脚本最好写成“可探测”的,而不是死写一个类名后全靠运气。
5. 不要忽略服务端边界
客户端绕过并不等于真正认证成功。很多场景下,你只能做到:
- 绕过本地提示
- 进入某些页面
- 构造伪登录态
但一旦调用需要服务端鉴权的接口,仍然会失败。这个边界一定要讲清楚,尤其在做安全评估时,不能把“UI 登录成功”误写成“系统已被完全绕过”。
6. 测试环境优先
如果你在做企业内部安全测试,建议:
- 使用测试账号
- 使用测试环境接口
- 提前和业务方约定日志与流量范围
- 避免对真实用户态数据造成影响
一个实战思路模板
当你面对一个新的 APK 时,可以直接套下面这个方法:
模板 A:客户端前置校验怀疑链
- 搜登录入口 Activity / Fragment
- 找按钮点击事件
- 追本地
check()/validate()/verify() - Hook 环境检测函数
- 验证是否开始发请求
模板 B:响应后二次判断怀疑链
- 找登录接口回调
- 找
code/status/success/token - 找额外业务字段:
userStatus/role/bindState - Hook getter 观察真实值
- 定点改最小字段并验证页面流转
模板 C:登录态保存怀疑链
- 找
saveToken - 找 SharedPreferences key
- 找
isLogin() - 找启动页/首页跳转逻辑
- 同时验证持久化和页面状态
总结
这篇文章的重点其实不是“如何把登录改成功”,而是建立一套稳定的定位—验证—绕过方法:
- JADX 负责把登录流程拆开,找出候选校验点
- Frida 负责逐点验证,确认真正决定结果的函数
- 绕过时尽量选择最小、最稳、最容易回退的修改点
如果你只记住三条可执行建议,我会建议你记这三条:
-
先找“谁拦截发请求”,再找“谁决定登录成功”
不要一上来就全局 Hook。 -
先打印证据,再改返回值
没有证据链的绕过,很容易误判。 -
区分客户端成功和服务端认证成功
UI 过了,不代表鉴权真的过了。
最后再强调一次:本文方法只适用于授权测试、教学研究和防护验证。从防守角度看,如果你是开发或安全工程师,也可以反过来用这套方法检查自家 App:看看关键登录逻辑是不是过度放在客户端、是不是被轻易 Hook、是不是缺乏服务端强校验。
如果你的应用确实依赖客户端判断登录态,那它迟早会成为别人的练手题。把真正的认证决策放回服务端,永远比在客户端多写几个 if 更靠谱。