DOM XSS Source 与 Sink 全景图谱
一、为什么单独研究 DOM XSS 的 Source/Sink
传统 XSS(反射型、存储型)主要是服务端输出处理不当导致的,防御的核心在后端。而 DOM XSS 是客户端 JavaScript 运行时引入的漏洞,它的成因链条可以抽象为:
Source(脏数据源头) → 数据流传播 → Sink(危险汇聚点)
只要数据流从任意 Source 流到了任意 Sink 且中间没有经过有效 sanitize,就构成 DOM XSS 漏洞。理解这条链路是发现和修复 DOM XSS 的关键。
二、完整 Source 清单
2.1 URL 类 Source(最常见)
// location 对象
document.URL
document.documentURI
location.href
location.hash // # 后面的部分
location.search // ? 后面的部分
location.pathname
location.protocol
location.hostname
location.host
location.origin
// URL 解析器
new URL(location.href).searchParams.get('param')
new URLSearchParams(location.search).get('param')
// document.referrer —— 上一页来源(可被攻击者控制)
document.referrer
// window.name —— 可跨页面传递
window.name
攻击示例
// 攻击者构造 URL:
// http://victim.com/page?payload=<img src=x onerror=alert(1)>
var params = new URLSearchParams(location.search);
var data = params.get('payload');
document.getElementById('output').innerHTML = data; // 直接流入 Sink
location.hash 的特殊性
http://victim.com/page#payload=<img src=x onerror=alert(1)>
location.hash 的值不会发送到服务器,这意味着即便后端做了严格过滤,DOM XSS 依然可能发生。
2.2 存储类 Source
// localStorage —— 同源下持久化
localStorage.getItem('key')
localStorage.key(index)
Object.keys(localStorage).forEach(k => localStorage.getItem(k));
// sessionStorage —— 标签页级别
sessionStorage.getItem('key')
// Cookies(注意:非 HttpOnly 的 Cookie 可被 JS 读取)
document.cookie
// IndexedDB
indexedDB.open(dbName).onsuccess = (e) => {
const tx = e.target.result.transaction(storeName);
tx.objectStore(storeName).getAll().onsuccess = (r) => console.log(r.target.result);
};
攻击示例(localStorage 污染)
// 攻击步骤:
// 1. 利用另一个漏洞(如 XSS 或钓鱼)写入 localStorage
localStorage.setItem('userNick', '<script>alert(document.cookie)</script>');
// 2. 目标页面读取并直接渲染
var nick = localStorage.getItem('userNick');
document.getElementById('nick').innerHTML = nick; // DOM XSS!
2.3 DOM 树类 Source
// 表单输入
document.forms[0].elements['field'].value
document.getElementById('userInput').value
document.querySelector('input[name=q]').value
// data-* 属性
el.getAttribute('data-content')
el.dataset.content
// URL 编码/解码方法(可能引入误解)
decodeURIComponent(userInput)
unescape(userInput) // 已废弃但仍在部分老代码中出现
atob(base64String) // Base64 解码
// 富文本编辑器内容
CKEDITOR.instances.editor.getData()
tinymce.get('editor').getContent()
2.4 消息类 Source(postMessage)
// 另一个页面/iframe 通过 postMessage 发送数据
window.addEventListener('message', function(e) {
// 没有校验 origin 就直接使用 e.data
document.getElementById('target').innerHTML = e.data;
});
安全写法
window.addEventListener('message', function(e) {
if (e.origin !== 'https://trusted.example.com') return;
// 再对 e.data 做 DOMPurify.sanitize()
});
2.5 网络响应类 Source
// AJAX / Fetch 返回值如果被拼进 DOM
fetch('/api/user/profile')
.then(r => r.json())
.then(data => {
document.getElementById('bio').innerHTML = data.bio;
});
// jQuery
$.get('/api/comments', function(data) {
$('#comments').append(data.html);
});
2.6 其他少见但可能的 Source
// window.name(跨标签页可读)
window.name
// history.state
history.state
// performance API(可能包含 URL)
performance.getEntriesByType('resource')[0].name
// FileReader 读取用户上传的文件名/内容
fileReader.onload = function(e) {
document.body.innerHTML += e.target.result;
};
三、完整 Sink 清单
3.1 HTML 注入类(最危险)
| Sink | 严重程度 | 说明 |
|---|---|---|
innerHTML |
🔴 极高 | 替换/追加 HTML 字符串,自动解析执行 |
outerHTML |
🔴 极高 | 替换元素自身(内部包括该元素) |
insertAdjacentHTML |
🔴 极高 | 在指定位置插入 HTML |
$().html() (jQuery) |
🔴 极高 | jQuery 的 HTML 方法 |
$().append() / after() / before() |
🔴 高 | 追加内容如果是 HTML 字符串则解析 |
document.write() / document.writeln() |
🔴 高 | 直接写文档流 |
<iframe srcdoc> |
🔴 高 | srcdoc 属性接受 HTML 字符串 |
<xmp> + 后续 innerHTML |
🟡 中 | 被打破的预格式化标签 |
示例:insertAdjacentHTML
// insertAdjacentHTML(position, htmlString)
// position: 'beforebegin' 'afterbegin' 'beforeend' 'afterend'
element.insertAdjacentHTML('beforeend', userInput);
// 等价于 element.innerHTML += userInput
示例:document.write
// 注意:如果文档已关闭,document.write 会重写整个页面
if (document.readyState === 'complete') {
// 写入会打开一个新文档覆盖整个页面
document.write('<html><body>Hacked!</body></html>');
}
3.2 JavaScript 执行类
| Sink | 严重程度 | 说明 |
|---|---|---|
eval() |
🔴 极高 | 执行任意字符串为 JS |
new Function() |
🔴 极高 | 创建函数体 |
setTimeout(string, ms) |
🔴 高 | 字符串参数会被 eval |
setInterval(string, ms) |
🔴 高 | 同上 |
Function.prototype.constructor |
🟠 中 | (function(){}).constructor(code) |
alert() / confirm() / prompt() + ${} |
🟢 低 | 这些本身不危险,但如果包含可被浏览器解析的 HTML 就另当别论 |
示例:setTimeout 字符串陷阱
// 危险写法 —— 字符串参数
setTimeout('console.log(userInput)', 1000);
// 安全写法 —— 函数参数
setTimeout(() => console.log(userInput), 1000);
setTimeout(console.log.bind(null, userInput), 1000);
示例:new Function
// 危险:把用户输入作为函数体
var body = getUserInput();
var fn = new Function('return ' + body);
fn(); // 执行任意代码
// 攻击者输入:1; alert(document.cookie); //
3.3 URL/属性写入类
| Sink | 严重程度 | 说明 |
|---|---|---|
location.href = userInput |
🔴 极高 | 可以 javascript: 协议 |
location.src = userInput |
🔴 极高 | 同上 |
document.location = userInput |
🔴 极高 | 同上 |
<a href="..." > + innerHTML |
🟠 中 | 如果 href 被设为 javascript:alert(1) |
<img src> + onerror |
🟠 中 | 攻击者可以构造资源加载失败触发 onerror |
<iframe src> |
🟠 中 | 可加载恶意页面 |
<svg onload=...> |
🔴 高 | SVG 事件处理器 |
示例:javascript: 协议
// 攻击者输入:javascript:alert(document.cookie)
var target = getUserInput();
// 安全版本:只有以 http:// https:// 开头的才允许跳转
if (/^https?:///.test(target)) {
location.href = target;
} else {
// 拒绝
}
3.4 jQuery 特殊 Sink
jQuery 的某些方法本身就会解析 HTML:
// jQuery 3.0 前,$() 会对包含 HTML 标签的字符串进行解析
var html = '<img src=x onerror=alert(1)>';
$(html); // 创建 DOM 节点(即便没插入 document 也可能执行?不会,但可以通过 append 执行)
$('#target').html(html); // 执行
$('#target').append(html); // 执行
// $.parseHTML 不会执行脚本
var nodes = $.parseHTML(html); // 安全
3.5 HTML 上下文中特殊 Sink
<!-- 在 <script> 标签内部拼接 -->
<script>
var userInput = "...";
var code = "var name = '" + userInput + "';"; // 可能打破引号
eval(code);
</script>
<!-- 在 HTML 注释内 -->
<script>
var userInput = "--><script>alert(1)</script><!--";
document.write("<!-- comment " + userInput + " -->");
</script>
<!-- SVG 中 -->
<svg>
<script>// 某些浏览器允许 SVG 内联脚本</script>
<a onfocus="alert(1)">hover me</a>
<animate attributeName="href" from="javascript:alert(1)" to="x" dur="1s" />
</svg>
四、自动检测 Source→Sink 数据流
4.1 静态分析工具
- Semgrep:基于模式匹配
- ESLint:自定义规则检测危险 API
- CodeQL:数据流分析能力强
- jshint + 自定义规则
4.2 动态检测(运行时插桩)
方法一:Hook 危险 Sink
// 在 DevTools Console 中运行
(function() {
var dangerous = ['innerHTML', 'outerHTML', 'insertAdjacentHTML'];
dangerous.forEach(function(m) {
var _setter = Object.getOwnPropertyDescriptor(Element.prototype, m);
Object.defineProperty(Element.prototype, m, {
set: function(v) {
console.warn('XSS可能: 赋值给 ' + m);
console.trace();
return _setter.set.call(this, v);
},
configurable: true
});
});
})();
方法二:浏览器 DevTools
- Sources → Event Listener Breakpoints → SCRIPT/EXCEPTIONS
- 设置 DOM Breakpoints(右键元素 → Break on → subtree/attribute modification)
方法三:DOMPurify Test Hook
// 让 DOMPurify 在遇到可疑内容时抛出异常
DOMPurify.setConfig({ ON_HREF_ATTRIBUTE: function() { console.log('可疑 href'); } });
五、防御策略矩阵
| Source 类别 | 推荐防御 |
|---|---|
| URL 参数 | 白名单校验 + 类型转换(parseInt / Number) |
| localStorage/sessionStorage | 读取后 DOMPurify.sanitize() |
| postMessage | 必须校验 e.origin |
| DOM 属性值 | 读取后 escapeHTML() 或 DOMPurify.sanitize() |
| Fetch/AJAX 响应 | 区分 JSON(安全)和 HTML(危险),统一 sanitize |
| Sink 类别 | 推荐替换 |
|---|---|
innerHTML |
textContent / innerText / document.createElement + .textContent |
eval |
彻底移除,用结构化数据 + 逻辑分支替代 |
setTimeout(string) |
用函数参数:setTimeout(fn, ms) |
location.href = userInput |
校验协议(仅 http/https/mailto/tel 白名单) |
<script> 标签写入 |
CSP script-src 'self' 阻止内联脚本 |
六、实战练习:判断下面哪一行会构成 DOM XSS
function greetUser() {
var name = location.hash.substring(1); // ① Source
if (!name) name = '访客';
var container = document.getElementById('welcome');
container.textContent = '你好, ' + name; // ② textContent —— 安全
var link = document.createElement('a');
link.href = name; // ③ 如果 name = 'javascript:alert(1)' 呢?
link.textContent = '跳转';
container.appendChild(link);
var raw = location.search.split('=')[1] || ''; // ④ Source
container.insertAdjacentHTML('beforeend', raw); // ⑤ 危险 Sink!DOM XSS!
}
greetUser();
分析:
- ② 安全,
textContent只做纯文本赋值,不解析 HTML - ③ 有条件危险:如果用户点击该链接,且浏览器允许
javascript:协议,就会执行 - ⑤ 确定的 DOM XSS:
insertAdjacentHTML是危险 Sink,raw直接来自 URL 没做 sanitize
修复方案:
container.insertAdjacentHTML('beforeend', DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
ALLOWED_ATTR: []
}));
七、小结
DOM XSS 的攻防本质就是一场 Source 污染 → Sink 汇聚 的追踪与阻断游戏。要做好防御:
- 识别:全面排查你的应用中有哪些 Source 和 Sink
- 阻断:在 Sink 之前统一调用 sanitize(推荐 DOMPurify)
- 分层:CSP + HttpOnly Cookie + SameSite 作为最后防线
- 工具化:引入 ESLint 规则 + CodeQL 扫描,把 DOM XSS 检测自动化