使你的软件守规矩----防止缓存泄漏(查看原文)
通过防御性编程保护你的代码
在我们前一个栏目中,我们描述缓存溢出攻击处在很高的水平,并且讨论为什么缓存溢出有如此巨大的安全问题。通过防御型编程保护你的代码免受缓存溢出攻击是本栏目的主题。我们将覆盖C编程语言中主要的安全陷阱,显示为什么你需要避免特定的结构并展示优先的编程实践。最后,我们讨论可以帮助你有效停止缓存溢出的其它技术。
C中绝大部分的缓存溢出问题可以直接追溯到标准C库。最差的肇事者是有问题的不带任何参数检查的字符串操作(strcpy、strcat、sprintf、gets)。通常来讲,类似“避免strcpy()”与”绝不使用gets()“的硬性快速的规则接近该目标。
今天写的程序依旧使用这些调用,因为开发者从不考虑避免它们。一些人从这里与那里获得一些提示,不过只有优秀的开发者可以扭紧心弦。他们或许使用自己的方法对传递给危险函数的参数进行检查,或者不正确地推理使用一个潜在危险的函数在一些特定情况是”安全的“。
公共敌人一号是gets()。绝不使用gets()。该函数从标准输入中读取一行用户输入的文本,并且在遇到EOF字符或换行符之前并不停止读取文本。那是正确的:gets()根本不进行边界检测。它总是可能溢出使用gets()的任何缓存。作为替换,使用方法fgets()。它所做的与gets()所做的一样,不过它接受一个大小参数去限制读入的字符数量,因此给你提供一个防止缓存溢出的方法。例如,替换下面的代码:
void main() { char buf[1024]; gets(buf); }
使用下面的:
#define BUFSIZE 1024 void main() { char buf[BUFSIZE]; fgets(buf, BUFSIZE, stdin); }
C编程中的主要陷阱
C语言中标准函数的数量具有使你陷入困境的巨大潜能。不过并不是所有它们的使用是糟糕的。通常,使用这些函数需要一个随意输入传给该函数。这个列表包括:
- strcpy()
- strcat()
- sprintf()
- scanf()
- sscanf()
- fscanf()
- vfscanf()
- vsprintf()
- vscanf()
- vsscanf()
- streadd()
- strecpy()
- strtrns()
坏消息是我们建议你如果可能避免使用这些函数。好消息是在很多情况存在合理的可供选择的函数。我们将会讲述它们每一个,因此你可以看到是什么构成它们的错误使用与如何避免它。
strcpy()函数拷贝源字符串到一个缓存。没有指定将要拷贝的字符数量。拷贝的字符数量直接依赖于源字符串有多少字符。如果源字符串碰巧来自于用户输入并且你没有显示限制大小,你就潜在陷入一个大问题!
如果你知道目标缓存的大小,你可以增加一个显式检查:
if(strlen(src) >= dst_size) { /*Do something appropriate, such as throw an error. */ else { strcpy(dst, src); }
一个更简单的实现相同目标的方式是使用strncpy()运行时库:
strncpy(dst, src, dst_size-1); dst[dst_size-1] = '\0'; /* Always do this to besafe! */
如果src比dst大,该函数并不抛出错误;它只是在当到达最大尺寸时停止拷贝字符。注意,前面调用strncpy()的-1。如果src比dst大,它给我们添加null字符的空间。
当然,使用strcpy()而没有任何潜在安全问题也是有可能的,就像我们下面例子中看到的:
strcpy(buf, "Hello!");
即使该操作没有溢出buf,它也只在少量字符的时候才这样。因为,我们知道这些字符是静态的,并且因为它们十分明显无害的,在此没有任何东西需要担心。当然,直到字符串”Hello”的静态存储可以被其它途径重写。
另一个确保你的strcpy()不会溢出的方法是当需要它的时候重新分配空间,通过对源字符串使用strlen()函数确保分配足够多的空间。例如:
dst = (char *)malloc(strlen(src)); strcpy(dst, src);
函数sprintf()与vsprintf()是为格式化文本并且保存到缓存的多功能函数。他们可以直接用于模拟strcpy()的行为。换句话说,就和使用strcpy()一样,它容易给你的程序添加缓存溢出。例如,考虑下面的代码:
void main(int argc, char **argv) { char usage[1024]; sprintf(usage, "USAGE: %s -f flag [arg1]\n", argv[0]); }
我们尝尝看到代码像这个一样优美。它看上去足够无害。它产生知道程序如何调用的字符串。那种方式,二进制程序的名称可以改变,程序的输出会自动反应该改变。虽然如此,代码有非常严重的错误。文件系统趋向于限制任何文件的名字在特定数量的字符。因此,你最好考虑如果你的缓存足够大以至于能够容纳最长的可能名字,你的程序会是安全地,正确吗?只要改变1024为我们操作系统适合的数字我们就完成了吗?不过并不是这样的,我们可以简单地通过书写启动上面程序的我们自己的小程序来打乱该限制:
void main() { execl("/path/to/above/program", <<insert really long string here>>, NULL); }
函数execl()启动第一个参数中命名的程序。第二个参数作为argv[0]传递为调用的参数。我们可以使该字符串像我们想要的一样长。
因此,我们如何绕开该{v}sprint()的问题?不幸的是,并没有完全可移植的方式。一些体系结构提供snprintf()方法,它准许程序员指定从每个源中拷贝多少字符。例如,如果snprintf在你的系统上有效,我们可以修复前面例子:
void main(int argc, char **argv) { char usage[1024]; char format_string = "USAGE: %s -f flag [arg1]\n"; snprintf(usage, format_string, argv[0], 1024-strlen(format_string) + 1); }
{v}sprint()的很多(并不是所有)版本都产生比使用这两个函数的更安全方式。你可以为格式字符串自身的每个参数指定一个精度。例如,另一个修复前面出错的sprintf()的方式是:
void main(int argc, char **argv) { sprintf(usage, "USAGE: %.1000s -f flag [arg1]\n", argv[0]); }
如果两个解决方案在你程序必须运行的系统上都不可用,你最好的解决方案是打包snprintf()的工作版本与你的代码。一个免费的可用方式可以在sh存档格式中找到,参考Resources。
继续,scanf函数家族设计也很差劲。在这个例子中,目标缓存会溢出。考虑下面代码:
void main(int argc, char **argv) { char buf[256]; sscanf(argv[0], "%s", &buf); }
如果扫描的单子大于buf的大小,我们就拥有溢出情况。幸运的是,有解决该问题的简单方式。考虑下面的代码,它没有安全弱点:
void main(int argc, char **argv) { char buf[256]; sscanf(argv[0], "%255s", &buf); }
在百分号与s之前的255指明argv[0]中的不多于255个字符会实际保存在变量buf中。相匹配的字符的其它部分不会被拷贝。
接着我们转到streadd()与strecpy()。虽然并不是每一台机器都具有这些调用,具有有效的这些函数的程序员在使用它们的时候需要十分小心。这些函数将一个可能具有不可读字符的字符串转换为可打印表现。例如,考虑下面的程序:
void main(int argc, char **argv) { char buf[20]; streadd(buf, "\t\n", ""); printf(%s\n", buf); }
该程序打印:
\t\n
而不是打印空格空白。如果程序员没有过早预见无溢出地处理输入的输出缓存需要多大,streadd()函数会是有问题的。如果输入缓存包含单独的字符,如ASCII001(ctrl-A),它将打印成四个字符,“\001”。这是字符串增长的最坏情况。如果你不能分配足够多的空间,而输出缓存总是四倍于输入缓存的大小,缓存溢出是可能的。
另一个很少用的函数是strtrns(),因为很多机器并不拥有它。该函数strtrns()拥有3个字符串与一个保存结束字符串的缓存作为参数。第一个字符串是基本都拷贝到该缓存。从第一个字符串拷贝到该缓存的字符,除了在第二个字符串中出现的。如果确实存在,在第三个字符串中相同索引的字符将作为替换。这听起来很令人困惑。让我们看一个将argv[1]中所有小写字母字符转换为大写字母字符的例子:
#include <libgen.h>
void main(int argc, char **argv)
{
char lower[] = "abcdefghijklmnopqrstuvwxyz";
char upper[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
char *buf;
if(argc < 2) {
printf("USAGE: %s arg\n", argv[0]);
exit(0);
}
buf = (char *)malloc(strlen(argv[1]));
strtrns(argv[1], lower, upper, buf);
printf("%s\n", buf);
}
前面的代码实际上并没有包含缓存溢出。但是如果我们使用固定大小静态缓存而不是使用malloc()去分配拷贝argv[1]的足够空间,或许会产生缓存溢出。
避免内部缓存溢出
realpath()函数拥有一个可以潜在包含相对路径的字符串,并且将它转换为引用相同文件不过通过绝对路径的字符串。当它这样做的时候,它扩展所有符号链接。
这个函数拥有两个参数,第一个是要被规范化的字符串,第二个是将要保存结果的缓存。当然,你需要确保结果缓存足够大以至于能够处理任何大小的路径。分配MAXPATHLEN大小的缓存应该是足够的。然而,realpath()有另外一个问题。如果你传递一个大于MAXPATHLEN的将要规范化的路径,realpath()实现内部的静态缓存就溢出了!事实上,你不能访问已经溢出的缓存,不过无论如何它伤害了你。作为结果,你需要明确不实用realpath(),除非你确保你尝试规范化的路径长度不大于MAXPATHLEN。
其它普遍可用调用拥有相同的问题。非常常见的调用syslog()在注意到并且在不久前修复之前具有相同的问题。这个问题在大部分机器上都是正确的,不过你不应该依赖正确的行为。最好总是假设你的代码可能运行在大部分不利的环境下,仅仅万一某一天确实出现了。getopt()家族调用的多种实现,与getpass()函数一样,也异常内部静态缓存溢出的影响。如果你必须使用这些函数,最好的解决方法是总是限定你传递给它们的输入长度。
你自己去模拟gets()、安全问题等等是非常简单的。例如,下面的代码:
char buf[1024];
int i = 0;
char ch;
while((ch = getchar()) != '\n')
{
if(ch == -1) break;
buf[i++] = ch;
}
哎哟!任何你可以用于读入字符的函数都在这个问题上崩溃,包括getchar()、fgetc()、getc()与read()。
缓存溢出问题的教训是:总是确保进行边界检测。
C与C++都没有自动边界检测,这是非常糟糕的,不过事实上有一个非常好的原因它们为什么不那样做。边界检测的代价是效率。通常,C在多种权衡时更热衷效率。然而,效率成本增长了,C程序员必须忙个不停了,并且极具安全意识保证他们的程序没有问题,即使这样保证代码没有问题并不是容易的事。
在这一天与这个时代,参数检查并不组成程序效率的最大消耗。绝大多数应用从不注意这个区别。因此总是进行边界检测。在你拷贝到你自己的缓存之前,检查数据的长度。也要检查确保你没有传递很大的数据到另一个库,因为你也不能相信别人的代码!(回忆一下我们前面讨论的内部缓存溢出。)
什么是其它危险?
不幸的是,即使系统调用的”安全“版本----就像相对strcpy()而言的strncpy()----也不是完全安全的。也有可能把事情搞砸。即使”安全”调用有时也会保留字符串不确定的或遇到微妙的大小差异(off-by-one)bug。当然,如果你碰巧使用一个小于源缓存结果缓存,你也会在十分困难的地方发现自己。
这些错误比迄今为止讨论到的每一个都难域构造,不过你还要意识到它们。当你使用这类调用时仔细考虑。如果你没有十分注意缓存大小,很多函数会行为不端,包括bcopy()、memcpy()、snprintf()、strncpy()、strcadd()与vsnprintf()。
其它避免的系统调用是getenv()。getenv()的最大问题是你可能从没假设一个特殊的环境变量可以是任何特定长度的。我们将要在下一个栏目中讨论环境变量的各种各样问题。
迄今为止,我们为你提供了一个大的洗衣房式的通用的易受缓存溢出问题影响的C函数列表。肯定还有更多的具有相同问题的函数。实际上,注意第三方检测设备(COTS)软件。不要假设关于其它人的软件的行为。也要注意我们并没有仔细检查每一个平台上的所有通用库(我们不希望那样的工作),其它问题调用很可能存在。
即使我们检查任何地方的每一个通用函数,你也应该十分、十分怀疑我们是否尝试声明我们列出所有你曾经遇到的问题。我们只是希望给你一个良好的开端。其余的取决与你。
静态与动态测试工具
我们将在后面的栏目详细介绍脆弱的检测工具,不过现在值得一体的是两种类型的扫描工具会检测效率,帮助查找与去除缓存溢出问题。两个主要的分析工具类别是静态工具(代码被考虑但从不运行)与动态工具(代码被执行去判断行为)。
许多静态工具可以用于查找潜在的缓存溢出问题。糟糕的是它们中没有对通用公众有效的工具。该工具类的许多并没有比自动通过运行grep命令去查找源代码中每一个问题函数实例做的更多。虽然存在更好的技术,这依旧是一个高效率的缩小成千上万行大程序为仅仅几百行“潜在问题”的方式。(在下一栏目,我们将展示给你基于该技术的快餐式的扫描工具,同时给你提供它如何构造的思想。)
更好的静态工具以一些放些使用数据流信息去合计哪个变量影响其它哪些变量。这种方式,一些基于grep的分析的”假阳性”可以被丢弃。David Wagner已经在他的工作中实现了该方法(描述在“Learning the basics of buffer overflows”,查看资源),就像对高可靠技术的研究。数据流相关方法的问题是刚刚介绍的假阳性(即,并没有标识真正问题的一些调用)。
第二类方法引入动态分析的应用。动态工具通常关注代码的运行,去查找潜在的问题。一种在实验中的方法是错误注入。思想是以可以实验它的方式编写指令程序,运行”要是…怎么样“的游戏并且观察发生了什么。一个相关错误注入工具,FIST(查看资源),已经用于定位潜在缓存溢出脆弱性。
最终,动态与静态方法的一些结合可以为你的投入提供最大的反馈。更多判断最好结合的工作等着去做。
Java与堆栈保护会有帮助
像我们前一栏目(参考资源)提到的,堆栈粉碎是缓存溢出攻击的最坏种类,尤其当将要粉碎的堆栈在特权模式下运行。该问题的一个简洁的解决方法是不运行堆栈。通常,溢出的代码写在程序堆栈上,并且执行它们。(我们将会在下一栏目中解释这是如何实现的。)很能去为包括Linux与Solaris的许多操作系统获取非运行堆栈补丁。(一些操作系统并不需要该补丁;它们就是那样做的。)
非执行堆栈具有一些隐含的性能问题。(没有免费的午餐。)此外,它们在程序中有堆栈溢出与堆溢出两者的时候很容易失败。堆栈溢出优先引起程序跳到溢出代码,它们存放在堆中。在堆栈中的代码没有实际执行,只是堆中的代码。这些功能型顾虑足够重要,我们把下一栏目奉献给它们。
当然,另一个选项是使用类型安全型语言,如Java。一个非极端的措施是为C语言获取执行数组边界检测的编译器。如为GCC存在的工具。该技术具有预防所有缓存溢出(堆与堆栈)的优势。消极的一面是对于性能关键的指针密集型语言,该技术会束缚适当的性能。不过在大多数情况,该技术会异常出色。
堆栈防御工具实现了比通用边界检测更具性能的技术。他在堆栈分配的数据后面添加少量数据,并且随后在缓存溢出可能发生之前查看该数据是否还在。这个模式称作”金丝雀“。(Welsh矿工在煤矿中防止一只金丝雀以告知有危险的情况。当空气变得有毒时,这个金丝雀会衰弱,帮助给予矿工足够的时间去注意与逃离。)
堆栈防御方法不像通用边界检测那样安全,不过依然很有用。与通用边界检测相比,堆栈防御的主要不足是它不能防止堆溢出攻击。通常,最好使用该工具保护你的整个操作系统,否则你程序中的非保护的库调用(如标准库)依然能够开启基于堆栈溢出代码威胁的大门。
与堆栈防御相似的工具是内存完整性检测包,如Rational的Purify。该类工具甚至可以防止堆溢出,不过通常不用于由于性能瓶颈原因产生的代码。
总结
在该栏目的前两部分,我们给你介绍了缓存溢出,并且针对如何写出避免这些问题的代码给以指引。我们也讨论了一些工具,它们可以帮助使你的代码保持安全于令人畏惧的缓存溢出。表1总结我们推荐你小心或避免使用的程序结构。如果你有其它你你认为我们应该添加到该列表中的函数,请告知我们,我们将更新该列表。
函数 | 安全性 | 解决方法 |
gets | 最危险 | 使用fgets(buf, size, stdin)。这几乎总是个大问题! |
strcpy | 很危险 | 使用strncpy作为替换 |
strcat | 很危险 | 使用strncat作为替换 |
sprint | 很危险 | 使用snprintf作为替换,或者使用精度指示符 |
scanf | 很危险 | 使用精度指示符,或做你自己的解析 |
sscanf | 很危险 | 使用精度指示符,或做你自己的解析 |
fscanf | 很危险 | 使用精度指示符,或做你自己的解析 |
vfscanf | 很危险 | 使用精度指示符,或做你自己的解析 |
vsprintf | 很危险 | 使用vsnprintf作为替换,或做你自己的解析 |
vscanf | 很危险 | 使用精度指示符,或做你自己的解析 |
vsscanf | 很危险 | 使用精度指示符,或做你自己的解析 |
streadd | 很危险 | 确保你分配源参数的4倍大小作为目标的尺寸 |
strecpy | 很危险 | 确保你分配源参数的4倍大小作为目标的尺寸 |
strtrns | 危险 | 手动检查目标至少与源字符相同大小 |
realpath | 很危险(或少,依赖于实现 | 分配给你的缓存MAXPATHLEN大小。也要手动检查参数确保输入参数不长于MAXPATHLEN。 |
syslog | 很危险(或少,依赖于实现 | 在传递给该函数之前,截断所有的输入字符在一个合理的大小。 |
getopt | 很危险(或少,依赖于实现 | 在传递给该函数之前,截断所有的输入字符在一个合理的大小。 |
getopt_long | 很危险(或少,依赖于实现 | 在传递给该函数之前,截断所有的输入字符在一个合理的大小。 |
getpass | 很危险(或少,依赖于实现 | 在传递给该函数之前,截断所有的输入字符在一个合理的大小。 |
getchar | 中等危险 |
如果在循环中使用该函数,确保检查缓存边界。 |
fgetc | 中等危险 | 如果在循环中使用该函数,确保检查缓存边界。 |
getc | 中等危险 | 如果在循环中使用该函数,确保检查缓存边界。 |
read | 中等危险 | 如果在循环中使用该函数,确保检查缓存边界。 |
bcopy | 低危险 | 确保你的缓存和你说的那么大 |
fgets | 低危险 | 确保你的缓存和你说的那么大 |
memcpy | 低危险 | 确保你的缓存和你说的那么大 |
snprintf | 低危险 | 确保你的缓存和你说的那么大 |
strccpy | 低危险 | 确保你的缓存和你说的那么大 |
strcadd | 低危险 | 确保你的缓存和你说的那么大 |
strncpy | 低危险 | 确保你的缓存和你说的那么大 |
vsnprintf | 低危险 | 确保你的缓存和你说的那么大 |
在我们匆忙完成工作的过程中,至今,我们还遗漏缓存溢出之外的一些详细信息。在我们下一个栏目中,我们将引擎的更深一层工作,并得到油腻。我们将更深入缓存溢出如何工作,甚至展示一些溢出代码。
资源
- sh档案格式的snprintf()的免费工作版本
- FIST,定位潜在缓存溢出脆弱性的错误注入工具。
- DeveloperWorks上的“使你的软件守规矩:学习缓存溢出基础”(Make your software behave: Learning the basics of buffer overflows),Gary与John在高水平描述缓存溢出攻击并且讨论为什么缓存溢出是唯一最大的软件安全威胁
- DeveloperWorker上的“使你的软件守规矩:确保你的软件是安全的”(Make your software behave: Assuring you software is secure),Gary与John展示它们为安全设计的第五步
- DeveloperWorker第一个“使你的软件守规矩”栏目,Gary与John解释它们的安全哲学,并且解释他们为什么关注开发展面临的软件安全问题
- Gary与John的DeveloperWorks栏目已经更新与出版成书《构建安全软件》,它引入丰富的新材料。现在捡起一个拷贝,你的使用者会谢谢你。