The Closed Shadow DOM

a bit of research on security of the shadow DOM

The Closed Shadow DOM

I recently did some research into the Shadow DOM because I observed some chrome extensions (mis)using it for security.


Browsers fairly recently introduced support for Shadow DOMs. Shadow DOM is part of the Web Components feature suite, which aims to allow JS developers to create reusable custom elements with their functionality encapsulated away from the rest of the website code.

The Shadow DOM allows the component author to create an encapsulated sub-DOM tree for their component.

The purpose of the Shadow DOM as stated by Google:

Shadow DOM fixes CSS and DOM. It introduces scoped styles to the web platform. Without tools or naming conventions, you can bundle CSS with markup, hide implementation details, and author self-contained components in vanilla JavaScript.

Essentially, you can use the Shadow DOM to isolate your component's HTML and CSS from the rest of the webpage. For example, if you create element IDs in a shadow DOM, they will not conflict with element IDs in the parent DOM. Any CSS selectors you utilize in your shadow DOM will only apply within the shadow DOM and not to the parent DOM, and any selectors you utilize in the parent will not penetrate within the shadow DOM.

// creating a shadow DOM
let $element = document.createElement("div");
$shadowDomRef = $element.attachShadow({ mode: "open" }); // open or closed

Normally, when you attach an "open" shadow DOM to an element, you can obtain a reference to the shadow DOM with $element.shadowRoot. However, if the shadow DOM is attached under "closed" mode, you can't obtain a reference to it this way. Even after reading all developer documentation I could find, I'm still slightly unclear about the purpose of closed mode. According to Google:

There's another flavor of shadow DOM called "closed" mode. When you create a closed shadow tree, outside JavaScript won't be able to access the internal DOM of your component. This is similar to how native elements like <video> work. JavaScript cannot access the shadow DOM of <video> because the browser implements it using a closed-mode shadow root.

However, they also state:

Closed shadow roots are not very useful. Some developers will see closed mode as an artificial security feature. But let's be clear, it's not a security feature.

And I agree! So, I still have to wonder why the closed mode exists in the first place.

Hacking the shadow DOM

Unfortunately, developers don't always follow the guidelines, and despite google advice that it is not a security feature, will still attempt to use it as a security feature anyways! I became interested in the shadow DOM after auditing a chrome extension that injected customer PII into arbitrary webpages using the closed shadow DOM, and wondered whether it was possible to exfiltrate from it.

Chromium actually does a surprisingly good job of encapsulating and isolating the shadow DOM from the parent DOM. It turns out to be quite difficult to exfiltrate or obtain information from the shadow DOM at all. Without an obvious reference to the shadow DOM in the parent's JavaScript scope, it is surprisingly difficult to obtain one.

Various attempts:

  • JavaScript event handlers on the parent do not receive events from elements within the shadow DOM
  • CSS selectors and styles do not penetrate within the shadow DOM
  • some more esoteric APIs such as document.elementsFromPoint(x, y); do not return elements in the shadow DOM

Introducing window.find() and text selections

One of the few ways I found to extract text from within the shadow DOM was via text selections. The function window.find("search_text") penetrates within a shadow DOM. This function effectively has the same functionality as ctrl-F on a webpage, and will select the first match of the text if it exists.

Furthermore, we can then call document.execCommand("SelectAll") to expand the selection as much as possible, selecting text that we may not know.

From there, we can call window.getSelection() to return the contents of selected text inside the shadow DOM.

This is a nice method to exfiltrate text from the shadow DOM in certain scenarios, but it left me somewhat unsatisfied. I wanted to find a way to obtain a reference to the shadow DOM itself, so I could manipulate it entirely.

In firefox, this is easy! The result of getSelection() returns a Selection object, where anchorElement is a reference to an element in the shadow DOM. So, we can exfiltrate contents of the shadow DOM as follows:

getSelection().anchorNode.parentNode.parentNode.parentNode.innerHTML

However, chromium does a better job of sandboxing this, and the anchorNode for a selection in the shadow DOM returns the DOM node that the shadow DOM has been attached to.

contenteditable or CSS injection

One way we might be able to interact with the shadow DOM is if we have an HTML or JS injection inside of it. There are some interesting situations where you can obtain injection within a shadow DOM where you wouldn't be able to on a normal crossorigin page.

One example, is if you have any elements with the contenteditable attribute. This is a deprecated and little used HTML attribute that declares the content of that element to be user-editable. We can use selections along with the document.execCommand API to interact with a contenteditable element and obtain an HTML injection!

find('selection within contenteditable');

document.execCommand('insertHTML',false,'<svg/onload=console.log(this.parentElement.outerHTML)>')

Perhaps even more interestingly, contenteditable can be declared on any element in chromium by applying a deprecated CSS property: -webkit-user-modify:read-write!

This allows us to elevate a CSS/style injection into an HTML injection, by adding the CSS property to an element, and then utilizing the insertHTML command.

dicectf 2022 - shadow

I ran a challenge based on these findings for dicectf 2022 where participants needed to try and exfiltrate an HTML comment from a shadow DOM given a CSS injection inside of it. It got 0 solves during the competition, but was solved by Super Guesser right after the event.

Writeup: https://github.com/Super-Guesser/ctf/blob/master/2022/dicectf/shadow.md