安卓逆向实战:使用 Frida 定位并绕过常见 Root 检测逻辑的完整方法
很多 Android 应用,尤其是金融、风控、游戏和企业安全类 App,上来第一件事就是检查设备有没有 Root。一旦命中,它可能直接闪退、拒绝登录,或者静默降级某些功能。
如果你只是停留在“把 su 隐藏掉”这一层,通常很快就会发现:不够。因为现代 App 的 Root 检测往往是组合拳——查文件、查命令、查系统属性、查挂载信息、查安装包,甚至 JNI/native 层一起上。
这篇文章我不打算只讲“某个 hook 点怎么写”,而是带你从定位检测逻辑、到建立绕过思路、再到写出可运行的 Frida 脚本,完整走一遍。你看完后,应该能独立处理大多数 Java 层 Root 检测,遇到 native 检测也知道往哪里继续挖。
说明:本文内容用于安全研究、应用自测与授权测试场景。请勿用于未授权目标。
背景与问题
Root 检测本质上是在回答一个问题:当前设备环境是否可信。
常见检测点包括:
- 是否存在
su、busybox、magisk等文件 - 是否能执行
which su、id、getprop等命令 - 系统属性是否异常,如
ro.debuggable=1 build tags是否包含test-keys- 某些 Root 管理器或框架包是否已安装
- SELinux、挂载点、
/proc信息是否异常 - native 层通过
access/stat/fopen/system等接口进一步检查
这些逻辑有个典型特点:分散。你打开 APK,搜索一圈可能只能看到一个 isDeviceRooted(),但点进去后往往又是层层调用,甚至混淆得比较严重。
所以,真正高效的方法不是死抠静态代码,而是:
- 先动态定位
- 确认命中的检测分支
- 最小化绕过
- 逐步扩展到更底层
前置知识与环境准备
这篇文章默认你已经具备这些基础:
- 会用
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 层常见调用:
accessstatfopen
2. 命令执行检测
App 直接执行命令并读取结果:
which susu -c idgetpropmountid
Java 层常见调用:
Runtime.getRuntime().exec(...)ProcessBuilder.start()
native 层常见调用:
systempopenexecve
3. 系统属性与构建信息检测
检测是否为调试环境或测试签名环境:
ro.debuggablero.securero.build.tagsBuild.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.existsRuntime.execProcessBuilder.startSystemProperties.getPackageManager.getPackageInfo
原因很简单:大多数 App 的 Root 检测,至少会经过其中一个。
第二步:打印可疑参数
比如:
- 路径中包含
su、busybox、magisk - 命令中包含
getprop、mount、which su - 包名中包含
magisk、supersu
第三步:必要时打印调用栈
只打印参数有时不够,因为真正的业务逻辑入口可能在混淆类中。调用栈能帮你快速定位:
- 哪个类在发起检测
- 检测在哪个生命周期执行
- 哪个分支导致退出
实战代码:先做“定位版”脚本
这份脚本的目标不是立即绕过,而是先找出谁在检测。建议先跑它,看日志,再决定最小化 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.RootCheckcom.xxx.guard.EnvVerifier- 混淆类如
a.b.c.a
一旦定位到真正的检测类,后面有两种策略:
- 直接 hook 检测函数返回 false
- 从底层 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 层,如
access、stat、fopen、system
示例:
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 会明显卡顿。应只对这些关键字处理:
sumagiskbusybox
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 点,而是建立一套稳定的方法论:
- 冷启动注入,抢在检测前
- 先追踪,再修改
- 优先 Java 层关键 API 定位
- 找到业务检测函数就直接下手
- Java 无果时及时转 native
- 最终脚本尽量最小化、定向化
如果你是中级阶段,我特别建议你把文章里的思路拆成两套脚本来练:
- 一套
trace_root.js:只负责观察 - 一套
bypass_root.js:只负责绕过
这样你会比“上来就复制一份万能脚本”进步快很多。我自己早期踩过最大的坑,就是试图用一份脚本解决所有问题,最后日志乱、逻辑乱、定位也慢。后来改成“先定位、后收敛”,效率高了不少。
最后再强调一次边界:本文覆盖的是常见 Java 层 Root 检测及其向 native 扩展的定位方式。如果目标还叠加了 Frida 检测、反调试、完整性校验或壳保护,你还需要继续补对应对抗点。但至少在 Root 检测这件事上,你现在已经有一套能真正落地的方法了。