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 XSSinsertAdjacentHTML 是危险 Sink,raw 直接来自 URL 没做 sanitize

修复方案:

container.insertAdjacentHTML('beforeend', DOMPurify.sanitize(raw, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
    ALLOWED_ATTR: []
}));

七、小结

DOM XSS 的攻防本质就是一场 Source 污染 → Sink 汇聚 的追踪与阻断游戏。要做好防御:

  1. 识别:全面排查你的应用中有哪些 Source 和 Sink
  2. 阻断:在 Sink 之前统一调用 sanitize(推荐 DOMPurify)
  3. 分层:CSP + HttpOnly Cookie + SameSite 作为最后防线
  4. 工具化:引入 ESLint 规则 + CodeQL 扫描,把 DOM XSS 检测自动化