Write-up Sthack 2046 : la billetterie helloassso

6 Juin 2026

Cette année, comme chaque année, la meilleure conf / CTF s’est encore déroulée à Bordeaux. Je parle évidemment de la Sthack, et j’y étais pour une fois non pas en participant, mais en tant que staff, et j’ai pu participer à la création de plusieurs challenges.

Voici donc dans cet article le write-up du challenge Sthack 2046, qui était divisé en 4 sous-étapes autour d’une fausse billetterie, « helloassso ».

Sthack 2046 (1/4)

Les joueurs ont commencé le challenge en allant sur https://helloassso.com et en faisant face à cette super page :

On est en 2046. La Sthack existe toujours (évidemment) et les places partent rapidement. Les orgas ont donc mis en place plusieurs protections antiscraping :

Le guichet ouvre un lot de 10 places toutes les 60 secondes, et toute réservation non finalisée en moins de 5 secondes est invalidée.

Voilà les différentes informations à remplir :

Et enfin la partie la plus chiante :

Pour une personne qui passe depuis son browser, elle ne le voit pas, mais un challenge est silencieusement résolu par son navigateur afin de vérifier que c’est bien un humain.

Vous l’avez compris, remplir tous ces champs en moins de 5 secondes est humainement très compliqué, donc les joueurs devaient scripter pour réserver leurs places.

La majorité de ces questions et même le petit calcul mathématique étaient assez simples à scripter, mais le Proof-of-Work délivré par le serveur était la partie la plus complexe.

Délivré sur GET /api/tickets/challenge :

Il faut trouver un counter (u64 little-endian) tel que scrypt(nonce || counter) présente au moins 12 bits à zéro en tête.

Le piège pédagogique : les paramètres Scrypt ne sont pas dans la réponse. Le champ algo est volontairement vague (memory_hard_pow_v1). Le seul moyen de connaître le sel, le N, le r, le p et la longueur de sortie, c’est de reverser le module WASM servi au navigateur. Un script Python qui se contente de lire la réponse JSON ne peut pas deviner ces valeurs.

Pour cela il fallait récup le binaire :

curl -sS https://helloassso.com/assets/wasm/sthack_pow.wasm -o guichet.wasm

Le désassembler :

On repère les constantes Scrypt :

On en tire les paramètres exacts :

ParamètreValeur
saltsthack_salt
log2(N)10 → N = 1024
r1
p1
dkLen32
inputnonce (hex décodé) ‖ counter_le (8 octets)

Une fois les paramètres connus, n’importe quelle stdlib crypto fait l’affaire. Le travail à 12 bits prend ~1,3 s en natif — largement dans le budget de 5 s.

Proprement en Rust :

Et on peut solve complètement la partie 1 :

On obtient du coup le premier flag : STHACK{W4SM_Sc4lp3r_B0t_5kipped_Th3_Qu3u3}

et un cookie JWT fastlane_session avec l’info que l’étape 2 se passe sur /cart.

Avec le message propre : « Place réservée. helloassso approuve. Cette session vous donne accès direct à l’étape 2 sans attendre le prochain batch. Renvoyez ce jeton dans un cookie nommé fastlane_session (curl : -b 'fastlane_session=<jeton>'). »

ps : pour info j’avais prévu que ce soit solvé comme ça, mais plusieurs équipes ont juste enchaîné toutes les réponses en scriptant avec Selenium et du coup elles n’ont pas dû s’embêter avec cette partie car le challenge était solve « naturellement » par leur navigateur.

Étape 2 : Sthack 2046 (2/4)

On voit ça :

Évidemment les utilisateurs pouvaient m’envoyer 1337 euros en bitcoin et je leur aurais donné le flag de l’étape 2 car je suis un être profondément corruptible, mais le moins cher était évidemment de trouver une vulnérabilité.

En se concentrant bien on voit qu’on peut rentrer un code promo :

Encore faut-il avoir un code promo ! Pour cela il fallait faire un peu de recon sur le site.

En allant sur helloasso.com/robots.txt, on peut voir plusieurs paths :

La plupart étaient des baits, mais sur /press il y avait 2 articles :

Et en lisant l’article :

On découvre plusieurs indices — HTTP/2 natif sur la stack — et le fameux code promo STHACK_TEN, qui fait une réduction de 10 % et n’est utilisable qu’une fois par panier.

Ici aussi ce n’était pas une simple vulnérabilité race condition où il fallait spammer le code promo pour ne devoir rien payer, mais exploiter la vulnérabilité TOCTOU single packet attack de 2023 de James Kettle avec son célèbre article The Single Packet Attack.

(ça fail si on tente basiquement de faire un spam)

La faille : TOCTOU

Le handler d’application du promo est volontairement vulnérable — pas de transaction, pas de SELECT ... FOR UPDATE, et même une fenêtre de course élargie exprès :

app.post('/cart/apply-promo', async (req, reply) => {
  const cart = await prisma.cart.findUnique({ where: { id: cartId } });
  if (cart.promoApplied) return reply.code(400).send(/* already_applied */);

  const promo = await prisma.promo.findUnique(/* … */);
  await new Promise(r => setImmediate(r));          // ← race window élargie

  const discount = Math.floor(cart.total * promo.discount);
  await prisma.cart.update({
    data: { total: { decrement: discount }, promoApplied: true }
  });
});

Le piège est subtil : le discount est calculé à partir de l’état pré-course (1337 €). Si N requêtes franchissent le if (cart.promoApplied) avant que l’une d’elles n’ait écrit promoApplied = true, alors les N requêtes envoient toutes un UPDATE total = total - 13370. Postgres sérialise les écritures : N décréments qui s’empilent. Le total plonge dans le négatif.

La contrainte qui force la technique moderne

Le endpoint vit sur le port 8443 en HTTP/2 natif (allowHTTP1: false), et il n’y a aucun reverse proxy devant. Pourquoi ? Parce qu’un Caddy ou un nginx en terminaison TLS reconstituerait les requêtes h2 en HTTP/1.1 séquentielles vers le backend — et tuerait l’attaque. Ici, le multiplexage h2 va de bout en bout.

Conséquence : un flood HTTP/1.1 parallèle classique se heurte au head-of-line blocking de TCP, les requêtes arrivent en file, et le check anti-doublon les rejette une à une. Seule l’attaque à paquet unique passe.

Le solve :

1. Une seule connexion TLS + h2.
2. Envoi de 30× (HEADERS + DATA), mais on retient le dernier octet du body
   → END_STREAM = false. Le serveur alloue ses buffers et attend.
3. Pause ~80 ms, le temps que tout se stabilise.
4. Un seul sendall() des 30 octets manquants, END_STREAM = true.
5. Le kernel coalesce le tout en UN segment TCP.
6. Le serveur démultiplexe → exécute les 30 handlers en parallèle
   → tous voient promoApplied = false → les 30 décréments s'empilent.

Voici un exemple pour solve :

Certains utilisateurs ont également solve en groupant la même requête dans une même tab groupée sur Burp, puis ont envoyé en mode SPA depuis Burp et ça a également solve :

On obtient le second flag STHACK{R4c3_W1nd0w_0p3n_-_P4id_N3g4tive_Eur0s} et un nouveau JWT.

On a l’indice également : il faut aller sur /ticket/finalize.

Mais « L’audit anti-fraude a néanmoins levé un drapeau "payment_anomalous" sur votre reçu. Présentez ce reçu à /ticket/finalize pour finaliser votre réservation. »

Étape 3 : Sthack 2046 (3/4)

Ce qu’on a en arrivant sur finalize :

Le JWT qu’on a au début, en allant sur jwt.io :

On a donc effectivement payment_anomalous en statut.

Le front est explicite : pour débloquer la personnalisation du badge VIP, il faut un reçu status=verified_paid et tier=vip_custom.

RS256 interdit les raccourcis habituels : pas de alg:none, pas de HS256 bruteforçable. Il faut une vraie signature… ou détourner la confiance.

La faille : jku sans whitelist

Le header du JWT porte un paramètre jku (JWK Set URL) : l’URL où le serveur va chercher la clé publique pour vérifier la signature. Et le backend la suit aveuglément, sans la moindre whitelist.

Traduction : si on pointe jku vers notre JWKS, le serveur télécharge notre clé publique et valide un jeton signé avec notre clé privée. La chaîne de confiance est inversée.

Le fetch refuse les IP privées (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, loopback v6…). Il faut donc héberger le JWKS sur une IP publique.

Le script globalement pour solve :

J’expose sur http://144.91.117.28:8088/jwks.json :

On rentre :

On a donc le flag : STHACK{jku_1s_Tru5t_But_N0t_Wh1t3l1sted}

Étape 4 : Sthack 2046 (4/4)

On a un formulaire riche : nom d’équipe, slogan, citation, couleur d’accent, position du logo. Le backend forward ces champs à un service PDF (Flask + Jinja2 + WeasyPrint) qui génère une carte d’embarquement.

On a une erreur de base quand on veut générer :

La faille : f-string + Jinja2

Le template est construit par f-string dans pdf-svc/app.py :

BADGE_TEMPLATE_BASE = f"""... <h1>{team_name}</h1> ..."""

Nos champs deviennent donc du code source Jinja parsé ensuite. SSTI dans les règles de l’art. Sauf que rien n’est gratuit ici : il y a trois défenses.

Les trois défenses

1) Un WAF côté backend qui bloque le SSTI naïf au niveau HTTP :

const FORBIDDEN = [
  /\b__class__\b/i, /\b__mro__\b/i, /\b__subclasses__\b/i,
  /\b__globals__\b/i, /\b__builtins__\b/i, /\b__import__\b/i,
  /\bsubclasses\b/i, /\bmro\b/i, /\bglobals\b/i,
  /\bsubprocess\b/i, /\bpopen\b/i,
  /\blipsum\b/i, /\bcycler\b/i, /\bnamespace\b/i, /\bjoiner\b/i,
  /os\.\w+/i, /import\s+/i, /exec\s*\(/i, /eval\s*\(/i,
];

Un {{''.__class__.__mro__[1].__subclasses__()}} cumule 3 matches → 403.

2) Un sanitizer de sortie dans le service PDF, qui caviarde le flag dans la page rendue :

FLAG_REDACTOR = re.compile(rb"STHACK\{[^}]{1,128}\}")
html = FLAG_REDACTOR.sub(b"[REDACTED]", html)

Donc même un SSTI qui imprimerait le flag dans le PDF le verrait remplacé par [REDACTED]. Le canal de sortie « normal » est mort.

3) Mais la stack trace, elle, n’est pas passée au redactor. En cas d’erreur de rendu, le service renvoie traceback.format_exc() brut dans une réponse JSON 500. C’est notre canal d’exfiltration.

Bypass du WAF — la concat Jinja

Jinja évalue la concaténation ~ au runtime. Donc '__' ~ 'cl' ~ 'ass__' vaut '__class__' côté moteur, mais la chaîne envoyée ne contient jamais __class__ d’un seul tenant → le WAF (qui regarde le texte brut) est aveugle. Pareil pour subclasses, mro, Popen… On peut aussi jouer avec les escapes hex ('\x5f\x5fcl\x5f\x5f') que Jinja décode dans les string literals.

L’idée « Successful Errors » (Korchagin, 2025)

Plutôt que d’afficher le résultat, on le glisse dans une exception. Un dict vide {} sur lequel on appelle .pop(x) lève un KeyError dont le message contient x. Si x est le contenu de /flag.txt (lu via FileLoader.get_data), alors le KeyError qui remonte jusqu’au handler Flask embarque le flag — et la stack trace nous le ramène en clair.

Payload final

À placer dans le champ team_name (les autres champs restent normaux) :

{{ ({})|attr('pop')((''|attr('__'~'cl'~'ass__')|attr('__'~'ba'~'se__')|attr('__'~'subcl'~'asses__')()|selectattr('__'~'nam'~'e__','equalto','FileLoader')|list)[0]('x','/flag.txt').get_data('/flag.txt') ) }}

Décortiqué :

  1. on remonte à object via __class__.__base__ ;
  2. on liste ses sous-classes et on sélectionne FileLoader par son __name__ (pas par un index fragile) ;
  3. FileLoader.get_data('/flag.txt') lit le fichier ;
  4. ce contenu devient l’argument de {}.pop(...)KeyError avec le flag dedans.

Variante subprocess.Popen : possible si subprocess est importé dans le service cible (ce n’est pas le cas par défaut ici, donc cette voie échoue avec un list object has no element 0). À garder en tête pour d’autres cibles.

Et dans la stacktrace d’erreur on a le flag STHACK{Succ3ssfUl_3rr0rs_C0d3_1nj3ct10n_M4st3r}


Voilà pour le writeup complet, gg aux équipes qui ont pu tout solve ! Désolé si à certains moments je n’ai pas pu venir à certaines tables pour débugger ou donner quelques hints, j’étais également en cuisine sur cette édition pour couper le bon poulet pour les burgers !

J’espère que vous avez également aimé les autres challenges (OSINT, ipv6, réseau, web smuggling) :