跳转到内容
123xiao | 无名键客

《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑-206》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见登录校验逻辑

这篇文章我会用一个“中级读者能直接上手”的角度,带你把一次典型的 Android 登录校验分析流程完整走一遍:先用 JADX 静态找入口,再用 Frida 动态确认分支,最后只改最小逻辑完成验证
重点不是“暴力跳过”,而是学会一套可复用的方法。

背景与问题

在 Android 应用里,登录相关逻辑通常不会只有一个按钮点击事件那么简单。你经常会遇到这些情况:

  • 前端先做手机号、密码格式校验
  • 本地判断验证码、token、签名、时间戳
  • 请求前对参数做二次封装
  • 服务端返回后再走一层“是否登录成功”的布尔判断
  • 某些壳子或混淆会让代码阅读变得很难受

很多同学一开始逆向登录流程,会直接去搜“login”“check”“verify”。这当然能碰运气,但实际项目里,类名和方法名往往都被混淆成 a()b()c(),靠关键词常常不够。

更稳妥的路线通常是:

  1. 先从 UI 事件入手,定位点击登录按钮后的调用链
  2. 借助 JADX 看清分支条件
  3. 用 Frida 在运行时打印参数、返回值和调用栈
  4. 只 hook 关键判断点,验证“哪一步决定了登录成败”

这类分析过程,在测试自有应用、做安全评估、复现客户端薄弱校验时都非常常见。


前置知识

如果你已经能熟练安装 Frida、会 adb 基本命令,可以直接跳到实战部分。

建议你具备这些基础:

  • 会用 adb devicesadb shell
  • 知道 APK 反编译查看 Java 层代码
  • 能看懂 Java / Kotlin 的基本控制流
  • 了解 Android 常见组件:ActivityFragmentTextViewOnClickListener

环境准备

本文默认环境如下:

  • Android 测试机或模拟器
  • 已安装 frida-server,并与本机 Frida 版本一致
  • Python 3
  • jadx-gui
  • adb

安装校验:

adb devices
frida-ps -U

如果能列出设备与进程,说明基本环境没问题。


核心原理

这一类“登录校验绕过”本质上并不是魔法,它通常围绕三个关键点展开:

  1. 找到校验发生的位置
  2. 确定校验输入和返回结果
  3. 在最小影响范围内修改行为

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
  • 含有 loginsigninaccountuser 等资源文件
  • 布局 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 == 200
  • token != 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.LoginActivity
  • com.demo.app.auth.LoginChecker
  • com.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 了但没效果”的问题,本质上是验证步骤不完整。

建议按下面顺序确认:

  1. 确认进程附加正确
  2. 确认脚本已加载
  3. 确认按钮点击后目标方法真的被调用
  4. 确认 hook 的是正确重载
  5. 确认返回值被改后,控制流确实变化
  6. 确认不是后续还有第二层校验
  7. 确认登录后必要状态被初始化

可以把这个流程理解成“分层排除”。


用状态图看登录流程里的分叉

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 = xxxxx
  • is_login = true

那基本能确认登录态是怎么落地的。


安全/性能最佳实践

这一节很关键,尤其是做企业安全测试或内部研究时,边界要清楚。

1. 仅在授权环境中测试

Frida 与逆向分析应仅用于:

  • 自有 App 调试
  • 经过授权的安全评估
  • 教学与研究环境

不要把这类技术用于未授权目标,这是基本边界。


2. 优先做“观察性 hook”,少做“破坏性 hook”

建议顺序:

  1. 先打印参数
  2. 再打印返回值
  3. 最后才改返回值

这样你更容易知道自己改动了什么,也更容易回溯问题。


3. 最小化修改范围

比起直接 hook 整个登录函数,优先选择:

  • 单个校验方法
  • 单个响应判断方法
  • 单个状态写入点

原因很简单:

  • 更稳定
  • 更容易解释
  • 更不容易引入副作用

4. 避免高频 hook 影响性能

像下面这些高频调用点,除非必要,不建议长期打印:

  • TextUtils.isEmpty
  • String.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 登录逻辑分析,最怕两种情况:

  • 只看静态代码,不验证运行时行为
  • 一上来就粗暴改逻辑,结果后面全崩

更稳的套路其实很清晰:

  1. JADX 静态定位入口和关键分支
  2. Frida 动态确认参数、返回值与真实执行路径
  3. 优先绕过最小校验点
  4. 继续验证登录态是否完整落地

如果你只记住一个结论,我建议是这句:

登录绕过不是“让某个 if 变 true”这么简单,而是要定位“哪个状态真正决定了应用已登录”。

在真实项目里,这个状态可能是:

  • 一个布尔返回值
  • 一个 token
  • 一次 SharedPreferences 写入
  • 一个内存中的用户对象
  • 一次页面跳转前的二次校验

所以,做分析时尽量沿着“状态流”去看,而不是只沿着“函数名”去找。

如果你已经能跑通本文里的脚本,下一步建议你自己找一个测试 APK,按下面顺序练一遍:

  • 先找登录按钮点击事件
  • 再找本地输入校验
  • 再找响应成功判定
  • 最后找登录态写入点

把这一套走顺了,后面分析注册、支付前校验、会员态判断,思路都是相通的。


分享到:

上一篇
《Docker 镜像体积优化实战:多阶段构建、层缓存与构建提速方案》
下一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题》