分析一个竞品的app的时候发现手机打开就闪退,按照以往的经验直接用apatch开启内核root,然后app基本上就能过,这次是碰到个狠的,发现开启lite模式都不行,还是能检测到并闪退,于是研究了一番它的检测最终成功在手机上实现魔改的框架进行hook,下边是整理的文档
1. 概述
某商业安全 SDK 采用 SaaS 加固模式,在运行时执行多层环境检测。检测到异常后通过信号终止进程并上报检测结果。本文还原其客户端检测的完整技术原理。
2. 检测架构
该 SDK 的检测分为两个独立层面,采用完全不同的技术路线:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | ┌────────────────────────────────────────────────────────┐
│ 第一层: Java/JNI 检测 │
│ 通信方式: Binder IPC │
│ 检测目标: 已安装应用、开发者选项、PM 代理 │
│ 特点: 不走文件系统 syscall,seccomp 无法拦截 │
├────────────────────────────────────────────────────────┤
│ 第二层: Native syscall 检测 │
│ 通信方式: 内联 SVC
│ 检测目标: root 文件路径、/proc 虚拟文件、fd 映射 │
│ 特点: PLT/GOT hook 无效,仅 seccomp-bpf 可拦截 │
├────────────────────────────────────────────────────────┤
│ 自杀机制: SIGALRM 定时器 │
│ 上报机制: 后台 Service 上传检测结果 │
└────────────────────────────────────────────────────────┘
|
两层检测独立运行、互不依赖,分别由不同的 native SO 执行:
| SO 文件 |
大小 |
职责 |
libcovault-appsec.so |
~1.7M |
第一层:Java/JNI 检测(全量包扫描、PM 代理、开发者选项) |
libtoolChecker.so |
~5.4K |
第二层:Native syscall 检测(路径扫描、/proc 读取、fd 遍历) |
libSecureComponent.so |
~1.2M |
辅助安全组件 |
3. 第一层: Java/JNI 检测 (libcovault-appsec.so)
3.1 整体流程
在 Application.onCreate 后立即通过 JNI 调用 Android Java API 执行检测,整个流程约 2 秒。
1 2 3 4 5 6 7 8 | Application.onCreate
├── SDK 初始化
├── PackageManager 代理检测
├── 全量已安装应用扫描 (核心)
├── DevicePolicyManager 检测
├── USB 调试 / 开发者选项检测
├── JSON 组装检测结果
└── 启动 Service 上报
|
3.2 全量包扫描
核心检测手段。通过 native 层 JNI 调用 PackageManager API:
1 2 3 4 5 | // 第一轮:获取所有已安装包(含元数据)
PackageManager.getInstalledPackages(GET_META_DATA)
// 第二轮:获取所有已安装应用信息
PackageManager.getInstalledApplications(GET_META_DATA)
|
对每个应用提取多维度特征:
| 特征 |
API |
用途 |
| 包名 |
ApplicationInfo.packageName |
黑名单比对 |
| APK 路径 |
ApplicationInfo.sourceDir |
安装位置识别 |
| 应用名称 |
getApplicationLabel() |
辅助识别 |
| 权限列表 |
getPackageInfo(GET_PERMISSIONS) |
敏感权限检测 |
| Xposed 元数据 |
Bundle.getString("xposeddescription") |
Xposed 模块检测 |
| ContentProvider |
ProviderInfo.authority |
特定 Provider 检测 |
关键特点:
- 全量扫描:不是只查特定包名,而是遍历设备上全部已安装应用,扫描结果上传服务端判定
- 通过 JNI 执行:native 层直接调用 JNI 的
CallObjectMethodV,绕过了 Java 层 Method Hook
- Xposed 模块识别:检查每个应用的
AndroidManifest.xml 是否包含 xposeddescription 元数据
3.3 PackageManager 代理检测
1 2 3 4 5 | Object sPackageManager = ActivityThread.sPackageManager;
Proxy.getInvocationHandler(sPackageManager);
|
检测通过 java.lang.reflect.Proxy 动态代理 IPackageManager 的 hook 方案。
3.4 开发者选项检测
1 2 | Settings.Secure.getString("adb_enabled")
Settings.Global.getString("development_settings_enabled")
|
值为 "1" 则标记为异常。
3.5 为什么 seccomp 拦截不到
getInstalledPackages 走 Binder IPC:
1 | app 进程 → ioctl(binder_fd, BINDER_WRITE_READ) → system_server → 包数据库 → Binder → app 进程
|
数据流走 ioctl(Binder 底层),不涉及 openat/faccessat 等文件系统 syscall。seccomp-bpf 只能拦截特定 syscall,无法干预 Binder 通信内容。
4. 第二层: Native syscall 检测 (libtoolChecker.so)
4.1 整体流程
由独立的轻量级 SO(仅 5.4K)在后台线程中执行:
1 2 3 4 5 | 后台线程
├── [1] faccessat 路径扫描 (70+ 条)
├── [2] statx 二次验证 (关键路径)
├── [3] /proc 虚拟文件读取 (maps/status/mounts)
└── [4] fd 遍历 (newfstatat + readlinkat)
|
4.2 文件路径扫描
直接内联 SVC 指令:检测 SO 不通过 libc 封装函数(如 access()、stat()、open())发起系统调用,而是在代码中直接使用内联汇编 SVC #0 指令。这意味着 PLT hook(如 xhook/bhook 等基于 GOT/PLT 表修改的方案)完全无法拦截这些调用,因为执行路径根本不经过 libc 的函数入口。
// 伪代码示意 (arm64)
MOV X8, #48 // __NR_faccessat
MOV X0, #-100 // AT_FDCWD
LDR X1, =path_str // 文件路径
MOV X2, #0 // F_OK
SVC #0 // 直接陷入内核,不经过 libc
这是一种常见的反 hook 技术:绕过用户态所有函数级 hook(PLT hook、GOT hook、inline hook libc),只有内核级拦截(seccomp-bpf)才能干预。
通过 faccessat(AT_FDCWD, path, F_OK, 0) 检测 root 相关文件,覆盖 70+ 条路径:
su 二进制 (~24 条):
遍历所有常见 su 安装位置,包括 /system/bin/su、/system/xbin/su、/sbin/su、/vendor/bin/su、/apex/com.android.runtime/bin/su 等。
Magisk (~29 条):
覆盖 Magisk 的全部可能存在路径,包括 /sbin/magisk、/data/adb/magisk、/cache/magisk.log、/system/addon.d/99-magisk.sh 等。
SuperSU / daemonsu (~12 条):
包括 /.subackup、/data/.supersu、/su/bin/daemonsu 等。
Xposed / Substrate (~6 条):
包括 /system/lib/libsubstrate.so、/magisk/xposed/system/lib/libart.so 等。
4.3 statx 二次验证
在 faccessat 扫描之后,使用 statx(syscall 291)对关键路径进行二次验证。
statx 是 Linux 4.11 引入的新 syscall,功能类似 stat 但 syscall 编号不同。如果 seccomp 过滤器只覆盖了 faccessat (48) 和 newfstatat (79),没有覆盖 statx (291),检测就能绕过拦截。
同理,faccessat2(syscall 439,Linux 5.8 引入)也可能被用于绕过不完整的 syscall 过滤。
原理:
1 2 3 4 5 | // 同一个文件,两个不同 syscall 编号
faccessat(AT_FDCWD, "/system/bin/su", F_OK) // syscall 48
statx(AT_FDCWD, "/system/bin/su", 0, 0, buf) // syscall 291
// 如果 seccomp 只拦截了 48,291 就漏过去了
|
4.4 /proc 虚拟文件检测
| 文件 |
检测内容 |
异常指标 |
/proc/self/maps |
内存映射中的 SO 文件名 |
存在 hook 框架相关 SO |
/proc/self/status |
Seccomp: 字段 |
值为 2 说明安装了 seccomp 过滤器 |
/proc/self/status |
TracerPid: 字段 |
非 0 说明正在被调试 |
/proc/self/mounts |
挂载点列表 |
存在 Magisk overlay 等异常挂载 |
正常应用的 Seccomp: 值为 0,安装了 seccomp 过滤器的进程值为 2。检测 SDK 利用这一点反过来识别使用 seccomp 进行对抗的沙箱。
4.5 fd 遍历
遍历 /proc/self/fd/ 下的所有文件描述符:
1 2 3 4 5 | 对每个 fd N:
newfstatat(AT_FDCWD, "/proc/self/fd/N", &stat, 0)
readlinkat(AT_FDCWD, "/proc/self/fd/N", buf, size)
→ 获取 fd 指向的文件路径
→ 检查路径是否包含敏感关键词
|
此方法可以发现通过 dlopen 加载但已从 maps 中删除的 SO — 即使 /proc/self/maps 被过滤,已打开的 fd 仍然指向原始文件。
5. 自杀与上报机制
5.1 SIGALRM 自杀
1 2 3 4 | 启动时: alarm(N) 设置定时器
检测完成:
├── 全部通过 → 取消定时器,正常运行
└── 发现异常 → 定时器到期 → SIGALRM → 进程死亡
|
选择 SIGALRM 而非 SIGKILL 的原因:
- 日志中显示为 "Alarm clock" 而非 "Killed",更隐蔽
- Java 层的
UncaughtExceptionHandler 无法捕获信号级进程终止
- 应用看起来像正常超时退出
5.2 多进程架构
1 2 3 | 主进程 ← seccomp 已安装
隔离进程 1 ← 后台服务,上报检测结果
隔离进程 2 ← 看门狗,监控主进程
|
看门狗进程运行在独立进程空间,不受主进程 seccomp 影响。它可以从外部读取主进程的 /proc/PID/status 和 /proc/PID/maps,理论上可以检测主进程是否安装了 seccomp 或被注入了 SO。
6. Inline Hook 检测
SDK 会校验关键函数(如 pthread_create)的入口指令:
1 2 3 4 | void *func = dlsym(libc, "pthread_create");
uint32_t first_insn = *(uint32_t *)func;
|
Dobby 等 inline hook 框架会修改函数前几个字节为跳转指令。此检测会导致使用 inline hook 修改 libc 函数的方案失效。
7. 检测使用的 syscall 汇总
| syscall |
编号 (arm64) |
用途 |
faccessat |
48 |
文件存在性检查 (主要) |
statx |
291 |
文件状态扩展查询 (二次验证) |
faccessat2 |
439 |
文件访问检查 v2 |
openat |
56 |
打开文件 |
read |
63 |
读取 /proc 虚拟文件 |
newfstatat |
79 |
fd 状态查询 |
readlinkat |
78 |
读取 fd 链接目标 |
fstat |
80 |
fd 状态查询 |
statfs |
43 |
文件系统状态 |
8. 数据收集行为
SDK 在检测过程中收集并上报以下数据:
| 数据类型 |
收集方式 |
| 设备全部已安装应用列表(包名、路径、权限、Provider) |
PackageManager API |
| 每个应用的完整权限列表 |
getPackageInfo(GET_PERMISSIONS) |
| Xposed 模块标识 |
Bundle.getString("xposeddescription") |
| USB 调试状态 |
Settings.Secure |
| 进程内存映射 |
/proc/self/maps |
| 进程挂载信息 |
/proc/self/mounts |
| 进程文件描述符列表 |
/proc/self/fd 遍历 |
全量应用扫描(遍历设备所有已安装应用并提取详细信息)超出了安全检测的合理最小范围。Android 11 引入包可见性限制正是为了限制此类行为。
9. 检测局限性
架构层面
- 权限不对等:检测 SDK 运行在用户态,kernel root 运行在内核态,检测者权限低于被检测者
- syscall 可被拦截:所有文件系统检测通过标准 syscall 执行,seccomp-bpf 是内核级拦截机制
- Binder 返回值可被修改:PM 查询走 Binder,返回值经过 Java 层可被过滤
- 单次检测:启动时一次性执行,无持续监控
实现层面
- 内联 SVC 仍可被拦截:虽然使用
SVC #0 绕过了 PLT/GOT hook,但 seccomp-bpf 工作在内核层面,在 syscall 进入内核时拦截,与用户态调用方式无关。无论是 libc 封装还是内联 SVC,最终都要通过 SVC #0 陷入内核,seccomp 都能拦截
- 路径硬编码:su/magisk 路径列表固定在 SO 中
- 未校验拦截行为:只看结果(文件存在/不存在),不验证 syscall 是否被拦截
- SIGALRM 依赖检测结果:全部通过就不触发自杀
- 看门狗进程未充分利用:理论上可从外部检测主进程异常,实际未观察到充分利用
与更强方案的对比
| 方案 |
检测层面 |
绕过难度 |
| 本 SDK (文件扫描) |
用户态 syscall |
低 (seccomp) |
| 本 SDK (包扫描) |
用户态 Binder |
低 (Java hook) |
| Play Integrity API |
硬件 TEE + 服务端 |
极高 |
| 服务端行为分析 |
服务端 |
视实现而定 |
| 内核级检测 |
内核 |
极高 |
纯客户端用户态检测方案的根本局限在于:检测代码和被检测环境运行在同一权限层级(甚至更低),无法建立可信的信任根。真正有效的检测需要将信任根转移到硬件(TEE)或服务端。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!