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

《安卓逆向实战:使用 Frida 定位并绕过常见 Root 检测逻辑的完整方法》

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

安卓逆向实战:使用 Frida 定位并绕过常见 Root 检测逻辑的完整方法

很多 Android 应用,尤其是金融、风控、游戏和企业安全类 App,上来第一件事就是检查设备有没有 Root。一旦命中,它可能直接闪退、拒绝登录,或者静默降级某些功能。

如果你只是停留在“把 su 隐藏掉”这一层,通常很快就会发现:不够。因为现代 App 的 Root 检测往往是组合拳——查文件、查命令、查系统属性、查挂载信息、查安装包,甚至 JNI/native 层一起上。

这篇文章我不打算只讲“某个 hook 点怎么写”,而是带你从定位检测逻辑、到建立绕过思路、再到写出可运行的 Frida 脚本,完整走一遍。你看完后,应该能独立处理大多数 Java 层 Root 检测,遇到 native 检测也知道往哪里继续挖。

说明:本文内容用于安全研究、应用自测与授权测试场景。请勿用于未授权目标。


背景与问题

Root 检测本质上是在回答一个问题:当前设备环境是否可信

常见检测点包括:

  • 是否存在 subusyboxmagisk 等文件
  • 是否能执行 which suidgetprop 等命令
  • 系统属性是否异常,如 ro.debuggable=1
  • build tags 是否包含 test-keys
  • 某些 Root 管理器或框架包是否已安装
  • SELinux、挂载点、/proc 信息是否异常
  • native 层通过 access/stat/fopen/system 等接口进一步检查

这些逻辑有个典型特点:分散。你打开 APK,搜索一圈可能只能看到一个 isDeviceRooted(),但点进去后往往又是层层调用,甚至混淆得比较严重。

所以,真正高效的方法不是死抠静态代码,而是:

  1. 先动态定位
  2. 确认命中的检测分支
  3. 最小化绕过
  4. 逐步扩展到更底层

前置知识与环境准备

这篇文章默认你已经具备这些基础:

  • 会用 adb
  • 知道 APK 反编译的基本流程
  • 用过 Frida 基础注入方式
  • 了解 Java 层 hook 的基本语法

环境

  • Android 测试机或模拟器
  • Python 3
  • Frida / frida-tools
  • adb
  • 可选:jadx 用于辅助静态查看

安装 Frida 工具:

pip install frida frida-tools

确认设备连接:

adb devices
frida-ps -U

如果目标 App 包名为 com.demo.target,常见启动方式:

frida -U -f com.demo.target -l root_bypass.js --no-pause

我个人习惯是优先用 -f 冷启动,因为很多 Root 检测在 Application 或首屏之前就执行了,晚附加常常错过关键路径。


核心原理

Root 检测的“实现”很多,但“思路”并不复杂。大致可以分成四类:

1. 文件存在性检测

典型逻辑:

  • 检查 /system/xbin/su
  • 检查 /system/bin/su
  • 检查 /sbin/su
  • 检查 Magisk 路径
  • 检查 BusyBox 路径

Java 层常见调用:

  • java.io.File.exists()
  • java.io.File.canExecute()

native 层常见调用:

  • access
  • stat
  • fopen

2. 命令执行检测

App 直接执行命令并读取结果:

  • which su
  • su -c id
  • getprop
  • mount
  • id

Java 层常见调用:

  • Runtime.getRuntime().exec(...)
  • ProcessBuilder.start()

native 层常见调用:

  • system
  • popen
  • execve

3. 系统属性与构建信息检测

检测是否为调试环境或测试签名环境:

  • ro.debuggable
  • ro.secure
  • ro.build.tags
  • Build.TAGS

Java 层常见入口:

  • android.os.SystemProperties.get()
  • android.os.Build.TAGS

4. 包名与环境特征检测

例如:

  • 检查是否安装 Magisk Manager
  • 检查 SuperSU、KingRoot 等
  • 检查 Xposed、Substrate 等痕迹

Java 层常见入口:

  • PackageManager.getPackageInfo()
  • getInstalledPackages()

一张图看懂定位思路

flowchart TD
    A[启动目标 App] --> B[Frida 冷启动注入]
    B --> C[优先监控 Java 层关键 API]
    C --> D{是否命中可疑调用}
    D -- 是 --> E[打印参数与调用栈]
    E --> F[确认 Root 检测逻辑]
    F --> G[最小化返回值篡改]
    G --> H{是否仍被拦截}
    H -- 是 --> I[扩展到 native 层监控]
    H -- 否 --> J[完成绕过]

这个流程的关键点是:先观察,再动刀。很多人一上来就全局 hook 一堆 API,结果日志爆炸、性能下降、App 还更容易崩。


定位 Root 检测逻辑的实战思路

第一步:先扫常见 Java 检测点

建议优先监控这些方法:

  • File.exists
  • Runtime.exec
  • ProcessBuilder.start
  • SystemProperties.get
  • PackageManager.getPackageInfo

原因很简单:大多数 App 的 Root 检测,至少会经过其中一个。

第二步:打印可疑参数

比如:

  • 路径中包含 subusyboxmagisk
  • 命令中包含 getpropmountwhich su
  • 包名中包含 magisksupersu

第三步:必要时打印调用栈

只打印参数有时不够,因为真正的业务逻辑入口可能在混淆类中。调用栈能帮你快速定位:

  • 哪个类在发起检测
  • 检测在哪个生命周期执行
  • 哪个分支导致退出

实战代码:先做“定位版”脚本

这份脚本的目标不是立即绕过,而是先找出谁在检测。建议先跑它,看日志,再决定最小化 hook 方案。

Java.perform(function () {
    var File = Java.use('java.io.File');
    var Runtime = Java.use('java.lang.Runtime');
    var ProcessBuilder = Java.use('java.lang.ProcessBuilder');
    var SystemProperties = Java.use('android.os.SystemProperties');
    var PackageManager = Java.use('android.app.ApplicationPackageManager');
    var Exception = Java.use('java.lang.Exception');
    var Log = Java.use('android.util.Log');

    function printStack(tag) {
        try {
            var stack = Log.getStackTraceString(Exception.$new());
            console.log('\n[' + tag + '] Stack:\n' + stack);
        } catch (e) {
            console.log('printStack error: ' + e);
        }
    }

    File.exists.implementation = function () {
        var path = this.getAbsolutePath();
        if (path && (
            path.indexOf('su') !== -1 ||
            path.indexOf('busybox') !== -1 ||
            path.toLowerCase().indexOf('magisk') !== -1
        )) {
            console.log('[File.exists] ' + path);
            printStack('File.exists');
        }
        return this.exists();
    };

    Runtime.exec.overload('java.lang.String').implementation = function (cmd) {
        console.log('[Runtime.exec] ' + cmd);
        if (
            cmd.indexOf('getprop') !== -1 ||
            cmd.indexOf('mount') !== -1 ||
            cmd.indexOf('which su') !== -1 ||
            cmd.indexOf('su') !== -1
        ) {
            printStack('Runtime.exec');
        }
        return this.exec(cmd);
    };

    Runtime.exec.overload('[Ljava.lang.String;').implementation = function (cmdArray) {
        var arr = [];
        for (var i = 0; i < cmdArray.length; i++) {
            arr.push(cmdArray[i]);
        }
        var cmd = arr.join(' ');
        console.log('[Runtime.exec array] ' + cmd);
        if (
            cmd.indexOf('getprop') !== -1 ||
            cmd.indexOf('mount') !== -1 ||
            cmd.indexOf('which su') !== -1 ||
            cmd.indexOf('su') !== -1
        ) {
            printStack('Runtime.exec array');
        }
        return this.exec(cmdArray);
    };

    ProcessBuilder.start.implementation = function () {
        try {
            var list = this.command();
            var cmd = [];
            for (var i = 0; i < list.size(); i++) {
                cmd.push(list.get(i).toString());
            }
            var finalCmd = cmd.join(' ');
            console.log('[ProcessBuilder.start] ' + finalCmd);
            if (
                finalCmd.indexOf('getprop') !== -1 ||
                finalCmd.indexOf('mount') !== -1 ||
                finalCmd.indexOf('which') !== -1 ||
                finalCmd.indexOf('su') !== -1
            ) {
                printStack('ProcessBuilder.start');
            }
        } catch (e) {
            console.log('ProcessBuilder parse error: ' + e);
        }
        return this.start();
    };

    SystemProperties.get.overload('java.lang.String').implementation = function (key) {
        var value = this.get(key);
        if (
            key.indexOf('ro.debuggable') !== -1 ||
            key.indexOf('ro.secure') !== -1 ||
            key.indexOf('ro.build.tags') !== -1
        ) {
            console.log('[SystemProperties.get] ' + key + ' => ' + value);
            printStack('SystemProperties.get');
        }
        return value;
    };

    PackageManager.getPackageInfo.overload('java.lang.String', 'int').implementation = function (pkg, flag) {
        if (
            pkg.toLowerCase().indexOf('magisk') !== -1 ||
            pkg.toLowerCase().indexOf('supersu') !== -1 ||
            pkg.toLowerCase().indexOf('kingroot') !== -1
        ) {
            console.log('[PackageManager.getPackageInfo] ' + pkg);
            printStack('PackageManager.getPackageInfo');
        }
        return this.getPackageInfo(pkg, flag);
    };

    console.log('[*] Root detection tracer loaded');
});

运行方式

frida -U -f com.demo.target -l trace_root.js --no-pause

你应该观察什么

重点看这些日志:

  • 哪个 API 最先被调用
  • 参数具体是什么
  • 调用栈中是否有明显的自定义类,如:
    • com.xxx.security.RootCheck
    • com.xxx.guard.EnvVerifier
    • 混淆类如 a.b.c.a

一旦定位到真正的检测类,后面有两种策略:

  1. 直接 hook 检测函数返回 false
  2. 从底层 API 篡改检测结果

我一般优先选 2,因为更通用,也不容易漏掉多处调用。


实战代码:通用 Root 检测绕过脚本

下面这份脚本是一个比较实用的 Java 层通用版,覆盖:

  • 文件检测
  • 命令执行
  • 系统属性
  • Build.TAGS
  • 包名检测

它不是“万能脚本”,但足够应对很多常见场景。

Java.perform(function () {
    var File = Java.use('java.io.File');
    var Runtime = Java.use('java.lang.Runtime');
    var String = Java.use('java.lang.String');
    var ProcessBuilder = Java.use('java.lang.ProcessBuilder');
    var SystemProperties = Java.use('android.os.SystemProperties');
    var Build = Java.use('android.os.Build');
    var PackageManager = Java.use('android.app.ApplicationPackageManager');

    var fakeRootFiles = [
        '/system/bin/su',
        '/system/xbin/su',
        '/sbin/su',
        '/system/sd/xbin/su',
        '/system/bin/failsafe/su',
        '/data/local/xbin/su',
        '/data/local/bin/su',
        '/data/local/su',
        '/su/bin/su'
    ];

    var rootPackages = [
        'com.topjohnwu.magisk',
        'eu.chainfire.supersu',
        'com.koushikdutta.superuser',
        'com.thirdparty.superuser',
        'com.kingroot.kinguser'
    ];

    function containsKeyword(str) {
        if (!str) return false;
        str = str.toLowerCase();
        return str.indexOf('su') !== -1 ||
               str.indexOf('busybox') !== -1 ||
               str.indexOf('magisk') !== -1 ||
               str.indexOf('getprop') !== -1 ||
               str.indexOf('mount') !== -1 ||
               str.indexOf('id') !== -1;
    }

    File.exists.implementation = function () {
        var path = this.getAbsolutePath();
        for (var i = 0; i < fakeRootFiles.length; i++) {
            if (path === fakeRootFiles[i]) {
                console.log('[Bypass File.exists] ' + path + ' => false');
                return false;
            }
        }

        if (path && (
            path.toLowerCase().indexOf('magisk') !== -1 ||
            path.toLowerCase().indexOf('busybox') !== -1
        )) {
            console.log('[Bypass File.exists] ' + path + ' => false');
            return false;
        }

        return this.exists();
    };

    Runtime.exec.overload('java.lang.String').implementation = function (cmd) {
        if (containsKeyword(cmd)) {
            console.log('[Bypass Runtime.exec] ' + cmd);
            return this.exec('grep');
        }
        return this.exec(cmd);
    };

    Runtime.exec.overload('[Ljava.lang.String;').implementation = function (cmdArray) {
        var arr = [];
        for (var i = 0; i < cmdArray.length; i++) {
            arr.push(cmdArray[i]);
        }
        var cmd = arr.join(' ');
        if (containsKeyword(cmd)) {
            console.log('[Bypass Runtime.exec array] ' + cmd);
            var fakeCmd = Java.array('java.lang.String', ['grep']);
            return this.exec(fakeCmd);
        }
        return this.exec(cmdArray);
    };

    ProcessBuilder.start.implementation = function () {
        try {
            var cmdList = this.command();
            var arr = [];
            for (var i = 0; i < cmdList.size(); i++) {
                arr.push(cmdList.get(i).toString());
            }
            var cmd = arr.join(' ');
            if (containsKeyword(cmd)) {
                console.log('[Bypass ProcessBuilder.start] ' + cmd);
                this.command(Java.array('java.lang.String', ['grep']));
            }
        } catch (e) {
            console.log('ProcessBuilder hook error: ' + e);
        }
        return this.start();
    };

    SystemProperties.get.overload('java.lang.String').implementation = function (key) {
        if (key === 'ro.debuggable') {
            console.log('[Bypass SystemProperties] ro.debuggable => 0');
            return '0';
        }
        if (key === 'ro.secure') {
            console.log('[Bypass SystemProperties] ro.secure => 1');
            return '1';
        }
        if (key === 'ro.build.tags') {
            console.log('[Bypass SystemProperties] ro.build.tags => release-keys');
            return 'release-keys';
        }
        return this.get(key);
    };

    try {
        Build.TAGS.value = 'release-keys';
        console.log('[Bypass Build.TAGS] => release-keys');
    } catch (e) {
        console.log('Build.TAGS set failed: ' + e);
    }

    PackageManager.getPackageInfo.overload('java.lang.String', 'int').implementation = function (pkg, flag) {
        for (var i = 0; i < rootPackages.length; i++) {
            if (pkg === rootPackages[i]) {
                console.log('[Bypass PackageManager] ' + pkg + ' => NameNotFoundException');
                throw Java.use('android.content.pm.PackageManager$NameNotFoundException').$new(pkg);
            }
        }
        return this.getPackageInfo(pkg, flag);
    };

    console.log('[*] Root bypass script loaded');
});

绕过流程图

sequenceDiagram
    participant App as 目标App
    participant API as Java检测API
    participant Frida as Frida Hook
    participant Sys as 系统环境

    App->>API: File.exists("/system/xbin/su")
    API->>Frida: 进入 hook
    Frida-->>App: 返回 false

    App->>API: Runtime.exec("which su")
    API->>Frida: 进入 hook
    Frida-->>App: 替换为无害命令

    App->>API: SystemProperties.get("ro.debuggable")
    API->>Frida: 进入 hook
    Frida-->>App: 返回 "0"

    App->>API: getPackageInfo("com.topjohnwu.magisk")
    API->>Frida: 进入 hook
    Frida-->>App: 抛 NameNotFoundException

如果已经定位到具体检测函数,如何更稳地绕过

有些 App 会自己封装一个统一检测方法,比如:

  • RootCheckUtil.isRooted()
  • SecurityManager.checkEnv()
  • NativeBridge.detectRoot()

这时候,比起全局 hook API,更推荐直接改业务判断。因为这样:

  • 日志更少
  • 兼容性更好
  • 不容易影响 App 其他正常功能

例如你在调用栈里找到 com.demo.security.RootCheckUtil.isRooted()

Java.perform(function () {
    var RootCheckUtil = Java.use('com.demo.security.RootCheckUtil');

    RootCheckUtil.isRooted.implementation = function () {
        console.log('[Bypass custom root check] isRooted => false');
        return false;
    };
});

如果是返回整型或状态码,也按原签名改:

Java.perform(function () {
    var EnvCheck = Java.use('com.demo.security.EnvCheck');

    EnvCheck.check.implementation = function () {
        console.log('[Bypass EnvCheck.check] => 0');
        return 0;
    };
});

这类点对点 hook 往往是“最优解”。前提是你已经定位准确。


常见坑与排查

这一部分非常重要,因为很多人不是不会写 hook,而是脚本明明写了,却没生效

1. 注入时机太晚

现象:

  • App 一打开就闪退
  • 附加后没有任何 Root 检测日志
  • 关键函数根本没被命中

原因:

  • 检测发生在应用启动早期,如 Application.attach()onCreate(),甚至壳加载后第一时间

解决:

  • 使用冷启动模式 -f
  • 必要时 hook Application.attach
  • 在 attach 后再安装更多 hook

示例:

Java.perform(function () {
    var Application = Java.use('android.app.Application');
    Application.attach.overload('android.content.Context').implementation = function (ctx) {
        console.log('[*] Application.attach');
        this.attach(ctx);
        console.log('[*] Context ready: ' + ctx);
    };
});

2. Hook 重载不完整

现象:

  • 明明 hook 了 Runtime.exec(String),但还是被检测到

原因:

  • 目标实际走的是:
    • exec(String[])
    • exec(String, String[])
    • exec(String[], String[])
    • exec(String[], String[], File)

解决:

  • 枚举并 hook 所有关键重载

查看重载:

Java.perform(function () {
    var Runtime = Java.use('java.lang.Runtime');
    Runtime.exec.overloads.forEach(function (o) {
        console.log(o);
    });
});

3. App 在 native 层做检测

现象:

  • Java 层都绕过了,App 还是提示 Root
  • 日志里看不到可疑 Java 调用

原因:

  • 检测发生在 so 中,例如直接调用 access("/system/xbin/su")

解决:

  • 继续 hook libc 层,如 accessstatfopensystem

示例:

function hookNativeRootCheck() {
    var accessPtr = Module.findExportByName(null, 'access');
    if (accessPtr) {
        Interceptor.attach(accessPtr, {
            onEnter: function (args) {
                this.path = Memory.readCString(args[0]);
            },
            onLeave: function (retval) {
                if (this.path &&
                    (this.path.indexOf('/su') !== -1 ||
                     this.path.toLowerCase().indexOf('magisk') !== -1)) {
                    console.log('[native access] ' + this.path + ' => fake not found');
                    retval.replace(-1);
                }
            }
        });
    }
}

setImmediate(hookNativeRootCheck);

4. 替换命令导致业务异常

现象:

  • App 不再提示 Root,但某些页面卡死
  • 进程行为异常

原因:

  • 你把 Runtime.exec() 统一替换成了不兼容命令,目标代码还在等输出流/错误码

解决:

  • 不要过度粗暴替换
  • 只拦截明确的 Root 检测命令
  • 必要时伪造合理输出,而不是简单改成 grep

5. 混淆导致难以定位类名

现象:

  • 调用栈全是 a.a.a.a
  • 看不出哪个方法是主入口

解决:

  • 结合静态反编译看调用链
  • 看参数特征,而不是只看类名
  • 在关键调用点打印完整栈并交叉验证

6. 多进程 App 漏进程

现象:

  • 主进程 hook 有效,但功能页仍提示 Root

原因:

  • 某个组件运行在独立进程中

解决:

先列出进程:

frida-ps -Uai

再针对具体进程注入,或使用 spawn/follow 模式。


逐步验证清单

我平时做这类题,基本都会按这个顺序验证,能明显减少“脚本一大坨但不知道哪里失效”的情况。

验证 1:脚本是否真的加载

console.log('[*] script loaded');

验证 2:目标 API 是否被命中

先不要改返回值,只打印日志。

验证 3:命中的是不是 Root 相关参数

看路径、命令、属性键、包名是否真相关。

验证 4:最小化篡改是否足够

只改一个点,比如只改 File.exists("/system/xbin/su")

验证 5:是否存在第二套检测逻辑

如果改掉一个点后仍失败,说明还有别的分支在生效。

验证 6:是否进入 native 层

Java 层无果时,不要反复调 Java hook,及时下探。


安全/性能最佳实践

动态 hook 很方便,但也很容易写成“大网兜”脚本。下面这些建议很实用。

1. 优先最小化 hook 面

不要一开始就把所有文件访问、所有命令执行都改掉。这样做有两个问题:

  • 容易影响 App 正常逻辑
  • 日志量巨大,排查反而更慢

更好的方式是:

  • 先 trace
  • 锁定目标
  • 再做定向 bypass

2. 参数过滤必须做严

例如 File.exists() 被调用频率很高,如果你每次都打印栈,App 会明显卡顿。应只对这些关键字处理:

  • su
  • magisk
  • busybox

3. 调用栈打印只在定位阶段开启

调用栈很有用,但非常重。我的建议是:

  • 第一轮定位开
  • 确认关键路径后关掉
  • 最终版脚本只保留必要日志

4. Java 层失败时,及时切 native

不要执着于“我一定要在 Java 层绕过”。现在不少 App 已经把关键检查下沉到 so,甚至 Java 层只是个壳。

5. 区分“研究脚本”和“稳定脚本”

两类脚本应该分开维护:

  • 研究脚本:日志多、覆盖广、方便定位
  • 稳定脚本:hook 少、动作准、尽量不影响业务流程

6. 明确边界条件

有些检测并不是单纯 Root 检测,而是和:

  • 模拟器检测
  • 调试器检测
  • Frida/Xposed 检测
  • 签名校验
  • 完整性校验

混在一起。此时即使你绕过了 Root 检测,App 仍可能失败。要避免误判“脚本没生效”。


一张图看 Java 与 Native 的分层关系

classDiagram
    class AppLogic {
        +isRooted()
        +checkEnv()
    }

    class JavaAPI {
        +File.exists()
        +Runtime.exec()
        +SystemProperties.get()
        +PackageManager.getPackageInfo()
    }

    class NativeLib {
        +access()
        +stat()
        +fopen()
        +system()
    }

    AppLogic --> JavaAPI
    AppLogic --> NativeLib

这张图想表达的是:App 逻辑可能同时依赖 Java 层和 native 层检测。所以遇到“Java hook 都做了还不行”的情况,不一定是你写错了,而是层级还不够深。


一个更贴近实战的策略组合

如果你面对的是一个中等复杂度目标,我建议按这个顺序来:

阶段一:侦察

  • hook File.exists
  • hook Runtime.exec
  • hook SystemProperties.get
  • 打印调用栈

目标:找出检测入口与参数模式。

阶段二:定向绕过

  • 先 hook 业务方法,例如 isRooted()
  • 若不稳定,再 hook 底层 API
  • 只改命中的关键参数

目标:尽快让 App 过检测。

阶段三:补漏

  • 检查是否有 native 检测
  • 检查是否有多进程
  • 检查是否混入 Frida/Xposed 检测

目标:把“偶尔成功”变成“稳定成功”。


总结

Root 检测绕过,真正重要的不是背几个现成 hook 点,而是建立一套稳定的方法论:

  1. 冷启动注入,抢在检测前
  2. 先追踪,再修改
  3. 优先 Java 层关键 API 定位
  4. 找到业务检测函数就直接下手
  5. Java 无果时及时转 native
  6. 最终脚本尽量最小化、定向化

如果你是中级阶段,我特别建议你把文章里的思路拆成两套脚本来练:

  • 一套 trace_root.js:只负责观察
  • 一套 bypass_root.js:只负责绕过

这样你会比“上来就复制一份万能脚本”进步快很多。我自己早期踩过最大的坑,就是试图用一份脚本解决所有问题,最后日志乱、逻辑乱、定位也慢。后来改成“先定位、后收敛”,效率高了不少。

最后再强调一次边界:本文覆盖的是常见 Java 层 Root 检测及其向 native 扩展的定位方式。如果目标还叠加了 Frida 检测、反调试、完整性校验或壳保护,你还需要继续补对应对抗点。但至少在 Root 检测这件事上,你现在已经有一套能真正落地的方法了。


分享到:

上一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-144》
下一篇
《Java 中基于 CompletableFuture 与线程池的异步任务编排实战与性能优化-309》