验证输入—接收用户数据的最佳实践方法

发表于:2007-07-04来源:作者:点击数: 标签:
2003 年 7 月,计算机应急反应小组协调中心报告了 Microsoft Windows 的 DirectX MI DI 库中一组危险的漏洞。DirectXMIDI 库是用于播放 MIDI 格式音乐的底层 Windows 库。不幸的是,这个库没有能力去检查 MIDI 文件中的所有数据值;text、copyright 或者 MTh

  2003 年 7 月,计算机应急反应小组协调中心报告了 Microsoft Windows 的 DirectX MIDI 库中一组危险的漏洞。DirectXMIDI 库是用于播放 MIDI 格式音乐的底层 Windows 库。不幸的是,这个库没有能力去检查 MIDI 文件中的所有数据值;text、copyright 或者 MThd track 域中错误的值可以导致这个库的失效,而攻击者就可以利用这一漏洞让系统去执行他们想要执行的任何代码。这是特别危险的,因为 Internet Explorer 在察看一个包含 MIDI 文件链接的网页时,会自动加载那个文件并播放它。结果呢?一个攻击者只需要发布一个网页,当用户察看这个网页时,让用户的计算机删除所有的文件、把所有的机密文件通过电子邮件发送到其他地方、机器崩溃,或者去做任何攻击者想要做的事情。
  
  检查输入
  在几乎所有安全的程序中,您的第一道防线就是检查您所接收到的每一条数据。如果您能不让恶意的数据进入您的程序,或者至少不在程序中处理它,您的程序在面对攻击时将更加健壮。这与防火墙保护计算机的原理很类似;它不能预防所有的攻击,但它可以让一个程序更加稳定。这个过程叫做检查、验证或者过滤您的输入。
  
  一个明显的问题是,在何处执行检查?是在数据最初进入程序时,或者是在一个低层次的例程在实际使用这些数据时?通常,最好在这两处都对其进行检查;这样,即使一个攻击者成功地突破了一道防线,他们还会遇到另一条。最重要的规则是所有的数据必须在使用之前被检查。
  
  误区:寻找不正确的输入
  安全程序开发人员一个最大的误区是尝试去查找“非法的”数据值。这是不对的,因为攻击者非常聪明;他们常常会想到出其他的危险数据值。所以应该做的是确定哪些是合法的,检查数据是否符合定义,拒绝所有不符合定义的数据。为了安全,在开始时应该特别谨慎,只允许您知道合法的数据。毕竟,如果您限制的过于严格,用户很快就会报告说程序不允许合法的数据进入。另一方面,如果你限制的过于宽松,可能得直到程序被破坏您才会发现这一问题。
  
  例如,我们假设您要基于用户的某个输入创建文件名。您可能知道不应该允许用户的输入中包括“/”,但是仅仅去检查这一个字符可能是不对的。比如,控制字符呢?空格会不会出问题?如果以破折号开头呢(在不好的代码中可能会出问题)?特别的短语会不会出问题?在绝大多数情况下,如果您创建了一个“非法”字符的列表,攻击者还是可以找到利用您的程序的方法。所以,应该检查并保证输入符合你认为是安全的特定模式,而拒绝不符合这个模式的所有输入。
  
  确定出您所知道的危险值仍不失为一个好主意:您可以用它们(在头脑中)检查您的确认例程。这样,如果您知道使用“/”是危险的,就可以检查您的模式保证它不会让这个字符通过。
  
  当然,所有这些都面临着一个问题:什么是合法的值?答案部分取决于您所期望的数据类型。所以接下来的几节我们将讨论程序要用到的几种通用数据类型――以及如何处理它们。
  
  数字
  我们从看起来最容易读的一类信息开始――数字。如果您期望输入的是一个数字,就确认数据是数字格式――比如,只是针对阿拉伯数字,并且是至少一位阿拉伯数字(您可以使用与正则表达式 ^[0-9]+$ 检查它)。在大多数情况下会有一个最小值和一个最大值;如果是这样,要确认数据在合法范围之内。
  
  不要根据没有减号这一条件就认为不会有负数。在很多数据读取例程中,如果读到一个特别大的数,就会发生"溢出"而变成一个负数。实际上,一个非常聪明的针对 Sendmail 的攻击正是基于这一原理。Sendmail 会检查"调试标记"是不是比合法的值大,但是它并没有去检查这个值是不是负数。Sendamil 的开发者想当然地认为既然他们不允许使用减号,就不必再去检查输入是不是负数了。问题是数据读取例程会将大于 2^31 的数,比如4,294,967,269 ,转换成负数。攻击者可以利用这一点来覆盖至关重要的数据,并控制 Sendmail。
  
  如果您读取的浮点数,还有另外需要关注的问题。许多设计用来读取浮点数的例程可能会允许“NaN”(非数字)这样的值。这样实际上会给接下来的处理例程带来问题,因为任何与这些数据比较的结果都会是假(而且,NaN 与 NaN 也不相等!)。您还需要知道标准 IEEE 浮点数的其他特殊定义,比如正无穷大和负无穷大,负零(还有正零)。所有您的程序没有考虑到的输入数据都有可能导致以后被利用。
  
  字符串
  同样,对于字符串您也要确定哪些是合法的,并拒绝所有其他的字符串。通常指定合法字符串最简单的方法是使用正则表达式:只需正确使用正则表达式编写描述哪些字符串合法的模式,抛弃那些不符合这个模式的数据。例如,^[A-Za-z0-9]+$ 指定字符串至少为一个字符长,而且只能包括大写字母、小写字母和阿拉伯数字0到9(任意的顺序)。您可以使用正则表达式来更为详细地限制所允许的字符串(例如,您可以进一步指定第一个字符可以是哪些字母)。所有的语言都已实现正则表达式的库;Perl 是基于正则表达式的,对于 C,函数 regcomp(3) 和 regexec(3) 是POSIX.2 标准,并被广泛应用。
  
  如果您使用正则表达式,一定要明确地指出您要匹配数据的的开始(通常用 ^ 来标识)和结束(通常用 $ 来标识)。如果您忘记了包括 ^ 或者 $,攻击者就可以在他们的攻击中嵌入合法的文本通过您的检查。如果您使用的 Perl,并且使用的它的多行选项(m),要注意:您必须使用 \A 来标识开始,用 \Z 来标识结束,因为多行操作改变了 ^ 和 $ 的含义。
  
  最大的问题是如何明确地指出在字符串中哪些是合法的。通常,您应该尽可能地严格。有很多字符都会带来特定的问题;只要可能,您就不愿意允许在程序内部或者最终输出中有特定含义的那些字符。人们发现这确实很困难,因为在一些情况下有太多的字符可能会带来问题。
  
  这里是经常会带来问题的字符的部分清单:
  
  常规控制字符(字符值小于32): 还特别包括字符0,传统上称做 NUL;我把它称为 NIL 以区别于 C 语言中的 NULL 指针。在 C 语言中 NIL 标记了一个字符串的结束;即便您没有直接使用 C 语言,许多库会间接地去调用 C 语言的例程,如果给出了 NIL,就有可能出错。另一个问题可以被解释为命令结束的行结束符。不幸的是,有好几种行结束编码:基于 UNIX 的系统使用的是换行字符 (0x0a),但是基于 DOS 的系统(包括windows)使用的是 CP/M 的回车换行 (0x0d 0x0a),Apple MacOS 使用的是回车 (0x0d),许多 IBM 主机(比如 OS/390)使用的是下一行 (0x85),并且有一些程序甚至(错误地)使用反 CP/M 标记 (0x0a 0x0d)。
  字符值大于127:这些是国际化的字符,但问题是它们可能会有许多可能的含义,所以您需要确保它们被正确地解释。通常这些都是 UTF-8 编码的字符,有其自身的复杂性;可以参考本文 后面关于 UTF-8 的讨论 。
  元字符: 元字符是在您所依赖的程序或库中——比如命令 shell 或者 SQL——有特定含义的字符。
  在您的程序中有特定含义的字符: 例如,用于定界的字符。许多程序将数据存放在文本文件中,使用逗号、制表符或者冒号隔开数据域;您需要拒绝含有这些值的数据或者对其进行编码。当前,一个常见的问题是小于号 (<),因为 XML 和 HTML 都用到了它。
  这不是一个详尽的清单,并且您经常是需要接受它们中的一部分的。以后的文章将讨论当您不得不接收这些字符时如何处理它们。给出这个清单的目的是说服您尝试去接受尽可能少的数据,并且在接受另一个字符之前要慎重考虑。您接受的字符越少,您给攻击者制造的难度就越大。
  
  更多的特殊数据类型
  当然,还有更多的特殊数据类型。这里是对其中一部分的一些简要介绍。
  
  文件名
  如果数据是一个文件名(或者用于创建一个文件),应该对其进行严格限制。最好不要让用户来选择文件名,如果不得不那样做,那么把字符局限于形如 ^[A-Za-z0-9][A-Za-z0-9._\-]*$ 的较小模式。您应该考虑将“/”、控制字符(尤其生成新行的)和前导符“.”(UNIX/Linux 系统中的隐藏文件)等这些字符从合法模式中去掉。以“-”为前导也不好,因为写得不好的脚本会把它们解释为选项:如果有一个文件名为“-rf”,那么在 UNIX/Linux 中执行命令 rm *,将会变成执行 rm -rf *。将“../”从模式中去掉也是一个好主意,使攻击者无法“跳出”当前目录。如果可能,不要允许使用通配符(使用字符 *、?、[] 和 {} 来选择一组文件);攻击者可以通过创建稀奇古怪的通配模式来让系统不知如何处理而关闭。
  
  Windows 还有另外一个问题:一些文件名(忽略扩展名和字母的大小写)总是被认为是物理设备。例如,如果一个程序在任何目录中试图去打开“COM1”或者甚至“com1.txt”,将被系统误解为是尝试和串口通信。由于我所关心的是类 UNIX 系统,我就不再深入地探讨如何解决这个问题了,而且这也没有什么意义,因为这只是一个例子,用来说明一种用于检查的合法字符不足的情况。
  
  本地化
  在当今全球经济的时候,许多程序都允许用户用于显示的语言和其他语言相关的特定信息(比如数字格式和字符编码)。程序通过用户提供一个“Locale”值来得到这一信息。例如,本地化参数值为“en_US.UTF-8”说明本地化参数使用的语言是 English,使用美国习惯,使用 UTF-8 编码。本地的类 UNIX 程序从环境变量中(通常是 LC_ALL,但可能更详细地分为 LC_COLLATE、LC_CTYPE、LC_MONETARY、LC_NUMERIC 和 LC_TIME;其他要检查的值是 NLSPATH、LANGUAGE、LANG 和 LINGUAS。)得到这一信息。网络应用可以通过接收语言请求的头信息或者别的方法来获得这个信息。
  
  由于用户可能是一个攻击者,我们需要对本地化参数值进行验证。我建议您确保本地化参数匹配以下模式:
  
  ^[A-Za-z][A-Za-z0-9_,+@\-\.=]*$
  
  我 如何 来创建这个验证模式比这个模式本身更有价值。我首先查找了相关的标准和库文档来确定一个 正确 的本地化参数应该是什么样的。就这一点而言,有很多互相抵触的标准,所以我必须确保最终的模式可以接受所有这些标准定义的本地化参数。很快我就发现只需要以上列出的字符,限定这个字符集(尤其是第一个字符)可以避免很多问题。然后我考虑了常见的危险字符(比如作为目录分隔符的“/”,用于“上级目录”的“..”,用于前导的破折号,或者空的本地化参数),并确认它们被过滤掉。
  
  UTF-8
  国际化对程序还有另外一方面的影响:字符编码。处理文本需要某种约定将字符转换为计算机实际可以处理的数字;这些约定叫做 字符编码 。一个特别常见的文本编码方法是 UTF-8,它是一个优秀的字符编码方法,本质上可以表示任何语言的任何字符。UTF-8 之所以特别受欢迎是因为它将普通的 ASCII 文本作为它的一个简单子集。结果是,原来只是设计用于处理 ASCII 的程序可以很简单地升级到可以处理 UTF-8;在一些情况下这些程序根本不需要修改。
  
  但是,和任何美好的事物一样,UTF-8 也有其不足。有一些 UTF-8 字符由一个字节来表示,一些用两个字节来表示,还有一些用三个字节来表示,甚至更多,而程序被假定总是生成最短的可能的表示。可是,许多 UTF-8 读取器会接收到“过长”的序列;比如,某些三个字节的序列可能被解释成由一个由两个字节表示的字符。攻击者可以利用这一点来“骗过”数据验证来攻击程序。您的过滤器可能不允许十六进制的 2F 2E 2E 2F (“/../”),但如果它允许 UTF-8 的十六进制值 2F C0 AE 2E 2F ,程序可能也会把它解释为“/../”。所以,如果您要接收 UTF-8 文本,您要确认每一个字符都使用最短可能 UTF-8 编码(拒绝任何不是最短形式的文本)。许多语言有处理这些的工具,您如果自己写的话也不难。要注意序列"C0 80"是一个可以表示 NIL (字符00)的过长序列;有一些语言(比如 Java)认为这个特定的序列是可以接收的。
  
  电子邮件地址
  许多程序必须接收电子邮件地址, 但是处理所有可能的合法电子邮件地址(如 RFC 2882 和 822 所指定的)令人惊讶的困难。Jeffrey Fiedl 的用于检查电子邮件地址的“短”正则表达式有 4,724 个字符长,即使是这样还是没有包括所有的情况。不过,大多数的程序可以是非常严格的,只接收一个特别受限子集的电子邮件以正常地工作。在大多数情况下,只要程序可以接收正常的"name@domain"格式的因特网地址(像“john.doe@somewhere.com”),拒绝像“John Doe <john.doe@somewhere.com>”这个在技术上合法的地址是没问题的。Viega 和 Messier 2003 年出版的书中有可以完成这项检查的子例程。
  
  Cookies
  网络应用程序经常为重要的数据使用 cookie。如我以后将要讲到的那样,不要忘记用户可以任意地重新设置 cookie 的值和形式,这一点很重要。不过,有一个重要的验证窍门现在有必要一提。如果您接收一个 cookie 值,检查它的域值是不是您所期望的(比如,您的一个站点)。否则,一个(可能已经被击垮)相关的站点可能被插入到用于欺骗的 cookie 中。如果您对此关心,可以参考 IETF RFC 2965 ,可以得到关于这种攻击的详细说明(在 参考资料 中有相关链接)。
  
  HTML
  有时候您的程序要从一个不信任的用户处得到数据并把它传给其他的用户。如果第二个用户的程序有可能被这些数据破坏掉,那么您有责任保护第二个用户。使用看起来可信任的中间媒介传输恶意数据的攻击被称为“交叉站点恶意内容”攻击。
  
  这个问题对于网络应用来说尤其是一个难题,比如那些允许用户添加当场连续评述的社区"黑板"。在这种情况下,攻击者可以尝试添加包含恶意代码脚本、图片标签的 HTML 格式的评述;目的是让其他用户的浏览器运行在察看本文的时候去执行那些恶意的代码。由于攻击者通常是试图添加恶意的脚本,因些这种变化被称为"交叉站点脚本攻击"(XSS 攻击)。
  
  通常来说避免这种攻击的最好的办法是验证您所接收的 HTML 没有包括这种恶意的脚本。同样,您要做的是把您所知道的安全的列出来,然后禁止其他。
  
  通常,在 HTML 中您至少可以接收下面这些以及它们的结束标签:
  
  <p> (段落)
  <b> (加粗)
  <i> (斜体字)
  <em> (强调)
  <strong> (特别强调)
  <pre> (预定义文本)
  <br> (强制断行 -- 注意它不需要关闭标签)
  记住 HTML 标签是不区分大小写的。除非您检查过了属性的类型和它的值,否则不要接收任何属性;有很多支持 Javascript 等的属性可能会给您的用户带来麻烦。
  
  您当然可以扩展这个集合,但是要小心。特别需要注意的是任何让用户立即加载另一个文件的标签,比如 image 标签――那些标签非常适合 XSS 攻击。
  
  另外一个问题是您需要确认攻击者无法任意打乱文件的其余部分,特别是,您要确保任何评述或者片段看起来不能像正式的内容。一个方法是保证任何 XML 或 HTML 命令完全对称(任何打开的都要关闭)。这在 XML 术语中称为"格式良好的"数据。如果您正在接收标准 HTML,您可能不需要为段落标记(<p>)做这些,因为它们不是对称的。
  
  在许多情况下您可能要接收 <a> (超级链接),还可能需要属性“href”。如果您必须这样做,您必须要验证您链接到的 URI/URL――这是我们的下一个话题。
  
  URI/URL
  技术上讲,超文本链接可以是任何“统一资源标识符”(URI),不过现在大多数人只知道一种特定的 URI,那就是“统一资源定位符”(URL)。许多用户将盲目地点击指向一个 URI 的超级链接,并假定不会因为显示它而带来麻烦。作为一个开发者,您的任务是确保用户的期望不会落空。
  
  尽管 URI 提供了很大的灵活性,可是如果您接收到了一个来自攻击者的 URI,您需要在把它转给任何其他人之前检查它。攻击者可以在 URI 中加入很多古怪的东西来迷惑用户。例如,攻击者可以引入一些查询,而导致用户去做不愿去做的事情,并且他们可以让用户误以为是要浏览另一个网站而不是他们确实在访问的。 不幸的是,很难给出一个适用于所有情况的单一模式。不过,一个可以防止大多数攻击,同时可以允许大多数有用的链接通过的(对于公共的网站而言)最安全的模式是:
  
  ^(http|ftp|https)://[-A-Za-z0-9._/]+$
  
  一个更为复杂的模式是:
  
  ^(http|ftp|https)://[-A-Za-z0-9._]+(\/([A-Za-z0-9\-\_\.\!\~\*\'\(\)\%\?]+))*/?$
  
  如果您的需要更复杂,您就需要更复杂的模式来检查数据;可以在我的书中(在 参考资料 中列出了)查找一些其他方法。
  
  数据文件
  复杂的数据文件和数据结构通常由众多的小的组件构成。只需要把这个文件或者结构分解,并检查每一部分。如果这些组件之间的有特定的依赖关系,那就一并检查它们。在开始时,编写这些代码可能会有一点无聊,不过它对于可靠性确实有好处:如果您拒绝了那些非法数据,许多不可思议的问题马上就会消失。
  
  结束语
  显然,有很多种不同的数据需要去检查。但是这些数据是在何处进入到您的程序的?答案在各种情况下不尽相同;实际上,您的程序可能会通过您所没有想到的途径得到攻击者的数据。我将在下一部分讨论这个问题。

原文转自:http://www.ltesting.net