警惕无处不在的差异表示:编译器优化中的隐藏风险

本文探讨编译器优化导致的源代码变量差异表示现象,分析SQLite漏洞案例,介绍使用Binary Ninja和CodeQL的检测方法,并提供预防建议。差异表示可能将良性整数溢出转化为可 exploited 漏洞。

警惕!差异表示无处不在!

Trail of Bits 最近发布了一篇博客文章,讨论了某些版本 SQLite 中存在的一个有符号整数溢出漏洞,该漏洞可能导致任意代码执行和拒绝服务。在针对该漏洞开发概念验证利用程序时,我们注意到编译器对一个重要整数变量的表示在程序的不同部分存在语义差异。这些差异导致变量溢出时产生不一致的解释,我们称之为“差异表示”。一旦发现一个例子,我们尝试寻找更多——并发现差异表示在编译的 C 代码中实际上相当常见。

这篇博客文章探讨了编译器优化产生的同一源代码变量的差异表示。我们将尝试定义差异表示,并查看我们发现的 SQLite 漏洞,该漏洞由于源代码变量(表现出未定义行为)的差异表示而更容易被利用。然后,我们将描述用于在现有开源代码库中查找更多差异表示的二进制和源代码分析方法。最后,我们将分享一些消除程序编译时产生差异表示风险的建议。

一个简单例子

以下是一个现实生活中的代码模式示例,可能导致差异表示:

1
2
3
4
5
int index_of(char *buf, char target) {
    int i;
    for (i=0; buf[i] != target; i++) {}
    return i;
}

index_of 函数接收一个字符数组作为输入,循环遍历数组并递增 i,直到遇到第一个目标字符,然后返回该目标字符的索引。人们可能期望 buf[index_of(buf, target)] == target,但该语句的评估可能取决于编译器的优化级别。更具体地说,它可能取决于编译器处理未定义行为的方式,当 i 的值超过最大正整数值(INT_MAX,即 0x7fffffff)时。

如果目标字符出现在缓冲区的前 INT_MAX 字节中,假设平台使用 32 位整数,该函数将表现出明确定义的行为。如果函数扫描数组的前 INT_MAX 字节而未找到目标字符,i 将递增超过 int 类型可表示的最大正值,这是未定义行为。

那么编译器会如何处理那段代码——即在运行时可能表现出有符号整数溢出的代码?当然,因为有符号整数溢出是未定义行为,编译器可以选择做任何事情,包括产生“鼻恶魔”。那么这是一个关于期望的问题:我们期望一个合理的编译器做什么?如果 i 递增超过 INT_MAX,我们期望 index_of 尝试从内存中读取字符的位置是哪里?

我们可能期望编译器做出两个看似合理的选择之一:

  1. i 表示为有符号 32 位值,导致 iINT_MAX(正值,表示为 0x7fffffff)回绕到 INT_MIN(负值,表示为 0x80000000),在这种情况下,函数将从 buf[INT_MIN] 作为负数组索引读取下一个字节
  2. i 表示为无符号 64 位值,导致 i 递增到无符号值 0x80000000,函数从 buf[0x80000000ul] 读取下一个字节,这是内存中下一个连续的字节

在任一情况下,如果下一个读取的字符是目标字节,index_of 函数将返回 (int) 0x80000000,即 INT_MIN(负数)。然而,在情况 2 中,检查目标字符的内存位置不会是 buf[INT_MIN]。换句话说,如果编译器选择将 i 表示为无符号 64 位值,表达式 buf[index_of(buf, target)] == target 将不成立——而这正是 Clang 在优化级别 -O1 及以上编译 index_of 的方式:

1
2
3
4
5
6
7
8
9
index_of(char*, char):              # @index_of(char*, char)
        mov     eax, -1
.LBB0_1:                            # =>This Inner Loop Header: Depth=1
        inc     eax
        lea     rcx, [rdi + 1]
        cmp     byte ptr [rdi], sil
        mov     rdi, rcx
        jne     .LBB0_1
        ret

这是同一源代码变量 i 的差异表示示例。函数返回的 i 值由 32 位 eax 寄存器上的加法(inc)表示,而用于访问数组缓冲区的 i 值由 64 位 rdi 寄存器上的加法(lea)表示。源代码没有区分这两个版本的 i,因为程序员可能期望用于索引缓冲区的值与函数返回的值相同。然而,如我们所示,情况并非如此。

差异表示如何出现?

编译器可以对程序应用优化以提高程序性能。编译器必须确保对明确定义输入的操作的正确性,但它们可以采取任意自由措施来加速未定义行为的执行。例如,为了在 64 位平台上优化代码,编译器可以用 64 位加法替换 32 位加法,因为在 32 位平台上的加法定义行为在 64 位平台上也是定义行为。

当编译器应用程序优化导致单个源变量在输出程序中以不同语义表示时,就会出现差异表示。我们观察到的差异表示实例都源于未定义行为(特别是有符号整数溢出)。由于程序员不应编写具有未定义行为的程序,有人可能认为差异表示不是问题。然而,我们断言即使在未定义行为的情况下,程序也应对相同值有一致的解释。

我们发现的差异表示出现在符合以下模式的代码中:

  • 有符号整数变量在循环外声明。
  • 变量在循环中递增或递减,并允许溢出。
  • 变量在循环中用于访问数组。
  • 变量在循环外使用。

2011 年 LLVM 开发者邮件列表上的讨论提供了对可能溢出变量的表示以及溢出对优化影响的迷人见解。

出现了一个野生差异表示!

我们在尝试为 CVE-2022-35737 开发概念验证利用程序时发现了第一个差异表示,这是我们在 SQLite 中发现的一个漏洞。我们注意到,当使用 libsqlite3.so 的调试版本(编译时无优化)和执行优化版本时,我们的概念验证利用程序行为不同;我们发现这很有趣,因为它似乎暗示优化产生了同一库的语义不同编译。

我们通过反汇编两个版本的库并分析漏洞附近的代码来深入挖掘。编译代码的差异源于源代码,特别是 sqlite3_str_vappendf 函数:

1
2
3
4
5
6
7
8
9
806 int i, j, k, n, isnull;
    ...
824 k = precision;
825 for(i=n=0; k!=0 && (ch=escarg[i])!=0; i++, k--){
826   if( ch==q )  n++;
827   if( flag_altform2 && (ch&0xc0)==0xc0 ){
828     while( (escarg[i+1]&0xc0)==0x80 ){ i++; }
829   }
830 }

下图显示了优化二进制文件的反汇编版本:

在该代码片段中,用户输入缓冲区(escarg)被扫描以查找引号和 Unicode 字符。在指令 [1a] 处,r10 包含 escarg 的地址,rsi 用于索引到缓冲区以从中获取值;rsi 寄存器在前一条指令中设置,该指令对 32 位 edx 寄存器进行符号扩展。此索引操作对应于源代码第 825 行的 escarg[i] 表达式。随着每次循环迭代,edx 在指令 [1b] 处递增;因此,源代码变量 i 表示为有符号 32 位整数,并可用作 escarg 的负索引。

然而,指令 [2a] 显示了不同的情况:r10 仍然包含 escarg 的地址,但 rax+1 用于在内部循环中索引到缓冲区,以扫描 Unicode 字符(在源代码第 828 行的 escarg[i+1] 表达式中)。指令 [2b] 将 rax 作为 64 位值递增——且没有 32 位符号扩展——然后循环回 [2a]。此版本的 i 表示为 64 位无符号整数,因此当 i 超过最大 32 位有符号整数值(0x7fffffff)时,其下一次内存访问将在 escarg+0x80000000 处。

利用程序通过利用第 828 行 i 的不同语义工作;这些语义导致 i 在溢出时回绕到特定的较小正值,因此它不会在第 825 行用作 escarg 缓冲区的负索引。关于利用程序的详细信息在我们关于该漏洞的博客文章和我们的概念验证利用程序中提供。

寻找更多差异表示

在流行代码库中发现差异表示后,我们开始思考,“这是个别现象吗?我们能在其他项目中找到差异表示吗?”我们尝试了两种方法来识别其他潜在的差异表示,并在 SQLite 和 libxml2 中找到了更多例子。

自底向上(编译二进制)搜索

在第一次尝试寻找更多差异表示时,我们采取了“自底向上”的方法,直接查看编译的二进制文件。我们编写了一个 Binary Ninja 脚本,该脚本对差异表示的编译模式进行建模,并利用 Binary Ninja 的中级中间语言(MLIL)静态单赋值(SSA)形式提供的抽象。我们扫描了每个函数的 MLIL 表示中的所有指令,寻找符合以下条件的任何 Phi 节点:

  • 使用由 Phi 节点的定义变量定义的变量(表明节点可能影响循环控制变量)
  • 定义在向下转换操作中使用的变量(因此在别处表示为较窄的值)
  • 使用分配了多个大小的变量(即,可能表示为 64 位或 32 位的变量)
  • 定义在后续 64 位操作中使用的变量

如果 Phi 节点匹配所有这些条件,我们将其标记为潜在差异表示源,并将其打印到 Binary Ninja 控制台终端以供调查。

我们的脚本在 SQLite 和 libxml2 中都发现了额外的潜在差异表示,包括 libxml2 中的以下节点:

脚本在 libxml2.so 扫描中识别的前五个 Phi 节点

脚本还识别了上图中未显示的以下 Phi 节点:

1
xmlBuildURI@0x7b6c0: rax_33#51 = ϕ(rax_33#50, rax_33#52)

addr2line 实用程序表明此部分二进制文件对应于 libxml2/uri.c:2085 中的 xmlBuildURI 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
   2084 while (bas->path[cur] != 0) {
    2085     while ((bas->path[cur] != 0) && (bas->path[cur] != '/'))
    2086         cur++;
    2087     if (bas->path[cur] == 0)
    2088           break;
    2089
    2090     cur++;
    2091     while (out < cur) { 2092 res->path[out] = bas->path[out];
    2093           out++;
    2094     }
    2095 }

此代码模式似乎与原始 SQLite 代码中的模式相似。但请注意,编译具有差异表示的代码不一定可达,即使使用未定义输入。例如,如果无法将整数 cur 推进到超出 32 位整数的可接受值,上述代码片段中整数的语义将不会发散。

不出所料,当我们在无优化(级别 -O0)编译的库版本上运行脚本时,我们没有找到任何差异表示。该结果验证了我们对差异表示由编译器优化引起的理解。

自顶向下(源代码)搜索

我们还执行了“自顶向下”搜索,寻找在编译优化时可能产生差异表示的源代码模式。

我们使用 CodeQL 创建源代码查询。这些查询识别符合以下条件的源代码:

  • 变量在循环外声明。
  • 变量在循环体中递增。
  • 变量用于在循环体中的语句中访问内存。
  • 变量在循环后再次使用,在循环体外。

我们还使用额外的可选条件运行 CodeQL,查询变量在循环中的条件语句中用于访问内存的情况,而不仅仅是在其体中。这通过消除循环条件阻止变量溢出的情况减少了误报数量。(例如,如果 i 在循环条件 i < 10 中使用,它不会溢出,但如果循环条件是 buf[i] != xi 可能溢出。)

CodeQL 在 libxml2 中找到了 20 个可能产生差异表示的代码模式,其中两个(在 xmlBuildURI 中)也被 Binary Ninja 识别。

请注意,我们的自顶向下和自底向上搜索识别了可能存在差异表示的代码;程序语义中的实际差异仍然需要导致未定义行为的输入。

防止编译程序中的差异表示

防止差异表示的最佳方法是避免在程序中包含未定义行为。不过,这不是特别可操作的建议。如果我们建议程序员避免编写使用循环外声明变量的 forwhile 循环,那将更无帮助。

相反,程序员应使用不能溢出的数据类型用于计数或访问数组的变量(例如,使用 size_tuintptr_t 而不是 int)。他们还应该避免 C 程序员中不幸常见的做法:将错误条件与 int 函数的负返回值绑定(例如,使用返回值 -1 表示失败);假设大规模重构不可能,我们建议在这些情况下使用 ssize_t 而不是 int。最后,程序员应避免对程序在响应未定义行为时会做什么做出任何假设。

结论

我们不能做出全面声明评估与差异表示相关的风险。一些基本上不可达的差异表示可以被视为未定义行为的好奇心——一个来源 C 编程琐事问题,会难倒你的朋友。其他可能更有后果,将原本良性的整数溢出转化为可 exploited 漏洞,如我们的 SQLite 漏洞案例。我们的希望是通过描述现象并使程序员能够在出现时识别差异表示,我们可以帮助社区准确评估其严重性。

我要感谢我的导师 Peter Goodman,在我与 Trail of Bits 的暑期实习期间,他在追求漏洞和奇怪编译器行为方面提供了专家指导。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容 一个简单例子 差异表示如何出现? 出现了一个野生差异表示! 寻找更多差异表示 自底向上(编译二进制)搜索 自顶向下(源代码)搜索 防止编译程序中的差异表示 结论 近期文章 Trail of Bits 的 Buttercup 在 AIxCC 挑战赛中获得第二名 Buttercup 现已开源! AIxCC 决赛:磁带故事 攻击者的提示注入工程:利用 GitHub Copilot 作为新员工发现 NVIDIA Triton 中的内存损坏 © 2025 Trail of Bits。 使用 Hugo 和 Mainroad 主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计