Détection et suppression des DDoS avec Akvorado et Flowspec

Vincent Bernat

Akvorado collecte des flux sFlow et IPFIX, les stocke dans une base de données ClickHouse et les présente dans une console web. Bien qu’il n’y ait pas de détection de DDoS intégrée, il est possible d’en créer une à partir de requêtes pour ClickHouse.

Détection des attaques DDoS#

Supposons que nous voulions détecter des attaques DDoS ciblant nos clients. À titre d’exemple, nous considérons une attaque DDoS comme une collection de flux sur une minute ciblant une adresse IP client unique, à partir d’un port source unique et correspondant à l’une de ces conditions :

  • une bande passante moyenne de 1 Gbit/s,
  • une bande passante moyenne de 200 Mbit/s lorsque le protocole est UDP,
  • plus de 20 adresses IP sources et une bande passante moyenne de 100 Mbit/s, ou
  • plus de 10 pays sources et une bande passante moyenne de 100 Mbit/s.

Voici la requête SQL pour détecter de telles attaques au cours des 5 dernières minutes :

SELECT *
FROM (
  SELECT
    toStartOfMinute(TimeReceived) AS TimeReceived,
    DstAddr,
    SrcPort,
    dictGetOrDefault('protocols', 'name', Proto, '???') AS Proto,
    SUM(((((Bytes * SamplingRate) * 8) / 1000) / 1000) / 1000) / 60 AS Gbps,
    uniq(SrcAddr) AS sources,
    uniq(SrcCountry) AS countries
  FROM flows
  WHERE TimeReceived > now() - INTERVAL 5 MINUTE
    AND DstNetRole = 'customers'
  GROUP BY
    TimeReceived,
    DstAddr,
    SrcPort,
    Proto
)
WHERE (Gbps > 1)
   OR ((Proto = 'UDP') AND (Gbps > 0.2)) 
   OR ((sources > 20) AND (Gbps > 0.1)) 
   OR ((countries > 10) AND (Gbps > 0.1))
ORDER BY
  TimeReceived DESC,
  Gbps DESC

Voici un exemple de sortie1 où deux de nos utilisateurs sont attaqués. L’un d’eux subit apparemment une attaque d’amplification NTP, l’autre une attaque d’amplification DNS :

TimeReceived DstAddr SrcPort Proto Gbps sources countries
2023-02-26 17:44:00 ::ffff:203.0.113.206 123 UDP 0.102 109 13
2023-02-26 17:43:00 ::ffff:203.0.113.206 123 UDP 0.130 133 17
2023-02-26 17:43:00 ::ffff:203.0.113.68 53 UDP 0.129 364 63
2023-02-26 17:43:00 ::ffff:203.0.113.206 123 UDP 0.113 129 21
2023-02-26 17:42:00 ::ffff:203.0.113.206 123 UDP 0.139 50 14
2023-02-26 17:42:00 ::ffff:203.0.113.206 123 UDP 0.105 42 14
2023-02-26 17:40:00 ::ffff:203.0.113.68 53 UDP 0.121 340 65

Suppression des attaques DDoS#

Une fois détectée, il existe au moins deux façons d’arrêter l’attaque au niveau du réseau :

  • supprimer tout le trafic vers l’utilisateur ciblé (RTBH)
  • supprimer sélectivement les paquets correspondant aux motifs de l’attaque (Flowspec)

Suppression du trafic avec RTBH#

La méthode la plus simple consiste à sacrifier l’utilisateur attaqué. Bien que cela aide l’attaquant, cela protège avant tout votre réseau. C’est une méthode prise en charge par tous les routeurs. Vous pouvez également déléguer cette protection à la plupart des fournisseurs de transit. Cela est utile si le volume des attaques dépasse la capacité de votre connexion Internet.

Cela fonctionne en annonçant avec BGP une route vers l’utilisateur attaqué avec une communauté spécifique. Le routeur de bordure modifie l’adresse du prochain routeur de ces routes vers une adresse IP spécifique configurée pour transférer le trafic vers l’interface « nulle ». La RFC 7999 définit 65535:666 à cette fin. C’est ce qu’on appelle un trou noir déclenché à distance (“remote triggered blackhole”, RTBH) et expliqué en détail dans la RFC 3882.

Il est également possible de bloquer la source des attaques en utilisant uRPF défini dans la RFC 3704. C’est expliqué dans la RFC 5635. Cependant, uRPF peut être une charge importante pour les ressources de votre routeur. Consultez “NCS5500 uRPF: Configuration et Impact sur l’échelle” pour un exemple des types de restrictions auxquelles vous devez vous attendre lorsque vous activez uRPF.

Pour annoncer les routes, nous pouvons utiliser BIRD. Voici un fichier de configuration complet pour permettre à tout routeur de les collecter :

log stderr all;
router id 192.0.2.1;

protocol device {
  scan time 10;
}

protocol bgp exporter {
  ipv4 {
    import none;
    export where proto = "blackhole4";
  };
  ipv6 {
    import none;
    export where proto = "blackhole6";
  };
  local as 64666;
  neighbor range 192.0.2.0/24 external;
  multihop;
  dynamic name "exporter";
  dynamic name digits 2;
  graceful restart yes;
  graceful restart time 0;
  long lived graceful restart yes;
  long lived stale time 3600;  # keep routes for 1 hour!
}

protocol static blackhole4 {
  ipv4;
  route 203.0.113.206/32 blackhole {
    bgp_community.add((65535, 666));
  };
  route 203.0.113.68/32 blackhole {
    bgp_community.add((65535, 666));
  };
}
protocol static blackhole6 {
  ipv6;
}

Nous utilisons BGP LLGR pour garantir que les routes sont conservées pendant une heure, même si la connexion BGP est interrompue, notamment pendant une maintenance.

Du côté du récepteur, si vous disposez d’un routeur Cisco fonctionnant sous IOS XR, vous pouvez utiliser la configuration suivante pour éliminer le trafic reçu sur la session BGP. Comme la session BGP est dédiée à cette utilisation, la communauté 65535:666 n’est pas utilisée, mais vous pouvez aussi transférer ces routes à vos fournisseurs de transit.

router static
 vrf public
  address-family ipv4 unicast
   192.0.2.1/32 Null0 description "BGP blackhole"
  !
  address-family ipv6 unicast
   2001:db8::1/128 Null0 description "BGP blackhole"
  !
 !
!
route-policy blackhole_ipv4_in_public
  if destination in (0.0.0.0/0 le 31) then
    drop
  endif
  set next-hop 192.0.2.1
  done
end-policy
!
route-policy blackhole_ipv6_in_public
  if destination in (::/0 le 127) then
    drop
  endif
  set next-hop 2001:db8::1
  done
end-policy
!
router bgp 12322
 neighbor-group BLACKHOLE_IPV4_PUBLIC
  remote-as 64666
  ebgp-multihop 255
  update-source Loopback10
  address-family ipv4 unicast
   maximum-prefix 100 90
   route-policy blackhole_ipv4_in_public in
   route-policy drop out
   long-lived-graceful-restart stale-time send 86400 accept 86400
  !
  address-family ipv6 unicast
   maximum-prefix 100 90
   route-policy blackhole_ipv6_in_public in
   route-policy drop out
   long-lived-graceful-restart stale-time send 86400 accept 86400
  !
 !
 vrf public
  neighbor 192.0.2.1
   use neighbor-group BLACKHOLE_IPV4_PUBLIC
   description akvorado-1

Lorsque le trafic est éliminé, il est toujours remonté par IPFIX et sFlow. Dans Akvorado, utilisez ForwardingStatus >= 128 comme filtre pour sélectionner celui-ci.

Bien que cette méthode soit compatible avec tous les routeurs, elle permet à l’attaque de réussir car la cible est complètement inaccessible. Si votre routeur le prend en charge, Flowspec peut filtrer sélectivement les flux pour arrêter l’attaque sans affecter le client.

Suppression du trafic avec Flowspec#

Flowspec est défini dans la RFC 8955 et permet la transmission de spécifications de flux dans des sessions BGP. Une spécification de flux est un ensemble de critères à appliquer au trafic IP. Ceux-ci incluent le préfixe source et destination, le protocole IP, le port source et destination et la longueur des paquets. Chaque spécification de flux est associée à une action, encodée sous forme d’une communauté étendue : limitation du trafic, marquage ou redirection.

Pour annoncer des spécifications de flux avec BIRD, nous complétons notre configuration. La communauté étendue utilisée limite le trafic correspondant à 0 octet par seconde.

flow4 table flowtab4;
flow6 table flowtab6;

protocol bgp exporter {
  flow4 {
    import none;
    export where proto = "flowspec4";
  };
  flow6 {
    import none;
    export where proto = "flowspec6";
  };
  # […]
}

protocol static flowspec4 {
  flow4;
  route flow4 {
    dst 203.0.113.68/32;
    sport = 53;
    length >= 1476 && <= 1500;
    proto = 17;
  }{
    bgp_ext_community.add((generic, 0x80060000, 0x00000000));
  };
  route flow4 {
    dst 203.0.113.206/32;
    sport = 123;
    length = 468;
    proto = 17;
  }{
    bgp_ext_community.add((generic, 0x80060000, 0x00000000));
  };
}
protocol static flowspec6 {
  flow6;
}

Si vous avez un routeur ASR tournant sous IOS XR, la configuration ressemble à ceci :

vrf public
 address-family ipv4 flowspec
 address-family ipv6 flowspec
!
router bgp 12322
 address-family vpnv4 flowspec
 address-family vpnv6 flowspec
 neighbor-group FLOWSPEC_IPV4_PUBLIC
  remote-as 64666
  ebgp-multihop 255
  update-source Loopback10
  address-family ipv4 flowspec
   long-lived-graceful-restart stale-time send 86400 accept 86400
   route-policy accept in
   route-policy drop out
   maximum-prefix 100 90
   validation disable
  !
  address-family ipv6 flowspec
   long-lived-graceful-restart stale-time send 86400 accept 86400
   route-policy accept in
   route-policy drop out
   maximum-prefix 100 90
   validation disable
  !
 !
 vrf public
  address-family ipv4 flowspec
  address-family ipv6 flowspec
  neighbor 192.0.2.1
   use neighbor-group FLOWSPEC_IPV4_PUBLIC
   description akvorado-1

Ensuite, il convient d’activer Flowspec sur toutes les interfaces :

flowspec
 vrf public
  address-family ipv4
   local-install interface-all
  !
  address-family ipv6
   local-install interface-all
  !
 !
!

Comme pour la configuration à base de RTBH, vous pouvez afficher les flux éliminés avec le filtre ForwardingStatus >= 128.

Détection des attaques DDoS (suite)#

Dans l’exemple utilisant Flowspec, les flux sont aussi filtrés selon la longueur des paquets :

route flow4 {
  dst 203.0.113.68/32;
  sport = 53;
  length >= 1476 && <= 1500;
  proto = 17;
}{
  bgp_ext_community.add((generic, 0x80060000, 0x00000000));
};

C’est une addition importante : les requêtes DNS légitimes ont une taille inférieure à cela et ne sont donc pas filtrées2. Avec ClickHouse, vous pouvez obtenir les 10ème et 90ème centiles des tailles de paquets avec quantiles(0.1, 0.9)(Bytes/Packets).

Le dernier problème que nous devons résoudre est de rendre les requêtes plus rapides : elles peuvent avoir besoin de plusieurs secondes pour collecter les données et sont susceptibles de consommer des ressources substantielles de votre base de données ClickHouse. Une solution consiste à créer une vue matérialisée pour pré-agréger les résultats :

CREATE TABLE ddos_logs (
  TimeReceived DateTime,
  DstAddr IPv6,
  Proto UInt32,
  SrcPort UInt16,
  Gbps SimpleAggregateFunction(sum, Float64),
  Mpps SimpleAggregateFunction(sum, Float64),
  sources AggregateFunction(uniqCombined(12), IPv6),
  countries AggregateFunction(uniqCombined(12), FixedString(2)),
  size AggregateFunction(quantiles(0.1, 0.9), UInt64)
) ENGINE = SummingMergeTree
PARTITION BY toStartOfHour(TimeReceived)
ORDER BY (TimeReceived, DstAddr, Proto, SrcPort)
TTL toStartOfHour(TimeReceived) + INTERVAL 6 HOUR DELETE ;

CREATE MATERIALIZED VIEW ddos_logs_view TO ddos_logs AS
  SELECT
    toStartOfMinute(TimeReceived) AS TimeReceived,
    DstAddr,
    Proto,
    SrcPort,
    sum(((((Bytes * SamplingRate) * 8) / 1000) / 1000) / 1000) / 60 AS Gbps,
    sum(((Packets * SamplingRate) / 1000) / 1000) / 60 AS Mpps,
    uniqCombinedState(12)(SrcAddr) AS sources,
    uniqCombinedState(12)(SrcCountry) AS countries,
    quantilesState(0.1, 0.9)(toUInt64(Bytes/Packets)) AS size
  FROM flows
  WHERE DstNetRole = 'customers'
  GROUP BY
    TimeReceived,
    DstAddr,
    Proto,
    SrcPort

La table ddos_logs utilise le moteur SummingMergeTree. Lorsque la table reçoit de nouvelles données, ClickHouse remplace toutes les lignes ayant la même clé de tri, telle que définie par la directive ORDER BY, par une seule ligne qui contient des valeurs résumées3 en utilisant soit la fonction sum() soit la fonction d’agrégation explicitement spécifiée (uniqCombined et quantiles dans notre exemple).

Enfin, nous modifions notre requête initiale avec la requête suivante :

SELECT *
FROM (
  SELECT
    TimeReceived,
    DstAddr,
    dictGetOrDefault('protocols', 'name', Proto, '???') AS Proto,
    SrcPort,
    sum(Gbps) AS Gbps,
    sum(Mpps) AS Mpps,
    uniqCombinedMerge(12)(sources) AS sources,
    uniqCombinedMerge(12)(countries) AS countries,
    quantilesMerge(0.1, 0.9)(size) AS size
  FROM ddos_logs
  WHERE TimeReceived > now() - INTERVAL 60 MINUTE
  GROUP BY
    TimeReceived,
    DstAddr,
    Proto,
    SrcPort
)
WHERE (Gbps > 1)
   OR ((Proto = 'UDP') AND (Gbps > 0.2)) 
   OR ((sources > 20) AND (Gbps > 0.1)) 
   OR ((countries > 10) AND (Gbps > 0.1))
ORDER BY
  TimeReceived DESC,
  Gbps DESC

Assemblage final#

Pour résumer, la construction d’un système de suppression des attaques DDoS nécessite de suivre les étapes suivantes :

  1. définir un ensemble de critères pour détecter une attaque DDoS
  2. traduire ces critères en requêtes SQL
  3. pré-agréger les flux dans des tables SummingMergeTree
  4. interroger et transformer les résultats en un fichier de configuration pour BIRD
  5. configurer vos routeurs pour extraire les routes de BIRD

Un script Python comme celui qui suit permet de gérer la quatrième étape. Pour chaque cible attaquée, il génère à la fois une règle Flowspec et une route de type « trou noir ».

import socket
import types
from clickhouse_driver import Client as CHClient

# Insérez ici votre requête SQL
SQL_QUERY = "…"

# Combien de règles anti-DDoS doit-on publier au plus ?
MAX_DDOS_RULES = 20

def empty_ruleset():
    ruleset = types.SimpleNamespace()
    ruleset.flowspec = types.SimpleNamespace()
    ruleset.blackhole = types.SimpleNamespace()
    ruleset.flowspec.v4 = []
    ruleset.flowspec.v6 = []
    ruleset.blackhole.v4 = []
    ruleset.blackhole.v6 = []
    return ruleset

current_ruleset = empty_ruleset()

client = CHClient(host="clickhouse.akvorado.net")
while True:
    results = client.execute(SQL_QUERY)
    seen = {}
    new_ruleset = empty_ruleset()
    for (t, addr, proto, port, gbps, mpps, sources, countries, size) in results:
        if (addr, proto, port) in seen:
            continue
        seen[(addr, proto, port)] = True

        # Flowspec
        if addr.ipv4_mapped:
            address = addr.ipv4_mapped
            rules = new_ruleset.flowspec.v4
            table = "flow4"
            mask = 32
            nh = "proto"
        else:
            address = addr
            rules = new_ruleset.flowspec.v6
            table = "flow6"
            mask = 128
            nh = "next header"
        if size[0] == size[1]:
            length = f"length = {int(size[0])}"
        else:
            length = f"length >= {int(size[0])} && <= {int(size[1])}"
        header = f"""
# Time: {t}
# Source: {address}, protocol: {proto}, port: {port}
# Gbps/Mpps: {gbps:.3}/{mpps:.3}, packet size: {int(size[0])}<=X<={int(size[1])}
# Flows: {flows}, sources: {sources}, countries: {countries}
"""
        rules.append(
                f"""{header}
route {table} {{
  dst {address}/{mask};
  sport = {port};
  {length};
  {nh} = {socket.getprotobyname(proto)};
}}{{
  bgp_ext_community.add((generic, 0x80060000, 0x00000000));
}};
"""
        )

        # Blackhole
        if addr.ipv4_mapped:
            rules = new_ruleset.blackhole.v4
        else:
            rules = new_ruleset.blackhole.v6
        rules.append(
            f"""{header}
route {address}/{mask} blackhole {{
  bgp_community.add((65535, 666));
}};
"""
        )

        new_ruleset.flowspec.v4 = list(
            set(new_ruleset.flowspec.v4[:MAX_DDOS_RULES])
        )
        new_ruleset.flowspec.v6 = list(
            set(new_ruleset.flowspec.v6[:MAX_DDOS_RULES])
        )

        # TODO: publier les changements par courriel ou chat

        current_ruleset = new_ruleset
        changes = False
        for rules, path in (
            (current_ruleset.flowspec.v4, "v4-flowspec"),
            (current_ruleset.flowspec.v6, "v6-flowspec"),
            (current_ruleset.blackhole.v4, "v4-blackhole"),
            (current_ruleset.blackhole.v6, "v6-blackhole"),
        ):
            path = os.path.join("/etc/bird/", f"{path}.conf")
            with open(f"{path}.tmp", "w") as f:
                for r in rules:
                    f.write(r)
            changes = (
                changes or not os.path.exists(path) or not samefile(path, f"{path}.tmp")
            )
            os.rename(f"{path}.tmp", path)

        if not changes:
            continue

        proc = subprocess.Popen(
            ["birdc", "configure"],
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        stdout, stderr = proc.communicate(None)
        stdout = stdout.decode("utf-8", "replace")
        stderr = stderr.decode("utf-8", "replace")
        if proc.returncode != 0:
            logger.error(
                "{} error:\n{}\n{}".format(
                    "birdc reconfigure",
                    "\n".join(
                        [" O: {}".format(line) for line in stdout.rstrip().split("\n")]
                    ),
                    "\n".join(
                        [" E: {}".format(line) for line in stderr.rstrip().split("\n")]
                    ),
                )
            )

Jusqu’à ce qu’Akvorado intègre la détection et la lutte contre les attaques DDoS, les idées présentées dans ce billet fournissent une base solide pour commencer à construire votre propre système anti-DDoS. 🛡️


  1. ClickHouse peut exporter les résultats au format Markdown en ajoutant FORMAT Markdown à la requête. ↩︎

  2. Bien que la plupart des clients DNS réessaient avec TCP en cas d’échec, ce n’est pas toujours le cas : jusqu’à récemment, la bibliothèque Musl ne le faisait pas. ↩︎

  3. La vue matérialisée agrège également les données qu’elle a sous la main, à la fois pour l’efficacité et pour s’assurer que nous travaillons avec les bons types de données. ↩︎