// HackTricks · Web Pentesting

Shadow DOM

Shadow DOM

Shadow DOM encapsulates DOM and CSS implementation details, but it is not a security boundary for script already executing in the same origin/realm. It creates useful assessment surfaces when applications mix closed roots, Declarative Shadow DOM (DSD), and HTML parsing/sanitizing APIs.[3]

Why attackers care

  • mode: "closed" only hides the root from element.shadowRoot; code running in the same JS realm can still intercept its creation.[1]
  • DSD allows HTML such as <template shadowrootmode="open">...</template> to create real shadow roots during parsing.
  • Newer sinks such as Element.setHTMLUnsafe() and ShadowRoot.setHTMLUnsafe() can materialize DSD from attacker-controlled HTML.
  • Some sanitizers still treat <template>/DSD as inert markup and become bypassable when they return live DOM instead of a serialized string.

Enumerating shadow roots

Open roots are trivial to enumerate:

for (const el of document.querySelectorAll('*')) {
  if (el.shadowRoot) console.log('shadow host:', el, el.shadowRoot);
}

When testing large applications, recursively walk every discovered shadowRoot because Web Components are often nested several levels deep.

Intercepting closed roots

If you can run JavaScript before the component is initialized, hook attachShadow() and keep the returned reference:

(() => {
  const orig = Element.prototype.attachShadow;
  window.__shadowRoots = [];
  Element.prototype.attachShadow = function (init) {
    const root = orig.call(this, init);
    window.__shadowRoots.push({ host: this, mode: init.mode, root });
    return root;
  };
})();

This is one of the most important takeaways when auditing apps or browser extensions that wrongly assume closed protects secrets, CSRF tokens, anti-clickjacking UI, or privileged controls.

Declarative Shadow DOM as an injection surface

DSD creates a shadow root directly from markup:

<div id="host">
  <template shadowrootmode="open">
    <img src=x onerror=alert(document.domain)>
  </template>
</div>

Important parsing behavior for exploitation:[2][4]

  • innerHTML does not create declarative shadow roots.
  • Element.setHTMLUnsafe() / ShadowRoot.setHTMLUnsafe() do parse them.
  • Server-rendered HTML also creates DSD during the normal page parse.

Therefore, look for applications that:

  • hydrate server-rendered components,
  • import remote HTML snippets into custom elements,
  • expose preview/render features backed by setHTMLUnsafe(), or
  • wrap parsing APIs in custom “safe HTML” helpers.

Also remember that classic <script> tags inserted from HTML strings stay inert, so prefer event handlers, javascript: URLs, or event gadgets inside the shadow tree.

A useful gadget is slotchange:

x<template shadowrootmode=open><slot onslotchange=alert(1)>

The leading text node (x) becomes assigned content for the default slot, which fires slotchange.

Sanitizer and mXSS footguns

Recent sanitizer bypasses made DSD more relevant for XSS research:

  • If a sanitizer parses attacker HTML into a DOM tree and returns live DOM (for example DocumentFragment) instead of a string, a hidden DSD subtree may survive the cleanup step.
  • Returning a string is often safer here because innerHTML serialization does not include shadow roots.
  • Allowing <template> without recursively sanitizing its contents, or allowing the shadowrootmode attribute, can convert apparently inert markup into executable DOM.
  • Template handling is also a good place to look for mutation XSS bugs when sanitized markup is later re-parsed in a different context.

Typical risky pattern:

const frag = sanitize(userHTML, { returnDOM: true });
target.appendChild(frag);

If frag still contains a declarative shadow root, the append operation can reintroduce attacker-controlled handlers inside the page.

Dumping serializable / clonable shadow roots

Newer Shadow DOM features are useful post-XSS recon primitives:

  • shadowrootserializable / attachShadow({ serializable: true })
  • shadowrootclonable / attachShadow({ clonable: true })

Examples:

host.getHTML({ serializableShadowRoots: true });
const copy = host.cloneNode(true);

If the application opted in, this can expose shadow DOM content that would otherwise stay out of normal innerHTML output. This is especially interesting when developers use Shadow DOM to hide tokens, comments, feature flags, or internal UI state.

Practice

A good lab for this topic is the DiceCTF shadow challenge, which chained DOM XSS with closed-shadow-root abuse and CSS-based extraction ideas.[5]

References