404CTF - Divers 404CTF - Divers

404CTF - Divers

Divers

5 Ronisés (Intro)

On va jouer à un petit jeu… Arriveras-tu à gagner contre ma génération hyper-sécurisée ?

Code Python fournit par le chall

Résolution

Dans le code fourni, on remarque un élément: la génération d’une seed via timestamp:

def set_seed():
seed(int(time()))

Le générateur pseudo-aléatoire est donc seedé avec l’heure actuelle. Le nombre secret n’est donc pas totalement imprévisible, et dépends du moment où le programme est lancé. Le reste du code est déterministe.

L’idée est donc de lancer une première fois le programme afin d’obtenir un résultat:

Terminal window
┌─[✗]─[gabrog@parrot]─[~]
└──╼ $nc challenge.404ctf.fr 10200
On va jouer à un jeu. Devine mon nombre secret et je te donne le flag !
> 0
Et non, mon nombre c'était : 71627049126117054643922701134319866539130929766361621550137411269792890719118

On peut donc essayer de bruteforcer les timestamps proches pour trouver la seed:

target = int(input("secret affiché = "))
now = int(time())
# On cherche dans une range de +/- 30s
for t in range(now - 30, now + 30):
seed(t)
s = genere_nombre_super_secret(8)
if s == target:
print("Seed trouvée : ", t)
break

Et en connaissant la seed, on peut recalculer le secret. L’idée est donc de prédire l’heure du prochain lancement, pour anticiper la seed, et générer le bon secret:

date = int(datetime.datetime(2026, 5, 16, 22, 51, 0).timestamp())
# On cherche dans une range de +/- 30s
for t in range(date - 1, date + 2):
seed(t)
s = genere_nombre_super_secret(8)
print("Secret = ", s)

Je génère ici 3 secrets: à t-1, t, et t+1 afin de pouvoir déterminer, si ma solution est incorrecte, quel était le timing.

Après quelques tentatives, je réussis à timer correctement le lancement du chall, et à obtenir le flag:

Terminal window
┌─[✗]─[gabrog@parrot]─[~]
└──╼ $nc challenge.404ctf.fr 10200
On va jouer à un jeu. Devine mon nombre secret et je te donne le flag !
> 103817526872077778480925424787095322347587124263041450339005552241472860670495
Wow ! Voici le flag : 404CTF{J0l1_T1m1ng!\}

Le flag est donc 404CTF\{J0l1_T1m1ng!\}


Super enQuête Libre (1/4) (Intro)

Vous êtes un enquêteur mandaté par Télématique NordRouan, une école réputée dans le domaine de la poterie, pour élucider une affaire de vol. Des pièces d’une valeur inestimable ont disparu et il est impératif de retrouver le coupable, sous peine de voir l’établissement fermer ses portes.

Pour mener votre enquête, vous obtenez l’accès à une base de données recensant l’ensemble des étudiants, professeurs et employés de l’école. Avant de vous confier l’affaire, l’école souhaite évaluer vos compétences. Votre première mission : déterminer quel badge Noel Laurent utilise actuellement. Format du flag : 404CTF{badge_noel_laurent} Exemple du flag : 404CTF{19}

Résolution

On se connecte donc sur un serveur SQLite.

On va lister les tables, et leur format:

Terminal window
sql> .tables
AccessLog Attendance Badge Building Course Person Room sqlite_sequence
sql> .schema
# Je ne garde volontairement que ce qui nous intéresse pour ce chall
CREATE TABLE Badge (
badge_id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER NOT NULL REFERENCES Person(person_id),
issue_date TEXT NOT NULL,
expiry_date TEXT NOT NULL,
active INTEGER NOT NULL CHECK(active IN (0, 1))
);
# [...]
CREATE TABLE Person (
person_id INTEGER PRIMARY KEY AUTOINCREMENT,
last_name TEXT NOT NULL,
first_name TEXT NOT NULL,
birth_date TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
status TEXT NOT NULL CHECK(status IN ('student', 'teacher', 'staff')),
class TEXT
);
# [...]

On a donc deux tables itéressantes: Badge et Person. Si on affiche le contenu de la table Person:

sql> SELECT * FROM Person;
+-----------+-----------+------------+------------+----------------------------+---------+-------+
| person_id | last_name | first_name | birth_date | email | status | class |
+-----------+-----------+------------+------------+----------------------------+---------+-------+
| 1 | Durand | Alice | 2003-09-15 | alice.durand@campus.fr | student | G1 |
| 2 | Simon | Baptiste | 2003-08-11 | baptiste.simon@campus.fr | student | G5 |
| 3 | Michel | Camille | 2003-03-07 | camille.michel@campus.fr | student | G2 |
[...]
| 28 | Dupont | Béatrice | 2003-11-18 | beatrice.dupont@campus.fr | student | G1 |
| 29 | François | Cédric | 2002-07-11 | cedric.francois@campus.fr | student | G1 |
| 30 | Garnier | Delphine | 2006-08-04 | delphine.garnier@campus.fr | student | G6 |
+-----------+-----------+------------+------------+----------------------------+---------+-------+
30 ligne(s)
(affichage limité à 30 lignes sur 223)

On a 223 enregistrements, et on sait que les noms et prénoms sont enregistrés avec majuscules. On peut donc faire une requête SQL pour chercher l’ID de Noel Laurent:

SELECT person_id FROM Person WHERE first_name = "Laurent" and last_name = "Noel";

Ce qui donne en résultat:

sql> SELECT person_id FROM Person WHERE first_name = "Laurent" and last_name = "Noel";
+-----------+
| person_id |
+-----------+
| 38 |
+-----------+

On peut donc chercher le numéro de badge dans la table badge (en filtrant uniquement sur les actifs):

SELECT badge_id FROM Badge WHERE person_id = 38 AND active = 1;

Le retour est:

sql> SELECT badge_id FROM Badge WHERE person_id = 38 AND active = 1;
+----------+
| badge_id |
+----------+
| 165 |
+----------+

Le flag est donc 404CTF\{165\}.

Super enQuête Libre (2/4) (Facile)

Vos compétences étant désormais reconnues, Télématique NordRouan vous confie officiellement l’enquête. Vous supposez que le coupable a effectué des repérages avant de passer à l’acte. Identifiez la dernière personne à avoir visité 4 salles différentes au cours d’une même journée. Format du flag : 404CTF{prenom_nom} Exemple du flag : 404CTF{noé_dupont}

Résolution

En ayant les schémas des tables d’après le chall précédent, on peut builder une requête SQL pour avoir la réponse rapidement:

SELECT
p.person_id,
p.first_name,
p.last_name,
al.date,
MAX(al.time) AS last_visit_time,
COUNT(DISTINCT al.room_id) AS nb_rooms
FROM AccessLog al
JOIN Badge b
ON al.badge_id = b.badge_id
JOIN Person p
ON b.person_id = p.person_id
GROUP BY
p.person_id,
al.date
HAVING COUNT(DISTINCT al.room_id) >= 4
ORDER BY
al.date DESC,
last_visit_time DESC
LIMIT 1;

Pour explications:

On débute de la table AccessLog qui contient les infos:

  • Quel badge
  • Dans quelle salle
  • A quelle date & heure

Depuis ce log, il faut retrouver la personne, donc à travers son ID de badge.

Jointure sur les badges, puis les personnes:

FROM AccessLog al
JOIN Badge b
ON al.badge_id = b.badge_id
JOIN Person p
ON b.person_id = p.person_id

On rajoute un select pour identifier la personne:

SELECT
p.person_id,
p.first_name,
p.last_name,

Puis il reste alors à compter le nombres de salles uniques visitées:

COUNT(DISTINCT al.room_id)

Sans oublier de grouper par personne, et par date (jour, pas heure):

GROUP BY
p.person_id,
al.date

Puis, on nous demande au moins 4 salles de visitées:

HAVING COUNT(DISTINCT al.room_id) >= 4

La requête aurait pu s’arrêter là, mais si on veut réellement un seul résultat à la fin, on peut trier par date et heure décroissante:

ORDER BY
al.date DESC,
last_visit_time DESC

Et pour finaliser le tout, on limite à un résultat:

LIMIT 1

La requête retourne:

+-----------+------------+-----------+------------+-----------------+----------+
| person_id | first_name | last_name | date | last_visit_time | nb_rooms |
+-----------+------------+-----------+------------+-----------------+----------+
| 63 | Noémie | Robert | 2026-05-04 | 19:19:57 | 4 |
+-----------+------------+-----------+------------+-----------------+----------+

Le flag est donc 404CTF\{noémie_robert\}.

Super enQuête Libre (3/4) (Extrême)

L’interrogatoire de ce suspect n’a rien révélé de concluant. Cependant, une enquête interne au sein de Télématique NordRouan révèle que le voleur aurait utilisé un badge dupliqué pour accéder à la salle au moment du vol. D’après cette même enquête interne, ce vol aurait eu lieu le 16 mai vers 19h, soit après le dump de la base des données. Retrouvez alors l’identifiant de ce badge. Pour vous aider, Télématique NordRouan met à votre disposition la carte du campus.

Format du flag : 404CTF{identifiant_badge}

Résolution

On cherche donc dans les AccessLog deux accès proches temporellement, mais impossible physiquement, pour commencer le 16 mai:

SELECT
al.badge_id,
al.room_id,
r.room_name,
b.building_name,
al.date,
al.time
FROM AccessLog al
JOIN Room r
ON al.room_id = r.room_id
JOIN Building b
ON r.building_id = b.building_id
WHERE al.date = "2025-05-16"
ORDER BY al.badge_id, al.time;

Sauf que, spoiler, l’énnoncé dit soit après le dump de la base des données. Donc notre date n’est pas dans la base! Il faut trouver ce même comportement suspect, dans les logs présents actuellement.

On va donc chercher à comparer la table AccessLog avec elle-même pour trouver les ID de badges, les heures, et les salles qui en découlent.

On considère a1 = un passage et a2 un autre passage du même badge.

SELECT
a1.badge_id,
a1.date,
a1.time,
r1.room_name,
a2.time,
r2.room_name
FROM AccessLog a1
JOIN AccessLog a2
ON a1.badge_id = a2.badge_id -- Le même ID de badge
JOIN Room r1
ON a1.room_id = r1.room_id -- On récupère l'ID de la room 1
JOIN Room r2
ON a2.room_id = r2.room_id -- On récupère l'ID de la room 2
WHERE a1.date = a2.date
AND a1.room_id != a2.room_id; -- On cherche les dates identiques, mais salles différentes

Cette requête retourne ceci:

+----------+------------+----------+-----------+----------+-----------+
| badge_id | date | time | room_name | time | room_name |
+----------+------------+----------+-----------+----------+-----------+
| 100 | 2025-09-15 | 17:00:04 | H12 | 17:00:04 | H12 |
| 98 | 2025-09-15 | 17:01:23 | H12 | 17:01:23 | H12 |
| 119 | 2025-09-15 | 17:01:33 | H12 | 17:01:33 | H12 |
| 90 | 2025-09-15 | 17:01:38 | H12 | 17:01:38 | H12 |

On remarque que les entrées sont dupliquées, et ceci est dû à la jointure sur AccessLog. On peut éliminer ce soucis avec rajout de AND a1.log_id < a2.log_id sur la jointure AccesLog a2:

SELECT
a1.badge_id,
a1.date,
a1.time,
r1.room_name,
a2.time,
r2.room_name
FROM AccessLog a1
JOIN AccessLog a2
ON a1.badge_id = a2.badge_id
AND a1.log_id < a2.log_id
JOIN Room r1
ON a1.room_id = r1.room_id
JOIN Room r2
ON a2.room_id = r2.room_id
WHERE a1.date = a2.date
AND a1.room_id != a2.room_id;

Le résultat est bien meilleur:

+----------+------------+----------+-----------+----------+-----------+
| badge_id | date | time | room_name | time | room_name |
+----------+------------+----------+-----------+----------+-----------+
| 183 | 2025-10-23 | 11:39:13 | G24 | 12:32:21 | B12 |
| 102 | 2025-11-05 | 13:31:35 | C33 | 13:33:45 | B12 |
| 157 | 2025-11-11 | 11:11:25 | G12 | 13:19:44 | C51 |
| 200 | 2025-12-04 | 09:49:56 | C01 | 13:41:55 | H03 |
| 211 | 2025-12-05 | 08:46:23 | C34 | 11:32:48 | E43 |

Mais on a toujours trop d’entrées: 1321. Il faut filter davantage pour essayer d’extraire le comportement suspect.

On peut donc essayer de chercher des comportements suspects en peu de temps, en filtrant sur une différence de temps courte pour le même badge.

Nos dates et heures sont stockées dans deux colonnes différentes. Il faut trouver un moyen de comparer ces éléments de façon simple: le timestamp.

On peut les build avec strftime, à condition d’avoir le bon format, et c’est le cas ici!

Par exemple, on pourrait avoir:

strftime('%s', a1.date || ' ' || a1.time)

pour récupérer la date et heure d’un log en timestamp.

Et si on a deux timestamp, alors on peut les comparer. Et si on peut les comparer, alors on peut déceler les comportements suspects!

On va donc rajouter cette comparaison de timestamp ABS(strftime('%s', a1.date || ' ' || a1.time) - strftime('%s', a2.date || ' ' || a2.time)) < 120 à notre requête (on prend 2minutes comme référenciel, on peut affiner si besoin).

SELECT
a1.badge_id,
a1.date,
a1.time AS time1,
r1.room_name AS room1,
a2.time AS time2,
r2.room_name AS room2
FROM AccessLog a1
JOIN AccessLog a2
ON a1.badge_id = a2.badge_id
AND a1.log_id < a2.log_id
JOIN Room r1
ON a1.room_id = r1.room_id
JOIN Room r2
ON a2.room_id = r2.room_id
WHERE a1.date = a2.date
AND a1.room_id != a2.room_id
AND ABS(
strftime('%s', a1.date || ' ' || a1.time)
- strftime('%s', a2.date || ' ' || a2.time)
) < 120
ORDER BY a1.badge_id, a1.date;

Ce qui retourne:

+----------+------------+----------+-------+----------+-------+
| badge_id | date | time1 | room1 | time2 | room2 |
+----------+------------+----------+-------+----------+-------+
| 114 | 2026-02-17 | 08:05:52 | C34 | 08:07:23 | C23 |
| 174 | 2026-03-17 | 09:47:59 | C14 | 09:49:12 | C02 |
| 134 | 2026-04-03 | 08:10:18 | G34 | 08:12:07 | G24 |
| 105 | 2026-04-03 | 17:10:14 | H05 | 17:11:24 | H11 |
| 218 | 2026-04-10 | 13:35:24 | G22 | 13:36:57 | G11 |
| 118 | 2026-04-24 | 15:19:40 | H22 | 15:21:22 | H32 |
+----------+------------+----------+-------+----------+-------+

Aucun comportement suspect ici, tout a l’air correct. On peut donc augmenter la durée (5minutes):

+----------+------------+----------+-------+----------+-------+
| badge_id | date | time1 | room1 | time2 | room2 |
+----------+------------+----------+-------+----------+-------+
| 102 | 2025-11-05 | 13:31:35 | C33 | 13:33:45 | B12 |
| 214 | 2025-12-11 | 15:21:38 | B05 | 15:23:56 | B23 |
| 117 | 2025-12-12 | 11:37:24 | D32 | 11:40:54 | E21 |
| 118 | 2025-12-18 | 09:51:27 | A22 | 09:53:47 | G22 |
| 158 | 2026-01-15 | 17:02:04 | F03 | 17:04:32 | E24 |
| 236 | 2026-01-30 | 11:31:49 | D11 | 11:35:01 | E25 |
| 248 | 2026-02-06 | 15:18:29 | C35 | 15:21:47 | A22 |
| 136 | 2026-02-06 | 15:24:53 | F03 | 15:27:21 | E33 |
| 166 | 2026-02-11 | 17:09:31 | B11 | 17:12:47 | G22 |
| 114 | 2026-02-17 | 08:05:52 | C34 | 08:07:23 | C23 |
| 90 | 2026-02-18 | 08:07:40 | C33 | 08:10:03 | B14 |
| 228 | 2026-03-11 | 11:33:04 | F03 | 11:35:15 | E22 |
| 174 | 2026-03-11 | 11:37:37 | F03 | 11:42:15 | F43 |
| 209 | 2026-03-11 | 11:39:50 | F03 | 11:42:17 | E31 |
| 174 | 2026-03-17 | 09:47:59 | C14 | 09:49:12 | C02 |
| 206 | 2026-03-20 | 17:04:05 | F32 | 17:06:29 | E42 |
| 102 | 2026-04-01 | 09:51:12 | H21 | 09:53:23 | H04 |
| 134 | 2026-04-03 | 08:10:18 | G34 | 08:12:07 | G24 |
| 105 | 2026-04-03 | 17:10:14 | H05 | 17:11:24 | H11 |
| 218 | 2026-04-10 | 13:35:24 | G22 | 13:36:57 | G11 |
| 238 | 2026-04-13 | 08:01:46 | C51 | 08:06:02 | C11 |
| 211 | 2026-04-14 | 17:07:13 | F14 | 17:09:30 | E42 |
| 232 | 2026-04-16 | 09:46:41 | C52 | 09:49:02 | B32 |
| 159 | 2026-04-16 | 09:48:46 | D22 | 09:51:54 | E24 |
| 118 | 2026-04-24 | 15:19:40 | H22 | 15:21:22 | H32 |
| 184 | 2026-05-04 | 17:01:45 | A23 | 17:05:50 | C43 |
+----------+------------+----------+-------+----------+-------+

On remarque que certaines entrées sont correctes: 2min de différence dans un même bâtiment n’est pas un comportement suspect. On peut retirer ces éléments:

JOIN Building b1
ON r1.building_id = b1.building_id
JOIN Building b2
ON r2.building_id = b2.building_id
[...]
WHERE a1.date = a2.date
[...]
AND b1.building_id != b2.building_id

Soit la commande SQL complète (qui commence à être conséquente):

SELECT
a1.badge_id,
a1.date,
a1.time AS time1,
r1.room_name AS room1,
b1.building_name AS building1,
a2.time AS time2,
r2.room_name AS room2,
b2.building_name AS building2
FROM AccessLog a1
JOIN AccessLog a2
ON a1.badge_id = a2.badge_id
AND a1.log_id < a2.log_id
JOIN Room r1
ON a1.room_id = r1.room_id
JOIN Room r2
ON a2.room_id = r2.room_id
JOIN Building b1
ON r1.building_id = b1.building_id
JOIN Building b2
ON r2.building_id = b2.building_id
WHERE a1.date = a2.date
AND a1.room_id != a2.room_id
AND b1.building_id != b2.building_id
AND ABS(
strftime('%s', a1.date || ' ' || a1.time)
- strftime('%s', a2.date || ' ' || a2.time)
) < 360
ORDER BY a1.badge_id, a1.date;

Qui retourne:

+----------+------------+----------+-------+-----------+----------+-------+-----------+
| badge_id | date | time1 | room1 | building1 | time2 | room2 | building2 |
+----------+------------+----------+-------+-----------+----------+-------+-----------+
| 102 | 2025-11-05 | 13:31:35 | C33 | C | 13:33:45 | B12 | B |
| 117 | 2025-12-12 | 11:37:24 | D32 | D | 11:40:54 | E21 | E |
| 118 | 2025-12-18 | 09:51:27 | A22 | A | 09:53:47 | G22 | G |
| 158 | 2026-01-15 | 17:02:04 | F03 | F | 17:04:32 | E24 | E |
| 236 | 2026-01-30 | 11:31:49 | D11 | D | 11:35:01 | E25 | E |
| 248 | 2026-02-06 | 15:18:29 | C35 | C | 15:21:47 | A22 | A |
| 136 | 2026-02-06 | 15:24:53 | F03 | F | 15:27:21 | E33 | E |
| 166 | 2026-02-11 | 17:09:31 | B11 | B | 17:12:47 | G22 | G |
| 90 | 2026-02-18 | 08:07:40 | C33 | C | 08:10:03 | B14 | B |
| 228 | 2026-03-11 | 11:33:04 | F03 | F | 11:35:15 | E22 | E |
| 209 | 2026-03-11 | 11:39:50 | F03 | F | 11:42:17 | E31 | E |
| 206 | 2026-03-20 | 17:04:05 | F32 | F | 17:06:29 | E42 | E |
| 211 | 2026-04-14 | 17:07:13 | F14 | F | 17:09:30 | E42 | E |
| 232 | 2026-04-16 | 09:46:41 | C52 | C | 09:49:02 | B32 | B |
| 159 | 2026-04-16 | 09:48:46 | D22 | D | 09:51:54 | E24 | E |
| 184 | 2026-05-04 | 17:01:45 | A23 | A | 17:05:50 | C43 | C |
+----------+------------+----------+-------+-----------+----------+-------+-----------+

On note ces deux lignes:

| 228 | 2026-03-11 | 11:33:04 | F03 | F | 11:35:15 | E22 | E |
| 209 | 2026-03-11 | 11:39:50 | F03 | F | 11:42:17 | E31 | E |

Les

SELECT a1.badge_id, a1.date, a1.time AS time1, r1.room_name AS room1, b1.building_name AS building1, a2.time AS time2, r2.room_name AS room2, b2.building_name AS building2 FROM AccessLog a1 JOIN AccessLog a2 ON a1.badge_id = a2.badge_id AND a1.log_id < a2.log_id JOIN Room r1 ON a1.room_id = r1.room_id JOIN Room r2 ON a2.room_id = r2.room_id JOIN Building b1 ON r1.building_id = b1.building_id JOIN Building b2 ON r2.building_id = b2.building_id WHERE a1.date = a2.date AND a1.room_id != a2.room_id AND b1.building_id != b2.building_id AND ABS( strftime(‘%s’, a1.date || ’ ’ || a1.time)

  • strftime(‘%s’, a2.date || ’ ’ || a2.time) ) < 360 ORDER BY a1.badge_id, a1.date;

← Back to blog