Post

UMD CTF 2025 - WEB

UMD CTF 2025 - WEB

Web

brainrot-dictionary

Solvers: 199
Author: aparker

Description

This website will help you understand the rest of the nonsense going on in the CTF. You can even upload your own brainrot words and get definitions!
image

Solution

This website can only upload file with .brainrot extension. Let’s create a test.brainrot file and upload it.

image image

After uploading, we can see that we redirected to /dict endpoint which shows list of brainrot words.
Let’s look through the main.py provided by the challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
from flask import Flask, render_template, request, redirect, session, url_for, send_from_directory
import os
import re
import random
import string
from werkzeug.utils import secure_filename
from urllib.parse import unquote

app = Flask(__name__)
app.secret_key = os.urandom(32)
app.config['MAX_CONTENT_LENGTH'] = 1000

# Directory to save uploaded files and images
UPLOAD_FOLDER = 'uploads'

if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

def create_uploads_dir(d=None):
    dirname = os.path.join(UPLOAD_FOLDER, ''.join(random.choices(string.ascii_letters, k=30)))
    if d is not None:
        dirname = d
    session['upload_dir'] = dirname
    os.mkdir(dirname)
    os.popen(f'cp flag.txt {dirname}')
    os.popen(f'cp basedict.brainrot {dirname}')

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        if 'user_file' not in request.files:
            return render_template('index.html', error="L + RATIO + YOU FELL OFF")
        user_file = request.files['user_file']
        if not user_file.filename.endswith('.brainrot'):
            return render_template('index.html', error="sorry bruv that aint brainrotted enough")
        if 'upload_dir' not in session:
            create_uploads_dir()
        elif not os.path.isdir(session['upload_dir']):
            create_uploads_dir(session['upload_dir'])
        fname = unquote(user_file.filename)
        if '/' in fname:
            return render_template("index.html", error="dont do that")
        user_file.save(os.path.join(session['upload_dir'], fname))
        return redirect(url_for('dict'))
    return render_template('index.html')

@app.route('/dict')
def dict():
    if 'upload_dir' not in session:
        create_uploads_dir()
    elif not os.path.isdir(session['upload_dir']):
        create_uploads_dir(session['upload_dir'])

    cmd = f"find {session['upload_dir']} -name \\*.brainrot | xargs sort | uniq"
    results = os.popen(cmd).read()
    return render_template('dict.html', results=results.splitlines())



if __name__ == '__main__':
    app.run(debug=False, host="0.0.0.0")

We found these part interesting:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# When initializing the session
def create_uploads_dir(d=None):
    dirname = os.path.join(UPLOAD_FOLDER, ''.join(random.choices(string.ascii_letters, k=30)))
    if d is not None:
        dirname = d
    session['upload_dir'] = dirname
    os.mkdir(dirname)
    os.popen(f'cp flag.txt {dirname}')  # Copy flag.txt to the upload directory
    os.popen(f'cp basedict.brainrot {dirname}')  # Copy the default dictionary

# Route to display the dictionary list
@app.route('/dict')
def dict():
    # ...
    cmd = f"find {session['upload_dir']} -name \\*.brainrot | xargs sort | uniq"
    results = os.popen(cmd).read()
    # ...

They used find {session['upload_dir']} -name \*.brainrot | xargs sort | uniq to:

  • Finds all files with the .brainrot extension in the specified directory
  • xargs sort: Takes the list of filenames from the find output as arguments for the sort command
  • uniq: Removes duplicate files

When look through definition of xargs on man page, it uses whitespace to separate arguments. It means that when a filename contains whitespace, xargs treats it as multiple separate arguments.

image

So what if we upload a file with whitespace in the filename?

image image

As we can see, it show the flag. Here is the flow of the find command when uploading flag.txt basedict.brainrot:

  • When it execute command: find uploads/AbCdEf -name *.brainrot -> it will be: uploads/AbCdEf/flag.txt basedict.brainrot
  • After passing to xargs sort: xargs considers uploads/AbCdEf/flag.txt and basedict.brainrot as two separate arguments
  • The sort command will attempt to read the contents of both uploads/AbCdEf/flag.txt and basedict.brainrot -> So as the result, the content of the flag.txt will be read and displayed.

Flag: UMDCTF{POSIX_no_longer_recommends_that_this_is_possible}

Steve Le Poisson

Solvers: 139
Author: tahmid-23

Description

il est orange
image

Solution

This website use French language, pretty interesting =)). Let’s dive into the index.js file provided by the challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 📦 Importation des modules nécessaires pour faire tourner notre monde sous-marin numérique
const express = require("express");   // Express, le cadre web minimaliste mais puissant
const sqlite3 = require("sqlite3");   // SQLite version brute, pour les bases de données légères
const sqlite = require("sqlite");     // Une interface moderne (promesse-friendly) pour SQLite
const cors = require("cors");         // Pour permettre à d'autres domaines de parler à notre serveur — Steve est sociable, mais pas trop

// 🐠 Création de l'application Express : c’est ici que commence l’aventure
const app = express();

// 🧪 Fonction de validation des en-têtes HTTP
// Steve, ce poisson à la sensibilité exacerbée, déteste les en-têtes trop longs, ambigus ou mystérieux
function checkBadHeader(headerName, headerValue) {
    return headerName.length > 80 || 
           (headerName.toLowerCase() !== 'user-agent' && headerValue.length > 80) || 
           headerValue.includes('\0'); // Le caractère nul ? Un blasphème pour Steve.
}

// 🛟 Middleware pour autoriser les requêtes Cross-Origin
app.use(cors());

// 🧙 Middleware maison : ici, Steve le Poisson filtre les requêtes selon ses principes aquatiques
app.use((req, res, next) => {
    let steveHeaderValue = null; // On prépare le terrain pour récupérer l’en-tête sacré
    let totalHeaders = 0;        // Pour compter — car Steve compte. Tout. Toujours.

    // 🔍 Parcours des en-têtes bruts, deux par deux (clé, valeur)
    for (let i = 0; i < req.rawHeaders.length; i += 2) {
        let headerName = req.rawHeaders[i];
        let headerValue = req.rawHeaders[i + 1];

        // ❌ Si un en-tête ne plaît pas à Steve, il coupe net la communication
        if (checkBadHeader(headerName, headerValue)) {
            return res.status(403).send(`Steve le poisson, un animal marin d’apparence inoffensive mais d’opinion tranchée, n’a jamais vraiment supporté tes en-têtes HTTP. Chaque fois qu’il en voit passer un — même sans savoir de quoi il s’agit exactement — son œil vitreux se plisse, et une sorte de grondement bouillonne dans ses branchies. Ce n’est pas qu’il les comprenne, non, mais il les sent, il les ressent dans l’eau comme une vibration mal alignée, une dissonance numérique qui le met profondément mal à l’aise. Il dit souvent, en tournoyant d’un air dramatique : « Pourquoi tant de formalisme ? Pourquoi cacher ce qu’on est vraiment derrière des chaînes de caractères obscures ? » Pour lui, ces en-têtes sont comme des algues synthétiques : inutiles, prétentieuses, et surtout étrangères à la fluidité du monde sous-marin. Il préférerait mille fois un bon vieux flux binaire brut, sans tous ces ornements absurdes. C’est une affaire de principe.`); // Message dramatique de Steve
        }

        // 🔮 Si on trouve l’en-tête "X-Steve-Supposition", on le garde
        if (headerName.toLowerCase() === 'x-steve-supposition') {
            steveHeaderValue = headerValue;
        } 

        totalHeaders++; // 🧮 On incrémente notre compteur de verbosité HTTP
    }

    // 🧻 Trop d’en-têtes ? Steve explose. Littéralement.
    if (totalHeaders > 30) {
        return res.status(403).send(`Steve le poisson, qui est orange avec de longs bras musclés et des jambes nerveuses, te fixe avec ses grands yeux globuleux. "Franchement," grogne-t-il en agitant une nageoire transformée en doigt accusateur, "tu abuses. Beaucoup trop d’en-têtes HTTP. Tu crois que c’est un concours ? Chaque requête que tu envoies, c’est un roman. Moi, je dois nager dans ce flux verbeux, et c’est moi qui me noie ! T’as entendu parler de minimalisme ? Non ? Et puis c’est quoi ce délire avec des en-têtes dupliqués ? Tu crois que le serveur, c’est un psy, qu’il doit tout écouter deux fois ? Retiens-toi la prochaine fois, ou c’est moi qui coupe la connexion."`); // Encore un monologue dramatique de Steve
    }

    // 🙅‍♂️ L’en-tête sacré est manquant ? Blasphème total.
    if (steveHeaderValue === null) {
        return res.status(400).send(`Steve le poisson, toujours orange et furibond, bondit hors de l’eau avec ses jambes fléchies et ses bras croisés. "Non mais sérieusement," râle-t-il, "où est passé l’en-tête X-Steve-Supposition ? Tu veux que je devine tes intentions ? Tu crois que je lis dans les paquets TCP ? Cet en-tête, c’est fondamental — c’est là que tu déclares tes hypothèses, tes intentions, ton respect pour le protocole sacré de Steve. Sans lui, je suis perdu, confus, désorienté comme un poisson hors d’un proxy.`);
    }

    // 🧪 Validation de la structure de la supposition : uniquement des caractères honorables
    if (!/^[a-zA-Z0-9{}]+$/.test(steveHeaderValue)) {
        return res.status(403).send(`Steve le poisson, ce poisson orange à la peau luisante et aux nageoires musclées, unique au monde, capable de nager sur la terre ferme et de marcher dans l'eau comme si c’était une moquette moelleuse, te regarde avec ses gros yeux globuleux remplis d’une indignation abyssale. Il claque de la langue – oui, car Steve a une langue, et elle est très expressive – en te voyant saisir ta supposition dans le champ prévu, un champ sacré, un espace réservé aux caractères honorables, alphabétiques et numériques, et toi, misérable bipède aux doigts témérairement chaotiques, tu as osé y glisser des signes de ponctuation, des tilde, des dièses, des dollars, comme si c’était une brocante de symboles oubliés. Tu crois que c’est un terrain de jeu, hein ? Mais pour Steve, ce champ est un pacte silencieux entre l’humain et la machine, une zone de pureté syntaxique. Et te voilà, en train de profaner cette convention sacrée avec ton “%” et ton “@”, comme si les règles n’étaient que des suggestions. Steve bat furieusement des pattes arrière – car oui, il a aussi des pattes arrière, pour la traction tout-terrain – et fait jaillir de petites éclaboussures d’écume terrestre, signe suprême de sa colère. “Pourquoi ?” te demande-t-il, avec une voix grave et solennelle, comme un vieux capitaine marin échoué dans un monde digital, “Pourquoi chercher la dissonance quand l’harmonie suffisait ? Pourquoi saboter la beauté simple de ‘azAZ09’ avec tes gribouillages postmodernes ?” Et puis il s’approche, les yeux plissés, et te lance d’un ton sec : “Tu n’es pas digne de l’en-tête X-Steve-Supposition. Reviens quand tu sauras deviner avec dignité.`);
    }

    // ✅ Si tout est bon, Steve laisse passer la requête
    next();
});

// 🔍 Point d'entrée principal : route GET pour "deviner"
app.get('/deviner', async (req, res) => {
    // 📂 Ouverture de la base de données SQLite
    const db = await sqlite.open({
        filename: "./database.db",           // Chemin vers la base de données
        driver: sqlite3.Database,            // Le moteur utilisé
        mode: sqlite3.OPEN_READONLY          // j'ai oublié ça
    });

    // 📋 Exécution d'une requête SQL : on cherche si la supposition de Steve est correcte
    const rows = await db.all(`SELECT * FROM flag WHERE value = '${req.get("x-steve-supposition")}'`);

    res.status(200); // 👍 Tout va bien, en apparence

    // 🧠 Si aucune ligne ne correspond, Steve se moque gentiment de toi
    if (rows.length === 0) {
        res.send("Bah, tu as tort."); // Pas de flag pour toi
    } else {
        res.send("Tu as raison!");    // Le flag était bon. Steve t’accorde son respect.
    }
});

// 🚪 On lance le serveur, tel un aquarium ouvert sur le monde
const PORT = 3000;
app.listen(PORT, "0.0.0.0", () => {
  console.log(`Serveur en écoute sur http://localhost:${PORT}`);
});

After go through the code, we found the endpoint /deviner which have a SQL query that it will checks if our guess matches the flag stored in the database.

1
const rows = await db.all(`SELECT * FROM flag WHERE value = '${req.get("x-steve-supposition")}'`);

We can see that x-steve-supposition header and directly inserts it into an SQL query without sanitization -> leads to simple SQL injection.
But when we look that the if statement:

1
2
3
4
// 🧪 Validation de la structure de la supposition : uniquement des caractères honorables
    if (!/^[a-zA-Z0-9{}]+$/.test(steveHeaderValue)) {
        return res.status(403).send(`Steve le poisson, ce poisson orange à la peau luisante et aux nageoires musclées, unique au monde, capable de nager sur la terre ferme et de marcher dans l'eau comme si c’était une moquette moelleuse, te regarde avec ses gros yeux globuleux remplis d’une indignation abyssale. Il claque de la langue – oui, car Steve a une langue, et elle est très expressive – en te voyant saisir ta supposition dans le champ prévu, un champ sacré, un espace réservé aux caractères honorables, alphabétiques et numériques, et toi, misérable bipède aux doigts témérairement chaotiques, tu as osé y glisser des signes de ponctuation, des tilde, des dièses, des dollars, comme si c’était une brocante de symboles oubliés. Tu crois que c’est un terrain de jeu, hein ? Mais pour Steve, ce champ est un pacte silencieux entre l’humain et la machine, une zone de pureté syntaxique. Et te voilà, en train de profaner cette convention sacrée avec ton “%” et ton “@”, comme si les règles n’étaient que des suggestions. Steve bat furieusement des pattes arrière – car oui, il a aussi des pattes arrière, pour la traction tout-terrain – et fait jaillir de petites éclaboussures d’écume terrestre, signe suprême de sa colère. “Pourquoi ?” te demande-t-il, avec une voix grave et solennelle, comme un vieux capitaine marin échoué dans un monde digital, “Pourquoi chercher la dissonance quand l’harmonie suffisait ? Pourquoi saboter la beauté simple de ‘azAZ09’ avec tes gribouillages postmodernes ?” Et puis il s’approche, les yeux plissés, et te lance d’un ton sec : “Tu n’es pas digne de l’en-tête X-Steve-Supposition. Reviens quand tu sauras deviner avec dignité.`);
    }

This regex prevents us from using typical SQL injection characters like quotes, semicolons, hyphens, or spaces. And only allows alphanumeric characters and curly braces.
Let’s go through how Express handles headers:

  1. When using req.get("x-steve-supposition") in the SQL query, Express returns the first instance of this header.
  2. When validating the header with regex, Express stores the last value of this header in steveHeaderValue.

And we also found other interesting things is that req.rawHeaders will store all the headers in the order they were received.

image

So what if:

  • First header, we inject the sqli payload.
  • Second header, we just put a alphanumeric value to pass the regex check. ```js // Check headers one by one in a loop for (let i = 0; i < req.rawHeaders.length; i += 2) { let headerName = req.rawHeaders[i]; let headerValue = req.rawHeaders[i + 1];

    // Store the last value seen for this header if (headerName.toLowerCase() === ‘x-steve-supposition’) { steveHeaderValue = headerValue; } }

// Validate regex on the last value seen if (!/^[a-zA-Z0-9{}]+$/.test(steveHeaderValue)) { // Return error }

1
2
3
4
5
6
7
8
9
10
11
12
Let's try it out:

![image](/assets/img/umd-ctf_2025/request1.png)

Hmm, check the code again this part:
```js
function checkBadHeader(headerName, headerValue) {
    return headerName.length > 80 || 
           (headerName.toLowerCase() !== 'user-agent' && headerValue.length > 80) || 
           headerValue.includes('\0'); // Le caractère nul ? Un blasphème pour Steve.
}

The Accept and User-Agent header have passed the limit length. So what if we remove them?

image

It works! Now we can inject our payload. Let’s try the sqlite version as we seen that the code use sqlite3.Database as the driver.

image

Shows Tu as raison! means You're right! but we can not see the sqlite version. So this could be a blind conditional sqli.

From the SQL query, we can see that the column name is value and the table name is flag. Let’s extract using substr to bruteforce each position of the flag.

The flag format is UMDCTF{...} so let’s first try the substr position 1 is U to make sure our exploit is working.

image

Great, it works! Now let’s try to extract the rest of the flag. We will use the burp intruder to exploit this.
Then use the clusterbomb attack type and add $$ to these value:

1
X-Steve-Supposition: ' OR substr(value,$1$,1) = '$U$' -- 
  • For the first $1$, we will use payload type Numbers and range from 1 -> 30.
  • Then for the second $U$, we will use Simple list and use this list abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_.
  • Last part will add Tu as raison! to the Grep - Match to make it easier to filter for correct response.

image

After running the attack, we use Payload 1 for the position and Payload 2 for the character.

image

Finally, we get the flag.

Flag: UMDCTF{ile5TVR4IM3NtTresbEAu}

A Minecraft Movie

Solvers: 58
Author: tahmid-23

Description

I…AM STEVE! image image

Solution

First let go through the application and also watch requests through burp suite. So let’s start register account and create a post.

image

After create an account, we check that our session number is undefined.

image

At the homepage, we can see some Top Minecraft Movie Posts. Let’s check some posts and discover something interesting.

image

We get postId=58eb18f6-1fb8-455c-b0cb-b764ec1f7048 and look through, we notice This post was liked by an admin!, so what if we like or dislike this post?

image image

There is two requests which are /start-session and /legacy-social that we can see that our sessionNumber=1.

image

So confirm that our session number is 1. And we are curious about the legacy-social request. Let’s check it out.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
, R = O.useCallback(async C => {
        await W0();
        const G = await fetch(`${na}/legacy-social`, {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: `sessionNumber=${window.sessionNumber}&postId=${s}&likes=${C}`,
            credentials: "include"
        });
        if (!G.ok) {
            S(await G.text());
            return
        }
        await h()
    }

We found out that:

  • Uses form-urlencoded format instead of JSON which could lead to easily create request with HTML form.
  • No CSRF protection that attacker could forge the request because server does not verify the authenticity of the request.
  • Relies on client-side session so attacker can forge this session number.

Also found this part for content sanitization limitation:

1
2
3
4
5
6
7
8
9
10
11
const Y = rh.sanitize(f.content, {
    ADD_TAGS: ["iframe"],
    ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling", "src", "width", "height"]
});

O.useEffect(() => {
    rh.addHook("uponSanitizeElement", (s, f) => {
        var d;
        f.tagName === "iframe" && s instanceof Element && ((s.getAttribute("src") || "").startsWith("https://www.youtube.com/embed/") || (d = s.parentNode) == null || d.removeChild(s))
    })
})

From observation:

  • Allows iframe tags but only from YouTube embeds
  • For the content santitization, attacker can use form tag to create CSRF attack.

We also found suspicious part:

image

which means this endpoint could be vulnerable that current implementation is insecure due to:

  • Use dangerouslySetInnerHTML as a red flag in security audit.
  • Instead of using state management, it uses global window.sessionNumber.

Gathering all the information, now let’s create a normal post first.

image image

We can see that the body part of the request /create-post is:

1
2
3
4
{
    "title":"alex",
    "content":"hi steve"
}

And following that is the postId which is 6400f205-4759-4593-a258-1a30b64418ae. We can like and dislike and it will show two requests we have discussed before. But noticed that our post does not have like by admin.

image

while the other post has liked by admin. So if we make admin like our post, we can get the flag.

image

Gathering all the information, we can create a CSRF attack flow:

  • Create a normal post to grab that postId.
  • Then create another post with CSRF payload and pointer to our normal post.
  • Submit the post contain CSRF payload to admin so that admin by change will like our normal post.

So we have create a normal post already, let’s create another one with CSRF payload:

1
2
3
4
{
  "title": "hehe",
  "content": "<div class=\"flex justify-center\"><form action=\"https://a-minecraft-movie-api.challs.umdctf.io/legacy-social\" method=\"POST\"><input name=\"sessionNumber\" value=\"1\"><input name=\"postId\" value=\"6400f205-4759-4593-a258-1a30b64418ae\"><input name=\"likes\" value=\"1\"><input type=\"submit\" autofocus style=\"position:fixed;top:0;left:0;width:100%;height:100%;opacity:0\"></form></div>"
}

with the autofocus attribute to make the form submit automatically when the page loads.

image

Now, submit this postId:fe7fcd04-4476-4bce-a8f6-11dccc9175a1 to admin.

image

When check again our normal post, we got admin liked it.

image

Check out /account endpoint, we can see the flag.

image

Flag: UMDCTF{I_y3@RNeD_f0R_7HE_Min3S}

This post is licensed under CC BY 4.0 by the author.