Détection et suppression des DDoS avec Akvorado et Flowspec

Vincent Bernat

Ak­vo­rado col­lecte des flux sFlow et IPFIX, les stocke dans une base de don­nées ClickHouse et les pré­sente dans une console web. Bien qu’il n’y ait pas de dé­tec­tion de DDoS in­té­grée, il est pos­sible d’en créer une à par­tir de re­quêtes pour ClickHouse.

Détection des attaques DDoS#

Sup­po­sons que nous vou­lions dé­tec­ter des at­taques DDoS ci­blant nos clients. À titre d’exemple, nous consi­dé­rons une at­taque DDoS comme une col­lec­tion de flux sur une mi­nute ci­blant une adresse IP client unique, à par­tir d’un port source unique et cor­res­pon­dant à l’une de ces conditions :

  • une bande pas­sante moyenne de 1 Gbit/s ;
  • une bande pas­sante moyenne de 200 Mbit/s lorsque le pro­to­cole est UDP ;
  • plus de 20 adresses IP sources et une bande pas­sante moyenne de 100 Mbit/s ;
  • plus de 10 pays sources et une bande pas­sante moyenne de 100 Mbit/s.

Voici la re­quête SQL pour dé­tec­ter de telles at­taques au cours des 5 der­niè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 sor­tie1 où deux de nos uti­li­sa­teurs sont at­ta­qués. L’un d’eux su­bit ap­pa­rem­ment une at­taque d’am­pli­fi­ca­tion NTP, l’autre une at­taque d’am­pli­fi­ca­tion 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é­tec­tée, il existe au moins deux fa­çons d’ar­rê­ter l’at­taque au ni­veau du réseau :

  • sup­pri­mer tout le tra­fic vers l’uti­li­sa­teur ci­blé (RTBH) ;
  • sup­pri­mer sé­lec­ti­ve­ment les pa­quets cor­res­pon­dant aux mo­tifs de l’at­taque (Flowspec).

Suppression du trafic avec RTBH#

La mé­thode la plus simple consiste à sa­cri­fier l’uti­li­sa­teur at­ta­qué. Bien que cela aide l’at­ta­quant, cela pro­tège avant tout votre ré­seau. C’est une mé­thode prise en charge par tous les rou­teurs. Vous pou­vez éga­le­ment dé­lé­guer cette pro­tec­tion à la plu­part des four­nis­seurs de tran­sit. Cela est utile si le vo­lume des at­taques dé­passe la ca­pa­cité de votre connexion Internet.

Cela fonc­tionne en an­non­çant avec BGP une route vers l’uti­li­sa­teur at­ta­qué avec une com­mu­nauté spé­ci­fique. Le rou­teur de bor­dure mo­di­fie l’adresse du pro­chain rou­teur de ces routes vers une adresse IP spé­ci­fique confi­gu­rée pour trans­fé­rer le tra­fic vers l’in­ter­face « nulle ». La RFC 7999 dé­fi­nit 65535:666 à cette fin. C’est ce qu’on ap­pelle un trou noir dé­clen­ché à dis­tance (“re­mote trig­ge­red black­hole”, RTBH) et ex­pli­qué en dé­tail dans la RFC 3882.

Il est éga­le­ment pos­sible de blo­quer la source des at­taques en uti­li­sant uRPF dé­fini dans la RFC 3704. C’est ex­pli­qué dans la RFC 5635. Ce­pen­dant, uRPF peut être une charge im­por­tante pour les res­sources de votre rou­teur. Consul­tez “NCS5500 uRPF: Confi­gu­ra­tion et Im­pact sur l’échelle” pour un exemple des types de res­tric­tions aux­quelles vous de­vez vous at­tendre lorsque vous ac­ti­vez uRPF.

Pour an­non­cer les routes, nous pou­vons uti­li­ser BIRD. Voici un fi­chier de confi­gu­ra­tion com­plet pour per­mettre à tout rou­teur 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 uti­li­sons BGP LLGR pour ga­ran­tir que les routes sont conser­vées pen­dant une heure, même si la connexion BGP est in­ter­rom­pue, no­tam­ment pen­dant une main­tenance.

Du côté du ré­cep­teur, si vous dis­po­sez d’un rou­teur Cisco fonc­tion­nant sous IOS XR, vous pou­vez uti­li­ser la confi­gu­ra­tion sui­vante pour éli­mi­ner le tra­fic reçu sur la ses­sion BGP. Comme la ses­sion BGP est dé­diée à cette uti­li­sa­tion, la com­mu­nauté 65535:666 n’est pas uti­li­sée, mais vous pou­vez aussi trans­fé­rer ces routes à vos four­nis­seurs 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 tra­fic est éli­miné, il est tou­jours re­monté par IPFIX et sFlow. Dans Ak­vo­rado, uti­li­sez ForwardingStatus >= 128 comme filtre pour sé­lec­tion­ner celui-ci.

Bien que cette mé­thode soit com­pa­tible avec tous les rou­teurs, elle per­met à l’at­taque de réus­sir car la cible est com­plè­te­ment in­ac­ces­sible. Si votre rou­teur le prend en charge, Flows­pec peut fil­trer sé­lec­ti­ve­ment les flux pour ar­rê­ter l’at­taque sans af­fec­ter le client.

Suppression du trafic avec Flowspec#

Flows­pec est dé­fini dans la RFC 8955 et per­met la trans­mis­sion de spé­ci­fi­ca­tions de flux dans des ses­sions BGP. Une spé­ci­fi­ca­tion de flux est un en­semble de cri­tères à ap­pli­quer au tra­fic IP. Ceux-ci in­cluent le pré­fixe source et des­ti­na­tion, le pro­to­cole IP, le port source et des­ti­na­tion et la lon­gueur des pa­quets. Chaque spé­ci­fi­ca­tion de flux est as­so­ciée à une ac­tion, en­co­dée sous forme d’une com­mu­nauté éten­due : li­mi­ta­tion du tra­fic, mar­quage ou re­di­rection.

Pour an­non­cer des spé­ci­fi­ca­tions de flux avec BIRD, nous com­plé­tons notre confi­gu­ra­tion. La com­mu­nauté éten­due uti­li­sée li­mite le tra­fic cor­res­pon­dant à 0 oc­tet 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 rou­teur ASR tour­nant sous IOS XR, la confi­gu­ra­tion res­semble à 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

En­suite, il convient d’ac­ti­ver Flows­pec sur toutes les in­terfaces :

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

Comme pour la confi­gu­ra­tion à base de RTBH, vous pou­vez af­fi­cher les flux éli­mi­nés avec le filtre ForwardingStatus >= 128.

Détection des attaques DDoS (suite)#

Dans l’exemple uti­li­sant Flows­pec, les flux sont aussi fil­trés se­lon la lon­gueur 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 ad­di­tion im­por­tante : les re­quêtes DNS lé­gi­times ont une taille in­fé­rieure à cela et ne sont donc pas fil­trées2. Avec ClickHouse, vous pou­vez ob­te­nir les 10ème et 90ème cen­tiles des tailles de pa­quets avec quantiles(0.1, 0.9)(Bytes/Packets).

Le der­nier pro­blème que nous de­vons ré­soudre est de rendre les re­quêtes plus ra­pides : elles peuvent avoir be­soin de plu­sieurs se­condes pour col­lec­ter les don­nées et sont sus­cep­tibles de consom­mer des res­sources sub­stan­tielles de votre base de don­nées ClickHouse. Une so­lu­tion consiste à créer une vue ma­té­ria­li­sé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 uti­lise le mo­teur SummingMergeTree. Lorsque la table re­çoit de nou­velles don­nées, ClickHouse rem­place toutes les lignes ayant la même clé de tri, telle que dé­fi­nie par la di­rec­tive ORDER BY, par une seule ligne qui contient des va­leurs ré­su­mées3 en uti­li­sant soit la fonc­tion sum() soit la fonc­tion d’agré­ga­tion ex­pli­ci­te­ment spé­ci­fiée (uniqCombined et quantiles dans notre exemple).

En­fin, nous mo­di­fions notre re­quête ini­tiale avec la re­quê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é­su­mer, la construc­tion d’un sys­tème de sup­pres­sion des at­taques DDoS né­ces­site de suivre les étapes suivantes :

  1. dé­fi­nir un en­semble de cri­tères pour dé­tec­ter une at­taque DDoS
  2. tra­duire ces cri­tères en re­quêtes SQL
  3. pré-agré­ger les flux dans des tables SummingMergeTree
  4. in­ter­ro­ger et trans­for­mer les ré­sul­tats en un fi­chier de confi­gu­ra­tion pour BIRD
  5. confi­gu­rer vos rou­teurs pour ex­traire les routes de BIRD

Un script Py­thon comme ce­lui qui suit per­met de gé­rer la qua­trième étape. Pour chaque cible at­ta­quée, il gé­nère à la fois une règle Flows­pec 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’Ak­vo­rado in­tègre la dé­tec­tion et la lutte contre les at­taques DDoS, les idées pré­sen­tées dans ce billet four­nissent une base so­lide pour com­men­cer à construire votre propre sys­tème anti-DDoS. 🛡️