背景与问题
在 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/sumagiskbusyboxtest-keys- 可写的 system 分区
which su、getprop等命令检测
推荐测试环境
- Android 真机或模拟器
- Frida 16+
jadx用于静态分析adb logcat用于快速看异常- 可选:
objection、r2frida
典型目标场景
一个 App 在启动时做以下事情之一:
- 读取
Build.TAGS判断是否为test-keys - 检查
su、busybox、magisk文件是否存在 - 执行
which su/getprop/mount - 调用某个
isDeviceRooted()、checkRoot()方法 - 进一步进入 so 库,调用 native 函数检测
核心原理
Root 检测从实现角度看,通常分为四类:
- 文件痕迹检测
- 系统属性检测
- 命令执行检测
- 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信息]
逆向时的正确思路
很多人一开始就想“有没有一把梭的绕过脚本”。当然有一些通用脚本能拦住大部分检测,但实战里更重要的是定位路径。
更稳的流程是:
- 静态看入口
- 用 jadx 搜索:
rootsumagiskbusyboxtest-keyswhichgetprop
- 用 jadx 搜索:
- 动态看调用
- Hook 文件访问、命令执行、可疑方法
- 确认最终判定点
- 谁返回了
true - 谁触发了弹窗/退出
- 谁返回了
- 只改必要结果
- 优先改布尔返回值
- 再考虑改底层行为
这比“全局乱 Hook”更稳定,也更不容易把 App 搞崩。
分析路径:从入口到绕过
第一步:静态扫描,先猜检测类型
拿到 APK 后,先别急着跑脚本。我通常会先在 jadx 里搜这些关键字:
isRootedcheckRootRootBeertest-keysmagiskbusyboxsuwhich su
如果你搜到了类似下面的方法:
public boolean isDeviceRooted() {
return checkTestKeys() || checkSuExists() || checkBusybox() || checkMagisk();
}
那就很理想,直接盯这个返回值。
如果代码混淆严重,看不到这么直白的名字,也没关系。继续搜字符串常量,比如 /system/xbin/su、test-keys,通常仍然能抓到线索。
第二步:动态观察,先记录而不是立刻篡改
这一阶段目标不是马上绕过,而是知道 App 在查什么。
我建议先 Hook 以下点位做日志:
java.io.File.existsjava.lang.Runtime.execjava.lang.ProcessBuilder.startandroid.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.exec、File.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 模式选错
- 启动即检测:优先
-fspawn - 运行中某页面才检测: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.so 的 access/fopen/system/stat 级别 Hook。
6. 过度 Hook 导致 App 异常
比如全局 Hook String.contains,虽然有时有效,但很容易误伤业务逻辑。
我一般只把它当成辅助定位手段,验证完就删。
安全/性能最佳实践
Root 绕过不是 Hook 越多越好,越“重”越容易引入副作用。下面是我比较推荐的实践方式。
1. 优先 Hook 业务判定点
推荐顺序:
isRooted()/checkRoot()这种业务方法- Java 系统 API:
File.exists、Runtime.exec - Native libc:
access、fopen、system
这是一条从高层到低层的最小侵入路径。
2. 日志和篡改分阶段进行
先记录,再篡改。
不要一上来就把所有返回值都改掉。
先搞清楚它查了什么,再只改关键部分,成功率和稳定性都会高很多。
3. 控制 Hook 范围
例如,只针对可疑路径返回假结果:
if (path.indexOf("/system/xbin/su") !== -1) {
return false;
}
而不是把所有文件存在判断都改成 false。后者很可能让 App 自己的资源检查也挂掉。
4. 避免影响正常命令执行
很多 App 自己会跑 shell 命令做环境收集或网络诊断。
如果你把所有 Runtime.exec 都改成 echo,业务也可能异常。
更好的做法是仅拦这些命令:
which sugetpropmountidsu
5. 保持脚本可回退、可裁剪
我个人建议把脚本拆成几个模块:
log_only.jsjava_bypass.jsnative_bypass.jsbiz_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 了为什么还不行”的状态。
这篇文章想传达的核心其实就三句话:
- 先定位,后绕过
- 优先改最终判定点,而不是全局乱拦
- Java 不够就下沉到 Native,但仍然要做最小化修改
如果你只记一个实战建议,那就是:
第一轮先做“日志型 Hook”,把文件、属性、命令三类检测跑清楚;第二轮再决定是 Hook 业务方法,还是补底层拦截。
这样做的好处是,你不仅能“绕过去”,还能真正知道这个 App 是怎么做 Root 检测的。这对后续做反调试分析、证书锁定绕过、接口追踪,都会顺很多。