使用内存访问追踪与基于栈的延迟注入测试竞态条件
摘要
本文介绍了Project Zero团队开发的工具MAccConc,该工具通过内存访问追踪和基于栈的延迟注入技术,帮助测试和探索多线程代码(特别是Linux内核)中的竞态条件问题。
暂无内容
查看缓存全文
缓存时间: 2026/09/11 08:47
# 使用内存访问追踪和基于栈的延迟注入测试竞态条件
来源:https://projectzero.google/2026/09/maccconc-race-condition.html
许多安全漏洞都是竞态条件,即多线程执行必须以特定的交错方式运行才能产生负面影响。这给多种使用场景带来了挑战:
- 验证通过手动分析或静态分析发现的漏洞候选
- 回归测试:修复竞态条件漏洞后,通常难以编写可靠的回归测试来触发该漏洞
- 自动漏洞发现(如模糊测试):模糊测试器很难覆盖并发操作的所有有趣交错,或触及仅在操作竞争时才会执行的代码路径
我主要通过手动阅读代码发现漏洞。当我发现潜在漏洞时,通常会编写测试用例来验证或证伪漏洞的存在。但对于竞态条件漏洞,这两种结果都难以达成。对于Linux内核漏洞,我常通过在内核重编译时于适当位置添加条件式`mdelay()`调用(循环等待约指定时间);这些条件通常基于运行线程名称,但有时需要更复杂的条件。
在支持DTrace的平台(如macOS和Windows(https://github.com/microsoft/DTrace-on-Windows/commit/dbfb82f7fb70e001f303eab497ec4338295605f8))上,可通过DTrace探针调用`chill()`(https://docs.oracle.com/cd/E19253-01/817-6223/chp-actsub-chill/index.html)实现类似效果,但由于DTrace仅能追踪非内联函数边界或显式跟踪点而非每条指令,其效用有限。
无论何种平台,这种方法都耗时且需要反复试验才能确定代码是否存在漏洞。此外,在Linux内核中,竞态条件修复常附带手绘ASCII图,展示问题线程交错、调用图及相关内存访问(例如近期rt_spin_unlock UAF修复(https://git.kernel.org/linus/89038cc87d80c77e7aa6f42a64b2573b74af339f)和jbd2死锁修复(https://git.kernel.org/linus/981fcc5674e67158d24d23e841523eccba19d0e7))。若能有开发工具以类似表示法分析潜在漏洞代码并展示结果,将极为便利。
## 概述
我编写了用于探索Linux内核多线程测试用例可能交错的工具:
- 自动测试所有可能A-B-A交错的工具
- 手动探索可能交错的终端UI
- 手动探索可能交错的GUI
内核部分设计也支持通过模糊测试发现竞态条件,但相应的用户态工具尚未实现。这些工具以"MAccConc"(Memory Access Concurrency缩写)为名发布于GitHub(https://github.com/googleprojectzero/MAccConc),安装和使用说明请参见其README。
若只想查看工具演示,请直接跳转至[演示:自动测试](https://projectzero.google/2026/09/maccconc-race-condition.html#demo-automatic-testing)。若仅对工具理论感兴趣,请阅读[跨运行稳定内存访问标识符:计数增强栈追踪](https://projectzero.google/2026/09/maccconc-race-condition.html#stable-identifiers-for-memory-accesses-across-rays-count-augmented-stack-traces)章节。
## 前期工作
本项目灵感源于与Ned Williamson的讨论,其sockfuzzer(https://projectzero.google/2021/04/designing-sockfuzzer-network-syscall.html#h.bydr5qowo002)项目通过自定义调度器在同步原语处重调度以探索并发漏洞。相关会议演讲幻灯片(https://github.com/googleprojectzero/SockFuzzer/blob/main/third_party/concurrence/presentations/catch_me_if_you_can.pdf)和录像(https://www.youtube.com/watch?v=OpQvXGJcH4s)重点介绍了该并发测试方面。
我的工具主要基于类似SKI(https://www.usenix.org/conference/osdi14/technical-sessions/presentation/fonseca)的思路,但SKI采用不同实现:它通过TCG模式下的补丁QEMU记录内存访问并控制vCPU调度,并利用虚拟机快照探索不同执行交错。
## 发现可能参与竞态条件的内存访问点
如SKI论文所述,给定多线程测试用例的有趣执行交错可通过追踪所有线程的内存访问并寻找可能交互的访问对来发现——即至少其中一个是写操作,且它们访问重叠的内存范围。SKI论文将此类内存访问称为*通信点*。
这需要某种机制收集内存访问覆盖率。SKI通过修补QEMU的TCG模式实现;我则依赖"outline"模式下的ASAN插桩(编译器后端标志`asan-instrumentation-with-call-threshold=0`,在Linux内核中通过`CONFIG_KASAN_OUTLINE`选择),该模式在内存访问时生成辅助函数调用。
我认为在内核层收集数据是正确的选择,因为这将允许内核同时提供锁获取/释放等高级事件信息,尽管目前尚未实现。在内核实现理论上还支持裸机硬件测试而非仅限虚拟机。
由于Linux已有KCOV(https://docs.kernel.org/dev-tools/kcov.html)机制向用户态反馈内核基础块覆盖率,我决定使用相同机制记录内存访问信息。另一个选择是使用面向跟踪用例的ftrace,其包含基于fentry钩子的函数图追踪模式和更复杂的输出缓冲区管理。选择KCOV的原因包括:其内存中的跟踪数据表示更简单(可能有助于从崩溃的虚拟机恢复数据);采用始终启用的静态插桩而非运行时启用的零开销插桩;且根据我的印象,KCOV设计用于比ftrace更高频的跟踪事件。
### 实现细节:ASAN与TSAN
ASAN通常合并后续内存访问的辅助调用。为实现每次内存访问的回调,内核补丁使用`asan-opt-same-temp`后端标志明确禁用此编译器优化。
ASAN旨在识别UAF(释放后使用)漏洞,因此除非存在越界访问可能,否则不会对直接栈内存访问生成辅助调用。这意味着涉及栈对象的某些竞态条件(如等待队列)可能无法被检测。ASAN默认也不为全局变量访问生成辅助调用,但可通过`asan-opt-globals`后端标志禁用此优化。
替代方案是使用TSAN插桩,其专为检测数据竞争设计,还能提供访问原子性信息。TSAN插桩的缺点是编译器不支持同时生成ASAN和TSAN钩子——因此若要在TSAN钩子工作的同时保留内存安全违规(如UAF)检测,需基于TSAN钩子运行内核的ASAN实现或修改编译器。
### 实现细节:KCOV与后台任务
某些竞态条件涉及后台任务,例如:
- 网络回环包接收处理
- RCU回调
KCOV可选择性收集某些子系统的[远程覆盖率](https://docs.kernel.org/dev-tools/kcov.html#remote-coverage-collection);但在上游Linux中,对我而言有趣的多数后台任务尚未集成此机制,远程覆盖率当前主要用于处理设备传入数据(如蓝牙、USB)的模糊测试子系统。
为内核其他部分启用此功能相对简单,我已为RCU回调[起草了补丁](https://github.com/thejh/linux/commit/35c9db608c164c4112d57ecb9db32780ce4aba1d)。
## 跨运行稳定内存访问标识符:计数增强栈追踪
为测试不同内存访问顺序,需要跨测试用例执行稳定识别有趣内存访问的方法。若数据地址位于每次测试执行时新分配的对象中,基于数据地址的识别将失效;若内存访问位于`memcpy()`或`spin_lock()`等函数中,仅基于指令地址的识别也不理想。
SKI通过虚拟机状态快照解决此问题,确保每次执行从相同全局状态开始。我则使用计数增强栈追踪识别内存访问,每个栈追踪元素本质上包含被调用函数地址和数字(指示在调用栈帧中应跳过多少次对该被调用者的调用)。
计数增强栈追踪的语义示例如:"在此线程上,查看对`__x64_sys_recvfrom`的第二次调用,其中查看对`__sys_recvfrom`的第一次调用,再其中查看对`sock_recvmsg`的第一次调用,再其中查看对`unix_stream_recvmsg`的第一次调用,再其中查看对`unix_stream_read_generic`的第一次调用,再其中查看对`_raw_spin_unlock`的第二次调用,最后查看指令地址X处的首次内存访问"。
这能明确标识执行追踪中的点,独立于具体数据地址,且对无关追踪部分的控制流变化相对稳定。为此,KCOV必须提供函数入口/退出事件信息,以便用户态解析KCOV覆盖率输出时能追踪调用栈变化。这需要SanitizerCoverage的编译器支持;我数月前为LLVM提交了[相关功能补丁](https://github.com/llvm/llvm-project/commit/dc5c6d008f487eea8f5d646011f9b3dca6caebd7),该补丁已包含在LLVM 23.1.0版本中([参见文档](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs))。
## 通过延迟注入强制执行顺序
为通过KCOV强制特定执行顺序,我实现了ioctl `KCOV_SET_DI`,用户态可通过它请求在特定计数增强栈追踪的内存访问处执行操作(等待/唤醒)。[参见我内核分支中的文档](https://github.com/thejh/linux/blob/kcov-tracing/Documentation/dev-tools/kcov.rst)。
每个操作在用户态提供的共享标志数组索引处设置标志或等待标志设置。可能的操作类型包括:
- `DI_STACK_WAKE_PRE`:内存访问前设置标志N
- `DI_STACK_WAIT`:内存访问前自旋等待标志N设置
- `DI_STACK_WAKE_POST`:内存访问后设置标志N
通过同一ioctl,用户态还可配置自旋等待迭代上限。此外,有ioctl供用户态直接操作相同标志。
此API支持两种延迟注入方式:约束式延迟注入和全指定顺序。
### 约束式延迟注入(A先发生于B)
用户态可设置一系列A先发生于B约束,每个约束通过不同线程中操作相同标志的动作对实现:
- 先发生的访问使用`DI_STACK_WAKE_POST`
- 后发生的访问使用`DI_STACK_WAIT`
此方法保留部分执行顺序非确定性。GUI和终端UI工具当前采用此方式。优点是对简单场景更直观;但需记录时间信息以近似展示事件发生顺序,且可能使执行追踪更复杂。此外,通常比全指定顺序需要更多约束,推理也更复杂。
### 全指定顺序(上下文切换式)
用户态可通过选择执行上下文切换点来决定事件发生顺序。对于简单的双上下文情况,这要求线程A开始运行系统调用时线程B首先自旋等待标志;当线程A到达某个计数增强栈追踪时,使用`DI_STACK_WAKE_PRE`和`DI_STACK_WAIT`组合暂停自身执行并让线程B继续;之后线程B可同样切换回。这是我在自动A-B-A交错测试器中采用的方法。
## 演示:自动测试
下文将解释更多背景;但首先请看两个玩具示例的演示!
这是对包含并发`dup(5)`和`close(5)`调用的测试用例使用自动A-B-A交错测试器的示例:
```
#define _GNU_SOURCE
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
static int test_fd;
static int dup_res, dup_errno;
void test_setup(void) {
test_fd = open("/", O_PATH);
}
void test_thread1(void) {
dup_res = dup(test_fd);
dup_errno = errno;
}
void test_thread2(void) {
close(test_fd);
}
void test_end(void) {
printf("dup(%d) = %d (%s)\n", test_fd, dup_res, dup_res == -1 ? strerror(dup_errno) : "success");
}
```
它发现一个`dup(5)`返回`5`的顺序,这符合预期但可能令人惊讶:
```
sh-5.3# ./kcov-autorace testcase/demo-dup-vs-close.so
loading kallsyms
RCU state (excluded): base=ffffffff82970100 len=500
loading testcase
initializing kcov
collecting A-B coverage
dup(5) = 6 (success)
testing candidates
dup(5) = -1 (Bad file descriptor)
dup(5) = -1 (Bad file descriptor)
dup(5) = -1 (Bad file descriptor)
dup(5) = 5 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
dup(5) = 6 (success)
stats: injection-failed:0 wait-timeout:7 reordered:4
sh-5.3#
```
## 演示:GUI
这是我在同一测试用例上使用GUI手动强制`dup(7)`返回`7`顺序的示例。首先启动GUI,然后在客户机运行测试用例:
```
sh-5.3# ./kcov-vsock-client testcase/demo-dup-vs-close.so
dup(7) = 8 (success)
```
此时未执行任何顺序约束;`dup()`和`close()`随机竞争。GUI显示执行顺序:当前视图仅显示两个线程的函数调用图(线程1黑色缩进,线程2红色缩进)。此次`close()`系统调用恰在`dup()`之后执行。普通函数显示为黑色;内联函数显示为绿色,但仅当其调用普通函数时显示(因未勾选"显示所有内联函数")。勾选"过滤至通信点"显示蓝色内存访问,这些是通信点(如前文所述:读取位置...
相似文章
@zxlzr: 介绍 MemTrace:让 LLM 记忆系统终于可调试 记忆正在成为AI智能体的核心组成部分。但…
MemTrace 是一个新工具,通过跨多轮追踪记忆操作,使LLM记忆系统变得可调试,解决了当前记忆增强型智能体的黑箱问题。
Windows堆栈限制检查回顾,后续
Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。
Linux 内核 __ptrace_may_access() 函数的逻辑漏洞 (CVE-2026-46333)
Qualys 披露了 Linux 内核 __ptrace_may_access() 函数中的一个逻辑漏洞 (CVE-2026-46333),可导致本地权限提升和信息泄露。该漏洞自 2016 年起存在,影响多个发行版,Qualys 已开发出四种概念验证利用代码。
Linux/x86-64上使用内存间接调用(徒劳?)的系统调用检测,第一部分
一篇技术博文,讨论在Linux/x86-64上检测系统调用的技术,包括指令双关、E9Patch、zpoline以及短指令修补的挑战。
大多数多智能体内存设计中隐藏的竞态条件
一位生产环境工程师描述了多智能体共享内存中的并发写入引发的竞态条件,并解释了如何通过切换到带投影的仅追加事件日志来解决该问题,同时指出了读己之写延迟方面的权衡。