NoBracketsCTF 2025 | Write-up de mes challenges
Le NoBracketsCTF est un CTF à destination des lycéens organisé par le club de CTF de mon école : GCC-ENSIBS, il se déroule en deux phases : qualifications (du 17 au 19 Octobre 2025, TODO équipes de 5 personnes participantes) et finale (de 10h15 à 17h15 le 19 Novembre 2025).
Voici un tableau récapitulant mes challenges, leur difficulté et leurs thématiques :
| Challenge | Tags | Difficulté |
|---|---|---|
| Décode-moi ! | Encodage | Introduction |
| Double Cipher | Oracle | Moyen |
| it’s complex | Gaussian Integers | Avancé |
| Roll the dice | OTP, Gaussian distribution | Moyen |
| We love backup | Compression, ChaCha20 | Avancé |
Challenges pour les qualifications en ligne
Décode-moi !
Difficulté : Introduction
Description :
Es-tu capable de décoder cette information secrète ?
Le challenge fournissait deux fichiers :
chal.py:
1
2
3
4
5
6
7
from os import getenv
from base64 import b85encode
FLAG = getenv("FLAG", "NBCTF{censuré}")
FLAG = FLAG.encode()
print(b85encode(FLAG).hex())
output.txt:
1
50436052654d746b437a6f2474376840536d69555660564c465770697b626141392a54574d775545564a257e4664326e3d5a4570315f476231685f4c456f5e304f567b633f28584a7666
Nous pouvons voir que le code source effectue deux opérations sur le FLAG :
- Un changement de base allant de la base 256 (chaîne d’octets) vers la base 85.
- Ensuite, il interprète cette base 85 comme une chaîne d’octets (i.e. la fonction b85encode renvoie une chaîne d’octets) pour ensuite l’encoder en base 16 : chaque octet est converti en deux nombres hexadécimaux.
Comme l’indique le titre, il n’y a pas de cryptographie dans ce challenge, c’est juste de l’encodage ! Nous pouvons alors inverser toutes ces opérations, il suffit d’exécuter les opérations inverses.
Voici comment procéder en Python :
1
2
3
4
5
6
7
8
9
10
11
from sys import argv
from base64 import b85decode
if len(argv) != 2:
print("Usage : python solve.py <path du fichier output.txt>")
with open(argv[1]) as out:
flag = bytes.fromhex(out.read())
flag = b85decode(flag)
print(flag.decode())
Nous obtenons le flag suivant : NBCTF{☝️🤓ce-nest-pas-de-la-crypto-mais-de-lencodage}.
Double Cipher
Difficulté : Moyen
Description :
Mon ami n’utilisait qu’un cipher pour chiffrer ses données, quel looser ! J’ai rajouté un deuxième cipher afin d’améliorer la sécurité 🤌
Code source
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from os import getenv, urandom
FLAG = getenv("FLAG", "NBCTF{censuré}")
FLAG = FLAG.encode()
def first_cipher(pt, key):
return bytes([(p^k)for p, k in zip(pt, key)])
def second_cipher(pt, key):
return bytes([(p*k) & 0xff for p, k in zip(pt, key)])
def main():
while True:
_ = input(".")
key = urandom(len(FLAG))
ct_0 = first_cipher(FLAG, key)
ct_1 = second_cipher(FLAG, key)
print(ct_0.hex() + ct_1.hex())
main()
Résolution du challenge
Les deux chiffrements mis en oeuvre dans ce challenge sont les suivants :
- Un XOR (noté $\oplus$), octet par octet, entre la clef et le plaintext.
- Une multiplication modulo 256, octet par octet, entre la même clef et le même plaintext.
Notons $K$ la clé et $K_i$ son i-ème octet ; idem pour $P$, le plaintext.
En connaissant le ciphertext, nous connaissons donc $P_i \times K_i \pmod{256}$ ainsi que $P_i \oplus K_i$.
Nous pouvons nous poser la question suivante : que se passe t-il si $K_i$ vaut 0 ? Réponse : le produit vaudra 0 lui aussi, nous le détecterons donc directement dans le chiffré. De plus, $A \oplus 0$ vaut toujours $A$, nous savons donc que lorsque nous détectons un résultat nul pour la multiplication, alors le chiffré par l’opération XOR est le plaintext lui-même.
Nous pouvons appliquer cette logique en boucle car le script intègre un oracle de chiffrement dans une boucle while True. Cela nous permet de reconstruire $P$ entièrement.
A noter que nous aurons parfois des faux positifs. Cela est dû au fait que l’anneau $\mathbb{Z}/256\mathbb{Z}$ n’est pas intègre : il possède des diviseurs de zéro.
Une implémentation complète de cette solution est disponible dans le fichier solve.py.
it’s complex
Difficulté : Avancé
Description :
J’ai dérivé ma clé privée en utilisant les opérations les plus complexes que je connaissais… Impossible de retrouver la clé privée depuis la clé publique, n’est-ce pas ?
Présentation du challenge
Le challenge tourne autour d’un mécanisme de dérivation de clé privée (notons la $y$) vers clé publique (notons la $z$).
Notons que ces éléments sont des éléments de $\mathbb{Z}/p\mathbb{Z}[i]$ tel que $i^2 = -1$ (notons ce corps $\mathbb{K}$). Nous pouvons appeler ça des Entiers de Gauss modulo p (bref, des nombres complexes mais modulo $p$).
Cette dérivation de clé consiste en (cf. code source ci-dessous):
- Une homothétie sur la norme de $y$ ($y$ devient $y^{(1)}$). Son rapport varie entre $1$ et $1337-1$.
- Des multiplications de $y^{(2)}$ par des puissances de $i$ ($y^{(1)}$ devient $y^{(2)}$).
- Des multiplications de $y^{(2)}$ par des nombres “spéciaux” ($y^{(2)}$ devient $y^{(3)}$).
- Finalement, $z = \alpha y^{(3)} + \beta$
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def derive_pub(y):
# First step
scale = rng.randrange(1, 1337)
y *= GaussianInteger(scale, 0)
# Second step
i = GaussianInteger(0, 1)
i_s = [i**j for j in range(1337)]
specials = [get_special(GaussianInteger) for _ in range(1337)]
for _ in range(rng.randrange(1337)):
y *= rng.choice(i_s)
y *= rng.choice(specials)
# Third step
return α*y + β
La fonction to_bytes
Il est important de remarquer que lorsque la clé est convertie en bytes (par la fonction .to_bytes), les opérations suivantes sont effectuées :
1
2
3
4
5
def to_bytes(self):
s = pow(self.real, 2, self.p) + pow(self.imag, 2, self.p)
if s > self.p: s -= self.p
return s.to_bytes((s.bit_length() + 7) // 8)
Le nombre est donc mappé vers une suite d’octet correspondant à la norme du nombre (plutôt le carré de sa norme, mais bon, c’est “pareil” dans notre contexte).
Des nombres “spéciaux” ?
Voici le code générant les nombres dit “spéciaux” :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_special(GI):
p = GI.p
while True:
a = rng.randrange(p)
maybe_b_squared = (1 - pow(a, 2, p)) % p
is_it_a_square = pow(maybe_b_squared, (p-1)//2, p)
if is_it_a_square != 1:
continue
b_squared = maybe_b_squared
b = pow(b_squared, (p+1)//4, p)
return GI(a, b)
Nous constatons qu’il génère des éléments de $\mathbb{K}$ de la forme : $a + b*i$ tel que $\sqrt{a^2 + b^2} = 1$. En effet, lorsque nous sommes modulo $p$ avec $p \equiv 3 \pmod{4}$ :
- Nous vérifions l’existence d’une racine carré (appelée résidu quadratique) en calculant le symbole de Legendre (
is_it_a_square = pow(maybe_b_squared, (p-1)//2, p)) - La racine carré se calcule en faisant puissance
(p+1)/4.
Ce script génère donc des éléments de $\mathbb{K}$ ayant une norme égale à 1.
Des rotations, et encore des rotations
Le coeur du challenge est de comprendre que les étapes 2 et 3 n’ont aucun impact sur la sortie de to_bytes. En effet :
- La multiplications par i consiste en une rotation sur le plan complexe : ceci n’a aucun impact sur la norme du nombre.
- La multiplications par un complexe de module 1 n’a aucun impact sur la norme du nombre (preuve laissée au lecteur ; hint : passer à la forme exponentielle).
Pour résoudre ce challenge, il suffit donc de retrouver $y^{(3)}$, car : $y^{(3)}$.to_bytes() == $y$.to_bytes().
Inversion de $z = \alpha y + \beta$
Nous pouvons juste soustraire $\beta$ à $z$ puis multiplier le résultat par l’inverse de $\alpha$. La vraie difficulté ici est de trouver l’inverse d’ $\alpha$ , voici une méthode :
Soit $u = a + bi$ un élément de $\mathbb{K}$, on cherche $v = c + di$ tel que $uv = 1$.
\((a + bi)(c + di) = 1\)
\((c + di) = \frac{1}{(a + bi)}\)
\((c + di) = \frac{(a - bi)}{(a + bi)(a - bi)}\)
\((c + di) = \frac{(a - bi)}{a^2 - abi + abi - b^2i^2}\)
\((c + di) = \frac{(a - bi)}{a^2 + b^2}\)
\((c + di) = (a - bi) \times (a^2 + b^2)^{-1}\)
Nous avons obtenu notre formule pour l’inverse d’un élément, nous pouvons donc faire notre “division” par $\alpha$ (qui est ici une multiplication par l’inverse).
Déchiffrement du secret
Nous pouvons maintenant bruteforce l’homothétie de départ afin de retrouver le flag :
1
2
3
4
5
6
7
for i in range(1, 1337):
ii = pow(i, -1, p)
secret = (sk * GaussianInteger(ii, 0)).to_bytes()
flag = dec(ct, secret)
if b"NBCTF" in flag:
print(flag)
Une implémentation complète de la solution est disponible dans le dossier WU du GitHub.
Challenges pour la finale en présentiel
Roll the dice
Difficulté : Moyen
Description :
Promis, mes dés ne sont pas pipés 🎲
Analyse du code source
Ce challenge de cryptographie expose un oracle de chiffrement sur le flag. Le flag est chiffré en utilisant le One Time Pad (OTP) :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from os import getenv
from mac import mac
from rng import rng
from string import ascii_uppercase
alphabet = ascii_uppercase + "É{}-"
assert len(alphabet) == 30
FLAG = getenv("FLAG", "NBCTF{CENSURÉ}")
RND_CONSTS = [rng() for _ in range(rng())]
def encrypt(pt):
ct = ""
for char in pt:
c = alphabet.index(char)
c = c + rng()
ct += alphabet[c % len(alphabet)]
ct += mac(pt).hex()
return ct
print("Bonne chance :)")
while True:
_ = input("")
enc = encrypt(FLAG)
print(enc)
C’est bien connu (cf. Wikipedia/OTP) que l’OTP est sécurisé ssi :
- The key must be at least as long as the plaintext.
- The key must be truly random.
- The key must never be reused in whole or in part.
- The key must be kept completely secret by the communicating parties.
Toutes les conditions ont l’air d’être respectées par le challenge, sauf la 2, nous devons vérifier l’implémentation de la fonction rng afin d’en être sûr.
La fonction rng
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from secrets import randbelow
def dice():
# Create a Secure Dice Implementation Using Python's `secrets` Library.
# This library utilizes a Cryptographically Secure Pseudorandom Number Generator (CSPRNG),
# ensuring the randomness is reliable and secure.
# This method leaves no room for vulnerabilities.
return randbelow(6)
def rng():
# To create secure random data mod 30, we need 5 dice.
# Indeed, 1 die => data in range [0 ; 6[
# 5 dice => data in range [0 ; 30[ (= [0*5 ; 6*5[)
# => no random bias, full secure.
NB_DIE = 30 // 6
return sum(dice() for _ in range(NB_DIE))
La fonction dice renvoie un nombre tiré aléatoirement dans l’ensemble $[0 ; 5]$ (une sorte de dé à 6 faces commençant à zéro). Pas de problème de ce côté, cette dernière utilise la library Python secrets fournissant de l’aléatoire cryptographique (contrairement à la library random qui implémente un Mersenne Twister).
Ensuite, la fonction rng effectue la somme de plusieurs dés afin de générer un nombre compris entre 0 et 30. Et là… c’est le drame.
Pour que le One Time Pad soit secure, nous avons besoin d’une distribution uniforme. Or, il est évident que la somme de dés ne fournit pas une telle distribution mais plutôt une distribution gaussienne.
Exploiter la distribution gaussienne
L’idée à retenir est qu’une distribution gaussienne a la forme d’une cloche : certains octets seront donc tirés plus souvent que d’autres. Nous pouvons alors procéder ainsi :
- Récupérer de nombreuses fois le flag chiffré.
- Pour chaque octet du chiffré :
- analyser quel octet apparaît le plus souvent ;
- en déduire que cet octet a été chiffré avec la clé la plus fréquente ;
- calculer le déchiffrement de cet octet à l’aide de cette clé.
Et hop, nous obtenons le flag.
Cependant, dans notre cas, la gaussienne n’est pas très « pointue ». En d’autres termes, le top 5 des octets les plus fréquents n’est pas clairement distinct (voir l’analyse dans solve.py). Nous devons donc effectuer un léger bruteforce en testant les 5 octets les plus probables ; le MAC associé nous permet d’obtenir une condition d’arrêt pour notre bruteforce.
L’implémentation complète de cette approche est disponible dans le fichier solve.py.
We love backup
Difficulté : Avancé
Description :
Tiens, tu veux tester mon site web ? On peut même faire des sauvegardes ! ⚠️ Attention : le script final peut prendre 5-10 minutes à trouver la solution ; je te conseille donc de tester en local (utilise le Dockerfile) et de run en remote uniquement quand ton script fonctionne en local.
Analyse du site web
Ce challenge expose un site web : 
Après un rapide tour des fonctionnalités du site et une analyse des différents fichiers sources, nous retenons que :
- d’après le
Dockerfile, le fichierflag.txtse trouve dans le dossier/upload; - nous pouvons téléverser des fichiers dans le dossier
/upload; - nous pouvons télécharger une sauvegarde chiffrée de l’intégralité du site.
La sauvegarde et son chiffrement fonctionnent de la manière suivante, pour chaque dossier (c’est‑à‑dire static, templates et uploads) :
- le script lit tous les fichiers, les ouvre et concatène leur contenu dans un buffer ;
- il compresse ce buffer dans un second buffer compressé ;
- enfin, il chiffre ce buffer compressé et le renvoie comme fichier à l’utilisateur.
La vulnérabilité
Quelques prérequis
Pour comprendre la vulnérabilité, il faut d’abord saisir la différence entre chiffrement par flot et chiffrement par bloc :
- Chiffrement par flot : les données de n’importe quelle taille peuvent être chiffrées, et l’on a
len(plaintext) == len(ciphertext). - Chiffrement par bloc : les données doivent correspondre à la taille d’un bloc pour être chiffrées. Si ce n’est pas le cas, on ajoute du padding. On obtient donc
len(plaintext) < len(ciphertext).
Dans notre cas, le système de sauvegarde utilise ChaCha20. Il s’agit d’un chiffrement par flot : la taille de la sauvegarde chiffrée est donc exactement égale à celle de la sauvegarde en clair.
Un autre prérequis est d’avoir une intuition du fonctionnement des algorithmes de compression tels que gzip. En particulier, il faut comprendre que la taille de gzip.compress(b"A"*100) sera inférieure à celle de gzip.compress(urandom(100)) : plus l’entropie est faible, plus la compression est efficace. Autrement dit, si la donnée à compresser contient des motifs répétitifs, la compression sera meilleure.
Exploitons‑la
Souvenons‑nous que nous pouvons téléverser des fichiers sur le serveur, puis télécharger la sauvegarde associée. Autrement dit, nous pouvons observer l’influence de notre fichier sur la taille finale de la sauvegarde compressée.
Imaginons que le flag soit : NBCTF{ABCDEFGH}. Supposons que nous téléversions :
- un fichier contenant
NBCTF{A, puis téléchargions la sauvegarde ; - un fichier contenant
NBCTF{B, puis téléchargions la sauvegarde.
Dans quel cas la sauvegarde sera‑t‑elle la mieux compressée ? Dans le cas n°1 ! Cela signifie que nous pouvons déterminer le premier caractère du flag en observant laquelle des deux sauvegardes est la plus petite. On peut ensuite réitérer l’opération caractère par caractère pour reconstruire l’intégralité du flag.
Une implémentation complète de cette approche est disponible dans le fichier solve.py.
