整理移动硬盘时,发现一个名为 attack 的目录,进去以后发现原来是一段简单的 C 语言代码。代码如下:
#include <stdio.h> void Attack() { while (1) { printf("Attack...\r\n"); } getchar(); } int main(int argc, char* argv[]) { int arr[5] = { 0 }; arr[7] = (int)Attack; return 0; }
看代码猜测,应该是死循环输出 Attack 字符串,因为毕竟是数组的越界访问(很多一些演示栈溢出的程序,都会用到数组的越界访问、字符串的拷贝等)。直接打开 VS 2015 进行编译、连接、运行,发现运行后什么结果都没有输出。当然了,这应该是被 VS 2015 的编译连接选项所导致的。进行一番设置,然后再进行执行。果然是死循环输出 Attack 字符串。
设置编译连接选项
类似这样的程序,在之前 VC 6 的编译环境下比较简单,到了高版本的 VS 下就需要设置相应的项目、编译、连接选项了,否则默认的安全选项会导致测试失败。不过好在这样的选项不多。这里逐一来进行设置。
在项目名上点击鼠标右键,在弹出的对话框上选择“属性”。
在“属性页”的“常规”选项中将字符集设置为“未设置”,如下图所示。
在 C/C++ 的“代码生成”选项中,将“安全检查”设置为“禁用安全检查(/GS-)”,如下图所示。
设置“连接器”下的“高级”选项,“随机地址”设置为“否”,“数据执行保护(DEP)”设置为“否”,如下图所示。
再次进行编译运行,发现死循环的测试成功了。如下图所示。注意观察右侧的滚动条,往下滚动速度很快。
原因分析
为什么会产生这样的显现呢?原因就是数组越界的赋值,代码如下:
arr[7] = (int)Attack;
在 C 语言中,函数名的名称就是函数的首地址。上面的赋值语句是将 arr[7] 的位置赋值为了 Attack 函数的地址。而 arr[7] 又是何物呢?在了解 arr[7] 之前,需要了解的是函数调用与函数的栈帧。
C 语言在调用函数时,根据函数的调用约定(C 语言的调用约定为 _cdcel)先将参数从右至左依次入栈,然后将返回地址压入栈中。当进入被调用的函数后,会先将 EBP 寄存器入栈,然后将 ESP 寄存器赋值给 EBP,最后通过 sub esp 来抬高栈顶,当作被调用函数的栈空间。EBP 作为基址指针,对当前函数(被调用函数)中的局部变量通过 [EBP - 0xXXX] 来进行访问,而对于调用时栈中的参数,则通过 [EBP + 0xXXX] 来进行访问。通常,[EBP + 4] 是返回地址,[EBP + 8] 是第一个参数的(如果有参数的话)。当函数返回时,通过 add esp 来收回栈空间,然后在执行 retn 指令时,会把栈中的保存的返回地址赋值给 EIP 寄存器,然后从返回地址继续执行代码。
有了上面的知识点,我们来看一下,上面程序的反汇编代码,代码如下:
004117F0 55 PUSH EBP 004117F1 8BEC MOV EBP,ESP 004117F3 81EC DC000000 SUB ESP,0DC 004117F9 53 PUSH EBX 004117FA 56 PUSH ESI 004117FB 57 PUSH EDI 004117FC 8DBD 24FFFFFF LEA EDI,DWORD PTR SS:[EBP-DC] 00411802 B9 37000000 MOV ECX,37 00411807 B8 CCCCCCCC MOV EAX,CCCCCCCC 0041180C F3:AB REP STOS DWORD PTR ES:[EDI] 0041180E C745 E8 0000000>MOV DWORD PTR SS:[EBP-18],0 00411815 33C0 XOR EAX,EAX 00411817 8945 EC MOV DWORD PTR SS:[EBP-14],EAX 0041181A 8945 F0 MOV DWORD PTR SS:[EBP-10],EAX 0041181D 8945 F4 MOV DWORD PTR SS:[EBP-C],EAX 00411820 8945 F8 MOV DWORD PTR SS:[EBP-8],EAX 00411823 B8 04000000 MOV EAX,4 00411828 6BC8 07 IMUL ECX,EAX,7 0041182B C7440D E8 5A104>MOV DWORD PTR SS:[EBP+ECX-18],test.0041105A 00411833 33C0 XOR EAX,EAX 00411835 52 PUSH EDX 00411836 8BCD MOV ECX,EBP 00411838 50 PUSH EAX 00411839 8D15 50184100 LEA EDX,DWORD PTR DS:[411850] 0041183F E8 19FAFFFF CALL test.0041125D 00411844 58 POP EAX 00411845 5A POP EDX 00411846 5F POP EDI 00411847 5E POP ESI 00411848 5B POP EBX 00411849 8BE5 MOV ESP,EBP 0041184B 5D POP EBP 0041184C C3 RETN
以上反汇编代码来自 OD,如下图所示。
在上面 0041180E 到 0041181D 的位置处,是对 arr 数组进行初始化的过程。对应代码的如下:
int arr[5] = { 0 };
可以看到,C 语言的一行代码,对应到汇编就有好多行。在 0041182B 处也是一行赋值语句,代码如下:
MOV DWORD PTR SS:[EBP+ECX-18],test.0041105A
EBP + ECX - 18,此处 ECX 的值为 1C,1C - 18 = 4,那么此处相当于是如下代码:
MOV DWORD PTR SS:[EBP + 4], test.0041105A
回顾我们前面提到的,[EBP + 4] 的位置处保存着返回地址,也就是调用当前函数的函数的下一条指令。比如,A 函数中调用了 B 函数,当 B 函数执行完成后,会接着执行 A 函数中,调用 B 函数处的下一条指令。而此时,返回地址被覆盖为 0041105A,那么,这个 0041105A 是什么值?回顾上面的代码,代码如下:
arr[7] = (int)Attack;
0041105A 是 Attack 函数的首地址。那么当 main 函数返回时,相当于调用了 Attack 函数。而 Attack 函数中是一个死循环。
观察内存变化
看一下代码执行到 0041180E 时 ebp 的情况,如下图所示。
此时,可以看到 [ebp + 4] 中的值是 00411FCE,然后再观察 [ebp - 18] 到 [ebp - 8] 内存中的值都为 cc。然后,将代码执行到 00411823 处,观察 ebp 的情况,如下图所示。
可以看到 [ebp - 18] 到 [ebp - 8] 的栈空间都被初始化为了 0。接着继续执行代码,到 00411833 的地址处,再次观察 ebp 的情况,如下图所示。
可以看到,[ebp + 4] 的栈地址处的值被修改了,接着将代码执行向下执行,执行到 0041184C 后,也就是执行完 retn 后观察 EIP 寄存器的值,如下图所示。
可以看到,此时 EIP 的值为 0041105A,而反汇编代码处是一个跳表的位置。在当前位置接着在单步一下,如下图所示。
从图中可以看到,在注释位置有一个“attack...”字符串的提示,从这点就可以看出,该段反汇编代码是 Attack() 函数了。
到此,整个程序的执行就说清楚了。
总结
这种程序虽小,但是考察的是对函数调用时内存结构相关的知识。虽然简单的,但还是很有意思的。