Challenge: Intigriti BRP Bonus Challenge — “Pray For Nepal”, a missing person registry
Target:https://challenge-brp.challenges.intigriti.io/challenge.php
Vulnerability type: DOM-based XSS through DOM clobbering of a script loader (CWE-79)
Root cause: One parameter, two readers, two different rules
Flag:INTIGRITI{f2cb05a5-5cc1-4d1d-b7cd-084306cbd5a0}
1. Executive Summary
This challenge hands you the exploit. The page reads person from the query string, injects it into the live DOM as HTML, then looks up two elements by id and uses their attributes to build the src of a <script> it appends to <head>. Inject those two elements and the page attacks itself. That part took an afternoon.
The other part took three sessions, because the obvious payload satisfies every condition written in the client source — both elements present, both attribute values comparing equal, the <script> element created, the request leaving the browser — and the endpoint answers 403 with an empty body.
/echo.php reads the same person value a second time, server-side, and enforces a rule the client never applies: one injected element has to be a DOM descendant of the other. getElementById tells you nothing about where an element sits, so sibling and child are the same payload from the client’s point of view. The client runs a value comparison. The server runs a structure comparison. Nothing in the delivered JavaScript, and nothing in any response, mentions the second rule.
Nest the anchor inside the latch and the same request returns 200.
2. The Registry
A single PHP page with a search box, three named records, and a #vault panel that reports the result of an “archive check”.

Three endpoints matter:
| Endpoint | Role |
|---|---|
/challenge.php | The registry page. Reflects person, correctly escaped. |
/echo.php | Answers as a JSONP-style callback. Loaded as a script. |
/reveal.php | Returns the flag when the session is in the right state. |
The headers are tighter than most CTF targets bother with:
content-security-policy: default-src 'none'; script-src 'nonce-<b64>' 'self'; style-src 'nonce-<b64>'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; object-src 'none'; form-action 'self'; frame-ancestors 'self'x-content-type-options: nosniffx-frame-options: SAMEORIGINset-cookie: PHPSESSID=<...>; path=/; HttpOnly; SameSite=Strictx-powered-by: PHP/8.4.27default-src 'none' plus a nonce kills inline handlers. But script-src also carries 'self', and that is the door the intended solution walks through: a same-origin <script src> needs no nonce at all.
Server-side, person is escaped properly. There is no reflected XSS in the PHP output. The second reader is the one that matters.
3. The Gadget
The page’s inline script, nonce-authorised and therefore allowed to run:
(function () { // abridged: sanitizer loop collapsed to a comment var marker = document.getElementById('record-anchor'); var latch = document.getElementById('archive-latch'); var note = new URLSearchParams(location.search).get('person') || ''; var profile = document.getElementById('profile');
if (!profile.querySelector('.profile') && note) { var holder = document.createElement('template'); holder.innerHTML = String(note); // holder.content: remove script/iframe/object/embed, strip every on* attribute profile.innerHTML = holder.innerHTML + '<p class="status"> not found</p>'; marker = document.getElementById('record-anchor'); latch = document.getElementById('archive-latch'); }
var alertFunction = window.alert.bind(window); window.alert = function (message) { alertFunction(message); fetch('/reveal.php', { method: 'POST', credentials: 'same-origin', headers: { 'X-Registry-Check': '1' } }) .then(r => { if (!r.ok) throw new Error('Reveal rejected'); return r.text(); }) .then(flag => { vault.textContent = 'ARCHIVE OPEN: ' + flag; }) .catch(() => { vault.textContent = 'ARCHIVE DENIED'; }); };
if (!marker || !latch || latch.getAttribute('data-key') !== 'river-lantern') return; var endpoint = marker.getAttribute('data-index'); if (typeof endpoint !== 'string' || endpoint.indexOf('/') !== 0) return; endpoint = decodeURIComponent(endpoint); if (endpoint !== '/echo.php?channel=echo&listener=alert(document.domain)//') return;
var loader = document.createElement('script'); loader.src = endpoint; document.head.appendChild(loader);})();Three things fall out of reading this once.
person goes into template.innerHTML, gets sanitized, and the serialization of that template is what lands in profile.innerHTML. So person is HTML injection, filtered by a blocklist: script, iframe, object, embed and every on* attribute are stripped. div, a, id and data-* come through untouched — exactly the vocabulary the gadget needs.
marker and latch start as null and are re-read after the injection. That is the published hint about “two variables that begin as null”. Whatever I inject with those ids becomes the gadget.
And the page has already replaced window.alert with something that POSTs /reveal.php and writes the response into #vault. The whole chain is pre-built. All it wants is two elements.
4. The Payload That Passes Everything and Still Fails
The obvious shape puts them side by side:
<a id="record-anchor" data-index="/echo.php?channel=echo&listener=alert(document.domain)//">x</a><div id="archive-latch" data-key="river-lantern"></div>This is not a guess that something happened. Checked in the page:
JSON.stringify({ anchor: !!document.getElementById('record-anchor'), // true latch: !!document.getElementById('archive-latch'), // true scripts: [...document.scripts].map(s => s.src).filter(Boolean)})// -> the echo.php URL is in document.scriptsElements present. Attribute values equal. Script element created, appended, requested. And:

403, content-length: 0, no message. Nothing that distinguishes it from any other refusal, and no reveal.php request at all, because the callback never ran.
That empty body is the whole trap. An opaque 403 on the one endpoint the chain depends on reads as this endpoint is closed, so I went to work on the endpoint. Over three sessions and more than four hundred requests I established, all negative:
- Every method, and every combination of
Origin,Referer,Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-DestandAccept. - A byte-perfect replay of the exact request Chrome sends for the script load, captured from the browser.
- Fresh sessions, primed sessions, reused sessions, the same TCP connection, a second egress IP, and the target’s own browser.
- 150 candidate parameter names in one request against
challenge.phpandindex.php. Responses byte-identical, so no hidden parameter exists. - 36 query-parameter names against
reveal.php. Response length constant at 340 bytes throughout. session.upload_progressas a session-write primitive, from two clients including a genuine slow 1.5 MB upload. Disabled.
One measurement should have stopped me. I diffed the page served on 2 November against a capture from 28 October, nonces normalised: byte-identical, 6765 bytes both. No redeploy. The program page showed 63 accepted submissions against precisely the build in front of me.
That is the moment to suspect your own model, not the target. I suspected the target.
5. The Gate
The payload shape is the gate. record-anchor has to be a descendant of archive-latch:
<div id="archive-latch" data-key="river-lantern"><a id="record-anchor" data-index="/echo.php?channel=echo&listener=alert(document.domain)//">signal</a></div>Same two ids, same two attribute values, same everything the client inspects. One structural difference:

echo.php 200. reveal.php 200. Flag in the vault. Those two screenshots are the same browser, the same session, seconds apart.
So /echo.php is doing its own read of person, and it is checking something structural. Containment is the only variable between those two requests: identical ids, identical attribute values, identical everything the client inspects, one nested and one not. getElementById resolves an id to an element and says nothing about where that element lives, so sibling, child, grandchild and a wrapper three levels up are indistinguishable client-side. That is exactly why the sibling payload looks finished.
The published hint said “the person parameter is read twice”. I read that as two reads of the same rule and went hunting for parameter pollution, a Referer-chained second read, and session coupling. It meant two readers enforcing different rules. The client-side equality test is not the specification; it is the weaker of two.
Two more details decide whether the correct payload works.
The ampersand must be a literal %26. Writing the entity instead, data-index="…channel=echo&listener=…", produces an identical attribute value in the browser, because the HTML parser resolves it before the client ever compares anything. The script load still fails. Both encodings, back to back in the same browser session: with %26 the loader fires and the flag lands, with %26amp%3B the <script> element errors and nothing runs. The client-side check passed in both cases, so the difference is on the server, which reads the raw person string — and there & is four extra characters.
It has to come from a real browser. I never got a 200 out of /echo.php from a command-line client, including with the exact header set Chrome sends for the script load — same method, same Referer, same Sec-Fetch-Dest: script, Sec-Fetch-Mode: no-cors, Accept: */*, same session cookie, replayed byte for byte. Correct nested payload, still an empty 403. I can state the symptom; I never isolated which part of the browser context it keys on.
That last one is why my curl A/B could never have found the gate. Both arms were failing, for two unrelated reasons, and both failures looked identical because the deny carries no body. I built an oracle whose two possible outputs were 403 and 403, and ran four hundred experiments through it.
6. The Flag
https://challenge-brp.challenges.intigriti.io/challenge.php?person=%3Cdiv+id%3D%22archive-latch%22+data-key%3D%22river-lantern%22%3E%3Ca+id%3D%22record-anchor%22+data-index%3D%22%2Fecho.php%3Fchannel%3Decho%26listener%3Dalert%28document.domain%29%2F%2F%22%3Esignal%3C%2Fa%3E%3C%2Fdiv%3EOpen it in a browser:

Dismiss the dialog, the overridden alert POSTs /reveal.php with X-Registry-Check: 1, and the vault fills in:

INTIGRITI{f2cb05a5-5cc1-4d1d-b7cd-084306cbd5a0}No CSP bypass, no sanitizer bypass. Both were solid, and the intended path never needed either of them broken.
One operational note from running this many times in fresh profiles: reveal.php occasionally answers 403 and the vault shows ARCHIVE DENIED even on a correct payload, and the same URL works moments later. The host burst-throttles, and the throttle response is byte-identical to the guard’s deny — another place where one opaque status code means two different things. If you get ARCHIVE DENIED, wait and retry before you theorise.
7. The Long Way Round
Two real findings came out of the wrong hypothesis. Neither reaches the flag, and both are worth keeping.
7.1 Mutation XSS through the sanitizer
person is parsed twice by parsers with different scripting flags, which is a genuine parse/serialize differential.
Inside a <template>, parsing happens with scripting disabled, and in that mode <noscript> content is parsed as elements. So in:
/challenge.php?person=<noscript><style></noscript><img src=x onerror=alert(document.domain)></style></noscript>the <style> opens a rawtext element inside the template, and everything after </noscript> is swallowed as text. The sanitizer’s holder.content.querySelectorAll('script,iframe,object,embed') matches nothing, and its on* strip finds no attributes, because at that moment there is no img — only characters.
holder.innerHTML then serializes that text back into profile.innerHTML, which parses with scripting enabled. Now <noscript> content is rawtext, <style> degrades to text, and <img src=x onerror=…> materialises as a real element with a live handler, after the sanitizer has already run.
The sanitizer is bypassed. The nonce-based CSP then blocks the handler, so this payload alone does not execute. Sanitizing a template’s parsed contents and inserting its serialization is unsafe by construction, and this is a clean demonstration of why.
7.2 Security headers dropped by a PHP startup warning
Send more than a thousand query variables and PHP emits Input variables exceeded 1000 at request startup, before any application code runs. Output has therefore already begun when the page tries to set its headers, and every header() call fails. The response arrives with no Content-Security-Policy, no X-Content-Type-Options, no X-Frame-Options.
The attacker decides, per request, whether the response is protected at all. Combine it with 7.1 and the mutation-XSS payload executes for real — confirmed with the native dialog on the live origin.
It is still a dead end for the flag, and the reason is the instructive part. The same warning fires before session_set_cookie_params() and session_start(), so those fail too. No session exists on that request, so /reveal.php cannot complete. The error path that unlocks execution is the error path that destroys the state the flag depends on.
This also produced the red herring I burned the most time on. Driving /reveal.php outside the gadget flow yields Undefined array key "challenge_person" with a line number, which looks exactly like a missing session value that some request must set by hand. I built priming machinery across pods, interleaved rounds, registry-name matrices and a cross-realm iframe call to chase it. Nothing sets that value by hand. It is written as part of the legitimate flow, and the warning was a symptom of arriving by the wrong route.
8. Root Cause, Fix, Lesson
Root cause. One input, two readers, two different rule sets. The client validates values, the server validates structure. Any such pair is a parser differential waiting to happen, and here the differential was the challenge.
Fixes, in order of value:
-
Stop re-reading
personon the client. The server already renders the name safely; the inline script re-introduces it as markup. Assign it withtextContent, or sanitize the serialized string that is actually inserted rather than the parsed tree, so sanitization and insertion see identical markup. -
Stop building a script URL out of DOM attributes. Reading
data-indexback out of the document re-trusts input the page just injected, andgetElementByIdresolves to whichever element exists, including the attacker’s. Keep the endpoint in a closure, and resolve elements inside a known-good subtree rather than by global id. -
Emit security headers before any output is possible. Set them at the web server or ingress layer, or buffer output until headers are flushed, so a startup warning cannot silently remove the policy. Disable
display_errorsin production so a warning never reaches a response body. -
Enforce one rule in one place. If containment is the requirement, check it on both readers or on neither. A rule applied by only one of two parsers of the same input is not defence in depth — it is the bug.
The lesson I paid for. When a client-side check passes and the server still refuses, stop varying values and start varying shape: nesting, ordering, containment, duplication, depth. A server-side structural check leaves no trace in the client code, emits no warning, and is invisible to an oracle whose failure mode is a zero-byte response.
And build oracles that can fail in more than one way. Mine returned 403 for a wrong payload and 403 for a right payload sent by the wrong client. Four hundred requests through a one-bit oracle is four hundred requests of nothing.
9. Reproduction
GET /challenge.php?person=<div id="archive-latch" data-key="river-lantern"> <a id="record-anchor" data-index="/echo.php?channel=echo&listener=alert(document.domain)//">signal</a> </div> -> 200, gadget clobberedGET /echo.php?channel=echo&listener=alert(document.domain)// (issued by the page itself, as a script load) -> 200, alert(document.domain)//({"signal":"received"});POST /reveal.php X-Registry-Check: 1 -> flag, rendered into #vaultNest the anchor inside the latch, send & as %26, and let a real browser drive it. Any one of those three wrong gives the same empty 403.
Captured live on 3 November 2026 against challenge-brp.challenges.intigriti.io, in Chrome with DevTools recording.
onevilx