首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
13
12
[原创]用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证
发表于: 2026-6-22 18:29
2704
[原创]用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证
太岁又沐风
2026-6-22 18:29
2704
# 用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证 > 配套实现:<a href="elink@1ceK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6@1j5h3W2K6N6h3W2A6i4K6u0r3M7$3g2F1N6s2u0&6">Sentry</a> 的 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](https://bbs.kanxue.com/thread-289567.htm) 提出了一条跳出 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 落在 libart**:`ClassLinker` 是有虚函数的 C++ 对象,首 8 字节是虚表指针,该地址必然落在 `/proc/self/maps` 里 `libart.so` 的映射区间内。 - **特征 B — 双向闭环**:`std::list` 底层是双向循环链表,头节点满足 `head->next->prev == head && head->prev->next == head`。 - **特征 C — 节点数合理**:链表至少 2 个节点(Boot + App),且不会是几百个。 三特征叠加,把"恰好长得像链表头的随机内存"的误命中概率压到可以忽略。锁定后缓存三个偏移(`vm→runtime`、`runtime→classlinker`、`classlinker→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 + 0x8`;`ClassLinker` 在 `Runtime + 0x258`,`class_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 位: ```c 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.db` 的 `scope` 表里。 - **开关**:在 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 解锁,与本检测无关)。 | 概览 | 调试页 | |:---:|:---:| |  |  | 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。 | 概览 | 调试页 | |:---:|:---:| |  |  | 卡片明细——注意第一行: ``` • 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 展开后的实测两块匿名可执行段: > >  --- ## 六、剖析:为什么是降维打击,又为什么不是银弹 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`](../app/src/main/cpp/detector/class_linker_scan.cpp)
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
13
点赞
・
12
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
你的帖子非常有用,感谢分享!
2026-7-16 06:22
mb_hsscwjpz
期待更多优质内容的分享,论坛有你更精彩!
2026-7-9 11:39
sinker_
为你点赞!
2026-6-30 06:30
Y6blNU1L
你的分享对大家帮助很大,非常感谢!
2026-6-29 16:20
灬哈密瓜
+1
这个讨论对我很有帮助,谢谢!
2026-6-25 17:31
随风而行aa
+1
你的分享对大家帮助很大,非常感谢!
2026-6-25 10:04
by_Lin
为你点赞!
2026-6-25 01:30
Yangser
你的分享对大家帮助很大,非常感谢!
2026-6-23 10:04
x1a0f3n9
为你点赞!
2026-6-22 22:13
孤独的街
为你点赞!
2026-6-22 20:17
vmoveq
感谢你的积极参与,期待更多精彩内容!
2026-6-22 20:02
aut0run
你的帖子非常有用,感谢分享!
2026-6-22 19:27
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
7
)
wangzehua
雪 币:
94
活跃值:
(5692)
能力值:
( LV3,RANK:30 )
在线值:
发帖
0
回帖
113
粉丝
3
关注
私信
wangzehua
2
楼
2026-6-22 19:18
0
温泉划水鱼
雪 币:
84
能力值:
( LV1,RANK:0 )
在线值:
发帖
6
回帖
222
粉丝
28
关注
私信
温泉划水鱼
3
楼
新检测点get
2026-6-22 19:42
0
shuangmou
雪 币:
754
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
67
粉丝
1
关注
私信
shuangmou
4
楼
感谢分享
2026-6-25 10:38
0
twsxtd
雪 币:
10
活跃值:
(2345)
能力值:
( LV2,RANK:10 )
在线值:
发帖
3
回帖
49
粉丝
15
关注
私信
twsxtd
5
楼
VMDebug.countInstancesOfClass
2026-6-25 11:19
1
Imxz
雪 币:
112
活跃值:
(9405)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
750
粉丝
9
关注
私信
Imxz
6
楼
tql
2026-6-25 15:39
0
mb_elqwyvnm
雪 币:
2629
活跃值:
(3257)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
155
粉丝
0
关注
私信
mb_elqwyvnm
7
楼
局限性
2026-6-26 02:07
0
小黄鸭爱学习
雪 币:
2954
活跃值:
(5907)
能力值:
( LV2,RANK:10 )
在线值:
发帖
18
回帖
337
粉丝
154
关注
私信
小黄鸭爱学习
8
楼
官方原生rom 直接给我检测到了23个classloader怎么回事。连BL都没解开
2026-7-9 18:22
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
太岁又沐风
13
发帖
15
回帖
10
RANK
关注
私信
他的文章
中某某动APP算法AI分析-一句话全自动分析网络请求和加密算法
2777
[原创]IOSDecryptHub:免越狱看清加解密,兼砸壳与 MCP
2024
[原创]美团大众点评降级抓包LSPosed插件-反混淆通用思路
5008
[原创]抖音抓包LSPosed插件多版本可用的技术刨析
8902
[原创]用 ClassLoader 计数对抗 LSPosed 隐藏:从看雪思路到 Android 16 落地与真机验证
2704
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部