文章总结: 该议题揭示C语言源码中看似安全的快照模式在编译器优化下可能重新加载不可信内存,形成Schrödinger’sTOCTOU漏洞。安全审计不能仅依赖源码,需结合构建产物验证,并建立二进制级安全契约。 综合评分: 85 文章分类: 漏洞分析,二进制安全,安全工具,安全开发,实战经验
Black Hat USA 2026:C语言:源码只是建议
原创
Max Luo Max Luo
白帽子罗棋琛
2026年9月30日 08:18 广东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
C 语言及其后果:源码只是建议
Black Hat USA 2026 议题笔记:C and Its Consequences: The Source Is Just a Suggestion
安全审计通常把 C 源码当作程序行为的权威描述:某个不可信长度只读取一次,保存到局部变量,完成边界检查,后续 memcpy() 也使用这个局部变量,于是 TOCTOU 窗口被关闭。
问题是,CPU 不执行源码。优化编译器只需要保证 C 抽象机要求的可观察结果,不需要保留开发者写下的加载次数。在寄存器压力、结构体复制、类型宽度变化或特定指令选择下,编译器可能删除局部副本,在使用点重新从攻击者可修改的内存加载值。
图 1:C and Its Consequences 讨论源码意图、C 规范与最终机器行为之间的偏移
材料将这类现象称为“Schrödinger’s TOCTOU”:只看源码时,它既安全又不安全;直到确定编译器、版本、架构、优化参数和最终二进制,才能知道危险的第二次加载是否出现。
这不是“关闭优化就安全”的简单问题。公开课件测试了跨二十年、多个工具链与多种架构的海量组合,结论是:是否重读可能被一个字段顺序、一字节结构体大小、-mtune 目标或编译器小版本改变。安全保证必须进入构建产物验证,而不能停在 Code Review。
1、一个看似普通的 TOCTOU
课件从一个 Kernel Handler 开始。packet_t 位于用户空间,Kernel 先检查 length,再用它复制数据:
c
typedefstruct {unsignedint length; uint8_t data[]; } packet_t; uint8_t buffer[MAX_SIZE]; intreceive(packet_t *pkt)/* pkt 指向用户可修改内存 */ { if (pkt->length > MAX_SIZE) { return-1; } memcpy(buffer, pkt->data, pkt->length); return0; }
图 2:检查和使用分别读取 pkt->length,攻击者可在两次访问之间修改它
漏洞很明确:第一次加载得到合法长度,检查通过;第二次加载前,另一线程或另一个处理器把共享页中的长度改大;memcpy() 随即越界写入固定 Buffer。
常规修复是先读取到局部变量,之后只使用快照:
c
intreceive(packet_t *pkt) { unsignedint local_length = pkt->length; if (local_length > MAX_SIZE) { return-1; } memcpy(buffer, pkt->data, local_length); return0; }
图 3:源码中的修复把不可信长度读取一次,再检查并使用局部副本
从源码语义看,攻击者无法再改变 local_length。但这段代码没有告诉编译器“读取次数具有安全意义”,也没有声明目标内存可能被异步修改。局部变量只是表达计算关系,不保证最终一定驻留寄存器或 Stack,更不保证后续不重新物化。
2、C 的 as-if 规则只承诺结果,不承诺过程
C 标准描述的是抽象机。编译器可以删除、合并、重排或重新生成任何不影响可观察行为的操作,只要真实机器的结果与抽象机“看起来一致”。文件写、外部调用和 volatile 访问等属于可观察行为,普通内存到底读一次还是两次,通常不在承诺范围内。
图 4:真实机器只需复现抽象机的可观察行为,不需要复制源码中的执行方式
这段代码还包含更根本的语言模型问题:如果攻击者或并发线程在没有同步的情况下修改普通 C 对象,而当前线程同时读取它,程序可能存在 Data Race,C 内存模型不会替开发者保留直觉中的“一致快照”。对于 MMIO、DMA、共享内存、用户映射页和 Hypervisor Guest Memory,外部修改更不一定能用普通 C 并发语义准确表达。
因此,下面三个概念必须分开:
text
源码 Data Flow:local = external->field;check(local);use(local) 语言保证:编译器是否必须只访问 external->field 一次 机器行为:目标二进制在当前架构和优化参数下实际发出几次 Load
静态分析大多证明第一层;安全边界需要第二层;线上运行的是第三层。
3、重新加载有时是更便宜的优化
“编译器为什么要把寄存器里的值丢掉,再访问一次更慢的内存?”课件给出的触发条件之一是寄存器压力。
如果所有通用寄存器都在使用,保留局部值需要先 Spill 到 Stack,之后再 Load 回来,相当于一写一读。若原地址仍可寻址,编译器可能认为直接重读只需要一次 Load,成本更低。第二次加载并不是编译器放弃优化,恰恰可能是 Register Allocator 的优化选择。
图 5:当寄存器耗尽时,重读原地址可能比保存局部变量再恢复更便宜
一个最小形态如下:
c
externconstint x; externintopaque(int); int y, w; intg(int a) { int t = x; /* 源码只读取一次 x */ w = t; /* 多个不可内联调用制造寄存器压力。 */for (int i = 0; i < 12; ++i) { a = opaque(a); } y = t; /* 机器码可能再次从 x 加载 */return a; }
在单线程、x 确实不变的普通程序中,两次加载与一次加载结果相同,优化完全透明。在 x 实际由 Guest、设备、其他 CPU 或不可信用户映射修改时,第二次 Load 才变成安全问题。
材料强调,“Compiler-invented Load”本身不是漏洞。漏洞产生于源码依赖稳定快照完成权限、长度、地址或状态验证,而编译产物又回到不可信位置取值。
4、Schrödinger’s TOCTOU 是构建属性,不是源码属性
课件将这种情况定义为一种新的 TOCTOU:源码中没有第二次读取,Compiler 在合法优化中引入了它;必须真正编译才能判断。
图 6:危险行为不直接出现在源码中,是否发生由具体编译结果决定
这里的“Schrödinger”不是说所有 Snapshot 都必然有漏洞,而是说源码审计无法给出稳定的二元答案。例如同一个结构体复制:
c
typedefstruct {int target; int rest[33]; } snapshot_t; snapshot_t dst; voidprocess(snapshot_t *untrusted) { snapshot_t local = *untrusted; if (local.target > 0) { dst = local; } }
当 rest[33] 令结构体大小为 136 字节时,某个构建只读取一次 target;改成 rest[34]、结构体大小变成 140 字节,向量化 Bulk Copy 与标量条件读取发生重叠,目标字段可能被加载两次。
图 7:仅增加一个整数字段,就可能让相同源码形态从单次读取变成两次读取
这类差异不会被 Source Diff 识别为安全变化。字段顺序、尾部 Flexible Array、对齐填充、ABI、LTO 和 CPU Tune 都可能在没有业务语义变化的情况下改变 Load 形态。
5、影响因素横跨整个编译链与指令集
手工逆向阶段找到约 50 类最小触发形态(课件称 Cat-state),至少涉及六个相互独立的编译器子系统。是否重载取决于:
- Compiler、版本、Architecture 与 Flags;
- Register Pressure 和函数内联;
- Struct Layout、字段位置、大小和对齐;
- 类型宽度、有符号/无符号扩展;
- Float/Int Union 和 Byte-order Conversion;
- Auto-vectorization 与 Bulk Copy;
- Sub-word Atomic;
- CISC Memory-operand Fold。
图 8:加载次数是 Compiler、代码形态、架构和优化参数共同作用的结果
研究覆盖 GCC、Clang、ICC、ICX、MSVC,并在 x86-64、i386、ARM、AArch64、MIPS、RISC-V、PPC64、SPARC 等大量架构上发现不同触发形态。
图 9:Rematerialization、宽度不匹配、Bulk/Scalar 重叠等机制跨工具链存在
因此,下面这些结论都不可靠:
text
“GCC 没问题,只有 Clang 会这样” “x86-64 寄存器多,不会重新加载” “当前 -O2 安全,升级小版本也安全” “Intel 构建验证过,AMD 的 -mtune 结果相同” “结构体只加了 Padding,不需要安全回归”
课件甚至给出同一源码在 -mtune=generic 与 -mtune=znver4 下分别单读、双读的例子。构建机 CPU、交叉编译配置和发行版默认 Flags 都必须进入威胁模型。
6、Alpha-lab 用语义 Fuzzing 逆向编译器边界
靠人工阅读汇编无法覆盖组合爆炸。课件搭建的 Alpha-lab 包含四个阶段:Mutation Engine、Matrix Runner、Load Detector 和 Flag Minimizer。
图 10:从最小 Cat-state 生成变体,跨矩阵编译,检测重复 Load,再缩减触发参数
具体工作量包括:
- 在本地 Compiler Explorer 环境保存约 200 GB 工具链;
- 使用 5 类工具链、200 多个版本和约 20 年 Release;
- 覆盖多种 Architecture 与 Optimizer/LTO/PIC/PIE/Stack Flags;
- 每个输入执行 10,000 次以上编译;
- 对 100 万以上输出用 Unicorn 跟踪指定变量的 Memory Access;
- 用 Delta Debugging 从约 250 个优化参数中缩小最小触发集合。
企业不需要复制完整研究平台,但可以为关键 Snapshot Pattern 建一个小型构建矩阵。最基础的 CI 任务如下:
bash
set -eu for cc in gcc clang; dofor opt in -O2 -O3 -Os; do output="artifacts/${cc}_${opt#-}.s""$cc""$opt" -S -fverbose-asm \ -o "$output" tests/untrusted_snapshot.c donedone
产物不能只上传不看。应为安全关键函数维护“允许访问哪些不可信内存、最多几次”的 Contract,并用反汇编分析器或动态 Emulator 计算实际读取次数。
一个简化的 Build Manifest 可以固定审计范围:
yaml
security_build_contract:function:receiveuntrusted_object:packet.lengthmax_external_loads:1architectures:-x86_64-aarch64compilers:-name:gccversion:"14.3"flags: ["-O2", "-flto"] -name:clangversion:"19.1"flags: ["-O2", "-flto"] fail_on:-duplicate_external_load-missing_bounds_branch-copy_length_not_derived_from_snapshot
7、真实攻击面集中在“快照—验证—使用”
研究的审计模式不是全局搜索所有普通 Load,而是先找不可信边界:Guest Memory、User Pointer、DMA Descriptor、Network/Wire Header、Shared Ring、Firmware Mailbox、Enclave Buffer。随后追踪三段式路径:
text
Snapshot:从攻击者可改内存读取字段 Validate:对快照做边界、权限或状态检查 Use:把值传到内存复制、映射、索引、地址计算或控制寄存器
如果 Use 阶段可以被 Compiler 重新关联回原始 Pointer,而且中间没有强 Barrier,这个 Pattern 就应进入二进制验证队列。
课件在 100 多个安全关键项目中找到 300 多处 Schrödinger TOCTOU 候选。
图 11:自动化审计覆盖 100 多个项目,并发现 300 多个候选 Pattern
材料列出的影响类别包括 VM Escape、Root 权限、Platform Persistence、Root of Trust 和 Enclave Breach。这里需要保持边界:候选 Pattern 不等于每个构建都存在可利用漏洞;攻击者还要能并发修改原始内存、命中两次读取窗口,并让新旧值组合形成安全影响。
但修复决策不应等到生产 Compiler 恰好输出双 Load。课件建议把 C 规范允许重载、又依赖稳定快照的形态视为 De facto Vulnerable,因为一次工具链升级就可能把安全构建切换为危险构建。
8、短期 Barrier 有效,但都容易静默退化
课件列出的现实控制包括:在精确访问点使用 volatile;使用 Atomic 固定并排序访问;asm volatile("" ::: "memory");Opaque Copy;Copy 后 Unmap;Read-only Mapping;以及在 Confidential Computing 中使用每租户加密隔离。
图 12:不同 Barrier 从语言、Compiler、映射与系统架构层约束不可信内存读取
最小修复可以把不可信加载显式成 READ_ONCE 风格操作:
c
#define READ_ONCE_U32(ptr) \ (*(volatile const uint32_t *)(ptr))intreceive(packet_t *pkt) { uint32_t local_length = READ_ONCE_U32(&pkt->length); if (local_length > MAX_SIZE) { return-1; } memcpy(buffer, pkt->data, local_length); return0; }
这段代码只表达“该访问必须发生一次”,不是通用同步原语。它不保证 pkt->data 在复制期间不变,不提供 CPU Memory Ordering,也不能替代平台提供的 copy_from_user()、MMIO Accessor 或 DMA API。
更稳妥的 Kernel 风格是用 Architecture 维护的 Accessor 把 Header 复制到 Kernel-owned Memory,再验证快照:
c
intreceive_from_user(constpacket_t __user *user_pkt) { uint32_t length; if (get_user(length, &user_pkt->length)) { return -EFAULT; } if (length > MAX_SIZE) { return -EINVAL; } if (copy_from_user(buffer, user_pkt->data, length)) { return -EFAULT; } return0; }
即便如此,也要检查 Accessor 是否在当前平台和 LTO 配置下保持所需语义。volatile 资格可能在传给 Non-volatile Parameter 时丢失;Barrier 位置会随重构漂移;Opaque Function 可能因 LTO 被内联;Atomic 则要求对象、对齐和所有访问方都符合内存模型。
图 13:现有控制大多需要人工标记、缺乏验证,并可能随代码演进静默失效
9、把二进制安全回归加入 CI
源码 SAST 仍然有用:它适合找到 Snapshot—Validate—Use 候选,并识别原始 Pointer 是否流到 Leaf Function。但最终门禁必须检查真实 Release Artifact,而不是 Debug Build 或 Compiler Explorer 中的近似片段。
推荐使用三层验证:
第一层是构建可复现性:记录 Compiler Digest、版本、Target Triple、完整 Flags、Linker、LTO/PGO 配置、Sysroot 和生成环境。SBOM 只记录依赖不够,还要有 Build Provenance。
第二层是函数级汇编 Contract:对安全关键函数生成稳定的符号边界,提取反汇编,检测对 Guest/User/MMIO 地址的重复 Load,并验证 Bounds Check 支配最终 Use。
第三层是动态竞争测试:在 Emulator、Hypervisor Test Harness 或 Device Mock 中,让攻击方在 Check 与 Use 窗口持续翻转字段,验证最终 Copy/Map 长度始终来自受信 Snapshot。
一个小型的反汇编回归脚本可以先对符号片段做 Hash,任何变化都触发人工审查:
python
import hashlib import pathlib import subprocess import sys defsymbol_disassembly(binary: pathlib.Path, symbol: str) -> bytes: result = subprocess.run( ["objdump", "-d", "--disassemble=" + symbol, str(binary)], check=True, capture_output=True, ) return result.stdout binary = pathlib.Path(sys.argv[1]) symbol = sys.argv[2] body = symbol_disassembly(binary, symbol) digest = hashlib.sha256(body).hexdigest() print(f"{symbol}{digest}")
Hash 变化不等于漏洞,Hash 不变也不证明安全;它的作用是阻止工具链升级后机器行为无声变化。更成熟的实现应规范化地址和符号,再对 Load/Data-flow 做语义比较。
CI 门禁还应包含一次“旧 Compiler 与新 Compiler”的差异构建。Compiler 或 Flags 更新不能被归类为普通依赖升级,它可能改变安全边界处的内存访问次数。
10、工程结论:安全属性属于源码、工具链和产物的组合
Alpha-lab 的结论很直接:无法仅凭源码预测结果。是否安全可能取决于哪台机器执行编译、Compiler Minor Version、Struct Size 对 16 取模、Field Order 或一次优化器调整。
图 14:没有单一开关可以禁止所有重载,搜索空间跨越整个生态
图 15:可利用性是 Compiler、版本、Architecture 与 Flags 的涌现属性
对 Kernel、Hypervisor、Firmware、Driver、Crypto Library 和 Enclave Runtime,建议建立以下制度:
- 把所有攻击者可写共享内存标成独立 Trust Domain;
- 禁止直接跨多层函数传递原始 User/Guest/MMIO Pointer;
- 用平台 Accessor 把安全相关字段复制到 Owned Snapshot;
- 对 Snapshot—Validate—Use Pattern 做专项 SAST;
- 对 Release Binary 做重复 Load 与 Control-flow Contract 检查;
- Compiler、Architecture、
-mtune、LTO 或 PGO 变化必须触发安全回归; - 保留多架构构建矩阵,不用单个 x86-64 结果外推全部 Target;
- 对无法证明的 Pattern,按可能发生二次 Load 设计,而不是按当前恰好单读设计。
课件提供的最小示例更能说明问题:源码把 *p 赋给 short t 一次,但 ARM GCC -O2 分别发出无符号半字加载 ldrh 与有符号半字加载 ldrsh,同一地址被读取两次。
图 16:类型解释差异让 Compiler 从同一地址分别执行有符号和无符号读取
原始资料:
- Black Hat 官方 Session 页面
源码仍然是审计入口,但不再是安全结论。只要不可信值参与长度、权限、地址、索引或状态判断,评审就必须一直走到 Release Binary:确认读了几次、从哪里读、检查是否支配使用,以及这项保证在下一次工具链升级后是否仍然成立。
原始会议材料(仓库内)
- 演讲课件 PDF
开源资料与原始议题 PDF
本文对应的 Markdown 原稿、Black Hat 原始议题 PDF 与配图已整理到 GitHub,可按文章编号查找和下载。
https://github.com/cybermaxluo/black-hat-usa-2026-talks
也可以点击文末“阅读原文”进入仓库。欢迎 Star、提交 Issue 或参与勘误。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:白帽子罗棋琛 Max Luo Max Luo《Black Hat USA 2026:C语言:源码只是建议》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论