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

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

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

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

声明:本文内容仅用于安全测试、应用加固验证、教学研究与授权场景。不要将文中方法用于未授权目标。逆向和动态插桩都很强,强也意味着要谨慎。

很多人学安卓逆向时,最容易卡住的并不是“工具不会用”,而是知道有校验,但不知道该从哪里切进去。登录逻辑尤其典型:Java 层有输入检查、接口层有签名、Native 层可能再补一刀,最后还夹杂着混淆、反调试和多线程。

这篇文章我不想只堆命令,而是带你按一条更接近实战的路径走一遍:

  1. 先用 JADX 做静态定位,找出登录入口和关键判断点。
  2. 再用 Frida 做动态验证,确认谁在真正决定“登录成功/失败”。
  3. 最后演示如何在授权测试环境下,绕过常见的客户端登录校验逻辑,并验证效果。

文章面向中级读者:默认你已经知道 APK、ADB、基本 Java/Kotlin 语法,但未必已经形成一套稳定的逆向排查方法。


背景与问题

在安卓 App 里,“登录失败”未必意味着服务端拒绝了你。很多时候,失败发生在更前面的客户端逻辑里,比如:

  • 用户名或密码格式不合法,直接 return
  • 本地 token 判空失败
  • 客户端时间戳、签名、设备指纹检查不通过
  • 某个 isVip()isDebuggable()isRooted() 之类的分支拦截了流程
  • 接口其实返回成功了,但 UI 层又做了二次判断

这类问题如果只抓包,常常会误判;如果只看代码,也容易被混淆和调用链绕晕。JADX + Frida 的组合好用就在于:

  • JADX:适合回答“可能在哪”
  • Frida:适合回答“到底是不是这里”

我自己踩过一个坑:明明抓包看到服务端返回了 {"code":0,"msg":"ok"},但页面就是提示“登录异常”。最后发现并不是接口失败,而是客户端在收到响应后,还检查了一个 data.userStatus == 1,否则本地直接 toast 失败。这个时候静态看一眼流程图,再用 Frida 验一下返回值,定位就非常快。


前置知识与环境准备

你需要准备什么

  • 一台安卓测试机或模拟器
  • adb
  • jadx-guijadx
  • frida-tools
  • objection(可选)
  • 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 字段,如 tokencodestatus
  • 搜索点击事件: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.check
  • DeviceUtil.isRooted
  • DeviceUtil.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]

经验上,我会按这个顺序验证:

  1. 先验证 UI 层:本地有没有提前拦截?
  2. 再验证环境层:是否被 Root/模拟器检测卡住?
  3. 再验证响应层:服务端成功后是否被客户端二次判断卡住?
  4. 最后才考虑 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 是否为 0
  • userStatus 是否不是 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 这些高频点:

  • isRooted
  • isEmulator
  • isDebuggable
  • checkSignature
  • checkXposed
  • checkFrida

第 3 步:验证响应层字段

重点盯:

  • code
  • status
  • success
  • token
  • role
  • userStatus
  • needBindPhone
  • needRealName

第 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:

  • getCode
  • getUserStatus
  • saveToken
  • isLogin
  • startActivity

把整条链路补齐。


6. 混淆后类名难找

JADX 里如果满屏都是 a.a.a.a,我一般会这样找:

  1. 搜字符串常量:"登录失败""token""/login"
  2. 搜布局 ID 对应的点击逻辑
  3. 搜 Retrofit 接口路径
  4. AndroidManifest.xml 找登录 Activity
  5. 用运行时枚举类辅助确认

示例脚本:

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:客户端前置校验怀疑链

  1. 搜登录入口 Activity / Fragment
  2. 找按钮点击事件
  3. 追本地 check() / validate() / verify()
  4. Hook 环境检测函数
  5. 验证是否开始发请求

模板 B:响应后二次判断怀疑链

  1. 找登录接口回调
  2. code/status/success/token
  3. 找额外业务字段:userStatus/role/bindState
  4. Hook getter 观察真实值
  5. 定点改最小字段并验证页面流转

模板 C:登录态保存怀疑链

  1. saveToken
  2. 找 SharedPreferences key
  3. isLogin()
  4. 找启动页/首页跳转逻辑
  5. 同时验证持久化和页面状态

总结

这篇文章的重点其实不是“如何把登录改成功”,而是建立一套稳定的定位—验证—绕过方法:

  • JADX 负责把登录流程拆开,找出候选校验点
  • Frida 负责逐点验证,确认真正决定结果的函数
  • 绕过时尽量选择最小、最稳、最容易回退的修改点

如果你只记住三条可执行建议,我会建议你记这三条:

  1. 先找“谁拦截发请求”,再找“谁决定登录成功”
    不要一上来就全局 Hook。

  2. 先打印证据,再改返回值
    没有证据链的绕过,很容易误判。

  3. 区分客户端成功和服务端认证成功
    UI 过了,不代表鉴权真的过了。

最后再强调一次:本文方法只适用于授权测试、教学研究和防护验证。从防守角度看,如果你是开发或安全工程师,也可以反过来用这套方法检查自家 App:看看关键登录逻辑是不是过度放在客户端、是不是被轻易 Hook、是不是缺乏服务端强校验。

如果你的应用确实依赖客户端判断登录态,那它迟早会成为别人的练手题。把真正的认证决策放回服务端,永远比在客户端多写几个 if 更靠谱。


分享到:

上一篇
《Java 中线程池参数调优与任务队列选型实战:从业务吞吐到稳定性保障》
下一篇
《Java 开发踩坑实战:定位并修复 Spring Boot 项目中的循环依赖与 Bean 初始化异常》