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

《安卓逆向实战:基于 Frida 定位与绕过常见 Root 检测逻辑的完整分析路径》

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

背景与问题

在 Android 安全测试里,Root 检测几乎是最常见、也最容易把分析流程打断的一环。很多 App 一启动就闪退,或者直接弹一句“检测到设备风险,禁止运行”。这类逻辑如果不先处理掉,后面的抓包、接口分析、业务逻辑定位都会卡住。

我自己做动态分析时,最常遇到的不是“不会 Hook”,而是:

  • 不知道 Root 检测到底写在哪一层
  • 明明 Hook 了一个函数,App 还是继续报 Root
  • Native 和 Java 双层检测混在一起,定位路径越来越乱
  • 脚本一上就崩,分不清是 Hook 点错了,还是 App 有反调试

这篇文章不走“只贴一个通用脚本”的路线,而是按完整分析路径来讲:
先判断 Root 检测在哪一层,再做最小化定位,最后选择合适的绕过策略。

适合你已经会一点 Frida、会基本的 APK 分析,但还没把 Root 检测绕过这件事做顺手的阶段。


前置知识与环境准备

你需要知道的基础

开始之前,默认你已经具备这些能力:

  • 会安装 adb
  • 会用 frida-server 或者 spawn/attach 模式连上设备
  • 知道 Java 层 Hook 的基本写法
  • 对 Android 常见 Root 产物有概念,比如:
    • /system/xbin/su
    • magisk
    • busybox
    • test-keys
    • 可写的 system 分区
    • which sugetprop 等命令检测

推荐测试环境

  • Android 真机或模拟器
  • Frida 16+
  • jadx 用于静态分析
  • adb logcat 用于快速看异常
  • 可选:objectionr2frida

典型目标场景

一个 App 在启动时做以下事情之一:

  1. 读取 Build.TAGS 判断是否为 test-keys
  2. 检查 subusyboxmagisk 文件是否存在
  3. 执行 which su / getprop / mount
  4. 调用某个 isDeviceRooted()checkRoot() 方法
  5. 进一步进入 so 库,调用 native 函数检测

核心原理

Root 检测从实现角度看,通常分为四类:

  1. 文件痕迹检测
  2. 系统属性检测
  3. 命令执行检测
  4. Native/JNI 检测

真正做逆向时,不要把它想得太玄。你可以把它当成一个“信号收集器”:

  • 找文件
  • 读属性
  • 跑命令
  • 调 native

只要我们在这些“信号入口”上把结果改掉,很多检测就失效了。

Root 检测常见信号源

flowchart TD
    A[App启动] --> B[Java层检测]
    A --> C[Native层检测]
    B --> D[检查文件是否存在]
    B --> E[读取系统属性]
    B --> F[执行shell命令]
    B --> G[调用第三方Root检测SDK]
    C --> H[JNI封装]
    H --> I[access/stat/fopen]
    H --> J[system/popen]
    H --> K[读取build信息]

逆向时的正确思路

很多人一开始就想“有没有一把梭的绕过脚本”。当然有一些通用脚本能拦住大部分检测,但实战里更重要的是定位路径

更稳的流程是:

  1. 静态看入口
    • 用 jadx 搜索:
      • root
      • su
      • magisk
      • busybox
      • test-keys
      • which
      • getprop
  2. 动态看调用
    • Hook 文件访问、命令执行、可疑方法
  3. 确认最终判定点
    • 谁返回了 true
    • 谁触发了弹窗/退出
  4. 只改必要结果
    • 优先改布尔返回值
    • 再考虑改底层行为

这比“全局乱 Hook”更稳定,也更不容易把 App 搞崩。


分析路径:从入口到绕过

第一步:静态扫描,先猜检测类型

拿到 APK 后,先别急着跑脚本。我通常会先在 jadx 里搜这些关键字:

  • isRooted
  • checkRoot
  • RootBeer
  • test-keys
  • magisk
  • busybox
  • su
  • which su

如果你搜到了类似下面的方法:

public boolean isDeviceRooted() {
    return checkTestKeys() || checkSuExists() || checkBusybox() || checkMagisk();
}

那就很理想,直接盯这个返回值。

如果代码混淆严重,看不到这么直白的名字,也没关系。继续搜字符串常量,比如 /system/xbin/sutest-keys,通常仍然能抓到线索。

第二步:动态观察,先记录而不是立刻篡改

这一阶段目标不是马上绕过,而是知道 App 在查什么

我建议先 Hook 以下点位做日志:

  • java.io.File.exists
  • java.lang.Runtime.exec
  • java.lang.ProcessBuilder.start
  • android.os.SystemProperties.get
  • 可疑业务方法(如 a.a.a() 这种混淆方法)

这样可以快速判断:

  • 是 Java 检测为主,还是 Native 检测为主
  • 检测是否发生在启动早期
  • 哪些路径/命令是关键证据

第三步:确定最终判定点

日志出来后,要找“最后拍板”的那个点。

典型情况有两种:

情况 A:业务层布尔值判断

比如:

if (SecurityChecker.a()) {
    showRootDialog();
    finish();
}

这时最优解是直接 Hook SecurityChecker.a() 返回 false

情况 B:底层检测分散,没有明显总开关

比如 App 到处都在:

  • new File("/system/xbin/su").exists()
  • Runtime.getRuntime().exec("which su")

这时就需要底层拦截,把这些检测结果“洗掉”。


Mermaid:完整定位与绕过决策图

flowchart LR
    A[启动App] --> B{静态分析是否发现明显Root方法}
    B -- 是 --> C[Hook业务判定方法]
    C --> D[返回false验证]
    B -- 否 --> E[Hook文件/命令/属性调用做日志]
    E --> F{检测在Java层还是Native层}
    F -- Java --> G[拦截File.exists/Runtime.exec/SystemProperties.get]
    F -- Native --> H[拦截access/stat/fopen/system]
    G --> I[观察是否仍触发Root判定]
    H --> I
    I -- 否 --> J[整理最小绕过脚本]
    I -- 是 --> K[继续回溯最终判定点]

实战代码(可运行)

下面给一套可直接使用的 Frida 脚本。它的设计目标不是“绝对通杀”,而是帮助你完成:

  • 日志观察
  • 常见 Root 信号拦截
  • 逐步缩小问题范围

保存为 root_bypass.js

Java.perform(function () {
    console.log("[*] Root bypass script loaded");

    var suspiciousPaths = [
        "/system/bin/su",
        "/system/xbin/su",
        "/sbin/su",
        "/system/app/Superuser.apk",
        "/system/bin/.ext/su",
        "/system/usr/we-need-root/su",
        "/system/sd/xbin/su",
        "/system/etc/init.d/99SuperSUDaemon",
        "/dev/com.koushikdutta.superuser.daemon/",
        "/system/xbin/daemonsu",
        "/su/bin/su",
        "/system/bin/busybox",
        "/system/xbin/busybox",
        "magisk"
    ];

    function containsSuspiciousPath(path) {
        if (!path) return false;
        path = path.toString();
        for (var i = 0; i < suspiciousPaths.length; i++) {
            if (path.indexOf(suspiciousPaths[i]) !== -1) {
                return true;
            }
        }
        return false;
    }

    // 1. Hook File.exists
    try {
        var File = Java.use("java.io.File");
        File.exists.implementation = function () {
            var path = this.getAbsolutePath();
            if (containsSuspiciousPath(path)) {
                console.log("[File.exists] blocked => " + path);
                return false;
            }
            return this.exists();
        };
        console.log("[+] Hooked java.io.File.exists");
    } catch (e) {
        console.log("[-] File.exists hook failed: " + e);
    }

    // 2. Hook Runtime.exec overloads
    try {
        var Runtime = Java.use("java.lang.Runtime");

        function shouldBlockCommand(cmd) {
            if (!cmd) return false;
            var s = cmd.toString();
            var keywords = ["su", "busybox", "magisk", "which", "getprop", "mount", "id", "sh"];
            for (var i = 0; i < keywords.length; i++) {
                if (s.indexOf(keywords[i]) !== -1) {
                    return true;
                }
            }
            return false;
        }

        Runtime.exec.overload("java.lang.String").implementation = function (cmd) {
            console.log("[Runtime.exec] " + cmd);
            if (shouldBlockCommand(cmd)) {
                console.log("[Runtime.exec] blocked => " + cmd);
                return Runtime.getRuntime().exec("echo");
            }
            return this.exec(cmd);
        };

        Runtime.exec.overload("[Ljava.lang.String;").implementation = function (cmdArray) {
            var joined = "";
            for (var i = 0; i < cmdArray.length; i++) {
                joined += cmdArray[i] + " ";
            }
            console.log("[Runtime.exec array] " + joined);
            if (shouldBlockCommand(joined)) {
                console.log("[Runtime.exec array] blocked => " + joined);
                return Runtime.getRuntime().exec(["echo"]);
            }
            return this.exec(cmdArray);
        };

        console.log("[+] Hooked java.lang.Runtime.exec");
    } catch (e) {
        console.log("[-] Runtime.exec hook failed: " + e);
    }

    // 3. Hook ProcessBuilder.start
    try {
        var ProcessBuilder = Java.use("java.lang.ProcessBuilder");
        ProcessBuilder.start.implementation = function () {
            var cmdList = this.command();
            var joined = "";
            for (var i = 0; i < cmdList.size(); i++) {
                joined += cmdList.get(i).toString() + " ";
            }
            console.log("[ProcessBuilder.start] " + joined);
            if (joined.indexOf("su") !== -1 || joined.indexOf("getprop") !== -1 || joined.indexOf("mount") !== -1) {
                console.log("[ProcessBuilder.start] blocked => " + joined);
                this.command(Java.array("java.lang.String", ["echo"]));
            }
            return this.start();
        };
        console.log("[+] Hooked ProcessBuilder.start");
    } catch (e) {
        console.log("[-] ProcessBuilder.start hook failed: " + e);
    }

    // 4. Hook android.os.SystemProperties.get
    try {
        var SystemProperties = Java.use("android.os.SystemProperties");
        SystemProperties.get.overload("java.lang.String").implementation = function (key) {
            var value = this.get(key);
            console.log("[SystemProperties.get] " + key + " => " + value);

            if (key === "ro.build.tags") return "release-keys";
            if (key === "ro.debuggable") return "0";
            if (key === "ro.secure") return "1";

            return value;
        };

        SystemProperties.get.overload("java.lang.String", "java.lang.String").implementation = function (key, def) {
            var value = this.get(key, def);
            console.log("[SystemProperties.get] " + key + " => " + value);

            if (key === "ro.build.tags") return "release-keys";
            if (key === "ro.debuggable") return "0";
            if (key === "ro.secure") return "1";

            return value;
        };
        console.log("[+] Hooked SystemProperties.get");
    } catch (e) {
        console.log("[-] SystemProperties.get hook failed: " + e);
    }

    // 5. Hook String.contains for Magisk keyword (谨慎使用)
    try {
        var StringCls = Java.use("java.lang.String");
        StringCls.contains.implementation = function (name) {
            if (name && name.toString().toLowerCase().indexOf("magisk") !== -1) {
                console.log("[String.contains] blocked keyword => " + name);
                return false;
            }
            return this.contains(name);
        };
        console.log("[+] Hooked String.contains");
    } catch (e) {
        console.log("[-] String.contains hook failed: " + e);
    }
});

运行方式

frida -U -f com.target.app -l root_bypass.js

如果你想避免启动太早导致某些类没加载完成,可以先 attach:

frida -U com.target.app -l root_bypass.js

如果业务层有明确判定点,优先直接 Hook

这是我更推荐的方式:谁最终返回“已 Root”,就改谁。

假设你在 jadx 里找到了:

public static boolean isRooted(Context context)

那可以这样写:

Java.perform(function () {
    var Checker = Java.use("com.target.security.RootChecker");
    Checker.isRooted.overload("android.content.Context").implementation = function (ctx) {
        console.log("[RootChecker.isRooted] forced false");
        return false;
    };
});

如果是实例方法:

Java.perform(function () {
    var Checker = Java.use("com.target.security.RootChecker");
    Checker.checkRoot.implementation = function () {
        console.log("[checkRoot] forced false");
        return false;
    };
});

这种方式优点非常明显:

  • 对业务影响最小
  • 不容易误伤其他逻辑
  • 日志更清晰
  • 维护成本低

Native 层怎么办

如果你发现 Java 层都拦了,App 还是继续报 Root,那就要怀疑 Native 检测。

常见特征:

  • Java 中只有一个 native boolean detectRoot()
  • Runtime.execFile.exists 都没打到日志
  • App 启动时加载了某个安全 so
  • logcat 里出现 JNI 调用痕迹

这时可以先 Hook libc 的文件和命令相关函数。

Frida Native Hook 示例

function hookNativeRootChecks() {
    var accessPtr = Module.findExportByName("libc.so", "access");
    var fopenPtr = Module.findExportByName("libc.so", "fopen");
    var systemPtr = Module.findExportByName("libc.so", "system");

    var suspicious = [
        "/system/xbin/su",
        "/system/bin/su",
        "/su/bin/su",
        "magisk",
        "busybox"
    ];

    function isSuspicious(path) {
        if (!path) return false;
        for (var i = 0; i < suspicious.length; i++) {
            if (path.indexOf(suspicious[i]) !== -1) return true;
        }
        return false;
    }

    if (accessPtr) {
        Interceptor.attach(accessPtr, {
            onEnter: function (args) {
                this.path = Memory.readUtf8String(args[0]);
                this.block = isSuspicious(this.path);
                if (this.block) {
                    console.log("[native access] " + this.path);
                }
            },
            onLeave: function (retval) {
                if (this.block) {
                    retval.replace(-1);
                    console.log("[native access] blocked");
                }
            }
        });
    }

    if (fopenPtr) {
        Interceptor.attach(fopenPtr, {
            onEnter: function (args) {
                this.path = Memory.readUtf8String(args[0]);
                this.block = isSuspicious(this.path);
                if (this.block) {
                    console.log("[native fopen] " + this.path);
                }
            },
            onLeave: function (retval) {
                if (this.block) {
                    retval.replace(ptr(0));
                    console.log("[native fopen] blocked");
                }
            }
        });
    }

    if (systemPtr) {
        Interceptor.attach(systemPtr, {
            onEnter: function (args) {
                var cmd = Memory.readUtf8String(args[0]);
                this.block = false;
                if (cmd.indexOf("su") !== -1 || cmd.indexOf("getprop") !== -1 || cmd.indexOf("mount") !== -1) {
                    this.block = true;
                    console.log("[native system] " + cmd);
                    Memory.writeUtf8String(args[0], "echo");
                }
            }
        });
    }
}

setImmediate(hookNativeRootChecks);

Mermaid:Java 与 Native 双层检测时序图

sequenceDiagram
    participant App as App
    participant Java as Java层
    participant JNI as JNI桥接
    participant Native as libc/so
    participant Frida as Frida Hook

    App->>Java: 调用安全检查入口
    Java->>Frida: Hook业务方法/系统API
    alt Java层直接检测
        Java->>Java: File.exists / Runtime.exec / SystemProperties.get
        Frida-->>Java: 返回伪造结果
    else 进入Native
        Java->>JNI: native detectRoot()
        JNI->>Native: access/fopen/system
        Frida-->>Native: 修改返回值或参数
    end
    Java-->>App: 返回未Root

逐步验证清单

很多脚本“看起来没报错”,但其实根本没生效。建议按下面清单一项一项验证。

验证 1:脚本是否真正注入

看是否打印了:

[*] Root bypass script loaded

如果没打印,先别想别的,说明脚本都没进去。

验证 2:检测逻辑是否走到了你的 Hook 点

例如你 Hook 了 File.exists,那至少应该看到类似日志:

[File.exists] blocked => /system/xbin/su

如果一条都没有,说明:

  • 目标根本不用这个 API
  • 类还没加载到
  • 检测在 Native 层

验证 3:App 的行为是否发生变化

比如原来启动秒退,现在能进入首页;原来弹 Root 提示,现在不弹了。
没有行为变化,就说明你拦的不是关键路径。

验证 4:删减 Hook,收敛到最小脚本

这个步骤很重要。
当你用“大网兜”脚本成功绕过后,要开始删:

  • 先去掉 String.contains
  • 再去掉底层文件 Hook
  • 最后只保留关键业务方法 Hook

这样你才能知道真正必要的 Hook 点是什么。


常见坑与排查

1. Hook 里调用原方法导致递归

这是 Frida 新手最常见的坑之一。

比如下面这段:

File.exists.implementation = function () {
    return this.exists();
};

这会再次进入你自己重写的实现,最终递归爆掉。

正确做法通常是提前保存 overload 引用,或者仅在特定分支拦截。像某些场景里 this.exists() 能工作,是因为 Frida 的封装行为刚好允许,但不要把它当通用写法依赖。

更稳一点的写法如下:

Java.perform(function () {
    var File = Java.use("java.io.File");
    var existsImpl = File.exists.overload();
    existsImpl.implementation = function () {
        var path = this.getAbsolutePath();
        if (path.indexOf("su") !== -1) {
            return false;
        }
        return existsImpl.call(this);
    };
});

2. spawn 模式和 attach 模式选错

  • 启动即检测:优先 -f spawn
  • 运行中某页面才检测:attach 更方便

如果检测发生在 Application.attach 之前,你 attach 上去就已经晚了。

3. 混淆导致方法名难找

遇到 a.b.c.a() 这种名字不要慌,重点看:

  • 参数类型
  • 返回值类型
  • 调用位置
  • 常量字符串

必要时可以枚举类和方法:

Java.perform(function () {
    Java.enumerateLoadedClasses({
        onMatch: function (name) {
            if (name.toLowerCase().indexOf("root") !== -1 || name.toLowerCase().indexOf("security") !== -1) {
                console.log(name);
            }
        },
        onComplete: function () {}
    });
});

4. 类没加载,Hook 时报错

有些类在启动早期还不存在。可以延后 Hook,或者从 Application.attach 切入。

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);

        var cl = ctx.getClassLoader();
        Java.classFactory.loader = cl;

        try {
            var Checker = Java.use("com.target.security.RootChecker");
            Checker.checkRoot.implementation = function () {
                return false;
            };
            console.log("[+] Hooked RootChecker.checkRoot");
        } catch (e) {
            console.log("[-] delayed hook failed: " + e);
        }
    };
});

5. 只拦 Java,不拦 Native,结果仍失败

这类现象非常典型。判断标准很简单:

  • Java 日志很少
  • 行为没有变化
  • so 加载明显

那就直接上 libc.soaccess/fopen/system/stat 级别 Hook。

6. 过度 Hook 导致 App 异常

比如全局 Hook String.contains,虽然有时有效,但很容易误伤业务逻辑。
我一般只把它当成辅助定位手段,验证完就删。


安全/性能最佳实践

Root 绕过不是 Hook 越多越好,越“重”越容易引入副作用。下面是我比较推荐的实践方式。

1. 优先 Hook 业务判定点

推荐顺序:

  1. isRooted() / checkRoot() 这种业务方法
  2. Java 系统 API:File.existsRuntime.exec
  3. Native libc:accessfopensystem

这是一条从高层到低层的最小侵入路径。

2. 日志和篡改分阶段进行

先记录,再篡改。

不要一上来就把所有返回值都改掉。
先搞清楚它查了什么,再只改关键部分,成功率和稳定性都会高很多。

3. 控制 Hook 范围

例如,只针对可疑路径返回假结果:

if (path.indexOf("/system/xbin/su") !== -1) {
    return false;
}

而不是把所有文件存在判断都改成 false。后者很可能让 App 自己的资源检查也挂掉。

4. 避免影响正常命令执行

很多 App 自己会跑 shell 命令做环境收集或网络诊断。
如果你把所有 Runtime.exec 都改成 echo,业务也可能异常。

更好的做法是仅拦这些命令:

  • which su
  • getprop
  • mount
  • id
  • su

5. 保持脚本可回退、可裁剪

我个人建议把脚本拆成几个模块:

  • log_only.js
  • java_bypass.js
  • native_bypass.js
  • biz_override.js

这样排查时可以快速切换,不会因为一个超大脚本搞不清哪部分生效了。


方案取舍:通用拦截 vs 精准改判

通用拦截

优点:

  • 上手快
  • 对未知 App 有初始效果
  • 适合做第一轮侦察

缺点:

  • 容易误伤
  • 性能开销更高
  • 维护性差

精准改判

优点:

  • 稳定
  • 副作用小
  • 适合长期保留

缺点:

  • 需要先完成定位
  • 混淆场景下要多花一点时间

我的建议很明确:

先用通用拦截找路径,再落到精准改判。

这基本是中级逆向阶段最省时间、也最不容易把自己绕进去的方式。


一个实战落地模板

如果你今天拿到一个新 App,可以直接按这个顺序做:

阶段 1:静态看线索

  • jadx 搜 root/su/magisk/test-keys
  • 记下可疑类名、方法名、字符串

阶段 2:动态跑日志脚本

  • Hook File.exists
  • Hook Runtime.exec
  • Hook SystemProperties.get
  • 看有没有命中

阶段 3:验证最终判定点

  • 弹窗前哪个方法返回 true
  • 退出前哪个方法被调用

阶段 4:最小化绕过

  • 优先只 Hook isRooted/checkRoot
  • 不行再补 Java 底层拦截
  • 还不行再补 Native Hook

阶段 5:回归验证

  • 冷启动
  • 页面跳转
  • 登录/下单等关键流程
  • 看是否出现新的副作用

总结

Root 检测绕过最怕的不是技术点本身,而是定位没有章法。一上来就套“万能脚本”,短期可能有效,但一旦碰到混淆、双层检测、Native 检测,很快就会陷入“明明 Hook 了为什么还不行”的状态。

这篇文章想传达的核心其实就三句话:

  1. 先定位,后绕过
  2. 优先改最终判定点,而不是全局乱拦
  3. Java 不够就下沉到 Native,但仍然要做最小化修改

如果你只记一个实战建议,那就是:

第一轮先做“日志型 Hook”,把文件、属性、命令三类检测跑清楚;第二轮再决定是 Hook 业务方法,还是补底层拦截。

这样做的好处是,你不仅能“绕过去”,还能真正知道这个 App 是怎么做 Root 检测的。这对后续做反调试分析、证书锁定绕过、接口追踪,都会顺很多。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》