修复缓冲区溢出问题

来源:岁月联盟 编辑:zhu 时间:2009-03-12

  当 David LeBlanc 和我确定《Writing Secure Code》一书的目录时,我们明确地意识到必须着重介绍缓冲区溢出问题,因为已经有太多的开发人员在编写代码时犯了太多的此类错误,这些错误导致了可被人利用的缓冲区溢出的出现。在本文中,我将集中介绍为什么会出现缓冲区溢出及其修复的方法。为什么会出现缓冲区溢出

  出现缓冲区溢出需要具备很多条件,包括:

  使用非类型安全的语言,如 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);

  // 使用 cDest
  ...
} 

  令人惊讶的是,这段代码可能没有什么错误!这完全取决于 CopyData() 的调用方式。例如,以下代码是安全的:

char *szNames[] = {"Michael","Cheryl","Blake"};
CopyData(szName[1]);

  这段代码是安全的,因为名字是硬编码的,并且知道每个字符串在长度上不超过 32 个字符,因此调用 strcpy 永远是安全的。然而,如果 CopyData 和 szData 的唯一参数来自不可靠的源(如套接字或文件),则 strcpy 将复制该数据,直到碰到空字符为止;如果此数据的长度大于 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];
  // 复制并使用 szName
  strcpy(szBuff,szName);
}

  此代码的问题在于函数不能判断 szName 的长度,这意味着将不能安全地复制数据。函数应知道 szName 的大小:

void Function(char *szName, DWORD cbName) {
  char szBuff[MAX_NAME];
  // 复制并使用 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

  // 探测
  memset(szBuff, 0x42, cbName);
#endif

  // 复制并使用 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 都会有实质的减小。第二个问题是,即使用户传递了缓冲区大小,他们通常也是逐一减小的。那么在计算字符串大小时,您有没有包含末尾的空字符?当我针对这个问题进行读者调查时,通常是对半分。其中一半认为在计算缓冲区大小时确实要考虑末尾空字符,另外一半则不这么认为。第三,在某些情况下,n 版本可能不会以空字符作为结果字符串的结束字符,因此请一定要阅读文档。

  如果编写 C 代码,请考虑使用 ATL、STL、MFC 或者您最喜欢的字符串处理类来处理字符串,而不要直接处理字节。唯一潜在的不足是可能出现性能的下降,但总的来说,大部分这些类的使用都会使代码更加强大和可维护。

  使用 /GS 进行编译

  Visual C .Net 中的这个新的编译时选项会在某些函数的堆栈框架中插入值,有助于减少基于堆栈的缓冲区溢出的潜在弱点。请记住,此选项不会修复您的代码,也不能删除任何错误。它只是象一个棒球运动的捕手,帮助您减少某些类的缓冲区溢出变为可被人利用的缓冲区溢出的潜在可能性,以免攻击者向过程中写入代码并执行。可以把它视为一个很小的保险措施。请注意,对于使用 Win32 应用程序向导创建的新的本机 Win32 C 项目,将默认启用此选项。此外,Windows .NET Server 编译时也使用了此选项。有关详细信息,请参阅 Brandon Bray 的 Compiler Security Checks In Depth(英文)。

  排除隐患

  下面我给出了一些代码,其中至少包含一处安全隐患。您能找出来吗?我将在下一篇文章中公布答案!

WCHAR g_wszComputerName[INTERNET_MAX_HOST_NAME_LENGTH   1];

// 获取服务器名称并将其转换为 Unicode 字符串。
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)) {
  // 其余代码被略去

  Michael Howard 是 Microsoft Secure Windows Initiative 小组的安全程序经理,也是《Writing Secure Code》的作者之一。他的主要工作就是确保人们设计、构建、测试和记录无缺陷的安全系统。他最喜欢的话是“尺有所短,寸有所长”。