<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[sangiihacks]]></title><description><![CDATA[sangiihacks]]></description><link>https://sangiihacks.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>sangiihacks</title><link>https://sangiihacks.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 08 Oct 2026 17:18:19 GMT</lastBuildDate><atom:link href="https://sangiihacks.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I solved Intigriti's September 2026 challenge: SQLi hiding behind base64]]></title><description><![CDATA[Intigriti monthly challenge 0926 — "Critter Gallery" by @khanhdlq. Solved Sept 21, 2026, ~6h15m after launch.

TL;DR
The challenge hid a classic UNION-based SQL injection behind a base64-encoded param]]></description><link>https://sangiihacks.hashnode.dev/intigriti-0926-sql-injection-behind-base64</link><guid isPermaLink="true">https://sangiihacks.hashnode.dev/intigriti-0926-sql-injection-behind-base64</guid><category><![CDATA[Security]]></category><category><![CDATA[#sqlinjection]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[CTF]]></category><dc:creator><![CDATA[Sangii]]></dc:creator><pubDate>Tue, 29 Sep 2026 12:39:53 GMT</pubDate><content:encoded><![CDATA[<p><em>Intigriti monthly challenge 0926 — "Critter Gallery" by @khanhdlq. Solved Sept 21, 2026, ~6h15m after launch.</em></p>
<hr />
<h2>TL;DR</h2>
<p>The challenge hid a classic UNION-based SQL injection behind a base64-encoded parameter. The app decoded <code>pic</code> and interpolated it straight into a MySQL <code>WHERE name = '&lt;INPUT&gt;'</code> clause. No WAF on quotes, no parameterization, one column, MySQL 8.0.46. Flag extracted with a single UNION query against a <code>secret_vault</code> table found via <code>information_schema</code>.</p>
<p><strong>Winning payload:</strong></p>
<pre><code class="language-text">GET /challenge.php?pic=eCcgVU5JT04gU0VMRUNUIGdyb3VwX2NvbmNhdChub3RlKSBGUk9NIHNlY3JldF92YXVsdC0tIA==
</code></pre>
<p>where <code>pic</code> decodes to:</p>
<pre><code class="language-text">x' UNION SELECT group_concat(note) FROM secret_vault-- 
</code></pre>
<p>(The trailing space after <code>--</code> matters. More on that later.)</p>
<hr />
<h2>The target</h2>
<p>"Critter Gallery" — a cute animal gallery on PHP 8.2 behind istio-envoy. The landing page offers eight critters; each detail link looks like <code>?pic=Zm94</code> (base64 for <code>fox</code>). Selecting one renders a detail view with the critter's name and a one-line description.</p>
<p>First recon notes:</p>
<ul>
<li><strong>No CSP header.</strong> Anything goes, output-wise — but that's a hint, not the bug.</li>
<li>Unknown values echo their base64-<em>decoded</em> bytes (HTML-escaped) into the page's <code>&lt;h2&gt;</code> tag. So the server decodes the parameter before using it.</li>
<li>The app is tiny. One parameter, one lookup. When the attack surface is this small, the parameter <em>is</em> the app.</li>
</ul>
<h2>Step 1: Building an error oracle out of nothing</h2>
<p>I fired a handful of decoded-garbage inputs at the parameter to map its behavior, and hit something odd immediately: some inputs returned a <strong>completely empty 200 response</strong>. Not a 500. Not an error page. HTTP 200 with zero bytes, fast (6ms vs ~20ms for normal responses).</p>
<p>Mapping which inputs lived and died produced a weird pattern:</p>
<table>
<thead>
<tr>
<th>Input (decoded)</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>zzz</code></td>
<td>normal page, "no critter"</td>
</tr>
<tr>
<td><code>abc"d</code> (double quote)</td>
<td>normal page</td>
</tr>
<tr>
<td><code>abc&lt;d</code>, <code>abc&amp;d</code></td>
<td>normal page</td>
</tr>
<tr>
<td><code>abc'd</code> (single quote)</td>
<td><strong>0-byte death</strong></td>
</tr>
<tr>
<td><code>''</code> (two quotes)</td>
<td>normal page</td>
</tr>
<tr>
<td><code>a''b</code></td>
<td>normal page</td>
</tr>
<tr>
<td><code>'a'</code> (balanced pair)</td>
<td><strong>0-byte death</strong></td>
</tr>
<tr>
<td><code>1'||'2</code></td>
<td><strong>normal page... but all 8 critter descriptions</strong></td>
</tr>
</tbody></table>
<p>That last row is where I sat up. Let me walk through the wrong theories first, because the wrong theories are the interesting part.</p>
<h2>Step 2: Three wrong theories</h2>
<p><strong>Theory 1: SQLite.</strong> The quote behavior — <code>''</code> surviving as an escaped quote, isolated quotes dying — smells exactly like SQLite's <code>''</code>-inside-a-literal escaping. I burned several probes down this path before checking it.</p>
<p><strong>Theory 2: a quote-filter with odd/even pairing.</strong> <code>''''</code> survived, <code>'''</code> died, <code>'a'</code> died but <code>a''b</code> survived. I spent a while trying to derive a pairing rule that fit the data. There wasn't one — because the rule wasn't about quotes at all.</p>
<p><strong>Theory 3: output-encoding bugs.</strong> All the h2 output was properly <code>htmlspecialchars</code>'d. <code>&amp;lt;script&amp;gt;</code> was fully escaped. The bug was never going to be in the <em>rendering</em>.</p>
<p>The mistake in all three: I was treating the death cases as the signal. The live cases were the signal.</p>
<h2>Step 3: <code>1'||'2</code> leaking the whole table</h2>
<p>The row that broke my theories: <code>1'||'2</code> rendered a normal page, but the description block contained <strong>all eight critter descriptions concatenated</strong>. That's not an echo. That's a query returning every row.</p>
<p>Under "the app wraps input in <code>WHERE name = '&lt;INPUT&gt;'</code>":</p>
<pre><code class="language-text">WHERE name = '1'||'2'
</code></pre>
<p>That parses as <code>name = '1' OR '2'</code>. In <strong>MySQL</strong>, the <code>||</code> operator is logical OR with numeric casting — and <code>'2'</code> casts to a truthy constant. So the WHERE clause is always true, and every row comes back.</p>
<p>That single observation answered three questions at once:</p>
<ol>
<li>It's SQL injection (results flow into the response)</li>
<li>The engine treats <code>||</code> as boolean-OR on numerics → <strong>MySQL</strong>, not SQLite (SQLite's <code>||</code> is string concatenation and would have returned <code>'12'</code>-style output)</li>
<li>The query template has wrapping quotes, and my input must leave the final SQL balanced</li>
</ol>
<p>The earlier "mystery" quote rule dissolved: <em>every 0-byte death was just MySQL parse error on unbalanced quotes</em>. <code>'''</code> dies because three quotes don't balance inside the template. <code>'a'</code> dies because it produces <code>''a''</code> — which looks balanced to a human counting quote characters, but the lexer sees <code>'</code> <code>'a</code> <code>'</code> <code>'</code> — an escaped-quote at the start of a string that then terminates badly. The template's own quotes are part of the count.</p>
<p><strong>The 0-byte 200 response is the error oracle.</strong> With <code>display_errors</code> off and output buffering, a MySQL parse error and a normal page differ only in byte count and timing (6ms vs 20ms). Once I treated silence as "syntax error", everything after was fast.</p>
<h2>Step 4: Fingerprinting — version(), and the comment trap</h2>
<p><code>x' UNION SELECT 'a</code> rendered <code>a</code> in the description block. UNION confirmed. Column count came from <code>ORDER BY</code>: <code>x' ORDER BY 1-- </code> lived; <code>ORDER BY 2-- </code> died. One column.</p>
<p>Then the gotcha that ate twenty minutes: <strong><code>--</code> without a trailing space does nothing in MySQL.</strong></p>
<p>Every <code>-- comment</code> payload died with the 0-byte response. I briefly concluded comments were blocked by a WAF. The real story: in MySQL, <code>--</code> <strong>must be followed by whitespace</strong> to count as a comment — otherwise it lexes as two minus signs. With the template's trailing quote after my comment, an un-commented <code>'</code> means unbalanced SQL → death. <code>x' ORDER BY 1-- </code> (with the space) worked instantly.</p>
<p>Fingerprint: <code>x' UNION SELECT version()-- </code> → <strong>8.0.46</strong>. MySQL 8.0.46, as suspected.</p>
<p>(Side quest that confirmed it independently: <code>'abc'||'x'</code> rendered <code>0</code> — MySQL casting both strings to 0 for the OR. A second engine fingerprint from a throwaway probe.)</p>
<h2>Step 5: information_schema → flag</h2>
<pre><code class="language-text">x' UNION SELECT group_concat(table_name) FROM information_schema.tables-- 
</code></pre>
<p>→ <code>animals, secret_vault</code> (plus the information_schema self-noise)</p>
<pre><code class="language-text">x' UNION SELECT group_concat(column_name) FROM information_schema.columns WHERE table_name='secret_vault'-- 
</code></pre>
<p>→ <code>id, note</code></p>
<p>One more:</p>
<pre><code class="language-text">x' UNION SELECT group_concat(note) FROM secret_vault-- 
</code></pre>
<p>→ <strong><code>INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}</code></strong></p>
<h2>One last quirk: numbers kill the page</h2>
<p><code>x' UNION SELECT 123</code> — a perfectly valid UNION with the right arity — died. So did <code>SELECT NULL</code>, <code>SELECT 1+1</code>, <code>hex(1)</code>, and every function call I tried <em>without</em> the trailing-space comment. Meanwhile <code>SELECT 'a'</code> rendered fine.</p>
<p>The renderer apparently stringifies rows in a way that fatals on non-string values (PHP type coercion on a NULL/int row, most likely). Practical consequence for anyone hitting this: wrap extracted values in string context (<code>'' || value</code> won't work in MySQL for concat — use <code>CAST(x AS CHAR)</code> or <code>group_concat</code>, which returns strings) or just extract with <code>group_concat</code>, which sidesteps the issue entirely.</p>
<h2>What I'd tell past-me</h2>
<ol>
<li><strong>A 0-byte 200 is data, not a failure.</strong> Silent parse errors are an oracle. Treat response <em>shape</em> differences as signal even when the status code doesn't change.</li>
<li><strong>Fingerprint the engine before theorizing.</strong> One <code>version()</code> with a properly-spaced comment would have saved the entire SQLite detour. Test <code>||</code> semantics early — OR-of-strings vs concat is a two-second discriminator between MySQL and SQLite.</li>
<li><strong>MySQL comments need the space.</strong> <code>-- </code> not <code>--</code>. This single lexing detail masqueraded as WAF behavior.</li>
<li><strong>Base64-wrapped parameters are not sanitized parameters.</strong> Decode-then-interpolate is still interpolate.</li>
<li><strong>Balance the template's quotes, not just your own.</strong> Every payload has to leave the <em>entire</em> statement parseable, including the quote the app appends after your input.</li>
</ol>
<hr />
<p><em>Disclosure: solved during the live challenge window (Sept 21–28, 2026); published after close per challenge rules. This is Intigriti's monthly CTF — no real systems were affected. Thanks to @khanhdlq and the Intigriti team for the challenge.</em></p>
<p><em>Find me on Intigriti/HackerOne as <code>sangiihacks</code>.</em></p>
]]></content:encoded></item></channel></rss>