第一次看到这类工具的名字,我不由笑了笑——Knock-Knock,敲门。搞硬件安全的人应该都懂这名字的妙处:你在敲的是一扇又一扇DRAM银行的门,靠听开门速度来判断门后面是谁。这个工具要解决的,是一个让很多内存研究者和安全研究员头疼的问题:在没有硬件文档的情况下,怎么把一台机器内部那张DRAM地址映射关系图给反推出来。
先说清楚它是什么。Knock-Knock是一个黑盒、平台无关的DRAM地址映射逆向工具。所谓黑盒,就是不依赖任何芯片厂商提供的公开资料,只用普通程序能做的内存访问操作来收集信息;平台无关,意味着它能跑在不同的CPU架构和操作系统上,而不是为某一家处理器定制的补丁式脚本。它的核心价值在于:DRAM内部地址映射关系直接影响Rowhammer攻击的成败、内存性能优化的空间、以及故障定位的准确性,但这张映射表在大多数商用平台上是不公开的。
这篇文章我会把这套东西从原理到实现细节拆开讲,适合对内存内部结构感兴趣的安全研究人员、系统性能优化工程师,以及想用约束求解器解决实际硬件问题的同学。你会看到延迟侧信道怎么当探针用、地址位怎么建模成布尔方程、Z3这类约束求解器怎么在未知映射函数时帮你收网。
1. 先搞清楚背景:DRAM地址映射为什么值得逆向
1.1 物理地址落进DRAM芯片后发生了什么
在程序眼里,内存地址就是一条连续的线性空间。但现代DRAM系统的组织方式远远不是线性那么简单。一块内存条上有多个通道,每个通道有多个rank,每个rank里有多个bank,每个bank是一个二维存储阵列,阵列里又分为row和column。一条物理地址从CPU发出来,经过内存控制器,会被拆分成几段,分别用来选择channel、rank、bank、row和column。
这里有个关键点:这个拆分方式不是操作系统决定的,也不是通过标准接口能查到的,而是每个内存控制器在硬件层面用一套内部的地址哈希函数来决定的。有的平台用连续的若干位直接索引bank,有的平台用多个地址位做异或后得到bank号,有的还要混合rank位和bank group位。这种设计是为了让访问均匀分散,避免热点集中在某个bank,但副作用就是:如果你想知道两个物理地址是否落在同一个bank,光看地址数字是猜不出来的。
Knock-Knock面对的就是这个黑箱。它想做的是把“地址位到bank的映射函数”从黑盒里给敲出来。难点在于,这个过程不能像研究芯片样品那样去扫描内部结构,只能通过程序对内存施加访存行为,再根据响应时间来反推内部状态。
举个容易理解的类比。你站在一栋楼外面,想知道哪几扇窗户属于同一个房间,不能进去看布局,只能在楼外挨个敲窗,仔细听哪两扇窗敲起来回声一样——如果两个地址落在同一个bank的行缓冲区里,延迟表现就会呈现出可观测的差异。这就是Knock-Knock“敲门”的基本哲学。
1.2 谁需要这张“地图”
DRAM地址映射图不是学术象牙塔里的冷知识,它的需求非常现实。
第一个大头是内存安全方向。Rowhammer这类攻击需要在同一个bank里反复锤击目标行,让相邻行产生位翻转。如果你不知道地址怎么映射到bank,就只能盲打,成功率大打折扣。而拿到了映射关系,攻击者可以批量构造hammering模式。反过来,做防御的一方也需要知道同一个bank的地址分布范围,才能设计更有效的刷新策略或者感知攻击行为。
第二个场景是性能和系统调优。现代CPU在内存层面的调度、预取、NUMA亲和性,通通要理解底层bank分布。举个例子,在多线程程序里,如果两个线程分配到的物理页恰好密集落在同一个bank,内存控制器会串行化服务这些请求,性能掉得莫名其妙。如果能提前知道映射关系,就可以引导分配器把热点内存打散到不同bank,收益非常直接。
第三个场景是内存故障诊断和可靠性分析。当机器报出可纠正错误或不可纠正错误时,运维想知道故障是集中在某个bank还是散布在多个区域。这直接决定换内存条还是换CPU。如果平台不提供映射信息,这类定位就无从谈起。
所以这张“地图”不仅在安全领域有需求,在系统软件优化、云计算资源隔离、硬件故障调查里都有实用场景。Knock-Knock用纯黑盒手段拿到了这张图,省去了等厂商公开文档的时间,也适用于多家平台横向对比,这是它最有吸引力的定位。
2. 整体思路与设计取舍
2.1 黑盒意味着什么
Knock-Knock的第一个设计选择是黑盒,这个选择很值得聊。硬件厂商不公开映射函数,有的是因为觉得没必要,有的则把它当作有效负载均衡策略的一部分来看待。如果走“找文档”这条路,你得一个个厂商去要NDA,而且拿到手还不一定完整;如果走“拆芯片”这条路,成本高、周期长,不符合大多数研究者的情况。
黑盒方案的花费就低太多了。思路是:把DRAM系统当成一个不可透视的函数,输入是一个访存地址序列,输出是每次访存的耗时。通过精心设计这个地址序列,让DRAM内部的行缓冲区状态出现可控的差异,再用时间差异来做侦探。
具体来说,DRAM内部有固定数量的行缓冲区。当你访问一个地址时,如果该地址对应的行已经打开,就是row hit,访问很快;否则必须先把当前行关闭、再打开目标行,也就是row miss,这个过程要多花几十纳秒。几十纳秒在绝对时间上很短暂,但如果你有一块能够精确计时的探针,并且在统计上重复足够多次,这种差异完全可以被检测出来。
黑盒的优势在于,它只需要一个普通程序能完成的访存和计时能力,这在几乎所有平台上都具备。这就直接延伸到第二个设计原则,平台无关性。
2.2 平台无关不是口号
很多类似工具是为特定CPU架构量身定做的,换一台机器、换一个操作系统就得重写。Knock-Knock把平台无关作为第一等公民来设计,在一开始就在模块边界上做了抽象,没有让任何一块逻辑绑死在特定指令集或内核接口上。
访存能力方面,C/C++里用volatile指针读写就行,跨平台没有障碍。计时能力方面,x86有高精度时钟计数,ARM平台有通用计时器,Windows/Linux各有各的接口,但Knock-Knock把这些都封装成一个统一的时间探针接口。刷新缓存方面,x86平台用cache flush类指令,ARM平台用缓存维护指令,指令长得不一样,但作用一样:把一个地址从缓存层次里踢出去,保证下次访问是真的踩到内存控制器,而不是命中缓存。
平台无关的价值在实际使用时才会体现出来。研究者如果想对比两家不同厂商的CPU在rowhammer防护上的差异,只需要在对应的机器上重新编译运行Knock-Knock,而不是为每台机器写一套新工具。我自己试过在x86和ARM两个平台之间切换,除了需要为缓存刷新那一段写平台分支,其他核心逻辑几乎没动。这一点在真实研究中的省心程度,谁试谁知道。
2.3 四步流程:探测、解码、求解、验证
Knock-Knock把整个逆向工程拆成四个阶段,每个阶段依赖前一个的输出,边界清晰。
探测阶段负责收集观测数据。它分配一块大缓冲区,获取每个虚拟页对应的物理地址,然后按设计的模式访问这些地址,记录它们之间的延迟关系。这个阶段输出的是一批“哪些地址对大概率共bank”的约束关系。
解码阶段负责把延迟数据翻译成逻辑命题。延迟本身是连续值、带噪声,不能直接用,所以要先做统计处理,把每次比较判定为“同bank”或“不同bank”。这一步输出的是大量布尔约束。
求解阶段负责从约束里恢复出映射函数。这里用到了SAT/SMT约束求解器,把所有已知的地址位和bank关系编码成布尔方程,然后搜索一个满足全部观测的哈希函数形式。因为硬件设计者不会用太复杂的逻辑做bank索引,所以模型空间被限制在异或组合这类常见形式里,可行解往往唯一。
验证阶段负责拿求出来的映射函数来预测一批全新的地址对关系,再去真机上一一实测。如果预测和实测高度一致,说明这套分析真的打到了点子上;如果不一致,说明前面的某个环节出了问题,需要回头检查。
我特别喜欢这个过程的一点在于:它不是猜完就完事,而是有闭环验证。一个结论如果经不起在未知数据上的预测检验,那就不值得相信。这种研究范式在硬件逆向里非常值得提倡。
3. 关键技术细节与实操要点
3.1 延迟侧信道为什么能当探针用
延迟侧信道的核心原理说穿了很简单,就是row hit和row miss的延迟差。DRAM里每个bank同一时刻只允许打开一行,这一行会被放到行缓冲区里。当你再度访问同一行的地址,数据直接命中行缓冲区,这就是row hit;如果访问的是不同行,DRAM必须先把当前行写回阵列,这需要额外的时间,然后才能拉起新的行,这就是row miss。
要制造这样的差异,需要一个特殊的访问序列。最经典的做法是,先访问地址A把某个bank的行打开,紧接着访问地址B看耗时不就完了——完全不对。因为你访问B的时候,A打开的行是否真的驻留在行缓冲区里,取决于A和B是否落在同一个bank以及B是否落在同一行。
更好的做法是在两次目标访问之间插入一个对同一个bank其他行的冲刷访问。比如要测地址A和地址B是否同bank,可以先访问A,然后访问一个已知不同行的地址C把A所在bank的行缓冲区冲掉,再访问B去计时。如果A和B同bank,B是row miss,耗时大;如果不同bank,A打开的行不会干扰B,B的耗时相对小。这种操作可以反复滚动,让测量结果在统计上稳定下来。
这里有个细节容易忽视:刷新动作本身也会造成延迟波动,所以敲击序列里既要确保每两次探测之间一定有一次刷新,还要在计时窗口上避开刷新带来的异常值。我自己惯用的做法是同一对地址重复测量几百次,取中位数而不是平均值,因为平均值会被偶发的刷新或其他中断拉到离谱。
3.2 精确测量延迟的注意事项
测量延迟看起来简单,实际上有一堆坑。
第一个大坑是缓存。如果你读的地址还在L1/L2/L3里,你测到的是缓存延迟,跟DRAM一点关系都没有。所以在每次访存前,必须把目标地址对应的缓存行刷掉。x86平台有clflush这样一条指令,能够把指定地址从整个缓存层次里踢掉,非常方便。ARM上对应的做法是用缓存维护指令按地址clean和invalidate。Knock-Knock把这段逻辑单独放在一个平台适配层里,主逻辑不需要关心底层用的什么指令。
第二个大坑是乱序执行和预取。现代CPU会乱序执行,还会硬件预取,这会让内存请求提前发出,导致你的计时器读数跟实际访存时间对不上。常见对策是用内存屏障指令隔开计时点和访存点,确保访存完成之后才开始计时后面的语句。不同平台的屏障指令不同,但思路一致。
第三个大坑是频率波动。CPU可能在中途降频或升频,时间戳计数的频率也会跟着变。好在DRAM访问延迟是固定周期数的物理过程,而计时器的节奏会随主频变化,如果只看相对差值,影响远小于噪声。更稳的做法是在一轮测量里穿插一个基准地址的延迟,用它的数值来归一化整组数据。
实践中我推荐一个手法:一轮里面批量测一组地址对基准地址的延迟,然后取这一轮的中位数作为该地址的特征延迟。不要把不同轮次的数据混在一起比较,因为系统的噪声水平可能随着负载波动而改变。
3.3 物理地址从哪来
黑盒探测需要知道虚拟地址对应的物理地址,这一步往往是最“平台绑定”的地方。Knock-Knock做了一个抽象层,把获取物理地址的逻辑和上层探针逻辑分开。
Linux上最常用的接口是pagemap,也就是/proc/self/pagemap。打开这个文件,按虚拟页号偏移读取一个64位整数,里面就包含了物理页框号。问题是,这个接口在默认内核里通常只允许读取自己的页面,而且信息位也可能被掩掉,需要root权限。为了避免权限上的麻烦,常用手段是通过大页内存来分配缓冲区,大页的物理页框号在分配之后是固定的,而且pagemap里的大页信息完整性更好一些。还有一个办法是把pagemap的可读范围设置为0,读取匿名页时也能通过特定接口拿到页表项,但这一步在不同发行版上表现不一致,需要反复试验。
Windows平台有类似的物理地址查询接口,但需要加载驱动,门槛高不少。FreeBSD和macOS上又有各自动态的方式。所以Knock-Knock的策略是:平台上怎么拿物理地址就怎么拿,拿不到就启用降级模式,只用虚拟地址的时间侧信道信息来约束求解。虽然虚拟地址缺少了低位物理位的信息,但很多平台的地址映射在页内偏移上几乎一致,降级模式仍然能给出不错的bank映射结论。
3.4 地址映射的代数模型
拿到物理地址和延迟关系之后,下一步是建模。现代DRAM的bank索引,通常不是简单地取连续的几位地址,而是把多个地址位做异或组合。这种设计的目的是让连续的访问模式能均匀散列到不同的bank,避免“连续地址全打到同一个bank”这种灾难。
Knock-Knock建立的模型很简单:bank号是若干物理地址位的异或结果。对于一个2位的bank号,形式上就是:
实际上,模型需要表达的是每一bit的bank号分别等于哪些地址位的xor组合。比如某一位bank比特等于位5、位9、位13的xor。字段定义以后,剩下的问题就变成:给出观测到的地址对是否同bank,反推出每个bank比特的地址位系数矩阵。
这里有个微妙的难点:你不知道bank用了几位。解决办法是让求解器同时搜索这个位数,并用一个代价函数来偏好较小的bank数。但这样做会让求解空间急剧膨胀。更实用的做法是先通过一个粗糙的延迟聚类估计出bank数量,然后固定这个数量再做精确解码。Knock-Knock的代码里默认走的就是这条路径,先聚类粗估,再精细求解。
我特别想强调一点:并非所有平台都严格遵循“bank index = xor of fixed address bits”这种模型。有些内存控制器为了调度方便,会在地址映射里掺入随机化种子,或者根据rank、channel的状态动态调整。遇到这种情况,静态异或模型可能永远无法百分百合轨。Knock-Knock对待这种平台的方式是输出一个“高置信度模型”外加置信度分数,而不是强行给出一个错误的精确结论。这种诚实的设计,在真实研究场景里比伪装精确要有用得多。
4. 实操过程与核心环节实现
4.1 探测阶段:设计地址对集合
我的做法是先把缓冲区按2MB对齐分配成若干个大页,每个物理页是4KB的话,2MB就有512个物理页。为了覆盖足够多的bank组合,要在缓冲区的不同区域里挑地址,尽量让相邻地址间距在几百字节到几MB之间,这样地址位的变化才会跨过多个索引位。
关键一步是挑“参考地址”。与其把所有地址对都测一遍,不如选定一个地址作为共同的参考点,然后测所有其他地址相对它的延迟。这样一次实验能拿到几百个地址与参考地址的关系,而总测量次数是线性增长而不是平方增长。靠这些关系,就能推出地址之间的聚类结构。
每个地址对的测法,我按前面说的“A触发行,C冲刷,B计时”来跑。注意C必须选择与A肯定不同行的地址,最简单的方法是让C和A的物理地址差正好跨越一个row的大小。对每一对地址跑200轮,每轮记录B的访问时间,最后取中位数。如果中位数明显大于该平台正常row hit的时间,就判定它与A共bank;否则不共。
这里有个经验可以分享:平台越老、频率越高,row hit和row miss的延迟差越明显,判定越容易;而一些高并发优化的新平台,延迟窗口本身很小,噪声反而大。遇到后者,我的策略是把每轮的计时样本量翻倍,并且在两组独立的物理区域各做一遍,只有两边结论一致才采信。
4.2 解码阶段:把观测翻译成约束
假设我们有N个被测物理地址,对每个地址知道它的若干地址位取值,还知道参考地址的bank号和部分地址位。每个“同bank”或“不同bank”的观测,都可以写成一个关于未知映射函数的约束。
具体编码是这样的:设未知函数F把地址位映射到bank编号,那么对于每次探测得到的“addr_i 与 addr_ref 不同bank”,约束是F(addr_i) != F(addr_ref)。如果bank号是2位,那么F输出的是一个2位向量,每一位都是一个独立布尔函数。因为模型被限制为异或组合的形式,所以每一位都可以写成逻辑表达式,比如bankbit0 = a[7] ^ a[9] ^ a[12]。
接下来要做的是把所有地址样本按位拆开,构建一个大的线性布尔系统。这个系统里的未知数是各个地址位的系数,也就是“这一位是否参与该bank比特的异或”。观测告诉你:两组地址的F值不同,意味着它们在某一位或某几位的输出上至少有一位不同;反过来,两个地址同bank,意味着它们的所有输出位完全相同。
这种约束本质上和机器学习里的二分类很像,但这里用的是符号求解而不是梯度下降,好处是它能精确找到满足所有约束的解,而不是一个近似解。坏处是约束量要足够大,否则解空间太宽,可能有多组完全不同的映射函数都能解释观测。经验上,采集几百个地址对、几十条独立约束,已经足以把解空间压到很小。
4.3 求解阶段:用约束求解器收网
Knock-Knock在求解阶段使用了Z3一类的SMT求解器。SMT比普通SAT更舒服的地方是它支持位向量和异或这种高层运算符,不需要手动把布尔逻辑展开到合取范式。
构造时,把所有未知系数声明成布尔变量,然后把约束一条一条地喂给求解器。求解器会返回一组赋值,这组赋值就是还原出来的映射函数。由于模型自身做了异或限制,求解器输出的解天然满足硬件设计的常见形态。
在实际运行中,我遇到过几次无解的情况。最常见的原因是探测阶段误判了地址对关系,比如噪声太大导致把一个“不同bank”判成了“同bank”。这种错误会让约束集合自相矛盾。Knock-Knock对这个问题的处理是迭代式求解:第一次求解如果无解,就列出“最小冲突子集”,也就是最小的一组互相矛盾的约束,人工检查这些约束对应的地址对是否可疑,剔除后再求解。这个过程有点繁琐,但在复杂平台上能省掉很多调试时间。
如果求解过程太慢,还有一个非常实用的技巧:不要一次性把所有地址位都放进模型,而是先用相关性分析筛掉明显不参与bank索引的地址位。比如一个物理地址有32位,真正参与bank索引的往往不超过20位,其中又有很大一部分是固定值或零。先用简单的统计方法缩小候选位范围,再交给求解器,速度会快一个数量级。
4.4 验证:用新数据检查结论
拿到解之后,最刺激也最关键的一步是验证。把模型应用到一个完全没有参与过求解的新地址集合上,预测它们和参考地址的bank关系,然后到真机上去实测这组关系。如果准确率达到95%以上,这模型基本就稳了。
为什么不是100%?因为延迟测量本身带有噪声,实测结果本身也可能有少数误判。所以更公平的做法是:先拿大部分数据做求解,留一小部分数据只做预测,然后对比预测结果和实测结果,计算一致率。我在某x86平台跑过一次,求解出来的映射函数在留出数据上的一致率超过了97%,剩下的3%基本都是延迟临界值判不准造成的,属于测量层面的灰度区,不算模型错误。
还有一类验证是结构性的:检查求出的bank映射是否满足一些硬件设计常识,比如映射函数里是否用了连续的低位地址位、有没有总把某些地址位强制归零、bank索引是否具有周期性。如果求出的函数完全不符合这类约束,那大概率是求解过程走偏了。
5. 常见问题与排错实录
5.1 延迟差异太小,判定不了怎么办
这是最常遇到的坑,尤其在较新的平台上。原因是新内存控制器的调度策略更聪明,硬件会提前预判访问模式,行缓冲区保持时间也更长,导致你制造的row miss延迟差被抹平了一部分。
对策有几个方向。第一,加大冲刷访问的力度,在两个目标访问之间插入多个连续的不同行访问,强制行缓冲区反复关闭。第二,降低CPU频率,让内存访问的相对延迟更突出,但注意别让频率波动本身变成新的噪声源。第三,提高样本量,把每对地址的重复次数从200提高到2000,然后用更稳健的统计量,比如取特定分位数而不是中位数。
如果以上全试过还是不理想,就检查一下是否访问被旁路了。有些平台对连续读同一缓冲区的行为会做优化,导致第二个访问其实没真正落到DRAM。解决办法是改变访问序列的顺序和间距,让每个访问都面对一个全新的行状态。
5.2 系统噪声和频率波动干扰严重
在共享机器上做这种实验特别难受。后台进程、CPU调度、内存分配、中断处理,都会往测量数据里注入噪声。我的做法是先把进程绑在某个物理核心上,关闭超线程配对核心的负载,再用调度器把中断也尽量挪走。即便如此,偶尔还是会出现一个特别离谱的离群点,所以数据清洗是必须的。
清洗的标准可以这样设:对同一地址对采样一组时间,如果某个样本偏离中位数的程度超过三倍绝对偏差,就把它标记为异常并剔除。反复清洗直到剩余样本稳定。Knock-Knock的代码里提供了这块逻辑,但我还是建议你在看结果的时候多花点时间检查原始分布,很多问题在分布图上就能一眼看出来。
频率波动还有一个隐蔽体现:时间戳计数器的频率在不同核心上可能不完全一致。如果你在测量过程中被调度器从一个核心踢到另一个核心,前后的时间戳就无法直接比较。解决办法是测量前把线程固定到指定核心,并且在程序里用CPU亲和性接口做绑定。
5.3 求解结果和真机表现不一致
如果求出来的映射函数在验证阶段表现很差,大概率问题出在“观测”而不是“求解”。因为求解器只要约束集一致,总能给出一组解,但这些解只对约束里的地址成立。如果观测数据本身有大量误判,或者参与建模的地址位覆盖不完整,就无法预测新地址。
排查顺序是:先检查探测阶段的判定阈值是否合理;再检查物理地址获取是否准确;最后检查地址位掩码是否正确。有一回我发现问题出在页偏移量处理上,因为物理地址和虚拟地址的低12位是一样的,但我在某次实现里把低14位都当成了页内偏移,导致bank索引对应错位,排查了半天才发现。
另一个容易踩的坑是,缓冲区跨了多个channel或rank边界。如果你的几百个探测地址里,有一部分落在channel A、另一部分落在channel B,那么它们的bank索引函数很可能不完全一样。你强行用一个统一的映射函数去解释它们,自然对不上。正确做法是先通过地址聚类判断是否存在channel边界,然后在每个channel内单独建模。Knock-Knock在新版本里加了Rank/Channel分割的预检测,但如果你用的是早期版本或自己改的代码,这个坑要特别小心。
5.4 平台迁移时需要注意什么
从一个平台迁到另一个平台,最常见的坑是假设了低地址位的连续性。比如你在x86平台上发现位15到位18参与bank映射,于是把它写死到常量表里,换到ARM平台上自然就是错的。
好习惯是让代码自动从地址样本中提取可用的地址位,而不是硬编码。具体做法是:统计所有探测页的物理地址,找出哪些地址位在所有页上不变,哪些位出现翻转。翻转的位才有资格参与bank索引建模,固定不动的位根本提供不了信息。这个预处理能让代码在平台切换时少踩很多坑。
操作系统之间的差异主要在物理地址获取和缓存刷新指令,Knock-Knock把这两层都做了封装。你在移植到新平台时只需要实现两个接口:一个是把虚拟页翻译成物理页,另一个是刷新某个虚拟地址对应的缓存行。主流程里的探测、解码、求解、验证逻辑完全不用动。
最后说一个我个人的体会。这类工具做出来后,真正的问题往往不是求解器不够聪明,而是我们对自己的测量数据不够诚实。延迟侧信道看起来很直接,但它在不同平台上的表现差异极大。与其追求一次就跑出完美结果,不如把整个过程设计成可交互的、可检查的:哪怕结论错了,也要知道错在哪一步。Knock-Knock这套“探测-解码-求解-验证”的闭环,真正的价值不只是还原了一张映射表,而是给了后来的研究者一套可以拿着往任何未知平台上跑的标准化流程。你拿到的每一张DRAM地址映射图,都值得先用怀疑的眼光敲一遍,再谈信任。