首页
社区
课程
招聘
[原创]用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证
发表于: 2026-6-22 18:29 2520

[原创]用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证

2026-6-22 18:29
2520

用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证

配套实现:Sentry 的 D7 检测通道(class_linker_scan.cpp)。本文所有日志与截图均取自 Pixel 6 Pro / Android 16(API 36),LSPosed 模块真机注入。

LSPosed 配合 Shamiko 之后,绝大多数传统反 Hook 手段都失效了。扫 /proc/self/maps 找异常 SO、dlopen/dlsym 探框架导出、Java 层抛异常回溯堆栈找 XposedBridge——这些都建立在"框架会在某个可观测面留下痕迹"的假设上,而 Shamiko 的全部工作就是抹掉这些痕迹:隐藏文件、隐藏 maps 条目、隐藏包名。攻击者甚至能 Hook open/read 乃至系统调用,给检测 SDK 喂一份完美的假数据。

看雪 thread-289567 提出了一条跳出 API 维度的思路:不去问系统"装没装框架",而是直接数虚拟机里存活的 ClassLoader 数量。这条路的价值在于它命中了 ART 的一个结构性死局——但原文作者只在 Android 13/14 上验证,并坦言"我的 SDK 目前不敢上线"。本文做三件事:讲清这条死局的原理,把它在 Android 16 上真正跑通(原文实现到了 15/16 会直接失效,原因后面会拆),然后用真机 LSPosed 正负样本对照,证明它能在所有 Java 层检测被绕过时单独把注入抓出来。


一、死局:ClassLinker 的 class_loaders_ 链表

ART 的 Runtime 持有一个 ClassLinker,后者内部维护一条 std::list——class_loaders_,登记当前进程里所有存活的 ClassLoader。这条链表是 GC 可达性的一部分:ClassLoader 在链上,它加载的类和代码就被视为可达,不会被回收。

LSPosed 要把模块代码注入目标进程,就必须为模块创建 ClassLoader(实践中是 InMemoryDexClassLoader,直接从内存里的 dex 字节加载,连文件都不落地)。于是它陷入一个无法两全的局面:

  • 要存活:模块 ClassLoader 必须挂在 class_loaders_ 上,否则 GC 判定它不可达,下一轮回收就把模块代码卸了,Hook 当场失效甚至崩溃。
  • 要隐藏:它得从一切可观测面消失。

这两件事在 class_loaders_ 这条链上是矛盾的。Shamiko 能藏文件、藏 maps、藏包名,因为那些都是"查询接口",Hook 掉返回值即可;但 class_loaders_ 是 ART 自己跑 GC 时要遍历的 C++ 堆内存结构,不是给外部查询用的 API。要让自己从链上消失又不被 GC 回收,等于要求 ART 一边认为它可达、一边遍历不到它——做不到。所以模块 ClassLoader 只能赖在链表里。只要它在,数一下链表长度就能发现异常。

干净的简单 App 进程通常只有 2-3 个 ClassLoader(BootClassLoader + App 的 PathClassLoader,可能再加一个 WebView)。被 LSPosed 注入后,框架自身、每个启用的模块各自带来额外的 ClassLoader,数量显著抬高。


二、实现:无符号、纯内存指针运算定位链表

检测的前提是不能依赖任何会被 Hook 的东西——不调 GetClassLinker、不 dlsym、不碰私有符号(也顺带规避了 Android 7+ 的 linker namespace 隔离)。整条路径只有 C++ 内存指针运算:

  1. JNIEnv->GetJavaVM() 拿到 JavaVMExt*,这是 JNI 标准接口,全版本稳定;
  2. JavaVM 指针附近探测出 Runtime*
  3. Runtime 对象内扫描成员指针,找 ClassLinker*
  4. ClassLinker 内找到 class_loaders_ 这条 std::list 的头,遍历计数。

不同 ROM、不同 ART 版本的结构体布局不一样,硬编码偏移必然碎。所以用运行时特征扫描,三个特征同时成立才锁定:

  • 特征 A — VTable 落在 libartClassLinker 是有虚函数的 C++ 对象,首 8 字节是虚表指针,该地址必然落在 /proc/self/mapslibart.so 的映射区间内。
  • 特征 B — 双向闭环std::list 底层是双向循环链表,头节点满足 head->next->prev == head && head->prev->next == head
  • 特征 C — 节点数合理:链表至少 2 个节点(Boot + App),且不会是几百个。

三特征叠加,把"恰好长得像链表头的随机内存"的误命中概率压到可以忽略。锁定后缓存三个偏移(vm→runtimeruntime→classlinkerclasslinker→list),后续直接走快速路径。

实测扫描日志(Android 16,可以看到特征校验起作用——第一个候选 Runtime 指针在 libart 里找不到合法 vtable 对象,被正确跳过,第二个才命中):

[CLscan] libart range 0x7c10304000-0x7c10f08000
[CLscan] no match in Runtime[0..0xe00]: libart-vtable candidates=0, closed-lists=0
[CLscan] LOCKED cl_off=0x258 list_off=0x60 count=3
[CLscan] located: vm_runtime_off=0x8 cl=0x258 list=0x60 count=3

vm_runtime_off=0x8 说明在这台机器上 Runtime* 就在 JavaVM + 0x8ClassLinkerRuntime + 0x258class_loaders_ClassLinker + 0x60。这三个值都是扫出来的,不是写死的。


三、Android 16 上的两个致命坑(原文实现失效的真正原因)

把原文思路照搬到 Android 16 会直接拿不到结果。调试下来是两个独立的问题,原文作者在 13/14 上都没触发。

坑一:指针标签(Pointer Tagging / TBI)污染所有比较

arm64 的 Top-Byte-Ignore 加上 Android 的堆指针标签,会让 GetJavaVM 返回的指针顶字节带上 tag。实测这台设备拿到的是:

vm = 0xb400007d73a24410

注意顶上的 0xb4。硬件解引用时按 TBI 规则忽略顶字节,所以 *ptr 能正常工作——但任何把指针当整数做的算术比较都会被这 8 位污染:判断 p < 0x0000_8000_0000_0000(用户空间上界)会失败,判断链表 next->prev == head 时如果两个指针一个带 tag 一个不带也会假性不等。原文那套 if (vtable < art_start || vtable > art_end) 的区间判断,在带 tag 的指针上直接全军覆没,扫描提前夭折。

修法是在任何比较/查表之前先把指针规范化,清掉高 8 位:

static inline uintptr_t canon(const void *p) {
    return (uintptr_t)p & 0x00FFFFFFFFFFFFFFULL;
}

链表遍历、vtable 区间判断、可读性查表,全部在去 tag 后的同一地址空间里进行。少了这一步,Android 15/16 的设备一台都跑不通。

坑二:mincore 对 app 进程失效

原文用 mincore 在解引用前判断内存页是否映射,以此防野指针 SIGSEGV。这个护盾在 Android 16 上自己先塌了——实测 mincore 对普通 app 进程返回失败,导致所有可读性判断都判否,扫描在第一步就退出,根本走不到计数。

换成解析 /proc/self/maps 建一张可读区间表(带 r 权限的段),用 syscall 直读、一次解析、按区间判断地址有效性。既不依赖 mincore,又抗 Hook(直读文件而非走 libc),还更快。顺带把区间表上限开到 8192 段——现代 App 的 maps 动辄几千行,开小了高位的 JavaVM 地址会落在被截断的部分之外,又是一个隐蔽的失效点。

fail-safe 与阈值校准

定位失败(OEM 深度魔改布局、ART 版本漂移)一律返回 -1,上层当作"无法检查"跳过,绝不误报。阈值不拍脑袋,按实测两点定(均 fresh-process 跑 3 次稳定取值):干净 baseline = 3,活跃注入 = 12,原文另测 LSPosed 环境 13-15。综合得干净 ≤5、注入 ≥12,安全间隔约 [6,11],取 DANGER = 9——高出干净上界 4、低于注入下界 3,两侧都留余量。计数只作辅助证据,单独超过 9 才独立判危;这台 App 结构简单才敢这么用,移植到微信、手淘那种多 ClassLoader 宿主必须重新测 baseline。


四、复现流程

  • 设备:Pixel 6 Pro(raven),Android 16 / API 36,已 root(Magisk + Zygisk),装有 LSPosed。
  • 被测:Sentry v1.5,本地构建签名安装(adb install -r -g)。
  • 注入模块:一个 Xposed 模块 com.cx.hook,作用域勾选 anti.rusda(Sentry 包名)。LSPosed 的模块作用域配置在 /data/adb/lspd/config/modules_config.dbscope 表里。
  • 开关:在 LSPosed Manager 里启用/禁用该模块,分别对应注入态与干净态。模块作用域改动在目标 App 下次启动时生效,无需重启。

每次测量都 am force-stop anti.rusda 起新进程,再点"开始检测",避免缓存偏移影响。日志用 adb logcat -s SentryTag 抓。


五、真机验证:正负样本对照

干净态(LSPosed 模块禁用)

[CLscan] LOCKED cl_off=0x258 list_off=0x60 count=3

count=3,Xposed / Hook Framework 判 PASS,总分 95(唯一扣分项是 Bootloader 解锁,与本检测无关)。

概览 调试页
clean overview clean debug

Xposed / Hook Framework 卡片明细:

• Xposed framework not detected (Class.forName)
• ClassLinker class_loaders_ count: 3 (offsets cl=0x258 list=0x60)

注入态(启用 com.cx.hook,作用域 anti.rusda)

com.cx.hook 注入 Sentry 进程后再扫:

[CLscan] LOCKED cl_off=0x258 list_off=0x60 count=12
[CLscan] located: vm_runtime_off=0x8 cl=0x258 list=0x60 count=12

count 从 3 跳到 12,越过阈值 9,Xposed / Hook Framework 判 DANGER,总分掉到 78。

概览 调试页
lsp overview lsp debug

卡片明细——注意第一行:

• Xposed framework not detected (Class.forName)
• ClassLinker class_loaders_ count: 12 (offsets cl=0x258 list=0x60)
• Abnormally high ClassLoader count (>= 9) - strong LSPosed/injection indicator

关键对照:逐项展开 Debug 页

光看 PASS/FAIL 标签不够,得把每一项卡片展开读 detail,才能确认各通道到底命中没命中、命中的是什么。注入态下把 11 项调试检测逐一展开,实测明细如下:

# 检测项 结果 实测明细
1 Frida Threads PASS /proc/self/task 无 frida 线程名 Native
2 Frida Ports PASS 端口全关、无 frida 进程 Native
3 Memory Signatures FAIL 2 块匿名可执行内存 2caa538000-2caa65a000(1160 KB)、2cad504000-2cad5e5000(900 KB) Native
4 Maps detection (Java exec) PASS maps 无可疑模块名 —— 名字被藏 Java
5 Ptrace / IDA Attach PASS 未被 trace(state S) Java
6 Debugger Attached PASS 无调试器 Java
7 Xposed / Hook Framework FAIL Class.forName 未命中(绕过)+ ClassLinker count=12 Java+Native
8 SO Code Integrity PASS 无 Frida(frida 已关) Native
9 ArtMethod Entry PASS entry 落在 libart/oat/jit/apex 区间内 Native
10 Hook Trap PASS 自有 SIGTRAP handler 正常触发 Native
11 Dirty Page / Memory Injection FAIL 6 段 libart.so 可执行段带 Private_Dirty 4kB Native

把第 7 项 Xposed / Hook Framework 的 Java 子通道单独拆开看,会更触目惊心——它内部串了 5 条 Java 侧检测,全军覆没:

Java 子通道 LSPosed 注入下
Class.forName("…XposedBridge") 绕过(明细:not detected)
抛异常回溯堆栈找 Xposed 类 绕过(无命中行)
反射查 findAndHookMethod 绕过(无命中行)
VMDebug.getInstancesOfClasses 枚举 InMemoryClassLoader 实例 绕过(无命中行)
ClassLinker class_loaders_ 计数(Native) 命中,count=12

结论一目了然:注入态下 3 个 FAIL 项全是 native 通道,从三个互不相干的角度抓到了同一次注入——

  • Xposed / Hook Framework:ClassLoader 计数 3→12,链表实例瞒不住;
  • Memory Signatures:模块用 InMemoryDexClassLoader 加载,dex 的可执行内存以两块匿名 r-x 段(1160 KB + 900 KB)落地,被匿名内存启发抓到;
  • Dirty Page:Zygisk patch libart 代码段,留下 6 段 Private_Dirty 脏页。

所有 Java 层手段——包括理论上最直接的 InMemoryClassLoader 实例枚举——以及 Maps 文本扫描,全部判 PASS(被绕过)。Maps 文本扫描 PASS 的原因很直白:LSPosed 从内存加载模块,进程 maps 里没有任何带 cx.hook/lsp/xposed 字样的文件映射。直接验证 Sentry 进程的 maps:

# grep -iE "cx.hook|lsp|xposed" /proc/<pid>/maps  →  无输出

文本特征被抹得干干净净。但 class_loaders_ 链上多出来的那 9 个 ClassLoader 实例、模块 dex 的可执行内存、libart 的脏页——这三样都是注入"为了能跑起来"必须付出的代价,抹不掉。这就是把战场从"问答式 API"下沉到"运行时内存事实"的意义。

Memory Signatures 展开后的实测两块匿名可执行段:

mem sig detail


六、剖析:为什么是降维打击,又为什么不是银弹

ClassLinker 计数能赢,根子在于它检测的对象是 GC 必须遍历的运行时数据结构,而不是任何形式的查询接口。Shamiko 这类隐藏方案的全部能力建立在"拦截查询、篡改返回"上——文件查询、PackageManager 查询、maps 读取,都是 App 主动问、系统被动答的模型,answer 可以被 Hook。而 class_loaders_ 是 ART 自己在 GC 时要走的链,没有"问答"环节给攻击者插手;要让模块 ClassLoader 从链上消失,等价于让它被 GC 回收,模块自身就死了。攻击者唯一的出路是针对性逆向并 inline Hook 检测函数本身,但那是成本极高的定点打击,已经脱离"通用隐藏"的范畴。

但它也不是银弹,工程上有三条必须正视的边界:

  • 宿主复杂度决定 baseline。简单 App baseline=3,注入后 12 很显眼;但多模块、带 WebView、用 split APK 的复杂宿主,干净 baseline 本就可能十几个,阈值会和注入态重叠。计数检测必须按宿主单独校准,不能跨 App 套用同一个魔数。
  • 激进的内存盲扫需要 fail-safe。从 Runtime 偏移 0 扫到 0xE00、读任意候选指针,本身就是在刀尖上走。可读性探针 + 三特征校验 + 定位失败返回 -1,是保证它在千奇百怪的 OEM ROM 上"抓不到就跳过"而不是"崩给你看"的前提。
  • 平台演进会搬走地基。这次 Android 16 的 TBI 标签和 mincore 失效就是活例子——原文在 13/14 能跑的代码,到 16 一行没改就全废。任何依赖底层内存布局的检测都得跟着系统版本持续回归。

所以正确的工程姿态不是"用 ClassLinker 计数一招鲜",而是把它作为多通道里抗隐藏能力最强的一条,和 native 匿名内存启发、smaps 脏页、GOT 指针逃逸等并联。这次实测里三条 native 通道同时命中同一次 LSPosed 注入,就是这个思路的体现:任何单条通道哪天被针对性绕过,其它通道还在。检测与对抗本就是螺旋上升的——当 API 层的 Hook 已经泛滥,把战场下沉到虚拟机内存布局,往往能换来一个攻击者绕不动、或绕的成本远高于收益的时间窗。


*实现细节见 app/src/main/cpp/detector/class_linker_scan.cpp


[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

收藏
免费 0
打赏
分享
最新回复 (7)
雪    币: 94
活跃值: (5257)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
2
2026-6-22 19:18
0
雪    币: 88
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
新检测点get
2026-6-22 19:42
0
雪    币: 754
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
感谢分享
2026-6-25 10:38
0
雪    币: 10
活跃值: (2210)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
VMDebug.countInstancesOfClass
2026-6-25 11:19
1
雪    币: 112
活跃值: (8985)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
tql
2026-6-25 15:39
0
雪    币: 2278
活跃值: (2756)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
局限性
2026-6-26 02:07
0
雪    币: 2864
活跃值: (5697)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
官方原生rom 直接给我检测到了23个classloader怎么回事。连BL都没解开
5天前
0
游客
登录 | 注册 方可回帖
返回