Limiter les attaques par force brute sur le service SSH

dim. 07 janv. 2018 by Marmotte

Le service SSH (Secure SHell) permet d'obtenir l'accès à un terminal sur une machine distante, ce qui le rend donc très pratique pour l'administration. De ce fait, il est disponible sur un grand nombre de serveurs. Malheureusement, cela le rend donc aussi très attirant pour les personnes cherchant à contrôler un grand nombre machines, souvent pour les utiliser à des fins frauduleuses, ce qui explique qu'énormément de robots tentent de s'y connecter en permanence.

La plupart se contentent d'attaques par force brute, c'est-à-dire en tentant divers couples identifiant/mot de passe dans le but de trouver une combinaison valide. Dans cet article, j'explique les configurations mises en place sur mon serveur pour protéger le service SSH contre ce type d'attaque.

Note : Cet article remplace le paragraphe Limiter les attaques par force brute sur SSH de mon article sur iptables. J'ai en effet reçu quelques remarques me signalant que cet extrait de configuration seul n'était pas suffisant comme exemple, et qu'il pouvait ne pas correspondre à tous les cas d'utilisation.

Objectifs

Avant de chercher à mettre en place une configuration visant à se protéger d'une attaque, il faut bien entendu définir de quelle attaque on veut se prémunir, et comment le faire.

La solution ultime serait de stopper le service ou d'en bloquer l'accès complet... ce qui n'est pas souhaitable, puisque le but est justement de pouvoir utiliser SSH. Il faut donc trouver un moyen de ralentir les attaques, afin d'en diminuer l'efficacité. La difficulté principale est alors de trouver l'équilibre entre des règles très strictes, qui empêcheraient une bonne utilisation du service aux utilisateurs légitimes, et des règles très souples, qui ne gêneraient pas (ou trop peu) les attaques par force brute.

Les objectifs que je me suis fixé sont :

  • Limiter l'efficacité de ces attaques au point de les rendre inutiles.
  • Réduire autant que possible l'influence de ces attaques sur l'utilisation légitime du service.
  • Éviter de bloquer les utilisateurs légitimes. Un utilisateur sera considéré légitime s'il réussit à ouvrir une session.
  • Ne pas surcharger le système à protéger.

Pour limiter l'influence des attaques sur l'exécution du service, il me semble important de protéger en amont, au niveau du pare-feu. De cette manière, une fois un accès suspect détecté, l'attaquant ne pourra plus interagir avec le service lui-même. Cela permet aussi de protéger contre d'éventuelles failles du service déclenchées par une surcharge de celui-ci, par exemple.

Une fois une machine bloquée, il me semble aussi important de conserver le blocage tant que l'attaque continue. Cela permet d'éviter de solliciter inutilement le service tout au long de l'attaque, et par conséquent de réduire encore le nombre de tentatives de connexions indésirables.

Quelques solutions possibles

Plusieurs solutions permettent d'atteindre en partie ou en totalité ces objectifs. Elles ne sont d'ailleurs pas toutes incompatibles entre elles. Il est ainsi possible d'en mettre plusieurs en place simultanément, pour cumuler leurs avantages.

Configuration du service SSH

Les premières solutions se trouvent au niveau de la configuration du service lui-même. En effet, avant de chercher à sécuriser l'accès au service en ajoutant d'autres éléments autour, il est primordial de commencer par bien le configurer. Ces modifications se font dans le fichier /etc/ssh/sshd_config.

Changer le port

C'est une modification très simple, qui permet d'éviter un bon nombre d'attaques, puisque certains robots ne tentent de se connecter que sur le port par défaut. Malheureusement, cela est parfois problématique selon les réseaux d'où on va avoir à se connecter. Il arrive en effet que la plupart des ports soient bloqués en sortie, et que seuls certains ports couramment utilisés (souvent 22, 80 et 443), soient disponibles. Ce n'est pas non plus une parade parfaite, comme tout procédé de sécurité par l'obscurité, puisqu'il est assez simple de tester tous les ports accessibles, afin de détecter sur quel port le service SSH est accessible.

Authentification par clé SSH

N'autoriser que l'authentification par clé SSH permet de se prémunir totalement du risque de découverte d'un couple identifiant/mot de passe valide, puisque plus aucun mot de passe ne peut être tenté. Cependant, il est parfois nécessaire de pouvoir se connecter par mot de passe, par exemple depuis une machine sur laquelle on n'a pas la clé SSH nécessaire, ou en utilisant certains appareils ne proposant pas ce mode d'authentification.

De plus, cette solution seule, bien que très efficace, ne protège pas totalement le service. Certains robots peu intelligents continueront ainsi de tenter un très grand nombre de connexions, ce qui peut induire certains effets indésirables, comme une forte charge du service et le remplissage des journaux.

Nombre de tentatives d'authentification par connexion

La configuration par défaut du serveur OpenSSH autorise six tentatives d'authentification par connexion. Réduire cette valeur permet de limiter l'efficacité des attaques lors de l'utilisation de règles basées sur le nombre de connexions, puisque la connexion sera alors fermée automatiquement après un nombre réduit d'échecs.

Note : Contrairement à ce qu'on lit parfois sur internet, le nombre de tentatives par défaut est bien six, et non trois, coté serveur. C'est le client OpenSSH qui limite à trois par défaut (paramètre NumberOfPasswordPrompts).

Attention : Ce paramètre tient compte de toutes les tentatives d'authentification : mot de passe, clé SSH, etc. Un utilisateur ayant deux clés SSH disponibles aura donc deux tentatives d'authentification décomptées automatiquement, si aucune de ces deux clés n'est acceptée. Dans ce cas, il peut être nécessaire de préciser la clé SSH à utiliser, soit au moyen du paramètre en ligne de commande -i /path/pubkey, soit dans le fichier de configuration ~/.ssh/config.

Dans tous les cas, il est conseillé de laisser la valeur de ce paramètre à 2 au minimum pour éviter tout problème pour les clients utilisant des clés SSH.

Configuration du pare-feu

Certaines configurations basiques peuvent être faites au niveau du pare-feu, afin de limiter les accès au service.

Liste blanche

Dans certains cas, il est possible d'établir une liste des adresses des machines ou réseaux autorisés, et d'interdire l'accès à tous les autres. Elle présente l'avantage d'empêcher totalement l'accès au service par les attaquants. Cependant, comme l'authentification par clé SSH, cette solution est très efficace mais n'est pas applicable dans toutes les situations.

Liste noire

Établir une liste noire de machines ou réseaux pour les empêcher d'accéder au service peut permettre de réduire le nombre d'attaques provenant de réseaux connus pour beaucoup pratiquer ce genre d'attaque. Dans la pratique, cela ne se révèle pourtant que très peu efficace, puisqu'il faut tout d'abord trouver la liste des machines ou réseaux à bloquer, puis la maintenir. De plus, beaucoup de serveurs compromis sont mis à contribution pour devenir eux-mêmes des attaquants. Les attaques peuvent donc provenir de n'importe quel réseau, rendant le principe de la liste noire inefficace.

Limiter la fréquence des nouvelles connexions

Utiliser la règle limit avec iptables permet effectivement de ralentir la fréquence des tentatives de mots de passe, et donc de soulager le service des attaques lancées en continu. Cependant, elle limitera aussi bien les utilisateurs légitimes que les attaquants, et ne permet pas la conservation du blocage pendant toute la durée d'une attaque.

Il existe un autre moyen de limiter la fréquence des nouvelles connexions permettant d'éviter ces problèmes, décrit plus loin dans cet article.

Fail2Ban : La fausse bonne idée

Note : Ce que je décris ici n'est peut être valable que pour la configuration de Fail2Ban visant à limiter les attaques par force brute sur le service SSH, et peut être limité au fonctionnement des paquets fournis par Debian ou Ubuntu. Je n'ai pas testé Fail2Ban dans les autres cas, et n'ai donc pas d'avis à propos des autres configurations.

Fail2Ban est un logiciel qui exécute des commandes définies lorsqu'un certain nombre de lignes correspondent à une expression régulière dans un fichier surveillé. Dans le cas de la lutte contre les attaques par force brute sur le service SSH, la commande exécutée ajoute une règle de blocage de l'adresse IP concernée dans la configuration du pare-feu.

Il a l'avantage d'être simple à mettre en place et de permettre de modifier la configuration basique très facilement, ce qui en fait l'un des outils les plus couramment utilisés pour protéger un serveur contre les attaques par force brute sur le service SSH. C'est d'ailleurs la seule configuration activée par défaut dans le paquet fourni par Debian.

Pourtant, en analysant un peu son fonctionnement, je le trouve trop peu fiable dans cette utilisation pour pouvoir recommander son utilisation.

Voici une liste d'éléments que je lui reproche :

  • C'est un service supplémentaire, qui plus est, lancé en tant que root.
  • En cas d'absence de journalisation (disque plein, bug du service surveillé, crash ou absence de démon de syslog...), il ne fonctionne pas.
  • La version upstream supporte IPv6 depuis peu de temps, mais il semblerait que cette version ne soit pas encore disponible dans les dépôts Debian officiels (stable/testing/sid).
  • Selon la machine utilisée, la taille des journaux et la complexité des expressions régulières, il peut être très consommateur en mémoire vive, processeur et accès disque.
  • Pendant la durée de bannissement, plus aucune tentative de connexion ne peut être enregistrée dans les journaux, ce qui l'empêche de savoir si l'attaquant continue les tentatives ou pas, l'empêchant ainsi d'adapter la durée du bannissement.
  • Chaque bannissement ajoute une règle dans le pare-feu, ce qui ajoute des traitements supplémentaires au niveau du firewall pour chaque paquet suivant provenant de n'importe quelle source, que la connexion soit déjà établie ou non.
  • Il peut crasher (comme tout logiciel), ce qui est encore plus gênant s'il crashe après avoir banni un utilisateur légitime qui se tromperait trop de fois, puisque ce bannissement ne serait alors pas retiré automatiquement.

Note : Il est possible de modifier les règles appliquées par Fail2Ban dans sa configuration. Ainsi, on peut obtenir la conservation du le bannissement pendant toute la durée de l'attaque en utilisant le module xt_recent, ou utiliser ipset afin de définir des listes d'adresses IP sans créer une nouvelle règle pour chaque adresse à bloquer. Cela utilise cependant des règles plus complexes de netfilter, ce qui s'éloigne de la simplicité de mise en place souvent recherchée lors de l'installation de Fail2Ban.

En plus de ces points, ce que je lui reproche le plus est le simple fait d'être basé sur la lecture de journaux. Pour commencer, le nombre d'échecs avant blocage (paramètre maxretry) présent dans la configuration de Fail2Ban est souvent mal compris, et interprêté de manière trompeuse par les utilisateurs. Il s'agit du nombre de lignes du journal correspondant aux expressions régulières vérifiées par Fail2Ban, et non du nombre de tentatives de mots de passe ayant échoué.

Il faut aussi savoir que le contenu des journaux ne reflète pas forcément la réalité d'une attaque par force brute :

  • Les démons de syslog récents groupent parfois plusieurs lignes identiques en une seule pour réduire la taille des journaux, ce qui fait qu'une unique ligne globale est enregistrée, au lieu d'une par tentative. Compter le nombre de lignes correspondant à des expressions régulières donne alors un résultat assez éloigné du nombre de tentatives de connexion erronées. Par exemple :

    Jan  7 13:43:24 MarmotteDesktop sshd[10304]: Failed password for marmotte from 127.0.0.1 port 41428 ssh2
    Jan  7 13:43:25 MarmotteDesktop sshd[10304]: message repeated 4 times: [ Failed password for marmotte from 127.0.0.1 port 41428 ssh2]
    

    Note : Les expressions régulières présentes dans la configuration par défaut du paquet Fail2Ban de XUbuntu 16.04 ne repèrent pas ce format de ligne, ce qui rend ce problème plus gênant encore.

  • Selon la page de manuel sshd_config, les tentatives erronées ne sont enregistrées qu'à partir de la moitié de la valeur du paramètre MaxAuthTries, soit à partir de trois échecs dans la configuration par défaut. Lors de mes tests, le comportement que j'ai observé était légèrement différent : la première tentative n'est jamais enregistrée dans les journaux, et les suivantes le sont toujours, quelle que soit la valeur du paramètre MaxAuthTries. Malgré cette différence, dans les deux cas, il apparaît que certaines tentatives ne sont pas enregistrées dans les journaux. Un attaquant ne tentant qu'un mot de passe par connexion passera donc totalement inaperçu pour les filtres de Fail2Ban, à moins que le paramètre MaxAuthTries n'ait la valeur 1.

Un utilisateur ne vérifiant pas le fonctionnement, ni la configuration, de Fail2Ban et de son service SSH pensera donc qu'un attaquant ne peut tenter que cinq mots de passe par période de dix minutes avant d'être bloqué pour une durée de dix minutes... En réalité, sur le système qui m'a servi à tester Fail2Ban :

  • En conservant la configuration par défaut des paquets XUbuntu impliqués (Fail2Ban, client OpenSSH, serveur OpenSSH, rsyslog, etc.), j'ai pu tenter huit mots de passe avant d'être bloqué.
  • En passant le paramètre -o NumberOfPasswordPrompts=6 au client OpenSSH, donc sans aucune modification coté serveur, j'ai pu tenter vingt-sept mots de passe avant d'être bloqué.
  • En passant le paramètre -o NumberOfPasswordPrompts=1 au client OpenSSH, aucune tentative n'a été détectée par Fail2Ban (la valeur de la ligne Total failed de la commande sudo fail2ban-client status sshd n'évoluait pas).

Ensemble de solutions appliquées

Parmi les solutions possibles, j'ai choisi de :

  • Limiter la fréquence des nouvelles connexions, au moyen du module xt_recent de netfilter.
  • Limiter le nombre d'authentifications par connexion, dans la configuration du service SSH.

En plus de ces deux éléments, afin d'éviter de bloquer un utilisateur légitime qui se connecterait très souvent, les adresses IP correspondant à des sessions ouvertes avec succès doivent être retirées automatiquement de la liste des adresses à surveiller.

Note : La configuration que j'explique ici est relativement complexe, et donc plutôt destinée à des utilisateurs avancés. Dans tous les cas, surtout dans le domaine de la sécurité, il est fortement déconseillé d'installer ou configurer quelque chose que l'on ne comprend pas sur sa machine. Bien que je ne reproche beaucoup de choses à Fail2Ban, je conseille donc tout de même son utilisation aux utilisateurs qui ne se sentent pas à l'aise avec les notions abordées ici.

Configuration iptables

Le principe de la configuration iptables détaillée ici est le suivant :

  • Chaque nouvelle connexion sur le port 22 ajoute l'adresse IP dans une liste d'adresses à surveiller (liste ssh).
  • Si l'adresse IP est bannie, mettre à jour la date du dernier paquet reçu et bloquer la connexion.
  • Si l'adresse IP a atteint une des limites définies, ajouter un bannissement pour cette adresse IP.
  • Sinon, la connexion est acceptée.

Ces vérifications ne sont faites que quand la connexion est en état NEW. Après un certain nombre d'échecs, ou si l'authentification n'est pas réussie après un certain temps (deux minutes par défaut), la connexion est automatiquement fermée, forçant l'attaquant à ouvrir une nouvelle connexion. Il n'est donc pas nécessaire de vérifier les autres états, puisqu'un attaquant ne réussissant pas à se connecter ne pourra rien faire d'autre que des tentatives de connexion.

Il est possible d'avoir un fonctionnement satisfaisant en utilisant moins de règles, par exemple, en bannissant pour un mois dès la quatrième tentative erronée dans une période d'une heure. Cependant, j'ai mis ces règles en place pour éviter de bloquer trop longtemps un utilisateur se trompant de mot de passe, ou ayant oublié de définir la clé SSH à utiliser, avec des durées de bannissement augmentant progressivement avec le nombre d'échecs.

Voici l'extrait de mon fichier /etc/iptables/rules.v4 correspondant à cette configuration :

# Déclaration des chaînes ajoutant un nouveau bannissement
:SSH_BAN_10_MINUTES -
:SSH_BAN_1_DAY -
:SSH_BAN_1_HOUR -
:SSH_BAN_1_WEEK -
# Déclaration des chaînes vérifiant les bannissements en cours
:SSH_BANNED_10_MINUTES -
:SSH_BANNED_1_DAY -
:SSH_BANNED_1_HOUR -
:SSH_BANNED_1_WEEK -

[...]

# Ajout de l'adresse IP de chaque nouvelle connexion dans la liste d'adresses à surveiller
# Placer cette règle au début permet de tenir compte des paquets arrivant pendant qu'un bannissement est actif
# afin qu'un attaquant bloqué pour une heure n'évite pas un bannissement d'une semaine s'il continue son attaque
-A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh

# Si l'adresse IP est déjà bannie, blocage de la connexion
# Le paramètre --reap permet de nettoyer automatiquement les listes en supprimant les adresses IP qui n'ont envoyé
# aucun paquet pendant la durée de surveillance
-A INPUT -m recent --rcheck --seconds 604800 --reap --hitcount 1 --name ssh_banned_1_week -j SSH_BANNED_1_WEEK
-A INPUT -m recent --rcheck --seconds 86400 --reap --hitcount 1 --name ssh_banned_1_day -j SSH_BANNED_1_DAY
-A INPUT -m recent --rcheck --seconds 3600 --reap --hitcount 1 --name ssh_banned_1_hour -j SSH_BANNED_1_HOUR
-A INPUT -m recent --rcheck --seconds 600 --reap --hitcount 1 --name ssh_banned_10_minutes -j SSH_BANNED_10_MINUTES

# Ajout d'un bannissement si une limite du nombre de nouvelles connexions est atteinte :
#   12 connexions en une journée : Blocage pendant une semaine
#   8 connexions en une heure : Blocage pendant une journée
#   4 connexions en dix minutes : Blocage pendant une heure
#   2 connexions en une minute : Blocage pendant dix minutes
# Le paramètre --reap n'est placé que sur la règle définissant la durée la plus longue
-A INPUT -p tcp --dport 22 -m state --state NEW -m recent --rcheck --seconds 86400 --reap --hitcount 13 --name ssh -j SSH_BAN_1_WEEK
-A INPUT -p tcp --dport 22 -m state --state NEW -m recent --rcheck --seconds 3600 --hitcount 9 --name ssh -j SSH_BAN_1_DAY
-A INPUT -p tcp --dport 22 -m state --state NEW -m recent --rcheck --seconds 600 --hitcount 5 --name ssh -j SSH_BAN_1_HOUR
-A INPUT -p tcp --dport 22 -m state --state NEW -m recent --rcheck --seconds 60 --hitcount 3 --name ssh -j SSH_BAN_10_MINUTES

# Si l'adresse IP n'est pas bannie et n'a pas atteint de limite, le paquet est accepté
-A INPUT -p tcp --dport 22 -m state --state NEW -j ACCEPT

# Règles d'ajout d'un nouveau bannissement
-A SSH_BAN_1_WEEK -m recent --set --name ssh_banned_1_week -j SSH_BANNED_1_WEEK
-A SSH_BAN_1_DAY -m recent --set --name ssh_banned_1_day -j SSH_BANNED_1_DAY
-A SSH_BAN_1_HOUR -m recent --set --name ssh_banned_1_hour -j SSH_BANNED_1_HOUR
-A SSH_BAN_10_MINUTES -m recent --set --name ssh_banned_10_minutes -j SSH_BANNED_10_MINUTES

# Règles de blocage d'un utilisateur banni
# Le paramètre --update conserve la date du dernier paquet reçu, pour réinitialiser la durée du bannissement
-A SSH_BANNED_1_WEEK -m recent --update --name ssh_banned_1_week -j DROP
-A SSH_BANNED_1_DAY -m recent --update --name ssh_banned_1_day -j DROP
-A SSH_BANNED_1_HOUR -m recent --update --name ssh_banned_1_hour -j DROP
-A SSH_BANNED_10_MINUTES -m recent --update --name ssh_banned_10_minutes -j DROP

Note : Lors de l'ajout de cette configuration en IPv4 et en IPv6 simultanément, il faut faire bien attention à nommer les listes du module xt_recent différemment. En effet, netfilter applique automatiquement un masque /32 sur les adresses lorsque le fichier contient des adresses IPv4, ce qui casse les adresses IPv6 qui y sont stockées.

Configuration du service sshd

Les règles de pare-feu mises en place se basent sur le nombre de nouvelles connexions effectuées, et pas sur le nombre de tentatives de mot de passe. J'ai donc décidé de réduire le nombre maximum de tentatives de mot de passe par connexion, pour contrôler plus finement le nombre de tentatives d'authentification autorisées.

Pour cela, il suffit de modifier une ligne dans le fichier /etc/ssh/sshd_config :

MaxAuthTries 2

Éviter le blocage des utilisateurs légitimes

La configuration établie avec iptables ne sait pas si les connexions sont réussies ou pas, et comptabilise donc toutes les connexions effectuées, même celles qui sont abandonnées sans échec ni succès. Cependant, il peut arriver qu'un utilisateur légitime ait besoin d'effectuer plusieurs connexions dans une période réduite (déconnexion par erreur de la session SSH, plusieurs copies par scp ou rsync, utilisation de git, etc.).

Pour éviter que cela ne se produise, il est nécessaire de retirer les adresses IP des utilisateurs légitimes de la liste des adresses IP à surveiller. L'utilisateur sera considéré légitime dès lors qu'il réussit à ouvrir une session avec succès. Les adresses IP des attaquants ayant réussi à trouver un mot de passe valide seront aussi supprimées de la liste, mais s'ils peuvent se connecter, les empêcher de tenter des mots de passe n'est plus la préoccupation principale.

Pour cela, j'ai écrit un script très simple, exécuté lors de l'ouverture d'une session par PAM (Pluggable Authentication Modules). De cette manière, se connecter 10 fois dans la même seconde par une clé SSH ne nous fera pas bannir, et cela est valable même sans qu'un shell ne soit lancé. Il sera donc exécuté aussi lors de l'utilisation de commandes comme scp, rsync, ou lorsque la connexion est ouverte dans le seul but d'établir une redirection de port.

Cette configuration doit être faite dans le fichier /etc/pam.d/sshd, en ajoutant cette ligne :

session    optional     pam_exec.so /usr/local/bin/iptables-pam

Parmi les variables d'environnement passées lors de l'exécution du script, la variable $PAM_RHOST contient l'adresse IP de la machine ayant réussi à ouvrir une session avec succès. Les listes d'adresses IP étant gérées par le module xt_recent de netfilter, elles sont donc situées dans le répertoire /proc/net/xt_recent/. Il est possible de les manipuler en écrivant dans ces fichiers.

Le script /usr/local/bin/iptables-pam doit être exécutable, et contient :

#! /usr/bin/env bash

if [ "$PAM_TYPE" == "open_session" ]
then
    # Suppression de l'IP dans la liste des IPv4
    echo "-$PAM_RHOST" > /proc/net/xt_recent/ssh
    # Suppression de l'IP dans la liste des IPv6
    echo "-$PAM_RHOST" > /proc/net/xt_recent/ssh6
fi

Note : Le chemin des listes d'adresse IP doivent évidemment correspondre au nom donné dans la configuration iptables. Le fichier /proc/net/xt_recent/ssh6 utilisé ici provient de la configuration pour ip6tables de mon serveur, qui est identique à celle pour iptables, à l'exception des noms de listes, qui sont préfixés par ssh6 au lieu de ssh.