| Field | Detail |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | Prototype Pollution / DOM XSS |
| Difficulty | Practitioner |
| Objective | Find a prototype pollution source and a gadget to call alert() by bypassing a developer's attempted patch on transport_url |
Client-Side Prototype Pollution via Browser APIs¶
I tested the URL query string as a prototype pollution source — the most common entry point — by injecting a test property via __proto__:
/?__proto__[tetoProperty]=miku
console.log({}.tetoProperty)
// → miku
Prototype pollution confirmed. Any property injected into __proto__ is now inherited by all objects in the page's JavaScript runtime.
Browsing the source in DevTools led to searchLoggerConfigurable.js:
async function searchLogger() {
let config = {params: deparam(new URL(location).searchParams.toString()), transport_url: false};
Object.defineProperty(config, 'transport_url', {configurable: false, writable: false});
if(config.transport_url) {
let script = document.createElement('script');
script.src = config.transport_url;
document.body.appendChild(script);
}
...
}
transport_url controls a script.src — a classic gadget. The developer patched it with Object.defineProperty() setting configurable: false, writable: false. This looks locked down, but there's a gap.
Object.defineProperty() accepts a property descriptor — which is itself a plain JavaScript object inheriting from Object.prototype. The developer specified configurable: false and writable: false but didn't specify value. Without a value field, the property is defined with value: undefined, not the previous false. More importantly, if Object.prototype.value is polluted, that value bleeds into the descriptor when defineProperty reads it — causing the property to be set to our payload. The developer locked the property but left the descriptor object open to prototype contamination.
The execution chain: Object.prototype.value = 'data:,alert(1);' → defineProperty picks it up from the prototype → config.transport_url gets set to our payload → if(config.transport_url) is truthy → <script src="data:,alert(1);"> gets appended → alert(1) fires.
Injecting via the URL:
/?__proto__[value]=data:,alert(1);
Alert fired. data:,alert(1) is a valid script source — the browser executes content after the comma as JavaScript, no external server needed.
Lab solved