CodeQL C++ 0.6.1 版本新增检测能力深度解析:cpp/double-free 与 cpp/use-after-free 内存安全查询
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()
其原理可以拆解为四步:
- 两次释放构成一对源点与汇点:查询首先定位两个"进入解分配函数的指针"节点(
isFree),第一次释放dealloc作为数据流起点(source),第二次释放e2作为汇点(sink); - 路径连通性验证:借助
FlowFromFree模块(定义于 FlowAfterFree.qll),从第一次释放的指针出发,沿数据流路径跟踪到第二次释放,证明同一条执行路径上同一指针确实被释放两次,而非两个互斥分支各自释放; - 路径图输出:
@kind path-problem与DoubleFree::PathGraph使得结果以**完整漏洞路径(source → sink)**形式呈现,便于开发者定位两次释放之间的全部中间语句; - 误报抑制:
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 目录下,方便对照学习。
实战建议与总结
- 优先修复带路径证据的结果:两条查询输出均为 path-problem 格式,含完整释放→复用的数据流路径。修复时应沿路径逐一确认每一次释放/使用的执行条件,而不是只看告警行。
- 释放后置空是低成本高收益的防御:无论 double-free 还是 UAF,在释放后立即将指针赋值为
nullptr并在后续使用前判空,都能显著压缩漏洞面,这与 DoubleFree.qhelp 的官方建议一致。 - 注意查询的已知盲区:从测试标注可知,判空后的使用(
use_if_nonzero)、间接别名路径等场景在当前实现下仍可能漏报;同时字段级释放-重分配可能产生假阳性。高价值代码建议结合experimental/Security/CWE/CWE-415实验性查询与人工代码审计交叉验证。 - 版本演进线索:0.6.1 中这两条查询的默认精度为 medium,但当前仓库源码已将两者提升为 high 且安全严重性评级 9.3(DoubleFree.ql),说明该检测框架仍在持续强化,建议使用较新版本的 CodeQL 查询包以获得更优精度。
通过 FlowAfterFree.qll 这一共享数据流内核,cpp/double-free 与 cpp/use-after-free 在 0.6.1 之后形成了统一的"释放流分析"体系:同一套状态机框架,一个以"再次释放"为汇点,一个以"再次使用"为汇点,共同覆盖了指针生命周期中最危险的两类越界复用。