Android内核无痕Hook理解和感悟

admin 2026-05-28 04:08:19 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入探讨Android内核无痕Hook技术,针对传统Hook手段面临的内存CRC校验、可执行内存扫描等检测困境,提出基于内核态的降维打击方案。核心思想是利用硬件断点(HWBP)触发异常、控制页表项(PTE)与UXN权限实现物理级内存控制,结合动态二进制插桩(DBI)重编译指令,并通过内核干预创建幽灵内存实现完全隐蔽,最终达到对目标进程零篡改、零痕迹的Hook效果。 综合评分: 85 文章分类: 逆向分析,内核安全,移动安全


cover_image

Android内核无痕Hook理解和感悟

珍惜Any 珍惜Any

看雪学苑

2026年5月27日 17:59 上海

在小说阅读器读本章

去阅读

1、前言

这篇文章主要是我自己近年来在攻防对抗中,对 Hook 检测与反检测的一些理解和心得。随着各路安全SDK和反作弊引擎的不断进化,传统的 Hook 手段早已千疮百孔。我们目前面临的常规 Hook 检测方案主要集中在以下几点:

  • 内存 CRC 校验:扫描 .text 代码段,一旦发现机器码被修改(如 Inline Hook 替换了 B / LDR 指令),直接被检测。
  • 可执行内存扫描:扫描 /proc/self/maps,寻找 App 进程中多出来的、非正常的 r-x 或 rwx 内存段…
  • 函数调用栈检测 (Stack Walking):利用 FP (X29) 和 LR (X30) 向上回溯…
  • 函数调用线程检测:检测特定的敏感函数是否由非预期的线程发起调用。

面对这些严密的防线,传统的修改内存式的 Hook 已经举步维艰。本文将带你跳出原本的思维框架,从内核层面构建一套真正的“无痕 Hook”。

2、看完这篇文章你能学到什么?

在安全攻防对抗中,防守方在用户态(Ring 3)已经布下了天罗地网。想要在这种高压环境下实施动态分析与修改,如果继续死磕用户态,只会陷入无尽的 API 捉迷藏中。我们要做的,是进行“降维打击”——利用内核态(Ring 0)的绝对权力,来对用户态实施完全隐形的接管。

通过本文,你不仅能获得一套经过实战检验的终极无痕 Hook 架构,更重要的是理解这种“以高打低”的安全设计哲学。具体而言,你将学到:

  • 高风险场景的破局思路:突破传统 Inline Hook 的思维局限,理解基于内核异常路由的 Hook 思想(明白为何“不改代码也能劫持执行流”)。
  • 榨干底层机制的极限性能:深入理解 ARM64 的硬件断点 (HWBP) 机制,掌握如何通过“单断点状态机跳跃”,以近乎零开销同时监听函数的入参和返回值。
  • 物理级别的内存控制:掌握 Linux 内存页表 (PTE) 与访问权限 (UXN) 的底层控制方法,学会如何在物理层面拉起“高压电网”。
  • 攻克指令插桩的地狱副本:了解用户态指令重编译 (DBI) 的核心难点,解决令人头疼的 PC 相对寻址修正,以及 ARMv8.3 PAC 指针验证机制的无痕剥离。
  • 终极隐蔽艺术:学会如何在内核态为用户态擦除痕迹,实现完全隐形的“幽灵内存 (Ghost Mem)”,让一切基于 maps 扫描的检测作废。

3、本文解决痛点

传统 Inline Hook 的最大痛点在于“必须弄脏案发现场”。这不仅体现在对原始代码的篡改,更体现在它无法掩盖的“作案工具”。具体来说,传统方案面临两大“死穴”:

  • 指令完整性校验 (CRC/Hash 检测) 只要你在原始函数的头部写入了 B 或 LDR 等跳转指令,基于内存快照或文件映射对比的完整性校验就一定会报警。在反作弊系统眼里,被修改过的 .text 代码段就像是白纸上的墨滴一样显眼。

  • 异常的可执行内存段 (Trampoline 扫描) 这是传统 Inline Hook 无法绕过的致命伤。为了能让程序在执行完我们的逻辑后,跳回原函数继续执行,传统方案必须在进程中申请一块具有可执行权限r-x 或 rwx)的新内存,用来存放修复好的跳板指令(Trampoline)。 然而,顶级反作弊引擎会疯狂遍历 /proc/self/maps,只要它发现内存中多出了一块未归属于任何系统白名单 SO、且具有执行权限的匿名内存,就会立刻判定存在外挂或越狱环境。

而本文提出的架构,核心目的就是做到“踏雪无痕”: 既要利用硬件断点和 PTE 异常机制,对原始 .text 代码段做到物理级别的零篡改;又要通过内核层的深度干预(接管遍历 maps 的相关系统调用),为重编译的指令打造一块在 maps 列表中完全隐形、查无此人的“幽灵内存”。最终,对应用层目标进程做到真正的“神不知鬼不觉”。

4、前奏知识:如何用现实世界理解这些硬核底层概念?

在深入讲解架构之前,我们需要快速对齐几个底层概念。为了不那么枯燥,我们不妨把目标 App 想象成一家“安保极其森严的银行”,里面有巡逻的保安(反作弊系统)在日夜不停地检查门窗有没有被破坏(内存完整性/CRC校验)。

而我们作为高级特工,要想在不弄坏一扇门、不惊动保安的情况下劫持目标目标人物(目标函数地址执行),我们需要以下四件“法宝”:

1. 硬件断点 (HWBP) —— “绝对无痕的红外绊马索”

  • 底层定义:借助 ARM64 CPU 内置的调试寄存器(Debug Registers),让 CPU 在执行到特定地址时自动触发硬件异常,无需修改哪怕一个字节的内存。监听读取,执行,写入硬件断点都可以监听 。

  • 为什么要这么设计(CPU 视角的妥协)

  • 大家熟知的软件断点 (SWBP)(也就是各种调试器默认的下断点方式),本质上是把目标内存位置的指令,强行替换成一条特殊的陷阱指令(比如 ARM 的 BRK 或 x86 的 INT 3)。这就像是在银行地上挖个坑埋地雷,非常容易被安保人员(CRC 内存校验)发现地砖被动过。

  • 那为什么还要发明硬件断点? 想象一下,如果代码烧录在只读存储器(ROM)里,根本无法修改内存,你怎么调试?为了解决这个问题,CPU 厂商在芯片内部物理蚀刻了几个特殊的寄存器。你把内存地址写进去,CPU 内部的硬件比较器就会在每次执行指令时进行极其高速的比对(if 当前 PC == 寄存器地址)。一旦匹配,CPU 瞬间挂起线程并向内核报警。

  • 现实推演:传统的 Inline Hook 像是在银行地上挖坑;而硬件断点,是 CPU 芯片原厂自带的天花板红外绊马索。目标椅子(代码内存)原封不动,保安怎么扫都正常,但只要目标人物坐上去(CPU 执行到此地址),红外线就会瞬间向指挥中心(内核)发送无声警报。

2. PTE (Page Table Entry) 与缺页异常 —— “市政地图与空头支票”

  • 底层定义:内存页表项(PTE)。CPU 通过页表将虚拟地址映射到物理地址。

  • 为什么要这么设计(OS 视角的欺骗与懒惰)

  • 内核的谎言:现代操作系统对每一个 App 都在撒谎。它给每个 App 发放了一张极其庞大的“虚拟城市地图”(虚拟内存),让 App 以为自己独占了所有的土地。

  • 空头支票(懒分配):如果你在代码里 malloc(10GB) 的内存,系统会立刻把 10GB 的物理内存条划给你吗?绝对不会! 内核非常“抠门且懒惰”,它只是在你的“虚拟地图”上画了个圈,给你开了一张 10GB 的空头支票,但底层并没有分配哪怕 1 字节的物理内存。

  • 缺页异常 (do_page_fault):当 App 真的去读写这块内存时,CPU 一查底层页表(PTE),发现竟然没绑定物理内存!CPU 会立刻抛出一个异常(缺页中断)。此时,整个世界(App 线程)被内核瞬间按下暂停键。内核赶紧在后台擦汗,偷偷找一块真实的物理内存绑到 PTE 上,然后按下播放键。App 醒来后继续写入,它完全不知道自己刚才被定格过

  • 现实推演:PTE 就像是城市的底层导航地图。操作系统(市政厅)随便在地图上画大饼,但从不提前修路。只有当市民(App)走到地图边缘要掉下悬崖的瞬间(触发缺页异常),市政厅才“时空暂停”,把路铺好,再让市民继续走。

    核心伏笔:操作系统的正常运转,本身就极度依赖这种“引发异常 -> 暂停世界 -> 内核介入 -> 恢复世界”的机制。而我们的无痕 Hook,正是要完美劫持这个内核用来修路的“时空暂停键”!

3. UXN (Unprivileged eXecute Never) —— “只看不准摸的隐形高压电网”

  • 底层定义:ARM64 页表权限位。当 PTE 中的 UXN 被置位时,这页内存对于用户态(EL0)就是不可执行的。敢执行?CPU 立刻赏你一个 Instruction Abort 异常,强行交出控制权。
  • 现实推演:我们在目标人物所在的街区,拉起了一道极其特殊的隐形高压电网。这个电网的牛逼之处在于:保安用手电筒照它、拍照(内存读取扫描)完全正常,畅通无阻;但只要目标人物企图在这里“做动作”(执行指令),就会瞬间触电,被强行传送到我们的秘密审讯室(陷入内核态)。

4. DBI (Dynamic Binary Instrumentation) —— “1:1 完美复刻、且重绘路标的影视基地”

  • 底层定义:动态二进制插桩。将原始指令原样拷贝到新内存,并在运行时动态修正由于地址变化导致的寻址错误(如重编译 ADRPBLDR literal 等指令)。

  • 现实推演与深度硬核解析: 因为原大楼已经被我们通上了高压电(UXN),目标人物没法在那办公了,所以我们需要在郊区申请一块新地皮,一比一搭建一个“影视基地(克隆页)”让他继续工作。

    但是,仅仅是“原样拷贝砖块(机器码)”是绝对不行的!这会引发极其致命的崩溃,原因就藏在 ARM64 架构的寻址底层逻辑中。

    在现代操作系统中,为了安全(ASLR 地址随机化)和高效,编译器生成的代码都是位置无关代码 (PIC – Position Independent Code)。这意味着,ARM64 指令极其狂热地喜欢使用相对寻址 (Relative Addressing)

    在 ARM64 中,由于一条指令总共只有 32 个 Bit(4字节),它根本塞不下一个完整的 64 位(8字节)绝对内存地址。因此,CPU 采取了聪明的策略:以当前执行的指令位置(PC 寄存器,Program Counter)为原点,记录偏移量。

  • 相对跳转 (BBLCBZ)

    指令里写的不是“跳到 0x7FFF0000”,而是写着“以当前我脚下的地砖 (PC) 为起点,向前跳 50 步”。

  • 全局数据 / 字符串寻址 (ADRPLDR literal)

    指令写的不是“去 0x88880000 拿密钥”,而是写着“以我脚下的地砖 (PC) 为起点,向左拐 10 步,打开那个保险柜”。

致命的危机出现了: 当我们将这些指令原封不动地搬到郊区的“影视基地”时,目标人物的脚下的地砖(PC 寄存器的物理地址)已经全变了! 如果在新的影视基地里,他依然机械地执行“向前跳 50 步”或者“向左拐 10 步”,他大概率会一头撞死在水泥墙上,或者一脚踩空掉进深渊——在计算机世界里,这就叫 Segmentation Fault(段错误,内存越界)

DBI 引擎的降维重写: 这就解释了为什么我们需要一个强大的用户态 DBI 引擎。我们的引擎在拷贝指令时,必须像一个极其敏锐的特工测绘员,逐行扫描这1024 条指令:

总结:DBI 重编译的本质,就是把所有依赖于旧地理位置的相对路标,全部擦除,并重写为不受物理位置约束的绝对 GPS 坐标。 只有这样,目标人物在郊区的影视基地里,才能完美无瑕地与远在市中心的其他数据和函数进行交互。

为什么重编译这种“脏活累活”应该在用户态做?因为内核态环境极其严苛,缺乏标准库,且频繁分配内存容易引发死机。在用户层这个宽敞明亮的工作室里,把复杂的“影视基地图纸”画好,再一把交接给内核,才是最稳健的顶级架构。

终极连环计(串联这四件法宝): 弄懂了上面这四件法宝,我们的惊天计划就呼之欲出了: 我们先在郊区建好一个带监控的影视基地(DBI);然后黑入市政系统(PTE),在原银行大楼拉起只针对执行流的高压电网(UXN);一旦目标人物试图在银行办公,瞬间触电并被传送到内核;内核查阅地图后,将其神不知鬼不觉地投送到影视基地中继续工作…… 这一切,在银行保安眼里,什么都没发生过。

5、代码实现介绍 :深入内核,掌控全局的上帝之手

在这一章节,我们将深入 Linux 内核态,看看这套“无痕 Hook”在代码层面是如何运转的。我们将从四个核心模块展开:硬件断点的线程级束缚与死循环破局、UXN 高压电网的深度解析、DBI 指令重编译,以及幽灵内存隐身术。

5.1 硬件断点的真实面貌:线程级束缚与 PID/TID 之谜

在很多新手的认知里,Hook 都是“进程级别”的——我对某个地址下个 Hook,这个 App 里的所有线程走到这里都会被拦截。但在硬件断点(HWBP)的世界里,完全不是这样。

硬件断点是极其私人的、绑定到具体线程(Thread)的 CPU 物理寄存器状态。 在 Linux 内核的底层设计中,其实*没有严格的“进程”和“线程”之分**,它们都是一个个的 task_struct(任务调度单元)。

为了区分,内核引入了两个概念:

  • PIDTYPE_TGID (Thread Group ID):线程组 ID。这就是我们在应用层通常理解的“进程 PID”。一个 App 的主线程的 TID,就等于它的 TGID。
  • PIDTYPE_PID (TID):任务 ID。这就是真正意义上的线程 ID。每一个跑在 CPU 上的线程,都有自己独一无二的 TID。

踩坑点 1:硬件断点单线程机制: 因为 HWBP 是线程级的,如果你只对主进程(TGID)下发硬件断点,那么只有主线程会被拦截。其他子线程走到目标地址时,CPU 根本不会报警。 因此,在我们的框架中,如果想 Hook 某个地址,必须在用户态遍历 /proc/[pid]/task 目录,把目标进程下的所有子线程 TID 全部找出来,然后在内核里对每个 task_struct 逐一调用 register_user_hw_breakpoint。不仅如此,还得在内核挂载 wake_up_new_task 回调,实时监听 App 创建的新线程,第一时间给新线程也套上断点枷锁。

采坑点 2:回收机制: 既然硬件断点是绑定线程的,那当目标线程退出(死亡)时,我们理所当然要释放掉为它分配的 Hook 自定义的结构体。

新手的做法:在线程退出的回调里,直接调用 kfree() 把内存释放掉。 灾难的发生:Linux 内核是极度高并发的!假设 CPU 0 上的线程 A 刚好退出了,触发了释放内存;但就在同一纳秒,CPU 1 上由于硬件断点触发,正准备去读取线程 A 的这个 Hook 结构体。内存刚被释放,另一个核心却在读取,瞬间触发 Use-After-Free(UAF),内核当场崩溃,手机直接黑屏重启!

为了解决这个多核并发的灾难,我们绝对不能使用粗暴的全局大锁(会导致系统严重卡顿),而是引入了 Linux 内核最优雅的并发哲学:RCU(Read-Copy Update)无锁读取 + 标记清除式异步 GC(垃圾回收)

RCU 机制的通俗原理解析

什么是 RCU?你可以把它理解为“博物馆换展品”。

  • 读者(Reader):反作弊检测、断点触发时的回调,都是读者。RCU 允许成千上万个读者完全不加锁地疯狂读取数据,性能极高(rcu_read_lock 几乎是零开销)。
  • 写者(Writer):负责修改或删除数据。当我们要删除一个展品(Hook 节点)时,我们不能把正在看展的游客赶出去。我们会先在地图上把这个展品摘除(逻辑删除),游客再也找不到进来的路;但对于已经在里面的游客,我们任由他们看。直到最后一个游客离开(经过一个宽限期 Grace Period),我们才真正把展品砸碎销毁(物理释放)

5.2 硬件断点的终极对抗:为反作弊再造一个“楚门的世界”

前文提到,硬件断点像是一根无痕的红外绊马索。但是,天下没有免费的午餐。目标 App 里的反作弊引擎(巡逻保安)绝不是吃素的,他们对付硬件断点有一套非常成熟且恶心的“查房机制”。

巡逻保安的“花式查房”策略 (用户态反查检测)

目前顶级的反作弊 SDK,通常会使用 ptrace 和 perf_event_open 双管齐下,施展 “连环五步杀” 来检测 HWBP 是否被外挂占用:

  • 基础查房(读寄存器):直接通过 ptrace(PTRACE_GETREGSET) 读取硬件调试寄存器,如果发现地址不为 0,说明有外挂提前下了断点。
  • 满载占坑测试(写寄存器):ARM64 最多只有 6 个断点。反作弊尝试把 6 个坑位全占满,如果返回失败(说明被外挂偷偷占了几个),判定异常。
  • 读写一致性校验:反作弊逐一写进去一批无用的断点,再立刻读出来比对。如果读出来的地址和刚写进去的不一样,判定内核被 Hook!
  • 越界诱导陷阱(极限测试):这是最阴险的一招!反作弊故意尝试设置 7 个断点(上限 Max + 1)。正常的操作系统一定会返回 -ENOSPC(设备空间不足)。如果返回成功,说明操作系统被外挂的粗劣 Hook 给弄坏了,直接被检测!
  • 主动触发测试:反作弊给自己随意写一个 demo 函数, 给它下硬件断点,然后主动调用 demo。如果断点没触发,或者触发后寄存器没被正确修改,说明系统的硬件断点机制被外挂全局劫持了。

特工的降维防御:状态欺骗与假账本 (State Spoofing)

既然我们在内核态拥有“上帝视角”,我们为什么要和保安硬碰硬呢? 对付查房最好的办法,就是给保安塞一份伪造的监控录像!

我们在内核层 Hook 了 sys_ptrace 系统调用,实现了一套硬件调试寄存器的虚拟化方案: 我们在内核里为每个线程维护了一套“虚假的寄存器账本” (struct user_bp_stat)。反作弊引擎呼叫 ptrace 时,内核再也不去碰真实的物理 CPU 寄存器了,而是全程陪反作弊引擎在这本“假账本”上“演戏”。

我们来看看内核防御代码的核心逻辑(完美反杀上述的查房套路):

//  内核拦截 ptrace 的核心逻辑:陪反作弊引擎“演戏”
static void ptrace_after(hook_fargs4_t *args, void *udata) {
    long request = (long) syscall_argn(args, 0); // ptrace 请求类型
    long target_tid = (long) syscall_argn(args, 1);
    long type = (long) syscall_argn(args, 2); // NT_ARM_HW_BREAK 等

    // 1. 获取我们为该线程偷偷准备的“虚假寄存器账本”
    struct user_bp_stat *stat = find_or_create_user_bp_stat(uid, pid, tid);
    int max_count = is_break ? MAX_BRPS : MAX_WRPS; // 物理上限 (ARM64 一般是 6 个)
    int *count = is_break ? &stat->hw_break_set_count : &stat->hw_watch_set_count;
    struct hw_reg_state *cur_regs = is_break ? stat->break_regs : stat->watch_regs;

    // -----------------------------------------------------------
    //  戏码 A:保安试图修改调试寄存器 (PTRACE_SETREGSET)
    // -----------------------------------------------------------
    if (request == PTRACE_SETREGSET) {
        struct my_user_hwdebug_state hwdebug_local;
        kf_copy_from_user(&hwdebug_local, iov.iov_base, copy_len);

        int new_count = 0;
        // 【关键 1】:把保安写的数据全部存到我们的“假账本” cur_regs 里,绝不写入真实 CPU!
&nbsp; &nbsp; &nbsp; &nbsp; for (int i = 0; i < reg_count; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cur_regs[i].addr = hwdebug_local.dbg_regs[i].addr;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cur_regs[i].ctrl = hwdebug_local.dbg_regs[i].ctrl;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (cur_regs[i].addr != 0) new_count++;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; *count = new_count;

&nbsp; &nbsp; &nbsp; &nbsp; // 【关键 2:细节拉满!完美模拟内核越界报错】
&nbsp; &nbsp; &nbsp; &nbsp; // 应对保安的“越界诱导陷阱(写上限+1)”测试:
&nbsp; &nbsp; &nbsp; &nbsp; if (*count > max_count) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; args->ret = -ENOSPC; // 假装物理空间不足,欺骗保安
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; LOGI(" ptrace_after set ret ENOSPC: %d > %d\n", *count, max_count);
&nbsp; &nbsp; &nbsp; &nbsp; } else {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; args->ret = 0; &nbsp; &nbsp; &nbsp; // 假装设置成功
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
&nbsp; &nbsp; // -----------------------------------------------------------
&nbsp; &nbsp; // &nbsp;戏码 B:保安试图读取调试寄存器检查 (PTRACE_GETREGSET)
&nbsp; &nbsp; // -----------------------------------------------------------
&nbsp; &nbsp; else if (request == PTRACE_GETREGSET) {
&nbsp; &nbsp; &nbsp; &nbsp; struct my_user_hwdebug_state hwdebug_local;
&nbsp; &nbsp; &nbsp; &nbsp; hwdebug_local.dbg_info = max_count;

&nbsp; &nbsp; &nbsp; &nbsp; // 【关键 3】:把“假账本”里的数据塞给保安!
&nbsp; &nbsp; &nbsp; &nbsp; for (int i = 0; i < *count; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; hwdebug_local.dbg_regs[i].addr = cur_regs[i].addr;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; hwdebug_local.dbg_regs[i].ctrl = cur_regs[i].ctrl;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; // 修改用户态的返回值,完成偷天换日
&nbsp; &nbsp; &nbsp; &nbsp; kf_copy_to_user(iov.iov_base, &hwdebug_local, copy_len);
&nbsp; &nbsp; &nbsp; &nbsp; args->ret = 0;
&nbsp; &nbsp; }
}

至于 perf_event_open 的主动触发测试?由于我们的硬件断点是精确到具体的 TID 和目标地址的,只要反作弊测试的不是我们 Hook 的那个具体业务函数,它的 perf_event_open 在内核底层依然会被正常处理,物理异常依然会正常触发。我们只拦截我们关心的目标,对反作弊的测试用例一律放行。

降维防线的最终效果

当我们这套内核伪装逻辑生效后,目标 App 里的反作弊引擎会看到这样一幅“祥和”的画面:

  • 它想查当前断点?我们返回假账本里的 0,它以为干干净净。
  • 它想下 6 个断点占坑?我们把它存进假账本,返回 成功,它以为自己大权在握。
  • 它想下 7 个断点测试?我们返回 -ENOSPC,它确信操作系统原封未动、完美无瑕。
  • 它想写完再读出来比对?因为它始终在和我们的“假账本”交互,写啥读啥,读写一致性完美闭环。

反作弊引擎在这个虚假的“楚门世界”中疯狂运转、安心入睡。而就在同时,我们真实的 HWBP 断点在 CPU 的物理寄存器上,肆无忌惮地监听着它的核心逻辑。

5.3 基础 HWBP 演示:修改 PC 与致命的“死循环”困局

了解了目标是谁,我们来看看如何触发拦截。内核提供了 register_user_hw_breakpoint API,当我们为一个线程绑定好断点地址后,只要 CPU 执行到这里,就会触发我们预设的回调函数 bg_hwhook_handler

如果在 Inline Hook 模式下,我们要让目标函数 A 跳转到我们自定义的函数 B(跳板),内核态的操作极其简单粗暴——直接篡改 PC 寄存器

// &nbsp;内核态断点触发回调 (基础 Inline Hook 演示)
static void bg_hwhook_handler(struct perf_event *bp, struct perf_sample_data *data, struct pt_regs *regs) {
&nbsp; &nbsp; // ... 寻找匹配的断点 matched ...
&nbsp; &nbsp; if (matched->is_inlinehook) {
&nbsp; &nbsp; &nbsp; &nbsp; // 瞬间修改 PC 指针,完成“空间跳跃”!
&nbsp; &nbsp; &nbsp; &nbsp; // 原本 CPU 下一步要执行 A,现在被我们强行指到了 B (rep_addr)
&nbsp; &nbsp; &nbsp; &nbsp; regs->pc = matched->rep_addr;
&nbsp; &nbsp; &nbsp; &nbsp; return;
&nbsp; &nbsp; }
}

踩坑点 2: 上面这行 regs->pc = matched->rep_addr; 看似完美,却隐藏着一个巨大的逻辑炸弹。 当自定义函数 B 执行完我们的监控逻辑后,它大概率需要调用原函数A 以维持程序正常运转。

此时灾难发生了:

  • B 尝试调用原函数 A
  • CPU 刚走到 A 的入口,啪!又踩中了硬件断点!
  • 内核再次触发异常,把 PC 又改回了 B
  • B 又调用 A -> A 又触发断点跳到 B…… 砰!栈溢出,App 当场崩溃!

为了解决死循环,我们必须让跳板函数在调用原函数前,拥有“暂时关闭电闸”的能力。 我们的解决思路是引入一套上下端协同的控制流

  • 执行原函数前(关闸):在用户态跳板 B 中,先通过我们自定义的 Syscall 发送 call_hwhook_disable_exact() 指令。内核收到后,立刻调用 perf_event_disable(bp)暂时关闭当前线程的硬件断点
  • 执行原函数(安全通行):此时当前线程的 CPU 寄存器里没有断点了,它可以安全地执行完原函数 A
  • 执行完原函数后(开闸):跳板 B 拿到返回值后,再次通过 Syscall 发送 call_hwhook_enable_exact()。内核调用 perf_event_enable(bp),重新拉起红外绊马索,恢复对后续调用的监控。

这就是为什么传统的 Inline Hook 可以在用户态全自动完成,而无痕硬件 Hook 必须依赖应用层和内核层的高频精密协同。

5.4 突破死循环的终极兵器:动态跳板 (Trampoline) 与 X20 安全屋

前面提到,为了防止调用原函数时引发“无限触电”的死循环,我们必须在调用前“关闸”,调用后再“开闸”。

如果我们的目标仅仅是“在目标函数执行前/后抓取数据(观察者模式)”,我们完全可以在内核态通过“状态机跳跃”搞定,不需要任何额外的内存。 但如果我们想要“完全替换目标函数的执行流,且在任意时刻随时调用原函数”,我们就必须在用户态(Ring 3)精心打造一个隔离间(动态跳板 Trampoline)。

这个隔离间不能是静态编译的 C++ 代码,因为我们要适配无数个不同的目标函数。因此,我们在用户态引入了 VIXL 动态汇编引擎,在程序运行时,利用 mmap 动态写出一段极度纯密的 ARM64 机器码。

这个跳板的完整运转流程,堪称一场精密的特工作业:

1. 栈环境借用与上下文快照

根据 ARM64 的调用约定,X0-X7 和 V0-V7 用于传递参数,它们是“易变寄存器(Volatile)”。只要发生函数调用,它们里面的数据就会变成垃圾。 因此,跳板的第一步是强行在当前线程的栈上“借用” 512 字节的临时空间,把原函数的入参全部锁进保险柜(压栈)。

2. “X20 安全屋” 保护 LR 寄存器

这是一个极易踩坑的底层细节!跳板在执行过程中,必然需要使用 BLR 指令去调用我们的“关闸/开闸”函数。而在 ARM64 中,BLR 指令会自动覆盖 LR (X30) 寄存器! 如果 LR 被毁,函数执行完就再也回不到真正的调用者那里了,App 会当场崩溃。 破解之法: 根据 AAPCS64 约定,X20 是非易变寄存器(Callee-Saved)。跳板在入口处,瞬间将真实的 LR 转移到 X20 中保护起来。只要内部调用的 C++ 函数守规矩,X20 的值就永远不会变。X20 成了这趟时空穿梭中绝对安全的“避难所”。

3. 动态汇编流的“七步绝杀”

有了上下文快照和安全屋,VIXL 引擎便开始动态吐出机器码,完成这套“关闸 -> 执行 -> 开闸”的连环操作。为了方便理解,我们将繁杂的机器码还原为直观的伪汇编逻辑:

; ================== VIXL 动态生成的跳板代码 ==================

; 【第 1 步】:Prologue 借用 512 字节栈帧,保护上下文
SUB SP, SP, #512
STR X20, [SP, #416] &nbsp; &nbsp; &nbsp; &nbsp;; 备份原 X20
MOV X20, X30 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; 将真实的 LR (X30) 藏进 X20 安全屋
; ...(此处省略保存 X19-X29, V8-V15 等非易变寄存器的代码)...

; 【第 2 步】:保存原函数的入参 (X0-X7, V0-V7)
STP X0, X1, [SP, #0] &nbsp; &nbsp; &nbsp; ; 压栈保存参数,防被破坏
; ...

; 【第 3 步】:调用 Syscall 暂时关闭硬件断点 (关闸)
MOV X0, <目标函数地址>
MOV X16, <bg_bghook_disable_exact_地址>
BLR X16 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 触发关闸!

; 【第 4 步】:恢复刚才保存的参数,准备调用原函数
LDP X0, X1, [SP, #0]
; ...

; 【第 5 步】:无挂碍地调用原函数 (此时已没有硬件断点)
MOV X16, <目标函数地址>
BLR X16 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 此时 X0/V0 中已拿到原函数的真实返回值!

; 【第 6 步】:将拿到的返回值压栈保护起来,去重新开闸
STP X0, X1, [SP, #0] &nbsp; &nbsp; &nbsp; ; 保护返回值
MOV X0, <目标函数地址>
MOV X16, <bg_bghook_enable_exact_地址>
BLR X16 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 触发开闸!重新布下红外绊马索

; 【第 7 步】:Epilogue 恢复所有现场,光速逃离
LDP X0, X1, [SP, #0] &nbsp; &nbsp; &nbsp; ; 恢复真正的返回值到 X0
MOV X30, X20 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; 从 X20 安全屋中把真实的 LR 拿出来还给 X30!
LDR X20, [SP, #416] &nbsp; &nbsp; &nbsp; &nbsp;; 恢复原 X20
ADD SP, SP, #512 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ; 归还 512 字节栈帧
RET &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;; 完美返回给上层调用者!

通过这套基于 VIXL 动态生成的跳板,我们不仅完美避开了硬件断点重复触发的死亡螺旋,更做到了对原函数寄存器状态的 100% 像素级还原

更绝的是,为了对抗内存扫描,这块存放跳板指令的内存,也可以通过 prctl(PR_SET_VMA_ANON_NAME) 伪装成合法的系统内存段名,或者直接由内核接管申请(如前文提到的隐藏内存方案),让反作弊系统彻底变成“睁眼瞎”。

5.5 硬件断点的高阶玩法:状态机跳跃与零开销监听

搞定了基础的执行流替换,我们再来玩点高端的。 上面提到,每次执行原函数都需要手动“关闸、开闸”,这在某些极高频调用的函数里性能损耗较大。更重要的是,如果我们的目标仅仅是“监听入参和返回值,不想修改执行流”,传统的方案要么消耗 2 个断点,要么需要庞大的跳板。

在我们的 bg_hwhook_handler 回调中,实现了一个极具创意的单断点状态机跳跃机制

// &nbsp;内核态断点触发回调 (高阶观察者模式)
static void bg_hwhook_handler(struct perf_event *bp, struct perf_sample_data *data, struct pt_regs *regs) {
&nbsp; &nbsp; // ... 寻找匹配的断点 matched ...

&nbsp; &nbsp; // 【状态 2:命中了 LR,说明函数原生执行完毕,正在返回】
&nbsp; &nbsp; if (matched->is_listen_return_value && matched->is_waiting_return) {
&nbsp; &nbsp; &nbsp; &nbsp; // 抓取 regs->regs[0] 里的返回值,发给应用层
&nbsp; &nbsp; &nbsp; &nbsp; sendMsgForUser(matched, regs, BG_HW_RETURN_VALUE);

&nbsp; &nbsp; &nbsp; &nbsp; // &nbsp;把断点从 LR 瞬间移回到函数入口,迎接下一次调用!
&nbsp; &nbsp; &nbsp; &nbsp; struct perf_event_attr_510 attr = ...;
&nbsp; &nbsp; &nbsp; &nbsp; attr.bp_addr = matched->orig_bp_addr;

&nbsp; &nbsp; &nbsp; &nbsp; // 极其关键的“时效性”组合拳:先 Disable,修改后再 Enable,迫使 CPU 立即刷入物理寄存器!
&nbsp; &nbsp; &nbsp; &nbsp; perf_event_disable(matched->bp_handle);
&nbsp; &nbsp; &nbsp; &nbsp; modify_user_hw_breakpoint(matched->bp_handle, (struct perf_event_attr *)&attr);
&nbsp; &nbsp; &nbsp; &nbsp; perf_event_enable(matched->bp_handle);

&nbsp; &nbsp; &nbsp; &nbsp; matched->bp_addr = matched->orig_bp_addr;
&nbsp; &nbsp; &nbsp; &nbsp; matched->is_waiting_return = false; // 状态重置
&nbsp; &nbsp; &nbsp; &nbsp; return;
&nbsp; &nbsp; }

&nbsp; &nbsp; // 【状态 1:命中了函数入口】
&nbsp; &nbsp; // 抓取 regs->regs[0~7] 里的参数,发给应用层
&nbsp; &nbsp; sendMsgForUser(matched, regs, -1);

&nbsp; &nbsp; // &nbsp;把硬件断点瞬间跳跃至 LR 寄存器指向的地址!
&nbsp; &nbsp; if (matched->is_listen_return_value && modify_user_hw_breakpoint) {
&nbsp; &nbsp; &nbsp; &nbsp; uint64_t lr = (uint64_t)STRIP_PAC(regs->regs[30]); // 获取原生的调用者返回地址

&nbsp; &nbsp; &nbsp; &nbsp; struct perf_event_attr_510 attr = ...;
&nbsp; &nbsp; &nbsp; &nbsp; attr.bp_addr = lr; // 移到 LR

&nbsp; &nbsp; &nbsp; &nbsp; perf_event_disable(matched->bp_handle);
&nbsp; &nbsp; &nbsp; &nbsp; modify_user_hw_breakpoint(matched->bp_handle, (struct perf_event_attr *)&attr);
&nbsp; &nbsp; &nbsp; &nbsp; perf_event_enable(matched->bp_handle);

&nbsp; &nbsp; &nbsp; &nbsp; matched->bp_addr = lr;
&nbsp; &nbsp; &nbsp; &nbsp; matched->is_waiting_return = true; // 状态切换为等待返回
&nbsp; &nbsp; }
}

这种玩法的降维打击在于: 它不需要分配任何用户态跳板内存,不需要改变目标函数的执行逻辑。它就像一个如影随形的幽灵,只在“进入”和“返回”的两个瞬间闪现,拿走数据,然后了无痕迹。

运转流程主要说明如下 :

5.6 PTE Hook + UXN:拉起隐形高压电网与时空传送

硬件断点虽好,但 ARM64 物理 CPU 规定死了:最多只有 6 个执行断点寄存器。 如果你想 Hook 100 个函数怎么办?硬件断点当场歇菜。这时候,我们就必须切换到 PTE (页表项) Hook 方案。

我们在前奏知识里提到,操作系统依靠 PTE 来管理内存权限。我们的思路很简单:直接找到目标函数所在的物理内存页,在底层剥夺它的“可执行权限 (UXN)”。

//拉起 UXN 高压电网
void *mm = kf_get_task_mm(current);
void *ptep = NULL;
// 遍历页表找到目标的 PTE 指针
kf_apply_to_page_range(mm, mapping->orig_page_addr, PAGE_SIZE, extract_pte_callback, &ptep);
if (ptep) {
&nbsp; &nbsp; u64 pval = *(volatile u64 *)ptep;
&nbsp; &nbsp; pval |= MY_PTE_UXN; &nbsp;// 核心魔法:置位 UXN (用户态不可执行)
&nbsp; &nbsp; *(volatile u64 *)ptep = pval;
&nbsp; &nbsp; flush_tlb_page_custom(mapping->orig_page_addr); // 暴力刷新 TLB 使其立即生效
}

至此,原代码所在的那 4KB 内存彻底变成了一片“雷区”。代码内容依然可以被完美读取(躲避了 CRC 校验),但只要任何线程试图在这里执行哪怕一条指令,CPU 就会触发缺页异常(Instruction Abort)。

我们只需在内核的缺页处理源头 do_page_fault 设下拦截网,就能完成接管和时空跃迁:

// &nbsp;时空跃迁 (缺页异常路由)
static void do_page_fault_before(hook_fargs4_t *args, void *udata) {
&nbsp; &nbsp; unsigned long fault_addr = (unsigned long)args->arg0;

&nbsp; &nbsp; // ... 过滤异常类型,确保是我们制造的执行异常 ...
&nbsp; &nbsp; uint64_t fault_page = fault_addr & PAGE_MASK;
&nbsp; &nbsp; uint32_t insn_idx = (fault_addr & ~PAGE_MASK) / 4; // 精准算出报错的是该页的第几条指令

&nbsp; &nbsp; // 查阅应用层发来的“传送地图 (offset_map)”
&nbsp; &nbsp; if (pos->pid == pid && pos->orig_page_addr == fault_page) {
&nbsp; &nbsp; &nbsp; &nbsp; uint32_t target_offset = pos->offset_map[insn_idx];

&nbsp; &nbsp; &nbsp; &nbsp; // 瞬间修改线程的 PC 指针,将它扔进我们在用户态重编译好的影视基地!
&nbsp; &nbsp; &nbsp; &nbsp; regs->pc = pos->recomp_page_addr + (target_offset * 4);

&nbsp; &nbsp; &nbsp; &nbsp; args->skip_origin = 1; // 告诉内核:这个异常是我们故意制造的,别把 App 杀了!
&nbsp; &nbsp; &nbsp; &nbsp; args->ret = 0;
&nbsp; &nbsp; }
}

这种设计的极致之美在于:它是绝对并发安全的。 不管当前有多少个线程同时撞到了这块内存,CPU 都会把它们挨个送进内核态,然后内核给它们每人分配一个新坐标(重编译页的 PC),大家相安无事地继续执行。

5.7 核心基石:DBI 指令重编译 (搭建影视基地)

把 PC 扔进新内存很容易,但新内存里的指令必须能正常运行!我们在用户态用 C++ 实现了一个极其轻量级的 DBI (动态二进制插桩) 引擎。它的任务是:扫描原函数的那 1024 条指令,把它们搬到克隆页,并在搬运的过程中,精准擦除并重写所有依赖地理位置的相对路标

这里面最大的挑战就是处理各种千奇百怪的 ARM64 相对寻址指令。我们来看看 DBI 引擎是如何“拆弹”的:

拆弹一:远距离的无条件跳转 (B/BL)

当我们在原始代码中写了一句 B func 时,指令里存的不是 func 的绝对地址,而是类似于“往前跳 100 步”。 现在我们把代码搬到了几十 MB 甚至几 GB 之外的“克隆页”,如果它还“往前跳 100 步”,就会掉出悬崖崩溃(B 指令的最大跳跃范围是 ±128MB)。

DBI 引擎的解法:强行转换为绝对寻址(Far Redirect) 如果发现跳跃目标超出了克隆页的范围,我们就不再用 B 指令了,而是用一连串的指令,强行拼凑出一个 64 位的绝对地址,并通过寄存器跳转。

// ????️ DBI 重编译 B/BL 远跳 (Far Redirect)
static uint32_t emit_far_redirect(uint32_t *out, uint64_t target, uint64_t fault_addr) {
&nbsp; &nbsp; // 1. 先把我们准备征用的 X17 寄存器保存到栈底,防止破坏业务数据
&nbsp; &nbsp; out[0] = 0xF81E0FF1; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; /* STR X17, [SP, #-32]! */
&nbsp; &nbsp; // ...
&nbsp; &nbsp; // 2. 利用 LDR 指令,从当前 PC 后面的内存池里,把 64 位的目标地址捞进 X17
&nbsp; &nbsp; out[3] = encode_ldr_lit64(17, 16); &nbsp;/* LDR X17, [PC, #16] */
&nbsp; &nbsp; // ...
&nbsp; &nbsp; // 3. 执行无条件跳转!
&nbsp; &nbsp; out[6] = 0xD61F0220; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;/* BR X17 */

&nbsp; &nbsp; // 4. 把 64 位的目标地址当做数据,硬编码贴在指令后面
&nbsp; &nbsp; emit_u64(&out[7], target);
&nbsp; &nbsp; return 11; // 原来只占 1 个槽位的 B 指令,现在膨胀成了 11 个槽位!
}

原本只要 4 字节的指令,现在膨胀成了 44 字节(11 个槽位)。这就是为什么 DBI 引擎在拷贝指令前,必须先跑一趟 dbi_compute_layout,预先算出每一条指令膨胀后的偏移量,并记录在 offset_map 里的原因。

拆弹二:条件跳转 (B.cond/CBZ/TBNZ)

条件跳转(比如:如果 X0 等于 0,就跳到那里)比无条件跳转更恶心。在 ARM64 中,条件跳转指令(如 CBZ)的有效射程非常短,只有 ±1MB! 一旦我们把代码搬到老远的克隆页,这些条件跳转 100% 会越界。

DBI 引擎的解法:反转条件,金蝉脱壳 既然直接跳不过去,那我们就“反着来”! 假设原指令是:如果条件成立,跳到 目标A。 我们把它改写为:如果条件【不】成立,跳过下面这段远跳指令 + 远跳指令跳到 目标A

// ????️ DBI 重编译条件跳转 (以 B.cond 为例)
static uint32_t dbi_emit_bcond_outpage(...) {
&nbsp; &nbsp; uint32_t cond = insn & 0xF;

&nbsp; &nbsp; // 1. 翻转条件,比如把 BEQ (等于) 变成 BNE (不等于)
&nbsp; &nbsp; // 2 & 0x7FFFF << 5 代表向前跳 2 个指令的距离(跳过下面的远跳)
&nbsp; &nbsp; out[0] = ARM64_BC_INST | ((2 & 0x7FFFF) << 5) | cond;

&nbsp; &nbsp; // 2. 原本的逻辑是往后走的,现在用一个无条件跳转连到远跳逻辑
&nbsp; &nbsp; out[1] = encode_b(48);

&nbsp; &nbsp; // 3. 在后面接上前面写好的 11 槽位的远跳大招 (Far Redirect)
&nbsp; &nbsp; return dbi_emit_cond_outpage_tail(out, target, ...);
}

拆弹三:基于 PC 的数据寻址 (ADRP+ADD)

Android 中的字符串和全局变量,通常是用 ADRP 和 ADD 两条指令组合来获取的。ADRP 的作用是:以当前指令所在的 4KB 页为基准,加上一个相对页偏移,算出目标数据所在的页地址。 代码搬到克隆页后,基准页全变了,算出来的数据地址必然是错的。

DBI 引擎的解法:直接算出绝对地址,硬塞进去 由于 DBI 引擎在编译时就知道原函数的物理地址,我们可以提前把目标数据的绝对地址算得清清楚楚。然后把原本的 ADRP 强行变成一条 LDR 指令,直接从内存里把算好的绝对地址加载到目标寄存器里。

// ????️ DBI 重编译 ADRP 指令
static uint32_t dbi_emit_adrp(uint32_t *out, uint32_t insn, uint32_t insn_idx, uintptr_t page_addr) {
&nbsp; &nbsp; uintptr_t pc = page_addr + (uintptr_t)insn_idx * 4;
&nbsp; &nbsp; int64_t val = decode_adrp_value(insn, pc); // 提前根据原生 PC 算出真实的绝对地址
&nbsp; &nbsp; uint32_t rd = insn & 0x1F; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 看看人家原本想存进哪个寄存器

&nbsp; &nbsp; out[0] = encode_ldr_lit64(rd, 8); // 把 ADRP 强行改写为 LDR Rd, [PC, #8]
&nbsp; &nbsp; out[1] = encode_b(12); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 跳过后面的数据区
&nbsp; &nbsp; emit_u64(&out[2], (uint64_t)val); // 直接把算好的绝对地址贴在这里!
&nbsp; &nbsp; return 4;
}

ARMv8.3 PAC 验证机制采坑点: 在高版本 Android 设备上,函数跳转会使用带签名的 BLRAAZ X8 等指令。如果我们强行替换跳转,会破坏签名导致 SIGILL。DBI 引擎在这里施展了一手精妙的位运算魔法:

// DBI 引擎处理 BLR 系列指令
static uint32_t dbi_emit_blr(uint32_t *out, uint32_t insn, ...) {
&nbsp;uint64_t lr_val = page_addr + (uint64_t)(insn_idx + 1) * 4; // 计算出原生应该返回的真实 LR 地址
&nbsp;// 【核心魔法】:清除指令的第 21 位!
&nbsp;// 这会将 BLR 变成 BR,BLRAAZ 变成 BRAAZ,BLRAB 变成 BRAB!
&nbsp;// 完美保留了硬件对目标指针的 PAC 签名验证,同时阻止它污染 LR 寄存器!
&nbsp;uint32_t br_insn = insn & ~(1 << 21);
&nbsp;out[0] = encode_ldr_lit64(30, 8); &nbsp; // LDR X30, [PC, #8] (把原生的真实地址强行塞入 LR)
&nbsp;out[1] = br_insn; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 执行降级后的 BR* 指令
&nbsp;emit_u64(&out[2], lr_val); &nbsp; &nbsp; &nbsp; // 填入字面量:原生 LR 地址
&nbsp;return 4;
}

远跳修正 (Far Redirect): 当原本的相对跳转 (B, BL) 因为搬到克隆页而导致目标超出 ±128MB 范围时,我们自动将其转换为使用暂存寄存器 (X17) 的绝对跳转,并且在此过程中绝不破坏原始上下文(连状态寄存器都完美保留)。

5.8 (低阶方案)基础MapItem隐藏

为了存放 DBI 编译好的代码,我们在用户层 mmap 了一块内存。这块内存必然会暴露在 /proc/self/maps 中。我们要让它彻底隐形。 反作弊扫描 maps 最终都会调用内核的 show_map 或 show_smap 等 seq_file 打印函数。我们直接在 KPM 内核模块中 Hook 它们。

// &nbsp;幽灵内存:拦截 maps 文件的打印缓冲区
static void after_hide_maps(hook_fargs2_t *args, void *udata, char *from) {
&nbsp; &nbsp; struct seq_file *m = (struct seq_file *) args->arg0;
&nbsp; &nbsp; size_t prev_count = args->local.data0; // 记录打印前的缓冲区大小
&nbsp; &nbsp; size_t len_added = m->count - prev_count; // 算出本次新增的数据长度

&nbsp; &nbsp; // 提取出本次准备输出的一行文本
&nbsp; &nbsp; char *entry_buf = vmalloc(len_added + 1);
&nbsp; &nbsp; memcpy(entry_buf, m->buf + prev_count, len_added);
&nbsp; &nbsp; entry_buf[len_added] = '\0';

&nbsp; &nbsp; // 检查这行是否包含我们 DBI 引擎内存的名称/特征
&nbsp; &nbsp; if (isNeedHideMapsListItem(entry_buf, false)) {
&nbsp; &nbsp; &nbsp; &nbsp; // 发现目标!执行“缓冲区截断”:时光倒流!
&nbsp; &nbsp; &nbsp; &nbsp; m->count = prev_count; // 把缓冲区的写入指针强行拨回原位
&nbsp; &nbsp; &nbsp; &nbsp; args->ret = SEQ_SKIP; &nbsp;// 告诉 seq_file 系统:抛弃这条记录
&nbsp; &nbsp; &nbsp; &nbsp; m->pad_until = 0;
&nbsp; &nbsp; }
&nbsp; &nbsp; kvfree(entry_buf);
}

5.9 终极潜行:幽灵内存 (Ghost Mem) 的 VMA-less 物理级隐身

为了存放 DBI 编译好的代码(影视基地),我们在用户态必须有一块可执行的内存。常规做法是调用 mmap,但这会产生一个致命问题:只要调用了mmap,操作系统(市政厅)就会在进程的 mm_struct 链表中合法注册一个 VMA(虚拟内存区域)。 这个 VMA 必然会暴露在 /proc/self/maps 中。

我们在前面的方案中,通过拦截 seq_file 打印缓冲区,做到了“蒙住保安的眼睛”。但这还不够极致,如果保安不用眼看,而是用底层的 API(如 mincore)去盲探内存呢?

为了做到 100% 的绝对隐形,我们抛弃了常规的内存分配,直接在内核态实现了一套 VMA-less(无 VMA)的物理级内存映射方案

核心思想:越过市政厅,直接改写底层地契! 既然 maps 文件只会遍历操作系统登记过的 VMA 链表,那我们干脆不向操作系统申请 VMA!我们在内核偷偷买一块地(分配物理内存),然后伪造一张底层地契(PTE 页表项),硬塞给目标 App。对于操作系统而言,这块内存根本不存在(查无此 VMA);但对于 CPU 硬件(MMU)而言,这块内存是完全合法的!

我们来看看特工是如何分 3 步“私搭乱建”的:

第一步:在“内网”暗中备货 (分配内核物理页)

我们不在用户态申请内存,而是在内核驱动中,使用 vzalloc 偷偷分配一块纯净的内核内存。此时,这块内存只属于内核,用户态是绝对碰不到的。

// 1. 分配内核内存 (物理页备货)
void *kaddr = kf_vzalloc(size);
if (!kaddr) return 0;

第二步:在用户态地图上找一个“无人区”

我们在用户态的地址空间里(比如 0x6000000000 这个偏远的虚拟地址大区),利用时间戳或随机数,随机挑选一个没有被任何 VMA 占用的空地(target_va),作为我们即将开辟的幽灵基地的入口。

// 2. 寻找空闲的幽灵地址 (避开已有 VMA)
unsigned long target_va = get_random_ghost_addr(); // 例如 0x6000123000

第三步:魔改 PTE,打通内核与用户态的“走私通道”(核心高光)

这是整个幽灵内存最秀的操作!我们调用内核 API apply_to_page_range,强行介入目标 App 的页表树,把刚才内核物理内存的 PFN(物理页帧号),硬生生地挂载到我们随机挑选的用户态虚拟地址(target_va)上!

在这个过程中,我们必须手工伪造页表项(PTE)的硬件属性。来看看这段极其硬核的位运算魔法:

// ????️ 注入 PTE 的回调函数:强行将内核内存映射给用户态!
static int inject_pte_callback(void *ptep_void, unsigned long addr, void *data) {
&nbsp; &nbsp; u64 *ptep = (u64 *)ptep_void;
&nbsp; &nbsp; struct ghost_inject_data *inj_data = (struct ghost_inject_data *)data;

&nbsp; &nbsp; // 1. 拿到我们偷偷分配的内核内存的真实物理页帧号 (PFN)
&nbsp; &nbsp; unsigned long current_kaddr = inj_data->kaddr_base + inj_data->offset;
&nbsp; &nbsp; unsigned long pfn = kf_vmalloc_to_pfn((void *)current_kaddr);

&nbsp; &nbsp; // 2. 将 PFN 偏移到对应的物理地址位
&nbsp; &nbsp; u64 pte_val = (u64)pfn << PAGE_SHIFT;

&nbsp; &nbsp; // &nbsp;3. 【核心魔法】:纯手工拼装 ARM64 用户态 Normal Memory 的完美 PTE 属性!
&nbsp; &nbsp; // 0x1: Valid (页表项有效)
&nbsp; &nbsp; // 0x2: Page (这是一个 4KB 页,不是块)
&nbsp; &nbsp; // 0x4: Normal Memory (普通内存,允许缓存,AttrIndx=1)
&nbsp; &nbsp; // 0x40: User (赋予用户态 EL0 访问权限!至关重要!)
&nbsp; &nbsp; // 0x300: Inner Shareable (内部共享)
&nbsp; &nbsp; // 0x400: Access Flag (AF,访问标志已置位)
&nbsp; &nbsp; // 0x800: Not Global (nG,防止污染内核全局 TLB 缓存)
&nbsp; &nbsp; pte_val |= 0x1 | 0x2 | 0x4 | 0x40 | 0x300 | 0x400 | 0x800;

&nbsp; &nbsp; // 4. 暴力覆写底层硬件页表项!
&nbsp; &nbsp; *(volatile u64 *)ptep = pte_val;

&nbsp; &nbsp; // 5. 暴力刷新硬件 TLB 缓存,强制 CPU 立刻认下这张“伪造地契”
&nbsp; &nbsp; asm volatile("dsb sy\n" "tlbi vmalle1is\n" "dsb sy\n" "isb\n" ::: "memory");

&nbsp; &nbsp; inj_data->offset += PAGE_SIZE;
&nbsp; &nbsp; return 0;
}

降维打击的艺术 当 inject_pte_callback 执行完毕后,奇迹发生了: 目标 App 在用户态去访问 target_va 这个地址时,CPU 的硬件 MMU 查阅底层页表,发现属性里写着 0x40 (User 权限),于是高高兴兴地把数据返回给了 App。CPU 硬件完美认可是合法的。 但是!如果反作弊引擎去遍历操作系统的 /proc/self/maps,操作系统去查自己的户籍本(mm_struct -> mmap 链表),却会发现这个地址没有任何登记记录!操作系统认为它是非法的。

这种利用 “硬件页表 (PTE) 与 操作系统管理层 (VMA) 之间的信息差” 制造出来的内存,就是真正的 幽灵内存 (Ghost Mem)

它不需要挂载任何 /proc 文件系统的隐藏 Hook,因为它从源头上就彻底逃脱了 Linux 操作系统的内存管理!在这块幽灵内存里存放我们的 DBI 引擎和克隆代码,检测就算把系统 API 翻个底朝天,也找不到一丝痕迹。

5.10 实现方案:KPM 模块与内核的桥梁

由于这套架构极度依赖用户态和内核态的高频协同,我们在内核态编写了一个 KPM (Kernel Patch Module) 驱动,并通过 Hook 新增了一个自定义的系统调用(Syscall),作为上下端通信的桥梁:

// &nbsp;内核态 Syscall 桥接层
static void syscall_before(hook_fargs6_t *args, void *udata) {
&nbsp; &nbsp; long flag = (long) syscall_argn(args, 0);
&nbsp; &nbsp; if (flag != BGSYSCALL_FLAG) return; // 校验魔数

&nbsp; &nbsp; long cmd = (long) syscall_argn(args, 1); // 获取指令类型

&nbsp; &nbsp; // ... 权限验证与安全校验 ...

&nbsp; &nbsp; // 拦截原始系统调用,将命令路由到我们内核里的业务逻辑
&nbsp; &nbsp; args->skip_origin = 1;
&nbsp; &nbsp; args->ret = bg_syscall(cmd, a1, a2, a3, a4);
}

// 调度中心
static unsigned long bg_syscall(long cmd, long arg1, ...) {
&nbsp; &nbsp; switch (cmd) {
&nbsp; &nbsp; &nbsp; &nbsp; case BGSYSCALL_HB_HOOK: // 下发硬件断点
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return call_hwhook_hook((void*) arg1, ...);
&nbsp; &nbsp; &nbsp; &nbsp; case BGSYSCALL_HB_DBI_COMMIT: // 下发 DBI 重编译内存页表
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return call_hwhook_dbi_commit((void __user *)arg1);
&nbsp; &nbsp; &nbsp; &nbsp; case BGSYSCALL_HD_SO_ADD: // 添加 Maps 隐身名单
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return call_hide_mem_add(...);
&nbsp; &nbsp; &nbsp; &nbsp; // ...
&nbsp; &nbsp; }
}

通过这种方式,应用层(C++ DBI 引擎)只需要组装好“传送地图”,发起一句 syscall,内核瞬间心领神会,为其铺平底层的一切高压电网与监控盲区。

6、相对传统Inlinehook区别

讲到这里,整个框架的拼图已经完整。但如果你是一个在安全圈摸爬滚打多年的老手,你一定会问:搞这么复杂,到底值不值?各种 Hook 方式到底有何优劣?

我们通过一个深度对比,来彻底看清这场“攻防军备竞赛”的演进脉络:

方案 A:传统 Inline Hook

  • 原理:直接修改目标函数的前几个字节,替换为 B 或 LDR PC 等跳转指令,跳到我们分配好的 Trampoline(跳板)执行。

  • 优势

  • 极速、简单:纯用户态即可完成,不需要 Root 或内核权限。

  • 兼容性好:业界有大量成熟的框架(如 Dobby, PLTHook),开箱即用。

  • 致命弊端(被打击的原因)

  • 必死于 CRC 校验:修改了 .text 段的代码,这就好比在保安眼皮底下砸碎了银行的玻璃,任何内存完整性校验一扫一个准。

  • 必死于 Trampoline 扫描:跳板代码无处遁形,必然暴露在 maps 的匿名可执行内存中。

  • 并发灾难:在修改指令的瞬间,如果另一个线程刚好执行到这里,极易引发不可挽回的 Crash。

方案 B:纯硬件断点 Hook (HWBP)

  • 原理:将目标地址写入 CPU 的 Debug 寄存器,靠 CPU 硬件中断(SIGTRAP)来接管执行流。

  • 优势

  • 绝对无痕:内存(.text 段)一字节未改,CRC 校验完全失效。

  • 随心所欲:可以精准区分读取(Read)、写入(Write)和执行(Execute),这是 Inline Hook 永远做不到的。

  • 致命弊端(被自身物理限制卡死)

  • 名额极其稀缺:ARM64 物理 CPU 规定死了,最多只有 6 个执行断点,4 个数据观察点。如果你想 Hook 10 个函数,对不起,硬件不支持。

  • 容易被反向侦测:反作弊引擎同样可以通过 ptrace 抢占这些寄存器,或者读取你的寄存器状态来发现你在下断点。

方案 C:本文的终极架构 (PTE + UXN + DBI 重编译 + 幽灵内存)

  • 原理:结合了内核页表权限控制(UXN 触发异常)、用户态引擎(DBI 克隆并修正指令)以及内核隐身术(缓冲区截断)。

  • 绝对优势(降维打击)

  • 物理级零篡改,但容量无限:它像硬件断点一样完全不修改原始内存(无视 CRC),同时由于是基于 4KB 的内存页触发缺页异常,理论上你想 Hook 多少个函数就可以 Hook 多少个,彻底打破了 HWBP 的数量诅咒!

  • 完美的并发与状态隔离:原地址永远是高压电网(UXN),多个线程同时撞网,内核会把它们分别安全地引导到克隆页去执行,彻底消灭了多线程改代码时的竞态 Crash。

  • 绝对的隐蔽性:克隆页由内核守护隐身(maps 抹除),返回地址(LR)由 DBI 引擎和跳跃断点法完美伪装。反作弊的各种探测手段——内存扫描、堆栈回溯,在这套体系下全部沦为盲人摸象。

  • 代价与弊端

  • 超高的工程门槛:需要编写内核驱动(KPM/APatch),需要极其深厚的 ARM64 指令集功底来手写 DBI 引擎。

  • 首次触电的性能微损:目标线程第一次踩中 UXN 电网时,需要经历一次 do_page_fault 陷入内核再返回的过程。但由于后续都在克隆页里全速原生执行,这种一次性的性能损耗在绝大多数场景下可以忽略不计。

7、常见使用情景

7.1 用 HWBP 或者PTE Hook 零开销接管 LSPlant 初始化

任何 ART Hook 框架在启动时,都需要在 Android 的核心系统库(如 libart.so)中 Hook 几个关键的底层函数来构建运行环境。 这里出现了一个极其美妙的巧合:LSPlant 初始化恰好需要提供 6 个基础的 Inline Hook 接口!而我们在前面提到,ARM64 的硬件断点 (HWBP) 上限,不多不少,刚好也是 6 个

这意味着,我们完全不需要改动任何一处系统内存,直接将我们的 硬件断点Hook或者PTE  喂给 LSPlant,就能以 0 字节修改的完美伪装,完成整个框架的底层初始化:

常见的使用场景是LSPLant的初始化,LSPLant初始化需要提供inlinehook的接口,可以直接使用PTE Hook 。

使用硬件断点Hook也可以满足需求,LSPLant 初始化需要6个函数Hook地址,硬件断点正好满足需求,实现无修改Hook内存,这块需要对LSPLant魔改一下,因为LSPLant 里面用到了mmap去分配内存,保存一些跳板地址,可以把跳板的回调放到初始化里面,配合Ghost Mem 直接实现无痕。

&nbsp;lsplant::InitInfo initInfo{
&nbsp; &nbsp; // 1. 将常规的 Inline Hook 替换为我们的硬件断点 Hook / PTE Hook
&nbsp; &nbsp; .inline_hooker = my_stealth_inline_hooker,
&nbsp; &nbsp; .inline_unhooker = my_stealth_inline_unhooker,

&nbsp; &nbsp; // ... 符号解析逻辑 ...
&nbsp; &nbsp; .art_symbol_resolver = [...],
&nbsp; &nbsp; .art_symbol_prefix_resolver = [...],

&nbsp; &nbsp; // &nbsp;2. 劫持内存分配:切断 mmap,接入幽灵内存!
&nbsp; &nbsp; .mem_map = [](void* addr, size_t length, int prot, int flags, int fd, off_t offset) -> void* {
&nbsp; &nbsp; &nbsp; &nbsp; // 当 LSPlant 试图申请跳板内存时,直接呼叫内核 KPM 模块
&nbsp; &nbsp; &nbsp; &nbsp; // 越过操作系统,分配无 VMA 记录的 Ghost Mem!
&nbsp; &nbsp; &nbsp; &nbsp; return call_alloc_hidden_mem(length);
&nbsp; &nbsp; },
&nbsp; &nbsp; .mem_unmap = [](void* addr, size_t length) -> int {
&nbsp; &nbsp; &nbsp; &nbsp; MagiskRuntime::SystemLogD("lsplant custom munmap called: addr=%p", addr);
&nbsp; &nbsp; &nbsp; &nbsp; // 释放幽灵内存
&nbsp; &nbsp; &nbsp; &nbsp; return call_free_hidden_mem((unsigned long)addr);
&nbsp; &nbsp; },

&nbsp; &nbsp; .generated_class_name = "android",
&nbsp; &nbsp; .generated_source_name = "android"
};

在完成上述魔改后,LSPlant 运行所需的所有跳板指令和动态生成代码,全部被塞进了“幽灵内存”中。此时的 maps 列表干干净净,反作弊引擎的静态扫描直接报废。

7.2 ART 层的深水区博弈:从“改名片”到“设陷阱”

搞定了初始化,我们迎来了真正的挑战:如何无痕地 Hook 一个 Java 方法?

LSPlant 默认 Hook Java 方法的原理,是经典的“入口替换法”。在 Android ART 虚拟机中,每个 Java 方法在底层都对应一个 ArtMethod 结构体,结构体里有一个非常关键的指针:entry_point_from_quick_compiled_code_,它指向了这个 Java 方法被编译为机器码后的真实物理内存地址。 LSPlant 的做法是:动态生成一段包含我们 Hook 逻辑的新机器码,然后把这个指针强行篡改,指向我们的新代码。

指针漫游检测: 在攻防对抗中,防守方根本不去扫指令,他们直接扫描内存中的所有 ArtMethod 结构体! 保安拿起一个 Java 方法的“名片”(entry_point 指针),去 maps 户籍系统里一查:正常的指针应该指向合法的 boot.art 或者 /data/app/.../oat/ 格式的系统/应用只读执行段。 结果发现,这个指针竟然指向了一块查无此人的幽灵内存,或者一块匿名的 rwx 内存!保安瞬间拔枪,直接检测。

终极解法:Frida 级 Native 插桩 + 内核级无痕拦截

如果想要达到 100% 的 Bypass,我们就绝对不能去碰ArtMethod 里的那个入口指针,名片必须是原装正品!

我们需要对 LSPlant 进行深度魔改,抛弃它的指针替换逻辑,转而采用类似 Frida 的暴力思路,但结合我们的内核框架来降维执行:

  • 查名片,找老巢:读取目标 Java 方法 ArtMethod 结构体中的 entry_point_from_quick_compiled_code_ 指针,找到该 Java 方法编译后的真实汇编指令入口地址
  • 布设隐形陷阱:不对这个 entry_point 指针做任何修改。而是直接拿着这个真实的指令地址,呼叫我们的内核 KPM 模块,在这里拉起 UXN 高压电网(PTE Hook),或者拉起红外绊马索(硬件断点 HWBP)

至此,完美的闭环形成了: 反作弊去查 ArtMethod 结构体,发现入口指针完美指向合法的系统 .oat 文件; 反作弊去读取该指针指向的机器码,发现每一条指令都和系统原生一模一样,CRC 校验完美通过; 反作弊去遍历 /proc/self/maps,依然找不到任何可疑的跳板内存。

然而,只要这个 Java 方法被调用,CPU 刚踏上第一块真实的地砖,瞬间触发缺页异常坠入内核态,执行流被我们如同提线木偶般肆意操纵。

8、反思&总结

底层安全的系统攻防博弈,永远是一场没有停歇终点、持续螺旋上升演进的狂热猫鼠游戏。当那些顶级的安全防守方厂商将立足于应用层(Ring 3)与标准内核 API 领域的防御拦截规则矩阵——诸如毫秒级的不间断内存 CRC 连续盲扫、基于现代硬件特性背书的 PAC 物理签名校验 、以及极其深度的执行栈调用链路长距逆向回溯等技术打磨到极致时,作为探求系统边界的攻击者与测试方,唯一能够突破铜墙铁壁的破局出路,就只剩下实施更为决绝的底层维度降维打击

一旦研究者掌握并牢牢控制了位于操作系统 Ring 0 内核级最核心操作的上帝视角,能够肆意拨弄硬件底层的页面权限与物理寻址转换,应用层曾经建立的一切看似坚不可摧的固化防御机制,在这一刻便如同被釜底抽薪般形同虚设、彻底瓦解。

然而,获取这种近乎主宰系统一切维度运行规律的统治力,并非无需支付高昂的代价。这套复合架构虽然在真实的实战红蓝对抗中展现出了当今技术能够企及的、无可比拟的深层隐蔽性与极其强悍的拦截修改功能,但其实施落地的门槛也是常人难以逾越的鸿沟。每一次底层架构的适配都步履维艰:诸如需要承担首发业务线程在第一次陷入深层内核处理 do_page_fault 异常时,面临不可避免的硬件上下文频繁切换所导致的那一瞬微小性能抖动;此外,更需要耗费极为庞大且艰巨的研发心力,去痛苦地兼容当今日益碎片化、魔改横行的各大手机设备厂商 Android 深度定制版闭源内核的重重验证。

展望未来,攻防的焦点正在向硬件层与云端迅速转移。新一代 ARM 架构下,为了应对更为泛滥的缓冲区漏洞,刚刚崭露头角、并被誉为“硬件级超级 ASAN”的 MTE(Memory Tagging Extension,内存标记扩展机制)已经开始在最新旗舰设备上进入实装阶段 。MTE 巧妙地利用指针的顶端高位字节存入 4 位的元数据密钥标签,并严格校验每一次硬件内存读写的标签吻合度 。同时,诸如 BTI(Branch Target Identification,分支目标标识)机制也进一步锁死了未经授权的非预期指令间接跳转行为 。不仅如此,防守方云端那算力磅礴、赋能于最新 AI 启发式的海量行为基线数据异常检测统计模型(如 Vacnet 系统设计理念)的逐步成熟并大规模列装商用 ,也正在构建一堵无形的云端高墙。

当所有这些硬件级与云端分布式的终极安全护城河全面合围普及之时,哪怕是今日那些被业界奉为圭臬、堪称完美无瑕的“幽灵跳板引擎”与“状态跃迁机制”,也终将在未来的某一天迎来更为严苛且无情的新一轮大洗牌与新挑战。底层二进制世界的探索航道从来就没有真正意义上的安全停泊止境,黑客帝国般对抗碰撞出的智慧锋芒,也唯有在对计算系统最极限、最底层的运转法则永无止境的极致敬畏与持续挑战中,方能淬炼得愈发耀眼与锐利。

9、结尾

PTE Hook主要灵感来自@伏秋洛

硬断Hook思路主要来自B.B

#

看雪ID:珍惜Any

https://bbs.kanxue.com/user-home-819934.htm

*本文为看雪论坛精华文章,由 珍惜Any 原创,转载请注明来自看雪社区

第十届安全开发者峰会【议题征集】-欢迎投稿

# 往期推荐

开源通讯软件view once图片的调用逻辑分析

硬啃反封号 dylib:OLLVM CFF/MBA 混淆拆解与167 Hook 深度分析

2026腾讯游戏安全竞赛决赛安卓客户端安全分析

第二届软件系统安全赛 robo_admin 题解

第三方MiniFilter中常见的一种TOCTOU漏洞

球分享

球点赞

球在看

戳“阅读原文”一起来充电吧!


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 珍惜Any 珍惜Any《Android内核无痕Hook理解和感悟》

评论:0   参与:  0