Fail2Ban et attaque Flood HTTP - Épisode 2

On a vu dans Utiliser Fail2Ban pour bloquer un FLOOD HTTP comment bloquer une attaque particulière : celle qui utilise un user agent en WordPress/. Ce n'est malheureusement pas le seul vecteur d'attaque qui existe, et il faut les bloquer un par un : on préfère avoir quelques faux positifs que des faux négatifs.

J'en profite pour répondre à @fuolpit :

Exactement. C'est pour cela que l'étape d'analyse est primordiale : on identifie l'attaque, et on la bloque facilement. On peut donc se permettre de recommencer autant de fois que nécessaire, il arrivera bien un moment où l'attaquant sera bloqué (il ne possède sans doute pas une infinité d'adresses IP).

Pour le coup, ça dépend de l'attaquant. S'il aime persévérer, et que la première fois ça a fonctionné, il recommencera. Grace à Fail2Ban, il sera vite bloqué (par IP, donc il ne pourra même pas essayer une autre attaque. Génial !).
S'il est vraiment méchant, il peut alterner entre plusieurs vecteurs. Et là, c'est le drame.

C'est à peu près ce qui s'est passé aujourd'hui, en fait. Une fois l'attaque bloquée (elle continue encore à l'heure où j'écris ces lignes, pour vous dire à quel point il insiste !), je peux vous affirmer que 541 IP sont à l'origine de cette attaque. (Merci fail2ban-client status http-wordpress-dos qui me donne le nombre exact, hihi).
C'est à peu près 6 fois plus que la première attaque mentionnée dans l'article précédent, et cette fois 2 caractéristiques ressortent.

  • L'attaquant utilise PHP, l'User-Agent qui ressort dans les logs est PHP/xxxx ou WordPress/xxxx. Mais c'est très ponctuel, peu puissant. (Légitime, la règle déjà en place se charge de le bloquer)
  • Il y a des requêtes qui n'ont ni User-Agent ni URL ni Referer :
190.129.173.130 - - [17/Mar/2016:15:56:11 +0100] "GET / HTTP/1.0" 403 169 "-" "-"
190.129.173.130 - - [17/Mar/2016:15:56:12 +0100] "GET / HTTP/1.0" 301 185 "-" "-"
190.129.173.130 - - [17/Mar/2016:15:56:13 +0100] "GET / HTTP/1.0" 301 185 "-" "-"
190.129.173.130 - - [17/Mar/2016:15:56:24 +0100] "GET / HTTP/1.0" 301 185 "-" "-"

Par curiosité, je décide de regarder les informations de l'IP :

$ host 190.129.173.130
Host 130.173.129.190.in-addr.arpa. not found: 3(NXDOMAIN)
$ whois 190.129.173.130
bla bla bla
address:     BOL - La Paz - LP
country:     BO
bla bla bla

Bonjour, toi. Que fais-tu sur mon site franco-français ?
(Traduction: pourquoi est-ce que je ne mets pas en place un filtre d'IP basé sur le pays #GeoIP ?)

Soit. Dans tous les cas, l'utilisation CPU est à 100% et je dois faire quelque chose.

Je profite du fail que le protocole HTTP soit utilisé pour regarder une couche plus bas, directement au niveau des packets TCP. Bien entendu, avec notre ami tcpdump que tout le monde sait utiliser.
Petit rappel quand même, -A permet d'avoir le contenu des packets, -q permet de réduire le délai entre la réception du packet et l'affichage à l'écran, -n affiche l'IP sans chercher à obtenir son nom de domaine (traduction: c'est moins lent), et la partie port http and src <ip> précise qu'il faut filtrer selon ces deux critères.

$ tcpdump -A -q -n port http and src 190.129.173.130
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
15:14:56.067287 IP 190.129.173.130.33860 > bisounours.com.80: tcp 0
E..<.z@.1...P`M...V>.D.P4.W.........LH.........
..E.........
15:14:56.113868 IP 190.129.173.130.33860 > bisounours.com.80: tcp 0
E..(.{@.1...P`M...V>.D.P4.W....xP....>........
15:14:56.119860 IP 190.129.173.130.33860 > bisounours.com.80: tcp 98
E....|@.1...P`M...V>.D.P4.W....xP.......GET / HTTP/1.1
Host: stats.bisounours.com
Accept: */*
Content-type: text/xml
Content-length: 0


15:14:56.145325 IP 190.129.173.130.33805 > bisounours.com.80: tcp 0
E..(..@.1...P`M...V>...P..
	)SM.P..:.=........
15:14:56.145334 IP 190.129.173.130.33775 > bisounours.com.80: tcp 0
E..(..@.1...P`M...V>...P......B.P..:.U........
15:14:56.150812 IP 190.129.173.130.33861 > bisounours.com.80: tcp 0
E..<..@.1...P`M...V>.E.P4......................
..F.........
15:14:56.197107 IP 190.129.173.130.33861 > bisounours.com.80: tcp 0
E..(..@.1...P`M...V>.E.P4.....#.P...4.........
15:14:56.199615 IP 190.129.173.130.33861 > bisounours.com.80: tcp 98
E.....@.1..wP`M...V>.E.P4.....#.P...#y..GET / HTTP/1.1
Host: stats.bisounours.com
Accept: */*
Content-type: text/xml
Content-length: 0

Super, l'attaque consiste à envoyer une requête HTTP qui correspond exactement à ça :

GET / HTTP/1.1
Host: stats.bisounours.com
Accept: */*
Content-type: text/xml
Content-length: 0

C'est à dire que très peu de champs sont remplis. On a bien le Referer qui est absent, ainsi que l'User-Agent. Et ça, c'est caractéristique des attaques (ou des mauvais développeurs). On peut donc l'utiliser !

(Soit dit en passant, le client indique utiliser HTTP/1.1 mais les logs serveur indiquent 1.0 ... Bref, pas important pour l'instant)

On expérimente (oui, on teste en prod'. C'est rigolo, hein ? :3) une configuration Fail2Ban et on relance tout :

[Definition]
failregex = ^<HOST> -.*403.*"WordPress/.*
failregex = ^<HOST> -.*"GET / HTTP/1.0" 301 185 "-" "-"
ignoreregex =

Puis service fail2ban restart, et on surveille la consommation CPU (htop) ainsi que les logs Fail2Ban (tail -f /var/log/fail2ban.log). Et là, c'est le drame. Le VRAI drame.

La consommation CPU ne diminue pas, les logs n'indiquent aucun ban, les logs nginx continuent d'exploser ... AU SECOURS !!!!
Réflexe, quand on devient énervé et stressé, arrêter tout. Comme ça, la consommation CPU redescend et on peut se concentrer sur le véritable problème. Et quelle bonne idée, on se rend compte que non seulement le CPU revient à son allure normale, mais en plus, Fail2Ban continue d'être à 100% !

Mais, pourquoi Fail2Ban utiliserait-il 100% de CPU alors qu'il n'y a plus aucune requête entrante ? La raison est simple. En utilisant fail2ban restart, fail2ban a décidé de relire l'historique entier des logs nginx. Quelle bonne idée !

La solution dans ce cas là, c'est d'arrêter Fail2Ban, de demander gentiment (avec -f) à logrotate d'effectuer une rotation, et enfin de relancer Fail2Ban:

$ service fail2ban stop
$ logrotate -f /etc/logrotate.d/nginx
$ service fail2ban start

Et là, ça va mieux. nginx est arrêté et la consommation CPU est stable. On peut le relancer, et surveiller fail2ban-client status http-wordpress-dos. On voit bien des bannissements, mais en observant les logs nginx on se rend compte que ... des attaquants utilisent un User-Agent WordPress. Bizarre, notre configuration précise pourtant qu'il faut les bloquer ... Sauf que non.

Il est en effet possible de spécifier à Fail2Ban plusieurs formats de lignes à considérer comme erronées (et donc, qui entrainent à un bannissement), mais il faut les spécifier à la suite, sans re-déclarer le failregex=.
Par exemple, pour avoir nos deux règles, il faudrait utiliser cette configuration :

[Definition]
failregex = ^<HOST> -.*403.*"WordPress/.*
            ^<HOST> -.*"GET / HTTP/1.0" 301 185 "-" "-"
ignoreregex =

Et là, en faisant service fail2ban reload (et non restart, sans quoi le fichier de journaux est relu depuis le début), on a bien des IP bloquées de manière régulière .... puis seul le traffic légitime peut passer.

En vrai, j'ai utilisé des règles un peu plus brutales que cette détéction d'User-Agent & Referer vide, mais elles sont peut être trop brutales pour être dévoilées au public. Et j'ai augmenté la durée du blocage à 30 jours (au lieu de 1, c'est 30 fois plus !).

Et pour finir, un peu de vanité o/

$ fail2ban-client status http-wordpress-dos
Status for the jail: http-wordpress-dos
|- filter
|  |- File list:	/var/log/nginx/access.log
|  |- Currently failed:	17
|  `- Total failed:	61268
`- action
   |- Currently banned:	545
   |  `- IP list:	148.251.13.180 .............
   `- Total banned:	545

Conclusion: Ajouter des règles de bloquage c'est simple, il faut juste avoir les bons réflexes et le seul moyen de les obtenir est de tester. Lire mon contenu, c'est bien, et je vous en remercie, mais ce qui compte, c'est ce que vous savez faire. :-)

Comme d'habitude, je suis joignable sur Twitter si vous avez envie de démolir cet article. @PunKeel

BONUS (Sauf si vous utilisez OpenVZ)

Ajouter la ligne net.ipv4.netfilter.ip_conntrack_tcp_timeout_established = 10 à /etc/sysctl.conf (puis faire sysctl -p) permet de réduire le délai de coupure des connexions mortes.

En effet, une connexion TCP ouverte reste ouverte pendant un certain temps, même si elle n'est plus utilisée. Cela gaspille des ressources,
et en particulier cela ne libère pas les resources utilisées lors de l'attaque : votre serveur semblera occupé alors même que l'attaque sera bloquée.

PunKeel

PunKeel

INTP, Hacker, Développeur, Défenseur Open-Source, fan de Ghibli. Fan de nouvelles technologies, y compris systemd, mais allergique à Docker.

Read More