今天为大家推荐的论文来自中科院计算所内构安全实验室投稿并发表在 TACO 2024 上的最新工作Shining Light on the Inter-procedural Code Obfuscation: Keep Pace with Progress in Binary Diffing。该工作从二进制比对的视角,提出了一种过程间的代码混淆技术,解决现有代码混淆技术逐渐失效的问题,提升了对抗二进制比对技术的有效性。
该工作观察到,现有代码混淆技术的粒度往往集中在函数内部,缺乏函数间的变换,无法从根本上改变函数的语义,而二进制比对技术可以越来越准确地提取函数内部的特征以获得其语义,从而使函数内粒度的代码混淆技术不再有效。该工作认为应该强调函数间的代码混淆技术,因为它们能够更改函数的语义。所提出的过程间代码混淆技术包含函数裂变、函数聚变以及控制流隐藏三种混淆原语。实验结果表明混淆后的程序能降低二进制比对工具的准确率至19%以下,超过80%的漏洞函数的搜索排名下降到50名以后,同时混淆带来的开销不超过7%。目前该工作已经开源。
该论文贡献如下:
一种过程间的混淆技术。该工作认识到过程间混淆在对抗二进制比对技术中的重要性,并引入了一种过程间代码混淆技术,该技术可以跨函数混淆代码。
三个互补的混淆原语。该工作提出了三种混淆原语来完成代码的移动和隐藏。这些原语包括裂变——将一个函数划分为多个函数;聚变——将多个函数合并在一起;隐藏——利用异常处理机制因此程序中的控制流。
背景
二进制代码比对技术
二进制代码比对技术,也称做二进制代码相似性检测技术(Binary Code Similarity Detection,BCSD),是一种用于分析和比较二进制代码以识别二进制文件之间相似性的技术。它能够定量地测量二进制代码之间的差异并以预定义的粒度级别(通常在函数级别)给出匹配结果。该技术已广泛应用于软件漏洞搜索,恶意软件检测,代码克隆检测等场景中。如下图所示,二进制比对的过程通常从二进制代码的反汇编开始,该过程将二进制代码转换为汇编代码,从而在一定程度上恢复程序的语义信息。反汇编之后,二进制比对技术的工作流程可分为两个阶段:离线特征提取和在线代码搜索。
在离线阶段,二进制比对技术从汇编代码中提取特征。最近的研究重点是确定应该提取哪些特征来实现有效的代码比对。根据二进制比对工作的方法,它们可以分为两类:传统方法和基于学习的方法。
传统方法从二进制代码中提取统计信息,例如指令操作码的分布情况,控制流图以及每个节点的入度出度信息等。例如,一些工作提取了字符串常量、数字常量和不同种类指令的数量作为基本块和函数的标识。此外,很多工作也尝试过提取语义级特征作为二进制代码的标识,比如使用通过识别基本块的输入输出来描述一个基本块的功能。
最近,许多工作尝试采用机器学习技术来进行二进制比对。例如将汇编语言视为一种特殊的自然语言,然后使用自然语言处理技术对其进行分析,将程序中的每个元素(操作码、操作数)抽象为自然语言中的词素,并通过训练和聚类生成每个词素的向量化表示。
传统方法和基于学习的方法都有其优点和局限性。一方面,传统方法的比对效率较高,但常常难以应对复杂的代码变换。另一方面,基于学习的方法可以适应不同的代码表示,并对代码转换表现出更好的鲁棒性。然而它们需要大量标记数据进行训练,并且计算量可能很大。
在线阶段,二进制比对技术会计算提取的特征之间的相似度从而识别能够配对的二进制代码。这个过程可以看作是一个搜索过程,其中搜索空间的规模受到定义特征的粒度的影响。例如,如果粒度设置在函数级别,则搜索空间将由二进制文件中函数的数量决定,每个函数会得出一个该工具认为的最为相似配对的函数,或一个按照相似性排名的函数候选列表,以供逆向分析人员进行后续分析。而如果粒度设置在基本块级别,则考虑到代码中基本块的数量较多,搜索空间将进一步扩大。
此外,特征的具体表示会影响用于相似性计算的方法。例如在处理与向量相关的特征时,通常采用欧氏距离或余弦相似度等距离度量方法,从而量化了特征向量之间的差异或相似性。当使用与图相关的特征时,就需要图匹配算法来衡量图的相似度,即找到提取特征的节点或子图之间的对应关系,从而进一步判断代码结构和语义的相似性。
代码混淆
代码混淆技术(Code Obfuscation)旨在不改变程序功能的前提下,对软件的代码进行变换,复杂化软件代码,使得代码更加难以理解。变换后,函数的控制流发生较大变化,可用于隐藏程序的功能逻辑,因此也适用于漏洞的隐藏。由于该技术能够灵活地改变二进制代码,因此是对抗二进制比对的可用选择。实际上,代码混淆和二进制比对之间存在着一场军备竞赛。代码混淆不希望二进制比对技术成功地将未混淆的代码与混淆后的代码配对,反之亦然。
近几十年来,国内外研究人员在混淆技术方面提出了多种方法,上表总结了过去二十年来的代码混淆相关工作,并按照混淆粒度对这些工作进行分类:
指令级:指令替换(Instruction Substitution,SUB)将原始指令替换为等效指令,例如将一条add指令替换为两条sub指令。O-LLVM为算术和逻辑运算设计了10种不同的替换策略。此外,花指令(Garbage Substitution,GS)技术利用复杂指令集(Complex Instruction Set Computer,CISC)指令不定长的特点,在代码中插入垃圾字节来对抗反汇编。混合布尔算数(Mixed Boolean-Arithmetic,MBA)将代码中的算术指令复杂化,通常用来对抗符号执行等技术。
基本块级:为了增加条件分支指令的复杂性,许多工作提出了多种不透明谓词技术(Opaque Predicate,OPA)。这些不透明谓词不影响原始控制流的永真或永假条件(例如,x^2 != -1),也经常用于对抗符号执行等分析技术。冗余控制流(Bogus Control Flow, BCF)将任意代码插入到原始控制流中,并经常利用不透明谓词来防止这些代码被优化和执行,从而确保程序的原始功能。
动机
对于用户态应用软件来说,代码复用是引入安全风险的重要来源。代码复用使得攻击者无需费力挖掘其中的零日漏洞,而仅通过检测攻击目标中已存在漏洞即可。在这种情况下,二进制代码比对技术为攻击者提供了便利。在不需要源代码的情况下,攻击者可以利用该技术,通过将目标应用的二进制文件与已知漏洞的二进制代码进行比较来定位目标应用中存在的已知漏洞,进而发起攻击。近年来,基于学习的二进制比对技术近年来取得了长足的进步,可以在不到1秒的时间内完成万级的代码比对,这极大地方便了攻击者定位二进制文件中现有的漏洞。在这种场景下,通过对抗二进制比对技术来保护软件安全就显得尤为重要。
上表对过去十年在顶级刊物上发表的二进制比对工作进行了全面分析,并依据其对比方法、对比粒度以及对不同代码变换的适应能力进行了总结。早期的二进制比对工作往往以传统方法为主,自2018年以来,随着机器学习在不同研究方向的大规模应用,大多数工作都开始采纳了基于学习的二进制比对方法。
由于代码变换是造成二进制代码差异的常见来源,因此测试对代码变换的适应性成为了二进制比对工作的常见评估步骤。如表中的代码变换适应性所示,许多二进制比对工作都考虑了使用不同的主流编译器来生成二进制文件,其中大多数集中在GCC和Clang上。在特定的编译器下,大多数工作只考虑了O0到O3等默认优化选项,而只有一个工作探索了非默认优化选项的影响。
部分二进制比对工作也考虑了代码混淆技术带来的影响,并且从不同的粒度(例如,在语句、基本块或函数级别)评估了对代码混淆技术的适应程度。从它们的实验中可以看到,现有基于静态代码重写的混淆技术已经失效。究其原因,该工作认为这些混淆技术主要集中在函数内。为了保证功能正确性,函数内的代码混淆不能改变每个函数的功能,因此无法从根本上改变每个函数的语义。随着二进制比对技术越来越能够提取函数内的特征并理解其语义,现有的代码混淆技术难以取得令人满意的效果。
通过对现有二进制比对技术和代码混淆技术的观察与总结,该工作强调函数间代码混淆对二进制比对技术的影响,并通过性能与混淆效果两个视角来论述其有效性:
视角1——性能:在对近年来的代码混淆技术进行总结的过程中,该工作发现代码混淆与编译器优化存在对抗或破坏的关系。一般来说,编译器优化方向为提升程序的运行性能,同时缩小二进制文件的大小,而代码混淆往往为了增加程序的复杂度而降低了程序的性能,并增加了二进制文件的大小。通过对近年来的代码混淆技术的总结,该工作发现现有的代码混淆技术的作用粒度大多是函数或小于函数。而编译器中的优化技术也大都为函数内优化,因此这些以函数为粒度的混淆技术为了保持混淆效果不被优化,需要对抗若干编译优化技术,从而降低了程序的性能。因此,为了降低代码混淆带来的额外开销,使用的代码混淆应尽可能顺从编译优化,而大部分编译优化均为函数内优化,因此为了不对抗/破坏这些优化,该工作利用函数间的代码混淆来进行代码混淆。
视角2——混淆效果:经由以函数为粒度的代码混淆技术处理后,函数的功能一般不发生改变。而随着二进制代码比对技术在程序的抽象表示能力方面的不断提高,尤其是深度学习在代码比对技术中的应用,使得该技术捕获函数功能的能力越来越强,函数内代码混淆的效果逐渐减弱。而在函数间的代码混淆的帮助下,函数的界限被打破,函数的功能被改变,从而提升代码混淆在对抗二进制比对技术方面的效果。一方面,对于仅考虑函数内信息信息的二进制比对工作来说,函数间代码混淆可以从根本上击败它们,因为函数的代码结构和语义都发生了明显变化;另一方面,对于考虑函数间信息的二进制比对工作来说,由于它们提取的函数间信息(如函数调用调用图、调用类型、调用次数等)在混淆后也发生了显著变化,因此函数间代码混淆也可以有效对抗它们。
从攻防双方发表的文献中也进一步验证了该工作的想法:1)函数内联优化作为函数间变换的一种典型的优化技术,大多数二进制比对工作都讨论了该变换带来的问题,其中许多工作承认它会影响代码比对的准确性;2)一些工作发现通过强制开启编译器中的函数内联优化可以将二进制相似度降低约10%。
因此,该工作认为应该强调函数间的代码混淆技术,因为它们能够在二进制级别更改函数语义。基于上述观察,本文提出了一种函数间混淆技术,它可以跨函数移动代码,并利用编译器的优化来进一步转换(混淆)代码。其核心思想是一旦代码在函数之间进行重组,编译优化后生成的二进制代码会有很大不同。
设计
为了实现函数间代码混淆,该工作提出三种混淆原语:裂变,聚变,以及隐藏。
裂变原语以支配树为粒度将函数中的代码区域划分为单独的子函数,同时结合冷热代码分析技术来降低裂变带来的性能开销。此外,由于变量的定义-使用关系从函数内变为函数间,因此还需要通过传递参数来重建数据流。为了最大限度地减少参数传递引起的性能下降,裂变原语提出了一种数据流缩减机制来减少子函数的参数数量。最后,裂变原语通过插入调用子函数的函数调用语句并对子函数中的返回值进行编码来重建控制流。
聚变原语聚合具有兼容返回值的函数并合并它们的参数列表,其中兼容类型意味着在不同数据类型之间的转换不会损失精度。为了避免低效的栈传参方式,聚变原语提出了一种参数压缩机制来减少参数的数量。同时,为了完全重建控制流,聚变原语提出了一种标签化指针机制,通过将控制位附加在函数指针的高位上,来决定间接调用点时聚合函数内部执行的代码。此外,聚变原语还提出了一种跳板机制来处理跨模块的函数调用。最后,为了进一步提高混淆效果,聚变原语提出了深度聚变方法,在聚合后的函数内部对来自不同函数的无害化基本块(其执行不影响全局内存状态)进行合并。聚变之后程序的调用图发生明显变化,深度聚变导致基本块的特征被打破,并且产生了新的执行路径。
下图给出了一个关于在名为cal_file()的函数上进行函数间代码混淆的示例。该函数的功能为查找给定文件中内容的签名。它首先检查文件名是否为空(第4行),若非空则调用log()函数来记录文件名(第5行),随后打开文件(第6行)。若打开失败则返回-1(第7行),否则循环读取文件内容并调用cal()函数计算(第9-10行),计算完成后关闭文件(第11行)并返回计算结果(第12行)。
上图右侧给出了混淆前后的控制流,可以看到裂变原语分别将两个基本块(2-3)分裂为名为sepFunc-1()的函数,将四个基本块(5-8)分离为sepFunc-2函数。为了保证正确性,裂变原语在remFunc-1()函数中插入三个跳板基本块(abc),以创建与两个sepFunc函数的调用关系。
在裂变结束后,聚变原语将log()函数和sepFunc-2()函数聚合成fusFunc-1()函数,并在fusFunc-1()函数中插入一个入口基本块(e)来选择每次执行的代码块。为了实现更深层次的代码聚合,该工作提出了深度聚变的方法来结合来自不同函数体的基本块。在生成fusFunc-1()函数后,聚变原语会将所有对log()函数和sepFunc-2()函数的引用调整为对fusFunc-1()函数的引用。
隐藏原语是为了解决使用上述原语后仍可能存在的“顽固的”控制流,其重点是将直接控制流转换为间接控制流。例如,隐藏原语首先将sepFunc-1()函数中的基本块2末尾的分支语句替换为用throw语句,随后在后续基本块(d和3)中添加catch语句。这样通过利用异常和相关的catch语句实现了间接控制流驱动的hidFunc-1()函数,同时保留了代码的预期功能。此外,隐藏原语也为C语言和函数调用语句扩展了等效隐藏方法。最后,为了进一步打破hidFunc-1()函数与其他函数的边界,隐藏原语将被隐藏的代码(d和3)分发到了不同的函数中。此步骤确保同一个函数的控制流分布在多个函数中,从而提升整体的混淆效果并使分析程序结构更具挑战性。详细的设计与挑战请参阅论文。
实验
性能实验
该工作在SPEC CPU 2006 和2017上分别评估了经过裂变原语(Fission)、聚变原语(Fusion)、隐藏原语(Hidden)以及自动组合模式(Auto)处理后的程序的性能开销,。上图给出了SPEC CPU 2006和2017标准测试集的开销。在SPEC CPU 2006中,三种混淆原语的几何平均性能开销分别为4.24%,3.92%,6.49%,组合模式的开销为6.22%。在SPEC CPU 2017中,三种混淆原语的几何平均性能开销分别为5.67%,5.07%,8.19%,组合模式的开销为8.88%。某些测试用例(例如,456.hmmer)具有负的性能开销的原因是在裂变分裂部分代码后,remFunc可以进一步被内联到其调用函数,此外聚变还提高了代码局部性。实验结果证明,函数间代码混淆作为一种顺从编译优化的混淆技术,可以取得较低的性能开销。
对抗二进制比对实验
测量指标:该工具利用多个二进制比对工具来对比混淆前后的二进制代码,从而衡量混淆的有效性。然而,这些二进制比对工具难以同时使用,主要表现在三个方面:1)不同的工具定义了不同的代码特征(如指令统计、向量化特征等)与不同的相似性计算方法(如图形编辑距离或向量距离等),难以使用统一的计算方法;2)这些工具的对比粒度不同,多数二进制比对工具的粒度都是函数级别,而有些工具还出于特定的目的考虑了基本块和指令级别的相似性;3)衡量指标不同,其中一些工具使用了如真阳性(True Positive,TP)或假阳性(False Positive,FP)等。还有的使用Precision@k和Recall@k来衡量前k个配对函数中真正配对的比例。有些工具还根据其特定的功能自定义了不适用于其他工具的衡量指标。为了克服评估指标的差异,建立相似性计算的标准化指标至关重要。根据该工作的观察,这些差异来自在线搜索阶段。即从二进制文件中提取特征并计算距离后,不同的工具使用多样性方法来解释准确性。为此,该工作不再使用不同工具的不同度量指标,而使用一致的精度测量方式——precision@k,该方式也是二进制比对工具中常用的度量方法。
配对成功判断方法:由于过程间代码混淆改变了函数的数量,因此该工作放宽了对Precision@1的要求。对于裂变原语来说,如果oriFunc与任何从它生成的sepFunc或remFunc配对,则认为配对成功。对于聚变原语来说,如果fusFunc与任何一个在聚变前的函数配对,则认为配对成功。对于隐藏原语来说,如果hidFunc与隐藏前的函数配对,则认为配对成功。此外,由于一些工具的配对结果是基本块级别的,因此只要它们的所属函数配对成功,即使两个基本块没有真正匹配,该工作也认为配对成功。值得注意的是,上述设置比这些工具中最初使用的配对成功判断方法更为宽松,但对过程间代码混淆来说更具挑战性。
比对结果:如上图所示,更高的精度意味着更低的对抗效果。一方面,对于只考虑过程内信息的二进制比对工具,过程间混淆可以从根本上击败它们,因为代码结构和语义都发生了显著变化。另一方面,对于考虑过程间信息的二进制比对工作,过程间混淆也可以击败它们,因为它们提取的过程间信息,如函数调用的类型、函数调用的数量和调用图,在混淆后也会发生显著变化。
操作码直方图。操作码直方图是二进制文件中关键的内部信息,可以揭示整个二进制的语义。它在各种相关工作中也得到了广泛的研究和讨论。为了获取操作码的详细信息,实验使用objdump工具对所有二进制文件进行反汇编,并在向量中收集操作码直方图。向量中的每个元素表示一个特定的操作码类型(例如add),而它的值表示二进制文件中相应操作码的出现频率。通过比较原始二进制文件和混淆后的二进制文件的操作码向量,从而计算向量距离。
从上图中可以看到,不同混淆方法中操作码的分布是不同的,其中过程间代码混淆中的Auto模式对应的操作码分布变化最大,而O-LLVM中SUB模式混淆的二进制操作码变化最小。该实验证明了函数间代码混淆在仅仅使用本程序的代码而不依赖外部代码的前提下,仍然能生成多样化的指令。
与编译选项的对比
BinTuner是一个迭代编译工具,它使用编译器选项来转换代码以扩大二进制文件的差异,本实验选择了该工具作为编译器选项与代码混淆比较的目标,并利BinDiff分别计算BinTuner和本工作生成的二进制文件的相似度得分。
上图给出了相似度的实验结果,其中BTR-O0(即BinTuner生成的二进制与O0选项生成的二进制相似性)是BinTuner生成的二进制文件中相似性最低的。这是因为BinTuner在迭代编译过程中使用O0作为基准,而BTR-O1、BTR-O2和BTR-O3具有较高的相似性。对于过程间混淆来说,它选择了O2作为基准,因此与Auto-O0、Auto-O1和Auto-O3相比,Auto-O2的相似度稍高。但总的来说,与BinTuner生成的二进制文件相比,所有混淆后的二进制文件的相似性要低得多。
实验还收集了它们与默认优化级别(O0-O3)相比的运行时性能开销。BinTuner和过程间代码混淆在O0和O1上的性能都有加速效果,且在O2和O3上的性能会减慢。为了便于比较,所有性能均归一化为[0, 1]。如上图所示,就大部分程序来说,BinTuner与过程间混淆性能开销相当,但BinTuner也给某些程序带来了显著的开销。
有关混淆的编译器优化观察。值得一提的是,在实验过程中BinTuner存在摆动现象,即以O0为基准时,生成的二进制文件与O3类似,而以O3为基准时,与O0类似。这也表明BinTuner很难生成性能开销较低但二进制差异显著的二进制文件。相反,过程间代码混淆通过利用程序本身的代码来混淆自己,从而克服了此限制。
就编译器优化和混淆之间的关系来说,编译器的目标是通过优化来提高程序性能并减少二进制大小,而代码混淆技术通常需要对抗这些优化,以防止生成相同的二进制代码,因此代码混淆和编译器优化之间的这种对抗关系可能会导致不可接受的性能开销。为了最大限度地减少混淆带来的开销,代码混淆应尽可能顺从编译器的优化。根据该工作的统计,LLVM的O3选项下超过85%的优化是函数内的。因此,函数间代码混淆由于对编译器优化的干扰较少,所以在性能方面表现更好。此外,虽然编译器优化可以混淆程序的代码,但它们限于特定的代码模式,例如内联规则和与循环相关的变换规则,因此限制了代码混淆的灵活性。相比之下,过程间代码混淆可以以更灵活的方式混淆程序的代码,从而为混淆提供了更多的可能性。
内部数据统计
实验还收集了一些混淆时的内部统计信息来进一步证明混淆的有效性。对于裂变,该公族计算了裂变比例(sepFunc数目 / oriFunc数目),sepFunc大小(sepFunc中基本块的平均数目),裂变后函数的大小变化(裂变后oriFunc基本块的减少比例)。对于聚变,该工作计算了聚变比例(成功聚合的函数的比例),通过参数列表压缩减少的参数数量以及每个函数的无害基本块的数量。对于隐藏原语,实验测量了隐藏比例,它代表通过基于异常和基于间接调用的方法成功处理的函数的比例。
上表给出了具体的统计结果。总体而言,这些统计数据提供了过程间混淆能够全面地混淆程序代码的证据,突出了其生成裂变函数、成功聚合函数、隐藏控制流以及多种优化方法以减少运行时开销和增强混淆效果的能力。例如聚变比例为97-99%,这意味着几乎所有函数都已聚变。它还证明了运行时开销(如数据流缩减)和混淆效果增强(如深度聚变)的优化都是有效的。
论文:https://dl.acm.org/doi/10.1145/3701992
开源代码:https://github.com/Khaos2022/Khaos-master
作者简介
张培华,2024届计算所博士,导师为武成岗研究员,研究方向为系统安全,在 USENIX Security、TACO、CGO等发表过相关研究成果。个人主页:https://patrickphzhang.github.io/
王喆,中国科学院计算技术研究所处理器芯片全国重点实验室副研究员。长期从事系统安全、操作系统、编译和体系结构的研究。研究成果发表在IEEE Security and Privacy、ACM CCS、USENIX Security、USENIX OSDI等安全和系统领域的国际顶级会议/期刊上,并获得CCS杰出论文奖和最佳论文提名奖。带领团队研制的多款实际系统,落地应用到不同的实际应用场景中,在现实任务中发挥重要作用,受到广泛好评。