Android Root 检测原理
- [1. Android Root 检测的基本思路](#1. Android Root 检测的基本思路)
- [2. 什么是 Android Root](#2. 什么是 Android Root)
-
- [Linux/Android 权限模型里的 root](#Linux/Android 权限模型里的 root)
- [Root 不是一个布尔开关](#Root 不是一个布尔开关)
- [3. 为什么 Root 检测永远不可能 100% 准确](#3. 为什么 Root 检测永远不可能 100% 准确)
- [4. 具体检测项](#4. 具体检测项)
-
- [检测 Root 管理类 App](#检测 Root 管理类 App)
- [检测危险 App](#检测危险 App)
- [检测 Root 隐藏类 App(需要观察下误报情况)](#检测 Root 隐藏类 App(需要观察下误报情况))
- [检测 `test-keys`](#检测
test-keys) - [通过文件路径检查 `su`](#通过文件路径检查
su) - [检测 `magisk`](#检测
magisk) -
- [同 su 文件的查询方式](#同 su 文件的查询方式)
- [查 Magisk 文件痕迹](#查 Magisk 文件痕迹)
- [查 Unix Domain Socket(需要观察下误报情况)](#查 Unix Domain Socket(需要观察下误报情况))
- 检测危险系统属性
- 检测关键系统路径是否可写
1. Android Root 检测的基本思路
Android Root 检测不是去读取某个系统开关,然后得到"这台手机已经 root"或"这台手机没有 root"的绝对结论。正确的思路是:
-
收集很多和 root 高度相关的信号
-
只要其中一个或多个信号命中,就把设备判定为存在 root 迹象
-
把这些迹象组合成一个最终结果
原因很简单:一旦攻击者拿到 root,他在本机上的权限就非常高,理论上可以反过来欺骗你的检测逻辑。所以本地 root 检测从来都是一个对抗问题,不是一个静态判断
2. 什么是 Android Root
Linux/Android 权限模型里的 root
Android 底层是 Linux。Linux 中最特殊的用户是超级用户,也就是 root,对应 UID 0。拥有 root 身份后,进程可以:
-
访问本来不允许访问的文件
-
修改系统分区
-
注入或替换系统组件
-
干预别的进程
-
绕过很多普通 App 的安全边界
Android 在 Linux 之上又加了一层更严格的应用沙箱:
-
每个 App 默认有自己的 UID
-
每个 App 的数据目录彼此隔离
-
权限受 Android framework 和 SELinux 共同约束
所以正常 App 即使拿到了危险权限,也不等于拿到了 root。Root 的本质,是让某个进程获得超出普通应用模型的超级权限。
Root 不是一个布尔开关
Root 并不是系统里有一个公开字段,例如:
Plaintext
device.rooted = true
现实里 root 可能表现为很多不同形态:
-
系统里有
su二进制文件 -
安装了 Magisk、SuperSU 一类 root 管理器
-
某些系统分区变成可写
-
设备运行的是带
test-keys的工程构建 -
系统属性处于不安全配置
-
存在 Xposed、RootCloak 这类隐藏或注入框架
3. 为什么 Root 检测永远不可能 100% 准确
root == god
一旦对方拿到 root,他就有能力修改、拦截、伪造你的检测路径
-
你可以查
File.exists(),但对方可以 Hook 它 -
你可以执行
which su,但对方可以伪造 PATH 或拦截命令执行 -
你可以查
getprop,但对方可以改输出 -
你可以查
mount,但对方可以隐藏挂载信息 -
你可以下沉到 Native,攻击者还可以继续 Hook libc 或 JNI
因此,本地 root 检测的目标不是绝对正确,而是:
-
降低攻击者绕过检测的成功率
-
提高攻击者隐藏 root 的成本
-
给风控系统提供一个设备/账号可信度
4. 具体检测项
检测 Root 管理类 App
安装某些 App,本身就是非常强的 root 相关信号。检查设备上是否安装了已知 root 管理类应用:
Plain
com.noshufou.android.su
com.noshufou.android.su.elite
eu.chainfire.supersu
com.koushikdutta.superuser
com.thirdparty.superuser
com.yellowes.su
com.topjohnwu.magisk
com.kingroot.kinguser
com.kingo.root
com.smedialink.oneclickroot
com.zhiqupk.root.global
com.alephzain.framaroot
可以遍历包名列表,如果能拿到 Root 管理类应用包信息,说明这个包存在,返回 true。
局限性
-
包名库是静态的,查不到未知工具
-
攻击者可以隐藏、卸载或改包名
-
某些工具存在但不代表 root 当前可用,但高度可疑(正常用户不会装这些软件)
检测危险 App
这里的名单比 root 管理器更宽,包括 Lucky Patcher、ROM Manager、部分模块框架管理器等。
在真实风控场景中,部分精明的攻击者会对 root 管理器做一些隐藏,但常常会安装一些周边工具。周边工具未必说明已经 root,但经常能作为很好的辅助证据。
Plain
com.koushikdutta.rommanager
com.koushikdutta.rommanager.license
com.dimonvideo.luckypatcher
com.chelpus.lackypatch
com.ramdroid.appquarantine
com.ramdroid.appquarantinepro
com.android.vending.billing.InAppBillingService.COIN
com.android.vending.billing.InAppBillingService.LUCK
com.chelpus.luckypatcher
com.blackmartalpha
org.blackmart.market
com.allinone.free
com.repodroid.app
org.creeplays.hack
com.baseappfull.fwd
com.zmapp
com.dv.marketmod.installer
org.mobilism.android
com.android.wp.net.log
com.android.camera.update
cc.madkite.freedom
com.solohsu.android.edxp.manager
org.meowcat.edxposed.manager
com.xmodgame
com.cih.game_cih
com.charles.lpoqasert
catch_.me_.if_.you_.can_
检测 Root 隐藏类 App(需要观察下误报情况)
这里会查一些典型的 root 隐藏或注入框架 App。如果一台设备安装了专门隐藏 root 的框架,那么从安全角度讲,它已经高度可疑了。
Plain
com.devadvance.rootcloak
com.devadvance.rootcloakplus
de.robv.android.xposed.installer
com.saurik.substrate
com.zachspong.temprootremovejb
com.amphoras.hidemyroot
com.amphoras.hidemyrootadfree
com.formyhm.hiderootPremium
com.formyhm.hideroot
检测 test-keys
Android 固件在构建和签名时,常见会出现类似标签:
-
release-keys -
test-keys
通常来说:
-
release-keys更接近正式量产发布构建 -
test-keys更常见于 AOSP、自编译 ROM、工程构建或某些测试环境
此处直接读取:
Java
android.os.Build.TAGS
如果里面包含 test-keys,代表设备不是标准官方量产系统,系统可能被修改过,与刷机、调试、root 环境的相关性更高。返回 true
通过文件路径检查 su
什么是 su?
su 是 Unix/Linux 里非常经典的提权入口。在 Android root 场景中,通常意味着设备存在从普通身份切换到超级用户身份的能力。
这类检测的本质,是设备文件系统里,是否能找到 root 之后常见的二进制落点。
检测策略
硬编码路径
第一部分是硬编码的常见路径,此部分是:收集历史上各种 root 方案、刷机包、定制 ROM、工具链常见的落点目录,然后在目录后面拼接文件名:su
示例代码:
java
for (String path : pathsArray) {
String completePath = path + filename;
File f = new File(path, filename);
boolean fileExists = f.exists();
}
Plain
/data/local/
/data/local/bin/
/data/local/xbin/
/sbin/
/su/bin/
/system/bin/
/system/bin/.ext/
/system/bin/failsafe/
/system/sd/xbin/
/system/usr/we-need-root/
/system/xbin/
/system_ext/bin/
/cache/
/data/
/dev/
Path 拼接
PATH 代表的是:当前进程执行命令时默认会去哪些目录里找可执行文件。
如果只查固定路径,攻击者把 su 放到一个不在列表中的可执行目录,就可能绕过静态规则。
把 PATH 动态并进来,相当于扩大了搜索面。
具体策略是:把 PATH 按 : 拆开,逐个加入路径列表,然后拼接 su ,检测文件是否存在
局限性
-
su可以被隐藏、改名、按需挂载 -
某些 root 方案可能不在经典路径落盘
-
Java 文件访问 API 可以被 Hook
通过 which su 再查一次
实现方式是执行系统命令:
Java
Runtime.getRuntime().exec(new String[] { "which", "su" });
然后读取标准输出,只要有结果行,就认定 su 存在。
局限性
-
which本身可能不存在或被替换 -
命令执行结果可以被 Hook
-
PATH 可以被操控
单独做一遍 Native 检测
把检查 su 文件是否存在,除了在 Java 层做一次,还在 Native 层再做一次
因为 Java 层常见的几个点很容易成为 Hook 目标:
-
File.exists() -
Runtime.exec() -
PackageManager.getPackageInfo()
而把检查放到 Native 后,攻击者如果还想无痕绕过,就需要更进一步:
-
拦截 JNI 调用
-
干预动态库加载
-
Hook libc 的
fopen -
或直接屏蔽/替换 Native 库
绕过成本就变高了
检测 magisk
同 su 文件的查询方式
逻辑和查 su 文件一样,只不过查的是 magisk 这个二进制或相关落点。
现代 Android root 生态里,Magisk 的地位非常高。即使攻击者对 su 做了更多隐藏,相关的 Magisk 痕迹仍然可能留下来。
策略:路径复用检查 su 文件的路径,只不过二进制文件改为:magisk
查 Magisk 文件痕迹
即使 Magisk App 图标不见了、Java 层也看不到,它在系统里留下的安装残留、启动脚本、镜像文件、日志文件,仍然可能暴露存在
bash
/dev/.magisk.unblock
/sbin/magiskinit
/sbin/magisk
/sbin/.magisk
/data/adb/magisk.img
/data/adb/magisk.db
/data/adb/.boot_count
/data/adb/magisk_simple
/data/adb/magisk
/cache/.disable_magisk
/cache/magisk.log
/init.magisk.rc
查 Unix Domain Socket(需要观察下误报情况)
/proc/net/unix 里列的是系统当前的 Unix Domain Socket。看 socket 名字是不是符合一种"很像被刻意隐藏过"的形态,重点关注:
- 名字前缀是 @
- 去掉前缀后,不包含 /、空格、. 这些普通 socket 常见字符
- 如果剩下的名字长度达到 32 位以上,就认为非常可疑
检测危险系统属性
重点看两项属性:
-
ro.debuggable = 1表示系统更偏调试构建 -
ro.secure = 0表示系统安全边界更松
直接执行:
Java
getprop
然后把整段输出按行拆开,逐行匹配目标键值。不能只查数字 1 或 0,而是按 Android getprop 典型输出格式,检查是否包含(防止误报):
-
[1] -
[0]
局限性
-
一些非官方系统会带这些值,但不一定等价于 root
-
属性输出可以被伪造
-
现代 root 环境可能把这些属性清理得很干净
检测关键系统路径是否可写
执行 mount 命令,然后解析挂载表,去看如下关键路径是否被挂载成 rw:
Bash
/system
/system/bin
/system/sbin
/system/xbin
/vendor/bin
/sbin
/etc
正常量产设备中,这些关键系统路径通常不应该被轻易写入。如果它们以 rw 方式挂载,说明:
-
系统分区可被修改
-
攻击者可能已经获得足够高的系统控制力
-
设备更可能处于 root、刷机、篡改或工程态
注意:不同 Android 版本下,mount 命令的输出格式不一样。
Android 6 及以下 老格式:
Plaintext
<device> <mount_point> <type> <options>
Android 6 以上 新格式:
Plaintext
<device> on <mount_point> type <fs_type> (<options>)
因此代码需要根据 SDK_INT 决定:
-
挂载点在哪个字段
-
挂载选项在哪个字段
Android 6 以上的挂载选项外层还有括号,所以需要做好括号清理,再按逗号拆分,最后精确匹配 rw,参考代码:
java
public boolean checkForRWPaths() {
boolean result = false;
//Run the command "mount" to retrieve all mounted directories
String[] lines = mountReader();
if (lines == null){
// Could not read, assume false;
return false;
}
//The SDK version of the software currently running on this hardware device.
int sdkVersion = android.os.Build.VERSION.SDK_INT;
/**
*
* In devices that are running Android 6 and less, the mount command line has an output as follow:
*
* <fs_spec_path> <fs_file> <fs_spec> <fs_mntopts>
*
* where :
* - fs_spec_path: describes the path of the device or remote filesystem to be mounted.
* - fs_file: describes the mount point for the filesystem.
* - fs_spec describes the block device or remote filesystem to be mounted.
* - fs_mntopts: describes the mount options associated with the filesystem. (E.g. "rw,nosuid,nodev" )
*
*/
/** In devices running Android which is greater than Marshmallow, the mount command output is as follow:
*
* <fs_spec> <ON> <fs_file> <TYPE> <fs_vfs_type> <(fs_mntopts)>
*
* where :
* - fs_spec describes the block device or remote filesystem to be mounted.
* - fs_file: describes the mount point for the filesystem.
* - fs_vfs_type: describes the type of the filesystem.
* - fs_mntopts: describes the mount options associated with the filesystem. (E.g. "(rw,seclabel,nosuid,nodev,relatime)" )
*/
for (String line : lines) {
// Split lines into parts
String[] args = line.split(" ");
if ((sdkVersion <= android.os.Build.VERSION_CODES.M && args.length < 4)
|| (sdkVersion > android.os.Build.VERSION_CODES.M && args.length < 6)) {
// If we don't have enough options per line, skip this and log an error
QLog.e("Error formatting mount line: "+line);
continue;
}
String mountPoint;
String mountOptions;
/**
* To check if the device is running Android version higher than Marshmallow or not
*/
if (sdkVersion > android.os.Build.VERSION_CODES.M) {
mountPoint = args[2];
mountOptions = args[5];
} else {
mountPoint = args[1];
mountOptions = args[3];
}
for(String pathToCheck: Const.pathsThatShouldNotBeWritable) {
if (mountPoint.equalsIgnoreCase(pathToCheck)) {
/**
* If the device is running an Android version above Marshmallow,
* need to remove parentheses from options parameter;
*/
if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.M) {
mountOptions = mountOptions.replace("(", "");
mountOptions = mountOptions.replace(")", "");
}
// Split options out and compare against "rw" to avoid false positives
for (String option : mountOptions.split(",")){
if (option.equalsIgnoreCase("rw")){
QLog.v(pathToCheck+" path is mounted with rw permissions! "+line);
result = true;
break;
}
}
}
}
}
return result;
}
局限性
- 现代 systemless root 方案不一定真的把
/system变成可写