论文:Return-Oriented Programming without Returns
X86上的ULB
举例:
pop %eax;
jump *%eax
其中,X是任何通用寄存器。由于x86 ISA的临时特性,pop-jump序列作为非预期的指令以某种频率出现。
- 用另一个通用寄存器代替eax(esp除外)
- 使用双重间接跳转而不是单独间接跳转
- 使用具有立即偏移的双重间接跳转,通过添加或减去序列地址来抵消立即偏移的影响
- X86提供了两种双重间接跳转:near jump(在当前段中采用32位地址)和far jump(采用32位地址和16位段选择器)
其他可能的ULB指令序列:
- 基于调用X的序列,会在每次使用时减少esp
- 使用与esp不同的寄存器
例如
add 0x4,%eax;
jmp(%eax); - 使用SIB寻址,可使用寄存器组合,索引寄存器在每次引用后加4
- 内存位置作为可变状态而非寄存器
由于存在多种多样的可能构造ROP攻击的指令序列,进行不全面检测的防御方式效果明显有限。攻击者很可能切换到不同的返回类序列从而逃避检测。
选择Gadgets
来源:
Debian的libc本身
Mozilla的libxul
PHP的libphp5
编译器是否可以修改,从而避免发出pop-jump序列?
不能。
使用以jmp y结尾的指令序列,其中y是指向pop x的指针。pop x;jump x不在libc中,就必须存在于目标程序或它的一个库中。我们称其为BYOPJ范例(bring your own pop-jump)。
libc在每个Linux程序中都会被加载,我们可以使用它作为gadgets的语料库。但很大程度地使用libc中的指令序列而不使用libxul中的pop-jump序列不是一个真正的攻击者会使用的攻击方法。
大多可用的指令序列以近间接跳转或远间接跳转结束,跳转的地址在内存中被存储在存储器edx中。即很多可用的指令序列是以 jmp *(%edx) 或 ljmp *(%edx) 结束。为了将这些gadgets链接起来,我们要确保每个gadget的结尾都能够保存 pop *X; jmp *X 序列目录条目的地址。大多情况下,我们只需要修改函数调用gadget即可。
本文的设计:
三地址码的内存-内存gadget集。格式为:
x<—y op z
其中x,y和z处于内存中随意位置,包含了操作数和目标地址。
我们设计了一个三地址代码内存 - 内存小工具集:我们的小工具的形式是x yopz,其中x,y和z是内存中包含操作数和目标的文字位置。 我们使用寄存器edx来链接我们的指令序列; 对于我们BYOPJ范例中的pop xjmp x序列,我们使用寄存器ebx。 这意味着我们不能在寄存器ebx中存储任何状态,但我们不必担心在指令序列过程中更改其内容,因为它将在pop%ebx期间被覆盖。 这给我们留下了五个寄存器,eax,ecx,ebp,esi和edi,可以随意使用。
指令序列
我们使用了34个不同的以jmp x结尾的指令序列,来构造19个通用的gadgets:
load immediate,
move
load
store
add
add immediate
subtract
negate
and
and immediate
or
or immediate
xor
xor immediate
complement
branch uncon-ditional
branch conditional
set less than
function call
这些指令序列大多包含四个或更少的指令。这些序列是从libc的潜在指令序列集合(由Shacham的算法提供)中选出的。
数据的移动
gadgets需要能够在存储器和寄存器之间以及多个寄存器之间移动数据。方法如下:
- 将数据从存储器移入寄存器:mov n(x),y
- 将数据从寄存器移入存储器:mov y,n(x)
- 寄存器之间的移动有两种方法:
a) 使用xchg指令交换两个寄存器的内容
b) 将目的地址寄存器设置为0x00000000或0xffffffff,可以对源地址寄存器和目的地址寄存器进行or或and运算,从而实现移动
将固定地址处的值加载到内存中:
加载具有立即值的esi和具有固定地址的eax加上0xb来轻松实现。这需要两个pop操作。然后我们使用mov%esi,-0xb(%eax)将立即值写入内存。
Move Gadget:
将源地址加载到eax,将目标地址加载到ebp,从eax加载到edi,最后将edi存储到ebp中的地址的内存中。
Load Gadget:
不将内存中源地址中的字存储在目标地址处,而是用作指向存储器中另一个字的指针,该字加载到另一个寄存器中,然后存储在目标地址中。伪代码:
eax <— source
edi <— (eax)
esi <— (edi)
eax <— destination
(eax) <— esi
Store Gadget:
执行操作(A)B,其中A是目的地址处存储的字,B是源地址处存储的字。实际上,我们可以执行操作(A + n)B,其中n是字面值。这使常量数组容易索引到在内存中位置不固定的值,其中A是数组基数,n是偏移量。
算术运算
add,add immediate和subtract的gadget是比较容易实现的。它们将源操作数加载到寄存器中,执行适当的操作,然后将结果存回内存。 x86 ISA允许其中一个操作数成为内存中的一个位置,这样就不需要加载其中的一个操作数。这简化了gadgets。
Negate gadget从源地址加载数据,取双字的补码,并将其存储回内存。执行寄存器的二进制补码的neg指令不会出现在jmp x指令附近;我们使用xor%esi,%esi使esi为零,然后使用序列:subl -0x7D(%ebp,%ecx),%esi; jmp(%ecx),从零中减去该值。 subl指令执行操作esi esi(ebp + ecx 0x7D)。由于我们的jmp x使用ecx,我们必须用指向pop x; jmp x序列的指针地址加载它。这意味着ebp必须具有源地址加上0x7D再减去pop x; jmp x指针的地址的值
。
逻辑运算
and,and immediate,or,or immediate 的gadgets构造方式与add gadget类似。即,将操作数加载到寄存器中,执行操作,并将结果存回内存。唯一棘手的部分是如上所述的寄存器之间的数据移动。
xor和xor immediate 的gadgets类似,它不是将两个寄存器的值进行xor然后将结果存储回内存,而是将第一个源字写入目标地址,然后使用第二个源字xor该地址。
Complement gadget 计算源值的一个补码并将其存储到目标地址中。类似于negate gadget的情况,有一个不执行补码运算的x86指令没有出现在libc中的有用的指令序列中。我们完全按照nagate gadget进行操作,除了不使用零加载esi而使用0xffffffff = -1加载它。这是因为-1-x =非x。
分支Branching
- 绝对地址实现分支
- 相对地址实现分支
在正常程序中,分支可以是绝对地址,也可以是相对于当前指令的地址。 在面向返回的编程中,通过更改堆栈指针esp而非指令指针eip来执行分支。 我们可以通过将堆栈中的值弹出到esp中来实现绝对分支。 或者,可将小工具末端的负偏移弹出到edi中,然后使用序列sub %edi, %esp; ljmp(%eax)从堆栈指针中减去edi。这允许堆栈指针实现相对分支。 这是branch unconditional gadget的基础。
条件分支gadget:
为了获得Turing-complete行为,我们必须有办法执行条件分支。 我们需要一种方法通过存储在已知地址的内容来改变堆栈指针。 如果字的值为零,那么我们不会更改堆栈指针。 如果字是0xffffffff,那么像branch unconditional 的情况一样,减去堆栈指针的偏移量。 实现的方法是将字的值加载到寄存器中并与偏移量相与,再从堆栈指针中减去该结果。 该实现是and gadget 和 branch unconditional gadget的组合,即branch conditional gadget。
比较的实现:
如果第一个值小于第二个值,则将目标地址处的字设置为0xffffffff。
图中给出了实现小于判断的gadget实现。字符串比较指令cmpsl比较%ds:%esi和%es:%edi指向的两个值,如果后者大于前者,便设置进位标志。同时,寄存器esi和edi的值将根据方向标志来增加或减少。然而,这并不重要,因为我们只是比较一个字值。 sbb指令从esi中减去esi加上进位标志的值。实质上,如果第一个源值小于第二个源值,那么将设置进位标志,并将esi设置为0xffffffff;否则,将不设置进位标志,esi将设置为零,正如branch conditional gadget所需。必须要注意的一件事是:寄存器cl不能为零,否则会发生除零异常。
利用Set Less Than Gadgets和Logical Gadgets,可以形成基于比较两个值的六种关系(<,<=,=,!=,>=和>)中的任何一种的条件分支。在这一点上,我们的gadgets是图灵完备的。
函数调用
添加一个执行函数调用的gadget,从而扩展已拥有的gadgets集的功能。
执行函数调用的gadget能够让我们调用正常的面向返回的指令序列(即以return结束的指令序列),或允许我们调用合法的函数。其中,后者的操作更复杂。
这种方法可以避免两种检测:
- 依赖于函数调用堆栈的LIFO特性的ROP防御方法
- 依赖于return指令频率的防御方法
在进行函数调用之前,必须将堆栈指针指向新位置,以防止覆盖堆栈中的先前的gadgets。如果n是函数开始执行时堆栈指针所在的地址 - 即将存储返回地址的位置 - 则k个参数应存储在地址n + 4,n + 8,...,n + 4k种。这可以使用load immediate gadget或move gadget来完成。接着,函数调用gadget将用于计算 A fun(arg1; arg2; ::::; argk),其中堆栈指针为n。
由于x86的Linux应用程序二进制接口(ABI)指定寄存器eax,ecx和edx被调用者保存,因此如果被调用的函数覆盖了这些寄存器,我们必须注意不要混淆面向返回的代码。 这种情况下,一个棘手的问题是:由于edx被调用者保存,一旦我们要执行return,我们需要将edx恢复到指向pop x; jmp x的指针的地址。 如果我们关心eax中的返回值,我们不能仅使用libc中的指令序列。 继续我们的BYOPJ范例,如果目标程序有一个pop%edx; jmp(%edx)或pop%edx; jmp(%esi),那么我们就可以恢复edx而不覆盖eax中的返回值。 Mozilla的libxul有这样一个序列。 如果没有这样的序列,函数调用的gadget必须针对每个应用程序进行定制,而不能通用。
函数调用gadget的具体实现如下:
- 它首先做的是加载寄存器esi,ebp和eax。
寄存器esi加载了call-jump序列的序列目录表的开始地址,ebp加载了leave-jump序列的实际地址,eax加载了字值n(加上存储序列的偏移量)。 - 接下来,将call-jump的序列目录表的开始地址存储在地址n。
- 然后,寄存器esi加载0x38,并加上堆栈指针的值。
此时,esi保存着函数调用返回后堆栈指针的地址。 - 将返回地址移动到ebp中
既然已经知道函数返回后在堆栈上的位置,我们需要将它移动到ebp中。
最简单的方法是将它存储到内存中(存储在我们最终存储函数的返回值的地方),再将其从内存加载回edi,然后与ebp交换。
交换之后,edi保存着leave-jump序列的地址,ebp保存着函数调用之后堆栈指针的值。 - 接下来,将pop x; jmp x;的序列目录表的开始地址加载到esi,将存储函数指针的地址加载到ecx(加上偏移量),并将值n加载到eax。交换寄存器esp和eax的值,使堆栈指针为n。
- pop ebp实现链接
回想一下,函数调用gadget所做的第一件事就是将call-jump序列目录表的开始地址存储到n。此时,函数的间接调用发生了。函数返回之后,当eax保存返回值时,我们不能依赖寄存器ecx或edx中的值。但是,edi保存了leave-jump序列的地址,因此jmp %edi指令会导致执行leave指令,将堆栈指针存入ebp - 但ebp还保存着使用第一个xchg指令时设置的地址 - 然后将堆栈顶部的值弹出到ebp中。这导致pop x; jmp x序列目录表的开始地址(加上一个偏移量)被加载到ebp中,使得后续的jmp -0x7d(%ebp)指令能够链接下一个指令序列。 -
返回的两种方法
我们现在有两种实现返回的方法。
当没有pop%edx; jmp(%edx)序列时,我们使用popad; jmp(%edx),失去了返回值。这种情况下,函数调用gadget仍是完整的。
如果我们有一个pop%edx; jmp(%edx)序列,执行它即可,然后将eax中的返回值存储到内存中。
后一种形式的gadget如图。