9. Sécurité DNS, BGP et protocoles réseau#
Introduction#
Les protocoles réseau fondamentaux — DNS, BGP, SMTP — ont été conçus dans les années 1980 dans un contexte de confiance mutuelle entre acteurs de l’Internet. Leur sécurisation rétroactive est un chantier permanent : DNSSEC, RPKI, STARTTLS représentent des décennies d’efforts pour ajouter cryptographie et authentification à des protocoles intrinsèquement ouverts. Ce chapitre explore les vulnérabilités de ces protocoles et leurs contre-mesures.
DNSSEC : chaîne de confiance#
DNSSEC (DNS Security Extensions, RFC 4033-4035) ajoute l’authenticité et l’intégrité aux réponses DNS par signature cryptographique, sans chiffrement.
Architecture des clés#
DNSSEC repose sur deux paires de clés asymétriques par zone :
ZSK (Zone Signing Key) : clé de travail, rotation fréquente (mensuelle/trimestrielle), utilisée pour signer les enregistrements de la zone.
KSK (Key Signing Key) : clé d’ancre, rotation rare (annuelle), utilisée uniquement pour signer le ZSK (enregistrement DNSKEY).
Cette séparation permet de changer le ZSK sans toucher à la relation de confiance établie avec la zone parente.
Types d’enregistrements DNSSEC#
Enregistrement |
Rôle |
|---|---|
|
Contient la clé publique ZSK ou KSK |
|
Signature d’un RRset par le ZSK |
|
Empreinte du KSK de la zone enfant, stockée dans la zone parente |
|
Preuve d’inexistence d’un enregistrement (NSEC3 = hachage des noms) |
Validation récursive#
La chaîne de confiance part de la racine DNS (trust anchor maintenu par l’IANA) et descend vers la zone cible :
. (racine) → DS(fr.) → DS(alkimya.fr.) → DNSKEY(alkimya.fr.)
Un résolveur valide :
Le DNSKEY de la zone cible avec le DS de la zone parente.
Le RRSIG de l’enregistrement répondu avec le DNSKEY ZSK.
Si la chaîne est rompue (DS absent, RRSIG expiré, signature invalide), le résolveur retourne SERVFAIL.
NSEC3 contre l’énumération de zone
NSEC en clair permet l’énumération complète d’une zone (zone walking). NSEC3 hache les noms propriétaires (SHA-1 salé) pour rendre l’énumération très coûteuse sans l’éliminer totalement. Le paramètre d’itérations doit être limité (RFC 9276 recommande 0 itération).
Configuration BIND — signature de zone#
# Génération des clés (BIND 9 / dnssec-keygen)
dnssec-keygen -a ECDSAP256SHA256 -b 256 -n ZONE alkimya.fr # ZSK
dnssec-keygen -a ECDSAP256SHA256 -b 256 -n ZONE -f KSK alkimya.fr # KSK
# Signature de la zone (validité 30 jours, re-signature à 7 jours)
dnssec-signzone -A -3 $(head -c 1000 /dev/urandom | sha1sum | cut -b 1-16) \
-N INCREMENT -o alkimya.fr -t -e +2592000 \
/var/named/alkimya.fr.zone
# Vérification avec dig
dig +dnssec +multi alkimya.fr DNSKEY @8.8.8.8
dig +dnssec alkimya.fr A @8.8.8.8
# Vérification de la chaîne de confiance
dig +trace +dnssec alkimya.fr A
# Tester la validation DNSSEC (domaine avec DNSSEC cassé intentionnellement)
dig sigfail.verteiltesysteme.net A +dnssec
# Doit retourner SERVFAIL si le résolveur valide DNSSEC
DoH et DoT : confidentialité des requêtes DNS#
Les requêtes DNS traditionnelles transitent en clair sur UDP/53. Un opérateur réseau, un FAI ou un attaquant en position MITM peut observer toutes les résolutions de noms.
DNS over TLS (DoT) — RFC 7858#
Port 853 (TCP).
Encapsule DNS dans TLS 1.2+.
Le client authentifie le résolveur par son certificat.
Visible sur le réseau comme du trafic TLS vers le port 853 → facilement filtrable.
DNS over HTTPS (DoH) — RFC 8484#
Port 443 (HTTPS standard).
DNS encapsulé dans des requêtes HTTP/2 ou HTTP/3.
Indiscernable du trafic HTTPS normal → difficile à bloquer sans inspection TLS (TLS inspection).
Adopté par les navigateurs modernes (Firefox, Chrome).
DoH et contournement des politiques réseau
DoH peut contourner les résolveurs d’entreprise (filtrage de contenu, journalisation des requêtes DNS). Les DSI doivent gérer le DoH explicitement : soit forcer le résolveur interne via politique navigateur (ADMX/MDM), soit bloquer les résolveurs DoH publics connus.
Attaques DNS#
Cache Poisoning — l’attaque Kaminsky (2008)#
Dan Kaminsky découvre en 2008 une vulnérabilité critique dans les implémentations DNS : il est possible d’empoisonner le cache d’un résolveur récursif en envoyant des réponses forgées avant la réponse légitime.
Principe :
L’attaquant force le résolveur à résoudre un sous-domaine aléatoire de la zone cible (ex.
rand1234.alkimya.fr).Il envoie des milliers de réponses forgées avec des valeurs d’ID de transaction différentes.
Si une réponse forgée arrive avant l’autorité légitime et avec le bon ID (16 bits), le cache est empoisonné.
La réponse forgée inclut un enregistrement NS falsifié pour toute la zone
alkimya.fr.
Facteurs de succès :
Espace d’ID de transaction : 16 bits = 65 536 valeurs.
Port source aléatoire (RFC 5452) ajoute 16 bits → 2³² combinaisons.
TTL du cache : fenêtre d’attaque limitée.
DNS Hijacking#
Modification des enregistrements DNS légitimes par compromission :
Du bureau d’enregistrement (registrar hijacking).
Des serveurs DNS autoritaires.
Des routeurs CPE (DNS changer malware).
Exemple notable : campagne Sea Turtle (2019) — compromission de registrars pour rediriger des domaines gouvernementaux moyen-orientaux vers des serveurs sous contrôle attaquant.
DNS Tunneling — exfiltration de données#
Le DNS tunneling encapsule des données arbitraires dans des requêtes/réponses DNS. Les sous-domaines de taille inhabituelle encodent en base32/base64 les données exfiltrées.
GET /secret → encode → SVJFU1RJQV== → résolution DNS de
SVJFU1RJQV==.attacker.com → données récupérées par le serveur DNS attaquant
Outils connus : iodine, dns2tcp, DNScat2.
Détection par analyse de l’entropie des sous-domaines et de la longueur des labels DNS.
BGP Security : RPKI et détournements de routes#
BGP et ses limites#
BGP (Border Gateway Protocol) est le protocole de routage inter-AS d’Internet. Il repose sur la confiance : n’importe quel AS peut annoncer n’importe quel préfixe IP. Il n’existe aucune vérification d’origine native.
Cas réels de BGP Hijacking#
Pakistan Telecom (2008) : Pakistan Telecom annonce par erreur les préfixes YouTube (AS 36561) pour bloquer l’accès au Pakistan. L’annonce se propage mondialement — YouTube inaccessible pendant 2 heures depuis une grande partie d’Internet.
Rostelecom (2020) : Plus de 8 000 préfixes appartenant à Amazon, Google, Facebook, Cloudflare détournés via l’AS 12389 de Rostelecom pendant environ une heure. Probable erreur de configuration, mais illustre la fragilité du routage BGP.
RPKI et ROA#
RPKI (Resource Public Key Infrastructure) lie cryptographiquement les ressources Internet (blocs IP, numéros d’AS) à des certificats X.509 émis par les RIR (ARIN, RIPE NCC, APNIC…).
ROA (Route Origin Authorization) : objet signé qui déclare qu’un AS donné est autorisé à annoncer un préfixe spécifique avec une longueur maximale.
ROA : Préfixe 93.184.216.0/24, AS 15133, maxLength 24
Un routeur BGP peut valider chaque annonce reçue :
Valid : préfixe couvert par un ROA, AS et longueur corrects.
Invalid : préfixe couvert par un ROA mais AS ou longueur incorrects → à rejeter.
Not Found : aucun ROA ne couvre ce préfixe → décision locale.
Protocoles sécurisés : SMTP, SNMP, CAA#
SMTPS vs STARTTLS#
SMTPS |
STARTTLS |
|
|---|---|---|
Port |
465 (submission over SSL) |
587 (submission), 25 (MTA) |
Mécanisme |
TLS dès la connexion (wrapper) |
Connexion claire, puis upgrade TLS |
Risque |
Aucun downgrade possible |
Vulnérable au STARTTLS stripping |
Recommandation |
Préféré pour clients MUA |
Standard pour SMTP entre MTA |
Le STARTTLS stripping est une attaque MITM : l’attaquant retire la commande 250-STARTTLS de la réponse EHLO du serveur, forçant le client à transmettre en clair. La protection : requiretls (RFC 8689) et MTA-STS.
SNMPv3#
SNMPv1 et v2c n’offrent qu’une « community string » en clair comme authentification. SNMPv3 introduit :
USM (User-based Security Model) : authentification HMAC-MD5/SHA, chiffrement DES/AES.
VACM (View-based Access Control Model) : contrôle d’accès granulaire par OID.
Configuration minimale recommandée : authPriv (authentification + chiffrement AES-128).
Cellules Python exécutables#
Simulation de l’attaque Kaminsky#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Paramètres de l'attaque Kaminsky
# - L'ID de transaction DNS est sur 16 bits (65 536 valeurs)
# - La randomisation du port source ajoute 16 bits supplémentaires (RFC 5452)
# - Pour chaque requête DNS, l'attaquant envoie N réponses forgées par seconde
# - Le TTL détermine la durée pendant laquelle le cache doit être empoisonné
def probabilite_kaminsky(n_reponses_par_sec, ttl_s, port_random=False):
"""
Calcule la probabilité de succès de l'attaque Kaminsky.
Sans randomisation de port : espace = 65 536 (16 bits ID)
Avec randomisation de port : espace = 65 536 * nombre_ports_utilisables
"""
if port_random:
# ~32 768 ports source aléatoires disponibles (49152-65535 bien que variable)
espace = 65_536 * 32_768
else:
espace = 65_536 # ID transaction seulement
# Fenêtre d'opportunité : TTL du cache (on re-déclenche à chaque expiration)
# Nombre de tentatives total durant un "round" de TTL secondes
n_tentatives = n_reponses_par_sec * ttl_s
# Probabilité d'au moins 1 succès en N tentatives indépendantes
# P(succès) = 1 - (1 - 1/espace)^n ≈ 1 - exp(-n/espace) pour n << espace
prob = 1 - math.exp(-n_tentatives / espace)
return prob
ttls = np.linspace(1, 300, 300)
debits = [500, 2000, 10_000, 50_000] # réponses forgées par seconde
colors = sns.color_palette("muted", len(debits))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for ax, port_rand, titre in zip(
axes,
[False, True],
["Sans randomisation de port (RFC 5452 non appliqué)",
"Avec randomisation de port source (RFC 5452)"]
):
for debit, col in zip(debits, colors):
probs = [probabilite_kaminsky(debit, t, port_random=port_rand) for t in ttls]
ax.plot(ttls, probs, label=f"{debit:,} rép/s", color=col, linewidth=2)
ax.set_xlabel("TTL du cache (secondes)")
ax.set_ylabel("Probabilité de succès")
ax.set_title(titre, fontsize=10)
ax.set_ylim(0, 1.05)
ax.legend(title="Débit de l'attaquant", fontsize=8)
ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1.0))
fig.suptitle("Attaque Kaminsky : probabilité de succès du cache poisoning", fontsize=13, fontweight="bold")
plt.show()
Lecture du graphique
Sans randomisation de port, un attaquant envoyant 2 000 réponses forgées par seconde atteint une probabilité supérieure à 95 % en moins de 60 secondes. Avec la randomisation de port (RFC 5452), le même attaquant à 50 000 réponses/s ne dépasse pas 1 % en 5 minutes. C’est pourquoi la randomisation du port source est une contre-mesure critique, complétée par DNSSEC.
Graphe RPKI avec validation ROA#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
# Construction d'un graphe AS → préfixes avec validation ROA simulée
# Statuts : "valid", "invalid", "not_found"
# Définition des ROA (ground truth)
roa_db = {
"15133": [("93.184.216.0/24", 24)], # Edgecast / Verizon
"13335": [("1.1.1.0/24", 24), ("1.0.0.0/24", 24)], # Cloudflare
"16509": [("54.240.0.0/18", 24)], # Amazon AWS
"36561": [("208.65.152.0/22", 24)], # YouTube
}
# Annonces BGP simulées (certaines légitimes, certaines hijackées)
annonces = [
("AS15133", "93.184.216.0/24", "15133"), # valid
("AS13335", "1.1.1.0/24", "13335"), # valid
("AS13335", "1.0.0.0/24", "13335"), # valid
("AS16509", "54.240.0.0/18", "16509"), # invalid (longueur /18 > maxLength /24... non, ici maxLength=24 couvre /18? Non: /18 < /24 → valid car moins spécifique)
("AS_ROGUE", "208.65.152.0/22", "99999"), # invalid (AS inconnu)
("AS_ISP1", "203.0.113.0/24", "64500"), # not_found (aucun ROA)
("AS_ISP2", "198.51.100.0/24", "64501"), # not_found
("AS_ROGUE2", "1.1.1.0/24", "99888"), # invalid (AS différent de 13335)
]
def valider_roa(prefixe, as_annonce):
"""Valide une annonce BGP contre la base ROA."""
couverte = False
for as_roa, roas in roa_db.items():
for (pfx_roa, max_len) in roas:
# Vérification simplifiée : correspondance exacte du préfixe
if pfx_roa == prefixe:
couverte = True
longueur = int(prefixe.split("/")[1])
if as_roa == as_annonce and longueur <= max_len:
return "valid"
else:
return "invalid"
return "not_found"
# Construction du graphe
G = nx.DiGraph()
couleurs_noeuds = []
labels_noeuds = {}
couleurs_aretes = []
statuts_palette = {"valid": "#4CAF50", "invalid": "#F44336", "not_found": "#FF9800"}
for (as_nom, prefixe, as_num) in annonces:
statut = valider_roa(prefixe, as_num)
if as_nom not in G:
G.add_node(as_nom, type="as")
if prefixe not in G:
G.add_node(prefixe, type="prefix")
G.add_edge(as_nom, prefixe, statut=statut)
labels_noeuds[as_nom] = as_nom
labels_noeuds[prefixe] = prefixe
# Layout et visualisation
fig, ax = plt.subplots(figsize=(13, 7))
pos = nx.spring_layout(G, seed=42, k=2.5)
# Couleur des noeuds selon type
node_colors = []
for node in G.nodes():
if G.nodes[node].get("type") == "as":
node_colors.append("#5C85D6")
else:
node_colors.append("#B0BEC5")
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1200, ax=ax, alpha=0.9)
nx.draw_networkx_labels(G, pos, labels=labels_noeuds, font_size=7, ax=ax)
for (u, v, data) in G.edges(data=True):
statut = data["statut"]
nx.draw_networkx_edges(
G, pos, edgelist=[(u, v)],
edge_color=statuts_palette[statut],
arrows=True, arrowsize=20,
width=2.5, ax=ax,
connectionstyle="arc3,rad=0.1"
)
# Légende
from matplotlib.patches import Patch
legende = [Patch(facecolor=c, label=s) for s, c in statuts_palette.items()]
ax.legend(handles=legende, title="Statut ROA", loc="lower left", fontsize=9)
ax.set_title("Validation RPKI/ROA : annonces BGP valides, invalides et non couvertes", fontsize=12, fontweight="bold")
ax.axis("off")
plt.show()
# Résumé statistique
from collections import Counter
statuts = [valider_roa(pfx, asn) for (_, pfx, asn) in annonces]
cpt = Counter(statuts)
print("Résumé de la validation RPKI :")
for s, n in sorted(cpt.items()):
print(f" {s:10s} : {n} annonce(s)")
Résumé de la validation RPKI :
invalid : 2 annonce(s)
not_found : 2 annonce(s)
valid : 4 annonce(s)
Détection DNS Tunneling par entropie de Shannon#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
def entropie_shannon(chaine):
"""Calcule l'entropie de Shannon d'une chaîne (bits par caractère)."""
if not chaine:
return 0.0
frequences = collections.Counter(chaine.lower())
n = len(chaine)
return -sum((f / n) * math.log2(f / n) for f in frequences.values())
def longueur_moyenne_labels(domaine):
"""Longueur moyenne des labels DNS (entre les points)."""
labels = domaine.split(".")[:-2] # Exclut TLD et second niveau
if not labels:
return 0.0
return np.mean([len(l) for l in labels])
# Domaines normaux (légitimes)
domaines_normaux = [
"www.google.com", "mail.alkimya.fr", "api.github.com",
"static.cloudflare.com", "login.microsoftonline.com",
"cdn.jsdelivr.net", "fonts.googleapis.com", "accounts.google.com",
"www.lemonde.fr", "img.shields.io", "registry.npmjs.org",
"download.docker.com", "deb.debian.org", "security.ubuntu.com",
"smtp.gmail.com", "imap.gmail.com", "pop.gmail.com",
"docs.python.org", "pypi.org", "github.com",
]
# Sous-domaines générés par DNS tunneling (données encodées en base32)
import base64
def generer_tunnel_domain(payload, base_domain="evil.com"):
"""Simule un sous-domaine de tunneling DNS avec payload encodé."""
encoded = base64.b32encode(payload.encode()).decode().lower().rstrip("=")
# Découpe en labels de max 63 caractères
labels = [encoded[i:i+32] for i in range(0, min(len(encoded), 96), 32)]
return ".".join(labels) + "." + base_domain
secrets = [
"mot_de_passe_administrateur_confidentiel",
"token_jwt_eyJhbGciOiJSUzI1NiJ9.payload.signature",
"fichier_etc_shadow_root_hash_linux",
"/etc/passwd contenu exfiltré par DNS",
"cle_api_aws_AKIAIOSFODNN7EXAMPLE",
"SELECT * FROM users WHERE admin=1",
"connexion vpn identifiants utilisateur",
"private_key_rsa_begin_pkcs8_header",
"credit_card_4532015112830366_cvv_123",
"backup_database_dump_schema_users_table",
]
domaines_tunnel = [generer_tunnel_domain(s) for s in secrets]
# Calcul des métriques
def analyser_domaine(domaine):
sous = domaine.split(".")[0] # Premier label
return {
"entropie": entropie_shannon(sous),
"longueur_label": len(sous),
"longueur_totale": len(domaine),
}
metriques_normaux = [analyser_domaine(d) for d in domaines_normaux]
metriques_tunnel = [analyser_domaine(d) for d in domaines_tunnel]
entropies_normaux = [m["entropie"] for m in metriques_normaux]
entropies_tunnel = [m["entropie"] for m in metriques_tunnel]
longueurs_normaux = [m["longueur_label"] for m in metriques_normaux]
longueurs_tunnel = [m["longueur_label"] for m in metriques_tunnel]
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# Distribution des entropies
axes[0].hist(entropies_normaux, bins=8, alpha=0.7, label="Domaines légitimes",
color=sns.color_palette("muted")[0], edgecolor="white")
axes[0].hist(entropies_tunnel, bins=8, alpha=0.7, label="DNS Tunneling",
color=sns.color_palette("muted")[3], edgecolor="white")
axes[0].axvline(x=3.5, color="red", linestyle="--", linewidth=2, label="Seuil détection (3.5)")
axes[0].set_xlabel("Entropie de Shannon (bits)")
axes[0].set_ylabel("Nombre de domaines")
axes[0].set_title("Distribution de l'entropie du premier label DNS")
axes[0].legend(fontsize=9)
# Scatter entropie vs longueur du label
axes[1].scatter(longueurs_normaux, entropies_normaux,
label="Légitimes", color=sns.color_palette("muted")[0], s=80, alpha=0.8, zorder=3)
axes[1].scatter(longueurs_tunnel, entropies_tunnel,
label="Tunneling", color=sns.color_palette("muted")[3], s=80, alpha=0.8, zorder=3)
axes[1].axhline(y=3.5, color="red", linestyle="--", linewidth=1.5, label="Seuil entropie")
axes[1].axvline(x=20, color="orange", linestyle="--", linewidth=1.5, label="Seuil longueur")
axes[1].set_xlabel("Longueur du premier label (caractères)")
axes[1].set_ylabel("Entropie de Shannon (bits)")
axes[1].set_title("Entropie vs longueur : séparation légitimes / tunneling")
axes[1].legend(fontsize=9)
fig.suptitle("Détection DNS Tunneling par analyse d'entropie", fontsize=13, fontweight="bold")
plt.show()
# Statistiques
print(f"Entropie moyenne — légitimes : {np.mean(entropies_normaux):.2f} bits")
print(f"Entropie moyenne — tunneling : {np.mean(entropies_tunnel):.2f} bits")
print(f"Longueur moyenne — légitimes : {np.mean(longueurs_normaux):.1f} chars")
print(f"Longueur moyenne — tunneling : {np.mean(longueurs_tunnel):.1f} chars")
Entropie moyenne — légitimes : 1.86 bits
Entropie moyenne — tunneling : 4.19 bits
Longueur moyenne — légitimes : 4.8 chars
Longueur moyenne — tunneling : 32.0 chars
Limites de la détection par entropie
L’entropie est un signal nécessaire mais insuffisant : des CDN utilisent des sous-domaines hachés (ex. a8c3f2e1.cdn.example.com) avec une entropie élevée. Une détection robuste combine l’entropie, la fréquence des requêtes, les types d’enregistrements demandés (TXT et NULL sont favoris pour le tunneling), et les volumes de réponses inhabituellement grands.
Résumé#
DNSSEC sécurise l’intégrité des réponses DNS par une chaîne de confiance cryptographique (ZSK/KSK/RRSIG/DS), sans chiffrement du contenu. NSEC3 atténue l’énumération de zone.
DoH et DoT chiffrent les requêtes DNS pour protéger la vie privée. DoH sur le port 443 est difficile à distinguer du trafic HTTPS normal, ce qui pose des défis de gouvernance réseau.
L’attaque Kaminsky exploite la faiblesse de l’espace d’ID de transaction DNS (16 bits). La randomisation du port source (RFC 5452) étend l’espace à ~2³² ; DNSSEC est la protection définitive.
BGP est intrinsèquement non authentifié. RPKI/ROA lie cryptographiquement les annonces de préfixes à des AS autorisés. Les détournements de Pakistan Telecom (2008) et Rostelecom (2020) illustrent la fragilité persistante du routage Internet.
STARTTLS est vulnérable au stripping MITM contrairement à SMTPS (TLS immédiat). SNMPv3 (
authPriv) est la seule version à déployer en production. CAA records restreignent les AC autorisées à émettre des certificats pour un domaine.Le DNS tunneling contourne les contrôles réseau en encodant des données dans les sous-domaines. Sa détection repose sur l’analyse d’entropie de Shannon des labels DNS, combinée aux métriques de fréquence et de volume.