安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑
这篇文章我会用一个“中级读者能直接上手”的角度,带你把一次典型的 Android 登录校验分析流程完整走一遍:先用 JADX 静态找入口,再用 Frida 动态确认分支,最后只改最小逻辑完成验证。
重点不是“暴力跳过”,而是学会一套可复用的方法。
背景与问题
在 Android 应用里,登录相关逻辑通常不会只有一个按钮点击事件那么简单。你经常会遇到这些情况:
- 前端先做手机号、密码格式校验
- 本地判断验证码、token、签名、时间戳
- 请求前对参数做二次封装
- 服务端返回后再走一层“是否登录成功”的布尔判断
- 某些壳子或混淆会让代码阅读变得很难受
很多同学一开始逆向登录流程,会直接去搜“login”“check”“verify”。这当然能碰运气,但实际项目里,类名和方法名往往都被混淆成 a()、b()、c(),靠关键词常常不够。
更稳妥的路线通常是:
- 先从 UI 事件入手,定位点击登录按钮后的调用链
- 借助 JADX 看清分支条件
- 用 Frida 在运行时打印参数、返回值和调用栈
- 只 hook 关键判断点,验证“哪一步决定了登录成败”
这类分析过程,在测试自有应用、做安全评估、复现客户端薄弱校验时都非常常见。
前置知识
如果你已经能熟练安装 Frida、会 adb 基本命令,可以直接跳到实战部分。
建议你具备这些基础:
- 会用
adb devices、adb shell - 知道 APK 反编译查看 Java 层代码
- 能看懂 Java / Kotlin 的基本控制流
- 了解 Android 常见组件:
Activity、Fragment、TextView、OnClickListener
环境准备
本文默认环境如下:
- Android 测试机或模拟器
- 已安装
frida-server,并与本机 Frida 版本一致 - Python 3
jadx-guiadb
安装校验:
adb devices
frida-ps -U
如果能列出设备与进程,说明基本环境没问题。
核心原理
这一类“登录校验绕过”本质上并不是魔法,它通常围绕三个关键点展开:
- 找到校验发生的位置
- 确定校验输入和返回结果
- 在最小影响范围内修改行为
1. 静态分析负责“缩小范围”
JADX 的优势是看调用关系、资源 ID、字符串引用、匿名内部类逻辑。
比如登录按钮点击后,常见路径可能是:
LoginActivity.onCreate()- 按钮
setOnClickListener(...) - 调用
doLogin() checkAccount()checkPassword()requestLogin()
2. 动态分析负责“确认真相”
静态代码看到的未必就是真正执行路径,尤其当存在:
- 混淆
- 反射
- Kotlin lambda
- 多层封装
- 条件分支依赖运行时数据
这时 Frida 非常适合做两类事:
- 打印参数与返回值
- 临时篡改布尔结果或字符串结果
3. 绕过方式要尽量最小化
一个成熟的分析思路,不是“把所有 if 都改掉”,而是优先选择:
- 修改某个本地校验方法的返回值
- 修改某个状态字段
- 修改服务端响应后的成功判定
而不是粗暴地 hook 整个登录函数让它什么都不做。
后者虽然也许能“进页面”,但通常会留下很多后遗症,比如 token 缺失、用户态未初始化、后续页面崩溃。
一张总览图:从按钮到绕过点
flowchart TD
A[启动 App] --> B[点击登录按钮]
B --> C[JADX 定位点击事件]
C --> D[找到本地校验方法]
D --> E[Frida 打印参数/返回值]
E --> F{确认关键校验点}
F -->|本地格式校验| G[Hook boolean 返回值]
F -->|请求参数处理| H[Hook 参数构造]
F -->|响应结果判定| I[Hook success/code/token 判断]
G --> J[验证是否进入登录后页面]
H --> J
I --> J
核心原理拆解:典型登录校验点
在真实项目里,登录校验大致分成下面几类。
本地输入校验
例如:
- 手机号长度必须 11 位
- 密码长度不能小于 6
- 验证码不能为空
- 同意协议开关必须勾选
代码通常长这样:
private boolean checkInput(String phone, String password) {
if (phone == null || phone.length() != 11) {
return false;
}
if (password == null || password.length() < 6) {
return false;
}
return true;
}
这类最容易验证:直接 hook 返回值即可。
请求前签名或加密
例如:
md5(phone + password + salt)AES加密密码- 设备信息拼接签名
这类不一定适合直接“绕过”,更适合先打印原始输入与输出,搞清它到底在干什么。
服务端响应后再校验
例如:
if (resp != null && resp.code == 200 && resp.data != null && !TextUtils.isEmpty(resp.data.token)) {
saveLoginState(resp.data.token);
gotoHome();
}
就算前面的网络请求都成功了,最后可能也会在这里卡住。
这类点位,很多人第一次分析时容易漏掉。
用时序图理解定位流程
sequenceDiagram
participant U as 用户
participant A as LoginActivity
participant C as Checker
participant N as NetworkApi
participant F as Frida
U->>A: 点击登录
A->>C: checkInput(phone, pwd)
F-->>C: Hook 参数/返回值
C-->>A: true/false
A->>N: requestLogin(...)
F-->>N: 观察请求参数
N-->>A: resp
F-->>A: Hook 响应判定
A-->>U: 进入首页或提示失败
实战思路:先用 JADX 定位登录入口
这一段是我比较推荐的工作方式,效率通常比“先盲 hook”高很多。
第一步:定位登录界面
在 JADX 中优先看:
AndroidManifest.xml- 含有
login、signin、account、user等资源文件 - 布局 XML 中的按钮 id,比如
btn_login
比如你在布局里看到:
<Button
android:id="@+id/btn_login"
android:text="登录" />
然后在对应 Activity 中搜这个 ID 的引用。
第二步:定位点击事件
常见代码可能是:
findViewById(R.id.btn_login).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
doLogin();
}
});
或者 Kotlin 风格:
this.binding.btnLogin.setOnClickListener(new View.OnClickListener() {
@Override
public final void onClick(View view) {
LoginActivity.this.login();
}
});
这时就可以顺着 doLogin() / login() 往下跟。
第三步:找“阻断分支”
重点看这些关键词对应的逻辑形态,而不是只搜字符串:
if (!xxx()) return;TextUtils.isEmpty(...)toast(...)showError(...)isSuccess()code == 200token != null
一个非常典型的片段如下:
private void doLogin() {
String phone = this.etPhone.getText().toString();
String pwd = this.etPassword.getText().toString();
if (!checkInput(phone, pwd)) {
Toast.makeText(this, "输入不合法", 0).show();
return;
}
this.presenter.login(phone, pwd);
}
如果你已经看到这种结构,基本就能判定:
checkInput() 是第一个关键点。
实战代码(可运行)
下面用 Frida 演示三种常见打法。为了方便理解,我假设目标类如下:
com.demo.app.ui.LoginActivitycom.demo.app.auth.LoginCheckercom.demo.app.net.model.LoginResponse
你拿到真实 APK 时,把类名替换成实际名称即可。
方案一:Hook 本地输入校验,强制返回 true
适用场景:
- 已用 JADX 找到类似
checkInput()/validate()方法 - 方法返回
boolean - 登录被本地格式校验卡住
Java.perform(function () {
var LoginChecker = Java.use("com.demo.app.auth.LoginChecker");
LoginChecker.checkInput.overload("java.lang.String", "java.lang.String").implementation = function (phone, pwd) {
console.log("[*] checkInput called");
console.log(" phone = " + phone);
console.log(" pwd = " + pwd);
var original = this.checkInput(phone, pwd);
console.log(" original return = " + original);
console.log(" bypass -> return true");
return true;
};
});
运行方式:
frida -U -f com.demo.app -l bypass_check.js --no-pause
如果你的 Frida 版本不支持 --no-pause,可改为:
frida -U -f com.demo.app -l bypass_check.js
方案二:Hook 登录按钮对应方法,观察真实调用链
有时你并不确定究竟是哪个方法在拦截,这时先打印调用参数更稳。
Java.perform(function () {
var LoginActivity = Java.use("com.demo.app.ui.LoginActivity");
LoginActivity.doLogin.implementation = function () {
console.log("[*] LoginActivity.doLogin called");
var stack = Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Exception").$new()
);
console.log(stack);
return this.doLogin();
};
});
这个脚本不做绕过,只做观察。
我个人很常先跑这类“无害脚本”,因为它能帮你确认:
- 点按钮后是不是走到了你以为的那个方法
- 有没有中间代理层
- 是否有多个同名方法
方案三:Hook 服务端响应判定,伪造登录成功
有些 App 前端输入校验不严,但会在响应解析后再做一轮判定。
如果你已经确认卡在响应结果上,可以先观察响应对象,再决定怎么改。
假设代码类似:
if (resp.getCode() == 200 && resp.getData() != null && resp.getData().getToken() != null) {
return true;
}
return false;
那么可以 hook 这个判定方法:
Java.perform(function () {
var LoginActivity = Java.use("com.demo.app.ui.LoginActivity");
LoginActivity.isLoginSuccess.overload("com.demo.app.net.model.LoginResponse").implementation = function (resp) {
console.log("[*] isLoginSuccess called: " + resp);
var ret = this.isLoginSuccess(resp);
console.log(" original return = " + ret);
console.log(" bypass -> return true");
return true;
};
});
一个更稳的做法:先枚举重载再 hook
很多同学在 Frida 里最容易踩的坑就是:
方法名找到了,但参数签名不对。
下面这个脚本可以列出目标方法有哪些重载:
Java.perform(function () {
var clazz = Java.use("com.demo.app.auth.LoginChecker");
var methods = clazz.class.getDeclaredMethods();
for (var i = 0; i < methods.length; i++) {
console.log(methods[i].toString());
}
});
如果你看到类似:
boolean checkInput(java.lang.String,java.lang.String)boolean checkInput(java.lang.String,java.lang.String,boolean)
那就要按精确签名来 hook。
逐步验证清单
这一段很重要。很多“hook 了但没效果”的问题,本质上是验证步骤不完整。
建议按下面顺序确认:
- 确认进程附加正确
- 确认脚本已加载
- 确认按钮点击后目标方法真的被调用
- 确认 hook 的是正确重载
- 确认返回值被改后,控制流确实变化
- 确认不是后续还有第二层校验
- 确认登录后必要状态被初始化
可以把这个流程理解成“分层排除”。
用状态图看登录流程里的分叉
stateDiagram-v2
[*] --> Idle
Idle --> InputChecking: 点击登录
InputChecking --> InputFailed: 本地校验失败
InputChecking --> Requesting: 本地校验通过
Requesting --> ResponseChecking: 收到响应
ResponseChecking --> LoginSuccess: 响应判定通过
ResponseChecking --> LoginFailed: 响应判定失败
InputFailed --> Idle
LoginFailed --> Idle
LoginSuccess --> [*]
这个图能提醒你一个核心事实:
不要把“登录失败”都归因于同一个点。
它可能发生在输入前、请求前、响应后任意一层。
常见坑与排查
这一部分我尽量写得接地气一点,因为这些坑我自己都踩过。
1. 类名对了,方法就是 hook 不到
常见原因:
- 实际是 Kotlin 生成的方法名
- 方法被混淆
- 在父类或内部类里
- 有重载,签名不匹配
排查建议:
Java.perform(function () {
var c = Java.use("com.demo.app.auth.LoginChecker");
console.log(c.class.toString());
});
再配合 getDeclaredMethods() 打印实际方法列表。
2. hook 生效了,但界面还是没进入首页
这通常说明你绕过的是第一层校验,但不是最终决定点。
比如:
checkInput()被你改成true- 但网络响应判定仍然失败
- 或者 token 没保存,首页检查登录态时又把你踢回登录页
排查建议:
- 继续 hook
saveLoginState()、gotoHome()、isLoginSuccess() - 观察 SharedPreferences 写入
- 观察 token 是否为空
3. App 一启动就闪退
常见原因:
- 脚本在类加载前后时机不对
- 方法递归调用写错了
- 返回值类型不匹配
- 目标 App 有基础反调试或反 Frida
特别是这类错误很常见:
LoginChecker.checkInput.implementation = function (a, b) {
return this.checkInput(a, b);
};
如果你 hook 后又直接调用同一个替换后的方法,就会递归爆掉。
正确方式是使用指定重载,并在某些场景下先保存原始引用,或者确认当前写法不会递归到替换体。
更稳一点的写法:
Java.perform(function () {
var LoginChecker = Java.use("com.demo.app.auth.LoginChecker");
var target = LoginChecker.checkInput.overload("java.lang.String", "java.lang.String");
target.implementation = function (phone, pwd) {
console.log("[*] checkInput: " + phone + " / " + pwd);
var ret = target.call(this, phone, pwd);
console.log("[*] original = " + ret);
return true;
};
});
4. 找到的代码和实际运行逻辑不一致
这在混淆、热修复、加固环境里非常常见。
原因可能是:
- 你看的不是最终加载 dex
- 有插件化框架
- 有动态下发逻辑
- 有反射调用
排查路径:
- 用 Frida 打印实际被调用的方法
- 关注
ClassLoader - 必要时配合
DexClassLoader相关 hook
5. hook 了返回 true,但后续报空指针
这说明“登录成功”不只是一个布尔值问题,可能还依赖这些状态:
- 用户对象已初始化
- token 已写入本地
- session 已生成
- 某个单例状态已刷新
这时别只盯着 if,要沿着成功分支继续看:
- 成功后调用了哪些方法?
- 哪些字段被赋值?
- 哪些本地存储被写入?
一个实用脚本:观察 SharedPreferences 登录态写入
很多登录成功最终会落到本地存储,这个点特别适合验证。
Java.perform(function () {
var SPImpl = Java.use("android.app.SharedPreferencesImpl$EditorImpl");
SPImpl.putString.overload("java.lang.String", "java.lang.String").implementation = function (key, value) {
console.log("[SP] putString => " + key + " = " + value);
return this.putString(key, value);
};
SPImpl.putBoolean.overload("java.lang.String", "boolean").implementation = function (key, value) {
console.log("[SP] putBoolean => " + key + " = " + value);
return this.putBoolean(key, value);
};
});
如果你在点击登录后看到类似:
token = xxxxxis_login = true
那基本能确认登录态是怎么落地的。
安全/性能最佳实践
这一节很关键,尤其是做企业安全测试或内部研究时,边界要清楚。
1. 仅在授权环境中测试
Frida 与逆向分析应仅用于:
- 自有 App 调试
- 经过授权的安全评估
- 教学与研究环境
不要把这类技术用于未授权目标,这是基本边界。
2. 优先做“观察性 hook”,少做“破坏性 hook”
建议顺序:
- 先打印参数
- 再打印返回值
- 最后才改返回值
这样你更容易知道自己改动了什么,也更容易回溯问题。
3. 最小化修改范围
比起直接 hook 整个登录函数,优先选择:
- 单个校验方法
- 单个响应判断方法
- 单个状态写入点
原因很简单:
- 更稳定
- 更容易解释
- 更不容易引入副作用
4. 避免高频 hook 影响性能
像下面这些高频调用点,除非必要,不建议长期打印:
TextUtils.isEmptyString.equals- 通用 JSON 解析方法
- 基础网络库底层方法
否则日志会爆炸,App 也可能明显变卡。
更好的办法是:
- 只 hook 业务类
- 加条件过滤
- 在打印时限制关键参数
例如:
Java.perform(function () {
var Checker = Java.use("com.demo.app.auth.LoginChecker");
var target = Checker.checkInput.overload("java.lang.String", "java.lang.String");
target.implementation = function (phone, pwd) {
if (phone && phone.length() > 0) {
console.log("[*] target user input = " + phone);
}
return target.call(this, phone, pwd);
};
});
5. 对抗混淆时,先抓行为,再还原语义
我自己的经验是:
遇到严重混淆时,不要一开始就执着于把每个类名“翻译成人话”。
更有效的方法是:
- 找按钮事件
- 找关键 Toast
- 找网络请求前后
- 找 SharedPreferences 写入
- 找页面跳转
先把行为链打通,再慢慢补语义。
一个完整的实战流程模板
如果你之后自己分析别的 APK,可以直接套这个模板。
步骤 1:从资源和 Activity 找登录页
- 查
AndroidManifest.xml - 查布局中登录按钮 ID
- 查点击事件绑定
步骤 2:顺调用链找本地校验
doLogin()checkInput()validate()canLogin()
步骤 3:Frida 验证本地校验
- 打印参数
- 打印返回值
- 临时改成
true
步骤 4:若仍失败,分析网络响应判定
- 查
onSuccess() - 查
isLoginSuccess() - 查
resp.code - 查
token是否为空
步骤 5:验证登录态持久化
- hook
SharedPreferences - 查数据库 / 文件写入
- 查单例用户对象
步骤 6:确认后续页面是否依赖完整上下文
- 是否必须有 token
- 是否必须有用户信息对象
- 是否有首页二次校验
总结
这类 Android 登录逻辑分析,最怕两种情况:
- 只看静态代码,不验证运行时行为
- 一上来就粗暴改逻辑,结果后面全崩
更稳的套路其实很清晰:
- JADX 静态定位入口和关键分支
- Frida 动态确认参数、返回值与真实执行路径
- 优先绕过最小校验点
- 继续验证登录态是否完整落地
如果你只记住一个结论,我建议是这句:
登录绕过不是“让某个 if 变 true”这么简单,而是要定位“哪个状态真正决定了应用已登录”。
在真实项目里,这个状态可能是:
- 一个布尔返回值
- 一个 token
- 一次 SharedPreferences 写入
- 一个内存中的用户对象
- 一次页面跳转前的二次校验
所以,做分析时尽量沿着“状态流”去看,而不是只沿着“函数名”去找。
如果你已经能跑通本文里的脚本,下一步建议你自己找一个测试 APK,按下面顺序练一遍:
- 先找登录按钮点击事件
- 再找本地输入校验
- 再找响应成功判定
- 最后找登录态写入点
把这一套走顺了,后面分析注册、支付前校验、会员态判断,思路都是相通的。