Jump-Oriented编程-[JOP]-一种新的代码复用攻击
摘要
return-oriented的编程是一种有效的代码重用攻击,在这种攻击中,在现有的二进制文件中找到以ret指令结尾的短代码序列,并通过控制堆栈以任意顺序执行。这允许对于Turing-complete行为在目标程序中没有需要注入攻击代码,从而显著否定当前的代码注入防御努力。在另一方面,它的固有特性,如对堆栈的依赖和返回的gadgets的连续执行,促使各种各样的防御发现或阻止它的发生。
本文介绍了一种新的代码重用攻击,称为 jump-oriented编程。这种新的攻击消除对堆栈和ret指令的依赖(包括类似于ret的指令,如pop+jmp)return-oriented的编程不牺牲表达能力。这次攻击仍然制造和具有连接功能的gadgets,每个都执行某些基本操作,除了下面这些gadgets在一个间接分支结束,而不是利用ret来统一它们的便利性、攻击在dispatcher gadgets上调度和执行功能gadgets。已经成功地确定了可用性GNU libc库中的这些 jump-oriented的gadgets。用一个示例shellcode攻击来演示介绍该技术的实用性和有效性。
1.介绍
网络服务器经常受到攻击者的威胁,使用恶意制作的数据包来利用软件漏洞并获得未经授权的控制。尽管针对软件漏洞的根本原因进行了大量研究,但此类攻击仍然是最大的问题之一,在安全领域。军备竞赛在两国之间展开了越来越复杂的攻击和相应的攻击防御。
软件利用的最早形式之一是代码注入攻击,其中恶意消息包括机器码,并使用缓冲区溢出或其他技术将控制流重定向到攻击者提供的代码。然而,随着cpu和操作系统的出现,这种威胁在很多情况下都得到了缓解。特别地,强制属性“一个给定的内存页永远不会同时具有这两个属性”同时可写和可执行。基本前提其背后的原因是,如果不能向某个页面写入代码,以后又不能从该页面执行代码,那么代码注入就不可能实现。
不幸的是,攻击者已经开发出了创新的方法。例如,一种可能的方法是发送一种代码重用攻击,其中现有代码被重新用于一个恶意的结束。最简单和最常见的形式,这就是返回libc技术。在这个场景中,攻击者使用缓冲区溢出来覆盖部分带有返回地址和列表参数的堆栈libc(核心C库,动态链接到类unix环境中的所有应用程序)中的函数。这允许攻击者执行任意序列调用libc函数,常见的例子是调用system(“/bin/sh”)启动shell。
虽然return-into-libc功能强大,但它不允许在被利用的应用程序的上下文中进行任意计算。为此,攻击者可能转向返回导向编程(ROP)。与前面一样,ROP覆盖带有返回地址和参数的堆栈。然而地址现在指向任意现有的代码库,唯一的要求是这些代码片段或gadgets以ret指令结束,将控制转移到下一个gadgets.Return-oriented编程已经证明在上是Turing complete的各种平台和代码库,和自动化技术使此类攻击的开发成为一个简单的过程。真实世界该技术的危险性在Checkoway等人的研究中得到证实。用它来破坏一个常用的电子投票机的完整性。
自从return-oriented的编程出现以来,人们提出了许多防御措施来检测或防止基于rop的攻击。例如,DynIMA检测每个以ret结尾的小指令序列的连续执行,并怀疑它们是ROP中的gadgets攻击。DROP观察到,ROP执行时连续弹出的返回地址总是指向同一个地址特定的内存空间,并认为这是rop固有的对其检测有用的特征。无返回方法更进一步,消除了程序中的所有ret指令,从而消除了以返回为导向的gadgets,排除了
ROP-based攻击。
在本文中,提出了一种替代的攻击范式称为 jump-oriented的编程(JOP)。在一个JOP-based攻击时,攻击者放弃对堆栈的所有依赖控制流和ret用于gadgets的发现和链接,而不是仅仅使用一个间接跳转序列指令。因为几乎所有已知的防守技术对抗ROP取决于它对堆栈的依赖,或ret, 它们中有一种能够发现或防御这种情况的新方法。一个例外是强制执行的系统完全控制流完整性.不幸的是,这类系统并没有被广泛部署,可能是由于担心它们的复杂性和负面的性能影响。
与ROP类似,JOP的构造模块仍然很短称为gadget的代码序列。然而,并没有结束每个这样的gadgets都以一个间接的jmp结尾。其中一些jmp指令是由编译器。其他的不是有意的,而是由于x86指令的密度和未对齐的可行性执行。然而,与ROP不同的是,ret设备可以控制的内容自然返回控件栈,jmp gadgets正在执行到它的目标的单向控制流传输,这使得很难重新获得控制,将下一个 jump-oriented的执行链返回gadgets。
我们注意到一种基于间接jmps的代码重用攻击早在2003年就被作为一种理论可能性提出了。然而,一直存在着一个尚未解决的问题:攻击者如何保持对程序的控制执行。没有像ret这样的共同控制机制把它们统一起来,却不清楚如何把这些gadgets与单向jmps连在一起
我们对这个问题的解决方案是新提出的gadgets的类,dispatcher gadgets。这样的gadgets旨在控制各种 jump-oriented的控制流gadget。更具体地说,如果把其他gadgets看作执行基本操作功能gadgets,dispatcher gadgets是专门选择来确定的,接下来将调用functional gadget。自然地,dispatcher gadgets可以维护一个内部分派表,该表显式地指定函数控制流gadgets。此外,它确保结束的jmp指令将功能gadgets把控制权转移回调度器gadgets。通过这样做, jump-oriented的计算变得可行。
为了实现相同的Turing-complete表达ROP的力量,在识别各种jump-oriented gadgets用于内存加载/存储、算术计算、二进制操作、条件分支和系统调用的gadgets。为此,提出了一种算法来发现和收集jump-oriented gadgets,将它们组织到不同的类别中,并将它们保存在一个中心化gadget目录中。
综上所述,本文的贡献如下:
扩展代码重用攻击的分类,x86包含了一种新的攻击类型: jump-oriented编程。与现有的return-oriented编程,攻击有不依赖栈上的控制流的好处。相反,介绍执行功能的设备要扮演这个角色的dispatcher gadgets的概念。
提出一种基于启发式的算法在x86上发现各种jump-oriented gadgets,包括critical dispatcher gadgets。研究结果表明所有这些gadgets都在动态链接到几乎所有UNIX应用程序的GNU libc中充分可用。
证明这种技术的有效性基于gadgets的jump-oriented的shellcode攻击由我们的算法发现。
论文的其余部分组织如下: 第2部分提供x86相关方面的背景知识,架构和现有ROP方法。接下来,第3节解释了新的 jump-oriented的编程攻击的设计,然后第4节给出了一个实现一个x86 Linux系统,包括一个具体的攻击示例。第5节检查了方法的局限性,并探索了改进的方法。最后,第6节介绍,第七部分是本文的总结部分。
2.背景
了解本文的贡献,有必要简要总结一下return-oriented编程背后的技术。由于系统基于32位x86 architecture2,讨论主要集中在该平台。
如图所示,x86堆栈由两个专用的CPU寄存器: esp的“堆栈指针”寄存器,它指向堆栈的顶部,和ebp的“基础”指针”寄存器,它指向当前的底部堆栈帧。因为堆栈向下增长,也就是增长在地址递减方向上,esp<=ebp。每一个堆栈帧存储每个函数调用的参数,返回地址、以前的堆栈帧指针和自动(局部)变量(如果有的话)。堆栈内容或指针可以是直接通过两个堆栈寄存器进行操作,或隐式地通过各种CPU操作码,如push和pop。指令集包括执行函数调用(call)和从函数调用返回(ret)的操作码。调用
指令推入下一条指令的地址(返回地址)到堆栈上。相反,ret指令将堆栈弹出到eip中,直接恢复执行后调用。
攻击者可以利用缓冲区溢出漏洞或覆盖部分堆栈的其他缺陷,如替换当前帧的返回地址和提供的值。在传统的返回到libc方法,这个新值是指向libc中由攻击者选择的函数的指针。后受害者程序使用新值并输入函数,即覆盖返回地址旁边的内存单元被函数解释为参数,允许攻击者指定的任意函数的执行参数。通过将这些恶意堆栈帧链接在一起,可以执行一系列函数。虽然这无疑是一种非常强大的能力,它不允许攻击者执行任意计算。为攻击需要启动另一个进程(例如,通过exec())或改变内存权限,使传统的代码注入攻击成为可能(例如,通过mprotect())。
由于这些操作可能导致检测或拦截,隐蔽的攻击者可能转而转向return-oriented的编程,它允许任意计算在易受攻击的应用程序的上下文中。return-oriented的编程是由返回的洞察力驱动的栈上的地址可以指向任何地方,而不只是指向函数的开始,就像经典的return-into-libc攻击一样。因此,它可以通过一系列直接控制流这些是现有代码的小片段,每个片段以ret结尾小的代码片段称为gadgets,大的代码片段称为gadgets足够的代码库(如libc),有大量的选择可供选择的gadgets在x86平台上,选择变得更大,因为指令是可变的长度,因此,如果解码从不同的字节开始,CPU将以不同的方式解释指令的原始字节偏移量。
基于此,return-oriented的程序只是一个简单的控件中列出的gadgets地址和数据值序列存在漏洞的程序的内存。在传统的攻击中,是溢出到堆栈中,但是可以加载缓冲区其他地方,如果攻击者可以重定向堆栈指针esp到新的位置。可以想到gadgets的地址作为操作码在一个新的return-oriented的机器,堆栈指针esp是它的程序计数器。在这个定义下, 就像传统代码的基本块没有一样显式排列程序计数器,一个“基本块”return-oriented的代码不显式排列栈指针esp。相反,条件分支可以通过修改esp的值来创建逻辑循环。算术、逻辑和条件的组合分支产生了一个Turing completereturn-oriented的机器。首先是一组满足这些要求的gadget在x86上发现的,后来扩展到许多其他的平台。此外,这类攻击还可以执行任意系统调用,因为这是一个简单问题调用适当库例程,甚至直接访问调用系统内核接口。因为这个,以return-oriented的攻击在表现方式上等同于成功的代码注入。
一些研究人员试图解决这个问题return-oriented的编程问题。每一种防御系统都能识别出一个特定的特征以回击为导向的攻击,并围绕它开发一种检测或预防措施。有些强制使用后进先出堆栈不变式,有些检测ret的过度执行指令,还有一个甚至从内核中删除了ret操作码的每个实例。这些技术的共同之处在于,它们都假定攻击者必须使用堆栈来控制流。本文介绍了jump-oriented编程一个新的替代方案,不依赖于堆栈,并且因此对这种防御免疫。
威胁模型在本工作中,假设对手可以将payload(例如,调度表-第3节)放入内存和增益控制寄存器的数量,特别是指令指针eip转移程序执行。这个假设是合理的,因为一些常见的漏洞,如缓冲区溢出、堆溢出和格式字符串错误存在满足这一要求。还假设存在一个重要的代码库,以便在其中查找gadget。与ROP一样,发现仅使用libc的内容可以实现,它是有类的unix环境动态链接的进程。在防御方面,脆弱的程序受到严格强制执行代码完整性的保护,从而击败传统代码注入攻击.
3.设计
下图比较了return-oriented的编程(ROP)以及我们提出的jump-oriented编程(JOP)。就像在ROP是一个 jump-oriented的程序,它由一组gadgets地址和加载到内存中的数据值地址组成, 类似于新jump-oriented机器中的操作码。在ROP中,这些数据存储在堆栈中,栈指针esp充当“程序计数器” return-oriented程序。JOP并不局限于使用esp引用它的gadgets地址,控制流不会按照ret的指示被驱动。相反,JOP使用调度表保存gadget地址和数据。“程序计数器”是指向分派表的任何寄存器。控制流是由执行的特殊分派器gadgets顺序驱动gadgets的。在每次调用时,调度程序推进虚拟程序计数器,并启动相关的gadgets。
给出一个JOP程序的控制流程示例。在这个例子中,实际上增加了两个内存值(分别由eax和ebx指向), store将总和存入另一个由ecx指向的内存位置,即[ecx] ← [eax] + [ebx]。
这项工作的主要目标是证明jump-oriented编程的可行性。证明它的表达能力可以与return-oriented编程相媲美。但是,通过不依赖堆栈进行控制流,JOP可以潜在地使用任何内存范围,甚至包括不连续内存,以保存调度表。
下面,进一步阐述dispatcher gadgets(第3.1节)和功能gadgets(第3.2节),它们的基本运算包括实际计算。在此之后,讨论常用的代码库(第3.3节)。最后,探索引导一个jump-oriented程序的可能方法(3.4节)。
3.1.分配器gadget
dispatcher gadget在JOP技术中起着关键作用。它本质上维护一个虚拟程序计数器pc,并通过推进它来执行JOP程序一个接一个的gadget。具体来说,每个pc值指定调度表中的一个条目,它指向一个特定的jump-oriented功能性产品。一旦被调用,每个功能gadget将执行基本操作,如算术计算、分支或特定系统调用的调用。
我们考虑任何可以实现jump-oriented的gadget以下算法作为调度程序候选。
1 | pc ← f(pc); |
在这里,pc可以是一个内存地址或寄存器,表示指向我们的跳转程序的指针。它不是CPU的指令指针——它指的是由攻击者提供的gadget表。函数f(pc)有改变程序计数器pc可预测任何操作和演进的方式。在某些情况下,它可以通过纯粹的算术简单地表示 (f(pc) = pc + 4 as shown in Figure 3). 在其他情况下,它可能是内存解引用操作(例如,f(pc) = ∗(pc−18))或任何其他可以被攻击者预先预测的表达式。每次调用dispatcher gadget时,都会调用相应的pc。然后调度程序取消对它的引用并跳转到产生的地址。
考虑到dispatcher的广泛定义,没费什么事就找到了几个可行的候选libc。调度gadget推进pc的方式影响调度表的组织。具体地说,如果pc是重复的,调度表可以是一个简单的数组由一个常量(例如,f(pc) = pc+4)或一个链接,如果内存被解引用,则列出(例如,f(pc) = ∗(pc−18))。第4节中的示例攻击使用数组来组织分派表。
这种新的编程模式扩展了ROP中使用的基本代码攻击。具体来说,如果考虑基于rop的程序中使用的堆栈作为其调度表和esp作为它的pc机,在每个返回的gadget的末端的ret指令作为一个调度器来推进pc机,每完成一个gadget就乘以4,即f(pc) = pc + 4。然而,所有基于rop的攻击仍然依赖于堆栈, 但在基于jop的攻击中不再需要。
3.2.功能gadget
dispatcher gadget本身并不执行任何实际的操作, 它独立工作——它的存在完全是为了发送其他gadget,称之为功能性gadget。保持控制执行,调度程序执行的所有功能gadget, 得出结论一定要跳回去,这样下一个gadget才可以启动。
更正式地说,功能性gadget被定义为许多有用的指令,它们以将要加载的带有已知表达式结果的指令指针序列结束,。这个表达式可以是一个寄存器 (jmp edx),一个寄存器解引用 (jmp [edx]),或者一个复杂的解引用表达式 (jmp [edx + esi*4-1])。唯一的要求是在执行分支时,它必须计算到调度程序的地址,或者计算到导致调度的程序。然而,攻击并不依赖于这些分支的特定操作数: 功能gadget可能会改变CPU状态,以便为下一次操作提供一组不同的gadget。例如,一个gadget可能会以jmp [edx] 结束,然后另一个gadget可能会使用esi之前用于计算edx寄存器调度程序地址加载,并以jmp esi终止。此外,这个功能性gadget可能会对pc产生影响,这使得实现条件分支在 jump-oriented的程序中成为可能,包括引入循环。对于分支使用最明显的操作码是间接跳转(jmp),但值得注意的是因为没有依赖栈,也可以使用以调用结束的序列,因为将返回地址推入堆栈是无关的。
这里为了获得与ROP同样的表现能力需要一些不同种类的功能gadgets,简要地介绍一下。例子将在第4节中介绍。
用return-oriented的方法加载数据, 放置数据的明显位置: 堆栈本身。允许无处不在的pop指令加载寄存器。然而,加载数据值的方式多种多样任何从指针加载和推进指针的gadgets都可以。
在x86上,有各种各样的字符串和循环加载的序列。而且,即使jop没有依赖栈来控制流,没有栈的理由不能作为数据加载机制, 在ROP中,现有的防御技术关注于保护基于堆栈的控制流,而不是简单的数据访问。实现时,栈指针esp被重定向堆栈用于此目的。
- 访问内存
要访问内存,需要加载和存储gadgets。这些gadgets需要一个内存地址读取或写入该位置的字节或字。
- 算术和逻辑
一旦操作数(或指针操作数)被装入CPU寄存器,ALU操作可以通过寻找具有适当操作码(add、sub和or等)的gadget来应用。
- 分支
通过修改pc机的寄存器或内存位置可以实现无条件分支。条件分支通过调整基于先前pc计算的结果来实现的。有几种方法是可以实现的,包括将计算值添加到pc机,使用在gadget内基于逻辑的一个短的条件分支来改变pc,甚至使用x86的特殊条件移动指令更新pc(cmov)。
- system calls
上述设备足以使JOP Turing complete(即,能任意计算),系统调用需要执行大多数实际任务。建立一个系统有几种不同的方法调用。首先,通过设置具有适当参数和返回值的堆栈,可以调用合法函数恢复适当CPU的gadget地址状态并执行调度程序。然而,因为它可能让现有的ROP防御系统检测到这一点,更谨慎的方法是直接进行系统调用。执行此操作的方法因CPU和操作系统的不同而不同。在基于x86的Linux上,可以执行, 如果要触发中断,需要跳转到内核提供的中断例程调用__kernel_vsyscall来执行一个sysenter指令,甚至直接执行sysenter指令。
3.3.gadget探索发现
na¨ıve方法就是拆解目标二进制和寻找间接跳转或者调用指令来定位产品。但是,在x86平台上,指令长度可变,因此从相同的内存解码一个偏移量与另一个偏移量的操作可以产生不同的集合。这意味着每个x86二进制文件都包含一个可以访问的意外代码序列跳转到不在原始指令边界上的偏移量。考虑到这一点,一个定位gadget的算法ret是Shacham在ROP背景下给出的。
在gadget发现过程中采用了类似的方法。该算法通过扫描二进制文件的可执行区域来寻找间接分支指令的有效起始字节。在x86上,它由字节0xff和第二个字节组成,第二个字节具有特定的值范围,这样的序列可以通过线性搜索定位。从这里开始,一个字节一个字节地后退一步并解码每个可能终止于间接跳转的gadget是一件很简单的事情。这种方法在以下算法中正式定义。
1 | # procedure IsViableGadget(G) |
如算法中所示,FindGadget(C)过程使用字符串搜索来查找代码基C中的间接跳转,然后向后遍历最多δmax字节并反汇编每个结果代码区域。δmax的值是一个器件的最大尺寸,以字节为单位。它的选择取决于给定体系结构上指令的平均长度以及每个gadget要考虑的最大指令数。经验是,正如在ROP中观察到的,有用的gadget不需要超过5条指令。
有几个标准可以在这个阶段消除潜在的gadgets; 这些由IsViableGadget(G)过程检测。首先,由于算法每次后退一个字节,因此最初是间接跳转的序列可能不再被解释为间接跳转。如果是这样,这个gadget就不存在了。其次,间接跳转的目标可以是一个寄存器值(例如,esi)、寄存器([esi])所指向的地址,或者内存解引用([0x7474505b])所指向的地址。在后一种情况下,如果给定的地址在运行时不太可能是有效的、可写的位置,那么这个gadget就被消除了。第三,如果这个gadget的任何部分没有编码合法的x86指令,这个gadget就会被删除。最后,gadget本身可能包含一个与最后间接分支分离的条件分支。如果这个分支的目标在gadget边界之外,gadget就被消除了。而且,如果分支的目标与gadget中确定的指令不一致,就会删除分支。
这将在代码库中生成一组可能有用的gadgets,而在libc这样的大型代码库中,这将意味着成千上万个候选gadgets。这个集合通过启发式(G)进一步缩小,启发式(G)根据gadgets在特定用途上的可行性来过滤它们。在ROP中已经有很多关于完全自动化gadget搜索的工作,而JOP gadget搜索增加了额外的复杂性。因为每个gadgets都必须以返回调度器结束,所以必须小心确保用于此目的的寄存器在需要之前被正确设置。这在定位和链接 jump-oriented的gadgets时引入了两个要求。这个gadgets不能摧毁它自己的跳跃目标。但是,如果这种修改可以由以前的gadgets补偿,则可以修改目标。例如,如果一个gadget在结束jmp [edx]之前将edx作为附属加1,那么gadget开始时edx的值应该比预期值小1。因为gadgets是连在一起的,所以早期装置的附属不能干扰后续装置的跳跃目标。例如,如果在gadgets A中使用一个寄存器进行计算,并在gadgets B中用作跳转目标,那么在使用gadgets B之前,中间的gadgets必须将这个寄存器设置为调度程序地址。
由于增加了复杂性,在这项工作中搜索gadget需要额外的启发式,在算法中表示为启发式(G)。将在下面描述这些启发式中最有趣的部分。
为了在代码库中定位潜在的dispatcher gadget,我们开发了dispatcher启发式。该算法的工作原理是将搜索算法定位所有潜在gadget过滤到一个小集合,攻击设计者可以从中选择。对于每个gadget,首先在gadget的最后一条指令中获得跳转目标,然后根据三个条件检查gadget序列中的第一条指令。
首先,指令必须以跳转目标作为其目标操作数。如果gadget没有修改跳转目标,那么它就不能是dispatcher。
其次,根据操作码过滤gadgets。由于各种各样的x86操作码可能会促进pc的发展,通过黑名单过滤操作码比通过白名单更方便。因此,抛出不能对目标进行至少单词大小排列的操作码。
第三,完全覆盖目标操作数的操作(例如mov)必须是自引用的,也就是说,目标操作数也存在于源操作数中。例如,“加载有效地址”操作码(lea)可以基于一个或多个寄存器执行计算。指令lea edx,[eax+ebx] 在调度程序中不太可能有用,因为它会用eax+ebx 的计算覆盖edx—它不会使edx提前一个可预测的值。相反,指令lea edx,[edx+esi] 通过存储在esi中的值推进edx,因此是一个调度程序候选。自我引用的要求并不是严格必需的,因为可以有一个多寄存器方案充当调度程序,但是强制执行这个要求通过消除大量的误报大大简化了搜索。
一旦这些gadget经过这三个条件的过滤,我们就会检查每个候选者,并选择使用最不常用寄存器的一个。这是因为dispatcher使用的一个或多个寄存器将无法进行计算,这意味着依赖于这些寄存器的功能gadget将不可用。因此,为了使最大数量的功能gadget可用,选择使用最不常见寄存器的调度器。
有许多启发式方法可用于定位不同类型的功能gadgets。在条件转移gadgets的情况下,条件转移操作可以分为两个步骤: (1)根据比较更新通用寄存器,(2)利用这个结果对pc进行排列。因为第2步是一个简单的算术操作,所以将重点放在寻找实现第1步的gadgets上。
比较的结果存储在CPU的比较器flag寄存器中(x86上的EFLAGS),最常用的利用这些flag的方法是使用条件跳转指令。例如,在x86上,je指令将“如果相等就跳转”,即如果设置了“零flag”ZF。要找到利用这些指令的gadgets,启发式只需定位那些第一个指令是有条件跳转到同一gadgets后面的另一个指令的gadgets。这样的gadgets将有条件地跳过gadgets主体的某些部分,并可能被用于捕获通用寄存器中的比较结果,随后可以将其添加到pc中。
除了使用条件跳转之外,一些cpu,比如x86的现代迭代,还支持“条件移动”(cmov)和“在条件下设置字节”(set)指令。可以寻找一个使用这些指令有条件地改变寄存器的gadgets。
最后,还有隐式访问比较器flag的指令,例如adc(“带进位的加法”)。这指令就像一个正常的添加, 除了目标操作数将增加一个进一步如果设置了“进位标记”。因为携带flag代表了一个无符号整数的结果比较cmp指令,指令像adc条件转移指令,因此可以用于更新通用寄存器的比较结果。
相比之下,查找算术、逻辑和内存访问gadget的启发式方法要简单得多。只需要将操作码限制为所需的操作(add、mov等),并确保任何目标操作数不会与跳转目标冲突。
3.4.发动攻击
可能导致jump-oriented攻击的漏洞与return-oriented的编程类似。然而,关键的区别在于,ROP需要控制指令指针eip和堆栈指针esp,而JOP需要eip以及用于运行dispatcher gadget的内存位置或寄存器集。在实践中,这可以通过一个特殊的初始化器gadget来引导控制流来实现。具体来说,initializer gadget通过算术和逻辑或从内存加载值来填充相关的寄存器。一旦完成了这一步,初始化器就跳转到调度程序, jump-oriented的程序就可以开始了。initializer gadget可以采取多种形式,这取决于需要填充的寄存器的组合。一个简单的例子是执行popa指令的gadget,它从堆栈中加载每个通用寄存器。初始化器在所有情况下都不是严格必需的: 如果攻击者可以在寄存器恰好设置为有用值时接管控制流,那么分派器可以直接从那里运行。
前面已经深入讨论了可能导致return-oriented攻击的精确漏洞。由于空间的限制,省略了这里的细节,仅仅总结了攻击者可以通过重写堆栈、函数指针或setjmp缓冲区来启动jump-oriented攻击。由于前两个已经众所周知,在下面解释setjmp缓冲区。
- setjmp缓冲区
C99标准指定了setjmp()和longjmp()函数作为实现非本地goto的方法。该功能经常用于复杂的错误处理程序和用户模式线程库,例如pthreads的某些版本。程序员分配一个jmp_buf结构体,并在控制流最终返回的程序点使用指向该结构体的指针调用setjmp()。setjmp()函数将把当前CPU状态存储在jmp_buf对象中,包括指令指针eip和一些(但不是全部)通用寄存器。此时函数返回0.
稍后,程序员可以通过jmp_buf对象调用longjmp(),从而绕过所有堆栈语义,将控制流返回到最初调用setjmp()时的点。这个函数将恢复保存的寄存器并跳转到保存的eip值。此时,就好像setjmp()第二次返回一样,现在返回值是非零的。如果攻击者可以覆盖这个缓冲区,并且随后调用longjmp(),那么可以将控制流重定向到initializer gadget,以开始jump-oriented的程序。由于这种技术的简单性,在示例攻击(第4.4节)中使用了它。
4.实现
为了演示JOP技术的有效性,在现代Linux系统上开发了一种jump-oriented的攻击。具体来说,这种攻击是在32位x86平台上的Debian Linux 5.0.4下开发的,所有gadgets都是从GNU libc库中收集的。Debian为不同的CPU和虚拟化环境提供了libc的多个版本。目标库是/lib/i686/cmov/libc2.7. 所以,cpu版本支持条件移动(cmov)指令。下面,首先检查libc中gadget的总体可用性,然后介绍dispatcher和其他功能性gadget的选择。在此之后,将提供一个完全jump-oriented的示例攻击。
4.1.可用的gadgets
Jump-oriented编程需要以间接分支结尾的gadgets,而不是ret指令。这些分支可能是jmp指令,也可能是调用指令(不关心将堆栈用于控制流)。回想一下,x86的可变指令大小允许对同一代码进行多种解释,导致编译器生成一组预期的指令,以及通过从不同的偏移量重新解释代码发现的另一组意外指令。为了检查JOP和ROP中gadget的相对可用性,在图中显示了ret指令数量和间接jmp和call指令数量之间的比较。
如果被限制只使用预期的jmp和调用gadgets,那么仅在libc中就不太可能有足够的gadgets来维持一个Turing-complete的攻击代码,因为目前只有几百条这样的指令。然而,如果考虑到无意识的指令序列,就会有更多的gadgets可供选择。这在很大程度上是由于x86指令集的一个特定方面: 间接跳转的第一个操作码字节是0xff。因为x86使用2的补码带符号整数,所以小的负数包含一个或多个0xff字节。因此,除了操作码中提供的0xff字节外,代码流中存储的直接操作数中还有大量0xff字节可供选择。事实上,0xff是libc可执行区域中第二流行的字节,0x00是第一个。这意味着,在概率上,间接调用和跳转比其他情况要普遍得多。多亏了这一点,有大量候选跳跃gadgets可供选择。
为了搜索gadgets,应用第3.3节给出的算法。在这样做时,必须为δmax选择一个值,δmax是要考虑的最大的器件尺寸,以字节为单位。保守值是平均gadgets长度(5)乘以平均指令长度(3.5),即 5 · 3.5 = 18。然而,使δmax太大的唯一副作用是包括可能因其长度用处有限的器具,所以在包容性方面犯错误,设置δmax = 32字节。稍后,gadget列表可能按每个gadget的指令数量排序,以便专注于更短的、更有可能的选择。
当gadgets搜索算法应用于libc的可执行区域时,可以找到31136个潜在的gadgets。下面两个部分描述了如何通过启发式和手工分析筛选这些候选对象,以定位调度程序和功能gadgets来发动攻击。
4.2.Dispatcher Gadget
使用第3.3节中描述的启发式方法,将潜在gadget的完整集合减少到35个候选gadget。因为有如此多的选择,可以排除超过两个指令(任何有用的Gadget的最小长度)的序列,但仍然有14个候选指令可供选择。通过人工分析,发现其中12个是可行的。这些选择使用算术或解引用来改进pc,并依赖各种寄存器进行操作。由于调度程序使用的寄存器不能被功能性Gadget使用,因此选择使用最不常见寄存器的调度程序将使功能Gadget的范围更广。考虑到这一点,在示例shellcode中选择了以下dispatcher Gadget:
1 | add ebp, edi |
这个Gadget使用堆栈基指针ebp作为跳转目标pc,向它添加存储在edi中的值。发现,就功能Gadget而言,这两个寄存器在编译器生成的代码中都没有发挥重要作用。同样,应用于jmp指令的常量偏移量-0x39的影响很小,因为在开始设置ebp时可以静态补偿这个偏移量。因为它简单、可预测,并且只使用两个不太需要的寄存器,所以选择这个dispatcher Gadget来驱动第4.4节中使用的shellcode示例。
4.3.其它gadget
一旦调度程序就位,首先需要的功能gadgets之一就是加载操作数的方法。在ROP中,这是通过将数据与指向gadget的返回地址混合放置在堆栈中实现的。这样,gadgets就可以使用pop指令来访问数据。没有理由不能在JOP中应用这种方法,因为反rop防御技术关注的是滥用堆栈作为控制执行流的手段,而不是数据。在实现中,攻击的一部分包括将堆栈指针esp移动到恶意缓冲区的一部分。然后可以通过pop指令直接从缓冲区加载数据。这构成了load data gadgets的基础。启发式可以用于定位这些gadgets; 唯一的要求是 (a)候选的第一个指令必须是一个弹出到一个通用寄存器,而不是所选择的调度程序(ebp和edi)使用的寄存器,以及 (b)结尾的间接跳转不能将这个寄存器用于它的目的地。这个启发式在libc中产生了60种可能性,因此进一步过滤结果,只包括具有3条或更少指令的gadget;这给出了22种可能性。手工分析这个列表会产生14个load data gadget,这些gadget可以用来加载没有涉及到dispatcher的任何通用寄存器。没有必要进一步过滤—因为这些gadgets有不同的副作用和间接跳转目标,每一个都可能在不同的时间有用,这取决于在jump-oriented的程序中用于计算的寄存器。
如果需要同时加载所有寄存器,则可以执行使用popa指令的gadget。这条指令一次从堆栈中加载所有通用寄存器。这构成了initializer gadget的基础,它用于在攻击开始时准备CPU状态。
类似于搜索加载数据gadget,基本的算术和逻辑gadget可以通过简单的启发式找到。由于空间的限制,可以这么说,有大量实现这些操作的gadget可供选择。将gadget的长度限制为三条指令,发现add gadget有221个选择,sub有129个选择,or有112个选择,xor有1191个选择,等等。
实现对内存的任意访问也可以通过类似的方法实现。最直接的内存gadgets使用mov指令在寄存器和内存之间复制数据。寻找内存写入gadgets的启发式方法只需要找到mov [dest],src 形式的指令,内存读取gadgets是mov dest,[src] 形式的指令。与大多数x86指令一样,mov中的内存地址可能会被一个常量所抵消,但这可以在设计攻击时得到补偿。根据以上观察,搜索libc可以找到150个可能的加载gadgets和33个可能的基于mov的写gadgets。这还不包括大量隐式执行加载和存储操作的x86指令,比如字符串操作指令lod和sto。
为了定位条件分支gadgets,应用了3.3节中描述的启发式方法。到目前为止最常见的手段将比较的结果移动到一个通用寄存器通过adc和sbb指示,工作像添加和子,除了递增/递减一个进一步如果CPU设置“进位标记”。因为这个标志代表了一个无符号整数的结果比较,产品具有这些指令可以用来执行条件分支。libc中有1664个这样的gadgets,其中333个只有两条指令。这些gadgets可以更新任何通用寄存器。为了完成条件跳转,只需要应用之前找到的简单算术工具,将更新的寄存器的若干倍添加到pc上。
为了执行系统调用,攻击者可以采取许多不同的方法。当然,攻击者可以安排调用常规库例程,如system()。然而,由于这将涉及构建一个人工堆栈框架,因此这种方法存在被现有的反rop防御系统检测到的风险。相反,攻击者可以通过内核的通常接口直接请求系统调用。在基于x86的Linux上,这可以通过执行sysenter指令来访问“快速系统调用”功能来实现。
要使用这种机制,调用者将
(1)将eax设置为系统调用号,
(2)将寄存器ebx、ecx和edx设置为调用的参数,
(3)执行sysenter指令。
通常,调用者也会将ecx、edx、ebp和下一条指令的地址推送到堆栈上,但是这种记录对于jump-oriented的攻击者来说是可选的。相反,可以利用返回地址是在堆栈上指定的这一事实,将它指向调度程序。这意味着sysenter gadget不需要以间接跳跃结束。注意,这个返回地址与普通函数的返回地址不同 — 内核接口允许用户设置这个值。这是因为所有的系统调用在用户空间中都有相同的退出点: 一小段内核提供的代码跳转回存储的地址。
因此,进行系统调用的唯一挑战是填充正确的寄存器。随着参数数量的增加,这变得越来越困难。对于有三个参数(如execve())的调用,需要同时设置eax、ebx、ecx和edx。这有点棘手,因为除了系统调用所需的寄存器之外,没有任何popa gadgets会基于寄存器跳转,而且gadgets的选择也会受到限制,因为通用寄存器被特定的值所占用。然而,通过将多个gadgets链接在一起,只使用libc中的材料就可以进行任意的系统调用。例如,下面的gadgets序列将从攻击者提供的内存中加载eax、ebx、ecx和edx,然后进行系统调用。这个gadgets序列用于为下面给出的示例攻击构建shellcode。
1 | popa ; 加载所有寄存器 |
4.4.示例攻击
由于其简单,使用一个类似于Checkoway和Shacham给出的漏洞测试程序。该程序的源代码如下所示。实际上,这个程序将第一个命令行参数argv复制到堆上256字节的缓冲区中。由于该程序没有限制复制的数据量,因此该程序容易受到第3.4节中描述的setjmp攻击。攻击者可以溢出缓冲区,并在第17行上调用longjmp函数时控制寄存器ebx、esi、edi、ebp、esp和指令指针eip。这个特定的应用程序仅仅是一个例子: 任何提供对指令指针和其他寄存器的控制的利用都有可能被用来发起jump-oriented攻击。
- 示例漏洞程序:
1 | #include <stdlib.h> |
使用这个程序作为平台来启动一个jump-oriented的shellcode程序,该程序最终将使用execve系统调用来启动一个交互式shell。具体地说,示例攻击是在NASM中构造的,尽管NASM是一种汇编程序,但它只用于指定原始数据字段。NASM的宏和算术特性允许以一种简单的方式表达攻击代码。攻击的源代码在技术报告中给出。
当NASM组装该脚本时,它将生成一个二进制利用文件,然后作为命令行参数提供给受攻击的程序:
1 | $ ./vulnerable "`cat exploit.bin`" |
这将启动jump-oriented的程序,并最终产生一个交互式shell而不需要任何ret指令。
5.讨论
在本节中,将检查可能的限制,并讨论jump-oriented编程技术的进一步改进。首先,虽然发现JOP理论上能够进行任意计算,但手动构造攻击代码是一项复杂的任务,甚至比ROP还要复杂。主要原因是JOP gadgets增加了一层相互依赖。具体来说,由于依赖某些寄存器作为jump-oriented的系统的“状态”(例如,指向调度表的指针和每个gadgets执行后对调度程序的回调),对于可以组装的gadgets的顺序有复杂的限制。通常,攻击者需要引入那些唯一目的是让下一个gadgets工作(例如,通过设置跳转到目标寄存器)。这自然会使自动化技术的开发复杂化,从而促进jump-oriented的编程。
其次,虽然jump-oriented编程的思想在理论上适用于指令长度固定的架构(SPARC、ARM等),但可能需要更大的代码库才能实现完全的Turing-complete操作。这是因为x86的两个特性使得基于jmp和call的gadget特别丰富:
(1) 可变长度指令允许对代码流进行多种解释,
(2) 间接分支指令从特别常见的0xff字节开始。
深入分析将JOP应用于替代平台(如MIPS)的可行性和效率,包括dispatcher gadget的可移植性和相关的调度表(第3节),是留给未来工作的一个重要问题。
第三,如果检查两种不同编程模型的性质,即ROP和JOP,漏洞的基础不是返回或间接跳转,而是允许进入可执行程序或库中的任何地址的混杂行为。为了防御它们,有必要加强控制流的完整性。从另一个角度来看,可能会想当然地认为,这种攻击可以通过识别异常(如类似调度器的行为或高频的间接跳转)来简单地击败。不幸的是,事实并非如此。通过安排执行长时间运行的函数并定期更改调度程序,可以很容易地避免此类防御。接下来,将检查相关的工作并讨论一些正交防御,这些防御可以用来阻止或阻止return- or jump-oriented的编程。
6.相关工作
- 反ROP防御
最近,一些防御系统被提出来检测或防止return-oriented攻击。例如,ROPdefender基于单独的阴影堆栈,提出了一种二进制重写方法来确保每个返回目标的有效性,从而阻止了面向返回的gadget的执行。DROP和DynIMA通过监视每个以ret结尾的短指令序列的执行来检测基于rop的攻击。无返回方法认识到构建gadget和链接需要ret,并开发了一种基于编译器的方法来消除ret操作码的存在。相反,通过避免依赖ret或返回堆栈来发起基于jop的攻击,jump-oriented的编程对这些防御免疫。在这方面,JOP反映了正在进行的安全军备竞赛的趋势。最近,Onarlioglu等人介绍了G-Free,这是一种编译器,旨在生成无gadget的二进制文件。这是通过删除所有非预期的控制流传输,然后通过指针加密和堆栈cookie保护预期的控制流来实现的。由于该方案保护了间接跳转和调用指令,因此可以防止它们被滥用,并从本质上将现有的ROP攻击反泛化为传统的return-into-libc攻击。
独立于工作,Checkoway等人提出了一种并行的方法,用x86上的pop+jmp取代ROP中的ret,这与原始ROP模型有了进一步的发展。然而,pop+jmp序列很少,因此需要“自带 pop+jmp”范式,在这种范式中,序列必须在一个特别大的代码库中找到。事实上,对32位x86平台上Debian Linux 5.0.4中默认libc的文本部分的分析并没有得出一个pop+jmp序列。此外,使用这样的序列仍然需要依赖堆栈来控制gadget之间的控制流。相比之下,JOP模型没有这样的限制,因此威胁到更广泛的应用程序和环境。在这项工作中还提出了一个ARM实现,它依赖于一个“update-load-branch” gadget来维护控制流。这种相似性为一种理论提供了证据,即jump-oriented的攻击可能是一种跨平台威胁,而不限于x86。
从另一个角度来看,其他正交防御方案(如。已经被提出来抵御代码注入攻击。具体来说,地址空间布局随机化(ASLR)会随机化正在运行的程序的内存布局,这使得很难确定libc和其他合法代码中的地址,这些地址是基于返回到libc或基于ROP/jop的攻击所依赖的。然而,有一些反随机攻击绕过ASLR或限制其有效性。相反,指令集随机化(ISR)将每个运行进程的指令集随机化,这样即使攻击可能成功劫持了控制流,注入的攻击代码中的指令也无法正确执行。然而,基于return-into-libc和ROP/jop的攻击是无效的。
- 内存安全
在过去,许多防御机制也被提出来更好地加强记忆安全。例如,CFI和程序引导被设计用来保护正在运行的程序的控制流完整性属性。DFI等基于控制流完整性属性,并进一步扩展它以实现其他类型的内存安全(例如,数据流完整性)。注意,如果严格执行控制流完整性,ROP和JOP首先就会被阻止劫持控制流。然而,精确的CFI实施需要复杂的代码分析,这可能很难获得,特别是对于具有大型代码库的程序,包括libc或现代操作系统内核。此外,CFI并没有得到广泛的部署,这可能是由于对性能的担忧,特别是在实时执行的情况下。
- 其它代码复用
最近,研究人员发现了一些有趣的应用程序,可以重用恶意代码中的某些代码片段,以便更好地理解它们。例如,Caballero等人提出了BCR,这是一种旨在从(恶意软件)二进制文件中提取函数,以便以后可以重用的工具。Kolbitsch等人开发了Inspector,以重用二进制文件中的现有代码,并将其转换为一个独立的gadget,以后可以用来(重新)执行特定的恶意软件功能。相比之下,ROP和JOP可以重用漏洞程序的合法代码来构造任意计算,而不需要注入代码。
7.结论
在本文中,提出了一类新的代码重用攻击—jump-oriented编程。这种攻击消除了return-oriented的编程对堆栈和rets的依赖,但没有削弱它的表达能力。特别是,在这种攻击下,可以构建普通功能gadget,并将它们与每个执行特定原始操作的gadget连接起来。然而,由于缺乏ret将它们连接起来,这种攻击依赖于dispatcher gadget来分派和执行下一个功能强大的gadget。成功地开发了一个jump-oriented编程的shellcode攻击示例,GNU libc中丰富的jmp gadget表明了这种攻击的实用性和有效性。