反编译器是用来从程序二进制恢复到高级语言表示(通常是C代码)的工具。在过去的五年中,反编译器有了很大的改进,不仅是在产生的伪代码的可读性方面,而且在恢复的相似性方面也有了很大的改进。尽管反编译器经常被不同学科的逆向工程师所使用 (例如,支持漏洞发现或恶意软件分析),它们还没有被用来为源代码静态分析工具提供输入。特别是,源代码漏洞发现和二进制漏洞发现在今天仍然是两个非常不同的研究领域,尽管反编译器有可能弥合这一差距,并能对二进制文件进行源代码分析。
在本文中,我们在真实世界的漏洞上进行了一些实验,以评估这种方法的可行性。特别是,我们的测量旨在显示原始代码和反编译代码之间的差异如何影响静态分析工具的准确性。
值得注意的是,我们的结果显示,在71%的情况下,在反编译的代码上运行静态分析器可以检测到相同的漏洞,尽管在一些情况下我们观察到假阳性的数量急剧增加。为了了解这些差异背后的原因,我们对所有的案例进行了人工调查,我们发现了一些影响静态工具 "理解 "生成代码的能力的根本原因。
反编译器,SAST,漏洞,逆向。
随着我们的世界继续迅速加速进入以软件为动力的未来,日益支持我们的生活和生计的软件中的漏洞正在增加。这给软件开发和测试带来了一系列独特的挑战。软件往往由两类测试人员来检查是否有漏洞:一类是开发软件的人,因此可以接触到源代码(源码级程序分析);另一类是外部安全研究人员,他们往往无法接触到源代码 往往不能接触到源代码(二进制程序分析)。
源码级的漏洞分析与二进制的漏洞分析有着本质的区别,因为软件的关键信息,如类型、结构和大小信息,在软件被编译时就会丢失。这使得在二进制代码上执行某些分析范式,如静态漏洞检测,成为一项艰巨的挑战:在检测二进制代码中的漏洞之前,必须以某种方式恢复这些丢失的信息。这就解释了为什么在这个方向上的工作很少[7],以及为什么能够分析二进制代码的商业工具(如Veracode)需要用调试符号编译应用程序[18](即,本质上需要源代码)。缺乏源代码也阻碍了其他分析范式,如模糊分析和符号执行,因为即使是这些技术也受益于编译的能力,而不是在分析目标中加装instrumentation[55]。因此,静态分析技术往往需要源代码来有效地检测漏洞,而动态技术在有源代码时也能更好地发挥作用。
有趣的是,有一个相关的研究领域关注恢复编译过程中丢失的信息:反编译。近年来,人们提出了一些技术来改善数据类型[46,53]、代码结构[35,63,64]、甚至确切的语法特征的恢复[57]。这些技术已经被整合到越来越强大、准确和公开可用的反编译器原型中[33,39,40]。
我们的见解是,从概念上讲,反编译所离开的地方接近于漏洞检测的地方。也就是说,我们意识到,由反编译器恢复的类型信息、结构信息和伪代码可以被漏洞检测工具分析,以代替原始源代码,至少有一定的功效。此外,随着新兴技术不断改进反编译结果,原始代码和程序二进制反编译的伪代码之间的差距越来越小,反编译器可以成为基于源代码的漏洞检测技术的一个越来越有效的"拐杖"。
在本文中,我们进行了一项研究,以确定当前静态应用安全测试(SAST)工具在反编译器生成的代码上执行时检测漏洞的能力。虽然看起来很明显,反编译的代码仍然不适合静态分析,但我们的案例研究希望通过实验来量化我们离静态分析工具成为反编译代码的有效解决方案还有多远。为了做到这一点,我们测量了8个最先进的SAST工具的精度和召回率,因为它们在9个真实世界的应用程序的原始代码上操作,而这些应用程序的伪代码是由3个不同的最先进的反编译器反编译出来的。
我们的研究得出了四个主要结论。首先,当前反编译器的输出不适合大多数SAST工具在没有人类分析师干预的情况下进行任何分析,必须在基于编译的分析器(例如那些基于LLVM传递的分析器)应用之前进行修复。其次,当编译问题被手动修复时,SAST工具的运行召回率降低了71%,这表明在反编译器/SAST中可能实际存在着一种潜在的潜力。不幸的是,SAST工具在反编译代码上的精确度受到影响,平均误报率增加了232%。第三,我们发现,编译器的优化,特别是函数的内联,有时可以帮助(而在其他时候,阻碍)SAST工具。第四,通过分析原始代码和反编译代码之间SAST结果的差异,我们确定并描述了影响假阳性和真阳性检测性能差异的7个根本原因。
反过来,我们设想了一些可以从我们的结果中得到启发的直接的前进步骤。我们的研究巩固了这样一种认识:现代反编译器的设计是为了生成对人类来说容易理解的代码,而SAST工具的设计则不是为了摄入这种机器生成的代码。这为研究人员提出了一系列新的方向:尽管反编译器在设计时考虑到了源代码的要求,但即使是对反编译器的微小改进也能极大地提高SAST工具对二进制代码的功效。另外,未来的研究可以集中在SAST工具上,使它们在解析反编译的代码时更具有抗噪音能力。例如,在我们的研究中表现良好的SAST工具之一Joern[65]所执行的模糊解析方法已经朝这个方向发展了。此外,使用反编译器作为源码级静态分析的第一阶段,可以在我们的数据集上使用SAST工具以外的应用。例如,嵌入式设备固件仍然难以用动态(由于在不模拟特定硬件环境的情况下执行固件的困难,即所谓的重新托管问题)和静态(由于固件通常以二进制形式分布)技术进行测试。虽然在这两方面都取得了一些有限的进展[27,41,54],但反编译器辅助的静态分析器可以在没有标准替代品的情况下对这些情况进行自动化的漏洞评估。
综上所述,本文有三个主要贡献:
所有与该论文相关的工件都存在于 https://github.com/elManto/SAST_on_Decompilers
我们现在介绍与静态应用测试和反编译有关的技术现状。
正如Chess等人所认为的,静态应用安全测试(SAST)的目的是在开发阶段消除源代码中的漏洞[25]。
这个研究领域提出的第一个方法包括对源代码进行简单的词汇分析,旨在检测已知的缺陷结构(例如危险的API调用)的存在[16,56,60]。
为了克服这些天真的技术的局限性,研究人员提出了新的方法,利用应用程序的源代码的更详细的模型,通常是依靠编译器的解析组件来获得。例如,作者[47,59,66]提出不同的方法,在编译时提取源代码的AST,并将其用于漏洞检测。
其他研究人员则倾向于尝试提高对特定类别错误的检测精度。其中,缓冲区溢出[34,36,42,61,62]、释放后使用[23,67-69]和空指针解构[37,38,50]就是这种情况。
也就是说,我们的论文并没有提出一种新的静态分析方法。相反,它更接近于许多专注于程序分析工具基准的研究,例如[20,21,24,30,31,43,52],在这些研究中,从创建一个全面的测试案例到在实验中采用不同的工具集等几个方面进行分析。
关于反编译器的最早研究之一是由Cifuentes[26]在1995年进行的,作为她博士论文的一部分,她描述了反编译器如何工作,该领域的未来挑战,并提出了dcc,一个用于英特尔80286的反编译器。
在过去的二十年里,出现了两种主要的反编译器开发方法:基于规则的反编译和基于NMT(神经机器翻译)的反编译。基于规则的方法[10,11,23,44]是目前最流行的,尽管制作一个基于规则的反编译器特别耗时。例如,根据其作者的说法,RetDec的开发在一个由24名开发人员组成的团队中总共花费了7年时间[1]。
基于NMT的方法[33,39,40]的诞生与Katz等人[39]的开创性工作相吻合,作者将反编译问题概括为一项语言翻译任务,即由于采用了自然语言处理(NLP),从汇编到C。
另一个研究方向是提高反编译代码的质量,主要集中在两个方面:提高可读性和改进控制流布局。第一类包括旨在更好地恢复变量类型[46,53]和建议更有意义的变量名称[45]的工作。第二类传统上侧重于减少反编译器[35,63,64](DREAM /DREAM++反编译器)生成的GOTO语句的数量。
需要强调的是,所有这些研究都只关注于改善人类的可读性(也就是反编译器输出的可用性)。迄今为止,还没有任何研究分析过机器处理所产生的代码有多容易。
最后,在2018年,Schulte等人[57]提出了一种新颖的方法来生成可以成功重新编译的二进制等价的反编译代码。Schulte等人的论文依赖于一些创新技术,如采用现有的反编译器作为提升过程的种子,并使用人写的代码摘录来生成人类可读的代码,即使该工具(名为BED)没有发布。
本文从漏洞检测的角度,研究现代静态分析工具如何受到反编译过程的影响。为此,我们研究了以下实体的互动。SAST工具、易受攻击的应用程序和反编译器。
对于每个易受攻击的应用程序,我们按照图1的总结进行(由于篇幅原因在附录中报告),其中有两个主要管道被执行。
基线分析 。在源代码分析管道中,我们将应用程序的原始源代码输入到不同的静态分析器中,并存储其生成的报告供以后分析。
编译 。我们根据提供的构建脚本(如Makefiles)编译每个应用程序,使用与开发人员建议的相同的编译器选项,以获得编译后的二进制文件,进而送入反编译的代码分析管道。附录4.8提出了进一步的见解,在那里我们展示了我们对易受攻击的应用程序的一个子集进行的差异化分析的结果,以评估编译器优化的影响。
反编译和分析 。在反编译的代码分析管道中,我们使用我们的反编译器对二进制文件进行反编译,并通过不需要重新编译的SAST工具运行所得到的代码。
大多数的SAST工具需要编译目标应用程序(例如,执行LLVM传递)。因此,由于反编译器通常会产生类似于C语言的伪代码,而这些伪代码不能被重新编译,我们手动应用所需的修正,使反编译器的结果可以被gcc和clang编译器编译。这个耗时的过程是有趣的,原因有很多。首先,它使我们能够用我们研究中选择的所有静态分析工具完成实验。此外,它为我们提供了一个宝贵的反馈,即如果分析人员想在二进制程序上应用源代码静态分析,他们应该采取哪些步骤。换句话说,它使我们能够量化人类在环路解决方案的可行性和所需的努力。
在手动修复反编译的结果后,我们通过基于编译的SAST工具处理可重新编译的代码。
结果比较 。最后,我们对实验中获得的三组报告(关于原始源代码的报告,以及关于反编译和可重新编译代码的两组报告)进行人工比较,以评估检测率和假阳性率如何受到前面步骤的影响。这一比较的结果将在第4节中介绍。
每当结果不同时(即如果以前检测到的漏洞不再被检测到,或者如果工具产生了新的错误警报 工具产生了新的错误警报),我们进行了根本原因分析 以确定其原因。这个步骤也是手动进行的。要求我们逐步修改反编译后的代码,使其与原代码越来越相似。使其与原始源码越来越相似,直到我们想要研究的效果消失。直到我们想要研究的效果消失为止(即检测到漏洞或不再发出错误警报)。
在本节的其余部分,我们将讨论我们用来选择缺陷的应用程序、SAST工具和反编译器的方法。值得注意的是,应用程序和SAST工具必须一起选择。事实上,为了有足够的结果供我们比较,我们要求每个漏洞至少被两个SAST工具检测到,而每个SAST工具至少检测到两个漏洞。这个限制被证明是很难满足的,并迫使我们执行一个漫长的预选阶段,在这个阶段我们评估了许多候选者(包括漏洞和静态工具)。
我们对缺陷代码的选择是由五个主要要求驱动的。
代码库规模 。我们希望包括一个小型的代码库,以评估代码复杂性对反编译和漏洞检测阶段的影响。检测阶段的影响。
C++ 。 我们包括一个C++代码库,以评估反编译器只产生C代码作为输出的事实。
真实的漏洞 。我们想收集真实世界的 CVE 和 bug,它们可以适当地代表典型的 bug 类别。这将使我们在实际的评估阶段尽可能地通用,而不关注人为生成的漏洞。
错误的复杂性 。影响静态分析精度的一个重要因素是,需要检测的错误是程序间的(即,它的发现需要经过多个函数)还是程序内的(即,它在一个程序中是自成一体的)。在我们的数据集中,我们希望包括这两类的例子,更倾向于程序内的。事实上,我们的测试平台的目的不仅仅是为SAST工具设定基准,还包括具有不同检测复杂性的错误。
漏洞的可发现性 。最后,我们还受到一个事实的限制,即所选的漏洞应该由所选的SAST工具在原始源代码上识别,以便在观察反编译代码的判决时进行比较。
为了满足我们所有的限制条件,我们从九个不同的应用程序中收集了10个漏洞(表1)。这些应用的范围从4千到2.1百万LOC不等(所有LOC的统计数字在表4中报告)。请注意,对于两个项目,即Xorg和OpenCV,漏洞存在于应用程序的一个子组件中,可以被编译为一个独立的模块。我们的数据集涵盖了以下五类漏洞。
缓冲区溢出BOF 可能是最普遍的一类漏洞,这就是为什么我们决定在我们的评估中包括这类漏洞的五种变化,例如,对缓冲区处理API的三种错误使用(分别是scanf、memcpy和strcpy),一个基于堆的逐一缓冲区溢出的例子(程序间),最后是另一个基于堆的BOF。在一个C++代码库中,位于实现 父类中的一个抽象方法的实现。
整数溢出IOF 错误是软件中未定义行为的一个常见原因。我们的数据集包括一个IOF的例子,它影响了动态内存分配的大小,因此可能导致堆BOF。
当一个NULL指针被取消引用时,就会出现空指针定义 NPD 错误。我们在数据集中包括了一个NPD的例子:在这个例子中,指针是由calloc调用返回的,它被存储在一个结构的字段中。这个错误是由于调用者没有检查指针的有效性。
双重释放、释放后使用 DF(Double Free)、UAF 漏洞。一方面,我们期望从反编译的角度来看,这种缺陷更容易,因为反编译器可以在没有任何类型系统/大小问题的情况下重构free的使用。另一方面,检测DF/UAF的SAST工具需要在内部跟踪释放的指针并检查所有后续的指针访问。作为进一步的复杂性,这两个错误中的一个(DF),是两个程序间漏洞中的第二个。
除以零 DBZ 并不是一个内存损坏的漏洞,但它在过去影响了几个真实世界的软件,可以被用作拒绝服务漏洞。可被用作拒绝服务的漏洞。
由于我们不确定反编译过程对SAST工具所进行的分析的影响,我们想评估一系列依靠不同功能和技术的产品。我们最初确定了12个工具(9个开源的,3个商业的)。
在12个候选的SAST工具中,我们选择了那些能够满足选择标准的工具,即在我们的数据集中检测到至少两个漏洞。 最后,我们的静态分析器集合,在表2中列出,包括。CPPCheck 、Joern 、Infer 、Scan-build 、Ikos 、Codeql 、Comm 1和Comm 2,这是两个流行的商业工具,由于法律原因,我们必须匿名。
在选择这八个工具之前,我们进行了一系列的初步实验,其中我们测试了许多其他的SAST工具。在其他工具中,我们考虑了Comm 3(另一个流行的商业工具)、Frama-C[9]、CPACheck[22]和Flawfinder[8]。然而,我们放弃了它们,因为在对一个错误的子集执行后,它们没有显示出足够的检测率和分析的准确性。
我们选择了三个最先进的反编译器进行评估。IDAPro 7.1[11](来自HexRays的最先进的商业反编译器),Ghidra 9.2[10](领先的开源反编译器),以及Retdec 4.0[44](新兴的挑战者)。
两个主要原因影响了我们对这三种工具的选择。首先,其他新出现的替代方案在精度和生成代码的质量方面都远远落后。此外,以前关于反编译器[35,57,63,64]的工作在进行评估时只关注这三种反编译器。
非反编译提升器。一些工具,如MCSema[28],可以直接将二进制代码提升到LLVM IR,而不是反编译[49]。乍一看,这些可能是在二进制代码上应用需要编译的SAST工具的一个可用途径。然而,这些工具只执行由反编译器执行的分析的一个子集,事实上,可以被视为反编译过程的 "第一阶段"。因此,与反编译器的结果相比,它们的输出将包含不充分的信息,使产生的代码不适合SAST分析。例如,由Lifters产生的字节码不包含调试信息,而在llvm pass之上工作的SAST工具通常需要编译器生成的符号。尽管有可能开发出更复杂的SAST工具,在静态提升器的输出和它们的预期输入之间架起桥梁,但这正是反编译器已经从另一个方向所做的。
在这一节中,我们讨论了我们的实验,特别关注反编译过程是如何影响整个检测率和假阳性率的。误报率的影响,以及对每个工具的影响。我们把对这些结果背后的原因的调查留给下一节。
表3报告了八个SAST工具在分析应用程序的原始源代码时对我们数据集中的不同漏洞的检测结果。
值得注意的是,除了Joern、Clang和Code-ql的显著例外,其他工具在错误检测方面相当互补,分别发现了2-4个错误,总体上只遗漏了两个错误(CVE-2017-17760和BUG-2018)。
Joern和Code-ql的高检测率是由于我们编写的自定义查询规则,并从这两个项目的作者[5, 15]描述的例子和指南中得到启发。尽管我们的范围不是生成一个足以涵盖某类漏洞的许多可能情况的通用查询,但我们试图把自己放在一个事先不知道该漏洞的分析师的位置上,这也解释了为什么用户定义的规则仍然产生了一些假阳性。
尽管我们的努力是为了产生通用规则,但不可避免地会引入一些偏见。然而,我们认为这是包括这两个分析器的唯一方法,这两个分析器代表了目前源代码静态分析的最先进水平,在我们的研究中。让查询更加通用以捕捉更多的特定类别的漏洞,也会导致有偏见的结果,因为会增加假阳性。相反的策略(即只捕捉测试中的漏洞的极其专门的查询)将不能代表现实世界中可以使用的规则,因为分析者事先不知道这些漏洞。
在我们的分析中包括的其余六个分析器是用它们自己的规则集启动的,因此它们没有在实验中引入任何偏见。特别是,我们决定不为其他工具(如Comm 2或Comm 1)创建自定义规则,因为它们已经配备了一整套规则,足以检测我们数据集中的一些漏洞。
除了RetDec的两次执行因LLVM错误而在最大的项目(Wireshark和OpenCV)上失败外,所有三个反编译器都能成功反编译我们数据集中的九个二进制文件。要衡量生成的伪代码有多准确,甚至要衡量它与原始源代码有多接近,是非常困难的。为了完成这项任务,我们从[35, 64]的作者那里得到启发,他们采用代码行数和GOTO语句数作为核心指标来比较他们工作中的不同反编译器结果。作为一个粗略的指标,表4报告了代码行数的比较。在大多数实验中,HexRays的输出是最小的,与原始源文件相比,总共多出20.8%的代码行。Ghidra的代码也差不了多少(比原始文件多了26.2%),而RetDec则要啰嗦得多(在它成功运行的八个二进制文件中多了79.8%)。
以前关于反编译的论文经常把GOTO语句的数量作为衡量所生成代码 "质量 "的标准。虽然质量经常被用作可读性的同义词,而且目前还不清楚这是否会对静态分析工具产生任何影响,但较少的GOTO数量也可以被认为是更先进的反编译器的标志。我们注意到,所有的工具都产生了包含许多GOTO的代码,范围从最小的84个(HexRays on ytnef)到最大的36,002个(HexRays on Wireshark)。平均来说,HexRays每60.3个LOCs(原始源代码)就产生一个GOTO,Ghidra每60.7个就产生一个,RetDec每11.2个就产生一个。
最后,我们比较了项目源代码中的函数声明和三个反编译器产生的伪代码中的函数声明,以衡量输入参数数量的差异。平均而言,HexRays漏掉了4个参数,Ghidra漏掉了6个,RetDec漏掉了7个,每10个函数声明中就有一个。
在我们的SAST工具中,有三个可以直接分析源代码文件,而不需要对其进行编译。前两个工具能够分析反编译器的输出,而无需任何进一步的人工干预。而Comm 2在重建五个反编译代码实例的AST时失败了。
此外,其余五个工具需要编译目标应用程序来分析它。然而,正如[48]的作者所显示的,三个反编译器所产生的输出没有一个是正确的C代码,因此它们都不能被重新编译开箱即用。这使我们不得不寻找一个合适的解决方案来继续我们的实验。
因此,为了使自己处于分析者的位置,我们试图手动修正产生的伪代码,使其符合GCC和Clang的要求。我们对研究中考虑的所有三个反编译器的输出进行了这一操作,以比较静态分析器对不同输入伪代码的不同执行情况。
总的来说,人工程序花了至少90分钟到8小时(对于libyang)。然而,在花了24小时试图修复Wireshark和OpenCV(两个最大的项目)的反编译代码后,我们无法获得一个 "可重新编译 "的伪代码版本。因此,对于这两个应用程序,我们采用了另一种解决方案,它允许我们生成一个反编译的应用程序的版本,保留了漏洞,并可以被我们的SAST工具处理。特别是,对于这两种情况,我们固定了有漏洞的函数和它们调用的所有程序的伪代码。然后,我们将这些代码整合到有漏洞的模块的原始源代码中--这样就形成了一个混合代码库,其中所有与漏洞有关的代码都来自反编译器,而其余部分则逐字逐句地取自该模块的原始代码库。这种妥协使我们能够研究SAST工具是否仍能在可重新编译的代码中找到漏洞,从而将我们对这些工具的评估扩展到所有预选的漏洞,但不能测量其对整个误报数量的影响。
我们的手动程序由一些重复的步骤组成,涉及全局变量的正确定义、头文件的定义、函数调用的纠正(例如,经常是反编译器声明了一个有N个参数的方法,却用M!=N个参数来调用它)、解决类型不匹配的问题,以及一些小的语法操作来删除错误的关键词或用括号修复语法错误。
尽管我们意识到在手动修正伪代码时可能会引入一些偏见,但我们想强调的是,这模拟了一个现实的环境,因为目前这种方法需要一个人在环中的解决方案,而替代方案仍然缺失。
能够分析三种反编译器输出的SAST工具的检测结果在表6的 "反编译器输出"栏中列出。这些结果没有对三种反编译器中的每一种进行细分,因为除了下面讨论的CVE-2017-6298的情况外,无论哪种反编译器的检测结果都是一样的。
事实上,我们为每个版本的反编译代码启动了8个静态分析器(根据工具的不同,可以是原始的,也可以是手动修复的)。不幸的是,一些分析器-伪代码的组合不能产生分析结果,因为相应的工具以崩溃而失败。除了Ikos对CVE-2019-1010315的Hex-Rays反编译的执行之外,其他例外情况主要影响了Ikos分析时Ghidra和Reddec的输出(Reddec有3次失败,Ghidra有5次),Comm 1(Reddec有2次失败)和Comm 2(Reddec有3次失败,Ghidra有2次)。对于所有其他工具,有可能比较检测方面的输出,发现从SAST的角度来看,HexRays和Ghidra的结果之间没有差异。
总的来说,RetDec生成的代码更加复杂,对于人类分析员来说,可读性大大降低。然而,可读性并不一定影响自动算法,事实上,只有在使用Joern和Code-ql时,才能在RetDec的输出中检测到CVE-2017-6298漏洞。这是由于RetDec采用了一种更天真的方法,将结构的字段当作独立的变量来表示(而Ghidra和HexRays都是重构结构),然后再将它们分配到结构的伪代码表示中(即数组)。正如我们将在第5节详细解释的那样,这有助于静态分析工具更容易地跟踪各个字段的使用,在上述案例中,这有助于发现漏洞。
我们搜索了其他包含结构的案例,看看它们是否也受益于RetDec的反编译方法,但是在RetDec反编译的代码上,既没有发现Use-after-free,也没有发现与结构使用有关的Double free bug。请注意,由于RetDec未能完整地反编译Wireshark,我们手动尝试将工具直接指向易受攻击的函数(已被RetDec反编译),但这并没有导致任何检测,因为在这些情况下,生成的代码与HexRays的代码更相似,它包含一些模式,使错误检测更难。正如我们将在第5章中详细解释的那样,类型和结构在伪代码中的表示对于SAST工具是至关重要的。
在本文的其余部分,如果至少存在一个反编译的代码,使工具在分析时能识别出易受攻击的缺陷,我们就认为这是静态分析器在二进制上检测到的缺陷。同样,由于篇幅限制,对于表6(我们在这里评估假阳性的变化),我们只报告HexRays反编译代码的结果。此外,考虑到一些工具在Retdec和Ghidra上遇到的失败情况,对这些工具的误报评估将是不完整的。
表5给出了一个结果的总结,包括我们能够在反编译器的虚构输出上运行的工具,以及我们必须在手工策划的代码上测试的其余工具。绿色标记代表了在伪代码上发现错误的情况,而交叉标记则表示缺少检测。而破折号则表示在原始源代码和反编译代码中都没有发现该错误。
我们必须强调,在对原始HexRays反编译代码的五次执行中,Comm 2未能构建分析代码的AST。由于这个原因,我们选择在可重新编译的代码上运行它,并报告与这些执行有关的结果。
总的来说,只有一个工具(Chechmarx)能够重新发现与应用于原始源代码时一样的漏洞子集。然而,所有的工具仍然能够发现至少一个漏洞(而且往往不止一个),从而表明在反编译的代码上运行SAST工具并不是一个无用的程序。总的来说,在反编译后,原始代码库上的42个累积真阳性下降到30个(71%)。然而,并非所有的工具都受到同样的影响,正如表的最后一行所报告的那样。
在源代码上操作而不需要编译的三种工具受反编译过程的影响较小。此外,商业工具虽然在发现我们的数据集中的漏洞方面总体上不太有效,但在反编译的代码中也继续发现完全相同的错误,尽管在Comm 1的情况下,我们可以观察到一个新的漏洞被发现,而不是另一个不再被发现的漏洞。在光谱的另一端,Clang和Code-ql是受反编译过程影响最大的两个工具。
看待数据的另一种方式是将结果按漏洞分组,而不是看不同的工具。在这种情况下(表5最后一列报告的所有结果),整数溢出(BUG-2012)、使用后自由(BUG-2010)和双重自由(BUG-2018)显然是在反编译代码上最难检测的。
在光谱的另一端,除以0和基于堆栈的缓冲区溢出似乎反而最容易检测。对于第一个问题,人工检查显示,在反编译器重建源代码的方式上没有有趣的变化。该错误涉及两个整数变量,对于反编译器来说,这比字符串/指针更容易处理。因此,在对相应的二进制文件进行反编译后,从静态分析的角度来看,围绕漏洞的伪代码与原始代码相当相似。
对于三个基于堆栈的BOF,真正的阳性反而是以更多的假阳性为代价的,我们将在下一节中详细描述。对于这些情况,我们报告了一个星号(*),意味着大量的缓冲区操作被分析器标记,部分解释了这些情况的检测。
一个工具的可用性在很大程度上由假阳性的数量决定,因为报告成千上万的警报会使分流阶段既困难又耗时。
我们对每个项目的假阳性增量进行了研究,我们可以比较这些工具对反编译代码的结果。因此,我们决定把重点放在Hex-Rays的输出上,因为这是一个更容易解析的输出。对于SAST工具来说,CVE-2019-1010315只报告了一次失败。 1010315(正如第4.4节中所解释的,3个工具在 Ghidra/Retdec的输出)。此外,它不可能有 在Wireshark和OpenCV项目上进行这样的比较。因为我们无法重新编译反编译后的代码。
我们在表6中报告了错误警报的变化,用红色标记了错误警报增加超过50%的情况,用绿色标记了数量减少的情况。总的来说,如果我们不包括Joern(这是一个特殊的案例,我们将在下面描述),在78%的测试中,误报的数量增加。更糟糕的是,在61%的测试中,错误警报增加了50%以上。
我们指出,我们手动检查了静态分析器产生的警报,以评估它们是否代表实际的误报。我们为加快程序所做的唯一假设是,如果使用API调用(例如strcpy或memcpy)在源代码中是安全的,那么它在伪代码中就不可能成为漏洞。此外,许多误报可以被批量丢弃,因为它们与未初始化的变量有关。
然而,在某些情况下(主要是Clang和Comm 1),这些工具对反编译的代码产生的错误警报较少。为了弄清这背后的原因,我们检查了那些报告有负面变化的工具的报告。这种行为的主要原因之一是,源代码中的许多错误警报是由于自由相关的漏洞(UAF、DF、堆栈变量被释放)。然而,当分析反编译器时,SAST工具不能应用相同的数据流,此外,反编译器改变了包含释放的内存区域的变量类型,使分析器的工作更加困难。此外,有几个警告报告说源代码中出现了终止不良的字符串(即,没有适当空尾的字符串没有适当的空结尾的字节)。由于类型混淆的问题,同样的问题在反编译后的代码中无法检测出来。
为了评估Code-ql的假阳性率,我们采用了安装时提供的默认查询。这使我们能够获得无偏见的结果,与我们使用自己编写的自定义规则来查找漏洞的情况相比,我们会得到更多的结果。
最后,Joern值得单独讨论,因为该工具没有任何预定义的规则,因此所有的测试都是通过为每个项目扫描启用我们自己的启发式检查器来进行的。尽管这些肯定不是一个完整和通用的集合,但它们允许我们对这个静态分析器的假阳性也有一个合理的评估。此外,这样的工具会对代码进行模糊解析。即使这个特点使Joern成为分析反编译代码的完美候选者。 我们在某些情况下为这个事实付出了代价,它不能正确地解释某些代码片断,并跳过它们而不提供完整的分析。因此,内部表示法缺少一些无法正确解析的部分,因此我们的查询无法到达。这导致了查询输出的减少,因为只有一部分的代码可以被正确分析。
我们最初的假设是,在反编译的代码上运行SAST工具,最多只能检测到与它用来分析应用程序原始源代码时相同的漏洞(更有可能比这少得多)。尽管我们的实验表明,对于大多数被分析的情况,这个假设是正确的,但我们发现了一个有趣的案例(BUG-2018),其中的工具(Joern和Comm 1)可以在反编译的代码上检测到一个漏洞,但在原始代码库中却没有。
编译器可以影响程序的控制流,以至于不可能完全恢复原来的版本。例如,我们将在下一小节中看到,有时编译器会出于优化的原因,删除死代码或简化布尔条件。
Wireshark(BUG-2018)中存在的双自由漏洞是一个程序间的问题,因此对于静态分析工具来说更难检测。事实上,正如清单1所报告的(由于篇幅原因,我们在附录中报告),该漏洞涉及三个独立的函数,最终调用了两次g_free。
在原始代码库中,Joern只能够重建导致自由的流程的一个子集,因此错过了这个漏洞。同样地,Comm 1进行的内部分析也不足以发现原始源代码中的漏洞流。
然而,在检查了反编译的代码后,我们注意到,由于静态关键字的存在,编译器将不同的函数内联到一个主体中(val from unparsed)。这就把程序间的错误变成了程序内的错误,很大程度上简化了检测错误的任务。事实上,事实证明,Joern和Comm 1在他们能够分析的伪代码上都成功地揭示了这个错误。
编译器支持不同的优化水平,这些优化水平在汇编层面上修改了编译阶段的输出。因此,我们选择分析这些编译器选项如何影响反编译的结果,特别是分析伪代码的这种变化对SAST工具是否有意义。
为了验证这一点,我们根据以下四个阶段进行了一个额外的实验。(i) 选择:我们在开源项目中选择了两个,file和libssh2(CVE-2017-1000249和BUG-2012)。对这两个项目的选择是由其代码库的平均规模和有意义的检测数量驱动的。(ii) 用优化级别编译:我们用三种不同的优化级别编译所选项目,即O0、O2、O4(O0禁用所有优化通道,而O4表示生成的代码被高度优化以提高执行速度)。应该注意到,到目前为止讨论的所有实验都是使用每个项目的makefile中指定的默认编译器优化进行的(对于我们的应用总是O2)。(iii) 反编译:我们用HexRays对同一二进制文件的三个版本进行反编译。(iv) 分析:我们在每个反编译的结果上启动所有的SAST工具。这也意味着我们必须手动修复反编译代码的所有变体,以生成我们许多工具所要求的可重新编译的版本。
我们想研究的第一个方面是编译器选项如何影响假阳性的数量。所有的静态分析器都在所有的版本上运行,只有Ikos在解析用O4选项编译的代码时报告了一些问题。因此,在计算误报时,我们舍弃了它。
对于libssh2,这些工具分别在O0、O2和O4下累计产生了850、2421和1606个误报。对于文件,我们反而得到了3,085、2,275和2,984个警报,这取决于编译器的优化。这样的结果表明,没有明显的趋势,不清楚对代码进行更积极的优化是否会导致更多或更少的错误警报。然而,每个编译选项的误报量不同,意味着编译器实际上对生成的反编译代码有影响,因此,对SAST工具的解析方式也有影响。
然后,我们检查了这些工具生成的所有报告,以确定漏洞检测是否也受到编译器优化的影响。对于BUG-2012,我们无法发现静态分析工具在不同版本的反编译代码中的执行情况有任何不同。唯一能带来检测的配置是在代码的O0和O2版本上执行Ikos。在对这三种类型的伪代码进行人工检查后,我们了解到,除了声明变量的数量不同(O0为29个,O4为99个),编译器的优化水平并没有对缺陷的函数产生明显的影响。
CVE-2017-1000249却讲述了一个不同的故事。事实上,当扫描这三个版本时,工具报告了不同的结果,这取决于编译器的优化。更具体地说,在O0和O2的情况下,8个工具中有4个可以检测到这个错误。令人惊讶的是,在使用O4标志时,检测率下降到了零。为了理解这种急剧变化背后的原因,我们再一次查看了反编译的代码。第一个区别是,使用O4标志时,多个函数被内联编译,因此,有漏洞的函数成为一个更大的函数的一部分,阻碍了SAST工具对数据流的分析。此外,这样的修改不仅影响到二进制的本地定义函数,而且还影响到一些库函数。其中,原本包含在代码中的、导致缓冲区溢出的根本原因的memcpy调用被替换为一个内联实现,被工具忽略了。最后,由于优化的原因,一个对缓冲区大小的不安全的检查总是被评估为真(因为一个编程错误),被删除了,在第5节有更详细的描述。累积起来,这三个方面使SAST工具的工作变得非常困难,导致假阴性的增加。
虽然这个实验不能系统地发现编译器影响所产生的伪代码的所有可能情况,但这些观察表明,编译器对反编译阶段的假阳性和假阴性都有影响。
在这一节中,我们进行了一项调查,以找出每个SAST工具在源代码和反编译输出上的执行差异背后的原因。为此,我们逐渐改变伪代码,使其与原始代码库越来越相似,直到工具报告了缺失的漏洞,或者直到额外的假阳性消失。
我们的研究结果发现了七个主要的根本原因,其中四个负责假阳性,三个负责假阴性。对于其中的每一个,我们都讨论了由编译和反编译过程引入的代码中的具体元素(以下简称模式),这些元素降低了SAST的性能。表7中总结了模式的清单,以及受该特定模式影响的项目和工具。对于每一个模式,我们都指出了一个修复者,即工具链的组成部分(反编译器,SAST工具,或两者),它在缓解/解决有问题的模式方面处于最佳位置。事实上,一方面,反编译器可以尝试从二进制文件中推断出更多的信息,另一方面,SAST工具在设计时可以考虑到这种限制,在处理伪代码时可以更加放任。
最后,我们要强调的是,我们的目的是说明导致SAST工具性能下降的这种根本原因,而不是提出对这些问题的潜在补救措施。事实上,正如本节所解释的,所报告的问题并不容易解决,可能需要在反编译和静态分析领域的未来研究。
SAST输出中的大量额外警告是报告缓冲区溢出的存在。作为一个例子,我们提出以下文件应用的摘录:
看一下这段代码,很明显,由于正确使用了sizeof操作符,两个内存写入操作(即memset和fread)在这种情况下是安全的。反之,反编译后的代码看起来就完全不同了:
sizeof运算符是在编译时解决的,因此反编译器只看到实际的数值。直观地讲,人们会认为这使SAST的工作更容易 因为现在的工具不需要自己去计算 大小值。然而,数组定义已经被替换为 用一个标量变量(s1)来代替,它被声明为char*,没有任何关于其原始大小的信息。
因此,当SAST工具分析反编译的代码时,他们将这两个调用标记为两个潜在的缓冲区溢出,因为char* s1变量所指向的内存区域大小未知。
在其他例子中,访问缓冲区的不同方式(例如,通过索引buf[i]),导致了不同的警告,例如空指针的解除引用,仍然是因为指针变量的大小信息缺失。
讨论。虽然这个问题在比较源码和反编译码时相当明显,但适当的解决方案并不那么简单,它本质上取决于编译器工作和生成汇编代码的方式。事实上,即使对堆栈进行了复杂的分析,反编译器也不能推断出一个内存区域是属于同一个缓冲区还是代表一组不同的变量(特别是当一个缓冲区的一个元素被使用硬编码的索引访问时)。
虽然可以使用一些启发式方法来推断原始大小,例如,通过查看循环迭代或初始化例程,但风险在于,通过依赖这些信息,反编译器可以隐藏漏洞的存在。
另一个假阳性的来源是与SAST工具将几个数字语句标记为潜在的IOF有关。仔细分析一下,这是由反编译代码中的两个主要错误造成的。
这种模式的一个例子是返回一个整数值的函数,其中负值与错误条件有关。例如,这是一个来自Xorg项目的反编译代码片段:
v2变量被用来存储返回值,在出现错误的情况下,它被分配为-1。然而,v2被反编译器错误地声明为无符号int,因此,分配一个负值,导致SAST检查器认为该变量内可能发生下溢。
主要是由于反编译代码中更复杂的程序间数据流,我们注意到许多SAST工具在无法确定其中一个操作数是否已被初始化时,会报告一个加法(或减法)为潜在的危险。
作为一个例子,我们从libyang的伪代码中提取了以下几行代码:
第10行的减法被Infer和Ikos指出是危险的,因为这两个工具都找不到操作数的初始化语句。然而,当我们检查源代码,并与反编译的代码进行比较时,我们注意到,在这两段代码中,两个变量都被初始化了。关键的区别在于,在源代码中,这些变量是在调用函数之前被初始化的,而在伪代码中,它们是在程序的最开始被初始化的,所以很可能由于复杂的数据流,SAST工具会失去对它们传播的跟踪。
有趣的是,到目前为止,反编译研究人员主要研究了变量类型恢复[29,46,53]和名称生成[45],但之前的工作没有关注恢复的变量在控制流中的 "位置"。
影响libssh2的BUG-2012被Yamaguchi等人[65]作为采用Joern的典型用例提出来,但尽管这样的工具可以在原始代码库中检测到它,但它在反编译器的输出中错过了它的存在。主要的漏洞包括一个整数溢出,其值被用作动态内存分配的输入。因此,IOF可以产生一个未定义的动态内存分配,导致错误的内存访问。为了清楚起见,我们在下面的列表中报告了代码片段:
LIBSSH2的ALLOC宏分配namelen + 1字节,并在退出信号缓冲区中返回所要求的内存,最终被访问。如果数据在攻击者的控制之下,就有可能对该变量进行加工,使namelen + 1的总和导致IOF错误。
由此产生的伪代码:
现在我们可以注意到,宏的调用已经被替换成了它的实际值,对应于存储在结构体session中偏移量为8的函数指针(即宏被定义为session->alloc(..))。反编译器根据函数定义对函数指针进行相应的转换,导致调用的结构更加复杂。
指针投掷是问题的罪魁祸首,也是Joern和Code-ql对这段代码无效的原因。这两个工具中的第一个无法正确解析它,因此它完全跳过了这个调用。在这种情况下,没有任何查询可以到达缺陷的路径。
Code-ql实际上正确地解析了代码,但由于框架使用的内部表示法,用于查找原始漏洞的查询不再起作用。我们可以写一个新的、更通用的查询,仍然可以捕捉到这个漏洞--但更通用的规则会导致假阳性的数量增加。
讨论。这个问题的根本原因是函数指针的调用包含了许多铸造操作,因此阻碍了代码的静态解析。然而,我们可以通过实例化一个可以存储函数指针地址的变量,然后在单独的一行中调用它来轻松解决这个问题:
对于这种模式,让我们专注于CVE-2017-6298,一个由未检查的calloc返回值导致的空指针脱嵌。指针脱靶,是由未检查的calloc返回值造成的。
阅读下面的代码片段,该漏洞看起来相当明显,事实上不同的工具都可以检测到它(Joern, Comm 2, Ikos, Infer, and Code-ql):
首先,变量vl是一个指向自定义结构的指针,反编译器不知道其定义。memcpy调用本身是安全的,因为代码将正确的大小写入动态分配的缓冲区,但是vl->data的值没有被检查是否为空,如果calloc返回一个空值,可能会导致一个空指针的解除引用。
当用HexRays和Ghidra对代码进行反编译时,我们得到以下代码:
我们可以立即注意到,该结构被表示为一个有符号的整数指针(标识符v9)。calloc的返回值写在v9[0]中,在将其转换为指针后。
尽管工具理解返回值是写在一个局部变量中,但它们认为赋值发生在一个带符号的int类型的变量中。由于这样的类型混淆问题,从现在开始,静态分析器对返回值不再感兴趣,停止跟踪该路径的数据流,继续分析其他潜在的缺陷路径。总的来说,他们错过了返回的指针和在下面的代码中发生的取消引用之间的联系。
如果我们反过来看一下RetDec生成的代码:
让静态分析工具更简单地分析这个输出的原因是,calloc API的返回值直接被存储到一个适当的指针中,而没有进一步的投掷或数组访问。因此,工具能够跟踪数据流,因此能够识别memcpy中指针的使用。
在这个例子中,我们讨论了返回的指针分配给一个整数变量的情况,但是当反编译器在函数原型中把参数声明为整数而不是指针时,同样的问题也发生过几次(例如BUG-2010和BUG-2018,它们分别是UAF和DF)。
讨论。SAST工具似乎有追踪指针的问题,这些指针变成了整数,后来又变成了指针。从RetDec中学习,解决方案只是反向传播类型信息。换句话说,如果一个变量后来被转换为指针并被取消引用,那么这个信息应该被用来重新定义变量类型为指针。
例如,只需在HexRays的输出中声明一个int*类型的中间变量而不是v9,所有缺失该漏洞的工具都能正确地进行污点分析,直到它们到达memcpy的调用。
反编译器经常声明错误大小的变量(例如,双字而不是字节),然后依靠投射操作来确保其输出语句的类型系统一致性。这种行为导致许多SAST工具由于潜在的错误的指针铸造而产生错误警报。
作为一个例子,我们可以考虑清单12(原始代码)和清单13(反编译代码)中的代码片段。
在原代码中,这些元素的类型是uint8 t(即每个元素一个字节)。在反编译器的输出中,这两个变量变成了64位的整数,随后被投到BYTE中以执行xor操作。此外,第j个元素的检索是通过uint8 t类型的指针算术完成的。
类似的模式在我们的实验中经常出现,不同的源指针类型和使用不同的类型来执行转换。虽然这种模式与指针为整数的模式类似(事实上,反编译器再次使用整数变量来存储指针),但这里是错误的大小和转换操作导致了错误的警报,而不是像前面的模式那样无法跟踪数据流。
同样有趣的是,由于代表数组的变量的初始声明(int64 v22和int64 v26是整数类型而不是整数指针),该模式被一些SAST工具报告为危险的转换,而不是缓冲区溢出。
另一方面,如果在前面的代码中,这两个变量被定义为int64*,我们仍然会观察到一个潜在的不安全内存访问的警报警告,收敛在"无法恢复堆栈缓冲区的大小"描述的情况下。
讨论。 这又是一个类型混淆的案例,没有指针案例那么严重(因为它不能导致丢失真正的漏洞),但在某种程度上更难修复。事实上,将变量标记为指针的反向传播信息是不够的,正确确定所有整数的大小需要更复杂的分析和推理技术。
这最后一种模式很不寻常,但我们报告它,因为它是反编译代码中一些漏掉的漏洞的原因。对于我们的讨论,我们使用CVE-2017-1000249,一个存在于文件项目中的堆栈BOF。原始的源代码在下面的片段中被描绘出来:
memcpy是不安全的,因为在其调用前进行了错误的检查。事实上,OR操作符被用来代替AND来检查大小(descsz)是否在适当的范围内。布尔条件总是评估为 "真",这被一些工具(如CPPCheck)发现并报告为潜在的错误--在这种情况下,它是导致缓冲区溢出的。
然而,编译器也能够检测到条件总是被满足,它们可以相应地简化代码。这就产生了以下反编译的代码:
desc缓冲区是反编译器无法重构基于堆栈的数组的另一个例子。但这种模式的关键因素是,关于缓冲区大小的错误测试已经不存在了。由于编译器首先没有生成其相应的汇编代码,反编译器没有办法恢复它。
一旦静态分析工具发现的线索(对缓冲区大小的错误检查)被删除,一些工具就完全无法检测到该漏洞。
我们可以围绕四个要点提炼出我们的实验结果。
在伪代码上使用SAST工具的主要障碍是,反编译后的代码不能开箱重新编译。Schulte等人最近发表的论文使我们感到乐观,这个问题很快就会得到解决。然而,到目前为止,人类分析师需要手动修复反编译的代码,这个过程对于小型应用程序来说可能只需要几个小时,但对于由数百万个LOC组成的大型代码库来说,这个过程就变得异常复杂。
一旦解决了可重新编译的问题,现有的SAST工具可以发现(在我们的实验中)他们在原始代码中发现的71%的漏洞。虽然仍有改进的余地,但这个结果已经超出了我们最初的预期。在消极的一面,假阳性的数量往往大大增加,使得许多工具的输出难以浏览,而且很费时间。然而,即使FP平均增加了232%,在29/61个案例中,FP要么减少,要么没有明显增加,这表明我们的方法在许多情况下仍然很有前途。
编译器和反编译器的转换都以一种复杂的方式对最终结果做出贡献。我们的实验表明,没有线性趋势,在某些情况下,更积极的优化甚至简化了SAST工具的工作。例如,在两个案例中,静态分析器甚至能够发现他们在原始源代码中无法发现的一个漏洞。
今天,反编译器仍然被设计为生成人类容易理解的代码,而SAST工具仍然被设计为解析不是由机器生成的 "写得好 "的代码。这种以人为本的观点在未来可能,也应该改变。在第5节中,我们列出了7个根本原因,解释了我们在结果中观察到的差异。我们相信,我们列表中的许多条目可以通过改进反编译器或SAST分析(或两者)来解决,或至少是缓解。
总之,我们的案例研究表明,我们正在接近源码和二进制分析的衔接点。虽然仍然存在一些障碍,但我们相信,未来的工作将能够克服这些问题,重点是反编译方面和静态分析部分。
static void
string_fvalue_free(fvalue_t
*
fv)
{
g_free(fv
-
>value.string);
}
static gboolean
val_from_string(fvalue_t
*
fv)
{
string_fvalue_free(fv);
return
True
;
}
gboolean
val_from_unparsed(fvalue_t
*
fv, ...)
{
string_fvalue_free(fv);
...
return
value_from_string(fv, ...);
}
static void
string_fvalue_free(fvalue_t
*
fv)
{
g_free(fv
-
>value.string);
}
static gboolean
val_from_string(fvalue_t
*
fv)
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2023-4-28 19:27
被TUGOhost编辑
,原因:
上传的附件: