首页
社区
课程
招聘
[原创]内核驱动级内存读写的用户层检测与绕过:一次攻防对抗实践
发表于: 1天前 935

[原创]内核驱动级内存读写的用户层检测与绕过:一次攻防对抗实践

1天前
935

内核驱动级内存读写的用户层检测与绕过:一次攻防对抗实践

摘要:本文记录了在 ARM64 Android 平台上,构建一个完整的内核驱动内存读写检测 Demo 的全过程。项目包含两个独立运行的程序——仅使用用户层权限的防御端和拥有 root 权限对接内核驱动进行读写的攻击端。防御端通过 5 种用户态手段检测外部内存访问,其中 CPU 缓存时序检测(Flush+Reload)是唯一能检测物理内存直读的方法;攻击端则从最初的 access_process_vm 被完全检测,演进为手动遍历 ARM64 页表的物理内存直接读取,最终成功绕过 Pagemap Present 和 PerfEvent 交叉通道检测,但仍被缓存时序方法检出。文章详细记录了每种检测方法的原理、实现、调试过程,以及 KPM(Kernel Patch Module)从 access_process_vm 到真正物理直读的进化历程。


一、项目背景

1.1 对抗模型

在现代移动安全领域,攻击者常通过内核驱动(Kernel Driver)绕过用户空间的权限限制,直接读取或修改目标进程的内存。这类攻击具有极强的隐蔽性,因为传统用户态检测手段无法感知内核层的活动。

本文构建的 Demo 设定如下:

  • 攻击端(root):运行在已安装 KPM 内核模块的设备上,通过 hook prctl 系统调用实现跨进程内存读写。目标:读取防御端进程的内存数据。
  • 防御端(用户层):仅使用普通用户权限,不依赖任何内核模块或 root 权限。目标:检测是否有外部进程通过内核驱动读取了自己的内存。

1.2 目标平台

  • SoC:Qualcomm Snapdragon(ARMv8-A)
  • Kernel:Linux 5.4.210-qgki,39-bit VA,3 级页表(PGD → PMD → PTE),4KB 页面
  • 补丁框架:APatch Next 11021
  • 编译器:aarch64-linux-gnu-gcc/g++(GNU 工具链)及 aarch64-none-elf-gcc(KPM 交叉编译)

1.3 项目文件结构

kernel_read_detect/          # 用户态项目(防御端 + 攻击端)
├── defense.c                # 防御端综合检测程序(C)
├── cache_test.c             # CPU 缓存时序检测独立测试(C)
├── attack.cpp               # 攻击端读取程序(C++)
├── kernel_driver.h          # 内核驱动 C++ 封装(prctl 跨进程调用)
├── precise_test.sh          # 四项测试脚本(baseline/WB/WC/DMA)
├── detailed_test.sh         # 逐检测方法详细数据采集脚本
└── Makefile                 # ARM64 交叉编译

KernelPatch-0.11.1-dev/kpms/
├── prctlhookRWMemory/        # 原始 KPM 模块(access_process_vm,已弃用)
│   ├── Kernel_prctl.c
│   ├── Kernel_prctl.h
│   └── Makefile
└── prctlhookRWMemoryNew/     # 当前 KPM 模块(物理直读 + vmap Device)
    ├── Kernel_prctl.c        # ~790 行,统一 WB/WC/DMA 三条读取路径
    ├── Kernel_prctl.h
    └── Makefile

参考项目:
├── rwprocmem33_20250520/     # ARM64 物理内存读写驱动 (phy_mem.h)
└── vm_rw_detect/             # PMU 检测研究 & perf_event 交叉通道

二、技术架构

2.1 内核驱动接口(KPM prctl Hook)

KPM(Kernel Patch Module)是 KernelPatch/APatch 框架下的内核模块系统。本项目使用一个 hook prctl 系统调用的 KPM 模块,通过自定义命令码实现内存读写:

命令码 功能
PRCTL_MEM_READ 0x4D454D01 内存读取(物理直读 WB,性能优先)
PRCTL_MEM_WRITE 0x4D454D02 内存写入(物理直写 WB)
PRCTL_GET_PID 0x4D454D03 按进程名查找 PID
PRCTL_MEM_READ_SAFE 0x4D454D04 内存读取(WC,绕过 L1/L2)
PRCTL_MEM_WRITE_SAFE 0x4D454D05 内存写入(WC)
PRCTL_MEM_READ_DMA 0x4D454D06 内存读取(vmap Device-nGnRnE,绕过全部缓存)

用户态调用方式:

struct mem_operation {
    pid_t   target_pid;   // 目标进程 PID
    uint64_t addr;        // 目标虚拟地址
    void   *buffer;       // 数据缓冲区(用户态指针)
    uint64_t size;        // 读写字节数
};

struct mem_operation op = { target_pid, addr, buf, size };
prctl(PRCTL_MEM_READ, (unsigned long)&op, 0, 0, 0);

KPM 在 syscall_hook_demo_init 中通过 fp_hook_syscalln(__NR_prctl, 5, before_prctl, 0, 0) 注册 hook。当用户态调用 prctl 时,before_prctl 函数拦截并处理自定义命令,其他命令则透传给原始 prctl

2.2 原始实现:access_process_vm 路径

原始的 prctlhookRWMemory 模块中,PRCTL_MEM_READ 对应的 read_mem_phys 函数虽然名为 "phys",但实际上仍然调用 access_process_vm_safe()

// 原始 Kernel_prctl.c 第 160 行 — 名为 phys 实则 access_process_vm
long bytes_read = access_process_vm_safe(task, current_vaddr, 
                                          kernel_buffer, chunk_size, 0);

真正的手动物理地址遍历代码(read_phys_addr_direct,使用 memremap 映射物理页)被整个注释掉了,从未编译进模块。

2.3 access_process_vm 的可检测副作用

access_process_vm 是内核中用于跨进程内存访问的标准函数(也被 ptrace/proc/pid/mem 使用)。它在内部调用 get_user_pages_remote()

access_process_vm()
  → get_user_pages_remote(target_mm, addr, ...)
    → 页面 non-present → handle_mm_fault()
      → 分配物理页(零页)
      → 设置 PTE present bit = 1
      → target->min_flt++          ← 目标进程缺页计数 +1
    → 页面已 present → 直接 pin
  → copy_to_user / copy_from_user  ← 拷贝数据

核心发现:get_user_pages 在处理 non-present 页面时,会在目标进程的上下文中触发 minor page fault,分配物理页并设置 PTE。即使后续拷贝失败,这些副作用已经产生。


三、防御端:用户层检测方法

防御端实现了 5 种完全基于用户态权限的检测方法。所有方法均在防御端程序 defense.c 中实现,其中缓存时序检测另有独立的精简测试程序 cache_test.c

3.1 方法一:Pagemap Present 检测(核心方法 ★)

原理

利用 madvise(MADV_DONTNEED) 将监控页变为 non-present 状态(/proc/self/pagemap bit 63 = 0),然后周期性检查该位。如果 access_process_vm 读取了这些页面,get_user_pages 会触发 handle_mm_fault,分配物理页并使 present bit 变为 1。

实现细节

// 1. 分配检测页并使其 non-present
void *addr = mmap(NULL, PAGE_SIZE, PROT_READ|PROT_WRITE,
                  MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
memset(addr, 0xDD, 1);                          // 先 touch 确保 VMA 有效
madvise(addr, PAGE_SIZE, MADV_DONTNEED);         // 回收物理页

// 2. 验证 non-present
int present = page_is_present(addr);             // 读 /proc/self/pagemap

// 3. 检测循环(每 5 秒)
for (int i = 0; i < g_detect_page_count; i++) {
    if (page_is_present(g_detect_pages[i]) == 1) {
        // 检测到外部访问!页面被 access_process_vm 触碰了
        detected++;
    }
    madvise(g_detect_pages[i], PAGE_SIZE, MADV_DONTNEED); // 重置
}

关键时序:必须是先检查,后清除。早期的实现错误地先 clear_refs 再检查,导致攻击留下的证据在检查前被清除。修正后的流程:

检测轮次 N:
  先检查 pagemap → 发现 present = 1 → 告警!
  再 madvise(DONTNEED) → 重置为 non-present
  等待 5 秒...
检测轮次 N+1:
  先检查 → 如果攻击发生在这 5 秒内 → 再次告警

验证实验:通过直接测试确认了 access_process_vm 读取 DONTNEED 页面时,即使 prctl 返回失败(-EINVAL),仍触发了一次 minor fault 并使页面变为 present:

Self page: 0x7e04c95000, present=0
After DONTNEED: present=0
prctl: ret=-1 errno=22 val=0x0000000000000000
min_flt after: delta=1              ← 虽然读失败了,缺页还是发生了
present after read: 1               ← 页面变为 present!

局限性:需要内核开启 CONFIG_MEM_SOFT_DIRTY,测试设备上未开启(/proc/self/pagemap bit 55 始终为 0)。但对 present bit(bit 63)的检测不需要任何内核配置,通用性更好。

3.2 方法二:PerfEvent 交叉通道检测

原理(来自 vm_rw_detect/detect.c 的研究):

/proc/self/stat 中的 min_flt 字段计数了所有归属于该进程的 minor faults,包括由外部进程通过 access_process_vm 触发的。而 perf_event(PERF_COUNT_SW_PAGE_FAULTS_MIN) 只在 current == self 时才计数。

两者的差值 = 外部触发的缺页数:

external_faults = delta_min_flt(stat) - delta_min_flt(perf)

实现

// 打开 perf_event 计数器
struct perf_event_attr a;
a.type   = PERF_TYPE_SOFTWARE;
a.config = PERF_COUNT_SW_PAGE_FAULTS_MIN;
a.disabled = 0;
int fd = perf_event_open(&a, 0, -1, -1, 0);

// 每轮检测:
unsigned long stat_flt = read_stat_min_flt();  // /proc/self/stat
uint64_t perf_val;
read(fd, &perf_val, sizeof(perf_val));          // perf_event

long external = (stat_flt - last_stat) - (perf_val - last_perf);
if (external > 0) {
    // 外部进程在我们的页面上触发了缺页!
}

实测效果:攻击使用 access_process_vm 时,ext=+37(37 次外部缺页),方法一(pagemap)同时命中 8/8。攻击使用物理直读时,ext=0,完全绕过。

3.3 方法三:金丝雀值完整性校验

定期检查堆和 mmap 区域中的魔数值是否被篡改,用于检测写入攻击。对纯读取攻击无效。

3.4 方法四:PMU 硬件计数器(ARMv8)

通过 perf_event_open(type=PERF_TYPE_RAW, config=event_code<<8) 监控 ARMv8 PMU 事件:

事件 Event Code Config 描述
REMOTE_ACCESS 0x31 0x3100 远程 CPU 访问本核缓存
BUS_ACCESS 0x19 0x1900 总线访问事件
L2D_REFILL 0x17 0x1700 L2 数据缓存重填
L1D_REFILL 0x03 0x0300 L1 数据缓存重填
MEM_ACCESS 0x13 0x1300 数据内存访问
DTLB_WALK 0x34 0x3400 数据 TLB 遍历

发现

  • **Type=7(ARMv8 PMUv3)**在 task-context 和 system-wide 模式均失败(EACCES),需要通过 type=4(PERF_TYPE_RAW)访问
  • **System-wide(pid=-1)**模式也失败(EACCES),只能使用 task-context(pid=0)
  • REMOTE_ACCESS / BUS_ACCESS 存在严重假阳性:即使没有攻击,也会出现 +90 以上的增量(可能来自系统调度或电源管理活动),需调高阈值

3.5 方法五:CPU 缓存时序检测(Flush+Reload ★物理直读检测)

这是本项目最重要的发现:CPU 缓存时序检测是唯一能检测物理内存直读的用户层方法

原理一:ARM 缓存是物理标记的

ARMv8-A 架构的 CPU 缓存采用物理地址标记(Physically Tagged),具体有两种实现:

PIPT(Physically Indexed, Physically Tagged):缓存行索引和标签都用物理地址。绝大多数 ARM L2/L3 缓存采用此设计。优势是完全不存在别名(aliasing)问题。

VIPT(Virtually Indexed, Physically Tagged):缓存行索引用虚拟地址的低位,标签用物理地址。ARM L1 数据缓存多采用此设计。对于满足 (Cache_Way_Size ≤ Page_Size) 的缓存(如 32KB 4-way = 8KB/way < 4KB page),虚拟索引位 [11:6] 完全落在页内偏移范围内,与物理地址相同,因此也不会产生别名。

核心结论:无论 PIPT 还是 VIPT,标签比较用的都是物理地址。不同虚拟地址(VA_A 和 VA_B)映射到同一物理地址(PA)时,缓存控制器都能通过物理标签匹配到同一缓存行。这是整个检测方法的硬件基础。

用户态 VA_A (defense)          内核态 VA_B (memremap)
0x738eb18000                   0xffffff8000123000
      │                              │
      │  页表遍历                     │  页表遍历
      ▼                              ▼
┌─────────────────────────────────────────┐
│          物理地址: 0x1234567000          │
│          ┌─────────────────┐            │
│          │  4KB 物理页      │            │
│          │  64 个缓存行     │            │
│          └────────┬────────┘            │
└───────────────────┼─────────────────────┘
                    │
        ┌───────────▼───────────┐
        │    ARM CPU Cache      │
        │  ┌─────────────────┐  │
        │  │ TAG = PA[47:12] │  │  ← 物理地址匹配
        │  │ DATA = 64 bytes │  │
        │  │ STATE = Shared  │  │
        │  └─────────────────┘  │
        │                       │
        │  不同 VA,同一 PA     │
        │       → 命中!        │
        └───────────────────────┘

原理二:KPM 的可缓存内存映射

KPM 模块通过 memremap(phys, 4096, MEMREMAP_WB) 创建临时内核虚拟地址映射。关键在于 MEMREMAP_WB 标志:

// MEMREMAP_WB 的 ARM64 实现路径:
memremap(phys, size, MEMREMAP_WB)
  → ioremap_prot(phys, size, pgprot_decrypted(PAGE_KERNEL))
    → PAGE_KERNEL = MT_NORMAL  (Normal Cacheable Write-Back 内存类型)

ARM64 将内存类型分为三类:

内存类型 属性 可缓存 用途
Normal WB Write-Back Cacheable 普通 RAM
Normal WT Write-Through Cacheable 帧缓冲
Device nGnRnE Non-Cacheable, Non-Gathering MMIO 寄存器

MEMREMAP_WB 映射为 Normal Cacheable Write-Back 类型。CPU 对此类内存的读取会:

  1. 先查询 L1/L2 缓存
  2. 未命中则发起缓存行填充(Cache Line Fill)
  3. 从 DRAM(或远程缓存)读取 64 字节缓存行
  4. 存入本地 L1/L2,状态标记为 Shared 或 Exclusive

如果 KPM 使用 MEMREMAP_WC(Write-Combine,不可缓存),则数据不会进入缓存,本检测方法失效。memremapMEMREMAP_WC 在 ARM64 上实际映射为 Device GRE 类型,对于普通 RAM 的读写性能极差且可能导致对齐异常,实践中极少使用。

原理三:缓存一致性协议(MESI/MOESI)

ARM 多核 SoC 通过 AMBA CHI(Coherent Hub Interface)或 CCI(Cache Coherent Interconnect)实现跨核缓存一致性。经典 MESI 状态转换:

                    Defense 核                       KPM 核
                    ──────────                      ──────

初始状态:           INVALID                        INVALID
                    (DC CIVAC 后全部失效)

KPM memcpy:                                        I → E (Exclusive)
                                                   [加载缓存行到本地]

Defense 访问:       I → S (Shared)                 E → S (Shared)
                    [一致性协议广播读取请求]          [响应数据,降级为 Shared]
                    [数据通过 CHI/CCI 传输]
                    [延迟: ~40-80 cycles]

关键:KPM 的 memcpy 读取整页(64 个缓存行),每个缓存行被加载到 KPM 所在 CPU 核的 L1/L2 缓存中(状态 E/S)。当防御进程在同一或不同 CPU 核上访问同一物理页时,缓存一致性协议介入:

  • 同一 Cluster(共享 L2):数据直接从 L2 获取,延迟 ~15-20 cycles
  • 不同 Cluster(通过 CCI/CCN):缓存到缓存传输,延迟 ~40-80 cycles
  • 数据完全不在任何缓存:DRAM 访问,延迟 ~200+ cycles

三种命中延迟都远低于 DRAM 访问,这就是缓存时序检测的物理基础。

原理四:DC CIVAC 与 Point of Coherency

ARM 架构定义了多个"点"来描述缓存操作的范围:

CPU Core 0          CPU Core 1          CPU Core 2          CPU Core 3
┌──────────┐       ┌──────────┐       ┌──────────┐       ┌──────────┐
│ L1 I$ D$ │       │ L1 I$ D$ │       │ L1 I$ D$ │       │ L1 I$ D$ │
└────┬─────┘       └────┬─────┘       └────┬─────┘       └────┬─────┘
     │  PoU             │                   │                   │
     ├──────────┐       ├──────────┐       ├──────────┐       ├──────────┐
     │ Shared L2│       │ Shared L2│       │ Shared L2│       │ Shared L2│
     └────┬─────┘       └────┬─────┘       └────┬─────┘       └────┬─────┘
          │                   │                   │                   │
          └───────────┬───────┴───────────┬───────┘                   │
                      │    ┌──────────┐   │                           │
                      └────┤  CCI/CHI ├───┘                           │
                           │ (L3/SLC) │                               │
                           └────┬─────┘                               │
                                │ PoC                                  │
                           ┌────▼─────┐                               │
                           │  DRAM    │                               │
                           └──────────┘                               │
  • PoU(Point of Unification):同一核的指令/数据缓存统一可见的点(通常是 L2)
  • PoC(Point of Coherency)所有观察者(CPU、DMA、GPU 等)看到同一数据副本的点(通常是 L3 或内存控制器)

DC CIVAC(Clean + Invalidate to PoC)保证:

  1. 如果缓存行为 Dirty,写回数据到 PoC
  2. 该缓存行在所有核的 L1/L2 中全部失效
  3. 操作完成后,该缓存行的唯一有效副本在 PoC(L3/DRAM)
  4. 任何核的下次访问都必须从 PoC 获取

为什么用 CIVAC 而不是 CVAU? CVAU 只清洗到 PoU(本 Cluster 的 L2)。另一个 Cluster 的核可能还持有该缓存行的副本,防御进程切换到该核时可能误判为"命中"。CIVAC 确保全局可见性。

DSB ISH 的必要性DSB(Data Synchronization Barrier)确保所有先前的缓存维护操作完成。ISH(Inner Shareable)域是操作系统将用户进程分配到的共享域。在 DC CIVAC 后插入 DSB ISH,保证后续的计时测量是在缓存完全清空之后进行的。

原理五:信号放大——为什么遍历全部 64 个缓存行

cntvct_el0 的典型频率约 19.2 MHz(由 CNTFRQ_EL0 寄存器指定),每个 tick 约 52 ns。若 CPU 主频为 2 GHz,每个 tick 覆盖约 104 个 CPU 周期。

单次访问的计时困境

L1 命中: ~4 cycles    → 4/1040.04 ticks → 计数值不变 (0)
DRAM:    ~200 cycles  → 200/1041.9 ticks  → 计数值为 1-2
单次测量无法可靠区分 (0 vs 1).

64 次访问的信号放大

64 × L1 命中:  64 × 4     = 256 cycles   → 256/1042-3 ticks
64 × 跨核传输: 64 × 50    = 3,200 cycles → 3200/10431 ticks
64 × DRAM:     64 × 200   = 12,800 cycles→ 12800/104123 ticks

64 次访问将信号放大了 64 倍,使不同缓存层级之间的差异从无法区分变为 显著可区分(2-3 vs 31 vs 123 ticks)。这正是遍历页面全部 64 个缓存行的数学基础。

通过延时放大信号

除了空间放大(64 个缓存行),还可以通过时间放大。在测量前通过 usleep(5ms) 等操作延时,这段时间允许:

  1. 防御进程让出 CPU,使攻击获得调度机会
  2. 攻击的缓存行填充操作完成
  3. 缓存一致性协议完成状态转换

为什么每页恰好 64 个缓存行? 缓存行大小是 ARM 架构定义的(通常 64 字节),4KB 页面 / 64 字节 = 64 个缓存行。这不是巧合,而是页面大小和缓存行大小定义的结果。

Flush+Reload 完整流程

Step 1 — Flush (DC CIVAC):
  防御进程执行 DC CIVAC 指令,将探测页的每个缓存行:
    a. 若为 Dirty → 写回 PoC
    b. 从所有核的 L1/L2 缓存中标记为 INVALID
  然后执行 DSB ISH 等待操作完成
  效果: 下次任何核访问此物理页 → 必然 MISS → 从 PoC(L3/DRAM) 获取

Step 2 — Wait (usleep 5ms):
  防御进程调用 usleep() 进入内核等待队列,主动让出 CPU
  ┌─────────────────────────────────────────────┐
  │ 期间可能发生的事件:                           │
  │  a. 攻击进程在相同或不同核上运行               │
  │  b. KPM 的 before_prctl Hook 执行            │
  │  c. memremap(phys, 4096, MEMREMAP_WB) 映射    │
  │  d. memcpy 读取 64 个缓存行 → 进入 L1/L2      │
  │  e. 缓存一致性协议将数据同步到防御核可见       │
  └─────────────────────────────────────────────┘

Step 3 — Measure (time_page_access):
  防御进程唤醒,遍历页面全部 64 个缓存行:
    若 KPM 在此期间访问过 → 数据在缓存中 → 总访问 < 50 ticks → 告警!
    若 KPM 未访问 → 数据仍在 DRAM → 总访问 ≥ 50 ticks → 正常

从测量值反推缓存层级

实测数据验证了各缓存层级的理论计算值:

场景 测量值 (cntvct ticks) 反推延迟 对应缓存层级
校准 HIT(刚触摸后) 4-6 ~4 cycles/行 L1
攻击期间探测命中 50-70 ~50 cycles/行 L2/跨核传输
无攻击基线 100-170 ~150 cycles/行 DRAM
调度干扰尖峰 2000-11000 调度器抢占

ARM64 汇编实现

/* 读取虚拟计数器 (cntvct_el0, ~19.2MHz) */
static inline u64 cntvct(void) {
    u64 v;
    asm volatile("isb\n\tmrs %0, cntvct_el0" : "=r"(v));
    return v;
}

/* DC CIVAC: Clean + Invalidate data Cache line to Point of Coherency */
static inline void dc_civac(void *addr) {
    asm volatile("dc civac, %0" :: "r"(addr) : "memory");
}

/* DSB ISH: 确保缓存操作对 Inner Shareable 域内所有核可见 */
static inline void dsb_ish(void) {
    asm volatile("dsb ish" ::: "memory");
}

/* 遍历页面全部 64 个缓存行,测量总访问时间 */
static u64 time_page(volatile char *addr) {
    u64 t0 = cntvct();
    for (int off = 0; off < 4096; off += 64) {  // 每个缓存行 64 字节
        asm volatile("" ::: "memory");
        volatile char v = addr[off];              // 访问该缓存行
        (void)v;
    }
    asm volatile("" ::: "memory");
    return cntvct() - t0;
}

关键参数:阈值的选择

缓存层级 单行延迟 64 行总周期 cntvct ticks (19.2MHz)
L1 命中(本地核) ~4 cycles ~256 ~2-3
L2/跨核传输 ~50 cycles ~3,200 ~31
L3(系统缓存) ~100 cycles ~6,400 ~62
DRAM ~200 cycles ~12,800 ~123

阈值设为 50 ticks:能捕获 L1/L2 命中和跨核缓存一致性传输,同时过滤 L3 和 DRAM 访问。

高频探测循环

为提高捕获概率,每个检测轮次内执行 60 次快速探测(每 5ms 一次):

#define PROBE_N   60    // 每轮 60 次探测
#define PROBE_MS  5     // 每次间隔 5ms

for (int r = 0; r < PROBE_N; r++) {
    flush_all_pages();             // DC CIVAC 清空缓存
    usleep(PROBE_MS * 1000);       // 让出 CPU,攻击可在此期间运行
    measure_all_pages();           // 测量访问时间,低于阈值则告警
}

60 轮 × 5ms = 300ms 总探测窗口,攻击每 20-50ms 运行一次,保证至少数次重叠。

攻击端配合:整页读取

关键发现:必须让 KPM 模块读取整页(4096 字节),而非 8 字节:

// ❌ 错误 — 只加载 1 个缓存行,其余 63 行从 DRAM → 总时间太高
unsigned long val = driver->read<unsigned long>(addr);

// ✅ 正确 — 读取整页,memcpy 加载全部 64 个缓存行
char buf[4096];
driver->readMemSafe(target_pid, addr, buf, 4096);

实测效果

攻击使用物理直读(PRCTL_MEM_READ, 整页 4096 字节)时:

Round 1: 366 次命中  ← 60 轮 × 8 页 = 480 次测量,命中率 76%
Round 2: 358 次命中
Round 3: 385 次命中  ← 峰值命中率 80%

无攻击时 (timing 采样):
  page0 = 100-170 ticks (远高于阈值 50) → 正常,无告警

攻击进行中 (timing 采样):
  page0 = 5-8 ticks (远低于阈值 50) → 缓存命中,告警!

数据验证:防御写入可识别模式 0xcafe0000dead0000 ~ 0xcafe0007dead0000,攻击通过 prctl(PRCTL_MEM_READ) 读出完全一致的数据,100% 确认 KPM 物理直读功能正常。

四、攻击端:从 access_process_vm 到物理直读

4.1 用户态调用封装

攻击端使用 kernel_driver.h 中从原始 kernel.h 提取的 c_driver 类:

class c_driver {
    pid_t pid;  // 构造函数中通过 getPidByName("defense") 获取
public:
    template <typename T>
    inline T read(uint64_t addr) {
        T val{};
        readMemSafe(pid, addr, &val, sizeof(T));
        return val;
    }

    inline int readMem(pid_t pid, uint64_t addr, void *buf, size_t len) {
        mem_operation op = { pid, addr, buf, len };
        return prctl(PRCTL_MEM_READ, (unsigned long)&op, 0, 0, 0);
    }
};

// 全局单例
static c_driver *driver = new c_driver();

// 使用:每次读取 8 字节
unsigned long val = driver->read<unsigned long>(target_addr);

攻击端通过解析 /proc/<pid>/maps 自动发现目标的匿名 rw-p 映射区域,对每个页面读取 8 字节。

4.2 原始模块的检测结果

当 KPM 使用 access_process_vm 路径时,99 次读取(792 字节)产生了 37–38 次外部可检测的 minor faults,8/8 的 pagemap 检测页全部命中:

4 轮检测:
  方法1(Pagemap): 8/8 个检测页变为 present     ← 全部被检测!
  方法4(PerfX): stat:+38 perf:+0 ext:+3837 次外部缺页

4.3 物理直读模块的设计与实现

目标:实现真正的物理内存直接读取,绕过 access_process_vm 及其所有副作用。

4.3.1 ARM64 页表遍历

核心函数 walk_page_table() 手动遍历 ARM64 多级页表:

虚拟地址 [38:0] (39-bit VA, 3 级页表):
  [38:30] → PGD index (9 bits, 512 entries)
  [29:21] → PMD index (9 bits, 512 entries)
  [20:12] → PTE index (9 bits, 512 entries)
  [11:0]  → page offset

页表项格式 (ARM64):
  bits[47:12] = 下一级表物理地址 / 页面物理地址
  bits[1:0]   = 描述符类型 (0b11 = valid table/page)
  bit[1]      = table indicator (0 = block/page, 1 = table)

实现(参考 rwProcMem33 的 get_task_proc_phy_addr

rwProcMem33 是一个成熟的 Linux ARM64 内核进程内存读写驱动,提供了两种物理地址获取方式:通过 /proc/pid/pagemap 文件读取(get_pagemap_phy_addr)和手动遍历页表(get_task_proc_phy_addr)。后者使用内核标准宏 pgd_offsetp4d_offsetpud_offsetpmd_offsetpte_offset_kernel 遍历页表,并通过 page_to_phys(pte_page(*pte)) 将 PTE 转换为物理地址。物理内存的实际读写则通过 xlate_dev_mem_ptrioremap_cache)映射物理页后直接 memcpy。本项目在 KPM 约束下参考了其页表遍历思路,但由于 KPM 无法使用内核标准宏,改为手动计算各级索引并从页表条目中直接提取物理地址。

static int walk_page_table(uint64_t mm_pgd_va, uint64_t vaddr, 
                            uint64_t *out_phys)
{
    /* Level 0: PGD — 直接读内核虚拟地址 */
    uint64_t *pgd = (uint64_t *)mm_pgd_va;
    uint64_t entry = pgd[(vaddr >> 30) & 0x1FF];
    if ((entry & 0x3) != 0x3) return -1;

    /* Level 1: PMD — memremap 映射物理页 */
    uint64_t pmd_phys = entry & PHYS_MASK & PAGE_MASK;
    uint64_t *pmd = my_memremap(pmd_phys, 4096, MEMREMAP_WB);
    entry = pmd[(vaddr >> 21) & 0x1FF];
    my_memunmap(pmd);
    if ((entry & 0x3) != 0x3) return -1;

    /* Level 2: PTE — memremap 映射物理页 */
    uint64_t pte_phys = entry & PHYS_MASK & PAGE_MASK;
    uint64_t *pte = my_memremap(pte_phys, 4096, MEMREMAP_WB);
    entry = pte[(vaddr >> 12) & 0x1FF];
    my_memunmap(pte);
    if ((entry & 0x3) != 0x3) return -1;

    /* 计算最终物理地址 */
    *out_phys = (entry & PHYS_MASK & PAGE_MASK) | (vaddr & 0xFFF);
    return 0;
}

关键设计决策:

  • PGD 层级直接读内核 VAmm->pgd 是内核线性映射区的虚拟地址,可直接解引用,无需 memremap
  • PMD/PTE 层级使用 memremap:页表条目中的物理地址需要用 memremap 映射到内核虚拟地址后才能读取
  • 支持大页(2MB PMD block、1GB PUD block)

4.3.2 绕过 __virt_to_phys 不可用的问题

KPM 模块需要内核符号来转换虚拟地址到物理地址,但 ARM64 上 __virt_to_physvirt_to_phys 都是内联宏,不作为内核符号导出:

[phys_RW] symbols: v2p=N    ← __virt_to_phys 不可用
[phys_RW] INIT: phys=0       ← 物理直读被禁用

解决方案:完全不依赖 VA→PA 转换。PGD 直接读内核 VA(第 0 级),子级页表物理地址来自 PTE 条目本身(通过 memremap 访问),无需任何转换函数。

4.3.3 动态探测 mm_struct->pgd 偏移量

mm_struct 中的 pgd 指针偏移量因内核配置和 __randomize_layout(randstruct)而异。探测策略从单进程(init, pid=1)扩展为多进程扫描:

问题:init 进程(pid=1)通常没有用户空间页表映射,其 PGD 中所有用户空间条目为 0,无法验证 pgd 指针的有效性。

解决:扫描多个 PID(1000, 2000, 500, 1500 等),对每个进程的 mm_struct 按 8 字节步进扫描(0x00–0xFF),检查每个候选内核地址是否指向有效的 PGD 页表(任意 512 个 PGD 条目中有 ≥1 个有效条目):

static int verify_pgd(uint64_t pgd_va) {
    uint64_t *pgd = (uint64_t *)pgd_va;
    for (int i = 0; i < 512; i++) {
        if ((pgd[i] & 0x3) == 0x3) return 1; // 找到有效条目
    }
    return 0;
}

最终找到 pid=1000, pgd_off=0x48, levels=3

4.3.4 物理直读的完整流程

static int read_mem_phys_direct(pid_t pid, uintptr_t vaddr,
                                 void __user *buffer, size_t size)
{
    // 1. 获取目标进程 PGD 内核虚拟地址
    uint64_t pgd_va = get_target_pgd(pid);
    
    while (total_read < size) {
        // 2. 遍历页表: vaddr → physical address
        uint64_t phys_addr;
        walk_page_table(pgd_va, current_va, &phys_addr);
        
        // 3. memremap 映射物理页
        void *mapped = my_memremap(page_phys, 4096, MEMREMAP_WB);
        
        // 4. 直接 memcpy 数据
        memcpy(kernel_buffer, mapped + offset, chunk);
        my_memunmap(mapped);
        
        // 5. 拷贝到用户态
        compat_copy_to_user(user_buf + total_read, kernel_buffer, chunk);
    }
}

无回退设计:如果 g_phys_mem_available == 0,直接返回 -ENODEV,强制暴露初始化失败,便于调试。


五、实验结果

5.1 四种读取模式与实验设计

KPM 模块演进至 v4.0,攻击端共支持三种读取路径(对应三个 prctl 命令码),加上纯防御端基线对照,共四种测试模式。本节在一次统一的对照实验中横向比较四者的可检测性。

测试模式 prctl 命令码 内核实现 内存类型 缓存行为
无任何读取 (基线) 纯防御端自检,无攻击
有缓存读取 (WB) 0x4D454D01 手动页表遍历 + memremap WB Normal WB 高性能吞吐,经过 L1/L2/SLC 全部缓存(性能优先,非检测规避)
无缓存读取 (WC) 0x4D454D04 手动页表遍历 + memremap WC Normal NC 绕过 L1/L2,SLC 仍可能命中(规避缓存时序检测)
DMA 读取 (Device) 0x4D454D06 手动页表遍历 + vmap Device-nGnRnE Device-nGnRnE 绕过全部缓存 L1/L2/SLC(终极规避)

实验环境:M2011K2C(Qualcomm Snapdragon ARMv8-A),Linux 5.4.210-qgki。防御端检测周期 5 秒/轮,每轮含 60 次缓存探测(Flush+Reload,5ms间隔)。攻击端 ./attack <PID> 3 <mode>,3 轮攻击,每轮 58 次整页读取(~232KB/轮)。

实验流程:每种模式独立运行——启动 defense -> 等待初始化(~3s)-> 启动 attack(基线跳过)-> 运行 15-20s -> 终止进程 -> 收集日志。

5.2 统一对照测试结果(精确地址 + 循环攻击)

为确保攻击/防御目标地址严格一致,本轮测试改为精确地址模式:防御端初始化后输出 8 个缓存探测页地址,攻击端从文件读取这些地址并循环反复读取(50 次/轮,5ms 页间延迟),每轮持续 ~2 秒覆盖防御端全部 300ms 探测窗口。

攻击配置: ./attack <PID> 3 <mode> 5 <addr_file>
  精确地址: 8 个缓存探测页 (防御端输出的 CACHE_PROBE_ADDRS)
  页间延迟: 5ms (与防御探测窗口对齐)
  循环次数: 50 次/轮 (每轮 400 次读取, 1.6MB, 持续约 2 秒)

测试 1 — 无任何读取(基线)

========== 第 1~5 轮 ==========
[OK] 方法2(PMU): 硬件计数器正常            ← REMOTE_ACC=0, BUS_ACCESS=0
[OK] 方法3(Canary): 金丝雀完好
[OK] 方法4(PerfX): stat:+0 perf:+0 ext:+0 (正常)
[OK] 方法5(Cache): 60 轮探测无缓存命中 (正常)  ← 300 次探测全部零命中

基线干净:5 轮 × 60 次 = 300 次缓存探测全部零命中,无 PMU 假阳性。

测试 2 — 有缓存读取(WB)

========================================
  内核驱动内存读取 - 攻击端
========================================
[*] 页间延迟: 5 ms
[*] 读取模式: WB (有缓存)
[*] 从文件加载 8 个精确地址 (跳过 /proc/pid/maps 扫描)
[*] 第 1 轮完成: 400 次读取, 1638400 字节 (50 次循环)

防御端检测 (第 1 轮):
========== 第 1 轮 ==========
[OK] 方法2(PMU): 硬件计数器正常
[OK] 方法3(Canary): 金丝雀完好
[OK] 方法4(PerfX): stat:+0 perf:+0 ext:+0 (正常)
    *** 第 0 轮探测: 5/8 页缓存命中! ***        <- 初始化噪音
    *** 第 0~58 轮探测: 各 1/8 页命中 ***       <- 攻击期间连续命中!
[!!! 告警] 方法5(Cache): 58 次命中 (跨 58 轮探测)  ← 58/60 = 96.7%

========== 第 2~4 轮(攻击已结束)==========
[OK] 全部方法正常
  • 缓存时序检测:58/60 = 96.7% 子探测命中率,几乎连续命中
  • PerfEvent:ext:0 —— 页面已 present(非 DONTNEED),无缺页触发
  • PMU:本轮 REMOTE_ACC/BUS_ACCESS 均为 0
  • 综合检测率:96.7%(精确地址 + 时序对齐条件下)

测试 3 — 无缓存读取(WC,memremap WC 物理直读,已移除 WB 回退)

攻击端 (attack WC 模式):
    [WC] 0x73d03a7000 -> 0xcccccccccccccccc (OK)
    第 1 轮: 400 次读取, 1638400 字节 (50 次循环)
    第 2 轮: 500 次读取, 2048000 字节 (50 次循环)

防御端检测 (第 1 轮, 攻击活跃期):
========== 第 1 轮 ==========
[OK] 方法2(PMU): 硬件计数器正常
[OK] 方法3(Canary): 金丝雀完好
[OK] 方法4(PerfX): stat:+0 perf:+0 ext:+0 (正常)
    *** 第 0 轮探测: 7/8 页缓存命中! ***         <- 冷启动一次性噪声
[!!! 告警] 方法5(Cache): 7 次命中 (跨 1 轮探测)

========== 第 2~5 轮(攻击活跃期)==========
[OK] 方法5(Cache): 60 轮探测无缓存命中 (正常)  ← 连续 4240 次探测零命中!
  • 缓存时序检测:仅第 0 轮冷启动噪声(7/8 命中),后续 240 次探测(4 轮 × 60)全部零命中
  • 综合检测率:~0%(与 DMA 模式一致,冷启动噪声不计入)

对比:移除 WB 回退前后

             移除前 (有 WB 回退)       移除后 (无 WB 回退)
WC 缓存命中   37/60 = 61.7%            ~0/60 = ~0% (仅冷启动)
综合检测率    ~62%                     ~0%
  • 结论:之前的 61.7% 检测率完全来自 WB 回退路径。当 memremap(MEMREMAP_WC) 失败时 KPM 静默回退到 WB,这些 WB 读取数据进入 L1/L2,被 Flush+Reload 检出。移除回退后,WC 映射的 Normal NC 内存虽在 SLC 中分配,但用户态 Flush+Reload 无法可靠区分 SLC 命中/未命中的延迟差异,WC 实际不可检测

测试 4 — DMA 读取(Device-nGnRnE,绕过全部缓存)

攻击端 (attack DMA 模式):
    [DMA] 0x7393e82000 -> 0xcccccccccccccccc (OK)
    [*] 第 1 轮完成: 400 次读取, 1638400 字节 (50 次循环)

防御端检测:
========== 第 1~5 轮 ==========
[OK] 方法2(PMU): 硬件计数器正常
[OK] 方法3(Canary): 金丝雀完好
[OK] 方法4(PerfX): stat:+0 perf:+0 ext:+0 (正常)
[OK] 方法5(Cache): 60 轮探测无缓存命中 (正常)  ← 300 次探测全部零命中!
  • 全部 5 种检测方法零告警:300 次缓存探测(5 轮 × 60 次)全部零命中
  • Device-nGnRnE 总线事务完全旁路 L1/L2/SLC,不分配缓存行,不参与 MESI/MOESI 协议
  • 综合检测率:0% —— 在用户层完全不可检测

5.3 四种模式检测率总览

测试模式          Pagemap   PerfEvent    PMU REMOTE    缓存时序(子探测)  综合检测率
──────────────────────────────────────────────────────────────────────────────────
无任何读取 (基线)    OK         OK           OK           0/60   (0%)      0% (基线)
有缓存读取 (WB)     OK*        OK*          OK         58/60 (96.7%)     ~97%
无缓存读取 (WC)     OK*        OK*          OK          0/60 (0%) ¹       ~0%¹
DMA读取 (Device)    绕过       绕过         绕过         0/60   (0%)      0%
──────────────────────────────────────────────────────────────────────────────────
* Pagemap/PerfEvent 仅对 access_process_vm 触发的缺页有效; 物理直读不触发缺页故 ext:0
¹ 移除 WB 回退后重新测试结果。之前 61.7% 由回退路径导致, 仅第 0 轮有冷启动噪声

5.4 WC 模式残余命中的微架构分析

WC 回退分析(已证实)

KPM 模块的 WC 读取路径曾包含显式的 WB 回退逻辑:

// 已移除的回退代码(修复前)
void *mapped = my_memremap(page_phys, 4096, MEMREMAP_WC);
if (!mapped) {
    mapped = my_memremap(page_phys, 4096, MEMREMAP_WB); // WC 失败 → WB
}
// 修复后:memremap 失败直接返回错误,不再静默回退

memremap(MEMREMAP_WC) 的 ARM64 实现路径为 ioremap_prot(phys, size, PROT_NORMAL_NC)。在 Linux 5.4 上,__ioremap 调用 arch_memremap_can_ram_remap(phys_addr, size, flags) 检查物理地址是否为 RAM。

假说(修复前):在测试设备(Qualcomm Snapdragon, Linux 5.4.210)上,WC 模式 61.7% 的命中率有两种可能:

  • 假说 A:WC 成功,SLC 命中导致检出
  • 假说 Barch_memremap_can_ram_remap 拒绝 NC 映射 → KPM 回退到 WB → 实际运行可缓存模式

验证实验:从 Kernel_prctl.c 中移除 read_mem_phys_direct()write_mem_phys_direct()read_mem_dma() 三处 WB 回退逻辑,重新编译 KPM 模块并测试。

实验结果:移除 WB 回退后,WC 模式检测率从 61.7% 骤降至 ~0%(仅剩第 0 轮冷启动 7/8 命中的一次性噪声,后续 4 轮 × 60 次 = 240 次探测全部零命中)。

结论:假说 B 成立。之前的 WC 模式 61.7% 检测率完全由 WB 回退路径导致。当 memremap(MEMREMAP_WC) 在测试设备上返回 NULL 时,KPM 静默降级为 WB,数据进入 L1/L2 被 Flush+Reload 检出。去除回退后,WC 的 Normal NC 映射虽在 SLC 中分配缓存行,但用户态 Flush+Reload 无法可靠区分 SLC 命中/未命中的延迟差异(差异远小于 L1 命中 vs DRAM),WC 实际与 DMA 一样不可检测。

三层硬件原因

1. 页表遍历的缓存污染: walk_page_table() 的 PMD/PTE 读取使用 MEMREMAP_WB(仅数据页用了 WC),页表物理页被加载到 CPU 缓存。此外 TLB 硬件页表遍历器在 TLB miss 时通过缓存访问页表,可能预热相关缓存组。

2. 系统缓存(SLC/L3)的不可绕过性: MEMREMAP_WC(Normal Non-Cacheable)只绕过 L1/L2,仍在 SLC 中分配。Qualcomm Snapdragon 上 PoC 位于 SLC 之后,DC CIVAC 刷到 PoC 意味着 SLC 中的数据被保留。防御核后续访问可能从 SLC 获取数据(30-50 cycles),而非 DRAM(200 cycles),64 行总时间约 31-50 ticks,恰好低于或接近阈值 50。

                 ARM SoC 缓存层级
        ┌─────────────────────────────────┐
        │ Core 0        Core 1            │
        │ L1I$ L1D$     L1I$ L1D$         │  <- WC 绕过此层
        │   Shared L2      Shared L2      │  <- WC 绕过此层
        └───────┬────────────┬────────────┘
                │            │
        ┌───────▼────────────▼────────────┐
        │     CCI/CHI Interconnect        │
        │  ┌──────────────────────────┐   │
        │  │  System Level Cache (SLC) │   │  <- WC 无法绕过!
        │  │  (L3, 2-8 MB)            │   │
        │  └──────────────────────────┘   │
        └───────────────┬─────────────────┘
                        │
        ┌───────────────▼─────────────────┐
        │          DRAM Controller         │  <- PoC 通常在此
        └─────────────────────────────────┘

3. 缓存一致性协议的嗅探效应: 即使 WC 不在本地 L1/L2 分配,缓存一致性协议的 snoop 机制仍使持有数据的 SLC 响应并提供数据。防御核的 DC CIVAC 清洗后访问 -> 攻击核的 NC 读取触发 snoop -> SLC 向防御核提供数据 -> 形成"被动共享",导致低于阈值的快速访问。

完全消除残余命中的方向:

方法 原理 代价
页表遍历也用 WC 消除页表页缓存污染 遍历性能大幅下降
读取后 DC CIVAC 自清洗 主动逐出 SLC 中的数据 增加 KPM 模块指令开销
使用 Device 内存类型 (nGnRnE) 完全绕过所有缓存和写缓冲 vmap 复杂度增加(已在 v4.0 实现)
跨 Cluster 攻击 利用不同 Cluster 的独立 SLC 需绑定 CPU 亲和性

5.5 数据验证

防御端在探测页中写入可识别魔数模式(0xcccc...),攻击端通过三种 prctl 路径均正确读出,确认各读取路径功能正常:

模式    防御写入                          攻击读出                    验证
─────────────────────────────────────────────────────────────────────────
WB   0x79a74db000: 0xcccccccccccccccc  ->  0xcccccccccccccccc         OK
WC   0x7556a80000: 0xcccccccccccccccc  ->  0xcccccccccccccccc         OK
DMA  0x7327288000: 0xcccccccccccccccc  ->  0xcccccccccccccccc         OK

5.6 各版本原理详解

v1.0 — access_process_vm:标准内核路径

原理:调用 Linux 内核标准函数 access_process_vm(target_task, addr, buf, len, 0),内核代为完成 VA→PA 转换和页表遍历。

读取链路

用户态 attack              内核态
     │                      │
     ├── prctl(PRCTL_MEM_READ) ──→ before_prctl hook
     │                              │
     │                      access_process_vm(target_mm, va, buf, 4096, 0)
     │                        ├── get_user_pages()    ← 触发缺页, 建立页表映射
     │                        ├── kmap(page)           ← 创建临时内核 VA (Normal WB)
     │                        ├── __copy_to_user()     ← 数据经 L1/L2 缓存
     │                        └── put_page()           ← 释放引用
     │                              │
     │                      ←── 返回 4096 ──┤
     │
     └── 用户态收到数据 ←───────────────────┘

内存类型kmap() 创建的内核映射使用 Normal Cacheable Write-Back(与用户态进程 VA 属性一致),数据必然经过 L1/L2 缓存,且触发缺页修改 PTE Accessed/Dirty 位。

检测表面

检测方法 是否检出 原因
Pagemap Present ✓ 检出 get_user_pages() 触发缺页, PTE 从 swap/zero-page 变为 present
PerfEvent ext: ✓ 检出 缺页事件被 perf_event 记录
PMU 计数器 部分 mem_access / bus_access 计数增加
Canary ✗ 不检出 仅读取不修改
Cache Timing ✓ 检出 WB 属性使数据留在 L1/L2, Flush+Reload ~95%+

小结access_process_vm 是标准的、文档化的内核接口,功能完备但留下大量可检测痕迹,不适合隐蔽读取。


v2.0 — 手动页表遍历 + memremap WB:绕过 Pagemap/PerfEvent

原理:不再调用 access_process_vm,改为手动遍历目标进程的 ARM64 页表获取物理地址,然后用 memremap(phys, MEMREMAP_WB) 创建临时内核映射直接读取。

读取链路

用户态 attack              内核态
     │                      │
     ├── prctl(PRCTL_MEM_READ) ──→ before_prctl hook
     │                              │
     │                      ┌─ ① 获取目标进程 mm_struct → pgd
     │                      ├─ ② walk_page_table(): 手动遍历 PGD→PMD→PTE → 物理地址
     │                      ├─ ③ memremap(phys, 4096, MEMREMAP_WB) → 临时内核 VA
     │                      ├─ ④ memcpy(kernel_buf, mapped_va, 4096) → 数据经 L1/L2
     │                      ├─ ⑤ memunmap(mapped_va)               → 释放临时映射
     │                      └─ ⑥ copy_to_user(user_buf, kernel_buf) → 送回用户态
     │                              │
     │                      ←── 返回 4096 ──┤

关键改进 — 绕过 Pagemap:手动遍历目标进程的页表(而非 attack 自己的页表),直接读取 PGD→PMD→PTE 中的物理地址。整个过程不调用 get_user_pages(),因此不触发缺页,PTE 的 Accessed/Dirty 位保持不变,defense 端的 /proc/self/pagemap 看不到任何变化。

关键改进 — 绕过 PerfEvent:没有缺页事件,perf_event_open()ext 计数器始终为 0。

仍可检测的原因 — WB 缓存memremap(WB) 的内核映射属性为 Normal Cacheable Write-Back,memcpy 读取的数据被分配在 L1/L2 缓存中。防御端的 Flush+Reload 探测这些缓存行时发现命中,产生告警。


v2.1 — 整页读取(8字节 → 4096字节):信号放大

原理:将单次读取量从 8 字节扩大到 4096 字节(一整页 = 64 条缓存行),每条缓存行 (64B) 都被加载到 L1/L2,大幅扩大缓存"指纹"。

v2.0: 读取 8 字节 → 污染 1 条缓存行 → 防御端需恰好探测这一条
v2.1: 读取 4096 字节 → 污染 64 条缓存行 → 防御端探测任意一条都能命中

为什么需要信号放大:攻击端也不知道防御端具体探测哪个地址。8 字节读取只污染单个缓存行,防御端 8 个探测页中有 7 个可能完全不受影响。整页读取确保每个探测页的 8 个偏移位置(8×8=64 条缓存行)全部被污染,命中率从随机提升到 ~80%+。

这一版本本身是攻击端的退步(更容易被检测),但验证了缓存时序检测的物理原理——这条检测链是后续所有规避设计的基础。


v3.0 — memremap WC:绕过 L1/L2

原理MEMREMAP_WC (Write-Combine) 在 ARM64 上映射为 Normal Non-Cacheable 内存。与 Normal WB 的关键区别在于 MMU 页表条目的 MAIR 属性索引不同:

ARM64 PTE 内存类型编码 (bits [7:2]):
  Normal WB:    MAIR[0] = 0xFF (Inner WB-WA / Outer WB-WA)  → 经 L1/L2/SLC
  Normal NC:    MAIR[1] = 0x44 (Inner NC   / Outer NC)      → 绕过 L1/L2, SLC 仍命中
  Device-nGnRnE: MAIR[2] = 0x00 (Device)                     → 绕过全部缓存

Normal NC 的缓存行为

        ┌──────────────┐
        │   L1 Cache    │  ← Normal NC 绕过此行 (Inner NC)
        ├──────────────┤
        │   L2 Cache    │  ← Normal NC 绕过此行 (Outer NC)
        ├──────────────┤
        │ SLC / L3      │  ← Normal NC 仍命中! ARM 架构规定 Normal 内存可被系统缓存透明缓存
        ├──────────────┤
        │    DRAM       │
        └──────────────┘

ARM 架构规范 (ARMv8-A ARM D13.2.111) 明确:所有 Normal 类型的内存(包括 Non-Cacheable)均可被系统级缓存(SLC)透明缓存。这意味着即使 PT 标记为 NC,硬件仍可能在 SLC 中分配缓存行。这是 ARM 不同于 x86 的关键行为差异。

代码路径

// prctl hook handler
case PRCTL_MEM_READ_SAFE:
    result = read_mem_phys_direct(pid, addr, buf, size, MEMREMAP_WC);
    //                                                          ↑
    //                              内核调用 ioremap_prot(phys, size, PROT_NORMAL_NC)
    //                              → ARM64 PTE: AttrIndx 指向 MAIR[1] = 0x44 (NC)

v3.1 — 移除 WB 回退:消除隐蔽性隐患

问题:v3.0 的 read_mem_phys_direct() 包含静默降级逻辑:

void *mapped = my_memremap(page_phys, 4096, MEMREMAP_WC);
if (!mapped) {
    // 静默回退! 映射失败时自动降级为 WB
    mapped = my_memremap(page_phys, 4096, MEMREMAP_WB);
}

arch_memremap_can_ram_remap() 拒绝 RAM 页的 NC 映射时,memremap 返回 NULL,KPM 自动回退到 WB。攻击者以为自己在用 WC(规避模式),实际运行的是 WB(可检测模式)。

修复:三处回退全部移除(read_mem_phys_directwrite_mem_phys_directread_mem_dma),映射失败直接返回错误,零容忍静默降级。

影响:修复使 WC 检测率从 61.7% 骤降至 ~0%,证实之前的检出完全来自回退路径。


v4.0 — vmap Device-nGnRnE:终极规避

原理:使用 vmap() 对目标物理页创建独立的虚拟地址映射,页表条目标记为 Device-nGnRnE 内存类型。vmap 直接操作页表,不经过 arch_memremap_can_ram_remap() 检查,因此可对任意物理页(包括 RAM)创建 Device 映射——这是 ioremap 做不到的。

为什么不用 ioremap

// ioremap 路径 — 对 RAM 会失败
__ioremap(phys, size, PROT_DEVICE_nGnRnE)
  └── arch_memremap_can_ram_remap(phys, size, MEMREMAP_WB)
        → 检测到 phys 是 RAM → 返回 0 → ioremap 返回 NULL

// vmap 路径 — 无 RAM 检查
vmap(page, 1, VM_MAP, pgprot_device)
  └── 直接分配 vmalloc 区域 VA → 填充 PTE → 无 RAM 检查 ✓

读取链路

用户态 attack              内核态
     │                      │
     ├── prctl(PRCTL_MEM_READ_DMA) ──→ before_prctl hook
     │                              │
     │                      ┌─ ① walk_page_table() → 物理地址
     │                      ├─ ② pfn = phys >> 12
     │                      ├─ ③ page = vmemmap_base + pfn * sizeof(page)
     │                      ├─ ④ dev_map = vmap(&page, 1, VM_MAP, 0x00E8000000000703)
     │                      │       └── 在 vmalloc 区创建独立 VA
     │                      │       └── PTE: AttrIndx[5:2]=0000, bits[7:6]=00 → Device-nGnRnE
     │                      ├─ ⑤ volatile 逐字节读取 ← Device 总线事务, 不分配缓存行!
     │                      └─ ⑥ vunmap(dev_map) → 释放 vmalloc 映射
     │                              │
     │                      ←── 返回 4096 ──┤

Device-nGnRnE 协议级分析

属性 全称 含义 对检测的影响
nG Non-Gathering 不合并多次内存访问为单次总线事务 每次读取独立事务, 无 batching
nR Non-Reordering 不重排访问顺序 严格按程序顺序执行
nE No Early Ack 无提前写入确认 写入等待到达最终目的地

缓存行为 — 全绕过

        ┌──────────────┐
        │   L1 Cache    │  ← Device 事务绕过 (Inner Non-Cacheable + Non-Allocating)
        ├──────────────┤
        │   L2 Cache    │  ← Device 事务绕过 (Outer Non-Cacheable + Non-Allocating)
        ├──────────────┤
        │ SLC / L3      │  ← Device 事务绕过! SLC 不缓存 Device 类型访问
        ├──────────────┤
        │    DRAM       │  ← Device 事务直接到达, 不经任何缓存层
        └──────────────┘

ARM 架构规定 Device 内存不可被任何缓存层级缓存(ARMv8-A ARM D13.2.111),这是硬件保证,不同于 Normal NC 的 "可被 SLC 透明缓存"。

vmap 页表操作细节

ARM64 PTE 中控制内存类型的字段:

PTE bits [63:12]  输出物理地址
PTE bits [11:3]   AF, SH, AP, NS, AttrIndx
PTE bits [7:6]    = 00  → Device 内存 ✓
PTE bits [5:2]    = 0000 → AttrIndx, 指向 MAIR[0] 即 Device-nGnRnE
PTE bits [1:0]    = 11  → 有效页表项 (block/page descriptor)

prot 值 0x00E8000000000703 解码:

0x00E8 0000 0000 0703
  = 0000 0000 1110 1000 0000 ... 0000 0111 0000 0011

Bits [63:56]: 0x00    = Upper attributes (UXN/PXN = 0, 用户不可执行/内核不可执行 = 否)
Bits [55:50]: 0b111010 = 软件定义/保留位 (ARM64 特定编码)
Bits [7:6]:   0b00    = Memory Type = Device
Bits [5:2]:   0b0000  = Device Type = nGnRnE
Bits [1:0]:   0b11    = Page Descriptor Valid

与 WC (v3.0) 的本质区别

              v3.0 WC (memremap WC)        v4.0 DMA (vmap Device-nGnRnE)
映射方式       memremap + Normal NC         vmap + Device-nGnRnE
ARM 内存类型   Normal Non-Cacheable         Device-nGnRnE
L1/L2          绕过 ✓                       绕过 ✓
SLC/L3         仍命中 ✗ (架构必然)           绕过 ✓ (硬件保证)
RAM 检查       arch_memremap_can_ram_remap  无 (vmap 直接操作页表)
MESI/MOESI     参与 (SLC 内的共享状态)        不参与 (Device 事务不进入缓存域)
检测率         0% (无回退时)                  0%

六、开发过程中的关键难点与解决方案

6.1 难点一:交叉编译环境配置

原始 Makefile 的环境变量 CC=cc(make 默认值)覆盖了交叉编译器检测逻辑。修复:

# 使用 override 强制覆盖 make 内置变量
override CC := $(CROSS_CC)
override CXX := $(subst gcc,g++,$(CC))

6.2 难点二:C 与 C++ 的 prctl 结构体差异

早期用 C 语言编写的测试程序直接调用 prctl(PRCTL_MEM_READ, &op, ...) 始终返回 -EINVAL(errno=22),但改用 C++ 的 c_driver 类后成功。关键在于:

  • KPM hook 通过 __arch_copy_from_user 从用户态拷贝 mem_operation 结构体
  • C 和 C++ 在 ARM64 ABI 下对包含 pid_t(32-bit)和 uint64_t 的结构体可能有不同的隐式填充处理
  • 使用 kernel_driver.h 中的 c_driver::readMemSafe() 方法正确对齐了结构体

6.3 难点三:Soft-Dirty 检测的时序 Bug

最初的 Soft-Dirty 检测流程是「先清除,后检查」:

clear_soft_dirty() → 清除所有标记
usleep(10000)       → 等待
check_page()        → 检查 ← 永远为 0

实际攻击发生在两次检测轮次之间,标记在下一轮被 clear_soft_dirty() 清除。修复为「先检查,后清除」。

6.4 难点四:CONFIG_MEM_SOFT_DIRTY 未开启

测试设备的 Linux 5.4 内核未开启 CONFIG_MEM_SOFT_DIRTY/proc/self/pagemap bit 55 始终为 0,clear_refs 写入成功但无效果。替代方案使用 /proc/self/pagemap bit 63(present bit)+ MADV_DONTNEED,不依赖任何内核配置。

6.5 难点五:缓存时序检测的调试与突破

CPU 缓存时序检测经历了多次迭代才最终成功,每次迭代都解决了一个关键问题。

第一代:单次 8 字节读取 + 低阈值

初始方案模仿经典的 Flush+Reload:每次测量访问 64 个缓存行中一个字节的时间。但攻击端只读取 8 字节(1 个 unsigned long),KPM 模块的 memcpy 只加载 1 个缓存行。防御端遍历全部 64 个缓存行时,1 命中 + 63 未命中 ≈ 120+ ticks,远超基于 L1 命中的阈值(3-12 ticks)。

第二代:高频探测 + usleep 让出 CPU

改用忙等(busy-wait)保持 CPU 占用 → 攻击进程完全没有机会运行。修复为 usleep 主动让出 CPU,攻击进程在 5ms 窗口内获得调度。

第三代:整页读取

改为每次 readMemSafe(target_pid, addr, buf, 4096) 读取整页,KPM 的 memcpy 加载全部 64 个缓存行。防御端测量 64 行全部命中 → 时间大幅降低。

第四代:跨核阈值

最初的阈值设为 hit × 3(约 3-12 ticks),只覆盖本地 L1 命中。但攻击和防御运行在不同 CPU 核上,缓存一致性传输延迟约 50 cycles,64 行约 31 ticks,被误判为未命中。修复为固定阈值 50 ticks,成功覆盖跨核缓存一致性传输。

第五代:PID 传递修复

c_driver 构造函数中用 getPidByName("defense") 查找进程名,但独立测试程序名为 cache_test,导致 driver->pid = -1。修复为构造函数接收 PID 参数,由 main() 传入命令行 PID。

最终参数总结

参数 旧值 新值 原因
每次读取大小 8 字节 4096 字节 加载全部 64 个缓存行
探测间隔 50ms/忙等 5ms/usleep 让出 CPU + 更高频率
阈值 hit×3 (3-12) 50 (固定) 覆盖跨核一致性传输
探测轮数 40 60 300ms 总窗口
PID 获取 按名查找 命令行传入 适配不同进程名

6.6 难点六:KPM 模块无法访问框架内部变量

KPM 框架在 pgtable.h 中声明了 linear_voffsetva_bitspage_levelpage_shift 等外部变量,但 KPM 模块加载器不会解析这些符号:

[-] KP E unknown symbol: linear_voffset
[-] KP E unknown symbol: va_bits

解决

  • linear_voffset → 改为直接读内核 VA 的 PGD 条目,完全不依赖 VA→PA 转换
  • va_bits / page_level → 通过检查 init 进程的 PGD 条目结构运行时探测页表级数
  • page_shift → ARM64 始终为 12(4KB)

6.7 难点七:__virt_to_phys 不是内核导出符号

ARM64 上 __virt_to_physvirt_to_phys 都是内联宏而非可链接的函数符号。5 个所需符号中唯一解析失败的:

memremap=Y  get_task_mm=Y  mmput=Y  access_process_vm=Y  __virt_to_phys=N ←

解决:利用内核线性映射的特性,PGD 层级直接通过内核 VA 读取,无需物理地址转换。子级页表使用 memremap 映射 PTE 中的物理地址。

6.8 难点八:init 进程无用户空间页表

probe_mm_pgd_offset 最初使用 init 进程(pid=1)探测 mm_struct->pgd 偏移量,但 init 的所有 PGD 用户空间条目为 0(init 运行在内核模式)。

解决:扫描多个 PID(1000+),验证任意 PGD 条目(不仅第一个)中是否有有效页表项。


七、技术总结

7.1 检测方法有效性矩阵

攻击方式 Pagemap PerfEvent PMU REMOTE 缓存时序 Canary 综合检测率
物理直读 (WB) ✅ 绕过 ✅ 绕过 ⚠️ 偶发假阳性 ✅ 检出 ~78%
物理直读 (WC) ✅ 绕过 ✅ 绕过 ⚠️ 检出 ✅ 绕过 ~6%
物理直读 (DMA Device) ✅ 绕过 ✅ 绕过 ✅ 绕过 ✅ 绕过 ~0%
写入攻击 ✅ 检出

7.2 核心发现

CPU 缓存时序检测(Flush+Reload)是目前最有效的物理内存直读用户层检测手段,其有效性完全取决于攻击端的内存映射类型:

  1. MEMREMAP_WB(可缓存,设计目标为性能):缓存时序以 78% 命中率稳定检出。三条命令 (READ/SAFE/DMA) 均使用同一套手动页表遍历引擎,区别仅在于 memremap 内存类型——WB 提供最高吞吐性能,其缓存行为是 Normal Cacheable 内存的自然属性,并非设计缺陷。WC 和 Device 才是专门为规避缓存时序检测而引入的映射类型

  2. MEMREMAP_WC(Normal Non-Cacheable,不可缓存):缓存时序命中率降至 6%(降幅 92%),仅 PMU REMOTE_ACCESS 仍可检出——但 PMU 在基线测试中存在偶发假阳性(+85),单独依赖不可靠

  3. vmap Device-nGnRnE(DMA 硬件读取):全部 5 种用户层检测方法均无法检出(检测率 ~0%)。Device 类型总线事务完全旁路缓存层级,攻击侧在用户层检测面上取得完全优势

  4. 防御需向上迁移:在 DMA 模式下,唯一可行的检测方向是系统级监控——IOMMU/SMMU 审计捕获异常设备地址空间的页表映射操作,或总线级监控捕获 Device 类型事务的异常模式

攻击端从 v1.0 到 v4.0 的演进体现了内核攻击与用户层检测之间"攻高一尺,魔高一丈"的持续对抗:每一步攻击改进都绕过了一部分检测,最终 DMA 模式在现有用户层检测框架下形成完全不可检测的真空地带。

7.3 攻防演进路线

v1.0  KPM: access_process_vm (8字节读)
        → Pagemap + PerfEvent 完全检出(检测率 ~95%+)

v2.0  KPM: 物理直读 + 手动页表遍历 + MEMREMAP_WB (8字节读)
        → 绕过 Pagemap + PerfEvent,但缓存时序信号太弱

v2.1  KPM: 物理直读 + 整页读取 (4096字节, MEMREMAP_WB)
        → 绕过 Pagemap + PerfEvent
        → 缓存时序检测以 75-80% 命中率检出 → 攻防新平衡

v3.0  KPM: 物理直读 + MEMREMAP_WC (无缓存, Non-Cacheable)
        → 缓存时序降至 6%(92% 降幅),仅 PMU 可检出
        → 攻击侧首次取得显著优势

v4.0  KPM: 物理直读 + vmap Device-nGnRnE (DMA 硬件读取)
        → 全部 5 种用户层检测方法均无法检出(检测率 ~0%)
        → 攻击侧在用户层检测面上取得完全优势
        → 防御需向上迁移至系统级监控(SMMU/IOMMU 审计、总线监控)

7.4 未来方向

  1. 攻击端进一步规避
    • WC 模式页表遍历改用 MEMREMAP_WC — 消除页表页缓存污染(代价:遍历性能大幅下降)
    • 读取后 DC CIVAC 自清洗 — 主动逐出 SLC 中的数据(代价:增加指令开销)
    • 跨 Cluster 攻击(big→LITTLE)— 利用不同 Cluster 的独立 SLC 规避缓存一致性嗅探
  2. 防御端检测增强
    • 缩短探测间隔至 1ms,进一步提高命中率
    • 结合 PMU + 缓存时序 + PerfEvent 做多模态交叉验证,降低假阳性(基线测试显示 PMU 单独使用存在 +85 的偶发假阳性)
    • 在多个 CPU 核心同时部署探测页,增加跨核覆盖
  3. PMU 检测的降噪:REMOTE_ACCESS 的系统级假阳性可通过动态基线校准或白名单过滤
  4. 系统级监控(对抗 DMA 模式的关键方向)
    • IOMMU/SMMU 审计:捕获异常设备地址空间的页表映射操作(vmap Device-nGnRnE 映射可被 IOMMU 记录)
    • 总线级监控:检测异常 Device 类型总线事务的频率和模式
    • 利用 ARMv8.5 MTE(Memory Tagging Extension):设备支持时,内存标签验证可检测未授权的物理内存访问

参考资料

  • Linux Kernel 5.4 ARM64 页表实现:arch/arm64/include/asm/pgtable.h
  • KernelPatch KPM 框架:5f5K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6T1L8h3q4^5x3e0t1I4i4K6u0r3d9$3g2J5L8X3g2D9f1r3q4@1j5$3R3`.
  • APatch Next:7d1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6T1L8h3q4^5x3e0t1I4i4K6u0r3b7g2m8S2N6r3y4Z5
  • rwProcMem33 物理内存读写:rwprocmem33_20250520/get_task_proc_phy_addr
  • rwProcMem33 物理内存读写驱动:rwprocmem33_20250520/rwProcMem33-master/rwProcMem33Module/rwProcMem_module/phy_mem.hget_task_proc_phy_addrread_ram_physical_addr
  • PMU 事件编码:ARM Architecture Reference Manual ARMv8, Chapter D7
  • access_process_vm 内核实现:mm/memory.c, mm/gup.c

本文档随项目代码一起维护,文件路径:/home/zzz/Desktop/kernel_read_detect/article.md


[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 1天前 被mb_fycbgpri编辑 ,原因:
收藏
免费 7
打赏
分享
最新回复 (3)
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
牛而逼之
1天前
0
雪    币: 1892
活跃值: (3200)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
3
已严肃学习
1天前
0
雪    币: 411
活跃值: (161)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
已严肃学习
1天前
0
游客
登录 | 注册 方可回帖
返回