CodeQL C++ 0.6.1 版本新增检测能力深度解析:cpp/double-free 与 cpp/use-after-free 内存安全查询

原创2026-10-05 16:22:271,900 阅读
文章标签:静态分析应用安全SAST代码质量

CodeQL C++ 0.6.1 版本新增检测能力深度解析:cpp/double-free 与 cpp/use-after-free 内存安全查询

本文围绕 CodeQL C++ 查询包 0.6.1 版本变更笔记(cpp/ql/src/change-notes/released/0.6.1.md)展开,深入讲解本次发布的两项核心能力:新增的 cpp/double-free 双重释放检测查询,以及被现代化重构并正式赋予 "medium" 精度的 cpp/use-after-free 释放后使用查询。读完本文,你将理解这两类内存安全缺陷的检测原理、查询在仓库中的实现位置与数据结构、典型误报与漏报边界,以及如何在真实 C/C++ 项目中落地运行并解读结果。

0.6.1 版本概览:聚焦指针生命周期安全

CodeQL C++ 查询包的 0.6.1 版本是一个聚焦于指针生命周期(pointer lifetime)安全的小型功能性发布。变更笔记全文仅包含两项内容,但两项都指向同一类高危缺陷:已释放内存(freed memory)的非法复用。

变更项 查询 ID 查询名称 精度(precision) 说明
新增查询 cpp/double-free Potential double free medium 检测同一指针被解分配(deallocation)两次的路径
现代化重构 cpp/use-after-free Potential use after free medium 检测指针在内存被释放后仍被解引用的路径

需要特别说明的是,变更笔记中记录的是发布时点的默认精度配置。而查看当前仓库中的最新源码,这两条查询的元数据精度已被提升为 high,安全严重性评级均为 9.3(DoubleFree.ql、UseAfterFree.ql)。这反映了该检测框架自 0.6.1 之后持续迭代、精度不断改善的事实;下文对实现原理的剖析均基于当前仓库源码。

cpp/double-free:双重释放检测查询

漏洞本质与危害

双重释放(double-free)指同一块动态分配的内存被解分配函数释放两次及以上。当前仓库的查询帮助文档(DoubleFree.qhelp)明确指出其危害链条:

  • 释放行为不止一次会破坏内存分配器(allocator)内部的链表与元数据结构;
  • 攻击者可利用被破坏的分配器状态覆写任意内存地址,从而将双重释放升级为任意代码执行或信息泄露;
  • 即便无法被利用,双重释放也几乎必然导致程序崩溃(拒绝服务)。

典型触发场景

从查询自带的示例(DoubleFreeBad.cpp)可以看到最经典的"同一变量二次释放"模式:

int* f() {
	int *buff = malloc(SIZE*sizeof(int));
	do_stuff(buff);
	free(buff);
	int *new_buffer = malloc(SIZE*sizeof(int));
	free(buff); // BAD: If new_buffer is assigned the same address as buff,
	             // the memory allocator will free the new buffer memory region,
	             // leading to use-after-free problems and memory corruption.
	return new_buffer;
}

这里的隐患在于:free(buff) 之后,分配器可能把同一块地址重新分配给 new_buffer;此时第二次 free(buff) 实际上释放的是 new_buffer 指向的新内存,造成跨变量的连锁破坏。修复方式即删除多余的 free(buff) 调用。

另一类更隐蔽的场景出现在异常处理路径中(DoubleFreeBad2.cpp):

void g() {
	MyTask *task = nullptr;

	try
	{
		task = new MyTask;

		...

		delete task;

		...
	} catch (...) {
		delete task; // BAD: potential double-free
	}
}

若 try 块中第一次 delete task 之后抛出异常,控制流会跳到 catch 分支再次执行 delete task,形成潜在双重释放。推荐修复是在首次释放后将指针置空:

delete task;
task = nullptr;

因为大多数解分配函数在解引用前会执行空指针检查,对空指针执行 free/delete 是无害的。

查询实现原理:基于释放流(FlowFromFree)的数据流分析

cpp/double-free 并非一个简单的"同一个变量出现两次 free 调用"的语法级检查,而是一个路径敏感的(path-sensitive)数据流分析查询。它的核心逻辑位于 DoubleFree.ql,关键结构如下:

module DoubleFreeParam implements FlowFromFreeParamSig {
  predicate isSink = isFree/2;
  predicate isExcluded = isExcludedMmFreePageFromMdl/2;
  predicate sourceSinkIsRelated = defaultSourceSinkIsRelated/2;
}

module DoubleFree = FlowFromFree<DoubleFreeParam>;

from DoubleFree::PathNode source, DoubleFree::PathNode sink, DeallocationExpr dealloc, Expr e2
where
  DoubleFree::flowPath(source, sink) and
  isFree(source.getNode(), _, _, dealloc) and
  isFree(sink.getNode(), e2)
select sink.getNode(), source, sink,
  "Memory pointed to by $@ may already have been freed by $@.",
  e2, e2.toString(), dealloc, dealloc.toString()

其原理可以拆解为四步:

  1. 两次释放构成一对源点与汇点:查询首先定位两个"进入解分配函数的指针"节点(isFree),第一次释放 dealloc 作为数据流起点(source),第二次释放 e2 作为汇点(sink);
  2. 路径连通性验证:借助 FlowFromFree 模块(定义于 FlowAfterFree.qll),从第一次释放的指针出发,沿数据流路径跟踪到第二次释放,证明同一条执行路径上同一指针确实被释放两次,而非两个互斥分支各自释放;
  3. 路径图输出:@kind path-problem 与 DoubleFree::PathGraph 使得结果以**完整漏洞路径(source → sink)**形式呈现,便于开发者定位两次释放之间的全部中间语句;
  4. 误报抑制:isExcluded 谓词专门排除 MmFreePagesFromMdl(Windows 内核驱动中的内存释放例程)等已知的"释放后允许复用"模式,降低误报。

从源码看双自由检测的边界

  • 来源覆盖:isFree 在 FlowAfterFree.qll 中定义,覆盖 free、delete、delete[] 等解分配表达式(DeallocationExpr);
  • 字段级追踪:通过 defaultSourceSinkIsRelated(FlowAfterFree.qll)维护源点与汇点之间的相关性约束,确保数据流跨结构体字段、局部变量别名时仍然成立;
  • 与 CWE 对应:查询元数据中标注 external/cwe/cwe-415,对应 CWE-415 "Double Free"。仓库中另有实验性版本 experimental/Security/CWE/CWE-415/DoubleFree.ql 及配套 qhelp,供需要更高覆盖面的场景参考。

cpp/use-after-free:现代化重构后的释放后使用检测

漏洞本质与危害

释放后使用(use-after-free,UAF)指通过一个指针访问已被归还给动态内存管理器的内存。当前仓库的查询帮助文档(UseAfterFree.qhelp)概述其后果:从段错误(segfault)到内存损坏,再到后续内存管理调用行为异常,甚至可被利用为安全漏洞。UAF 与 double-free 常常互为因果——如上面的 DoubleFreeBad.cpp 示例所示,双重释放本身就会诱发对其他变量的 UAF。

典型触发场景

查询自带的示例(UseAfterFree.cpp)展示了"错误处理路径释放、后续仍使用"的经典模式:

void f() {
	char* buf = new char[SIZE];
	...
	if (error) {
		delete buf; //error handling has freed the buffer
	}
	...
	log_contents(buf); //but it is still used here for logging
	...
}

错误分支释放了 buf,但主路径在释放之后仍将 buf 传给 log_contents 使用,构成 UAF。修复方案是保证所有访问指针的执行路径都发生在释放之前,或在释放后将指针置空并增加判空保护。

0.6.1 的"现代化"体现在何处

变更笔记原文称 cpp/use-after-free 在 0.6.1 中"被现代化(modernized)并赋予 medium 精度"。对照当前仓库源码,这一现代化主要体现在检测框架的整体升级:

  • 同时导入新版数据流库 semmle.code.cpp.dataflow.new.DataFlow 与 IR 中间表示 semmle.code.cpp.ir.IR(UseAfterFree.ql),使分析建立在统一的 IR 三地址码之上,而非旧的 AST 语法树层面;
  • 与 cpp/double-free 一样,改用 FlowFromFree 状态机框架(DataFlow::StateConfigSig),实现跨语句、跨函数、跨字段的路径敏感追踪;
  • 释放后"使用"的汇点谓词 isUse 覆盖指针参与解引用、作为实参传入敏感调用、被读取等多种使用形态,具体实现在 FlowAfterFree.qll 中。

测试用例揭示的能力边界

仓库为该查询维护了完整的数据流测试套件:UseAfterFree/test.cpp(测试引用文件 UseAfterFree.qlref,期望结果见 UseAfterFree.expected)。测试用例清晰地标注了查询的检出能力与已知局限:

场景 用例 检出情况
释放后直接解引用 test1 中 use(data) 检出($ Alert)
释放后先判空再使用 test1 中 use_if_nonzero(data) 暂未检出(MISSING: Alert),属于已知漏报
释放后重新赋值再使用 test2 不报警(GOOD),正确处理"重分配后安全"场景
释放后置 NULL 再使用 test3 不报警(GOOD),正确识别空指针保护
别名指针访问已释放内存 test6 直接使用检出,间接别名路径暂未检出
循环内反复释放 test14 检出
delete 后调用成员方法 test11 检出
静态变量重新分配 regression_test_for_static_var_handling 不报警(GOOD),回归测试防误报

值得注意的是,测试中还包含两处刻意标注的假阳性(SPURIOUS: Alert):malloc_after_free 中通过结构体字段 s->i1.data、s->i2->data 释放后重新 malloc 再使用的场景(test.cpp)。这清晰地展示了该检测框架的诚实边界:字段级"释放-重分配"别名的区分仍是难点,需要结合更细粒度的点分析(points-to analysis)来进一步消解。仓库中的查询帮助文档也通过 pointsToWarning.inc.qhelp 引入了相关的警告说明(见 UseAfterFree.qhelp)。

如何在项目中运行这两条查询

方式一:通过 codeql 命令行按查询 ID 运行

在已经用 CodeQL CLI 创建好 C/C++ 数据库(例如 codeql database create 生成的 db 目录)后,可用 codeql database analyze 指定查询 ID 运行:

codeql database analyze <db> cpp/double-free cpp/use-after-free \
    --format=sarif-latest --output=results.sarif

这两条查询默认收录在 C++ 查询包(codeql-cpp)中,对应源码位于 cpp/ql/src 下的 Critical 目录(DoubleFree.ql、UseAfterFree.ql)。也可以直接传入查询文件路径进行定向分析。

方式二:作为安全查询套件的一部分

两条查询均带 security 与 reliability 标签,cpp/double-free 对应 CWE-415,cpp/use-after-free 对应 CWE-416,因而默认会纳入 codeql-cpp 安全套件(见 cpp/config/suites 下的套件配置)在完整扫描中执行。在 GitHub Advanced Security 的 code scanning 中,命中结果会以 warning 级别呈现,并附上完整的 source→sink 数据流路径。

方式三:本地查询测试与复现

仓库为这两条查询提供了可直接运行的测试基准。UAF 测试目录为 cpp/ql/test/query-tests/Security/CWE/CWE-416/semmle/tests/UseAfterFree,包含测试源码 test.cpp、测试引用 UseAfterFree.qlref 与期望输出 UseAfterFree.expected。可以在 CodeQL 开发环境中运行 codeql test run 复现测试结果,作为理解查询行为的第一手资料。double-free 的示例源码(DoubleFreeBad.cpp、DoubleFreeBad2.cpp)与修复示例(DoubleFreeGood.cpp、DoubleFreeGood2.cpp)则位于同一 Critical 目录下,方便对照学习。

实战建议与总结

  1. 优先修复带路径证据的结果:两条查询输出均为 path-problem 格式,含完整释放→复用的数据流路径。修复时应沿路径逐一确认每一次释放/使用的执行条件,而不是只看告警行。
  2. 释放后置空是低成本高收益的防御:无论 double-free 还是 UAF,在释放后立即将指针赋值为 nullptr 并在后续使用前判空,都能显著压缩漏洞面,这与 DoubleFree.qhelp 的官方建议一致。
  3. 注意查询的已知盲区:从测试标注可知,判空后的使用(use_if_nonzero)、间接别名路径等场景在当前实现下仍可能漏报;同时字段级释放-重分配可能产生假阳性。高价值代码建议结合 experimental/Security/CWE/CWE-415 实验性查询与人工代码审计交叉验证。
  4. 版本演进线索:0.6.1 中这两条查询的默认精度为 medium,但当前仓库源码已将两者提升为 high 且安全严重性评级 9.3(DoubleFree.ql),说明该检测框架仍在持续强化,建议使用较新版本的 CodeQL 查询包以获得更优精度。

通过 FlowAfterFree.qll 这一共享数据流内核,cpp/double-free 与 cpp/use-after-free 在 0.6.1 之后形成了统一的"释放流分析"体系:同一套状态机框架,一个以"再次释放"为汇点,一个以"再次使用"为汇点,共同覆盖了指针生命周期中最危险的两类越界复用。

登录后查看全文
codeql