注意!分歧表示无处不在!- Trail of Bits博客
Trail of Bits最近发布了一篇关于某些SQLite版本中存在有符号整数溢出漏洞的博客文章,该漏洞可能导致任意代码执行和拒绝服务。在为该漏洞开发概念验证利用程序时,我们注意到编译器对一个重要整数变量的表示在程序的不同部分存在语义差异。这些差异导致变量溢出时产生不一致的解释,我们称之为“分歧表示”。发现一个例子后,我们尝试寻找更多——结果发现分歧表示在编译的C代码中实际上相当常见。
这篇博客文章探讨了编译器优化产生的同一源代码变量的分歧表示。我们将尝试定义分歧表示,并查看我们发现的SQLite漏洞,该漏洞由于源代码变量(表现出未定义行为)的分歧表示而更容易被利用。然后,我们将描述用于在现有开源代码库中查找更多分歧表示的二进制和源代码分析方法。最后,我们将分享一些消除程序编译时分歧表示风险的建议。
一个简单示例
以下是一个可能导致分歧表示的真实代码模式的简单示例:
|
|
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
从内存中读取字符的位置是哪里?
我们可能期望编译器做出两种看似合理的选择之一:
- 将
i
表示为有符号32位值,导致i
从INT_MAX(表示为0x7fffffff的正值)回绕到INT_MIN(表示为0x80000000的负值),在这种情况下,函数将从buf[INT_MIN]
作为负数组索引读取下一个字节 - 将
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
的方式:
|
|
这是同一源代码变量i
的分歧表示示例。函数返回的i
值由32位eax寄存器上的加法(inc)表示,而用于访问数组缓冲区的i
值由64位rdi寄存器上的加法(lea)表示。源代码没有区分这两个版本的i
,因为程序员可能期望用于索引到缓冲区中的值与函数返回的值相同。然而,正如我们所展示的,情况并非如此。
分歧表示如何出现?
编译器可以对程序应用优化以提高程序性能。编译器必须确保在明确定义的输入上操作的正确性,但它们可以采取任意自由措施来加速未定义行为的执行。例如,为了在64位平台上优化代码,编译器可以用64位加法替换32位加法,因为在32位平台上加法的定义行为在64位平台上也是定义行为。
当编译器应用程序优化导致单个源变量在输出程序中以不同语义表示时,就会出现分歧表示。我们观察到的所有分歧表示实例都源于未定义行为(特别是有符号整数溢出)。由于程序员不应该编写具有未定义行为的程序,有人可能认为分歧表示不是问题。然而,我们断言即使在未定义行为的情况下,程序也应该对同一值有一致的解释。
我们发现的分歧表示出现在符合以下模式的代码中:
- 有符号整数变量在循环外声明。
- 变量在循环中递增或递减,并允许溢出。
- 变量在循环中用于访问数组。
- 变量在循环外使用。
2011年LLVM开发者邮件列表上的讨论提供了关于可能溢出的变量表示以及溢出对优化影响的迷人见解。
出现了一个野生分歧表示!
我们在尝试为CVE-2022-35737开发概念验证利用程序时发现了第一个分歧表示,这是我们在SQLite中发现的一个漏洞。我们注意到,当使用libsqlite3.so的调试版本(无优化编译)和执行优化的libsqlite3.so发布版本时,我们的概念验证利用程序行为不同;我们发现这很奇怪,因为它似乎暗示优化产生了同一库的语义不同编译。
我们通过反汇编两个版本的库并分析漏洞附近的代码进行了更深入的研究。编译代码的差异源于源代码,特别是sqlite3_str_vappendf
函数:
|
|
下图显示了优化二进制文件的反汇编版本:
在该代码片段中,用户输入缓冲区(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节点:
|
|
addr2line
实用程序表明此部分二进制文件对应于libxml2/uri.c:2085中的xmlBuildURI
函数:
|
|
此代码模式似乎与原始SQLite代码中的模式相似。但请注意,编译具有分歧表示的代码不一定可达,即使使用未定义的输入。例如,如果无法将整数cur
推进到超出32位整数的可接受值,上述代码片段中整数的语义不会分歧。
不出所料,当我们在无优化(级别-O0)编译的库版本上运行脚本时,没有找到任何分歧表示。该结果验证了我们对分歧表示由编译器优化引起的理解。
自顶向下(源代码)搜索
我们还执行了“自顶向下”搜索,查找在编译优化时可能产生分歧表示的源代码模式。
我们使用CodeQL创建源代码查询。这些查询识别满足以下条件的源代码:
- 变量在循环外声明。
- 变量在循环体中递增。
- 变量用于在循环体中的语句中访问内存。
- 变量在循环后再次使用,在循环体外。
我们还使用额外的可选条件运行CodeQL,查询变量在循环的条件语句中用于访问内存的情况,而不仅仅是在其体中。这通过消除循环条件阻止变量溢出的情况减少了误报数量。(例如,如果i
在循环条件i < 10
中使用,它不会溢出,但如果循环条件是buf[i] != x
,i
可能溢出。)
CodeQL在libxml2中找到了20种可能产生分歧表示的代码模式,其中两种(在xmlBuildURI中)也被Binary Ninja识别。
请注意,我们的自顶向下和自底向上搜索识别了可能存在分歧表示的代码;程序语义中的实际分歧仍然需要导致未定义行为的输入。
防止编译程序中的分歧表示
防止分歧表示的最佳方法是避免在程序中包含未定义行为。不过,这不是特别可操作的建议。如果我们建议程序员避免编写使用循环外声明变量的for和while循环,那将更无帮助。
相反,程序员应该使用不会溢出的数据类型来计数或访问数组(例如,使用size_t
或uintptr_t
而不是int
)。他们还应该避免C程序员中不幸常见的做法:将错误条件与int函数的负返回值绑定(例如,使用返回值-1表示失败);假设大规模重构不可能,我们建议在这些情况下使用ssize_t
而不是int
。最后,程序员应避免对程序在响应未定义行为时会做什么做出任何假设。
结论
我们不能做出评估与分歧表示相关风险的全面声明。一些基本上不可达的可以被视为未定义行为的好奇心——C编程琐事问题的来源,会难倒你的朋友。其他可能更 consequential,将原本良性的整数溢出转化为可利用的漏洞,如我们的SQLite漏洞案例。我们的希望是通过描述这种现象并使程序员能够在出现时识别分歧表示,我们可以帮助社区准确评估其严重性。
我要感谢我的导师Peter Goodman,在我与Trail of Bits的暑期实习期间,他在追求漏洞和奇怪编译器行为方面提供了专家指导。
如果你喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News
页面内容 一个简单示例 分歧表示如何出现? 出现了一个野生分歧表示! 搜索更多分歧表示 自底向上(编译二进制)搜索 自顶向下(源代码)搜索 防止编译程序中的分歧表示 结论 近期文章 使用Deptective调查你的依赖项 系好安全带,Buttercup,AIxCC的评分回合正在进行中! 使你的智能合约超越私钥风险成熟 Go解析器中意想不到的安全陷阱 我们从审查Silence Laboratories的首批DKLs23库中学到了什么 © 2025 Trail of Bits。 使用Hugo和Mainroad主题生成。