检查代码是否存在整数操作安全漏洞
发布日期 : 09/02/2004 | 更新日期 : 09/02/2004
Michael Howard
Secure Windows Initiative摘要: Michael Howard 提出关于整数操作安全漏洞的问题,并且阐述可以用来保护自己应用程序的安全性计划。
很多年以前,很少有人听说过整数溢出攻击,但现在好像每隔几天它就会出现一种新的形式。下面的简短列表就是在最近几个月内发现的一些整数溢出的安全性错误:
在本月的专栏中,我将阐述这些错误是如何出现的,如何在代码中搜寻它们以及如何修复它们。
在进入到本文的正题之前,我非常高兴的宣布“Writing Secure Code”接收到了在 2003 年 4 月于 San Francisco 召开的 RSA Security Conference 的“Conference Award for Industry Innovation”。
现在,回到整数攻击问题吧!
我不想解释什么是整数,我假设您知道它们是什么,并且知道有两种类型(有符号和无符号),其中当值是负数时,有符号整数的高位设置为 1,这是对 2 求补算法的结果。您也知道整数大小各有不同,最常见的长度为 64 位、32 位、16 位和 8 位的整数。这就是我所说的关于整数的全部内容,对于本文而言,如果您知道这些知识就足够了。
有三种主要整数操作可以导致安全性漏洞:
上溢和下溢
有符号与无符号的错误
截断
取决于它们自己的情况,这些问题可能不会产生安全性错误。但是,如果您的代码显示有一个或多个这样的问题,并且您的代码会操作内存,那么产生缓冲区溢出错误或应用程序故障的可能性就会增加。让我们仔细查看一下每一项。
上溢和下溢
快速看一下这段代码有什么问题?
bool func(size_t cbSize) { if (cbSize < 1024) { // we never deal with a string trailing null char *buf = new char[cbSize-1]; memset(buf,0,cbSize-1); // do stuff delete [] buf; return true; } else { return false; } }代码是正确的,对吗?它验证 cbSize 不大于 1 KB,并且 new 或 malloc 应该始终正确地分配 1 KB,对吗?让我们忽略以下事实,new 或 malloc 的返回值应该在此时进行检查。同样,cbSize 不能为负数,因为它是 size_t。但是,如果 cbSize 是零,又会如何呢?查看一下分配缓冲区的代码,它从缓冲区大小请求中减去一。从零减去一会产生 size_t 变量,这是一个无符号的整数,其限制为 0xFFFFFFFF(假设为 32 位的值)或者 4 GB。您的应用程序只有结束了,或者更糟!
请看下面相似的问题:
bool func(char *s1, size_t len1, char *s2, size_t len2) { if (1 + len1 + len2 > 64) return false; // accommodate for the trailing null in the addition char *buf = (char*)malloc(len1+len2+1); if (buf) { StringCchCopy(buf,len1+len2,s1); StringCchCat(buf,len1+len2,s2); } // do other stuff with buf if (buf) free(buf); return true; }同样,代码看起来编写得很好;它检查数据大小,验证 malloc 是否成功,并且使用 safe 字符串处理函数 StringCchCopy 和 StringCchCat(您可以从 http://msdn.microsoft.com/library/en-us/dnsecure/html/strsafe.asp 阅读更多关于这些字符串处理函数的内容)。但是,这段代码可能会受到整数上溢的危害。如果 len1 是 64,len2 是 0xFFFFFFFF,又会如何呢?确定缓冲区大小的代码合法地将 1、64 和 0xFFFFFFFF 加在一起,由于加操作的限制,会产生 64。接下来,代码仅分配了 64 个字节,然后代码生成了一个长度为 64 个字节的新字符串,然后将 0xFFFFFFFFF 字节与该字符串相连。同样,应用程序将会结束,在某些情况下,如果利用精心设计的大小进行攻击,代码可能会受到可用缓冲区溢出攻击。
此处的另外一个教训就是,如果缓冲区大小计算不正确,_safe 字符串处理函数就不安全。
JScript 溢出攻击
当使用乘法时也会出现相同类型的上溢错误,这发生在 Microsoft JScript 错误中。该错误仅表明它自己在使用 JScript 稀疏数组支持时会出现:
var arr = new Array(); arr[1] = 1; arr[2] = 2; arr[0x40000001] = 3;在这个示例中,数组具有三个元素,且长度为 0x40000001(十进制为 1073741825)。但是,由于该示例使用稀疏数组,它只占用内存的三个元素的数组。
实现 JScript 自定义排序例程的 C++ 代码在堆上分配临时的缓冲区、将三个元素复制到临时缓冲区中、使用自定义函数排序临时缓冲区,然后将临时缓冲区的内容移动回数组中。下面是分配临时缓冲区的代码:
TemporaryBuffer = (Element *)malloc(ElementCount * sizeof(Element));
Element 是一个 20 字节的数据结构,用于保存数组项。看起来程序将尝试为临时缓冲区分配大约 20 GB。您可能认为由于大多数人的计算机上不会有 20 GB 的内存,分配尝试将会失败。那么,JScript 常规内存不足处理例程将会处理该问题。遗憾的是,并没有发生这样的情况。
当使用 32 位的整数算法时,由于结果 (0x0000000500000014) 太大无法保存在 32 位的值中,我们会受到一个整数上溢攻击:
0x40000001 * 0x00000014 = 0x0000000500000014C++ 会丢弃所有不符合的位,因此我们会得到 0x00000014。这就是分配并未失败的原因 - 分配没有尝试去分配 20 GB,而是仅仅尝试分配了 20 个字节。然后,排序例程会假设缓冲区对于保存稀疏数组中的三个元素来说足够大,因此它将组成这三个元素的 60 个字节复制到了 20 个字节的缓冲区中,这样就溢出缓冲区 40 个字节。实在太冒险了!
有符号与无符号错误
快速查看下面的代码。它类似于第一个示例。看看您是否能够发现错误,如果您发现了错误,请试着确定该错误会产生什么结果。
bool func(char *s1, int len1, char *s2, int len2) { char buf[128]; if (1 + len1 + len2 > 128) return false; if (buf) { strncpy(buf,s1,len1); strncat(buf,s2,len2); } return true; }此处的问题在于字符串的大小存储为有符号的整数,因此只要 len2 是负值,len1 就可以大于 128,从而和就小于 128 个字节。但是,到 strncpy 的调用将会溢出 buf 缓冲区。
截断错误
让我们查看最后一种攻击类型,通过代码示例,您来猜猜看。
bool func(byte *name, DWORD cbBuf) { unsigned short cbCalculatedBufSize = cbBuf; byte *buf = (byte*)malloc(cbCalculatedBufSize); if (buf) { memcpy(buf, name, cbBuf); // do stuff with buf if (buf) free(buf); return true; } return false; }这种攻击,至少这种结果,与前面阐述的 JScript 错误有一点类似。如果 cbBuf 是 0x00010020,又会如何呢?cbCalculatedBufSize 只有 0x20,因为只从 0x00010020 复制了低 16 位。因此,仅分配了 0x20 个字节,并且 0x00010020 字节复制到新分配的目标缓冲区中。请注意,使用 Microsoft Visual C++?/W4 选项编译这段代码会生成:
warning C4244: 'initializing' : conversion from 'DWORD' to 'unsigned short', possible loss of data请注意类似下面的操作不要标记为一个警告:
int len = 16; memcpy(buf, szData, len);memcpy 的最后一个参数是 size_t,而参数 len 是有符号的。不会发出警告是因为 memcpy 始终假设第三个参数是无符号的,转换成无符号不会改变函数的输出。
注意,如果您尝试为 DWORD 分配一个 size_t,您将会收到一个警告,并不是因为在 32 位平台上可能会出现数据丢失,而是因为在 64 位平台上将会出现数据丢失。
warning C4267: '=' : conversion from 'size_t' to 'DWORD', possible loss of data您将收到这个警告,因为所有默认 C++ 项目都使用 -Wp64 选项进行编译,该选项会通知编译器监视 64 位可移植性问题。
托管代码中的整数操作问题
整数操作错误可能会发生在托管语言中,例如 C# 和 Visual Basic?.NET,但是潜在的损害会由于代码不直接访问内存而显著地降低。但是,调用本机代码(假设您的代码被授予调用非托管代码的权限)仍然可能会引起类似于上述的安全性问题。通用语言规范 (CLS) 中的整数是有符号的,一个错误就是当在非托管代码中将变量视为无符号的整数时,在托管代码中验证是否为有符号的整数参数。
这个特定的例子提出了一个更通用的建议:始终检查要传递到未托管代码的内容。托管代码中的很多整数操作错误可能会引起 Visual Basic .NET 中的可靠性错误,因为如果发生上溢或下溢,所有这样的操作都将引发 System.OverflowException。
默认情况下,C# 并不引发这些异常。如果您希望检查这些问题,请使用 checked 关键字:
UInt32 i = 0xFFFFFFF0; UInt32 j = 0x00000100; UInt32 k; checked {k = i + j;}补救措施
谁会想到只是操作整数就会导致安全性问题呢?对于易受攻击的代码的简单的补救措施如下所示:
if (A + B > MAX) return -1;利用无符号整数使用该代码:
if (A + B >= A && A + B < MAX) { // cool! }第一个操作 A + B >= A,检查是否存在包围,第二个操作确保相加后的和小于目标大小。
对于 JScript 中的操作问题,您可以检查元素的数量不超过预定的值,而预定的值要小于您将要分配给内存的最大量。例如,下面的代码会潜在地分配多达 64 MB 的内存:
const size_t MAX = 1024 * 1024 * 64; const size_t ELEM_SIZE = sizeof(ELEMENT); const size_t MAX_ELEMS = MAX / ELEM_SIZE; if (cElems >= MAX_ELEMS) return false;最后,对于数组索引、缓冲区大小以及相似对象,请使用无符号的整数,例如 DWORD 和 size_t。
关键代码检查点
当编译或检查与整数相关的问题的代码时,请记住下列要点:
使用最高的警告级别 /W4 来编译 C 和 C++ 代码。
对于缓冲区大小和元素计数,使用 size_t 或 DWORD。没有任何理由要为这些结构使用有符号的值。
请牢记,size_t 会根据您所使用的平台来表示不同的类型。size_t 是内存地址的大小,因此在 32 位平台上,它是 32 位的值,但在 64 位平台上,它就是 64 位的值。
如果代码执行任意类型的整数操作(加、乘等等),其中结果用于索引到数组或计算缓冲区大小,请确保操作数位于一个小的、容易理解的范围内。
警惕内存分配函数(new、malloc、GlobalAlloc 等等)的有符号参数,因为它们将被视为无符号的整数。
注意产生 C4018、C4389 和 C4244 警告的操作。
注意抛弃 C4018、C4389 和 C4244 警告的转换。
调查禁用 C4018、C4389 和 C4244 警告的 #pragma warning(disable, Cnnnn) 的所有使用。实际上,将它们标记为注释、重新编译,然后检查与整数相关的所有新的警告。
从其他平台或编译器迁移的代码可能会使用不同的数据大小。千万要小心!
如果从托管代码调用非托管代码,请确保它的符号是正确的。Win32 API 的很多参数都是无符号的 int 或 DWORD,而很多托管代码变量则是有符号的。
最后,如果您使用托管代码,请确保在适当的时候使用 catch OverflowExceptions。
发现安全漏洞
很多人检查出了我上个月的问题。它是一个整数上溢攻击。那么这段 C# 代码有什么问题呢?
string Status = "No"; string sqlstring =""; try { SqlConnection sql= new SqlConnection( @"data source=localhost;" + "user id=sa;password=password;"); sql.Open(); sqlstring="SELECT HasShipped" + " FROM detail WHERE ID='" + Id + "'"; SqlCommand cmd = new SqlCommand(sqlstring,sql); if ((int)cmd.ExecuteScalar() != 0) Status = "Yes"; } catch (SqlException se) { Status = sqlstring + " failednr"; foreach (SqlError e in se.Errors) { Status += e.Message + "nr"; } } catch (Exception e) { Status = e.ToString(); }