C和C++安全编码(原书第2版)
上QQ阅读APP看书,第一时间看更新

2.3.6 栈溢出

当缓冲区溢出覆写分配给执行栈内存中的数据时,就会导致栈溢出(stack smashing)。这种情况会对程序的可靠性和安全性造成严重的后果。stack段的缓冲区溢出使得攻击者能够修改自动变量的值或执行任意的代码。

覆写自动变量会破坏数据的完整性,在某些情况下也可能会引发安全漏洞(例如,在一个包含用户ID或密码的变量被覆写的情况下)。更常见的情况是,stack段的缓冲区溢出可能会允许攻击者通过覆写指向控制被(最终)转移的地址的指针来执行任意的代码,一个常见的例子是覆写返回地址(该地址也位于栈中)。此外,还可能覆写基于帧或基于栈的异常处理指针、函数指针或其他控制将会转移到的地址。

IsPasswordOK()程序示例容易遭受栈溢出攻击。要理解为何这个程序有漏洞,首先必须理解栈究竟是如何被使用的。程序在调用IsPasswordOK()之前,栈中包含的信息如图2.8所示。

图2.8 调用IsPasswordOK()之前的栈信息

操作系统(OS)或标准的启动序列把从main()的返回地址压入栈。在入口处,main()函数保存旧的传入帧指针,而这又来自操作系统或标准的启动序列。在调用IsPasswordOK()函数之前,栈包含局部布尔变量PwStatus,它保存由函数IsPasswordOK()返回的状态,此外,栈还包含调用者的帧指针和返回地址。

当程序执行IsPasswordOK()函数时,栈中包含的信息如图2.9所示。

图2.9 IsPasswordOK()执行时栈中的信息

请注意,Password数组和main()函数的返回地址都位于栈中,并且main()函数的返回地址的位置位于Password数组之后。理解在调用IsPasswordOK()的过程中栈会发生变化是非常重要的。当程序从IsPasswordOK()返回时,栈恢复到原来的状态,如图2.10所示。

图2.10 栈恢复到初始状态

main()函数继续执行,执行哪个分支取决于从IsPasswordOK()函数返回的值。

安全漏洞:IsPasswordOK()。如前所言,由于Password数组只能包含最多11个字符加上结尾的空字符,因此IsPasswordOK()程序存在一个安全缺陷。如图2.11所示,可以通过输入“12345678901234567890”这样的有着20个字符的密码,造成程序崩溃,从而很容易地暴露这个缺陷。

图2.11 如果超出其字符限制,未正确地限定边界的Password数组导致程序崩溃

要弄清楚程序崩溃的原因,首先必须理解在一个12字节的栈变量中保存20个字节长的密码会造成什么样的影响。由于20个字节是用户输入的,加上用作结尾的空字符,因此实际上存储该字符串所需要的内存是21个字节。但是,由于可用于存储密码的空间仅为12字节,因此栈中用来存储其他信息的9个字节(21–12=9)就被密码数据覆写了。图2.12展示了当调用gets()读取20个字节的密码以及溢出所分配的缓冲区后,被扰搅乱的程序栈。请注意,调用者的帧指针、返回地址以及PwStatus变量的一部分存储空间都已经被破坏了。

当一个程序发生故障时,一般的用户通常并不会想到程序可能存在漏洞。他们只会想到重启程序,但是,攻击者会继续研究,以查看程序的缺陷是否可被利用。

程序崩溃是因为缓冲区溢出导致返回地址被修改了,而新的地址要么是无效的,要么该地址的内存属于下列情况:(1)未包含有效的CPU指令;(2)虽然包含有效的指令,但是CPU的寄存器并没有为指令的正确执行做出正确的设置;或者(3)不可执行。

图2.12 损坏的程序栈

如图2.13所示,一段精心设计的输入字符串可以让程序产生意外的结果。

图2.14展示了当输入字符串溢出为Password分配的存储空间时,栈的内容是如何改变的。输入字符串包含一些看起来比较有趣的字符“j%*!”。这些都是可以利用键盘或者字符映射表(character map)输入的、可显示的ASCII字符。每一个字符都有一个对应的十六进制值:‘j’=0x6A,‘%’=0x10,‘*’=0x2A,‘!’=0x21。在内存中,这个4字符序列对应于一个4字节的地址,而这个地址覆盖了栈中原来的返回地址,所以IsPasswordOK()函数不是返回到main()中紧随此调用的指令,而是将控制返回给“授予访问”分支,这就使得攻击者绕过了执行密码验证逻辑的那段代码,从而获得对系统的访问权限。这种攻击是一个简单的弧注入攻击。弧注入攻击在2.3.8节中有更详细的描述。

图2.13 一个精心制作的输入字符串导致的意外结果

图2.14 使用一个精心设计的输入字符串导致缓冲区溢出后的程序栈