Skip to main content

Command Palette

Search for a command to run...

记一次基于页表级联监控(pts/ptm)的 Ept 内存内省实践

Updated
4 min read

0.前期准备

我们知道在x86架构下的linux操作系统中,地址变化一直是一个头痛(?)的问题,虚拟地址到物理地址的映射离不开段页式管理,而在早期的内核版本中分段式管理占据主要地位,分页式管理作为可选机制(CR0.PG = 1)。而在现代版本中,段式管理极度弱化,只剩下页式管理。 在虚拟化的环境下,外部如何实现对一块内存进行监控会遇到三个问题: 1.EPT修改权限只能按照gpa修改,然而我们面对的是gva/gla(由于分段式弱化,这俩个可以看成是一个东西),比如说某个对象的某个字段/某个模块的某段代码。 2.gva->gpa的变化频繁,包括但不限于swap,cow,重映射,页表替换,大页合并/拆分等,不可能只做一次翻译然后ept-hook物理页,这会导致后续出现严重问题。 3.页表写非常频繁,如果监控所有页表写,那么性能会爆炸,如何进行过滤是重中之重。 让我们想象一下这么一个场景:我们需要监控一段内存的读写执行次数,但是我们只能看到虚拟地址,并且虽然虚拟地址是连续的,但是实际对应的物理地址并非连续,物理页按4k存放,那么在ept机制下,我们应该如何实现gva-gpa-hpa的变化监控?

本文记录了我参考 Bitdefender HVMI 开源项目,尝试复现其核心内存监控逻辑的过程。HVMI 在处理“虚拟地址到物理地址动态映射(GVA-to-GPA)”时的 PTS/PTM 分层架构设计极为精妙,有效解决了传统 EPT Hook 难以跟踪上层逻辑地址的痛点。本文将拆解这一架构,并分享我的工程实现思路。

1.开始监控

前文提到,在现代 x86-64 里,分段几乎退化成“历史包袱”,绝大多数情况下你可以把“虚拟地址”当成“线性地址”来用。对我们来说,这意味着一件很爽的事——不用再费劲解释 GVA 到 GLA 的那层关系;我们真正要解决的问题只有一个:给我一段虚拟地址,让它永远能正确地跟随到它当前映射的物理页(GPA),并且我还能在最后用 EPT 权限把它拦下来。 但如果你真的做过 EPT hook,就知道“给一段虚拟地址”其实是个坑:EPT 只认识物理地址,虚拟地址背后那条翻译链会被操作系统不停地改。今天这段虚拟地址映射到 GPA=A,下一秒可能因为换页、COW、重映射就变成 GPA=B。 你如果只在第一次翻译完就去 hook A,很快就会 hook 错对象:你拦截到的是“旧页”,而真实访问已经跑到“新页”上了。 于是我们需要一套“层层递进”的思路:上层用人类容易理解的方式表达需求(保护某段区域),下层把它落实成 EPT 能执行的动作(改 EPT 权限),中间还要有机制保证映射一变,我们的 hook 也跟着变。所以真正解决的事:把“我要监控一段虚拟内存”变成“我持续、自动地监控这段虚拟内存映射到的物理页”。

需求提出

我们可以想象一个最现实的需求:我想保护某个区域,比如一个模块的代码段,或者某个敏感数据结构所在的一段地址。我们能获取到的是 [va, va+len)。这段区域很可能跨页。那第一件事就不是谈 EPT,而是谈“管理”:如果我不把它拆好、记好,后面销毁的时候必然漏。于是我们可以先引入一个容器:Object。 这个 Object 不神秘,你可以理解它就是一个“账本”。你告诉它一段虚拟地址,它做的工作很朴素:沿着页边界把这段范围切成一段一段的页内片段——每一段都保证在同一个 4K 页内。切完以后,它对每一段都创建一个“页内 hook 记录”。 这样 Object 就能对外说:这整个区域我负责,你要销毁就销毁整个 Object,不用你自己记住我在里面做了多少次 hook。 到这里为止,我们其实还没碰 EPT。我们只是把问题从“一个长范围”变成了“一堆页内范围”。接下来才是关键:对于每一个页内范围,我们要建立一个能够自动跟随映射变化的 hook。这就是我说的第二层:GVA hook。 伪代码

//Object 负责记账;Span 代表一段连续范围
  HookSpan* HookObject_HookRange(HookObject* obj, uint64_t as_root,
                                 uint64_t va_start, uint64_t length,
                                 HookType type, EptViolationCallback cb, void* ctx)
  {
    HookSpan* span = ..; span->va_start = va_start; span->length = length;
    if (as_root == 0) as_root = GetKernelAddressSpaceRoot();
    for (uint64_t va = va_start; va < va_start + length; ) {
      uint64_t chunk = PAGE_REMAINING(va);
      uint64_t left  = (va_start + length) - va;
      if (chunk > left) chunk = left;
      span->hooks[span->hooks_count++] =
        VaHook_Create(as_root, va, (uint32_t)chunk, type, cb, ctx, span);
      va += chunk;
    }
    obj->spans[obj->spans_count++] = span;
    return span;
  }

GVA hook

一个 GVA hook 的世界观是这样的:我盯住的是“某个虚拟页上的某个范围”。为了后面能回收,我必须把关键信息都记下来:页基址、页内 offset/length、以及——最重要的——两张“票据”:

  1. 一张叫 PTS hook:负责告诉我“翻译变了”。

  2. 一张叫 GPA hook:负责在“当前映射到的物理页”上真正动 EPT 权限。 你可以把 GVA hook 想成一个“代理人”:它不直接做最终拦截,它的职责是维护映射关系并保持底层 GPA hook 永远指向正确的物理页。 那 GVA hook 什么时候需要动手?答案是:当页表翻译发生变化时。所以我们必须要有 PTS hook。 并且我们注意到:GVA hook 从来不是“挂在某个虚拟地址上”这么简单,它必然挂在某个地址空间上下文上。在 x86 上,这个上下文至少由下面三件事组成: 1.CR3 基址(页表根的物理地址) 2.PCID(CR3 低位,开启 PCIDE 时有效) 3.“当前用的是哪套页表”(KPTI 下的 user vs kernel page table) 因此,如果目标 VA 属于用户态地址?用 User CR3,如果目标 VA 属于内核高半区?用 Kernel CR3(KPTI 下 user CR3 可能根本不映射内核) CR3 必须先规范化(mask 掉 PCID/低位控制位)才能当物理基址使用。

  static VaHook* VaHook_Create(uint64_t as_root, uint64_t va, uint32_t len,
                               HookType type, EptViolationCallback cb, void* cb_ctx,
                               HookSpan* parent)
  {
    VaHook* h = ..;
    h->va_page = ALIGN_DOWN_4K(va);
    h->offset  = (uint16_t)(va & 0xFFF);
    h->length  = (uint16_t)len;
    h->type    = type; h->cb = cb; h->cb_ctx = cb_ctx; h->parent = parent;

    h->map = MapTrack_Attach(as_root, va, VaHook_OnMapChange, h);  // ..
    if (h->map->cur_present) VaHook_OnMapChange(h, 0, h->map->cur_entry);

    return h;
  }

PTS hook

PTS hook 目标非常具体:把一个虚拟地址的翻译链路固定下来,并持续监控这条链路上的关键节点。 最典型的变化:present 位。确实,present 的三种转移几乎覆盖了我们最常见的麻烦:

  • 从 present 变成 non-present:说明页被换出或映射失效了,这时再保留底层 GPA hook 就是错的,应该立刻撤掉。

  • 从 non-present 变成 present:说明页回来了,我们要重新拿到新映射的 GPA,然后把 GPA hook 建回去。

  • present 仍然 present,但 GPA 变了:这就是典型的 COW 或 remap。策略也很明确:旧的 GPA hook 作废,新的 GPA hook 立刻补上。 所以PTS hook 就像盯着“门牌号”的快递员。门牌号(虚拟地址)不变,但住户(物理页)可能搬家。每次搬家它都第一时间通知 GVA hook:旧地址作废,新地址生效。 当然,工程实现里通常不会只看 present 位,还会监控物理地址字段、大页位、权限位等关键字段。 那么为了做到这件事,PTS hook 必须“记住翻译链”。在五级分页里,这条链是PML5→PML4→PDP→PD→PT。我们要把每一级 entry 都变成一个节点,节点之间串起来。

  • 我是哪一级(level),否则你不知道它对应的页大小、也不知道它在整条链里处于什么位置。

  • 我这个 entry 在物理内存里的地址(pt_pa_address),因为你最终要捕获的是“这个 entry 被写”。

  • 我怎么知道自己被写了?这就引出下一层:PTM hook。

  static void VaHook_OnMapChange(void* ctx, uint64_t old_e, uint64_t new_e)
  {
    VaHook* h = (VaHook*)ctx;
    bool old_p = EntryPresent(old_e), new_p = EntryPresent(new_e);

    if (old_p && !new_p) {                       // present -> non-present
      if (h->leaf) { EptLeafHook_Remove(h->leaf); h->leaf = NULL; }
      return;
    }

    if (!old_p && new_p) {                       // non-present -> present
      uint64_t ps = EntryPageSize(new_e, 1);
      uint64_t gp = MappedGpaPage(new_e, h->va_page, ps);
      h->leaf = EptLeafHook_Install(gp + h->offset, h->length, h->type, h->cb, h->cb_ctx, h);
      return;
    }
    if (old_p && new_p) {                        // present -> present (maybe COW/remap)
      uint64_t og = MappedGpaPage(old_e, h->va_page, EntryPageSize(old_e, 1));
      uint64_t ng = MappedGpaPage(new_e, h->va_page, EntryPageSize(new_e, 1));
      if (og != ng) { if (h->leaf) EptLeafHook_Remove(h->leaf);
        h->leaf = EptLeafHook_Install(ng + h->offset, h->length, h->type, h->cb, h->cb_ctx, h); }
    }
  }

PTM hook

如果说 PTS 关心的是“entry 变没变”,那么 PTM 关心的是“entry 是怎么被改的”。这两者的差别非常像“业务逻辑”和“底层采集”。 原因很简单:硬件不会帮你说“第 123 个页表项被写了”,它只会告诉你“这 4K 的页表页发生了写入”(更准确说,是你把它设成不可写后触发了拦截)。但 PTS 真正需要的是 entry 级别的变化。于是 PTM 的角色就清晰了:把页级别的写入拆分成 entry 级别,再把变化分发给正确的监听者。 于是 PTM 通常会以“页表页”为单位维护一个 table:它知道这张页表页的 GPA 是多少,知道自己为了捕获写入而建立的底层 GPA hook 是哪个;同时它在内部按 entry 索引组织订阅者列表——谁关心第 N 个 entry,就挂到第 N个槽位上。写入发生时,PTM 根据写入地址算出这是第几个 entry,然后只通知那一小撮订阅者,而不是把整页的写都扔给上层做“大海捞针”。 并且EPT violation 发生时,写并没有发生。我们拿到 write_gpa 远远不够;要判断“entry 到底变没变”,就必须得到 OldValue 和 NewValue。这意味着 PtWriteMux 这一层不能被写成“拿地址就通知”,而必须承认一个现实模块的存在:Instruction Emulation(或等价的单步/重放机制):解码导致 VMEXIT 的指令,计算它将写入的值,并在软件层面完成/验证写入。 换句话说:PTS 是盯“换房/搬家”的,PTM 是盯“房产证某一栏被改了”的。PTS 需要的是“最终住址变没变”,PTM 负责提供“证件字段变了”这一事实,并且精确定位到哪一栏。

  #define PT_MONITORED_BITS  (BIT_P | BIT_RW | BIT_US | BIT_PS | BIT_ADDR)
  static inline bool IsOnlyAdBitUpdate(uint64_t oldv, uint64_t newv)
  {
    uint64_t diff = oldv ^ newv;
    return (diff != 0) && ((diff & PT_MONITORED_BITS) == 0);
  }
  void PtWriteMux_OnPtPageWrite(uint64_t pt_page_gpa, uint64_t write_gpa, uint32_t size)
  {
    // 1) decode + emulate: compute Old/New (the write hasn't happened yet!)
    uint64_t oldv = 0, newv = 0;
    bool ok = EmulateCurrentInstructionWrite(write_gpa, size, &oldv, &newv); // ..
    if (!ok) { .. /* fail-safe: single-step or deny or fallback */ return; }
    // 2) filter: ignore CPU-driven noise (A/D storms, etc.)
    if (IsOnlyAdBitUpdate(oldv, newv)) {
      .. /* allow write and return without notifying upper layers */
      return;
    }
    // 3) dispatch: now it makes sense to notify MapTrack / translation logic
    int idx = EntryIndexWithinPtPage(write_gpa);
    .. /* notify subscribers[idx] with (oldv, newv) */
  }
  • 普通写:MOV/OR/AND 等

  • RMW/原子:CMPXCHG、XADD、LOCK 前缀

  • 部分写:32-bit/PAE 场景可能出现半边更新(需要缓冲直到凑齐)

  • 以及任何会导致“你以为写了 entry,其实只是在改某些无关位”的情况 咬住指令模拟必须具备处理复杂原子操作的能力,如果解码器不支持某些偏门指令导致计算出的 NewValue 错误,会导致页表状态与 EPT 状态去同步,引发系统崩溃。

GPA hook

到这里,我们已经能解释“为什么 GVA→GPA 能自动跟随”:页表项写入先被 PTM 捕获、分发给 PTS;PTS 判定翻译变化后通知 GVA hook;GVA hook 决定是否撤销/重建底层 GPA hook。最后剩下的,就是我们最关心的“真正改 EPT权限”的那一层:GPA hook。 GPA hook 做的不是‘一次改权限’,而是‘维护同一物理页上多个 hook 的合并状态,并在状态变化时改权限’。 为什么?因为同一个 GPA 页上可能同时存在多个 hook:有人要拦写,有人要拦执行,甚至同类 hook 也可能叠加。你如果每来一个请求就粗暴 SetEptPermission,很快就会出现“后来的覆盖前面的”或者“计数清零时权限没恢复”的问题。所以正确的姿势是:为每个 GPA 页维护一个聚合状态(比如 read_count/write_count/exec_count),只在计数从 0 变 1(首次需要拦截)或从 1 变 0(最后一个拦截撤销)时才真正去改 EPT 权限。这样你才能保证叠加和回收都正确。 除此之外,要注意EPT 的权限组合里不能出现 W=1 且 R=0。也就是说,我们不能把页面设置成“可写但不可读”。如果你试图做“纯读拦截”(只想 trap 读,不想 trap 写),直觉会告诉你把 R 关掉、W 留着,但这在 EPT 里是非法组合,会导致配置错误。最常见的做法是:你想 trap 读时,必须让写也一起被拦截(确保 W=0),这样组合就变成 R=0,W=0,是合法的。至于“我真的只想拦读不拦写”,那就需要更高层的策略或两阶段方案,但不能直接用 R=0,W=1 这种组合硬凑。

  static void ApplyAggregatedPerms(uint64_t gpa_page, EptPageState st)
  {
    bool R = (st.r_cnt == 0);
    bool W = (st.w_cnt == 0);
    bool X = (st.x_cnt == 0);

    // Hardware legality: cannot have W=1 while R=0 (misconfiguration risk).
    // So a "read trap" must not attempt to build R=0, W=1.
    if (W && !R) { .. /* fail-fast or adjust strategy */ return; }

    ApplyEptPerms(gpa_page, R, W, X);
    FlushEptTlbs();
  }

总结

我们获取到一段虚拟地址范围,我先用 Object 把它拆成若干个页内片段,保证后续能统一管理、统一销毁。每个片段建立一个 GVA hook,GVA hook 内部拿着两张票:PTS 用来盯翻译链,GPA hook 用来最终改 EPT 权限。PTS 为了能知道翻译何时变,必须在每一级页表 entry 上建立监听;但监听 entry 写入这件事不能靠“猜”,需要 PTM 把页表页写入拆解成 entry 粒度并精确分发。于是当操作系统换页、COW、重映射时,PTM 看到 entry 写入,PTS 判断“映射变了”,GVA hook 立刻撤旧建新,保证底层 GPA hook 永远指向当前真实的物理页。最终,GPA hook 以聚合计数的方式去维护 EPT 权限,并遵守硬件合法性(比如不能构造 R=0,W=1)

More from this blog

记一次基于 Linux 5.15.0-139 内核源码中ebpf辅助函数解惑学习

在 eBPF 开发中,辅助函数(Helper Functions)是连接沙箱代码与内核原生的唯一桥梁, 辅助函数既不是动态加载的插件,也不是脆弱的符号引用。它们是在内核启动那一刻,由引导代码根据链接脚本的‘施工图’,强行焊接在内存只读区域的物理基石。 本文起源于我在开发ebpf程序时思考辅助函数到底是什么?它和内核提供的其他函数有什么区别? 。 本文记录了我是如何通过源码分析、逆向思考和底层调试,一步步打通 eBPF 辅助函数逻辑的全过程。所有的代码均出自于5.15.0-139内核源码 第一...

Feb 6, 20265 min read9

记一次Introcore 内核级 Bug 调试记录:从 PUSH 指令到页表模拟崩溃

在虚拟机内省(VMI)的底层开发中,稳定性往往比功能更难攻克。最近在对 Windows 7 进行监控实验时,Bitdefender 的 HVMI (Introcore) 引擎频繁触发致命崩溃。错误指向了页表写入模拟逻辑。 通过对 hvmid 进程进行深度的 GDB 挂载调试,我还原了一个关于指令建模、地址不匹配以及异常处理机制的完整 Bug 链路。 1. 现象:内省引擎的“自杀” 在监控环境下,Introcore 抛出两类致命日志并导致守护进程崩溃: [ERROR] Access at 79c...

Jan 26, 20263 min read32

记一次从零设计 Vmi 代码注入引擎:劫持、执行、清理

0.摘要 在虚拟化安全的研究中,如何在尽量不修改 Guest 内核的前提下执行自定义代码,一直是一个有趣且充满挑战的话题。本文尝试分享一套基于 Hypervisor 的完整代码注入思路,涵盖了从劫持 Guest 执行流、基于 EPT 的透明注入、双向通信协议到生命周期管理的全过程。 仓库位置:[mini-int3-injector].(https://github.com/ania0-art/mini-int3-injector). 1. 引言:一点背景与思考 1.1 传统方案遇到的一些挑战 ...

Jan 25, 20264 min read36

记一次虚拟化下的detour框架实现

1.Detour机制基础 1.1 什么是Detour? 在虚拟化安全监控领域,我们经常需要监控Guest OS内部的关键操作——进程创建、权限提升、内存映射变化等。传统的EPT(Extended Page Table)监控虽然可以捕获内存访问,但存在明显的局限性: 语义鸿沟:从"某个地址被写入"推断"进程被创建"需要复杂的分析 性能开销:细粒度的内存监控会产生大量VM-Exit 覆盖盲区:纯寄存器操作、链表修改等无法通过内存监控捕获 因此,我们需要一种更直接的方法:在关键函数的入口和出...

Jan 17, 20266 min read21

once2twice

8 posts

有的没的杂七杂八