1. 项目概述:DOM-Based XSS,一个被低估的客户端“幽灵”
在Web安全领域,跨站脚本攻击(XSS)早已是臭名昭著的老对手。无论是反射型还是存储型,其攻击路径都离不开“服务器响应”这个环节,这也使得传统的防御手段,如Web应用防火墙(WAF)和服务器端输入过滤,有了明确的防御阵地。然而,今天我们要深入探讨的DOM-Based XSS,却像是一个游荡在客户端浏览器里的“幽灵”。它完全在用户的浏览器中发生,恶意脚本的注入和执行,服务器端可能毫不知情,传统的安全设备甚至“看”不到攻击流量。对于前端开发者、安全工程师乃至普通用户而言,理解这个“幽灵”的运行机制,是构建真正纵深防御体系的关键一环。简单来说,DOM-Based XSS是一种攻击者通过操纵页面的文档对象模型(DOM)来注入并执行恶意脚本的攻击方式,其特殊性在于,整个攻击的“罪魁祸首”是前端JavaScript代码对不可信数据源的不安全处理。
2. DOM-Based XSS的核心原理与攻击模型拆解
要理解DOM-Based XSS,我们必须先抛开“服务器-客户端”的传统攻击视角,将目光聚焦于浏览器内部。
2.1 DOM:动态网页的基石与潜在的风险接口
DOM不是一个编程语言,而是一个由W3C定义的、独立于平台和语言的接口。它允许程序和脚本(主要是JavaScript)动态地访问和更新文档的内容、结构及样式。当浏览器加载一个HTML页面时,它会解析HTML和CSS,并在内存中构建一棵DOM树。这棵树上的每一个节点都对应着页面中的一个元素(如<div>、<input>)。
JavaScript的强大之处在于,它可以通过DOM API(如document.getElementById、innerHTML、location.hash)来读取和修改这棵树。正是这种“动态性”带来了风险:如果修改DOM的数据来源不可信,且修改方式不安全,攻击者就能“教唆”JavaScript代码,在DOM中“写入”恶意脚本。
2.2 攻击链条:从数据源到执行点的完整路径
一次典型的DOM-Based XSS攻击,其生命周期完全在客户端闭环,可以拆解为以下清晰链条:
不可信数据源注入:攻击者找到一个方式,将恶意数据“放入”一个能被页面JavaScript访问的数据源中。这个数据源并非来自服务器响应体,而是来自客户端环境本身。最常见的源头包括:
window.location对象:特别是location.hash(URL中#后面的部分)、location.search(URL中?后面的查询参数)。攻击者可以构造一个恶意链接,如https://vulnerable-site.com/page#<script>alert(1)</script>。document.referrer:当前页面的来源URL。如果页面逻辑会根据referrer来动态生成内容,攻击者可以诱导用户从一个恶意网站跳转过来。document.cookie:尽管有HttpOnly保护,但某些脚本可能会错误地读取和输出Cookie内容。window.name:这个属性可以在页面跳转或iframe嵌套时传递数据,且生命周期较长。- 浏览器存储:如
localStorage、sessionStorage,如果存储的数据被未经验证地取出并使用。 - 来自其他窗口/iframe的消息:通过
postMessageAPI传递的数据。
不安全的DOM操作(Sink):页面中存在一段JavaScript代码,它从上述某个不可信数据源中读取了数据,然后通过一个“危险”的DOM操作方法,将这些数据当成了HTML或JavaScript代码来执行。这些危险的方法被称为“Sink”(接收器)。高危的Sink包括:
element.innerHTML = userDataelement.outerHTML = userDatadocument.write(userData)document.writeln(userData)eval(userData)setTimeout(userData, time)setInterval(userData, time)location.href = userData(如果userData以javascript:开头)- 某些HTML属性赋值,如
element.setAttribute('onclick', userData)
脚本执行与攻击达成:当不可信数据通过Sink被写入DOM时,如果其中包含的脚本标签(
<script>)或事件处理器(如onload、onerror)被浏览器解析并执行,攻击便告成功。此时,恶意脚本拥有当前页面的同源权限,可以窃取Cookie(未设置HttpOnly的)、发起恶意请求、篡改页面内容、进行键盘记录等。
注意:这里有一个关键区别。在反射型XSS中,恶意脚本是服务器“反射”回HTML响应中的。在DOM-Based XSS中,服务器返回的可能是完全干净、正常的HTML。是客户端JS“主动地”从URL里取出恶意代码并塞进了DOM。
2.3 与反射型、存储型XSS的本质区别
为了更深刻理解,我们用一个表格来对比:
| 特征 | 反射型XSS | 存储型XSS | DOM-Based XSS |
|---|---|---|---|
| 恶意代码存储位置 | 在URL中,由受害者请求携带 | 服务器数据库/文件系统 | 客户端URL片段、浏览器存储等 |
| 触发方式 | 用户点击特制链接 | 用户访问包含恶意代码的页面 | 用户访问特制链接或页面执行特定JS逻辑 |
| 服务器角色 | 解析请求参数,将其嵌入响应HTML | 存储并提供恶意代码 | 可能完全不参与,返回静态HTML |
| 检测难度 | 相对容易,WAF可检测请求与响应 | 相对容易,扫描器可检测存储点 | 极难,攻击流量不经过服务器,WAF无效 |
| 修复重点 | 服务器端对输入输出编码/过滤 | 服务器端对存储和输出编码/过滤 | 客户端JavaScript安全编码 |
这个对比清晰地表明,DOM-Based XSS的防御阵地发生了根本性转移,从前端开发阶段就必须介入。
3. 实战演练:解剖一个经典的DOM-Based XSS漏洞
理论说得再多,不如亲手“制造”并修复一个漏洞来得直观。我们假设一个常见的场景:一个单页面应用(SPA)有一个“欢迎消息”功能,消息内容从URL的hash中读取并动态显示在页面上。
3.1 漏洞代码示例
<!DOCTYPE html> <html> <head> <title>欢迎页面 - 漏洞版</title> </head> <body> <h1>网站首页</h1> <div id="welcome-message"> <!-- 消息将在这里动态显示 --> </div> <script> // 漏洞点:从 location.hash 获取数据,并直接使用 innerHTML 插入 function displayWelcomeMessage() { const message = window.location.hash.substring(1); // 去掉开头的 '#' const welcomeDiv = document.getElementById('welcome-message'); if (message) { // 高危操作!直接将未经验证的用户输入作为HTML解析 welcomeDiv.innerHTML = "欢迎您," + message + "!"; } else { welcomeDiv.innerHTML = "欢迎您,访客!"; } } // 页面加载时和hash变化时都更新消息 window.onload = displayWelcomeMessage; window.onhashchange = displayWelcomeMessage; </script> </body> </html>这段代码的意图是好的:如果用户访问https://example.com/#张三,页面上会显示“欢迎您,张三!”。它甚至考虑了单页面应用的路由特性,监听了hashchange事件。
3.2 攻击者如何利用?
攻击者会这样思考:
- 寻找Sink:代码中使用了
innerHTML来设置div的内容。 - 寻找数据源:
innerHTML的值由window.location.hash拼接而成。 - 构造Payload:攻击者需要构造一个
hash,使得拼接后的字符串在作为HTML解析时,能执行脚本。
一个最简单的攻击Payload是:#<img src=x onerror=alert(document.cookie)>
- 用户访问的完整URL被攻击者构造为:
https://vulnerable-site.com/#<img src=x onerror=alert(document.cookie)> window.location.hash.substring(1)得到:<img src=x onerror=alert(document.cookie)>welcomeDiv.innerHTML = "欢迎您," + "<img src=x onerror=alert(document.cookie)>" + "!";- 浏览器解析这段HTML,创建了一个
<img>元素,其src='x'显然是一个无效地址。 - 图片加载失败,触发
onerror事件处理器,执行其中的JavaScript代码:alert(document.cookie)。 - 此时,如果该站点的Cookie未设置HttpOnly,就会被弹窗显示出来,攻击者可通过更复杂的脚本将其发送到自己的服务器。
实操心得:innerHTML不仅会执行<script>标签,任何能触发脚本执行的HTML属性都是突破口,如onload、onerror、onmouseover等事件处理器,以及<a href="javascript:...">、<iframe src="javascript:...">等。攻击Payload千变万化。
3.3 漏洞修复:正确的安全编码实践
修复的核心原则是:将数据与代码分离。对于需要动态显示文本内容的地方,绝不应该使用innerHTML,而应使用只处理文本的API。
修复后的代码:
<script> function displayWelcomeMessage() { const message = window.location.hash.substring(1); const welcomeDiv = document.getElementById('welcome-message'); if (message) { // 修复:使用 textContent 或 innerText welcomeDiv.textContent = "欢迎您," + message + "!"; } else { welcomeDiv.textContent = "欢迎您,访客!"; } } window.onload = displayWelcomeMessage; window.onhashchange = displayWelcomeMessage; </script>使用textContent属性,无论message变量里包含什么,都会被当作纯文本字符串直接显示在页面上,浏览器不会对其进行HTML解析。<img src=x onerror=alert(1)>会原封不动地显示为字符,而不是一个图片元素。
重要提示:如果业务场景必须要动态生成HTML结构(例如,渲染一段来自后端的富文本),那么绝不能直接拼接字符串后使用
innerHTML。必须使用经过严格验证和净化的方法。这时应该使用一个成熟的、专门用于防御XSS的库,例如DOMPurify。它的作用是像过滤器一样,只允许安全的HTML标签和属性通过。
// 使用DOMPurify库的示例 import DOMPurify from 'dompurify'; const dirtyInput = window.location.hash.substring(1); const cleanHTML = DOMPurify.sanitize(dirtyInput); // 净化后的HTML welcomeDiv.innerHTML = "欢迎您," + cleanHTML + "!";4. 深入挖掘:其他常见危险模式与高级利用技巧
除了innerHTML+location.hash这个经典组合,DOM-Based XSS还有许多其他“变种”。
4.1 基于eval()或setTimeout/setInterval的动态代码执行
这是另一种高危模式。eval()函数会将其字符串参数当作JavaScript代码来执行。
// 危险代码:从URL参数中获取要执行的函数名 const functionName = new URLSearchParams(window.location.search).get('callback'); eval(functionName + '()'); // 如果callback是`alert(1)//`,则执行alert(1) // 同样危险的变体 setTimeout(location.search.split('=')[1], 100); setInterval(`console.log(${userInput})`, 1000);修复方案:绝对避免使用eval()。如果需要动态执行代码,应使用安全的替代方案,如使用对象映射(对象查找)。
const allowedCallbacks = { 'success': handleSuccess, 'error': handleError }; const functionName = new URLSearchParams(window.location.search).get('callback'); const funcToCall = allowedCallbacks[functionName]; if (funcToCall && typeof funcToCall === 'function') { funcToCall(); // 安全,只执行白名单内的函数 }4.2 jQuery中的安全隐患
在老式或使用不当的jQuery项目中,以下方法同样危险:
$('#el').html(userInput)$('<div>').append(userInput)$(userInput)// 直接解析字符串为DOM
jQuery的.html()方法和$()构造函数在遇到以<开头的字符串时,会尝试解析为HTML。修复方法与原生JS一致:显示文本用.text(),净化HTML用专门的库。
4.3 利用 AngularJS / Vue.js 等框架的客户端模板注入
早期的AngularJS(v1.x)有一个特性,模板中的{{ expression }}会被动态求值。如果攻击者能够控制这个表达式,就可能造成客户端模板注入,本质上也是一种DOM-Based XSS。
<!-- 假设 userInput 可控 --> <div ng-app> <p>{{ userInput }}</p> </div> <script> // 如果 userInput 是 ‘1 && alert(1)’,在旧版AngularJS中可能触发弹窗 </script>修复方案:对于现代前端框架(React, Vue 3, Angular 2+),框架本身已经提供了基础的上下文输出编码。例如,Vue的{{ }}和 React 的{}默认都会对动态内容进行HTML转义。关键在于,不要使用v-html(Vue) 或dangerouslySetInnerHTML(React) 这类“危险”的API去渲染不可信数据。如果必须用,必须配合严格的净化。
5. 系统性的防御策略与最佳实践
防御DOM-Based XSS不能只靠一两个补丁,而需要一套贯穿开发流程的体系。
5.1 开发阶段:安全编码规范
- 建立数据源清单:在项目安全评审中,明确所有可能被攻击者控制的客户端数据源(Source)。制作一个清单:
location(hash, search, pathname),document.referrer,window.name,localStorage,postMessage数据等。 - 识别并规避危险Sink:在代码审查和静态扫描中,重点检查所有使用
innerHTML、outerHTML、document.write、eval、setTimeout(string)、location.assign(javascript:...)的地方。建立ESLint规则,禁止或警告使用这些API。 - 强制使用安全API:
- 文本内容:无条件使用
textContent或innerText。 - 属性值:使用
setAttribute()或直接通过属性名(el.value)设置,而不是拼接字符串后赋值给innerHTML。 - URL处理:在设置
a.href、img.src、iframe.src等属性时,必须验证协议。使用new URL()API进行解析和校验,确保不是javascript:协议。
- 文本内容:无条件使用
- 实施严格的输入验证与上下文输出编码:
- 验证:对于从任何Source获取的数据,根据预期用途进行严格验证(如长度、格式、字符集)。例如,如果期望是用户名,就只允许字母数字和少量符号。
- 编码:编码不是简单的过滤
<script>。必须根据数据将要放置的上下文(Context)进行编码:- HTML上下文:使用
<,>,&,",'等转义。 - HTML属性上下文:同上,并确保属性值用引号包裹。
- JavaScript上下文:使用
\uXXXX形式的Unicode转义。 - URL上下文:使用
encodeURIComponent()。
- HTML上下文:使用
- 建议:使用成熟的编码库(如
he)来处理,避免自己写容易出错的转义函数。
5.2 部署与运行时:增加攻击难度与成本
内容安全策略(CSP):这是防御包括DOM-XSS在内的多种客户端攻击的终极利器。CSP通过HTTP响应头告诉浏览器,哪些外部资源可以被加载和执行。
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none';script-src 'self'表示只允许执行来自当前域名下的脚本。- 即使攻击者成功注入了
<script>alert(1)</script>,浏览器也会因为CSP的限制而拒绝执行它。 - 可以禁止内联脚本 (
‘unsafe-inline’),这能有效阻止大部分基于事件处理器(如onerror)的XSS。但这也意味着你所有的JS必须放在外部文件里。 - 注意:CSP的配置需要非常小心,错误的配置可能导致网站功能损坏。建议从
Content-Security-Policy-Report-Only头开始,只报告不拦截,观察无误后再强制执行。
设置安全的Cookie属性:为会话Cookie设置
HttpOnly和Secure属性。HttpOnly使JavaScript无法通过document.cookie读取Cookie,即使发生XSS,攻击者也难以直接窃取会话。Secure要求Cookie只能通过HTTPS传输。
5.3 测试与监控:主动发现漏洞
- 自动化动态扫描(DAST):使用OWASP ZAP、Burp Suite等工具对Web应用进行自动化扫描。这些工具会尝试构造各种XSS Payload并观察响应。但对于纯客户端的DOM-XSS,传统扫描器可能失效或需要特殊配置(如启用浏览器驱动)。
- 手动渗透测试:安全工程师通过浏览器开发者工具,手动追踪数据流。方法是:在潜在的数据源(如URL参数)处输入一个唯一标识符(如
test123),然后在所有Sink(如innerHTML赋值处)设置断点或搜索页面源码,看这个标识符是否会出现在危险的上下文中。 - 代码审计与静态分析(SAST):使用SonarQube、CodeQL等工具对源代码进行扫描,自动识别“Source-to-Sink”的数据流,发现潜在的不安全模式。
- 漏洞赏金计划:邀请外部安全研究人员帮助发现漏洞。
6. 常见问题排查与疑难场景解析
在实际开发和防御中,你可能会遇到一些令人困惑的场景。
6.1 为什么我的WAF没报警,但漏洞确实存在?
这是DOM-Based XSS最典型的特点。WAF通常部署在服务器前端,检查的是HTTP请求和响应。在纯DOM-XSS攻击中:
- 请求:攻击者发送的恶意Payload(如在
location.hash中)不会作为请求体的一部分发送到服务器。#后面的部分(hash)是浏览器客户端使用的,服务器根本收不到。 - 响应:服务器返回的HTML是干净、无恶意代码的。 因此,WAF“看”不到攻击流量,自然无法报警。防御重心必须前移到客户端代码安全。
6.2 使用了Vue/React等现代框架,是不是就高枕无忧了?
绝对不是。现代框架提供了默认的HTML转义,极大地降低了风险,但并非银弹。
- 框架的“逃生舱”:Vue的
v-html指令、React的dangerouslySetInnerHTML属性,就是为了绕过默认转义而设计的。一旦你使用了它们去渲染用户输入,所有的安全风险就又回来了。 - 危险的第三方库:你引入的某个UI组件库,其内部可能使用了
innerHTML且未做净化。 - 服务端渲染(SSR):在SSR场景下,如果服务端拼接字符串生成HTML时未转义,产生的将是存储型或反射型XSS,而不是DOM-Based。但风险同样存在。
- URL和样式注入:即使用
{{ }}安全地输出了文本,但如果你把用户输入直接用在:href或:style绑定里,仍然可能导致javascript:URL注入或CSS注入(虽然危害通常小于脚本执行)。
最佳实践:即使使用框架,也要遵循“永远不信任用户输入”的原则,对用于“逃生舱”或属性绑定的数据进行严格的验证或净化。
6.3 如何排查一个疑似DOM-XSS的漏洞报告?
收到一个形如https://your-site.com/#<script>...的漏洞报告时,按以下步骤排查:
- 确认触发点:在浏览器中打开该URL,打开开发者工具(F12)。
- 搜索源代码:在“Elements”面板,使用
Ctrl+F搜索攻击Payload中的特征字符串(如alert、onerror等)。如果能在渲染后的DOM树中找到它,说明它被当作HTML解析了。 - 追踪数据流:在“Sources”面板,在所有JS文件中对
location.hash、location.search、document.URL等关键词进行全局搜索。找到读取这些值的代码。 - 查找Sink:从读取数据源的代码出发,向下追踪,看这个值最终被传递到了哪里。是否传给了
innerHTML、document.write、eval或类似函数? - 验证修复:修改代码,使用
textContent或净化库后,重复步骤1-2,确认恶意代码不再被解析执行。
6.4 关于URL解析的安全陷阱
JavaScript:协议是一个常见的陷阱。考虑以下代码:
const userInput = 'javascript:alert(1)'; document.getElementById('myLink').href = userInput; // 极度危险!用户点击这个链接就会执行脚本。修复方法是,在设置href等属性前,必须验证协议:
function sanitizeUrl(url) { try { const parsed = new URL(url, window.location.href); // 以当前页面为基准进行解析 if (!['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol)) { return 'about:blank'; // 非安全协议,返回一个无害的空页面 } return url; } catch { return 'about:blank'; // 非法URL } } document.getElementById('myLink').href = sanitizeUrl(userInput);DOM-Based XSS就像潜伏在客户端阴影中的刺客,它绕过了传统的服务器端防线。对抗它,需要开发者从根本上转变思维,将“客户端数据同样不可信”作为安全编码的第一信条。从识别危险的Source和Sink,到强制使用安全的API,再到部署CSP这样的运行时防护,这是一个需要开发、安全、运维共同参与的持续过程。每一次对innerHTML的审慎使用,每一次对location.hash的严格校验,都是在为你的应用构建一道坚固的客户端盾牌。