拜读完珍惜佬写的 Android内核无痕Hook理解和感悟 文章以后, 受益匪浅 !!!
非常感谢珍惜佬的文章, 解释的非常清楚, 并且给出了很多实现细节, 非常值得学习和借鉴 !!!
经段一段时间的摸索, 终于完整实现了珍惜佬文章中的无痕 Hook 框架, 这篇文章记录一下整体的设计思路和技术方向,顺便聊点踩坑经历。
整个框架的所有无痕能力都是基于 APatch 模块提供的内核 Hook 能力实现, 感谢 APatch !!!
还要感谢 GitRoy 大佬提供的技术支持 !!!
传统 Hook 能力的弊端这里就不提及了, 珍惜佬已经讲的很清楚了, 在现在的高强度对抗中, 传统 Hook 能力已经有些不足了, 所以我们需要一种新的 Hook 能力来对抗现在的各种高强度检测手段.
所以 无痕 Hook 诞生了 !!!
既然从头造轮子, 那就一次搞定目前的需求吧, 所以框架覆盖了以下方向, 下面会附上原理和一些方案
先上一张整体架构图,感受一下模块关系:
几个关键设计决策:
Daemon 代理模式:KPM 先往 Zygote 注入一个 Daemon SO,由它实现所有 SDK 逻辑并通过 syscall 与内核通信。用户模块只需要 include 一个纯声明头文件,通过 Daemon 传入的函数指针表调用能力。这样用户模块极轻量,多模块零代码重复,符号解析和 Hook 句柄管理都集中在 Daemon 一处。
Zygote 注入:Daemon SO 只注入到 Zygote 进程常驻,子进程 fork 后按包名按需加载用户模块。好处是不用对每个目标 App 独立注入,Zygote fork 出来的所有进程天然继承。
鉴权:KPM 启动时生成 32 字节随机 session key,所有命令必须携带。Manager 端通过签名锚定自动获取 key——KPM 编译期内置 Manager 签名证书摘要,鉴权请求到达时在内核态校验调用者 APK 签名是否匹配,命中则缓存 uid 走 fast-path,整个过程无需提权。非 Manager 链路(agent / PC 调试)走人工配对码 fallback:一次性短码,限时消费,防止被测信道检测
整个注入链路完全在内核态完成:KPM 读取 Daemon SO 的 ELF 文件、解析重定位、通过页表操作映射幽灵内存、生成 bootstrap 跳板劫持 Zygote 执行流。Daemon 驻留 Zygote 后,fork 出的子进程按包名匹配配置,按需加载用户模块。
注入编排流程大致是:发现 Zygote PID → 读取 ELF → 内核态 ELF 链接器解析 PT_LOAD 段 → 幽灵内存映射到 Zygote 用户态地址空间 → 注册 NEEDED 库到符号解析器 → 重定位(IFUNC 自修复)→ 生成 ARM64 bootstrap 跳板 → task_work_add 劫持执行流。
用户模块(.ghm 文件)由 Manager App 管理,Daemon 通过自带的 ELF 链接器在幽灵内存中加载,调用 ghostEntry(GhostApi*) 入口。模块开发者只需 include 一个纯声明头文件,不链接任何 SDK 库。
Daemon 热重载的设计思路是"清场再进场":先清除 Zygote 上所有已注册的 hook 状态、拆除旧 daemon 幽灵内存映射(含用户态残留 PTE 清理),再重新注入新版本。整个过程不重启 Zygote,开发期迭代效率高。
内核 linker 有个很坑的点, 是 ifunc 的处理, 要么daemon全自实现函数, 要么就模块入口做一下ifunc的修复, 再进行其它操作
这里要感谢 WX 大佬提出的 Shadow Page 方案, 下面 Art Hook 的隐藏就是根据这个延伸出的 Shaodw Data Page 方案
这是框架 Inline Hook 的核心方案。核心思路是"同一 VA 在执行和读取之间切换不同物理页":
关键前提是设备支持 FEAT_EPAN(PAN3),它允许 EL0 使用 execute-only 映射(执行不可读的 PTE 权限组合)。
没有 EPAN 的设备走单页 --- 仿真降级路径——原页完全阻断,KPM 在 fault handler 里软件仿真执行指令。仿真器按层级覆盖:PC-relative 指令必须仿真(用原始 PC 计算),普通 ALU 和访存指令软件仿真,SIMD/FP 等罕见指令走 backup 页单步兜底。
[!WARNING]
除了 Inline REPLACE,Shadow Page 还支持三种指令插桩模式:
插桩命中靠特定的未定义指令编码触发异常,handler 内根据 PC 偏移查表判断归属,不需要读取目标 VA 字节。
这是整个框架隐身能力的根基。
常规 mmap 会在进程的 mm_struct 链表里注册 VMA,VMA 必然暴露在 /proc/self/maps。即使你 hook show_map_vma 把它过滤掉,mincore 等盲探手段还是能发现。
幽灵内存的思路是绕过操作系统管理层,直接操作底层页表:
利用"硬件页表 (PTE) 与操作系统管理层 (VMA) 之间的信息差"制造的内存——CPU 的 MMU 查页表发现合法,用户态可以正常访问;但操作系统遍历 VMA 链表查不到这条记录。maps 扫描、mincore 探测全部失效,从源头上逃逸了 Linux 内存管理。
此外还有一个 mmap_region 守卫:ghost 映射是 VMA-less PTE,内核 get_unmapped_area/mmap_region 选址只看 VMA 不看 PTE,目标进程后续普通 mmap 可能落在 ghost PTE 占用的 VA 上导致静默覆盖。pre-hook 检测重叠,命中则返回 -ENOMEM(fail-closed)
ARM64 CPU 内置了调试寄存器,你把目标地址写进去,CPU 每次执行时硬件比较器自动比对,命中就触发异常。整个过程不改一个字节的内存。
GhostHook 的 HWBP 不经过 perf_event 子系统,而是在内核态直接操作 ARM64 调试寄存器并接管 debug 异常处理路径。这样做的好处是绕过了 perf_event 路径的检测面
HWBP注册。Linux 内核里进程和线程都是 task_struct,HWBP 绑定的是 TID 而非 TGID。只给主线程下断点,子线程走到目标地址根本不会报警。
数量限制是个硬伤:ARM64 最多 6 个执行断点。想 Hook 更多函数就得换 Shadow Page 方案。但 HWBP 可以做状态机跳跃,单断点同时抓入参和返回值:
全程零额外内存、零执行流干预。CPU 在函数体内全速原生执行,只在"进"和"出"两个瞬间闪现一次。
如果需要替换执行流(不只是监听),就得引入用户态跳板。跳板里有一个"关闸→调原函数→开闸"的流程,避免调原函数时重复触发断点死循环。LR 寄存器的保护是个极易踩坑的点——BLR 指令会覆盖 X30,得找个 callee-saved 寄存器(比如 X20)当"安全屋"暂存原始 LR。
把指令搬到新内存(shadow 页 / 跳板)后,所有 PC 相对寻址指令会算错地址。ARM64 指令 32 位塞不下完整 64 位地址,编译器大量使用相对寻址(B/BL/CBZ/ADRP/LDR literal)。搬到新地址后"向前跳 50 步"会跳到完全错误的地方。
重定位引擎需要逐条扫描,分类处理:
指令膨胀后需要提前计算 offset_map(原始指令索引 → 克隆页偏移),这也是缺页异常路由时"传送地图"的数据来源。
有了 DBI 引擎以后就可以模拟执行指令实现 trace 了, 这里参考了 Frida Gum 的实现, 重构了一下, 感谢 Firda, 感谢开源 !!!
ART 虚拟机里每个 Java 方法对应一个 ArtMethod 结构体,其中 entry_point 指针指向编译后的机器码入口。传统 LSPlant 方案是替换这个指针指向自定义跳板,但反作弊会扫描所有 ArtMethod 的 entry_point,检查指针是否指向合法的 boot.art 或 OAT 文件区域。
GhostHook 同样会修改 entry_point 指针,但关键区别在于:修改后的字段驻留在 ArtMethod 堆线性区上,任何持有指针的代码都能读到篡改值。框架通过 Shadow Data Page 方案隐藏这次篡改:将目标数据页权限阻断,DABT 触发后内核根据发起访问的代码地址判断读取者身份——ART 运行时代码段注册为信任范围,信任读取者看到篡改后的 shadow 页,非信任读取者(反作弊扫描器)看到干净原始页。
同页多条 ArtMethod 被 hook 时共享一个 shadow 页,通过增量 patch 管理。非信任代码对同页的写访问也需要特殊处理——内核在写完成后做差分同步,保证 hook 字段不被覆盖,非 hook 字段保持最新。
ART hook 引擎(mako)是独立 SHARED lib,不静态链入 Daemon SO。Manager 开关启用时把路径推到 KPM 缓存,Daemon 首次调用 artHook 时 lazy load 进幽灵内存。mako 的跳板池也走幽灵内存(经适配层注入 ghostMalloc(RWX)),不引入 maps 可见区段。shorty 取值用反射 Method.getReturnType + getParameterTypes 自拼,不依赖 libart 内部符号。
定位 Java 方法有两条路径:按 className + methodName + sig 走标准反射,或直接拿反射 Method 对象——后者跳过符号解析,适合 hook 隐藏 API 等不可经标准路径定位的方法。两条路径殊途同归,最终都落到 mako 的 ArtMethod 入口替换上。
反射类加载的设计思路是预解析:在 daemon 初始化时把反射链句柄全部解析为全局缓存,fork 继承,后续调用时不再触发 JNI 查找,把开销压到最小。
这里 mako hook 是我之前写的 art hook 框架, 选择用外挂加载的方式主要是考虑到两个原因:
跨进程内存读写支持无痕模式。内核侧直接走目标进程页表遍历,不经 ptrace、不经 /proc/pid/mem、不设 TracerPid。
这条路径也用于 Shadow Page 的跨进程读隐藏:GUP(get_user_pages)路径不一定经过目标进程当前 CPU 的 TLB,而是通过页表遍历拿物理页。因此 shadow 页在 GUP 前需要临时把 PTE 指向 original PFN,GUP 后恢复 shadow PFN,且不刷新执行核 TLB。
思路是让内核直接遍历目标进程的 VMA 链表,把结果写到调用方 buffer。整个过程不经 /proc/maps、不设 TracerPid、不创建文件。agent 用它构建 per-pid VMA 缓存,配合 ELF 符号表缓存做地址→符号的反查。
这是 Trace 的记录模式:在 debug 异常处理的 Software Step 分支中加入 trace 步进处理,对目标线程设置单步标志进入连续单步。每条用户态指令触发一次 SS 异常,handler 在异常上下文里采集 PC + 全量 GPR + SP + PSTATE + 时间戳,写入专用 trace ring buffer(覆盖最旧 + 丢弃计数),消费者分批取走。
关键约束:MDSCR_EL1.SS 是 per-CPU 寄存器,目标线程被调度到其他 CPU 时 SS 会丢失。trace 期间必须把目标线程钉在单个 CPU 上(set_cpus_allowed_ptr),trace 结束后恢复原亲和性。
trace 的连续单步与框架其他单步消费者(HWBP 恢复单步、shadow BRK 单步、shadow read 单步、simulate backup 单步)共享同一个 MDSCR.SS 位。trace 在 SS 分发链中具有最高优先级,但不破坏其他机制的 one-shot 单步恢复。
Daemon 内置符号解析器,读 ELF .dynsym 解析符号地址。不走 dl_iterate_phdr——因为幽灵内存里的 SO 不在动态链接器的 link_map 里,dl_iterate_phdr 无法确定调用者上下文,返回 NULL。
符号解析用于两条路径:Hook 定位(按 SO 名 + 符号名找地址)和 Trace 符号化(批量解析 PC 对应的 {soName, sym, offset} 映射表,随 trace 帧推送到 PC)。
VMA 缓存经内核无痕读取,ELF 符号表缓存从磁盘文件解析一次后复用(优先全量符号表),排序后二分查找。
反作弊检测 HWBP 的手段已经非常成熟,主要通过 ptrace 和 perf_event_open 施展"连环五步杀":读寄存器查空位、满载占坑测试、读写一致性校验、越界诱导陷阱(故意设 7 个断点看是否返回 -ENOSPC)、主动触发测试。
应对思路是在内核层实现硬件调试寄存器的"假账本":拦截 ptrace 对调试寄存器的读写请求,反作弊全程跟假账本交互,真实 CPU 物理寄存器不受干扰。假账本需要精确模拟内核行为——包括越界报错、读写一致性等,细节不到位反而暴露。
Zygote 注入的 Daemon + 跳板 + Shadow Page 全部是 VMA-less PTE,而内核的 copy_page_range 只遍历 VMA——VMA-less 的幽灵内存 / shadow PTE 会被静默丢弃。硬件断点寄存器是 per-CPU 的,fork 也不复制。因此子进程要"继承"这套无痕 Hook 能力,必须在 fork 路径上手动克隆。
四类对象的继承策略各不相同:
线程 clone(共享 mm)不需要克隆——只有新进程才需要安装 ghost 内存。
有时模块需要在目标进程注入自定义 Java 类——比如运行时生成的代理类或 Hook 回调类。设计上走内存 DEX 加载路线:dex 字节不落盘,直接在幽灵内存中包装为 ByteBuffer,反射构造 ClassLoader。
关键设计点:dex 字节先拷贝到独立的幽灵内存分配,使数据生命周期与用户 SO 的 ELF 映射解耦——直接包装指针会让 buffer 生命周期耦合 SO 映射,拷贝后 dex loader 自主管理,卸载时确定释放。
parent ClassLoader 的选择影响可见范围:挂载 App ClassLoader 可经委托解析 App/framework 类;隔离加载则只有 bootstrap 类 + 自身 dex 类可见。这条路径完全在 daemon 侧实现,与 ART hook 引擎是否启用无关。
这两类拦截器都基于 Shadow Hook 实现,不修改 ArtMethod 字段、不引入 GOT trampoline、不在 maps 留可见区段——和框架其他 hook 能力保持一致的隐身性。
Binder Transact 拦截:思路是 shadow hook transactNative 的 native 函数地址(经 ArtMethod data 字段取址),在 transact 前后按 interface token 分发 before/after 回调。难点在于 Parcel 操作——读取 interface token 本身依赖 libbinder.so / libutils.so 的 native 函数,纯 JNI 做不到,需要封装一层 Parcel 工具来桥接。
设计上只 hook client 侧——server 侧的 transact 是 C++ 虚函数,shadow hook 无法有效拦截 vtable dispatch。binder call log 作为可选开关,默认关闭,避免热路径开销。
Linker dlopen 拦截:思路类似,shadow hook linker 的 dlopen 内部入口,在 SO 加载前后按 lib name 子串匹配分发回调。
两项能力均懒加载:首次调用才触发 init,per-process 独立,fork 后随 shadow PTE 继承。
用户模块的 ghostEntry 在进程启动早期执行,但目标 SO 可能尚未加载。当 App 后续 dlopen 目标 SO 时,.init/.init_array/JNI_OnLoad 在 linker 的 call_constructors 中执行——这发生在 dlopen 返回之前,用户模块无法在构造函数执行前 hook。
解法是在 linker 的构造函数调用路径上下 shadow hook,目标 SO 加载时拦截 .init/.init_array/JNI_OnLoad。这里有个符号解析的坑:linker 内部符号不在 .dynsym 里,且幽灵内存环境 dl_iterate_phdr 不可用,只能扩展磁盘 ELF 全量符号表回退。
JNI 动态注册的 trace 同理:JNIEnv 函数表中的 native 方法注册入口按固定偏移就能取到,无需解析 ART 内部符号,在注册时拦截匹配的方法即可 trace 其函数指针。
目标 SO 使用花指令混淆控制流:在真实指令间插入 junk bytes,通过 br x_reg(间接分支寄存器)跳转到 SO 内部的真实代码地址。静态反汇编器看到 br 时无法解析目标地址,从 junk bytes 起点反汇编会产生错误指令序列。
GTrace 采用动态仿真执行的方案,天然跟随 br 跳转——trace 日志中的指令序列已经是去花后的真实执行路径。trace 日志通过标注内部分支目标和 PC 间断区间,使自动化分析工具可直接提取去花后的真实执行路径,无需从 PC 非连续推断。
同一 SO 中不同函数的 trace 场景:先 traceStart(funcA) 再 traceStart(funcB),之后 A 和 B 可能在不同时刻被调用。如果 per-trace 配置(writer、traceMode、memReadMode 等)存在全局变量中,第二次调用会覆盖前者的配置,导致 trace 数据写错文件。
解法是将 code handler 的 per-trace 配置从全局变量重构为 per-handle 上下文,通过仿真引擎的 userData 传递给所有 hook 回调,实现多 handle 独立运行。由于仿真执行是同步阻塞的,两个 trace 不可能真正并发,但 shadow hook 是异步触发的——per-handle 化后各自独立,互不干扰。
这部分聊几个让人头疼的细节,给各位大佬省点时间。
vmap 多页分配返回同一 PFN
幽灵内存分配最初用 alloc_pages + vmap,结果发现在某些内核版本上 vmap 对多页分配不装独立 PTE——所有页返回同一个 PFN,用户态多页映射互相覆盖,加载 ELF 时报 missing DT_GNU_HASH。最后改成 vmalloc 区分配,vmalloc 有独立 L3 PTE,vmalloc_to_pfn 逐页返回独立 PFN。alloc_pages+vmap 只留给了单页 fallback 场景。
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 6小时前
被SharkFall编辑
,原因: