内核驱动级内存读写的用户层检测与绕过——一次攻防对抗实践
内核驱动级内存读写的用户层检测与绕过:一次攻防对抗实践
摘要:本文记录了在 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 项目文件结构
1 | kernel_read_detect/ # 用户态项目(防御端 + 攻击端) |
二、技术架构
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,绕过全部缓存) |
用户态调用方式:
1 | struct mem_operation { |
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():
1 | // 原始 Kernel_prctl.c 第 160 行 — 名为 phys 实则 access_process_vm |
真正的手动物理地址遍历代码(read_phys_addr_direct,使用 memremap 映射物理页)被整个注释掉了,从未编译进模块。
2.3 access_process_vm 的可检测副作用
access_process_vm 是内核中用于跨进程内存访问的标准函数(也被 ptrace 和 /proc/pid/mem 使用)。它在内部调用 get_user_pages_remote():
1 | access_process_vm() |
核心发现: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 | // 1. 分配检测页并使其 non-present |
关键时序:必须是先检查,后清除。早期的实现错误地先 clear_refs 再检查,导致攻击留下的证据在检查前被清除。修正后的流程:
1 | 检测轮次 N: |
验证实验:通过直接测试确认了 access_process_vm 读取 DONTNEED 页面时,即使 prctl 返回失败(-EINVAL),仍触发了一次 minor fault 并使页面变为 present:
1 | Self page: 0x7e04c95000, present=0 |
局限性:需要内核开启 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 时才计数。
两者的差值 = 外部触发的缺页数:
1 | external_faults = delta_min_flt(stat) - delta_min_flt(perf) |
实现:
1 | // 打开 perf_event 计数器 |
实测效果:攻击使用 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)时,缓存控制器都能通过物理标签匹配到同一缓存行。这是整个检测方法的硬件基础。
1 | 用户态 VA_A (defense) 内核态 VA_B (memremap) |
原理二:KPM 的可缓存内存映射
KPM 模块通过 memremap(phys, 4096, MEMREMAP_WB) 创建临时内核虚拟地址映射。关键在于 MEMREMAP_WB 标志:
1 | // MEMREMAP_WB 的 ARM64 实现路径: |
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 状态转换:
1 | Defense 核 KPM 核 |
关键: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 架构定义了多个”点”来描述缓存操作的范围:
1 | CPU Core 0 CPU Core 1 CPU Core 2 CPU Core 3 |
- 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 周期。
单次访问的计时困境:
1 | L1 命中: ~4 cycles → 4/104 ≈ 0.04 ticks → 计数值不变 (0) |
64 次访问的信号放大:
1 | 64 × L1 命中: 64 × 4 = 256 cycles → 256/104 ≈ 2-3 ticks |
64 次访问将信号放大了 64 倍,使不同缓存层级之间的差异从无法区分变为 显著可区分(2-3 vs 31 vs 123 ticks)。这正是遍历页面全部 64 个缓存行的数学基础。
通过延时放大信号:
除了空间放大(64 个缓存行),还可以通过时间放大。在测量前通过 usleep(5ms) 等操作延时,这段时间允许:
- 防御进程让出 CPU,使攻击获得调度机会
- 攻击的缓存行填充操作完成
- 缓存一致性协议完成状态转换
为什么每页恰好 64 个缓存行? 缓存行大小是 ARM 架构定义的(通常 64 字节),4KB 页面 / 64 字节 = 64 个缓存行。这不是巧合,而是页面大小和缓存行大小定义的结果。
Flush+Reload 完整流程
1 | Step 1 — Flush (DC CIVAC): |
从测量值反推缓存层级
实测数据验证了各缓存层级的理论计算值:
| 场景 | 测量值 (cntvct ticks) | 反推延迟 | 对应缓存层级 |
|---|---|---|---|
| 校准 HIT(刚触摸后) | 4-6 | ~4 cycles/行 | L1 |
| 攻击期间探测命中 | 50-70 | ~50 cycles/行 | L2/跨核传输 |
| 无攻击基线 | 100-170 | ~150 cycles/行 | DRAM |
| 调度干扰尖峰 | 2000-11000 | — | 调度器抢占 |
ARM64 汇编实现
1 | /* 读取虚拟计数器 (cntvct_el0, ~19.2MHz) */ |
关键参数:阈值的选择
| 缓存层级 | 单行延迟 | 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 一次):
1 |
|
60 轮 × 5ms = 300ms 总探测窗口,攻击每 20-50ms 运行一次,保证至少数次重叠。
攻击端配合:整页读取
关键发现:必须让 KPM 模块读取整页(4096 字节),而非 8 字节:
1 | // ❌ 错误 — 只加载 1 个缓存行,其余 63 行从 DRAM → 总时间太高 |
实测效果
攻击使用物理直读(PRCTL_MEM_READ, 整页 4096 字节)时:
1 | Round 1: 366 次命中 ← 60 轮 × 8 页 = 480 次测量,命中率 76% |
数据验证:防御写入可识别模式 0xcafe0000dead0000 ~ 0xcafe0007dead0000,攻击通过 prctl(PRCTL_MEM_READ) 读出完全一致的数据,100% 确认 KPM 物理直读功能正常。
四、攻击端:从 access_process_vm 到物理直读
4.1 用户态调用封装
攻击端使用 kernel_driver.h 中从原始 kernel.h 提取的 c_driver 类:
1 | class c_driver { |
攻击端通过解析 /proc/<pid>/maps 自动发现目标的匿名 rw-p 映射区域,对每个页面读取 8 字节。
4.2 原始模块的检测结果
当 KPM 使用 access_process_vm 路径时,99 次读取(792 字节)产生了 37–38 次外部可检测的 minor faults,8/8 的 pagemap 检测页全部命中:
1 | 第 4 轮检测: |
4.3 物理直读模块的设计与实现
目标:实现真正的物理内存直接读取,绕过 access_process_vm 及其所有副作用。
4.3.1 ARM64 页表遍历
核心函数 walk_page_table() 手动遍历 ARM64 多级页表:
1 | 虚拟地址 [38:0] (39-bit VA, 3 级页表): |
实现(参考 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 无法使用内核标准宏,改为手动计算各级索引并从页表条目中直接提取物理地址。
1 | static int walk_page_table(uint64_t mm_pgd_va, uint64_t vaddr, |
关键设计决策:
- 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 都是内联宏,不作为内核符号导出:
1 | [phys_RW] symbols: v2p=N ← __virt_to_phys 不可用 |
解决方案:完全不依赖 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 个有效条目):
1 | static int verify_pgd(uint64_t pgd_va) { |
最终找到 pid=1000, pgd_off=0x48, levels=3。
4.3.4 物理直读的完整流程
1 | static int read_mem_phys_direct(pid_t pid, uintptr_t vaddr, |
无回退设计:如果 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 探测窗口。
1 | 攻击配置: ./attack <PID> 3 <mode> 5 <addr_file> |
测试 1 — 无任何读取(基线)
1 | ========== 第 1~5 轮 ========== |
基线干净:5 轮 × 60 次 = 300 次缓存探测全部零命中,无 PMU 假阳性。
测试 2 — 有缓存读取(WB)
1 | ======================================== |
- 缓存时序检测:58/60 = 96.7% 子探测命中率,几乎连续命中
- PerfEvent:ext:0 —— 页面已 present(非 DONTNEED),无缺页触发
- PMU:本轮 REMOTE_ACC/BUS_ACCESS 均为 0
- **综合检测率:96.7%**(精确地址 + 时序对齐条件下)
测试 3 — 无缓存读取(WC,memremap WC 物理直读,已移除 WB 回退)
1 | 攻击端 (attack WC 模式): |
- 缓存时序检测:仅第 0 轮冷启动噪声(7/8 命中),后续 240 次探测(4 轮 × 60)全部零命中
- **综合检测率:~0%**(与 DMA 模式一致,冷启动噪声不计入)
对比:移除 WB 回退前后
1 | 移除前 (有 WB 回退) 移除后 (无 WB 回退) |
- 结论:之前的 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,绕过全部缓存)
1 | 攻击端 (attack DMA 模式): |
- 全部 5 种检测方法零告警:300 次缓存探测(5 轮 × 60 次)全部零命中
- Device-nGnRnE 总线事务完全旁路 L1/L2/SLC,不分配缓存行,不参与 MESI/MOESI 协议
- 综合检测率:0% —— 在用户层完全不可检测
5.3 四种模式检测率总览
1 | 测试模式 Pagemap PerfEvent PMU REMOTE 缓存时序(子探测) 综合检测率 |
5.4 WC 模式残余命中的微架构分析
WC 回退分析(已证实)
KPM 模块的 WC 读取路径曾包含显式的 WB 回退逻辑:
1 | // 已移除的回退代码(修复前) |
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。
1 | ARM SoC 缓存层级 |
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 路径均正确读出,确认各读取路径功能正常:
1 | 模式 防御写入 攻击读出 验证 |
5.6 各版本原理详解
v1.0 — access_process_vm:标准内核路径
原理:调用 Linux 内核标准函数 access_process_vm(target_task, addr, buf, len, 0),内核代为完成 VA→PA 转换和页表遍历。
读取链路:
1 | 用户态 attack 内核态 |
内存类型: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) 创建临时内核映射直接读取。
读取链路:
1 | 用户态 attack 内核态 |
关键改进 — 绕过 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,大幅扩大缓存”指纹”。
1 | v2.0: 读取 8 字节 → 污染 1 条缓存行 → 防御端需恰好探测这一条 |
为什么需要信号放大:攻击端也不知道防御端具体探测哪个地址。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 属性索引不同:
1 | ARM64 PTE 内存类型编码 (bits [7:2]): |
Normal NC 的缓存行为:
1 | ┌──────────────┐ |
ARM 架构规范 (ARMv8-A ARM D13.2.111) 明确:所有 Normal 类型的内存(包括 Non-Cacheable)均可被系统级缓存(SLC)透明缓存。这意味着即使 PT 标记为 NC,硬件仍可能在 SLC 中分配缓存行。这是 ARM 不同于 x86 的关键行为差异。
代码路径:
1 | // prctl hook handler |
v3.1 — 移除 WB 回退:消除隐蔽性隐患
问题:v3.0 的 read_mem_phys_direct() 包含静默降级逻辑:
1 | void *mapped = my_memremap(page_phys, 4096, MEMREMAP_WC); |
当 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:
1 | // ioremap 路径 — 对 RAM 会失败 |
读取链路:
1 | 用户态 attack 内核态 |
Device-nGnRnE 协议级分析:
| 属性 | 全称 | 含义 | 对检测的影响 |
|---|---|---|---|
| nG | Non-Gathering | 不合并多次内存访问为单次总线事务 | 每次读取独立事务, 无 batching |
| nR | Non-Reordering | 不重排访问顺序 | 严格按程序顺序执行 |
| nE | No Early Ack | 无提前写入确认 | 写入等待到达最终目的地 |
缓存行为 — 全绕过:
1 | ┌──────────────┐ |
ARM 架构规定 Device 内存不可被任何缓存层级缓存(ARMv8-A ARM D13.2.111),这是硬件保证,不同于 Normal NC 的 “可被 SLC 透明缓存”。
vmap 页表操作细节:
ARM64 PTE 中控制内存类型的字段:
1 | PTE bits [63:12] 输出物理地址 |
prot 值 0x00E8000000000703 解码:
1 | 0x00E8 0000 0000 0703 |
与 WC (v3.0) 的本质区别:
1 | v3.0 WC (memremap WC) v4.0 DMA (vmap Device-nGnRnE) |
六、开发过程中的关键难点与解决方案
6.1 难点一:交叉编译环境配置
原始 Makefile 的环境变量 CC=cc(make 默认值)覆盖了交叉编译器检测逻辑。修复:
1 | # 使用 override 强制覆盖 make 内置变量 |
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 检测流程是「先清除,后检查」:
1 | clear_soft_dirty() → 清除所有标记 |
实际攻击发生在两次检测轮次之间,标记在下一轮被 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 模块加载器不会解析这些符号:
1 | [-] KP E unknown symbol: linear_voffset |
解决:
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 个所需符号中唯一解析失败的:
1 | 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 攻防演进路线
1 | v1.0 KPM: access_process_vm (8字节读) |
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 框架:
https://github.com/bmax121/KernelPatch - APatch Next:
https://github.com/bmax121/APatch - 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