Retour de terrain Publié le · Mis à jour le · 5 min de lecture

Une alerte de scan dans notre SOC, causée par notre NAT

Docker s'approprie la chaîne FORWARD en silence. Après un redémarrage, nos nœuds privés ont produit une alerte de scan de ports lent parfaitement justifiée.

Une partie de nos serveurs n’a pas d’adresse publique. Les courtiers de messages et les nœuds de stockage n’ont aucune raison d’être joignables depuis Internet, mais ils ont besoin d’en sortir, pour les mises à jour de paquets et la résolution DNS. Ils passent donc par une passerelle NAT : un serveur d’administration qui, lui, a une adresse publique et route leur trafic.

Un matin, après un redémarrage de cette passerelle, les nœuds privés n’avaient plus accès à Internet. Et notre SOC affichait une alerte de scan de ports lent en provenance de notre propre réseau interne.

Les deux faits sont le même fait.

Docker s’approprie la chaîne FORWARD

Le premier point est celui qui piège le plus de monde, et il n’a rien de spécifique à notre installation.

Quand Docker démarre, il passe la politique de la chaîne FORWARD à DROP et insère en tête un saut vers une chaîne à lui, DOCKER-USER. Toute règle de routage écrite directement dans FORWARD est donc soit évaluée trop tard, soit jamais atteinte. La règle paraît présente, iptables -L la montre, et elle ne sert à rien.

DOCKER-USER existe précisément pour cela : c’est la chaîne prévue pour les règles de transit personnalisées, et elle est évaluée avant les règles propres à Docker. C’est là que doivent aller les autorisations de la passerelle :

- name: "FORWARD — allow SOC network through NAT (DOCKER-USER chain)"
  ansible.builtin.iptables:
    chain: DOCKER-USER
    source: 10.0.1.0/24
    jump: ACCEPT

Cohabiter avec Docker sur une machine qui fait autre chose que du conteneur demande de savoir qu’il modifie le pare-feu de l’hôte à votre insu. Ce n’est pas un défaut de Docker, c’est une conséquence de son modèle réseau. Mais ce n’est écrit nulle part au moment où l’on configure une passerelle.

Une couche en dessous : l’hyperviseur filtre aussi

Complément ajouté après publication, parce qu’il manquait une couche et qu’un lecteur qui reproduirait ce montage buterait dessus avant même d’arriver aux règles iptables.

Chez Hetzner, un réseau privé n’est pas un simple commutateur qui transporte ce qu’on lui donne. Le filtrage par adresse de destination se fait au niveau de l’hyperviseur, sur le commutateur virtuel. Un paquet émis par un nœud privé vers une adresse publique est donc supprimé avant d’atteindre le noyau de la passerelle. Aucune règle iptables ne le verra jamais, et le diagnostic part naturellement dans la mauvaise direction : on inspecte les compteurs de la passerelle, ils restent à zéro, et on en conclut que le trafic ne part pas.

Il faut déclarer explicitement à l’hyperviseur que la route par défaut du réseau privé passe par la passerelle :

resource "hcloud_network_route" "soc_default" {
  network_id  = hcloud_network.soc.id
  destination = "0.0.0.0/0"
  gateway     = var.nat_gateway_soc_ip
}

Trois couches doivent donc être correctes pour qu’un nœud privé atteigne Internet, et chacune échoue silencieusement : la route de l’hyperviseur, les autorisations de transit dans DOCKER-USER, et le MASQUERADE en sortie. Ces couches n’appartiennent pas au même système, ne se journalisent pas au même endroit, et deux d’entre elles ne produisent aucune trace du tout.

L’ordre des règles, et l’asymétrie du trafic de retour

Le second point est plus subtil, et il produit une panne particulièrement trompeuse.

Autoriser le transit depuis 10.0.1.0/24 semble suffire. Ce n’est pas le cas, parce qu’une connexion TCP n’est pas symétrique du point de vue du pare-feu. Le paquet sortant porte bien une adresse source privée. Le paquet de retour, lui, porte l’adresse du serveur distant, celle d’un miroir de paquets par exemple. Une règle qui n’autorise que la source 10.0.1.0/24 ne le reconnaît pas, et la politique DROP de la chaîne le supprime.

Le symptôme est déroutant : la connexion sort, elle n’est jamais refusée, et elle se fige. Le SYN part, le SYN-ACK est détruit à l’arrivée. Un ping peut fonctionner, une résolution DNS aussi, et chaque téléchargement se bloque indéfiniment.

D’où l’ordre imposé dans le rôle, avec l’autorisation du trafic déjà établi insérée en position 1, avant toute règle par source :

- name: "FORWARD — allow established/related return traffic (DOCKER-USER chain)"
  ansible.builtin.iptables:
    chain: DOCKER-USER
    ctstate: [ESTABLISHED, RELATED]
    jump: ACCEPT
    action: insert
    rule_num: 1

La partie intéressante : la détection avait raison

Voilà le cœur de ce retour de terrain.

Après le redémarrage, ces règles n’avaient pas été restaurées. Chaque tentative de sortie d’un nœud privé était donc bloquée, et chaque blocage journalisé. Le résultat, vu depuis le moteur de corrélation : une même adresse source interne produisant des dizaines de connexions refusées vers des destinations variées, de façon régulière et étalée dans le temps.

C’est exactement la signature d’un scan de ports lent. La règle de fréquence a donc fait son travail, a escaladé, et a produit l’alerte.

Ce n’était pas un défaut de détection. La règle avait raison sur ce qu’elle observait. Elle ignorait seulement une chose : que cette source était l’un de nos propres nœuds, en train d’échouer à joindre un miroir de paquets.

La tentation, dans ce cas, est de désactiver la règle. C’est la mauvaise réponse : on supprime la détection d’un comportement réellement suspect pour faire taire un cas particulier. La bonne réponse est de fournir le contexte manquant, en restreignant la suppression aux seules plages internes concernées, et en la maintenant à un niveau où elle reste consultable :

<rule id="100043" level="3">
  <if_sid>100030</if_sid>
  <srcip>10.0.1.0/24</srcip>
  <description>UFW blocked NAT forward from SOC node — internal routing miss, not a scan</description>
</rule>

L’évènement continue d’exister et reste interrogeable. Il cesse simplement d’alimenter le compteur de fréquence qui menait à l’alerte de scan. Une source extérieure produisant le même motif déclenche toujours l’escalade.

Ce qu’on en retient

  1. Docker modifie le pare-feu de l’hôte. Sur une machine qui fait aussi du routage, les règles de transit vont dans DOCKER-USER, jamais dans FORWARD.
  2. Une règle par adresse source ne couvre pas le retour. Autoriser ESTABLISHED,RELATED en premier, sinon les connexions se figent au lieu d’être refusées, ce qui est bien plus long à diagnostiquer.
  3. Vérifier ce qui survit à un redémarrage. Une règle de pare-feu active n’est pas une règle de pare-feu persistante, et l’écart ne se voit qu’au prochain démarrage.
  4. Un faux positif n’est pas toujours une erreur de détection. Celui-ci signalait correctement un comportement anormal, dont la cause était notre propre infrastructure. Le SOC observe votre parc, pas seulement vos attaquants, et il vous dira des choses vraies sur vous-même.

Le quatrième point est celui que nous retenons. Une alerte qui pointe vers une panne interne n’est pas du bruit à supprimer, c’est de l’information à lire.

dockeriptablesnatingenierie-de-detectionfaux-positif