安全性:修复那些缓冲区溢出!

缓冲区溢出存在的原因
对于要发生缓冲区溢出的情况,需要满足一些条件,其中包括:
使用非类型安全的语言,诸如 C/C++。
以一种不安全的方式访问或复制缓冲区。
编译器将缓冲区放在内存中紧邻或靠近关键数据结构的位置。
让我们仔细考察一下每一项。
缓冲区溢出主要是 C 和 C++ 的问题,因为这些语言不执行任何数组边界检查和任何类型安全检查。C/C++ 允许开发人员创建一些运行于非常底层的程序,这就允许直接访问内存和计算机寄存器。这样做的结果就是提高性能;很难让任何应用程序运行起来都象一个写得很好的 C/C++ 应用程序那样快。其他语言中也存在缓冲区溢出,但是这种情况很少见。并且,即便存在这种问题,那通常也不是开发人员的错误,而是运行时环境的错误。
其次,如果应用程序从一个用户(或攻击者)那里获取数据,然后将这些数据复制到该应用程序所保有的缓冲区中而不管目标缓冲区的大小,那么就有可能发生缓冲区溢出。换而言之,代码分配了 N字节,而代码向所分配的缓冲区中复制的内容超过了 N 字节。或者这么想 — 您有一个 12 盎司的杯子,而向杯子里倒入 16 盎司的水。那多余的 4 盎司水能放到哪儿呢?当然是水满而溢了!
最后,也是最重要的,缓冲区经常被编译器放置在紧邻“值得注意的”数据结构的位置。例如,如果某个函数在堆栈上有一个缓冲区,在这种情况下该函数的返回地址被放在内存中该缓冲区后的位置。因此,如果攻击者能够使该缓冲区溢出,他就能重写该函数的返回地址,使得当该函数返回时将返回到由攻击者指定的某个地址。其他一些值得注意的数据结构包括 C++ v 表、异常处理程序地址、函数指针,等等。
好了,我也介绍了很多内容,现在让我们看一个例子。
这段代码有什么问题?
void CopyData(char *szData) {
   char cDest[32];
   strcpy(cDest,szData);
   // use cDest
   ...

令人惊讶的是,这段代码可能没有任何问题!这完全取决于调用
CopyData()
的方式。例如,以下代码是安全的: char *szNames[] = {"Michael","Cheryl","Blake"};
CopyData(szName[1]);
这段代码之所以是安全的,是因为名称都是硬编码的,而且已知每个字符串的长度都不超过 32 个字符,因此对
strcpy
的调用总是安全的。然而,如果 CopyData
的唯一参数,szData
来自不可信赖的来源(如套接字或文件),则 strcpy
将复制数据直到碰到一个 null 字符为止,而如果该数据的长度大于 32 个字符,则 cDest
缓冲区溢出且内存中该缓冲区以上的任何数据都被打乱。遗憾的是,在这种情况下,被打乱的数据是从 CopyData
中返回地址,这就意味着当 CopyData
结束时,它将在攻击者所指定的位置处继续执行。再坏不过了!
其他数据结构也是敏感的。假设一个 C++ 类的 v 表被损坏,就像以下代码中一样:
void CopyData(char *szData) {
   char cDest[32];
   CFoo foo;
   strcpy(cDest,szData);
   foo.Init();
}
本示例假设 CFoo 类具有虚拟方法,以及一个 v 表或一个地址列表(用于所有 C++ 类共有的各种类方法)。如果由于覆盖
cDest
缓冲区而使该 v 表损坏,则该类的任何虚拟方法(在此示例中为 Init()
)都可能调用攻击者所指定的某个地址而不是 Init()
指定的地址。顺便说一句,假若您认为代码如果不调用任何 C++ 方法就是安全的,但有一个方法总是要调用的,即类的虚拟析构函数。当然了,如果一个类不调用任何方法的话,那它的存在还有什么意义呢?
修复缓冲区溢出
现在让我们转到积极的方面 — 如何消除和防止代码中的缓冲区溢出。
迁移至托管代码
在 2002 年 2 月和 3 月,我们举办了 Microsoft Windows Security Push。在这段时间内,我所在的组培训了超过 8,500 人,指导他们如何设计、编写和测试安全功能和为这些功能编写文档。我们给所有设计人员提出的一项建议就是拿出规划,将适当的应用程序和工具从本机 Win32 C++ 代码迁移到托管代码。我们之所以提出此建议,是出于多种原因的,但主要原因就是要帮助减少缓冲区溢出。在托管代码中,您所编写的代码不能直接访问指针、计算机寄存器或内存,因此要创建包含缓冲区溢出的代码就难得多。您应该考虑,或者至少计划,将某些应用程序和工具迁移到托管代码。例如,管理工具就是用于迁移的一个理想的候选者。很显然,您必须现实一点;您不可能一夜之间将所有应用程序从 C++ 迁移到 C# 或其他托管语言。
遵守黄金规则
在编写 C 和 C++ 代码时,对于如何管理来自用户的数据,您应该谨慎从事。如果一个函数从某个不可信赖的来源接收缓冲区,请遵守以下这些规则:
要求代码传递缓冲区长度。
检查内存。
采取防御措施。
让我们仔细考查一下每一项。
要求代码传递缓冲区长度
如果您的任何函数调用的签名类似下面这样,则其中存在错误:
void Function(char *szName) {
   char szBuff[MAX_NAME];
   // Copy and use szName
   strcpy(szBuff,szName);
}
这段代码的问题在于函数根本不知道
szName
有多长,这意味着您不能安全地复制数据。函数应该取得 szName
的大小: void Function(char *szName, DWORD cbName) {
   char szBuff[MAX_NAME];
   // Copy and use szName
   if (cbName < MAX_NAME)
      strncpy(szBuff,szName,MAX_NAME-1);
}
然而,您也不能完全信赖
cbName
。攻击者能够设置名称和缓冲区大小,因此您需要检查!
检查内存
如何才能知道
szName
和 cbName
是否有效?您确信用户会提供有效的值吗?通常说来,答案是否定的。要确定缓冲区大小是否有效,一个简单的方法就是检查内存。以下代码片段显示了在代码的调试版本中如何进行这种检查: void Function(char *szName, DWORD cbName) {
   char szBuff[MAX_NAME];
#ifdef _DEBUG
   // Probe
   memset(szBuff, 0x42, cbName);
#endif
   // Copy and use szName
   if (cbName > MAX_NAME)
      strncpy(szBuff,szName,MAX_NAME-1);
}
这段代码将尝试把值 0x42 写入目标缓冲区。您可能会觉得奇怪,为什么要如此操作而不是单单复制该缓冲区。通过将一个确定的已知值写入目标缓冲区的结尾,如果源缓冲区太大,您可以强制使该代码失败。这样也能在开发过程的早期找到开发问题。代码失败总比运行攻击者的恶意有效负载要好得多,这就是不要复制攻击者的缓冲区的原因。
注 只应在调试版本中进行这种操作,以便帮助在测试过程中找到缓冲区溢出的情况。
采取防御措施
老实说,检查是很有用的,但并不能使您免遭攻击。要获得安全,唯一有效的途径就是防御性地编写代码。您注意到代码已经是防御性的了。它检查传入函数的数据不超过内部缓冲区
szBuff
的大小。然而,在操作或复制不可信赖的数据 时如果误用了某些函数,这些函数就有可能存在严重的安全问题。此处关键在于不可信赖的数据。当检查代码中的缓冲区溢出错误时,您应该随着数据在代码内的流动而对其进行跟踪,并质疑该数据的有关假设。当您意识到有些假设不成立时,您将会对发现的问题感到惊讶不已。
值得注意的一些函数包括经典的函数,例如 strcpy、strcat、gets 等等。但不要将 strcpy 和 strcat 的所谓安全 n 版本 — strncpy 和 strncat 排除在外。按道理,这些函数使用起来应该更安全一些,因为它们允许开发人员对要复制到目标缓冲区中的数据大小进行限制。然而,开发人员也经常错误地使用这些函数!看一看以下的代码。您能找到毛病吗?
#define SIZE(b) (sizeof(b))
char buff[128];
strncpy(buff,szSomeData,SIZE(buff));
strncat(buff,szMoreData,SIZE(buff));
strncat(buff,szEvenMoreData,SIZE(buff));
如果需要提示的话,您看一下每个字符串操作函数的最后参数。还是不明白?在我给出答案之前,我经常开玩笑说,如果您禁用各个“不安全”的字符串操作函数而要求使用更安全的 n 版本,您就得耗尽下半辈子来解决新引入的各种问题。原因是这样的。首先,最后一个参数并不是目标缓冲区的总大小。它指的是该缓冲区上剩余的空间大小,当代码每次添加到
buff
时,buff
实际上就变得更小。第二个问题是,即便传入该缓冲区的大小,这个大小通常也会差一。在计算字符串大小时,您算不算结尾的 null 字符?如果我让读者就这一点投票,结果通常是 50 比 50。一半的读者认为在计算缓冲区大小时确实包括结尾的 null 字符,而另一半则认为不包括。第三,在某些情况下,N版本可能不以 null 来终结所得的字符串,因此一定要阅读相关文档。
如果编写 C++ 代码,请考虑使用 ATL、STL、MFC 或自己喜欢的字符串操作类来对字符串进行操作,而不要直接对各字节进行操作。唯一潜在的缺点是可能会导致性能下降,但是通常来讲,使用大多数这样的类会使得代码更可靠和便于维护。
编译时使用 /GS 选项
Visual C++ .NET 中的这个新编译时选项在某些函数的堆栈帧中插入一些值,以帮助减少基于堆栈的缓冲区溢出的潜在弱点。请记住,这个选项并不能修复您的代码,也不能解决任何问题。它只起辅助作用,以帮助减少某些类别的缓冲区溢出变为可被利用的缓冲区溢出的可能性,这类缓冲区溢出允许攻击者将其代码植入您的进程并执行。您可以把它看作一宗很小的保险单。请注意,对于利用 Win32 应用程序向导创建的新的本机 Win32 C++ 项目而言,默认设置是启用此选项。另外,Windows Server 2003 也是利用本选项编译的。有关详细信息,请参考 Brandon Bray 的《 Compiler Security Checks In Depth》。
发现弱点
我想以下面的一些代码作为本文的总结,其中至少有一处安全问题。您能发现吗?我将在我的下一篇文章中给出答案!
WCHAR g_wszComputerName[INTERNET_MAX_HOST_NAME_LENGTH + 1];
// Get the server name and convert it to the Unicode string.
BOOL GetServerName (EXTENSION_CONTROL_BLOCK *pECB) {
   DWORD   dwSize = sizeof(g_wszComputerName);
   char    szComputerName[INTERNET_MAX_HOST_NAME_LENGTH + 1];
   if (pECB->GetServerVariable (pECB->ConnID,
            "SERVER_NAME",
            szComputerName,
            &dwSize)) {
   // rest of code snipped
Michael Howard 是 Microsoft 的 Secure Windows Initiative 组的一位安全计划经理,同时也是《Writing Secure Code》的合著者之一。他致力于确保人们所设计、构建、测试和记录的系统符合安全要求。他最喜欢的一句话是“一人之工具,他人之凶器。”

你可能感兴趣的:(安全)