-
-
[原创]内核驱动级内存读写的用户层检测与绕过:一次攻防对抗实践
-
发表于: 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 对此类内存的读取会:
- 先查询 L1/L2 缓存
- 未命中则发起缓存行填充(Cache Line Fill)
- 从 DRAM(或远程缓存)读取 64 字节缓存行
- 存入本地 L1/L2,状态标记为 Shared 或 Exclusive
如果 KPM 使用 MEMREMAP_WC(Write-Combine,不可缓存),则数据不会进入缓存,本检测方法失效。 但 memremap 的 MEMREMAP_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)保证:
- 如果缓存行为 Dirty,写回数据到 PoC
- 该缓存行在所有核的 L1/L2 中全部失效
- 操作完成后,该缓存行的唯一有效副本在 PoC(L3/DRAM)
- 任何核的下次访问都必须从 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/104 ≈ 0.04 ticks → 计数值不变 (0)
DRAM: ~200 cycles → 200/104 ≈ 1.9 ticks → 计数值为 1-2
单次测量无法可靠区分 (0 vs 1).
64 次访问的信号放大:
64 × L1 命中: 64 × 4 = 256 cycles → 256/104 ≈ 2-3 ticks
64 × 跨核传输: 64 × 50 = 3,200 cycles → 3200/104 ≈ 31 ticks
64 × DRAM: 64 × 200 = 12,800 cycles→ 12800/104 ≈ 123 ticks
64 次访问将信号放大了 64 倍,使不同缓存层级之间的差异从无法区分变为 显著可区分(2-3 vs 31 vs 123 ticks)。这正是遍历页面全部 64 个缓存行的数学基础。
通过延时放大信号:
除了空间放大(64 个缓存行),还可以通过时间放大。在测量前通过 usleep(5ms) 等操作延时,这段时间允许:
- 防御进程让出 CPU,使攻击获得调度机会
- 攻击的缓存行填充操作完成
- 缓存一致性协议完成状态转换
为什么每页恰好 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:+38 ← 37 次外部缺页
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_offset→p4d_offset→pud_offset→pmd_offset→pte_offset_kernel遍历页表,并通过page_to_phys(pte_page(*pte))将 PTE 转换为物理地址。物理内存的实际读写则通过xlate_dev_mem_ptr(ioremap_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 层级直接读内核 VA:
mm->pgd是内核线性映射区的虚拟地址,可直接解引用,无需memremap - PMD/PTE 层级使用
memremap:页表条目中的物理地址需要用memremap映射到内核虚拟地址后才能读取 - 支持大页(2MB PMD block、1GB PUD block)
4.3.2 绕过 __virt_to_phys 不可用的问题
KPM 模块需要内核符号来转换虚拟地址到物理地址,但 ARM64 上 __virt_to_phys 和 virt_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 轮探测无缓存命中 (正常) ← 连续 4 轮 240 次探测零命中!
- 缓存时序检测:仅第 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 命中导致检出
- 假说 B:
arch_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_direct、write_mem_phys_direct、read_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_voffset、va_bits、page_level、page_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_phys 和 virt_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)是目前最有效的物理内存直读用户层检测手段,其有效性完全取决于攻击端的内存映射类型:
MEMREMAP_WB(可缓存,设计目标为性能):缓存时序以 78% 命中率稳定检出。三条命令 (READ/SAFE/DMA) 均使用同一套手动页表遍历引擎,区别仅在于 memremap 内存类型——WB 提供最高吞吐性能,其缓存行为是 Normal Cacheable 内存的自然属性,并非设计缺陷。WC 和 Device 才是专门为规避缓存时序检测而引入的映射类型
MEMREMAP_WC(Normal Non-Cacheable,不可缓存):缓存时序命中率降至 6%(降幅 92%),仅 PMU REMOTE_ACCESS 仍可检出——但 PMU 在基线测试中存在偶发假阳性(+85),单独依赖不可靠
vmap Device-nGnRnE(DMA 硬件读取):全部 5 种用户层检测方法均无法检出(检测率 ~0%)。Device 类型总线事务完全旁路缓存层级,攻击侧在用户层检测面上取得完全优势
防御需向上迁移:在 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 未来方向
- 攻击端进一步规避:
- WC 模式页表遍历改用
MEMREMAP_WC— 消除页表页缓存污染(代价:遍历性能大幅下降) - 读取后
DC CIVAC自清洗 — 主动逐出 SLC 中的数据(代价:增加指令开销) - 跨 Cluster 攻击(big→LITTLE)— 利用不同 Cluster 的独立 SLC 规避缓存一致性嗅探
- WC 模式页表遍历改用
- 防御端检测增强:
- 缩短探测间隔至 1ms,进一步提高命中率
- 结合 PMU + 缓存时序 + PerfEvent 做多模态交叉验证,降低假阳性(基线测试显示 PMU 单独使用存在 +85 的偶发假阳性)
- 在多个 CPU 核心同时部署探测页,增加跨核覆盖
- PMU 检测的降噪:REMOTE_ACCESS 的系统级假阳性可通过动态基线校准或白名单过滤
- 系统级监控(对抗 DMA 模式的关键方向):
- IOMMU/SMMU 审计:捕获异常设备地址空间的页表映射操作(
vmapDevice-nGnRnE 映射可被 IOMMU 记录) - 总线级监控:检测异常 Device 类型总线事务的频率和模式
- 利用 ARMv8.5 MTE(Memory Tagging Extension):设备支持时,内存标签验证可检测未授权的物理内存访问
- IOMMU/SMMU 审计:捕获异常设备地址空间的页表映射操作(
参考资料
- 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.h(get_task_proc_phy_addr、read_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内核攻防全技术栈,打造具备自动化能力的内核开发高手。