二进制咸鱼的自我救赎

幸福往往是摸的透彻,而敬业的心却常常隐藏。

About RSS

不同视角看 ROP

#pwn #rop

看了许多讲 ROP 的文章,发现很多只是讲了怎么利用,但是没有讲为什么这么利用。所以写文记录。

什么是 ROP

ROP (Return orient programming) 是一种漏洞利用方法,使得攻击者绕过保护(例如 NX 栈不可执行)执行恶意代码。攻击者通过栈溢出等手段实现覆盖返回地址劫持程序控制流,并且通过不同的代码片段(Gadgets)来拼接出攻击者希望执行的代码。

怎么进行 ROP

汇编复习

我们先复习一下 x86 架构下的一些指令。

调用一个函数一般使用 call 指令,call func 这条指令相当于 push ip, jmp func,即将当前 IP(instruction pointer 指令寄存器) 的地址存入栈中,并将 IP 改为被调用函数的地址。

leave 指令由于效率低于等价指令 mov sp, bp; pop bp,且可以被等价替换所以比较少见。

ROP 中经常出现 ret 这一条汇编语句。ret 这一条指令是配合 call 指令使用的。ret 相当于 pop ip,配合 call 使用时,功能为返回原来的控制流。

劫持一次“普通”的程序调用

当 C 程序调用函数时,会通过将函数参数压入栈中,来传递函数参数。然后通过 call 指令将控制流转移到函数的代码中。并且函数会将之前控制流的 bp 保存,用于函数结束时恢复栈环境。

例如下面的程序,将两个数字相加,并且将结果作为返回值。

#include <stdio.h>

int add(int a, int b)
{
    return a + b;
}

int main()
{
    int a = 1, b = 2;
    int sum = add(a, b);
    printf("%d\n", sum);
    return 0;
}

将上面的代码使用 cl 编译出来的汇编代码如下。

_sum$ = -12   ; size = 4
_a$ = -8      ; size = 4
_b$ = -4      ; size = 4
_main PROC
 push ebp
 mov ebp, esp
 sub esp, 12     ; 0000000cH
 mov DWORD PTR _a$[ebp], 1
 mov DWORD PTR _b$[ebp], 2
 mov eax, DWORD PTR _b$[ebp]
 push eax
 mov ecx, DWORD PTR _a$[ebp]
 push ecx
 call _add
 add esp, 8
 mov DWORD PTR _sum$[ebp], eax
 mov edx, DWORD PTR _sum$[ebp]
 push edx
 push OFFSET $SG9148
 call _printf
 add esp, 8
 xor eax, eax
 mov esp, ebp
 pop ebp
 ret 0
_main ENDP

可以看到程序在 call _add 前将两个变量 a, b 从栈中取出放入寄存器中,然后再将其作为参数压入栈中。在 call _add 后程序将存于 eax 的返回值取出并放入 _sum 变量中。

执行到 call _add 时,栈的情况如下。可以看到参数从左到右依次按照从低地址到高地址排序。

+----------------+
|esp    |1       | <---+ 函数参数1
+----------------+
|esp + 4|2       | <---+ 函数参数2
+----------------+
|esp + 8|        |
+----------------+
|...    |        |
+----------------+
|ebp    |ret addr|
+----------------+

执行完 call _add 后的栈环境,可以看到程序将 call 指令的下一条指令压入栈中。

+----------------+
|esp    |ret addr| <---+ 返回地址
+----------------+
|esp + 4|1       | <---+ 函数参数1
+----------------+
|esp + 8|2       | <---+ 函数参数2
+----------------+
|...    |        |
+----------------+
|ebp    |ret addr|
+----------------+

这是 add() 函数的汇编代码,可以看到程序先将旧的 ebp 入栈,然后将 esp 赋值给 ebp 作为新的栈基。然后再从栈中的对应位置取出函数参数的值,进行 add 操作后,将结果赋给 eax 作为返回值。

_a$ = 8       ; size = 4
_b$ = 12      ; size = 4
_add PROC
 push ebp
 mov ebp, esp
 mov eax, DWORD PTR _a$[ebp]
 add eax, DWORD PTR _b$[ebp]
 pop ebp
 ret 0
_add ENDP

add 函数中 push ebp; mov ebp, esp 执行后的栈如下,可以看到返回地址的上方保存了旧的 ebp 地址。

+-------+--------+
|esp    |old ebp |
+----------------+
|       |ret addr| <---+ 返回地址
+----------------+
|esp + 8|1       | <---+ 函数参数1
+----------------+
|esp + c|2       | <---+ 函数参数2
+----------------+
|       |        |
+----------------+
|...    |        |
+----------------+
|       |ret addr|
+----------------+

pop ebp 指令执行完成,ret 指令准备执行时,程序将栈顶(SP 所指的地址)存放的返回地址放入 IP,来恢复控制流。如果我们能控制返回地址,伪造函数调用,那么就可以让程序执行我们想要执行的代码。

下面是当 mov esp, ebp; pop ebp 执行后的栈,假设我们控制了 esp 处的内存,在下一条指令 ret 执行后,我们就劫持了程序的控制流。

+----------------+
|esp    |gadget  | <---+ 返回地址
+----------------+
|esp + 4|1       | <---+ 函数参数1
+----------------+
|esp + 8|2       | <---+ 函数参数2
+----------------+
|       |        |
+----------------+
|...    |        |
+----------------+
|       |ret addr|
+----------------+

gadgets 和 ROP 链

之前的操作只能劫持一次控制流,如果想用这一次机会将控制流劫持到 shellcode 上的话,在 NX 开启的情况下就会失效,又或者不能一次劫持就完成我们想达到的目标,这时候就需要 ROP 和 gadgets 了。这里的 gadget 指的是程序本体中出现的指令片段,通常以 ret 指令结尾。

将 gadget 的地址按执行顺序写入栈中,就可以实现 ROP 链。ROP 链将按照在栈中的顺序执行。

例如下图中,CPU 先执行 ret 指令,将 IP 置为 0x1000,然后执行 0x1000 处的两条指令,最后执行 ret 指令,将 IP 置为 0x500。CPU 执行 0x500 处的指令,最后 ret 指令执行时,将 IP 置为 main 函数所在的地址,CPU 开始执行 main 函数里的指令。

      +-------+--------+
+-----+0x500  | inst3  |
|     +----------------+
|  v--+       | ret    |
|  |  +----------------+
|  |  |...    | ...    |
|  |  +----------------+
|  |  |0x1000 | inst0  +<-+
|  |  +----------------+  |
|  |  |       | inst1  |  |
|  |  +----------------+  |
|  |  |       | ret    +-----+
|  |  +----------------+  |  |
|  |  |...    | ...    |  |  |
|  |  +----------------+  |  |
|  |  |esp    | 0x1000 +--+  |
|  |  +----------------+     |
^-----+esp + 4| 0x500  +<----+
   |  +----------------+
   +->+esp + 8| main   |
      +----------------+
      |       |        |
      +-------+--------+

如果为 x86_64 或者 amd64 程序,通常以寄存器进行函数的参数传递,ROP 链的构造经常使用 pop regs; ret 这样的 gadget 来实现寄存器传参,然后再在 ROP 链中添加被调用函数地址,实现调用函数。而参数和寄存器的对应关系,则是从第一个参数到第六个参数分别为 %rdi, %rsi, %rdx, %rcx, %r8, %r9

+----------------+
|esp    |padding |
+----------------+
|       |pop_ret |
+----------------+
|esp + 8|args    |
+----------------+
|esp + c|func    |
+----------------+
|       |        |
+----------------+
|...    |        |
+----------------+
|       |ret addr|
+----------------+

如果是 32 位程序,通常使用栈进行传参。则先将被调用函数地址放入栈的低地址,然后将参数以 C 语言代码中调用函数时从左到右的顺序将参数从低地址到高地址开始放入。来模拟 call 指令和之前的传参过程。

+----------------+
|esp    |padding |
+----------------+
|       |func    | <---+ 返回地址
+----------------+
|esp + 8|arg1    | <---+ 函数参数1
+----------------+
|esp + c|arg2    | <---+ 函数参数2
+----------------+
|       |        |
+----------------+
|...    |        |
+----------------+
|       |ret addr|
+----------------+

如果需要了解更多,则请查询相关系统和 CPU 架构的 ABI,例如https://stackoverflow.com/questions/2535989/what-are-the-calling-conventions-for-unix-linux-system-calls-on-i386-and-x86-6https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=vs-2019

扩展:面对常见的代码保护措施

如果出现 NX 时,就需要使用 ROP 技术绕过。

如果出现 canary 时需要想怎么泄露 canary,通过 off by one 或者格式化字符串实现泄露,还有少见的报错泄露法和覆盖 canary 法。

如果出现 pie 的话泄露地址也是必须的,off by one 或者格式化字符串实现泄露,或者 partical overwrite 实现构造 rop 链也是可以的。

如果需要栈迁移,则需要使用两次 leave 指令来修改 SP 和 BP,将栈迁移到可以控制的地方,从而实现执行 ROP 链。

当然利用方式还是得根据具体题目进行改变,所以请君自行补充。