解决一件事:App 在 Root 设备上启动即闪退时,让它继续跑。聚焦设备本地的 Root 痕迹检测——文件、Shell 命令、系统属性、包名、Native 层 syscall。
配套脚本:本篇产出的整套 Root 检测绕过脚本(Java 四维 Hook + Native 层 libc Hook + 酷狗 / 招行双实战绕过 + RUN.md 真机验证清单,复制即用)已整理打包。关注本公众号后私信回复关键词「脚本」 即可获取,本系列与后续 Unidbg / SO 逆向 / ARM 汇编 等系列脚本会统一在此发放并持续更新。
一、检测强度分级
-
一般商业 App:Java 层基础文件 + 包名检测,最简单的脚本即可。
-
游戏App:防 GameGuardian 修改内存、防模拟器刷号,Root + 模拟器双检为主。
-
金融App:合规要求驱动,普遍接入同盾、盾山、梆梆等第三方 SDK,Java + Native 双层 + 后台持续检测。
检测强度分级模型
边界:本文范围是设备本地检测——App 进程内的判定逻辑都可以靠 Hook 改返回值。但 2025 年 1 月 SafetyNet 关停后,银行/支付/政务类 App 主流已切换到 Play Integrity API + 硬件密钥认证 (Key Attestation) 双重把关:
- Play Integrity 的 verdict(
MEETS_BASIC / DEVICE / STRONG_INTEGRITY)是 Google 服务端签名后返回的 JWT,App 把它转发给业务后端校验。Hook App 本地无效——签名密钥不在 App 里。 - Hardware Key Attestation 通过
KeyStore.attestKey() 拿一条 TEE/StrongBox 出厂时刻入的硬件证书链,业务后端能据此判出"设备 bootloader 是否解锁过 / 是否换过 ROM"。几乎无法软件绕过,业内目前只能靠 PlayIntegrityFix Magisk 模块伪造 BASIC/DEVICE verdict,STRONG 拿不到。
也就是说,本文对付的是自家测试 / CTF / 非 Google Play 渠道的商业 App。强制 STRONG 的银行 App 不靠"多 Hook 几个点"——靠 Magisk 模块 + 干净硬件指纹 + ZygiskNext 隐藏链。
二、Root 检测的四个维度
Root 痕迹分四类——su 等特征文件、which su 类 Shell 命令、ro.build.tags=test-keys 等系统属性、Magisk Manager 这类包名。下图给出每类的关键证据和对应 Hook 点,第 2.1 节 – 第 2.4 节 一一对应展开:
Root 检测四维全景
同一个 App 经常两层都做一遍:Java 层走 File.exists / Runtime.exec;Native 层走 libc 的 access / stat / fopen,触发时机比 Java 层早。下图按 App 启动时间轴把两层并排画出来,每个检测点旁标了 Hook 入口:
Java 层与 Native 层检测架构
时间轴上 Native 检测在 JNI_OnLoad / SO 静态构造里就跑(T2),等 Java 检测线程起来(T3+)再 Hook 已经晚了——所以 Hook 必须赶在 native 检测之前就位,而 native 又早于 Java,唯一稳妥的办法是默认上 spawn(详见 第 4.2 节)。
拿到样本第一步要决定从哪层下手——按症状选策略,免得用银行 App 那套去打一个 Java 层小工具。下图把"症状 → 入手层"拆成三条决策路径:
Hook 从哪一层下手的三条决策路径
简而言之:Java 层闪退或弹 Toast,先 第 2.1 节 - 第 2.4 节;只有 native 痕迹(栈干净 / 信号文件 / Process 异常)才下到 第 3 章;分不清就 spawn 起来双层都挂,等命中再砍多余的 Hook。
2.1 文件存在性检测
Root 操作会留下特征文件——su 二进制、Magisk 管理文件、SuperSU 的 APK 等。App 通过 File.exists() 检查这些路径。
常见路径清单(备查,实战 Hook 用下面的通配):
su 二进制文件(含 Android 10+ 新分区):
/system/bin/su, /system/xbin/su, /sbin/su
/data/local/su, /data/local/bin/su, /data/local/xbin/su
/system_ext/bin/su ★ Magisk systemless 在 Pixel / Android 10+ 的常见位置
/product/bin/su, /odm/bin/su
/apex/com.android.runtime/bin/su, /apex/com.android.art/bin/su
Root 管理工具:
/system/app/Superuser.apk, /system/app/SuperSU.apk
/sbin/magisk, /sbin/.magisk
/data/adb/magisk, /data/adb/magisk.db
/data/adb/ksu, /data/adb/ksud # KernelSU
/data/adb/ap, /data/adb/apd # APatch
BusyBox(Root 常见伴随工具):
/system/xbin/busybox, /system/bin/busybox
Frida 自身:
/data/local/tmp/frida-server
把上面这份清单写成精确匹配的数组挡不住 Android 10+ 的 $PATH 扫描——酷狗 SystemUtils.Q6() 直接读 System.getenv("PATH") 切分查 su,Pixel 上 PATH 含 /system_ext/bin,Magisk systemless 装的 su 就在那(详见 第 5 章)。Hook 改用通配判定,命中 /su 结尾或 magisk 子串就一律 false:
Java.perform(function() {
var File = Java.use("java.io.File");
// 通配:路径以 /su 结尾,或包含 magisk —— 不要扩大到 root/sys/security 等关键字,
// 会误伤业务正常文件
function isRootPath(p) {
return p && (p.endsWith("/su") || p.indexOf("magisk") !== -1);
}
// exists / canRead / canExecute 三件套都要 hook
// 酷狗 SystemUtils.P6/Q6 用的是 f.exists() && f.canExecute()
// 若只 hook exists,App 写 if (f.canExecute()) return true; 就漏检
File.exists.implementation = function() {
var p = this.getAbsolutePath();
if (isRootPath(p)) {
console.log("[Root] File.exists → false: " + p);
return false;
}
return this.exists();
};
File.canExecute.implementation = function() {
var p = this.getAbsolutePath();
if (isRootPath(p)) return false;
return this.canExecute();
};
File.canRead.implementation = function() {
var p = this.getAbsolutePath();
if (isRootPath(p)) return false;
return this.canRead();
};
});
File.exists() 无参,路径在 File 对象内部,用 this.getAbsolutePath() 取。通配只锁 /su$ 和 magisk——扩到 sys 会命中 /system/...、/sys/... 几乎所有系统路径,扩到 root / security 也会撞上一堆正常的库名和缓存目录,招行 dRbf 那种反复 new File(...).exists() 探业务文件的逻辑首当其冲。
2.2 Shell 命令检测
which su——如果 su 在 PATH 中存在会返回路径,否则返回空。或者直接执行 su -c id,成功返回 uid=0 即视为已 Root。
Hook Runtime.exec(),拦截含危险关键字的命令:
Java.perform(function() {
var Runtime = Java.use("java.lang.Runtime");
Runtime.exec.overloads.forEach(function(overload) {
overload.implementation = function() {
var cmd = arguments[0];
var cmdStr = "";
if (typeof cmd === "string") {
cmdStr = cmd;
} else if (cmd !== null && cmd.length !== undefined) {
var parts = [];
for (var i = 0; i < cmd.length; i++) parts.push(cmd[i]);
cmdStr = parts.join(" ");
}
var dangerous = ["su", "which", "magisk", "busybox", "supersu",
"test-keys", "mount", "remount"];
var cmdLower = cmdStr.toLowerCase();
for (var j = 0; j < dangerous.length; j++) {
if (cmdLower.indexOf(dangerous[j]) !== -1) {
console.log("[Root] Runtime.exec 拦截: " + cmdStr);
return Runtime.exec.call(this, "echo");
}
}
return overload.apply(this, arguments);
};
});
});
替换成 echo 而非返 null:Runtime.exec 返回 Process,调用方一般会读它的输出流,null 触发 NPE 会让 App 崩。echo 输出空、退出码 0,等价于 su 不存在。
2.3 系统属性检测
ro.build.tags:debug build 是 test-keys,正式版是 release-keys,Root 的自定义 ROM 多半是 test-keys。
读取有两条路径,都要拦:
SystemProperties.get("ro.build.tags")android.os.Build.TAGS 静态字段直读
比如酷狗 FireEye SDK 的 DeviceInfo.isDeviceRooted 走的是后者(String tags = Build.TAGS;),单 Hook SystemProperties.get 拦不住。
Java.perform(function() {
// ===== 路径 A:SystemProperties.get =====
try {
var SystemProperties = Java.use("android.os.SystemProperties");
var fakeValues = {
"ro.build.tags": "release-keys",
"ro.debuggable": "0",
"ro.secure": "1",
"ro.build.type": "user",
"ro.build.selinux": "1",
// verified boot 三件套(金融类 SDK 常查)
"ro.boot.verifiedbootstate": "green",
"ro.boot.flash.locked": "1",
"ro.boot.veritymode": "enforcing",
};
SystemProperties.get.overload("java.lang.String")
.implementation = function(key) {
if (fakeValues[key] !== undefined) {
console.log("[Root] 属性伪造: " + key + " → " + fakeValues[key]);
return fakeValues[key];
}
return this.get(key);
};
SystemProperties.get.overload("java.lang.String", "java.lang.String")
.implementation = function(key, def) {
if (fakeValues[key] !== undefined) return fakeValues[key];
return this.get(key, def);
};
} catch(e) {}
// ===== 路径 B:android.os.Build 静态字段反射改值 =====
// Build.TAGS / Build.FINGERPRINT / Build.TYPE 是 public static final,
// 但通过反射 + setAccessible 仍可改。改完后所有 new String 形式的读取都拿到新值。
try {
var Build = Java.use("android.os.Build");
var fakeBuildFields = {
"TAGS": "release-keys", // FireEye / RootBeer 等直读 Build.TAGS
"TYPE": "user", // 部分 SDK 读 Build.TYPE 判 user/userdebug
// FINGERPRINT 不强制改(很多 App 用它做设备指纹上报,乱改可能干扰业务)
};
Object.keys(fakeBuildFields).forEach(function(name) {
try {
var field = Build.class.getDeclaredField(name);
field.setAccessible(true);
field.set(null, Java.use("java.lang.String").$new(fakeBuildFields[name]));
console.log("[Root] Build." + name + " → " + fakeBuildFields[name]);
} catch(e) {
console.log("[!] Build." + name + " 反射失败: " + e.message);
}
});
} catch(e) {}
});
API 30+ 反射可能失败:Android 11 把 Field.modifiers 列入 hidden API 黑名单,普通 App 拿不到 modifiers 字段去清 final 标志位;部分字段在 JIT/AOT 优化后会被 inline 成常量。两个补救路径:
- 从源头:Build 字段的值大多来自
SystemProperties.get("ro.product.model") 等属性读取,把 ro.build.tags / ro.product.model / ro.product.brand 等键加进路径 A 的 fakeValues,比改字段更稳 - 拦调用方:直接 Hook 那个读了
Build.TAGS 做判定的方法,从结果端绕过(参见 第 4.3 节 输出口法)
实测 Pixel 6 Pro / Android 13 / Magisk + Zygisk 上反射能成功(酷狗案例 RUN.md 记录这段反射写入的 Build 字段——TAGS / TYPE——写完读回一致),但未 root 设备 / 严格 hidden API 检查的 App 仍可能静默失败。判别方式:写完后立刻 console.log(Build.TAGS.value) 读回看,相等就成。
2.4 包名检测
PackageManager 检查 Magisk Manager(com.topjohnwu.magisk)、SuperSU(eu.chainfire.supersu)等是否安装:
Java.perform(function() {
var PM = Java.use("android.app.ApplicationPackageManager");
var NNFE = Java.use("android.content.pm.PackageManager$NameNotFoundException");
var hiddenPackages = [
"com.topjohnwu.magisk",
"io.github.vvb2060.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.noshufou.android.su",
"com.thirdparty.superuser",
"com.yellowes.su",
"com.kingroot.kinguser",
"de.robv.android.xposed.installer",
"org.meowcat.edxposed.manager",
"com.saurik.substrate",
];
function isHidden(name) {
for (var i = 0; i < hiddenPackages.length; i++)
if (name === hiddenPackages[i]) return true;
return false;
}
function filterList(list) {
var it = list.iterator();
while (it.hasNext()) {
var name = it.next().packageName.value;
if (isHidden(name)) {
it.remove();
console.log("[Root] 从列表移除: " + name);
}
}
return list;
}
// ===== 点查路径:getPackageInfo =====
// 老重载 (String, int)
PM.getPackageInfo.overload("java.lang.String", "int")
.implementation = function(name, flags) {
if (isHidden(name)) {
console.log("[Root] 隐藏包: " + name);
throw NNFE.$new(name);
}
return this.getPackageInfo(name, flags);
};
// Android 13+ (API 33) 新重载 PackageInfoFlags:targetSdk ≥ 33 走新签名
try {
PM.getPackageInfo.overload("java.lang.String",
"android.content.pm.PackageManager$PackageInfoFlags")
.implementation = function(name, flags) {
if (isHidden(name)) {
console.log("[Root] 隐藏包(Flags 重载): " + name);
throw NNFE.$new(name);
}
return this.getPackageInfo(name, flags);
};
} catch(e) {}
// ===== 枚举路径:getInstalledPackages + getInstalledApplications =====
PM.getInstalledPackages.overload("int")
.implementation = function(flags) {
return filterList(this.getInstalledPackages(flags));
};
try {
PM.getInstalledPackages
.overload("android.content.pm.PackageManager$PackageInfoFlags")
.implementation = function(flags) {
return filterList(this.getInstalledPackages(flags));
};
} catch(e) {}
// getInstalledApplications 返回 ApplicationInfo 列表,packageName 字段同名,filterList 直接复用
try {
PM.getInstalledApplications.overload("int")
.implementation = function(flags) {
return filterList(this.getInstalledApplications(flags));
};
PM.getInstalledApplications
.overload("android.content.pm.PackageManager$ApplicationInfoFlags")
.implementation = function(flags) {
return filterList(this.getInstalledApplications(flags));
};
} catch(e) {}
});
三、Native 层文件检测
SDK 在 Native 层直接调 libc 的 access() / stat() / open() / fopen(),绕过 Java 层。Android 11+ Bionic 主推 *at 系列(openat / faccessat / fstatat,POSIX.1-2008 atfd 风格),新 NDK 编译的 SO 大量走 *at——只 Hook access/stat 会漏。
锁定 Frida 16;Frida 17 起 Module.findExportByName 已 deprecated,改用 Process.getModuleByName("libc.so").getExportByName(...)。
(function() {
// ============ 路径判定 ============
// 以 /su 结尾、/su/ 中段、或 magisk/supersu/busybox/xposed 子串。
// 用 endsWith / "/su/" 中段判定,避免 indexOf("/su") 误命中 /subsystem/、/sys/.../subsys 等正常路径。
// 注:native 层 isRootPath 比第 2.1 节 Java 层多加了 supersu/busybox/xposed 三个关键字——它们是 root
// 工具的专用名,业务代码极少撞名;Java 应用层会调 File.exists 看一般业务文件(如招行 dRbf),扩关键字
// 会误伤,所以那边只锁 /su+magisk。Native 层调 access/stat 的几乎只剩 SDK 检测代码,边界更窄,可以放宽。
function isRootPath(path) {
if (!path) return false;
if (path === "/su" || path.endsWith("/su")) return true;
if (path.indexOf("/su/") !== -1) return true; // /system/su/bin/... 也算
if (path.indexOf("magisk") !== -1) return true;
if (path.indexOf("supersu") !== -1) return true;
if (path.indexOf("busybox") !== -1) return true;
if (path.indexOf("xposed") !== -1) return true;
return false;
}
// ============ ① access / faccessat —— 文件可达性 ============
// faccessat 路径是 args[1](args[0] 是 dirfd);faccessat 比 access 更常见
[
{ name: "access", pathArgIdx: 0 },
{ name: "faccessat", pathArgIdx: 1 },
].forEach(function(spec) {
var p = Module.findExportByName("libc.so", spec.name);
if (!p) return;
Interceptor.attach(p, {
onEnter: function(args) {
var path = Memory.readUtf8String(args[spec.pathArgIdx]);
this.block = isRootPath(path);
if (this.block) console.log("[Native] " + spec.name + " 拦截: " + path);
},
onLeave: function(retval) {
if (this.block) retval.replace(ptr(-1));
}
});
});
// ============ ② stat / lstat / stat64 / lstat64 / fstatat —— 文件元数据 ============
// ARM64/Android 14 实测 stat64/lstat64 仍导出,全 5 个挂上
[
{ name: "stat", pathArgIdx: 0 },
{ name: "lstat", pathArgIdx: 0 },
{ name: "stat64", pathArgIdx: 0 },
{ name: "lstat64", pathArgIdx: 0 },
{ name: "fstatat", pathArgIdx: 1 }, // dirfd 在 args[0]
].forEach(function(spec) {
var p = Module.findExportByName("libc.so", spec.name);
if (!p) return;
Interceptor.attach(p, {
onEnter: function(args) {
var path = Memory.readUtf8String(args[spec.pathArgIdx]);
this.block = isRootPath(path);
},
onLeave: function(retval) {
if (this.block) retval.replace(ptr(-1));
}
});
});
// ============ ③ open / openat —— 打开文件读 ============
// open(path, flags, ...) / openat(dirfd, path, flags, ...)
[
{ name: "open", pathArgIdx: 0 },
{ name: "openat", pathArgIdx: 1 },
].forEach(function(spec) {
var p = Module.findExportByName("libc.so", spec.name);
if (!p) return;
Interceptor.attach(p, {
onEnter: function(args) {
var path = Memory.readUtf8String(args[spec.pathArgIdx]);
this.block = isRootPath(path);
},
onLeave: function(retval) {
if (this.block) retval.replace(ptr(-1)); // -1 + errno = ENOENT
}
});
});
// ============ ④ fopen —— stdio 包装 ============
// 招行 parse_self_maps 用 fopen("/proc/self/maps","r")+fgets 的范式
// 很多 root 检测也用 fopen 读 /proc/.../status、/proc/mounts
var fopenPtr = Module.findExportByName("libc.so", "fopen");
if (fopenPtr) {
Interceptor.attach(fopenPtr, {
onEnter: function(args) {
var path = Memory.readUtf8String(args[0]);
this.block = isRootPath(path);
},
onLeave: function(retval) {
if (this.block) retval.replace(ptr(0)); // NULL = 打开失败
}
});
}
})();
少数高强度加固 SDK 会用 svc 0 直接走 syscall,整层绕开 libc Hook —— 它们不调 access/stat/open*,而是用 ARM64 的 mov x8, #SYS_openat; svc 0 内联汇编直接发起系统调用,完全不经过 libc。Interceptor.attach("libc.so", "openat") 在它们身上不会触发任何回调。这是个老技术(Windows 侧的 direct syscall、ARM64 侧的 svc 直调都存在多年),不是某年的新拐点;真正大量用它的也只是个别方案(见本节末)。
挡 svc 0 的两条路径:
- Stalker 指令级追踪:用
Stalker.follow() 扫描每条指令,在 svc 指令前插桩。性能开销大,精度最高。详见第 24 篇。 - Interruptor 库:基于 Stalker 封装的 syscall 级 Hook,API 类似
Interceptor 但作用在 svc 上。GitHub FrenchYeti/interruptor,几行代码就能 strace 一个进程的全部 syscall 并按号过滤。
判定目标是否用了 svc 0 的最快方法:Hook 完上面这段后,如果 App 仍然准确判出"已 Root"且能定位到某个 SO 模块,在那个 SO 里 r2 -A 搜 svc #0 指令——命中数十处且围绕 Root 路径字符串,就是它:
$ r2 -A libCheck.so
[0x00012340]> /ad svc #0 # 搜 svc 0 指令
0x00018a40 d4000001 svc 0 # ← 旁边能看到 mov x8, #56 (SYS_openat)
0x00018c20 d4000001 svc 0 # ← 附近字符串 "/system/bin/su"
0x00019100 d4000001 svc 0
... 28 hits in libCheck.so
像招行 libCmbShield.so 这种只有零星几条 svc 0、主要靠 plt_hook 自家 libc 的情况,上面脚本能拦住绝大部分。只有同盾、网易易盾的部分版本会大量用 svc 0 把 libc Hook 整层绕掉。
四、对抗升级与方法论
第 2、3 章 的代码模板抄了就能用,但跑之前先看三件事:
- 装了 Shamiko / Zygisk Assistant 的设备,root 痕迹在 mount namespace 层面已被挖掉,Java 层 Hook 大半冗余(第 4.1 节)
- 检测线程在
JNI_OnLoad 就跑完,attach 来不及——默认上 spawn,有必要再拦线程(第 4.2 节) - 加固 SDK 不逐点绕,找业务侧调用的输出方法改返回值(第 4.3 节)
第 5、6 章 两个实战分别落在这套方法论的"够用"和"边界"两侧。
4.1 反直觉:装了 Shamiko,第 2、3 章 大部分 Hook 反而冗余
如果你写了一套 200 行脚本,跑在自己设备上发现"啥都不用 Hook,App 也不闪退"——不是脚本生效了,是 Shamiko(或 Zygisk Assistant 对 KernelSU/APatch 同理)在更早的 zygote 注入阶段,直接把 root 痕迹从 App 进程的 mount namespace 里整体挖掉了。
具体做了什么:
bind mount 用空目录覆盖 /data/adb、/sbin/magisk 等路径,App 真的看不到这些文件——File.exists() 返回 false 是真返回 false,不是 Hook 出来的- 把
com.topjohnwu.magisk 从该 App 视图的 PackageManager 里隐藏(基于 <queries> 的精确点查也拿不到) - 重置
/proc/self/status、/proc/self/mountinfo 等暴露源 - 阻断 Zygisk API 在 DenyList 名单 App 内的暴露
影响:
- 第 2.1、2.2、2.4、3 章 在 Shamiko 设备上是重复保险——root 痕迹已在 mount namespace 层被挖掉,不写也大概率 OK。但这只在装了 Shamiko 时成立:酷狗(第 5 章)是纯 Magisk、没装 Shamiko 的环境,
/system_ext/bin/su 这个 symlink 明摆在那照样命中——这种环境下第 2、3 章的 Hook 一个都不能省 - 第 2.3 节(系统属性 / Build 字段)不在重复保险之列——Shamiko 不改 build 属性,FireEye 直读
Build.TAGS 还得靠 第 2.3 节 路径 B 反射拦 - 高强度 SDK 会上反 Shamiko 手段:行为指纹(
/proc/self/status 与 /proc/self/maps 横向交叉验证)、timing 异常(open root 路径 vs 普通路径耗时差)、特定 mount 点扫描(找 Shamiko 自己的痕迹)。有矛就有盾——这些手段跟随 Shamiko 出现就陆续在上,程度不一。第 2、3 章 脚本真正起作用的就是拦这些残留
实际顺序:先把 root 方案配齐(Magisk/KernelSU + Zygisk Assistant + 目标 App 加入 DenyList),再上 Frida 处理残留。反过来做的话会写一堆没用的脚本。
4.2 多线程检测的时序问题
高强度方案在 App 启动时起后台线程循环检测。Hook 装得再全,只要检测线程在 Hook 之前先跑了一遍并记下结论,后面 Hook 就晚了。
下图把 App 启动过程切成 5 个时间点:Attach 最早只能在 T3 之后接入,T2 的 JNI_OnLoad Native 检测已经成为既定事实。
Spawn vs Attach 时序窗口
策略一:用 Spawn 模式,确保绕过脚本在 App 任何代码执行前就生效。
策略二:直接阻止检测线程的启动:
Java.perform(function() {
var Thread = Java.use("java.lang.Thread");
Thread.start.implementation = function() {
var name = this.getName();
var threadClass = this.getClass().getName();
// SafetyNet 已于 2025-01 关停,从关键字里删掉避免误命中过渡期残留线程
var suspicious = ["security", "integrity", "check",
"detect", "guard", "protect", "PlayIntegrity"];
var nameLower = (name + " " + threadClass).toLowerCase();
for (var i = 0; i < suspicious.length; i++) {
if (nameLower.indexOf(suspicious[i]) !== -1) {
console.log("[线程] 阻止: " + name + " (" + threadClass + ")");
return;
}
}
this.start();
};
});
这套关键字会误杀业务线程,先把 return 改成只打日志跑一遍,确认哪些是检测线程再开拦截。招行案例 6 个检测都在 JNI_OnLoad 启动,attach 模式根本来不及——所有需要绕过强检测的脚本默认上 spawn。
4.3 第三方安全 SDK 的应对思路
同盾、盾山、梆梆这些 SDK 内部混淆且加固,逐点分析不现实。直接绕过它的结果输出函数——SDK 最终总会通过某个方法返回 boolean 或风险评分,改这一个返回值就够。
加固 SDK 输出口四步法 · 主路径 4 步 + 两类找错回溯
定位输出方法的四步:
Step 1:定位 SDK 边界类——jadx 打开 APK,Ctrl+Shift+F 输入包名前缀(如 com.tongdun、com.bangcle),找名字最朴素的那个(通常叫 SecurityClient / RiskDetector / SafeSDK)。
Step 2:找出输出方法——边界类里搜返回 boolean 或返回 int 的 public 方法:
isSecure() / isSafeEnv() / checkEnvironment() → 返回 boolean
getRiskLevel() / getDeviceScore() → 返回 int(评分越高越危险)
Step 3:找业务侧调用点——Find Usage 跳到调用位置,确认"返回什么值 = 安全":
if (!SafeSDK.getInstance().isSecure()) {
finish(); // 不安全就闪退 → 让 isSecure() 返回 true
}
// 或:
if (RiskDetector.getRiskLevel() > 60) {
showRiskDialog(); // → 让评分 ≤ 60
}
Step 4:一行 Frida Hook 收工:
Java.use("com.example.security.SafeSDK").isSecure.implementation = function() {
return true;
};
找错输出口的表现:脚本加载成功、log 显示 Hook 生效,但 App 照样闪退——SDK 里还有别的输出函数。回 Step 2 把候选列出来对照。两种典型的"找错":
- 酷狗(第 5 章):grep 出来的第一个候选 FireEye
isDeviceRooted 是"迷雾"——它确实在跑,但返 false(漏检),真凶是另一个 SystemUtils.O6。靠 第 5 章 Toast 反查才定位到。 - 招行(第 6 章):命中点根本不在调用栈里——native 命中只写信号文件,Java
onCreate 才 File.exists() 弹窗,Toast 栈干净到查不到 native 痕迹。这种延迟反馈得搜 Cts.A / Cts.B 常量的 caller,或 hook 所有 new File().exists() 看哪些路径被访问。
五、酷狗音乐
目标:com.kugou.android v20649。环境:Pixel 6 Pro / Android 13 / Magisk。症状:启动后短暂弹 Toast「当前处于 root 环境,请注意账号安全」。
不阻塞登录,但 SDK 会上报到服务器风控。下面只讲检测点和绕过——反查过程(grep / Toast.show 抽栈 / jadx 跳栈)就是第 4.3 节四步法 + 第 5 章 Toast 反查的应用,不展开。
5.1 检测点:SystemUtils.O6 = P6 || Q6
KgUserLoginAndRegActivity.onCreate 里调一次 SystemUtils.O6(),命中就弹 Toast:
public static boolean O6() { return P6() || Q6(); }
private static boolean P6() {
String[] dirs = {"/system/bin/", "/system/xbin/", "/system/sbin/", "/sbin/", "/vendor/bin/"};
for (String d : dirs) {
File f = new File(d + "su");
if (f.exists() && f.canExecute()) return true;
}
return false;
}
private static boolean Q6() {
for (String d : System.getenv("PATH").split(":")) {
File f = new File(d, "su");
if (f.exists() && f.canExecute()) return true;
}
return false;
}
两层:
- P6 —— 固定 5 个目录拼
"su" 后扫,exists() && canExecute() 同时成立才算 - Q6 —— 读
System.getenv("PATH") 切分,每个目录里查 su
Pixel 6 Pro 的 PATH 包含 /system_ext/bin,Magisk systemless 装的 symlink 就在那:
$ adb shell "ls -la /system_ext/bin/su"
lrwxrwxrwx 1 root root /system_ext/bin/su -> ./magisk
→ P6 五个固定目录都不命中(systemless 把老路径抹掉了),Q6 通过 $PATH 扫到 /system_ext/bin/su,O6 = true,Toast 弹。
反查路径速记:先 grep 文案(注意 strings 默认只输出 ASCII,中文 UTF-8 字节会被切掉,要 strings -e S 或 grep -aoF),找到 classes16.dex 后用第 5 章 Toast 反查抽栈定位到 onCreate 那一行。完整过程在酷狗的无损分析文档里,本节不展开。
5.2 两层防御 + ART AOT inline
A 拦根因(File.exists),B 拦业务输出口(O6/P6/Q6),不再叠 Toast hook(只遮盖症状不阻止检测):
Java.perform(function() {
var File = Java.use("java.io.File");
// ★ Deopt 触发器:给 Activity.onCreate 挂 no-op hook,强制 ART 把这个方法降级到解释器,
// 让 ART AOT inline 进 onCreate 的 O6/P6/Q6 重新走 ArtMethod 派发,下面的方案 B 才有机会响应
try {
Java.use("com.kugou.common.useraccount.app.KgUserLoginAndRegActivity")
.onCreate.implementation = function(b) { this.onCreate(b); };
} catch(e) {}
// A: File.exists / canExecute 通配拦截——根因,标准库不被 inline,hook 必中
File.exists.implementation = function() {
var p = this.getAbsolutePath();
if (p && (p.endsWith("/su") || p.indexOf("magisk") !== -1)) return false;
return this.exists();
};
File.canExecute.implementation = function() {
var p = this.getAbsolutePath();
if (p && (p.endsWith("/su") || p.indexOf("magisk") !== -1)) return false;
return this.canExecute();
};
// B: 业务侧输出口——配合上面的 deopt 触发器才会真正生效
try {
var SU = Java.use("com.kugou.common.utils.SystemUtils");
SU.O6.implementation = function() { return false; };
SU.P6.implementation = function() { return false; };
SU.Q6.implementation = function() { return false; };
} catch(e) {}
});
关键论断:无论 B 是否生效,A 都把 toast 兜下来。这是本案例脚本最值得记的工程结论:
| B hook 状态 | O6/P6/Q6 原代码 | 内部 File.exists 调用 | toast 结果 |
|---|
| ✓ 生效(带 deopt 触发器) | 不跑(被 frida 替换) | 不发生 | 不弹(B 拦掉) |
| ✗ 被 ART AOT inline 跳过 | 照跑(展开在 onCreate 里) | 发生 → A 全返 false | 不弹(A 让 rooted=false) |
实测两种状态:
- 装了 deopt 触发器:
[A]触发20次 [B]触发1次(A 少 14 条因为 P6/Q6 直接被 B 短路了) - 没装 deopt 触发器:
[A]触发34次 [B]触发0次(B 形同虚设,A 是主力)
两种状态下 toast 都不弹。A 是这套脚本的硬兜底——File 是 Java 标准库,ART 不 inline,hook 必中;B 是锦上添花——P6/Q6 万一改了名 A 也照样把底层 File.exists 截住。
为什么 B 默认不工作 → ART AOT inline:O6/P6/Q6 都是 private static,体积小,是典型的 inline 候选。ART 在装包时把它们的字节码直接展开到 KgUserLoginAndRegActivity.onCreate 的 native 机器码里——Frida 的 .implementation 挂在 ArtMethod 派发入口,inline 后 CPU 不经过 ArtMethod,hook 永远不响。加 Activity.onCreate no-op hook 让 Frida 触发该方法 deopt 后,ART 丢掉 AOT 机器码降级到解释器执行,解释器逐条解释字节码看到 invoke-static O6 才老实走 ArtMethod 派发,B 才复活。
ART AOT inline 让 Frida hook 失效 · dex / AOT 后机器码 / deopt 后解释器 三层状态对比
通用经验:hook 业务侧的 small-private-static 检测方法装上没报错但收不到事件,第一反应是 ART AOT inline——给持有它的调用方(一般是 Activity.onCreate)挂个 no-op hook 触发 deopt 即可。
六、招商银行:3 套独立 SDK 并联
目标:cmb.pb 13.2.0。症状:启动后两个不同弹窗——CmbShield Toast「检测到您当前设备可能存在风险」+ 后续 AlertDialog「安全风险」+ Activity「注入风险」。
3 套独立反检测 SDK 并联,任意一套命中都弹窗,必须全部伪装:
| SDK | 文件 | 检测内容 | 触发 UI |
|---|
| CmbShield | libCmbShield.so 自研 | frida 自检测 + maps 扫描 + anti-ptrace + dl_iterate_phdr + root 文件扫描 | 写信号文件 → AW.hanDeRe → Toast Cts.RTM |
| RootBeer + SecBaseFunc | dex(com.scottyab.rootbeer 改装)+ libtoolChecker.so | 7 项 root 检测(包名 / File.exists / Runtime.exec which su / Build / sysprop) | CheckRootTask → AlertDialog「安全风险」 |
| DeviceEnvironmentCheck | 动态加载 dex(com.a.b.c.*,不在 base.apk 内) | 周期轮询读 envc.push 文件 0/1 | CheckTask.onPostExecute → DECMsgDialog Activity「注入风险」 |
非加固——12 个 dex 是标准 multidex,没有 libjiagu/libshell/libDexHelper,常规脱壳工具链在这没东西脱。开发把火力放在 3 套并联 + native 检测 + 动态加载 dex 上。下面三节逐套讲检测点和绕过。
6.1 CmbShield 自研防护层
入口三件套(Manifest):
<application
android:appComponentFactory="cmb.shield.AP" <!-- Android 9+ 比 Application 更早 -->
android:name="cmb.shield.AW" <!-- 替身 Application,hanDeRe 弹窗判定中心 -->
... />
cmb.shield.AP → cmb.shield.AW.attachBaseContext → H.load() → System.loadLibrary("CmbShield") → JNI_OnLoad。
JNI_OnLoad 里启动一组 native 检测异步线程:
| 检测点 | 做什么 | 命中后 |
|---|
| parse_self_maps | 读 /proc/self/maps 扫 frida-agent / linjector / gum / re.frida 字符串 | 写信号文件 Cts.A |
| anti_ptrace(run_secsdk) | ptrace(PTRACE_TRACEME) 自己 ptrace 自己,让真调试器 attach 失败 | — |
| TracerPid 双进程 | fork app_process 子进程读自身 /proc/<ppid>/status 的 TracerPid,绕过自 ptrace 后仍能暴露调试器 | 写信号文件 |
| dl_iterate_phdr | linker 内部 SO 枚举(绕过 maps),过滤 dlpi_name 含 frida 的 entry | 写信号文件 |
| check_sd_root_files | 扫 /system/bin/su 等 root 路径 | 写信号文件 Cts.B |
| kill_inject | hook android_dlopen_ext,对 frida-agent.so 主动破坏 | 直接让 frida-agent 注入失败 |
延迟反馈机制:native 命中后不直接弹窗,而是通过 H.sha(key, path) JNI 接口把一个信号文件路径写到静态变量 Cts.A / Cts.B。Java 端 super.onCreate() 才到弹窗判定中心 hanDeRe():
private void hanDeRe() throws Exception {
if (isMp()) {
boolean fDted = dRbf(Cts.B); // File.exists(Cts.B) ? delete + true : false
boolean xDted = dRbf(Cts.A);
if (fDted || xDted) {
Toast.makeText(this, Cts.RTM, 1).show(); // "检测到您当前设备可能存在风险..."
}
if (!fDted) startService(ShieldService.class); // 30s 周期再扫
}
}
三处精巧设计:
- Toast 调用栈完全干净——栈里只有
hanDeRe → dRbf → File.exists → Toast,看不到任何 native 检测痕迹。第 5 章酷狗案例的 Toast 反查工作流在这里失效 - 删后即焚——
dRbf 触发后立即 delete(),逆向者 adb shell 进去看的时候文件早没了 - 服务器侧风控上报——
Clc.scl 异步 POST 到 Cts.G,绕过本机但绕不过账号标记
绕过主路径 —— 掐弹窗判定中心 + 断上报:
// Tier 1 — 弹窗判定中心 noop
const AW = Java.use('cmb.shield.AW');
AW.dRbf.implementation = (path) => false;
AW.hanDeRe.implementation = function () {};
// Tier 2 — 必须配合 Tier 1,否则账号被服务器侧打标
const Clc = Java.use('cmb.shield.Clc');
Clc.scl.implementation = function () {};
Clc.uclbn.implementation = function () {};
Tier 1 灭弹窗,Tier 2 灭上报。Toast 没了不等于绕过——同一份内部状态会被服务器读到,必须用 mitmproxy 抓 Cts.G 域名确认上报真的被断了。
TracerPid 双进程检测的对抗:第 18 篇 fgets 拦 TracerPid 行只覆盖当前进程;子进程要么 hook fork / posix_spawn 之后在子进程也注入脚本(frida 17 的 gating 模式支持),要么 native 层 hook 子进程的 open("/proc/.../status")。详见第 18 篇反调试。
6.2 RootBeer 7 个子检测 + 两个易漏点
RootBeer 是 com.scottyab 的开源 root 检测库,招行用它的改装版(外加 SecBaseFunc.checkRootOld File.exists 兜底)。
Dialog #1 "安全风险" 弹窗
↑ BaseAlertDialog.show
↑ CheckRootTask$a.onSuccess
↑ SecBaseFunc.isRoot()
├─ RootBeer.isRooted() ← OR 7 个子检测
└─ SecBaseFunc.checkRootOld() ← File.exists() 直接扫
7 个子检测:
| 方法 | 检测原理 |
|---|
detectRootManagementApps | PackageManager.getPackageInfo 扫 magisk/supersu 包名 |
checkForBinary("su" / "busybox" / "magisk") | File.exists 扫 16 条 root 路径 |
checkForDangerousProps | __system_property_get 读 ro.debuggable / ro.secure 等 |
checkForRWPaths | File.canWrite("/system", "/data", ...) |
detectTestKeys | Build.TAGS.equals("test-keys") |
checkSuExists | Runtime.exec(new String[]{"which","su"}) ★ 易漏 |
checkForRootNative | JNI → libtoolChecker.so → fopen(path,"r") ★ 易漏 |
前 5 项第 2.1 节 / 第 2.3 节 / 第 2.4 节的 hook 直接覆盖。两个易漏点必须特别处理:
6.2.1 checkSuExists 用数组形式
public boolean checkSuExists() {
Process p = Runtime.getRuntime().exec(new String[]{"which", "su"}); // ★ 数组!
return new BufferedReader(new InputStreamReader(p.getInputStream())).readLine() != null;
}
部分按"只检查 cmds[0]"实现的 hook 会漏——cmds[0] = "which" 不在 su 黑名单。正确做法:对整个 String[] 数组里任一元素含 su / busybox / magisk 都拦。本文第 2.2 节的 hook 是先拼接数组成字符串再 indexOf,所以已经覆盖;写新 hook 时要留意这个坑。
6.2.2 checkForRootNative 用 fopen 而非 access/stat
// libtoolChecker.so 内部
bool exists(const char *path) {
FILE *f = fopen(path, "r"); // ★ fopen,不是 stat/access!
if (f) { fclose(f); return true; }
return false;
}
朴素 native hook(只覆盖 stat/lstat/access/faccessat)漏。第 3 章主代码块的 ④ fopen 拦截已经覆盖——这是为什么第 3 章必须把 fopen 加进 isRootPath 通配。
完整伪装验证(应用全套 hook 后跑 research2.js):
RootBeer.checkSuExists() = false ✓ ← Runtime.exec 数组覆盖
RootBeer.checkForRootNative() = false ✓ ← fopen 拦截
RootBeer.checkForMagiskBinary() = false ✓
RootBeer.isRooted() = false ✓✓✓ (招行自己判定环境干净)
Magisk 也不再弹"超级用户请求"——因为 Runtime.exec / native execve 都拦在源头,没真去申请 su。
6.3 DeviceEnvironmentCheck:动态加载第三方 SDK
完整解决 RootBeer 后 App 仍弹「注入风险」——第三套独立 SDK:
Activity DECMsgDialog "注入风险"
↑ BaseFragmentActivity.startActivityForResult
↑ DeviceEnvironmentCheck$MsgDialog.show
↑ DeviceEnvironmentCheck.showMessage
↑ DeviceEnvironmentCheck$CheckTask.onPostExecute
↑ DeviceEnvironmentCheck$CheckTask$1$1.run(周期轮询)
包名混淆为 com.a.b.c.*,dex 不在 base.apk 任何位置(binary grep + dexdump -l plain 全 0 命中),是运行时动态加载的。
ClassLoader 突破:Java.use('com.a.b.c.DeviceEnvironmentCheck') 在主 factory 失败,要枚举所有 loader 找能 findClass 的:
let loader = null;
Java.enumerateClassLoadersSync().forEach(l => {
if (loader) return;
try { if (l.findClass('com.a.b.c.DeviceEnvironmentCheck')) loader = l; } catch (_) {}
});
const factory = Java.ClassFactory.get(loader);
const DEC = factory.use('com.a.b.c.DeviceEnvironmentCheck');
检测机制:AsyncTask.doInBackground 周期循环(LOOP_TIME 间隔,最长 MAX_TIME)调 getCheckResult(key),返回 0/1。getCheckResult 内部只做一件事——读 /data/user/0/cmb.pb/envc.push 文件:
ENTER getCheckResult("frida")
IO open ("/data/user/0/cmb.pb/envc.push")
IO fopen("/data/user/0/cmb.pb/envc.push")
EXIT getCheckResult("frida") = 1
envc.push 是 25 字节文件(24 char Base64 + \n),解码后 16 字节正好 1 个 AES-128 block 密文,每次启动重新生成(pm clear 后),AES key 是随机 session key:
$ adb shell su -c "cat /data/user/0/cmb.pb/envc.push"
s8RY6BbWj1lfdgQxncxNmQ==
envc.push 未解之谜:hook 了所有 libc 写 API(open/openat/openat2/creat/__open_2/fopen/write/fwrite/fputs/pwrite/rename/truncate 等近 20 个),全部 0 命中。但时序证据显示 envc.push 在某次 doInBackground EXIT 之后突然存在了。推测写者用了 raw inline syscall(mov x8, #__NR_openat; svc #0)绕过 libc 函数层,定位需 Stalker 或指令级 trace,留作扩展。
绕过路径:不需要破解 AES,从 Java 层最高点拦:
// Tier A: getCheckResult 永远返 0(推荐,最简)
DEC.getCheckResult.implementation = function (key) { return 0; };
// Tier B: Support 黑名单全空(额外保险)
const Support = factory.use('com.a.b.c.DeviceEnvironmentCheck$Support');
Support.isSupport.implementation = function (k) { return false; };
Support.isWarn.implementation = function (k) { return false; };
Support.isWarnAndExit.implementation = function (k) { return false; };
// Tier C: saveInjectInfo noop(阻止持久化命中事件)
const CheckTask = factory.use('com.a.b.c.DeviceEnvironmentCheck$CheckTask');
CheckTask.saveInjectInfo.implementation = function () {};
A+B+C 三层组合后,App 主界面完全没有任何风险弹窗,Magisk 也不再弹"超级用户请求被拒绝"。
6.4 环境伪装 vs UI 兜底
三套 SDK 都伪装完之后,回头看整篇思路。脚本采用的是环境伪装——hook 检测 API 改输入数据,让 App 跑完所有检测但读到的环境数据全是干净的,App 真的判定自己处于干净环境。
环境伪装的最小完备 API 覆盖清单——招行实战验证:
Native 层(libc / libdl):
fopen ← maps 扫描 / fopen 探 root(招行核心点)
fread ← 配合 fopen 读 maps,stream 内 chunk 字串替换
stat / lstat / access / faccessat ← 探 root 文件
execve / execv / execvp / posix_spawn ← native fork+exec su
system / popen ← shell 命令
__system_property_get ← 读 sysprop
dl_iterate_phdr ← linker 内部 SO 枚举,wrap callback 过滤 dlpi_name 含 frida
Java 层:
Runtime.exec 所有 4 个重载 ← 数组形式必须查任一元素
ProcessBuilder.start ← command list 含 su 类
File.exists / canRead / canExecute ← root 路径
Build.TAGS / Build.FINGERPRINT ← test-keys 替换
ApplicationPackageManager.getPackageInfo / getApplicationInfo
System.getenv("PATH") ← 过滤 sbin/xbin
DEC SDK(专用 ClassFactory):
getCheckResult(key) → 永远 0
Support.isSupport/Warn/... → false
CheckTask.saveInjectInfo → noop
第 2、3 章主代码块已经覆盖大部分。招行特有的两条扩展全文已经吸收:① 第 2.2 节 Runtime.exec 拼接后 indexOf 已覆盖数组任一元素;② 第 3 章 Native 主代码块的 ④ 已含 fopen。
6.5 启动方式 + 必备配置
spawn 启动——检测在 attachBaseContext 就跑,attach 晚一拍全晚:
frida -U -f cmb.pb -l cmb_bypass.js --no-pause
设备配置:
- Zygisk + DenyList 把
cmb.pb 加进去——避免 RootBeer 早早命中 - mitmproxy 抓
Cts.G 域名验证 Tier 2 上报真的被断了 - 长时间运行还要处理周期检测:CmbShield 的
ShieldService(30s 一次)和 DEC 的 CheckTask(LOOP_TIME 间隔轮询,最长 MAX_TIME)
总结
四个维度的 Hook 点速查(已同步 第 2、3 章 全部点):
| 维度 | 关键证据 | 着力点 |
|---|
| 文件 | su / Magisk / KernelSU / SuperSU 文件 | Java:File.exists + canExecute + canRead 三件套;Native:access/faccessat + stat/lstat/fstatat + open/openat + fopen 共 10+ 函数。路径用 endsWith("/su") 通配 |
| Shell | which su / su -c id / mount | Runtime.exec 所有重载拦截 |
| 系统属性 | ro.build.tags=test-keys / ro.debuggable=1 / verified boot | SystemProperties.get 双重载 + Build.TAGS / Build.TYPE 静态字段反射 |
| 包名 | Magisk Manager / SuperSU / Xposed 包名 | getPackageInfo × 2 + getInstalledPackages × 2 + getInstalledApplications × 2,共 6 入口 |
实战要点:
- Native 先于 Java + spawn——
JNI_OnLoad 早于 Java 检测线程,招行 6 个检测都在这里启动,attach 来不及。每段 Hook 用 try/catch 兜,打失败 .message 方便下次定位。 - 路径通配 > 白名单——
endsWith("/su") || indexOf("magisk") 比 20 条路径列表稳。酷狗 Q6() 扫 $PATH,命中 /system_ext/bin/su(Magisk systemless 常见位置),老清单挡不住。 - 加固 SDK 找输出口——第 4.3 节 四步法。grep 第一个候选可能是迷雾(酷狗 FireEye),用 第 5 章 Toast / Dialog 反查验证。Hook 装上没报错但收不到事件,多半是 ART AOT inline——给持有它的 Activity 方法挂个 no-op hook 触发 deopt(详见 第 5.2 节)。
- Toast 栈干净 ≠ 没检测——招行延迟反馈:native 命中只写信号文件,Java
onCreate 才 File.exists 弹窗。搜常量 / 信号文件路径的 caller(第 6.3 节 ①),或 hook 全部 new File().exists() 看哪些路径被访问。
📦 获取本篇脚本
本篇整套 Root 检测绕过脚本(Java 四维 Hook + Native 层 libc Hook + 酷狗 / 招行双实战绕过,9 个脚本 + RUN.md 真机验证清单,复制即用)已打包:
- 关注本公众号
- 私信回复关键词「脚本」
回复内含本系列与其它系列(Unidbg / SO 逆向 / ARM 汇编 ……)的脚本汇总,长期维护更新。