级别: 初级
David A. Wheeler, 研究员, Institute for Defense Analyses
2005 年 1 月 16 日
应用程序通常都会调用其他组件,例如底层的操作系统、数据库系统、可重用的库、Internet 服务(例如 DNS)、Web 服务,等等。本文通过讨论只使用安全组件、只传递有效数据,确保数据可以正确进行处理,检查返回值和异常情况,并且当数据在应用程序和组件之间传递时对数据进行保护,从而解释如何防止攻击者利用对其他组件的调用。
查看 PDF 文件时单击一个超文本链接不应该是什么安全问题,条件是您相信所调用的浏览器。但是 0.90 版本的 xpdf 的用户发现这个假设实际上非常糟糕。
当 xpdf 用户单击一个超文本链接时,xpdf 就会启动一个浏览器(默认情况下是 Netscape),并将这个 URL 发送给浏览器。到现在为止,一切都非常正常。然而,xpdf 的开发者决定使用 system() 调用来启动浏览器。这实在是个糟糕的主意。
在类 Unix 的系统中, system() 调用命令 shell,后者负责对发送给自己的文本进行解释。这意味着攻击者可以创建一个 PDF 文件,其中放上一个虚假的 URL,shell 会对其进行特殊处理。然后,如果攻击者可以让某人确信并单击这个超文本链接,就可以让这个人运行他们选定的任何程序 —— 例如删除所有的文件,或者将他们可以读取的内容都发送到一个外部的 email 地址中。
解决方案:调出安全组件
有关重用组件的几点说明
应用程序实际上几乎都不会是自包含的;它们通常都会调用其他组件,这些组件可能包括:
- 底层的操作系统。
- 数据库系统。
- 可重用的库(包括动态加载的库)。
- Internet 服务(例如 DNS)。
- Web 服务,等等。
如今大部分的编程语言都包括大量的内置库,有些是自包含的,有些则可能是专门设计用来调出外部程序或服务的。
重用的问题相当关键 —— 为每个应用程序都从新开始编写所有的内容是非常愚蠢的,因为重新开发每个组件会花费太长时间。而重用这些组件则具有潜在的安全性优势 —— 开发人员可以集中精力确保自己的组件不存在安全性漏洞,从而可以将这个安全性组件应用到更多的系统中。即使以后发现这些组件的安全性漏洞,也可以迅速修复,并将修正后的组件应用到所有使用该组件的应用程序中。
然而,调用其他组件也有一个不好的方面:攻击者可以利用这些调用进行攻击。
开放 Web 应用程序安全性项目(Open Web Application Security Project,OWASP)“十种最关键的 Web 应用程序安全性漏洞”将 注入缺陷(injection flaw) 作为最关键的一种安全性漏洞。OWASP 解释说 Web 应用程序会向外部系统或本地操作系统传递参数,还解释说:
如果攻击者可以在这些参数中嵌入恶意的命令,那么外部系统就可能以应用程序的名义执行这些命令。
为了在重用这些组件时帮助仔细规划,下面简要介绍一下需要考虑的事情:
- 只使用安全组件,并且只采用安全的方式。
- 只向组件传递有效数据,并且确保这些数据会按照您期望的一样进行处理。具体来说,就是要警惕元字符的使用(这是 SQL 注入、 shell 元字符注入、 格式化字符串 和 Perl open() 攻击的原因)。
- 检查返回值并处理异常。
- 当在应用程序和组件之间传递数据时对数据进行保护。
下面让我们更详细地考虑一下这些问题。
安全组件,安全方法
开始重用任何组件之前,即使是很小的一个函数,我们要执行的第一个步骤都是检查这个组件的文档,从而了解其中是否提及了安全性警告。例如,C 标准包括一个 gets() 函数,但是相关文档中会警告说 gets() 函数是危险的。问题是 gets() 不能防止出现缓冲区溢出的问题;攻击者可以简单地发送超过传递给 gets() 的缓冲区可以存储的数据。这样可以让攻击者控制内部数据,甚至控制整个程序。
下面是另外一个例子:每种语言都有一个可以返回一个“随机”值的函数,该值实际上是可以预知的。虽然这些函数对于模拟程序来说是有帮助的(因此您可以重新运行模拟程序),但是如果您使用这些函数来创建安全密钥,那可实在是非常危险。为什么呢?因为这些函数让攻击者很容易就可以判断出密钥。
如果您正在使用一个广泛使用的组件,就请考虑在 Web 上搜索一下,看是否存在已知的安全性问题,或者非正常使用的方法。
如果您试图重用要进行人机交互的整个应用程序,就需要特别注意 —— 一条很好的规则是您根本就不应该这样做。要进行人机交互的程序试图变得“有帮助”,并猜想用户的意图。这种“帮助”也可以帮助攻击者创建用于误导程序的数据。
虽然随着时间的流逝,要进行人机交互的程序通常都会发生一些微妙的变化,变得更加适合于用户,但是却可能误导试图调用它们的程序。另外,这些程序通常都有一些函数让用户根据自己的目的而调用其他程序。例如,诸如 vim (vi)、emacs 以及较老的 “ed” 之类的文本编辑程序都有一些函数让用户调出操作系统命令 shell,这会为攻击者的利用创建一个简单的目标。当在类 Unix 的系统上编辑文本时,比较安全的方式是使用为这些工具设计的一些命令,例如 sed、tr、gawk 或 perl。
同样的情况也适用于字处理、电子表格程序等。通常来说,人机交互的程序的编写应该构建在一些该程序可以调用的其他组件的基础之上。这样,就更容易修正用户接口,并且其他程序也更容易重用程序的功能。
传递有效数据,防止元字符的问题
如果某个组件正在被重用,那么它通常就不会为了您的特殊应用程序进行裁剪。反之,被重用的组件通常都具有很好的接口和很多强大的功能。在向重用的组件传递数据时,攻击者可能试图向您的应用程序提供数据,从而做一些您不期望的事情。
确切地了解哪些数据允许被发送到您正在重用的地方,并且确保您只会发送有效数据,这一点非常重要。如果有任何疑问,请 在将数据发送给组件之前 对数据进行检查(实际上,不管怎样都检查一次是一个不错的主意)。如果一个数字应该介于 0 和 100 之间,就请检查这个数字是否符合这个规则。确保您使用的数据类型与组件使用的数据类型一致。如果组件期望的是有符号整数,就请确保将您的数据先转换成有符号整数再进行检查。具体来说,如果组件期望一个最小值为 0 的整数,就请实际检查这一要求是否能够满足。几乎在所有的语言中,如果将一个非常大的无符号整数转换成一个有符号整数,结果都是一个负数,因为符号位通常就是有符号数值中的最高位。
只要可能,请确保您发送的数据不会受到攻击者的控制。实际上,在任何可能的地方,都要延续这条规则。常见的格式化字符串攻击来自于一个思想:攻击者可以控制显示数据所使用的格式。然而,如果字符串的格式在程序中是一个常量,那么攻击者就无法控制了。gcc 编译器选项 -Wformat-security 可以警告您代码中可能受到格式化字符串攻击的地方。
尽管攻击者可能会修改您发送的数据,但是您需要确保组件会按照您希望的方式对您发送的数据进行处理。这为我们带来了一个非常常见的问题: 元字符漏洞。很多优秀的可重用组件都实现了自己的一种复杂的命令语言。攻击者可能试图插入一些文本,组件会对这些文本进行解释,从而执行一些您(或开发人员)所不希望的操作。有两个经常发生这种情况的常见组件,分别是 Unix 命令 shell (/bin/sh) 和数据库系统的 SQL 命令,下面让我们来介绍一下这两个组件。
Shell 漏洞
类 Unix 系统的命令 shell(具体来说,就是标准 shell /bin/sh) 非常有用。它们可以简单地将不同的程序组合在一起执行有用的操作。您经常会发现在 shell 中一行简单的文本可以执行数百行程序才能实现的功能。
标准 shell 是一种完备的编程语言,具有很多内置的功能,用于简化与其他程序的集成。很多编程语言都包括一些内置的功能来调用标准 shell —— C 的 system(3) 和 popen(3) 函数,以及 Perl 的 ` 操作符也会调用标准的 shell。Windows 和 MS-DOS 用户会对 shell 在类 Unix 系统中使用的是如此频繁感到惊奇,因为 COMMAND.COM(Win/DOS 的等价产品)的功能非常有限,不能实现相同的功能。但是伴随这一功能一起出现的是责任。
具体来说,很多字符在 shell 中都有特殊的意义,因此一个常见的攻击技巧是,如果处理某些特殊字符,就试图让程序将这些特殊字符转发到 shell。
最好的解决方案是不要从程序内部直接调用 shell。既然这很容易犯错,干脆就不这样做好了。这就是说不要在安全的程序中使用 system() 和 popen() ,特别是当发送给这些调用的内容不是常量时。不要编写 setuid/setgid 这样的 shell 脚本;这在基于 Linux 的系统上根本就不会工作,在其他类 Unix 系统上也是一个安全漏洞。
如果您非常谨慎,就可以编写不是 setuid/setgid 的 shell 脚本。shell 脚本并不像其他 shell 的使用一样糟糕,因为脚本本身在文件中都是静态的。然而,即使这样使用 shell 也有一些缺点,因为在 shell 中执行操作很容易让攻击者有机会控制程序。
如果您正在编写 shell 脚本,聪明的方法是对每个程序都使用完整的路径名,这样即使攻击者可以修改 PATH 环境变量的设置,也很难起到什么危害。如果存在一个调用该脚本的 setuid/setgid 程序,请确保首先清除环境的设置(包括设置一个合理的 PATH 值)。
如果您坚持从程序中直接调用 shell,那么就请确保环境的设置是安全的。攻击者喜欢玩弄 PATH、IFS 以及其他环境变量的技巧来制造麻烦。请使用完整的路径名调用命令。
当直接调用 shell 时,最大的问题是您发送给 shell 的数据是否来自于非可信的数据。如果攻击者可以向 shell 发送具有特殊意义的字符,那就会出现麻烦。有很多这种字符。
在 shell 中,双引号(")可以终结另外一个双引号,分号(;)则表示一个命令结束,另外一个新命令开始,等等。例如,假设您有一个 C++ 程序,其中包含下面这段代码:
清单 1. 有漏洞的 C++ 代码
#include <string>
using namespace std;
...
string command = "md5sum < " + filename + " > ./results";
system(command.c_str());
}
|
要问的问题是攻击者可以控制变量 filename 的值吗?如果可以,就存在问题了。
让我们来看一个怎样利用这种代码的例子。SLOCCount 是我编写的一个程序,它统计程序中 源代码的行数(source lines of code)(因此名为 SLOC),(参见 参考资料 中的链接)。有些人认为我向 SLOCCount 添加了上面这样的代码。然而,重要的是认识到有些人使用 SLOCCount 来统计不是自己编写的程序。实际上,他们统计的程序可能是由攻击者编写的。由于 SLOCCount 可以被发送一个由专欲损害 SLOCCount 用户的攻击者编写的程序,因此 SLOCCount 应该试图对自己的用户进行定义,从而与那些攻击者区分开来。
现在想像一下如果攻击者将文件名修改成 x ; ;rm -fr ~ 会发生什么事情 —— 这种文件名是可能的。这意味着这个系统命令会向 shell 发送下面的命令:
md5sum < x ; rm -fr ~ > ./my-output
这样会删除运行 SLOCCount 的用户的整个主目录。注意虽然这个程序没有网络接口,我们 仍然 需要担心它的安全性,因为攻击者可以提供一些输入。
根据存储结果的方式不同,这段代码还有其他可能的问题。如果有多个程序正在向 my-output 写入内容会发生什么事情呢?攻击者可以操作文件、目录或其祖先目录吗?
元字符问题的一个简单解决方案是将输入信息限制为不是元字符的那些字符,例如 A-Z、a-z 和 0-9。有时您可以这样做。
但是如果您 必须 使用 shell 并且 不能 限制到达的数据,那么就需要在将命令中的字符发送到 shell 中之前对可能存在的每个元字符进行转义。对于 shell 来说,在每个您 不确信 没有问题的字符前面都插入一个“\”符号,并将其作为命令的一部分发送给 shell。另外,不要允许存在 NUL 字符;有些 shell 不会将其作为命令输入进行处理。
SQL 注入
SQL 注入本质上与 shell 元字符的问题是相同的,不过它是由 SQL 解释器进行解释的,而不是由 shell 进行解释的。
在 SQL 注入攻击中,程序会创建一个 SQL 命令,并将其发送给 SQL 解释器。这个程序允许攻击者包括可以修改 SQL 命令意义的字符。这些攻击的结果是广泛的 —— 他们可以获取您想要保密的数据,允许对任意数据的修改,允许在本不应该通过验证的地方通过验证,甚至锁定数据库,这样整个系统都变得不可用了。
SQL 注入攻击是一个可能会损害高价值站点的漏洞。如果您的站点具有足够的数据可以说明使用 SQL 数据库是适当的,那么这些数据可能也就值得其他人用于不良的目的。因为重新编写对 shell 的调用非常简单,因此 shell 的问题就不存在了;不过要消除 SQL 请求是不现实的。通常,程序整个就是要使用保存在 SQL 数据库中的数据进行工作。
一旦您知道需要查看什么内容,有漏洞的代码很容易就会产生损害。调用 SQL 的错误方法是,简单地执行字符串拼接(或插入)操作来创建 SQL 命令,这些命令使用未经筛选的数据。这个 Perl 代码就是该问题的例子: $cmd = "SELECT salary,lastname,firstname FROM employee WHERE eid=" . $eid; ,后面是 SQL 命令的 prepare() 和 execute() 。注意我们在此处进行了一次简单的字符串拼接操作("." 在 Perl 中是拼接操作)。如果变量 $eid 是一个简单的数字,那么一切都如我们所愿;但是如果攻击者将 $eid 修改成 "5 OR 1=1",那么 $result 就会最终包含从整个表中选择出来的列了。
其他技巧包括在运行其他命令时嵌入“;”,多插入一个双引号(")来提前转义引用的字符串,等等。对于攻击者来说,这个主题存在无穷的变化。基本上来说,如果您使用简单的字符串拼接或字符串替换操作来创建 SQL 查询,那么就很可能面临着问题。
避免这种问题的攻击有两种方法:
在接受任何数据之前,首先定义一个正则表达式来描述您希望接受的格式,不匹配这个格式的数据都会被拒绝。如果可能,确保您的格式不会接受 SQL 语法中的任何有语法含意的字符(例如双引号和分号)。如果可以,就将值限制为字母和数字。通过在合法的列表中都不包含空格和标点符号,就可以防止出现被控制的问题。
然而,更通常的情况是:您必须接受数据,这些数据如果不仔细处理,就可能会产生问题;有时,接受的数据是不应该接受的。记住,永远都不要简单地拼接文本来创建 SQL 命令。相反,在库例程中寻找一些可以自动防止您犯这种错误的高级例程。
不同的语言调用不同的内容,例如 "bound" 或 "placeholder" 或 "prepared" 或 "parameterized" SQL 语句。例如,PHP 有一个 bind_param 方法可以用来正确地替换用户数据。这些例程应该检查并确保用户输入的数据是按照希望的格式给出的(这样如果您声明这是一个数字,那么该例程就不会接受其他类型的数据);然后在数据中插入适当的转义字符,这样 SQL 解释器就不会曲解这些数据。
如果在您选择的语言中没有提供这种功能,就请在将这些数据合并到其他字符串中创建查询语句之前,考虑创建这种例程(至少创建一些专用的例程)来检查这些数据可以匹配所期望的格式。如果您必须自己编写这种例程,请 确保 所有引用的数据都是正确的。
下面是几个常见的例子,可以说明我们刚才一直讨论的漏洞。
不幸的是,将数据从攻击者传递到对数据进行解释的库是非常常见的。在 C 语言中,一个常见的错误是将攻击者的数据传递到格式化字符串参数中(例如 printf(3) 的第一个参数)。printf 的格式化字符串也可以输出数据(使用 %n 指令),并且可以暴露任意的数据,这就使得这个问题成为一个非常严重的漏洞。下面是一个这种错误的例子:
printf(bad); /* DON'T DO THIS if attacker can control 'bad' */ .
虽然格式化字符串攻击通常是用来攻击 C/C++ 程序,但是其他一些语言也有格式化字符串,对于这些语言来说,您需要确保攻击者不会控制格式化字符串。例如,Python 有一个内置的 "%" 操作符,它就执行格式化操作("%" 前面的参数就是指定的格式),因此要确保攻击者不会控制这个格式,也就是说使用常量作为字符串的格式。
在 Perl 中,一个常见的错误是错误地使用 open() 函数会让攻击者完全控制系统。如果您真正想实现安全性,那么 Perl 的 open() 函数通常都太过宽松了 —— 例如,如果攻击者可以将文件名修改为第一个字母是一个管道符号,或者第一个/最后一个字母是空格,通常就会出现问题,因为 open() 会对某些特定的字符进行特殊的解释。
Perl 的内置函数会忽略开头或结尾的空格字符,而不管这些位置是否应该是空格。通常,在 Perl 中您最好使用 sysopen() 而不是 open() ( perlopentut 和 perlfunc 的手册页中包含了更多信息)。
在调用命令行程序时必须小心。大部分类 Unix 程序都使用短横线(-)来表示选项。Windows 程序通常使用斜线(/),有时也使用短横线来表示选项。如果攻击者可以添加或修改某些以短横线或斜线开头的内容,并将其传递给命令行程序,那么这些内容就可能会被错误地当作选项进行处理。
除 C 之外的大部分语言,包括 C++,都可以很方便地使用一个内嵌 NUL 字符(字符 0)的字符串。与之相反,C 语言使用 NUL 字符来标记一个字符串的结束。这就意味着大部分 C 例程不能处理中间包含 NUL 字符的字符串。虽然这个区别看起来似乎很小,但是请考虑一个问题:很多库(包括操作系统调用)使用 C 的约定,因为实际上任何地方都可以使用 C 约定。因此,如果一个内嵌 NUL 字符的字符串被传递给使用 C 约定的库,那么这个字符串就会突然被从第一个 NUL 处截断了。攻击者有时可以利用这一点或类似的技巧来确保应用程序看到的内容和所调用的例程看到的内容不同。
返回值和异常
正确地发送数据还不够。您还需要确保所处理的内容都可以正确返回。
正确地处理响应实际上是在使用 C 语言编写安全代码时最大的问题之一( C 语言还使得很难避免缓冲区溢出的问题)。C 语言没有包含一种内置的功能来处理异常,因此默认情况下,如果您不处理函数的返回值,它就会将其忽略。每次您调用一个可以返回错误值的函数时,就必须小心地检查请求所生成的结果正是您所希望的(如果不是,就需要对错误进行处理)。如果攻击者 可能 让这种问题出现,就需要对这种情况进行规划。
例如, read(2) 所读取的字节数可能少于所请求的字节数, write(2) 所写入的字节数也可能少于所请求的字节数。虽然有些人可能并不喜欢,但是我还是要强烈建议 gcc 的用户启用 -Wreturn-type 选项( -Wall 的一部分),如果函数没有返回值,就会输出一些警告信息。您可以在函数的前面加上 "(void)",从而显式地声明不需要返回值,但是在这样做之前需要仔细考虑。
起初,这可能是一个痛苦的问题 —— 有些程序员并没有认识到 printf() 和很多其他函数实际上也会返回一个值。如果您担心安全性的问题,就应该注意这一点。使用 C 语言编写的真正安全的程序中大部分都是错误处理代码,只有很少一部分代码进行“正常”处理,这并不罕见。
值得感激的是,大部分其他编程语言都包含有异常处理的功能,这可以某种程度上简化错误处理的过程。但是不要因此而自满 —— 您仍然需要了解我们所调用的函数会触发哪些异常,以及如何适当地处理这些异常。具体来说,就是需要确保释放所持有的锁,并且防止会由于攻击者所发送的数据而引起整个应用程序的崩溃。
如果攻击者可以影响所返回的数据,就需要小心。例如,如果您调用了一个 DNS 解析程序来获得一些信息(例如 IP 地址所对应的域名),就请记住这些数据可能会是由攻击者直接提供的。谨慎对待这个问题 —— 不要假设它具有任何特定的格式或大小,除非您有理由相信这个数据,否则就不要想当然地信任它。
在处理任意数据时,必须谨慎,就像在输入这些数据时一样谨慎(因为这就是我们使用的数据)。数据中可能含有 NUL 字符、无效字符或其他可能产生问题的东西。
对在应用程序和组件之间传递的数据进行保护
确保攻击者不会干扰或读取在应用程序和所调用的组件之间传递的数据。在向底层的操作系统发起一个系统调用时,这通常都不是问题(至少直接调用时没什么问题)。
具体来说,如果您运行一个 setuid/setgid 程序,允许用户重定向库调用的机制被禁用了 —— 正如我们早已讨论过的一样, setuid/setgid 程序需要清除它们的环境设置,以便它们调用的东西就会受到保护。
但是这些机制不能处理 Internet 上广泛的情况。如果您正在向 Web 服务发起一个调用,即使您信任此 Web 服务,也需要确保攻击者不会干扰应用程序和使用的服务之间的通信。扪心自问一下:您是否希望防止攻击者读取(机密性)、修改(完整性)或阻止(可用性)服务。
维护机密性和完整性的典型解决方案涉及使用现有的安全协议和加密算法。不要重新发明您自己的协议和算法了。常见的安全协议包括 TLS/SSL、OpenSSH 和 IPSec。它们通常都会采用一些加密算法,例如 RSA、SHA-1、AES 和 Triple-DES。
创建一个到服务的安全连接,它会进行加密和验证,这样您至少有一种方法为到该服务的连接保证机密性和完整性。第一个步骤是了解您需要这些协议和算法;有关设置的知识请参阅另一篇文章。
现在安全了!
显然,如果您使用一种不安全的方法依赖其他组件,就不可能获得安全的程序。在本文中,我们给出了几种您可能碰到很多常见的攻击的方法。但是即使当您安全地调用其他组件时,回送输出结果的方法也可能破坏程序的安全。
并不奇怪,攻击者已经找到了一些方法来利用这些程序的输出结果。在下一篇文章中,我们将介绍如何统计这种攻击。
参考资料
|
|
关于作者
|