DOMDOM Times #19: Can We Really Mitigate Client-Side Prototype Pollution by Using iframes?
- "Client-Side Prototype Pollution"...?
- What is that?
- Problem Caused by Prototype Pollution
- Today's "iframe" Approach Against Client-Side Prototype Pollution
- But You Can't Get a "Fresh" Prototype Just by Creating a New iframe...!
- The Straightforward Case: Pollution of createElement
- A More Twisted Case: Pollution of contentWindow
- Conclusion: Implication from Security Viewpoint
- 1. If You Are a Web Page Developer
- 2. If You Are an Extension Developer
- 3. If You Don't Want Others Using Fresh Prototypes
- FYI
Hi, I'm canalun, a sec researcher and a Firefox developer based in Tokyo :)
"Client-Side Prototype Pollution"...?
What is that?
There are native prototypes in JavaScript contexts. Array.prototype, Object.prototype etc. Additionally, these have "methods" like Document.prototype.querySelectorAll. These methods are native code at first. There are some ways to call these kind of things (or only native prototypes?): "built-in", "native prototype", "primordials" and "farm-fresh prototype" (ok, joking).
They are sometimes overwritten. For example, let's see Google Cloud page.
As you can check in Console of DevTools, Promise, Document.prototype.querySelectorAll and so on are overwritten. They are not left as native code. I call this situation "Client-Side Prototype Pollution" in this article.
// On Console...
> Promise
> ƒ (xb){if(!(this instanceof rb))throw Error("Nb");this[va]=null;this[Sa]=[];try{var Sb=La();xb&&xb(Sb(n(this,!0)),
Sb(n(this,!1)))}catch(Yb){p(this,!1,Yb)}}
// You see `ƒ Promise() { [native code] }` originally.
> Document.prototype.querySelectorAll
> ƒ (){var y=_.Qa.apply(0,arguments),A=n===void 0||n(),D=g&&A?g(this):this;return f&&A?f.call.apply(f,[D,v].concat(_.r(y))):v.apply(D,y)}
// You see `ƒ querySelectorAll() { [native code] }` originally.
"Client-Side Prototype Pollution" is done by libraries, polyfill and sometimes developers' intentional scripting. For example, Zone.js overwrites Promise and it checks if its overwrite succeeds. Some of 3rd party script developers might have suffered from it during debug on frontend :)
// https://github.com/angular/zone.js/blob/b11bd466c20dc338b6d4d5bc3e59868ff263778d/lib/zone.ts#L707-L716
static assertZonePatched() {
if (global['Promise'] !== patches['ZoneAwarePromise']) {
throw new Error(
'Zone.js has detected that ZoneAwarePromise `(window|global).Promise` ' +
'has been overwritten.\n' +
'Most likely cause is that a Promise polyfill has been loaded ' +
'after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. ' +
'If you must load one, do so before loading zone.js.)');
}
}
Problem Caused by Prototype Pollution
If client-side prototype pollution is happening while you don't notice it, your web page can easily break. But that is a relatively less dangerous outcome . What's scarier is that malicious actors compromise your web page by prototype pollution.
These two possibilities are basic targets of Agoric's famous SES proposal.
In ECMAScript, a realm consists of a global object and an associated set of primordial objects -- mutable objects like Array.prototype that must exist before any code runs. Objects within a realm implicitly share these primordials and can therefore easily disrupt each other by primordial poisoning -- modifying these objects to behave badly. This disruption may happen accidentally or maliciously.
https://github.com/tc39/proposal-ses?tab=readme-ov-file#summary
They use this example to explain the risk of client-side prototype pollution. By supply chain attacks or something like that, what your script processes on the crafted context can be easily stolen. (Of course, there are potential safeguard like CSP. So it's not a simple 0/1 game.)
// From https://docs.agoric.com/guides/js-programming/hardened-js#hardening-javascript-frozen-built-ins
Object.assign(Array.prototype, {
includes: specimen => {
fetch('/pwned-db', { method: 'POST', body: JSON.stringify(specimen) });
return false;
},
});
Today's "iframe" Approach Against Client-Side Prototype Pollution
One of the major approaches against client-side prototype pollution is to create a separate JavaScript “Realm” which isolates your script from others by using iframe.
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
// iframe gets its window when connected
document.body.insertAdjacentElement('beforeend', iframe)
// extract the original prototype and you can use it
const originalPromise = iframe.contentWindow.Promise
iframe.remove()
This approach cannot be used when the page has specific CSPs like sandbox. But basically it looks nice. Actually, as far as I remember, the proposal of ShadowRealm mentions it.
But You Can't Get a "Fresh" Prototype Just by Creating a New iframe...!
Let's get to the main point.
Now, I show how to bypass this nice-looking scripts. I mean, you cannot get native code from iframe under intentionally crafted context.
To be more precise, if the behavior of createElement or contentWindow has been changed in specific ways, it becomes impossible to obtain "fresh" prototypes.
The Straightforward Case: Pollution of createElement
Let's see again the "iframe" approach.
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
// iframe gets its window when connected
document.body.insertAdjacentElement('beforeend', iframe)
// extract the original prototype and you can use it
const originalPromise = iframe.contentWindow.Promise
iframe.remove()
Unfortunately, this fails if a trap has been embedded in createElement!
In other words, if createElement itself has been instructed to "attach a load event that modifies Promise whenever an iframe is created", you won't be able to get a "fresh" Promise even from a brand-new iframe.
OK, this is an example of such trap:
const originalCreateElement = Object.getOwnPropertyDescriptor(
document.__proto__.__proto__,
"createElement",
).value;
Object.defineProperty(document.__proto__.__proto__, "createElement", {
...Object.getOwnPropertyDescriptor(
document.__proto__.__proto__,
"createElement",
),
value: function (tagName, options) {
const element = originalCreateElement.call(this, tagName, options);
if (tagName === "iframe") {
element.addEventListener("load", () => {
try {
element.contentWindow.Promise = "I am polluted...!";
} catch (e) {
// iframe might be cross-origin
}
});
}
return element;
},
});
And if you actually try it...yikes!
As you can see, we can no longer retrieve a "fresh" Promise, even from an iframe!
(I use the same images as in Japanese version of this article, so the comments are in Japanese. But it doesn't affect the content.)
For reference, below is what it looks like without the trap.
A More Twisted Case: Pollution of contentWindow
Can we get around the trap by creating an iframe by something other than createElement?
Let's consider creating an iframe using innerHTML!
const div = document.createElement("div");
div.id = 'prototype_fountain_parent'
// Add an iframe, get the Promise, then remove the div.
// Browsers' built-in XSS protection prevents script tags in innerHTML from executing,
// so we have to be a bit clever, haha.
const script = `
originalPromise = prototype_fountain.contentWindow.Promise;
prototype_fountain_parent.remove();
`
div.innerHTML = `
<iframe id="prototype_fountain" style="display:none;"></iframe>
<img src onerror="${script}">
`;
document.body.insertAdjacentElement('beforeend', div);
For now, we can confirm this works just fine. :)
(Of course, this might not work depending on the page's CSP!)
Now, what happens if contentWindow is polluted?
Let's run the following script beforehand.
const originalContentWindow = Object.getOwnPropertyDescriptor(
HTMLIFrameElement.prototype,
"contentWindow",
).get;
Object.defineProperty(HTMLIFrameElement.prototype, "contentWindow", {
...Object.getOwnPropertyDescriptor(
HTMLIFrameElement.prototype,
"contentWindow",
),
get() {
const contentWindow = originalContentWindow.call(this);
if (!contentWindow) {
return contentWindow;
}
contentWindow.Promise = "i am polluted, again...!";
return contentWindow;
},
});
Yep, this script causes our innerHTML-strategy to fail.
Actually, since our original createElement strategy also relies on contentWindow, this script defeats it as well :)
It might be a cat-and-mouse game. There might be other strategies to bypass client-side prototype pollution, but they'll probably be defeated somehow, and your attempt to get "fresh" prototypes will end in failure.
(Of course, if you find an interesting pattern, please let me know! :) )
Conclusion: Implication from Security Viewpoint
Please be careful if you are using iframe-approach mentioned in the beggining of this article as a security measure!
This is for those who think, "I want to handle this sensitive data on the front end, but it could be easily stolen if there's prototype pollution. Ah, I know! I'll create an iframe and process the data with a 'fresh' prototype. That should be safe!! Genius!" As we've gone through, you can't get fresh prototype from iframe under crafted context. Malicious actors can easily defeat your idea.
Let's break this down more by what you develop.
1. If You Are a Web Page Developer
Libraries used on your webpage can easily pollute the "inside" of an iframe. This is a particularly realistic scenario in the context of supply chain attacks (meaning a library deep in your dependency tree could one day start polluting prototypes and steal what your script processes).
If you're relying on getting a fresh prototype from an iframe as a defense against prototype pollution attack, be aware that it's NOT a effective security measure. You need to implement thorough countermeasures against supply chain attacks, XSS and so on.
2. If You Are an Extension Developer
Extension developers also need to be cautious!
If you are thinking like "But my script runs at document_start, before any other scripts!", that assumption is incorrect.
It's known that, at least in Chromium, a parent context can manipulate the prototypes of a newly created iframe's context BEFORE any document_start content scripts run inside that iframe
This means that if your extension is designed to handle sensitive information in the main world of a newly created iframe, you could be in trouble, not safe.
I feel like this Chromium bug should be fixed, but it's been left untouched for a long time.
So it'll probably stay this way, hahaha... :(
3. If You Don't Want Others Using Fresh Prototypes
Developers of some kind of web pages, third party scripts or extensions don't want others using fresh prototypes. This is a legitimate need. So please try the introduced ways to keep others from native prototypes :)
FYI
I will add this information onto OWASP browser extension cheat sheet.
BTW, The below article explains today's situation of client-side prototype pollution including ECMAScript related proposals. So please enjoy it also if you are interested in this never ending attack-defense :)
Lastly, previous DOMDOM Times articles are on Zenn. They are in Japanese, but it's easy to read with AI or translator as you may know :)
Bye!