跨站脚本 (XSS)
跨站脚本攻击 (XSS) 是一种攻击者能够让目标站点执行恶意代码,使其看起来像是网站一部分的攻击。
概述
网页浏览器从许多不同的网站下载代码并在用户的计算机上运行。其中一些网站高度可信,用户可能会将其用于敏感操作,例如金融交易或医疗咨询。而对于其他网站,例如休闲游戏网站,用户可能没有这种信任关系。浏览器安全模型的基础是这些站点应该相互隔离,因此来自一个站点的代码不应该能够访问另一个站点中的对象或凭据。这称为同源策略。
在成功的 XSS 攻击中,攻击者能够通过诱骗目标站点在其自己的上下文中执行恶意代码(使其看起来与目标站点同源)来颠覆同源策略。然后,该代码可以执行站点自身代码所能做的任何事情,例如:
- 访问和/或修改站点已加载页面的所有内容,以及本地存储中的任何内容
- 使用用户凭据发起 HTTP 请求,使攻击者能够冒充用户或访问敏感数据
所有 XSS 攻击都取决于网站做两件事
- 接受可能由攻击者精心制作的某些输入
- 将此输入包含在页面中,而未对其进行净化:即,未确保其不会作为 JavaScript 可执行。
两个 XSS 示例
在本节中,我们将通过两个容易受到 XSS 攻击的示例页面。
浏览器中的代码注入
在此示例中,假设用户银行的网站是 my-bank.example.com
。用户通常已登录,网站中的代码可以访问用户的账户详细信息并执行交易。网站希望显示一条针对当前用户的个性化欢迎消息。它将欢迎信息显示在标题元素中
<h1 id="welcome"></h1>
页面期望在URL 参数中找到当前用户的姓名。它提取参数值,并使用该值创建个性化问候消息
const params = new URLSearchParams(window.location.search);
const user = params.get("user");
const welcome = document.querySelector("#welcome");
welcome.innerHTML = `Welcome back, ${user}!`;
假设此页面从 https://my-bank.example.com/welcome
提供。为了利用此漏洞,攻击者向用户发送如下链接
<a
href="https://my-bank.example.com/welcome?user=<img src=x onerror=alert('hello!')>">
Get a free kitten!</a
>
当用户点击链接时
- 浏览器加载页面。
- 页面提取名为
user
的 URL 参数,其值为<img src=x onerror=alert("hello!")>
。 - 然后,页面将此值分配给
welcome
元素的innerHTML
属性,这会创建一个新的<img>
元素,该元素的src
属性值为x
。 - 由于
src
值生成错误,onerror
事件处理程序属性被执行,攻击者得以在页面中运行其代码。
在这种情况下,代码只是显示一个警报,但在真实的银行网站中,攻击者代码能够执行银行自己的前端代码所能做的任何事情。
服务器中的代码注入
在此示例中,考虑一个具有搜索功能的网站。搜索页面的 HTML 可能如下所示
<h1>Search</h1>
<form action="/results">
<label for="mySearch">Search for an item:</label>
<input id="mySearch" type="search" name="search" />
<input type="submit" />
</form>
当用户输入搜索词并点击“提交”时,浏览器会向“/results”发出 GET 请求,并将搜索词作为 URL 参数包含在内,如下所示
https://example.org/results?search=bananas
服务器希望显示搜索结果列表,标题指示用户搜索了什么。它从 URL 参数中提取搜索词。这在 Express 中可能如下所示
app.get("/results", (req, res) => {
const searchQuery = req.query.search;
const results = getResults(searchQuery); // Implementation not shown
res.send(`
<h1>You searched for ${searchQuery}</h1>
<p>Here are the results: ${results}</p>`);
});
为了利用此漏洞,攻击者向用户发送如下链接
<a href="http://example.org/results?search=<img src=x onerror=alert('hello')">
Get a free kitten!</a
>
当用户点击链接时
- 浏览器向服务器发送 GET 请求。请求的 URL 参数包含恶意代码。
- 服务器提取 URL 参数值并将其嵌入页面。
- 服务器将页面返回给浏览器,浏览器运行它。
XSS 攻击的剖析
与所有 XSS 攻击一样,这两个示例之所以可能,是因为网站
- 使用可能由攻击者精心制作的输入
- 在页面中包含输入而未对其进行净化。
这两个示例都使用相同的载体来传输恶意输入:URL 参数。但是,攻击者可以使用其他载体。
例如,考虑一个带评论的博客。在这种情况下,网站
- 允许任何人使用
<form>
元素提交评论 - 将评论存储在数据库中
- 将评论包含在网站提供给其他用户的页面中。
如果评论未被净化,那么它们就是潜在的 XSS 载体。这种攻击有时被称为存储型或持久型 XSS,并且特别严重,因为受感染的内容将提供给所有访问该页面的用户,每次他们访问时都会如此。
客户端和服务器 XSS
这两个示例之间的一个主要区别在于恶意代码注入在网站代码库的不同部分,这反映了每个网站的架构。
使用客户端渲染的网站,例如单页应用,在浏览器中修改页面,直接或间接通过 React 等框架使用 document.createElement()
等 Web API 进行修改。XSS 注入将在此过程中发生。这正是我们在第一个示例中看到的:恶意代码由页面中运行的脚本通过将 URL 参数值分配给 Element.innerHTML
属性来注入浏览器,该属性将其值解释为 HTML 代码。
使用服务器端渲染的网站在服务器上构建页面,使用 Django 或 Express 等框架,最常见的是将值插入页面模板中。XSS 注入(如果发生)将在模板化过程中在服务器中发生。这正是我们在第二个示例中看到的:代码由 Express 代码将 URL 参数值插入其返回的文档中来注入服务器。然后,当浏览器评估页面时,XSS 攻击代码会运行。
在这两种情况下,防御的通用方法是相同的,我们将在下一节中详细介绍。但是,您将使用的特定工具和 API 将有所不同。
XSS 防御
如果您需要在站点页面中包含外部输入,XSS 的主要防御措施有两项
- 使用输出编码和净化来防止输入变为可执行。如果您在浏览器中渲染内容,可以使用 Trusted Types API 来确保输入在包含到页面之前经过了净化函数处理。
- 使用内容安全策略 (CSP) 告诉浏览器允许执行哪些 JavaScript 或 CSS 资源。这是一种备用防御:如果第一道防线失败,可执行输入进入页面,那么正确配置的 CSP 应该可以防止浏览器执行它。
输出编码
输出编码是将输入字符串中可能使其危险的字符进行转义的过程,因此它们被视为文本而不是 HTML 等语言的一部分。
当您希望将输入视为文本时,这是一个合适的选择,例如,因为您的网站使用将输入插入内容的模板,如以下 Django 模板摘录
<p>You searched for {{ search_term }}.</p>
大多数现代模板引擎都会自动执行输出编码。例如,Django 的模板引擎执行以下转换
-
<
转换为<
-
>
转换为>
-
'
转换为'
-
"
转换为"
-
&
转换为&
这意味着如果您将 <img src=x onerror=alert('XSS!')>
传递到上面的 Django 模板中,它将转换为 <img src=x onerror=alert('XSS!')>
,并显示为以下文本
您搜索了 <img src=x onerror=alert('XSS!')>。
类似地,如果您使用 React 进行客户端渲染,嵌入在 JSX 中的值会自动编码。例如,考虑以下 JSX 组件
import React from "react";
export function App(props) {
return <div>Hello, {props.name}!</div>;
}
如果我们将 <img src=x onerror=alert('XSS!')>
传递给 props.name
,它将渲染为
你好,<img src=x onerror=alert('XSS!')>!
防止 XSS 攻击最重要的部分之一是使用性能良好且执行可靠输出编码的模板引擎,并阅读其文档以了解其提供的保护的任何注意事项。
文档上下文
即使您使用自动编码 HTML 的模板引擎,您也需要了解在文档的哪个位置包含不受信任的内容。例如,假设您有一个 Django 模板如下
<div>{{ my_input }}</div>
在此上下文中,输入位于 <div>
标签内,因此浏览器将其评估为 HTML。因此,您需要防范 my_input
是定义可执行代码的 HTML 的情况,例如 <img src=x onerror="alert('XSS')">
。Django 内置的输出编码通过将 <
和 >
等字符编码为 HTML 实体 <
和 >
来防止这种攻击。
但是,假设模板如下
<div {{ my_input }}></div>
在此上下文中,浏览器会将 my_input
变量视为 HTML 属性。由于 Django 对引号进行编码("
→ "
,'
→ '
),因此有效载荷 onmouseover="alert('XSS')"
将不会执行。然而,像 onmouseover=alert(1)
(或使用反引号 onmouseover=alert(`XSS`)
)这样的未加引号的有效载荷仍然会执行,因为属性值不需要加引号,并且反引号默认不被转义。
浏览器使用不同的规则来处理网页的不同部分——HTML 元素及其内容、HTML 属性、内联样式、内联脚本。需要进行的编码类型取决于插入输入的上下文。
在一个上下文中安全的内容在另一个上下文中可能不安全,因此有必要理解您包含不受信任内容的上下文,并实现此上下文所需的任何特殊处理。
-
HTML 上下文:插入到大多数 HTML 元素标签之间(除了
<style>
或<script>
)的输入被解释为 HTML。模板引擎应用的编码主要关注此上下文。 -
HTML 属性上下文:将输入作为 HTML 属性值插入有时是安全的,有时则不安全,具体取决于属性。特别是,像
onblur
这样的事件处理程序属性是不安全的,<iframe>
元素的src
属性也是如此。为插入的属性值添加引号也很重要,否则攻击者可能会在提供的值中插入一个额外的非安全属性。例如,此模板未对插入的值添加引号
django<div class={{ my_class }}>...</div>
攻击者可以通过使用
some_id onmouseover=alert(1)
这样的输入来利用此漏洞注入事件处理程序属性。为了防止攻击,请给占位符加上引号django<div class="{{ my_class }}">...</div>
净化
模板引擎通常允许开发人员禁用输出编码。当开发人员希望将不受信任的内容作为 HTML 而不是文本插入时,这是必要的。例如,在 Django 中,safe
过滤器会禁用输出编码,而在 React 中,dangerouslySetInnerHTML
具有相同的效果。
在这种情况下,开发人员有责任通过净化内容来确保内容安全。
净化是删除 HTML 字符串中不安全功能的过程:例如,<script>
标签或内联事件处理程序。由于净化和输出编码一样难以正确实现,因此建议使用信誉良好的第三方库。包括 OWASP 在内的许多专家都推荐 DOMPurify。
例如,考虑一个 HTML 字符串,例如
<div>
<img src="x" onerror="alert('hello!')" />
<script>
alert("hello!");
</script>
</div>
如果我们将此传递给 DOMPurify,它将返回
<div>
<img src="x" />
</div>
可信类型
拥有一个可以净化给定输入字符串的函数是一回事,但在代码库中找到所有需要净化输入字符串的位置本身可能是一个非常困难的问题。
如果您在浏览器中实现客户端渲染,那么如果使用未净化的不受信任内容调用一些 Web API,它们将是不安全的。
例如,以下 API 将其字符串参数解释为 HTML 并使用它来更新页面 DOM
Element.innerHTML
(React 的dangerouslySetInnerHTML
内部也使用它)Element.outerHTML
Element.insertAdjacentHTML()
Document.write()
其他 API 直接将其参数作为 JavaScript 执行。例如
Trusted Types API 使开发人员能够确保输入在传递给这些 API 之前始终经过净化。
强制使用可信类型的关键是 require-trusted-types-for
CSP 指令。如果设置了此指令,则将字符串参数传递给不安全的 API 将抛出异常
const userInput = "I might be XSS";
const element = document.querySelector("#container");
element.innerHTML = userInput; // Throws a TypeError
相反,开发人员必须将可信类型传递给这些 API 之一。可信类型是由 TrustedTypePolicy
对象从字符串创建的对象,其实现由开发人员定义。例如
// Create a policy that can create TrustedHTML values
// by sanitizing the input strings with DOMPurify library.
const sanitizer = trustedTypes.createPolicy("my-policy", {
createHTML: (input) => DOMPurify.sanitize(input),
});
const userInput = "I might be XSS";
const element = document.querySelector("#container");
const trustedHTML = sanitizer.createHTML(userInput);
element.innerHTML = trustedHTML;
注意: Trusted Types API 不提供净化功能:它是一个框架,开发人员可以在其中确保他们提供的净化功能已被调用。在上面的示例中,开发人员在 Trusted Types 框架中使用 DOMPurify 作为 HTML 接收器的净化器。
Trusted Types API 尚未获得良好的跨浏览器支持,但一旦获得,它将成为针对基于 DOM 的 XSS 攻击的重要防御措施。
部署 CSP
输出编码和净化都是为了防止恶意脚本进入站点页面。内容安全策略的主要功能之一是即使恶意脚本在站点页面中也能阻止其执行。也就是说,它是其他防御失败时的备用方案。
使用 CSP 缓解 XSS 的推荐方法是严格 CSP,它使用随机数或哈希来指示浏览器它期望在文档中看到哪些脚本。如果攻击者设法插入恶意 <script>
元素,那么它们将没有正确的随机数或哈希,并且浏览器将不会执行它们。此外,各种常见的 XSS 向量被完全禁用:内联事件处理程序、javascript:
URL 和像 eval()
这样将其参数作为 JavaScript 执行的 API。
防御总结清单
- 在浏览器或服务器中将输入插入页面时,请使用执行输出编码的模板引擎。
- 注意您插入输入的上下文,并确保在该上下文中执行适当的输出编码。
- 如果您需要将输入作为 HTML 包含,请使用信誉良好的库对其进行净化。如果您在浏览器中执行此操作,请使用可信类型框架来确保输入由您的净化函数处理。
- 实施严格的 CSP。