404CTF 2026 - Crypto Write-Ups
Welcome back 👋 ! In this new post, I’ll again present my write-ups for all the challenges listed in the crypto category that I’ve solved, this time for 2026 edition of the 404CTF, ordered by difficulty rating.
Another Write-Up ?
You can find the write-up for Pas functional encryption in a dedicated blog post
Those challenges are :
- Dur à CERNer
- Déjeuner à l’ANSSI
- Too big or too small
- 2B or not to be
- Le Tour de RSA en quatre-vingts seize jours
- What the hellman
- Pas très discret
- CHAesT
Note
You can find the original sources for all the challenges in the official GitHub repository of the event.
Here is the difficulty rating legend:
| Symbol | Difficulty | Base Points |
|---|---|---|
| 😇 | Intro | 100 |
| 🙂 | Easy | 500 |
| 😑 | Medium | 500 |
| 😡 | Hard | 500 |
| 😈 | Insane | 500 |
Information
The base points represent the points that were valued for challenges at the beginning of the CTF, before any solves.
Dur à CERNer
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 😇 | 478 |
Description
Le CERN tente une nouvelle manière d’obtenir des collisions de particules en les envoyant une par une. Si cette méthode marchait, cela permettrait de drasiquement réduire le coût des opérations ! Malheureusement, les chances d’obtenir une collision semblent astronomiquement faibles…
nc challenge.404ctf.fr 10007
Code
The following source code was provided :
| |
After a quick observation, we see that the server asks for two hexadecimal inputs that should be different, then calculates the SHA256 hash for each. If their hashes are equal, you get the flag !
At first sight, it looks like we have to find a hash collision on SHA256. Now, there are two things:
- First, to my knowledge and the internet’s knowledge, there is no known hash collision on SHA256 as of today.
- This is an introductory challenge and the first of this category.
So we’re either about to make a breakthrough in cryptanalysis, or there is something fishy here. As you’ve probably guessed, it’s not the first option.
Solving
The only function that is applied to our inputs before hashing is bytes.fromhex(), and the equality check for the inputs happens before this transformation.
So my guess is that there must be a way to pass two different inputs to this function that will result in the same output (bytes).
I’m used to Python, but I don’t know any way to do this \(\dots\) It’s therefore time to read the documentation.
Now, if we read carefully, we will find:
This bytes class method returns a bytes object, decoding the given string object. The string must contain two hexadecimal digits per byte, with ASCII whitespace being ignored.
What this means is that the two inputs aaaa and aa aa will output the same thing. And indeed, this is the information we need to solve this first challenge.
particule_a = "aa aa"
particule_b = "aaaa"
assert particule_a != particule_b # True
assert bytes.fromhex(particule_a) == bytes.fromhex(particule_b) # TrueHere is the final script to solve this challenge :
| |
404CTF{P4rt1cl35_g0_brrrrrrrrr!}
Déjeuner à l’ANSSI
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 🙂 | 338 |
Description
Pendant la pause déjeuner, vous vous êtes introduits à l’ANSSI et avez trouvés une machine dont le but est de chiffrer des données privées. Malhereusement pour vous, un filtre empêche les données les plus sensibles d’être leakées. En plus, il faut vous dépêcher avant que les cryptologues reviennent : vous n’avez le temps de faire qu’un seul essai. Saurez vous récupérer le flag ?..
nc challenge.404ctf.fr 10005
Code
The following source code was provided :
| |
This server implements a simple RSA cryptosystem and, upon connection, gives us the public key \((N, e)\), the encrypted flag \(c\), and a decryption oracle \(D\).
The challenge here is that because of the following check, we can’t directly decrypt the flag \(f\) (it would be too simple, not much of a challenge otherwise 🙃):
| |
Solving
To solve this challenge, we’re going to use a technique called masking that makes use of the multiplicative homomorphic property of RSA.
Indeed, for a chosen number \(k\), we have:
\[ D(k \cdot c) = D(k) \cdot D(c) \]Note
You can check my notes on RSA if you want to know the details of why this is true.
Because of that, we can send to the decryption oracle the number \((k^e \mod N) \cdot c\), which we can calculate, and the server will send us:
\[ \begin{aligned} D((k^e \mod N) \cdot c) &= D(k^e \mod N) \cdot D(c) \\ &= k \cdot f \end{aligned} \]Now, because we know \(k\), we can calculate \(k^{-1} \mod N\) and recover the flag by calculating:
\[ \begin{aligned} &k^{-1} \cdot D((k^e \mod N) \cdot c) &\mod N \\ = \; &k^{-1} \cdot k \cdot f &\mod N \\ = \; &f &\mod N \end{aligned} \]Here is the final script to solve this challenge :
| |
404CTF{Luncht1m3_4tt4ck_b35t_4tt4ck}
Too big or too small
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 🙂 | 170 |
Description
J’ai pas suivi mes cours de maths, la loi des grands nombres cite que plus le nombre est grand plus c’est compliqué à cracker c’est ça ?
Code
The following source code was provided :
| |
{"ct": [...], "N": [...], "e": 65537}Again, we’re facing an RSA cryptosystem, but this time, the modulus \(N\) is the product of 4000 128-bit prime numbers, and is therefore VERY large.
Solving
To understand how to solve this challenge, I send you to my notes on this exact problem.
The key insight here is that by taking the 4000-th root of \(N\), we get a very good approximation of one of the factors of \(N\). From there, we can use the function nextprime() used in the source file to recover one of the prime factors of \(N\). Now that we know one, it is trivial to find all the others.
Once we’ve found all the factors of \(N\), we can calculate \(\phi(N)\) as usual. But if you do so, you’ll calculate a very large \(d\), and the decryption process will take FOREVER. So how can we accelerate things ?
The trick here is that if we calculate a new number \(N_1\) as the product of a subset of the factors of \(N\) such that \(f < N_1\) (with \(f\) being the flag), then the decryption process works equally well by calculating \(\phi(N_1)\) and \(d = e^{-1} \mod \phi(N_1)\) according to those subset factors.
The question is now: how many factors should you take? You can just try all possibilities one by one, or, in this case, because the flag format and length are given, you can take as many factors as needed until the bit length of \(N_1\) is greater than the bit length of the potential flag.
Here is the final script to solve this challenge :
| |
404CTF{uN_c3rvEau_v4ut_M!lL3_c4rTe5_GraPhIqU3S}
2B or not to be
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 🙂 | 222 |
Description
Inspiré par les travaux de Blaise Pascal sur la statique des fluides, votre mentor a conçu un système cryptographique en quête d’un équilibre parfait. Pour garantir la robustesse de ses clés, il a instauré cinq règles rigoureuses que chaque bit doit satisfaire. Il est convaincu que cette complexité structurelle rend son secret inviolable. Montrez lui que vous n’êtes absolument pas démuni face à sa logique.
Code
The following source code was provided :
| |
868286f1e6f4c9e182dff7c683ffd796ed80eddfe7f186ed83c1ed8582fdedffc7f1dacfSolving
This one was very easy for me because I was lazy. I knew it was just a XOR, and I didn’t want to read nor understand the verify_all() function, so the first thing I did was to XOR the known part of the flag (404CTF{, given by the flag format of the event) with the given ciphertext to see if I could spot a pattern. And indeed, if you do so, you find repeating b2 bytes.
| |
So I just tried to decrypt using a long chain of these same bytes to see if it would successfully decrypt the ciphertext, and I was indeed correct.
Observation
If you were literally a genius, you could have realized that the key was even given in the name of the challenge, just reversed.
Here is the final script to solve this challenge :
| |
404CTF{S0mEt1Me$_2_mUC4_1s_70O_MuCh}
Le Tour de RSA en quatre-vingts seize jours
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 🙂 | 141 |
Description
Votre ami Jules Verne vous a envoyé de façon “sécurisée” une partie de son nouveau roman six fois pour être certain que vous le receviez entièrement. Peut-être que la science-fiction n’égale pas la science en sécurité…
Code
The following source code was provided :
| |
moduli = [...]
ciphers = [...]Again, RSA, but one thing should strike your attention if you’re familiar with it: the value of the parameter \(e = 96\), because:
- It is rather small (compared to the more usual \(65537\)).
- And it is even \(\dots\) not odd, so it is probably not coprime with \(\phi(N)\) (and indeed it isn’t, as we’ll see), hence we can’t calculate \(d\) to decrypt the ciphertexts.
Solving
One first important observation that we can make is that two consecutive moduli share a common prime factor \(p\), so we can recover the two prime factors of both moduli by taking their gcd.
Now that we know the factors of \(N\), we can calculate \(\phi(N)\) as usual, but as stated before, we can’t calculate \(d\) because \(gcd(e, \phi(N)) \neq 1\).
From that knowledge, we compute a new \(\phi_1\) by repeatedly dividing \(\phi\) by the common factors between \(\phi\) and \(e\) until we get \(\phi_1\) coprime to \(e\).
Using this new value \(\phi_1\), what we can do is find the \(e\)-th roots of unity and calculate a “partial” private exponent \(d = e^{-1} \mod \phi_1\).
Some math knowledge
A root of unity in this case is any number \(r\) such that \(r^e \equiv 1 \mod N\).
Using \(d\), we can “partially” decrypt \(c_1\) by calculating:
\[ f_p = c_1^d \mod N \]\(f_p\) is probably not the correct plaintext, or the one we’re looking for, but using the roots of unity, we can calculate:
\[ f_p \cdot r_i \mod N \quad \forall i \in \mathbb{N}^{*} \]until we find the correct plaintext, which we can easily check because we know that it contains the 404CTF{
string once converted from number to bytes, due to the flag format of the event.
Here is the final script to solve this challenge :
| |
404CTF{96_jours_cest_vraiment_trop_long_et_surtout_pas_premier}
What the hellman
| Points | Difficulty | Solves |
|---|---|---|
| 100 | 🙂 | 120 |
Description
Descartes et Fermat se sont fait reverse regressioned dans le présent mais ne maîtrisent pas encore toutes les technologies modernes. Montrez leur que ce n’était pas une bonne idée d’utiliser des mots de passes pour le protocole Diffie-Hellman. P.S : Les mots de passes ont été générés avec os.urandom.
Code
The following source code was provided :
| |
Descartes envoie les paramètres ainsi que sa clé publique : {"p": "0xcd08773b828bb3e7c6d497cdb10190b2b1160bee5a49e50cf8762bb974ca15c9b36748f8adc4ec2a2015c1d3cd29d6ef0a6877f3b47a1a8c2cd7a6eb34f912087720e1170a367033c60a2e217", "g": "0x2", "D": "0x901e8aaa158492419c319d541f49f3f43db529681b201423ff46b1a90e38108ae95b5acaac8946fd816f06feabec4a0bca035812d765e60960290dc782f01209272589ab4f7ef8ddad77eba3a"}
Fermat envoie sa clé publique : {"F": "0x9209b8bf4ab54fa06f3ac4bbf5e17541415250e142b8572d2dafe6bd5e18c1e1849cbf2c9d7d902fd6943c878cbc3a2f34fc501e28fc86763db288af8999d8ec39fb967e98089f2b62644933d"}
{"iv": "0cbdc451ad48c5529d4adab554a1e1e0", "encrypted_flag": "77a7543d73bd3b2c622e7f5346242caa4892e629ea0dc2cb84cf8481786d58de"}This time we’re facing a Diffie–Hellman Key Exchange used to generate a shared secret that serves as the key to encrypt the flag. The goal is therefore to find a way to solve the Discrete Logarithm Problem to recover the shared secret and then decrypt the flag.
Solving
The important observation we have to make here is about how the secret coefficients \(f\) and \(d\) are generated, using a hash function, and a different one for both. \(d\) is generated by converting to a number the output of a SHA256 hash, whereas \(f\) uses SHA1.
The main difference that we find between those two hash functions is their output length, which is 256 bits for SHA256, while it is only 160 bits for SHA1.
Now, from the challenge, we know that the prime used is a 256-bit number, so it means that our secret exponent \(f\) is many orders of magnitude smaller than the order of the group we’re operating in. This is the flaw we’re going to exploit.
The name of the challenge and some research on the internet should point you towards the Pohlig–Hellman algorithm, which is powerful for solving the DLP when \(p-1\) is a smooth number.
More precisely in this challenge, we’ll use a variant of this algorithm. Because \(f\) is “small”, we can solve the DLP in a subgroup, as long as \(p - 1\) has enough “small” factors.
Using this very great Integer factorization calculator, you can quickly get all “small” factors of \(p-1\), and if we take their product, we get a 229-bit number \(n\):
\[ n = \prod_{p_i \in P} p_i \]factors = [2, 11, 13, 43, 109, 409, 499, 541, 907, 2579, 2887, 3607, 3847, 4373, 5737, 23819, 27551, 34361, 356561, 457673, 555767]
x = 1
for e in factors:
x *= e
print(x.bit_length()) # 229which is a large enough subgroup to solve our DLP for \(f\) because, as we’ve said earlier, \(f\) is only of the order of 160 bits.
One last problem ?
The thing is that, from what I’ve tested and depending on your implementation of this algorithm, if we take this value of \(n\), we won’t get the correct value of \(f\) because the size of th subgroup is too much bigger than the value of \(f\).
The trick is therefore to take a subset of these factors such that their product is as close as possible to the maximum order of \(f\) while still being bigger. One good approach, and the one I took, is to progressively discard the smaller factors until you get there.
Note
In this case, I’ve found that discarding only the two first factors was enough for this implementation.
But to respect and get as close as possible to the order of \(f\), you should discard the first 10 factors.
Here is the final script to solve this challenge :
| |
404{sM0otH_w4y_oF_s0lV!ng}
Pas très discret
| Points | Difficulty | Solves |
|---|---|---|
| 162 | 😑 | 80 |
Description
Pour sécuriser les communications stratégiques des chercheurs de l’institut, un développeur un peu trop confiant a mis au point VaultChat, une application de messagerie à première vue solide mais qui cache plus d’un flop. À vous d’enchaîner de petites pirouhettes pour lui montrer l’étendu de son erreur.
nc challenge.404ctf.fr 10006
Code
The following source code was provided :
| |
This challenge implements a custom random number generator that uses Python’s standard PRNG under the hood.
The goal is to make use of the infinite loop and the given outputs to aggregate information until you have enough to be able to predict the next \(2047\)-bits output of this custom generator, which will then give you the flag if correct.
At the beginning of the program, we’re given the prime \(p\) and the base \(m\).
Solving
I know from my notes on the subject (and previous experiences) that it is possible to predict the output of the
Python standard PRNG given 624 consecutive 32-bit outputs, which is what the custom
class do by calling getrandbits(32).
| |
The server lets us generate as many outputs as we want with a size we choose between 8 and 2047 bits. The tricky part is that the values are not directly given to us but are “masked” as a discrete logarithm problem, with \(p\) a strong prime.
| |
It means that if we query the server for 32-bit outputs, we can’t solve the DLP and hence can’t access the hidden underlying generated random value.
The solution is simply to break down those words of 32 bits we need into 4 chunks of 8 bits. So we ask the server to generate 8-bit random values, and it sends us:
\[ m^e \mod p \]Now, because \(e\) is only 8 bits, we only have 256 numbers to test to find the correct one. Doing this 4 consecutive times gives us one complete 32-bit word.
The strategy to solve this challenge is:
- Repeat the above 624 times, by querying the server \(624 \times 4 = 2496\) times.
- Use the 624 32-bit outputs to recover the initial state of Python’s standard PRNG with known attacks.
- Simulate locally from this original state all the interactions you just had with the server to put your random generator in the same state as the server now.
- Ask the server to generate a 2047-bit value, generate it on your side, send it, and get the flag !
Improvement
Because \(m\) is created using the first \(2048\) output bits of the custom random generator, we can actually reduce our number of needed requests because \(m\) already gives us \(\frac{2048}{32} = 64\) of the 624 needed words.
Optimization
Because having that many exchanges with the server can take quite some time, I first send all the requests and data before I receive all the server’s responses at the same time.
I then parse all of it to extract what is needed before moving on to the last step.
# Utilities and functions to recover initial states of PRNG
# Taken from: https://github.com/StackeredSAS/python-random-playground/blob/main/functions.py | |
404CTF{L0g4r1thm3_d15cr3t_Scr3t}
CHAesT
| Points | Difficulty | Solves |
|---|---|---|
| 205 | 😑 | 74 |
Description
Pour sécuriser les communications stratégiques des chercheurs de l’institut, un développeur un peu trop confiant a mis au point VaultChat, une application de messagerie à première vue solide mais qui cache plus d’un flop. À vous d’enchaîner de petites pirouhettes pour lui montrer l’étendu de son erreur.
nc challenge.404ctf.fr 10006
Code
The following source code was provided :
| |
For this challenge, because there is quite some code and I’m lazy and don’t want to go through all of it in writing, I encourage you to take the time to read and understand this code as I will only point out the key points to solve it.
That being said, let’s get to it.
Solving
First things first, what we’re looking for (the FLAG) is in a chat session that is pre-made and stored by the server.
| |
To access it, we’ll need to forge a valid access token for this chat session.
We know that those tokens are cryptographically created using the session key, itself generated like so:
| |
If you test this line of code locally, you’ll find that it always evaluates to server_secret.
Knowing the secret key, we can now forge the correct token by reading how the server handles its format to give you access to the corresponding stored chat.
Once you get the stored chat session, its content is encrypted using AES-GCM with a random 32-byte key and a random 12-byte IV.
| |
The encryption key used is given in the plain data but encrypted with RSA.
| |
If we look more closely, the way the primes are generated for this RSA implementation seems weird, so we’ll focus our attention on this to try to recover the key.
| |
It first generates a prime \(p\) from a seed, then computes \(q\) as the next prime after \(p + M\) with \(M\) being a primorial, that is the product of the first 100 primes.
Because of that, we can express \(q\) as \(p + M + x\) for a small number \(x\), hence we have:
\[ \begin{aligned} N &= p \cdot q \\ &= p (p + M + x) \\ &= p^2 + pM + px \\ &= p^2 + p(M + x) \end{aligned} \]which we can rewrite as the following equation:
\[ \begin{equation} p^2 + p(M + x) - N = 0 \tag{1} \end{equation} \]Because \(x\) is rather small, we can increment it and solve the equation for \(p\) until we get \(p \mid N\). From there, we can easily recover \(q\), compute the RSA private key, and decrypt the encrypted encryption key.
Finally, to decrypt the content, we need to know the IV, and this information is not available anymore. But because we know the beginning of the encrypted content and the key used, we can recover the IV by XORing the AES-GCM encrypted content that we’re given with our own AES-ECB encrypted content of the known plaintext.
Once we know the IV, we just have to decrypt the flag !
Here is the final script to solve this challenge :
| |
404CTF{07aafdab5919b7a013b45466d0d18e87c29fa2e4953d767e55275ebc0c5396aa}
Final Word
In the end, I really liked the crypto challenges that were proposed this year, and I almost cleared the category this time, except for one challenge 😞, the hardest one, which was only solved by a few people.
Great CTF as always, looking forward to next year!