Crowdsec sur k3s

J’ai mis en place crowdsec sur mon cluster auto-hébergé. C’est diablement efficace, même si ça n’a pas été si simple à mettre en place.

A propos de Crowdsec

J’utilise fail2ban depuis très longtemps, qui m’a protégé de beaucoup d’attaques. Quand je suis allé au FOSDEM 2023, j’y ai vu une présentation de Crowdsec, où ils le définissaient comme un fail2ban amélioré, avec un aspect communautaire en plus.

La promesse de l’outil est de partager des listes d’IP à bannir, à partir des attaques constatées chez chaque utilisateur. Et de savoir détecter tout un tas de patterns d’attaques (appelés scenarios), y compris des CVE récentes.

Crowdsec n’est pas un outil simple: je vous encourage à lire ses principaux concepts avant de démarrer (j’avais cru pouvoir en faire l’économie, mais m’y suis résolu après avoir perdu pas mal de temps à faire des configurations qui ne fonctionnaient pas)

D’autre part, Crowdsec n’est pas un outil conçu spécifiquement pour Kubernetes, ce qui en complique parfois un peu l’utilisation (voir les petits pièges à la fin).

Configuration avec crowdsec-bouncer-traefik-plugin

Pour que Crowdsec bloque des IPs, il faut ce qu’ils appellent un Bouncer installé sur un élément par lequel passerait tout le trafic. Dans mon cas de figure, j’ai choisi l’Ingress de mon cluster k3s, donc Traefik.

J’ai utilisé ce bouncer: https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin

C’est un « middleware » au sens Traefik, de type plugin. Il a été conçu pour garder en mémoire des informations, de sorte de ne pas interroger crowdsec lapi à chaque requête http (voir en annexe mon premier essai avec un autre bouncer, qui avait ce défaut).

Et il y a un exemple pour l’utiliser dans kubernetes: https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/tree/main/examples/kubernetes

J’ai pourtant eu un peu de mal à trouver comment le faire marcher. Voici ce que j’ai ajouté dans ma conf traefik pour activer ce plugin:

    experimental:
      plugins:
        enabled: true

    additionalArguments:
      # To enable crowdsec traefik plugin globally
      - "--entrypoints.web.http.middlewares=crowdsec-crowdsec-bouncer-traefik-plugin@kubernetescrd"
      - "--entrypoints.websecure.http.middlewares=crowdsec-crowdsec-bouncer-traefik-plugin@kubernetescrd"
      - "--experimental.plugins.crowdsec-bouncer-traefik-plugin.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.crowdsec-bouncer-traefik-plugin.version=v1.2.0"

Et il a fallu déployer ce manifest de middleware Traefik:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: crowdsec-bouncer-traefik-plugin
  namespace: crowdsec
spec:
  plugin:
    crowdsec-bouncer-traefik-plugin:
      crowdsecLapiKey: xxx
      enabled: "true"
      # I suggest to start in "live" mode for the first tests, then to switch to "stream" mode
      crowdsecMode: live
      # Useful in stream mode to reduce the risk of Traefik blocking requests if lapi is unavailable
      updateIntervalSeconds: 300
      crowdsecLapiHost: crowdsec-service.crowdsec.svc.cluster.local:8080
      crowdsecLapiScheme: http
      # Increase timeout because my lapi pod can sometimes be slow
      httpTimeoutSeconds: 30
      # Useful only in case there is another reverse-proxy in front of Traefik
      #forwardedHeadersTrustedIPs:
      #  - 192.168.1.0/24
      #  - 10.0.0.0/8
      # We don't want to block local IPs
      clientTrustedIPs: 
        - 192.168.1.0/24

et ce fichier values pour le chart Helm de crowdsec (c’était ma première version, que j’ai enrichie ensuite avec d’autres acquisitions, collections etc):

container_runtime: containerd

agent:
  acquisition:
    - namespace: kube-system
      podName: traefik-*
      program: traefik
  env:
    - name: TZ
      value: "Europe/Paris"
    - name: PARSERS
      value: "crowdsecurity/cri-logs"
    # The traefik collection deploys the parser, and many scenarios working on HTTP logs, see https://app.crowdsec.net/hub/author/crowdsecurity/collections/traefik
    - name: COLLECTIONS
      value: "crowdsecurity/traefik"
    # We disable the default whitelist, so that it also detects potential attacks from the local network
    - name: DISABLE_PARSERS
      value: "crowdsecurity/whitelists"
  persistentVolume:
    config:
      enabled: false
lapi:
  dashboard:
    enabled: false
  env:
    - name: TZ
      value: "Europe/Paris"
    - name: ENROLL_KEY
      value: "yyyy"
    - name: ENROLL_INSTANCE_NAME
      value: "k3s_autohebergement"
    - name: ENROLL_TAGS
      value: "k8s linux"
  persistentVolume:
    data:
      enabled: true
      storageClassName: my-storage-class
    config:
      enabled: true
      storageClassName: my-storage-class

Configuration pour Nextcloud

Peu de temps après la mise en route, j’ai eu des clients Nextcloud (notamment celui intégré dans les smartphones /e/ OS) qui déclenchaient le scenario http-probing de crowdsec, ce qui faisait bannir leurs IP.

La raison était qu’ils parcourent parfois les répertoires synchronisés avec leur compte nextcloud (en webdav), et que certaines de ces requêtes HTTP renvoient une erreur 404. Crowdsec trouve cela louche, l’assimilant à un attaquant qui essaierait de trouver des fichiers php exploitables.

Heureusement, il est possible d’ajouter des whitelists. Au départ, j’avais ajouté la suivante:

name: mossroy/nextcloud-whitelist
description: my custom nextcloud whitelist
whitelist:
  reason: do not ban nextcloud clients for browsing on webdav
  expression:
     - "evt.Parsed.request matches '^/remote.php/dav/.*' and evt.Parsed.verb == 'PROPFIND'"

A noter que la commande « cscli explain » m’a été précieuse pour tester les whitelists. Il faut la lancer depuis un agent crowdsec. Elle permet facilement de tester une ligne de log, et voir comment elle est analysée par crowdsec. Exemple:

cscli explain --log 'x.x.x.x - - [01/Feb/2024:19:09:16 +0100] "PROPFIND /remote.php/dav/calendars/... HTTP/2.0" 404 281 "-" "-" 130334 "websecure-nextcloud-nextcloud-...@kubernetes" "http://10.42.2.43:80" 109ms' --type traefik --verbose

Ca affiche la manière dont il parse la ligne (avec les différents parsers disponibles), et si les whitelists matchent ou pas.

Mais c’est ensuite que je me suis rendu compte qu’il y avait une whitelist toute prête: https://app.crowdsec.net/hub/author/crowdsecurity/configurations/nextcloud-whitelist. Pour l’activer, il suffit d’ajouter crowdsecurity/nextcloud-whitelist dans la variable d’environnement PARSERS des agents, dans mon values.yaml. C’est bien plus complet que la whitelist que j’avais fait à la main.

Mais surtout, il y a encore plus simple: utiliser la « collection » nextcloud, qui permet d’un coup de déployer la whitelist, un parser spécifique pour le nextcloud.log, et un scenario de détection d’attaque bruteforce sur nextcloud. Il suffit pour cela d’ajouter crowdsecurity/nextcloud dans la variable d’environnement SCENARIOS des agents (au lieu de tout le reste de ce paragraphe).

NB: avec une installation par défaut de nextcloud sur kubernetes, le fichier nextcloud.log est écrit dans le PV de données, et pas parsé par Crowdsec. J’en parlerai probablement dans un autre article.

Whitelisting pour d’autres applications

J’ai aussi eu des bans pour freshrss et kanboard, en http-probing. Pour eux, il n’y a pas de whitelist existante dans le « hub » de Crowdsec, donc j’ai dû les créer moi-même.

Pour les déployer, je les ai simplement ajoutées dans le values.yaml:

config:
  parsers:
    s02-enrich:
      mossroyfreshrsswhitelist.yaml: |
        name: mossroy/freshrss-whitelist
        description: my custom freshrss whitelist
        whitelist:
          reason: do not ban freshrss clients for browsing the API (the authentication is on another path)
          expression:
            - "evt.Parsed.request matches '^/api/greader.php/reader/api/.*'"
      mossroykanboardwhitelist.yaml: |
        name: mossroy/kanboard-whitelist
        description: my custom kanboard whitelist
        whitelist:
          reason: do not ban kanboard clients for HTTP 401 on jsonrpc
          expression:
            - "evt.Parsed.request matches '^/jsonrpc.php$' and evt.Meta.http_status == '401'"

Mode stream

Par défaut, le plugin est en mode « live », c’est-à-dire qu’il interroge le pod lapi avant de décider s’il bloque ou pas. Il garde des informations en cache, ce qui permet de limiter un peu l’overhead de performance.

Mais il supporte aussi le mode « stream », pour lequel Traefik garde en mémoire l’ensemble des informations nécessaires à la prise de décision: c’est donc plus performant. Et il met à jour ces informations depuis le pod lapi au démarrage, puis toutes les minutes (par défaut). Une indisponibilité de ce pod lapi ne bloque plus tout le trafic entrant, sauf si ça tombe au moment de sa mise à jour. Pour limiter ce risque, j’ai ajouté updateIntervalSeconds: 300 dans la configuration du plugin Traefik, pour qu’il mette à jour ses informations toutes les 5 minutes plutôt que toutes les minutes. C’est un compromis: il sera moins réactif sur le blocage d’une attaque ciblée, mais les redémarrages du pod lapi feront moins d’indisponibilité.

Au départ, quand je choisissais ce mode « stream », traefik semblait tout bloquer. La raison était que l’interrogation du pod lapi au démarrage le sollicite beaucoup, notamment niveau consommation mémoire, ce qui dépassait la limite de mémoire fixée au pod. L’augmenter (à 200Mo au lieu de 100Mo) a permis de régler le problème:

lapi:
  (...)
  resources:
    requests:
      memory: 100Mi
    limits:
      memory: 200Mi

Ce chargement initial prend un peu plus de temps au démarrage de Traefik, mais ça reste supportable. Si besoin, il est possible d’activer un cache redis pour éviter ça.

Configuration du profil de remediation, et notifications par e-mail

En surchargeant le fichier profiles.yaml par défaut (via le values.yaml), on peut lui indiquer d’augmenter le temps de ban quand il y a récidive.

C’est aussi là qu’on peut lui demander d’éviter de créer des décisions (et donc d’envoyer des notifications) quand il y a déjà une décision récente sur la même IP. L’intérêt principal (dans mon cas de figure) est d’éviter de recevoir des notifications pour les IPs qui font partie d’une blocklist à laquelle je suis abonné (en particulier la community). Source: https://discourse.crowdsec.net/t/how-to-avoid-getting-alerts-for-ips-on-subscribed-blocklists/1648

D’autre part, pour être prévenu par e-mail, il faut mettre un peu de configuration supplémentaire aussi:

config:
  (...)
  profiles.yaml: |
    name: default_ip_remediation
    debug: true
    filters:
      # The GetDecisionsSinceCount avoids duplicate decisions, especially in the case of IPs already in a blocklist
      - Alert.Remediation == true && Alert.GetScope() == "Ip" && GetDecisionsSinceCount(Alert.GetValue(), "2h") < 1
    decisions:
    decisions:
      - type: ban
        duration: 4h
    # To configure an increasing ban time
    duration_expr: Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)
    notifications:
      - email_default  # Set the required email parameters in /etc/crowdsec/notifications/email.yaml before enabling this.
    on_success: break
  notifications:
    email.yaml: |
      type: email
      name: email_default
      log_level: warn
      smtp_host: mail.infomaniak.com
      smtp_port: 587
      smtp_username: 'xxxx'
      smtp_password: 'xxxx'
      sender_email: xxx@mossroy.fr
      receiver_emails:
        - mossroy@mossroy.fr
      encryption_type: starttls
      auth_type: plain
      email_subject: CrowdSec Notification
      format: |  # This template receives list of models.Alert objects
        <html><body>
        {{range . -}}
          {{$alert := . -}}
          {{range .Decisions -}}
            <p><a href="https://www.whois.com/whois/{{.Value}}">{{.Value}}</a> will get <b>{{.Type}}</b> for next <b>{{.Duration}}</b> for triggering <b>{{.Scenario}}</b> on machine <b>{{$alert.MachineID}}</b>.</p> <p><a href="https://app.crowdsec.net/cti/{{.Value}}">CrowdSec CTI</a></p>
          {{end -}}
        {{end -}}
        </body></html>

Si les emails n’arrivent pas, il faut aller voir les logs du pod lapi. Ca m’a été utile pour mettre au point la configuration SMTP.

Petits pièges

Les scenarios et collections ne font rien tant qu’ils n’ont pas de parser et surtout d’acquisition adequats: sous Kubernetes, il faut déclarer ces acquisitions dans le values.yaml, en plus de l’acquisition de traefik. En précisant à chaque fois le namespace (si besoin avec des étoiles pour en matcher plusieurs), le pod (étoiles en général nécessaires), et le nom du process qui les génère. Dans cet article, je n’ai mis que l’acquisition de Traefik, mais prépare un autre article avec d’autres exemples.

Les parsers/scenarios/collections sont injectés dans les pods agents, pas dans le pod lapi. Donc il peut être piégeux de regarder la configuration depuis le pod lapi, ou de la tester avec cscli explain par exemple. Il faut faire ça plutôt au niveau des agents.

Les documentations trouvées sur Internet (ou sur le forum de crowdsec) vous invitent parfois à lancer des commandes cscli xxx install (quand ce sont des docs adaptées à des déploiements hors kubernetes). Mais, avec le chart Helm, il ne faut pas faire comme ça: il faut déclarer l’équivalent dans le values.yaml.

Je pensais avoir un bug en constatant que certaines IPs n’étaient pas bloquées chez moi, alors qu’elles font partie de la community blockist. En fait c’est « normal », cela dépend des scenarios configurés: on ne reçoit que les IPs correspondant aux scenarios activés chez soi. Cf https://docs.crowdsec.net/docs/troubleshooting/#i-receive-few-ips-in-the-community-blocklist

Résultat

Avant la mise en place de Crowdsec, j’utilisais fail2ban, et étais submergé d’attaques bruteforce sur les pods wordpress. Quasiment toutes les IPs que fail2ban me bloquait sont maintenant bloquées nativement à travers la community blocklist. C’est un énorme gain pour se prémunir facilement de la grande majorité de ceux qui scannent tout ce qu’ils peuvent.

Je reçois encore des alertes, mais tant mieux, parce qu’elles sont liées à des patterns d’attaque que fail2ban ne détectait pas auparavant (ex: crowdsecurity/http-admin-interface-probing, crowdsecurity/http-bad-user-agent, crowdsecurity/http-crawl-non_statics).

Je compte aussi ajouter des scenarios custom pour d’autres applicatifs (si besoin, je ferai un autre article)

Une évolution possible serait d’utiliser un bouncer sur mon Turris, qui y bloquerait les IPs au niveau firewall (avant même d’arriver sur Traefik). crowdsec-firewall-bouncer est un package openwrt qui serait à tester pour ça.

Annexe: premier essai infructueux avec crowdsec-traefik-bouncer

Au départ, j’étais parti de cet article pour l’installation: https://www.crowdsec.net/blog/how-to-mitigate-security-threats-with-crowdsec-and-traefik

Dans mon cas de figure où j’avais un reverse-proxy frontal devant l’ingress Traefik (dont je me suis débarassé depuis), il a fallu que j’ajoute la ligne suivante dans l’objet Middleware « traefik-bouncer »:

trustForwardHeader: true

J’ai mis un moment à trouver ce paramètre, sans lequel le bouncer considérait toujours que l’IP de l’utilisateur était celle du reverse-proxy frontal, et ne bloquait jamais rien.

J’ai dû l’ajouter à la main car il n’est pas prévu dans le chart Helm du Bouncer (voir https://github.com/crowdsecurity/helm-charts/blob/main/charts/crowdsec-traefik-bouncer/templates/middleware.yaml)

Et ça a fonctionné! Enfin… oui et non. Ca m’a bien bloqué certaines IP d’attaquants, mais au prix d’une énorme dégradation de performances, qu’on peut voir ci-dessous: chaque requête HTTP entrante était ralentie d’au moins 2 secondes!

Le problème, c’est que, à chaque requête HTTP entrante sur Traefik, il contacte le bouncer, qui contacte lapi. Ce dernier appel prend sur mon cluster entre 150 ms et … plus de 5 secondes! Quand ça dépasse 5 secondes, la requête HTTP entrante est refusée (timeout). Symptôme: https://github.com/fbonalair/traefik-crowdsec-bouncer/issues/42.

J’ai essayé pas mal choses pour améliorer les performances (affinities, node plus puissant), sans succès. Il y avait probablement une raison à cette lenteur, mais j’ai arrêté d’investiguer: de mon point de vue, l’architecture de ce bouncer n’est pas adéquate: bloquer chaque requête entrante pour consulter crowdsec ne me parait pas tenable d’un point de vue performances, et n’est pas conforme à ce qui est dit dans la FAQ de crowdsec. Il y a un ticket (et même 2 PRs) pour mettre en place du cache, mais ça n’a pas bougé depuis septembre 2022… D’ailleurs l’ensemble du code de ce bouncer n’a pas bougé depuis octobre 2022.

C’est probablement la raison pour laquelle ce bouncer n’est pas listé dans les bouncers officiels de crowdsec.

Bref, j’ai laissé tomber. Dommage que crowdsec n’ait pas mis à jour son article de blog pour dire que ce bouncer était obsolète… Et dommage qu’ils le proposent à nouveau dans un article plus récent: https://www.crowdsec.net/blog/enhance-docker-compose-security

2 réflexions sur « Crowdsec sur k3s »

  1. Tres interessant effectivement. J’avais vu cela aussi https://reaction.ppom.me
    Sinon concernant la protection applicative L7, je préfère basculer sur un module comme modsecurite (ou son remplacent Coraza), pour le coup il est beaucoup puissant et complet, c’est un veritable WAF. Il est opencource et comme Crowdsec il dispose de plugin pour WordPress et Netxcloud pour gérer les faux positifs. Encore merci pour ces infos.

    1. Merci de ton retour, et des suggestions d’alternatives.

      Ces 2 outils n’ont a priori pas la dimension communautaire de Crowdsec: partage des IPs suspectes (celles bloquées localement), et abonnement à une liste des IPs à bloquer (celles menant des attaques confirmées plusieurs fois). C’est cette fonctionnalité qui m’a convaincu de tester Crowdsec, qui apporte un vrai plus dans mon contexte: elle m’évite une grosse partie des attaques (celles non ciblées spécifiquement contre moi)

      Par contre, il est effectivement possible que les solutions que tu cites soient plus adaptées pour contrer des attaques ciblées, je ne sais pas.

      D’autre part, je n’ai rien trouvé pour l’intégration de Reaction dans un cluster kubernetes (probablement dû à la jeunesse de cet outil). Pour Coraza, rien non plus dans leur doc, mais c’est probablement possible d’une manière ou d’une autre.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *