Comment sécuriser vos flux sortants en filtrant l’egress de votre serveur avec un Firewall

Le réseau de vos services
Les services que vous déployez vont utiliser du réseau, par définition.
Dans un sens pour servir les utilisateurs, et dans l’autre sens pour utiliser lui même des services.
Pour limiter les surprises, vous allez appliquer les principes de l’autorisation minimale et de la défense en profondeur.
Ingress
On appelle ingress le flot entrant vers un serveur.
Ça fait longtemps que l’on déploie ses applications derrière un service spécialisé qui lui, affrontera les tumultes du grand Internet.
Ce service (en level 7) peut être dans la place (Haproxy, Traefik, Nginx…) ou externalisé, en SAAS, comme Cloudflare.
Les protections bas niveau (en level 3) comme l’anti DDOS sont maintenant considérés comme allant de soi.
Grâce à Let’s Encrypt, le chiffrement via TLS est maintenant largement standardisé.
HTTP est fort pratique pour router plusieurs services derrière une seule paire IP/port.
SNI permet d’avoir la même possibilité de routage à partir du nom de domaine que le virtualhosting d’HTTP.
SNI est une extension TLS qui permet d’annoncer le domaine (en clair) avant le reste de la connexion qui sera chiffré.
Ainsi, il est possible de router le trafic, sans pour autant être capable de le lire.
Connaitre le nom de domaine peut être considéré comme indiscret, et une amélioration de SNI a été proposée, ESNI, mais cette technologie est peu diffusée, et certains pays la bloquent (Russie et Chine).
SNI, pourtant indépendant d’HTTP reste malheureusement peu usité dans d’autres protocoles. C’est ballot, des proxys comme Traefik savent maintenant router du SNI sans exiger de l’HTTP.
Le monde UDP est plus diversifié, avec un usage plus massif des ports, et le chiffrage et autres protections systématiques sont plus compliqués à mettre en place.
Avoir un service dédié pour gérer le flot entrant est maintenant standard, les proxy en SAAS, souvent lié à une offre de CDN sont maintenant classique, et le monde du Cloud pousse à la notion de bastion et LAN privé.
Il est rare maintenant qu’une application ne s’appuie pas sur des services métiers, et je parle juste des bases de données ou de cache, avant même d’évoquer les microservices.
Ces services métiers n’ont pas vocation à être accessibles depuis le grand Internet, ou alors vous allez très vite avoir des soucis.
Egress
Tout le monde fait attention à son Ingress (ou alors vous cherchez à passer chez Zataz), mais quid de son Egress, le flot sortant?
Les serveurs sans accès au réseau sont rapidement pénibles à utiliser : sans son apt-get update on n’est pas grand-chose.
Et même si on a une procédure propre pour redéployer régulièrement des images fraiches de ses services (que ce soit des images disque comme le fait Netflix, ou des images de conteneurs), il est tout à fait légitime qu’un service utilise un service distant.
L’arrivée fracassante de Kubernetes a permis de changer d’échelle de problèmes avec le réseau.
Avec k8s, vous allez avoir différentes applications (composés de multiples services) qui vont se partager un pool de ressource, permettant d’avoir de la scalabilité, de la résilience (quand un service tombe, un service sort de l’ombre à sa place), et meilleure utilisation des ressources matérielles (en dépassant les 10% de CPU de moyenne sur l’année).
Comme les services sont dispersés sur plusieurs machines, l’utilisation, et la maitrise du réseau, va devenir très importante.
Chaque application va vouloir avoir son propre LAN, et comme chaque machine va héberger des conteneurs de différentes applications, ça va tricoter sec.
Pour garantir la bonne utilisation des services (load balancing, chiffrage, authentification en mTLS, reprise sur incident, compression, métriques…) il peut être tentant d’utiliser un proxy ambassadeur comme Envoy, mais techniquement, on appelle ça un man in the middle, il va avoir besoin d’informations indiscrètes pour prendre ses décisions.
Comme il existe beaucoup d’approches pour gérer des LANs virtuels, la CNCF a abstrait ça avec la Container Network Interface. CNI permet de profiter de l’abstraction réseau de son fournisseur sans trop se soucier des choix et détails d’implémentation, ce qui est un argument de poids pour les gros du Cloud.
Cette utilisation frénétique du réseau a permis de valider ce qui marchait et marchait moins bien dans Linux, et de le faire évoluer pour que tout marche bien.
iptables s’est pris des cailloux, ce qui met en avant nftables qui essaye de lui succéder, mais surtout à eBPF de se généraliser dans le kernel, et dans le réseau en particulier avec XDP comme game changer comme on dit en Amérique.
XDP permet d’ajouter du code métier sur une carte ethernet (réelle ou virtuelle), et d’aller bien plus loin dans ce qui est possible pour filtrer et router du réseau, avec une vraie granularité, pas juste le kernel d’une VM.
Cilium et IOVisor se sont rapidement positionnés comme les caïds de l’eBPF.
Cilium se chargeant du réseau, avec une intégration fine de XDP, Envoy, CoreDNS, du eBPF pour voir ce qui se passe (et qui essaye de truander) et surtout pour gérer le réseau.
IOVisor se positionne sur le monitoring et l’analyse de performance.
Je veux juste filtrer ce qui sort
Voilà, on a une belle tempête d’acronymes, avec des technologies prometteuses et ambitieuses. Mais moi, ce que je veux, c’est ne permettre à un programme de n’avoir accès qu’à une liste de nom de domaines (avec des patterns, pour simplifier).
Cilium propose fièrement un filtrage de l’egress à base de whitelisting de noms de domaine. Mais est-il nécessaire de dégainer le roi de l’eBPF et k8s pour filtrer ce qui sort ?
iptables
iptables travaille au level 4, et n’a pas idée de ce qu’est un nom de domaine.
Il est légitime pour un service de changer d’IP pour peu qu’il garde son nom de domaine, et certains se permettent d’avoir plusieurs IPs pour un seul nom (pour du loadbalancing, ou de la résolution géographique).
DNS
Pour filtrer par nom et blanchir des IPs (whitelister en VO), il faut donc utiliser la résolution de noms, et pour ça le plus simple est un DNS.
Fournir un DNS qui ment est moralement douteux (vous savez, quand vous mettez des 127.0.0.1 dans votre /etc/hosts pour empêcher des applications de téléphoner à la maison), et il serait toujours possible de truander avec des appels illégitimes avec une IP en dur, ou en utilisant un DoH sur un domaine légitime.
Avec l’aide d’un DNS, il sera possible d’avoir un nom à filtrer, et la résolution de son IP, puis d’ouvrir l’accès à cette IP si le nom est accepté.
L’autre conséquence de k8s sur l’écosystème serveur est le retour en grâce du DNS dans le LAN, car la flemme et la facilité de travailler avec des IP lui avaient fait beaucoup de tort.
Au début, dnsmasq avait été retenu, pour finalement créer CoreDNS, qui apporte une architecture contemporaine à base de plugin et beaucoup de dynamisme.
dnstap
CoreDNS a le bon goût de gérer dnstap, un protocole robuste pour écouter les échanges DNS sans pour autant interférer avec lui.
Dnstap est implémenté par une ribambelle de serveurs (knot, unbound, bind, powerdns…), ce qui lui confère le statut de quasi-standard.
Network namespace
Pour ne pas brimer une machine complète, mais juste un service (ou une grappe de services), il faut utiliser un network namespace pour que le service ne voie qu’un seul réseau, et qu’une seule passerelle.
Vous pouvez le faire avec ip netns ou confier cette tâche à Docker.
Stratégie
La stratégie est donc simple.
Il existe bien des outils pour informer des demandes d’ouverture des connections TCP demandées au kernel, grâce à eBPF, comme tcpconnect, qui va même faire le lien avec la résolution de nom, port et pid.
Mais tcpconnect ne permet que de voir ce qu’il se passe, comme un netstat en temps réel, mais il ne va rien faire, et encore moins bloquer.
Il faut donc travailler au niveau du réseau, pas des syscalls.
Le service est dans un sous-réseau privé, son /etc/resolv.conf pointe sur un DNS qui fait juste relais, et qui cafte les demandes via dnstap.
Un service va écouter les demandes de résolution de nom (les CNAME). En
fonction du nom demandé, cet outil va ouvrir (ou pas) via l’IP de destination, un passage à travers le par-feu.
Implémentation avec on-his-name
Côté implémentation, nous avons choisi, comme à notre habitude
golang et des bibliothèques éprouvées sur des applications majeures.
Au niveau DNS, c’est golang-dnstap
qui permettra d’écouter les requêtes qui passent par
CoreDNS.
Il existe un daemon firewalld, géré par CNI mais qui a envie de causer sur DBUS pour paramétrer son firewall sur un serveur?
firewalld ne semble pas avoir beaucoup de fan hors RedHat (qui en assure le développement et la maintenance).
Nous allons rester sur le classique iptables avec la librairie
go-iptables quie permet d’avoir
rapidement un prototype fonctionnel (au moins pour GNU/Linux).
Pour éviter de perturber l’ensemble du daemon, le filtrage
s’appliquera seulement sur les conteneurs attachés à un réseau dédié
docker network create on-his-name

Qui va créer, côté hôte, un bridge (le comportement par défaut de Docker)
18: br-e65ade98d3cb: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
link/ether 02:42:ad:0d:b0:ae brd ff:ff:ff:ff:ff:ff
inet 172.18.0.1/16 brd 172.18.255.255 scope global br-e65ade98d3cb
valid_lft forever preferred_lft forever
inet6 fe80::42:adff:fe0d:b0ae/64 scope link
valid_lft forever preferred_lft forever

IPTables
Côté Docker, les travaux sur les firewalls sont en friches.
Docker est intrasèquement très lié à iptables via libnetwork et la tendance ne semble pas s’inverser.
Malgré tout et grâce à Docker 17.06 des nouvelles
chaînes iptables DOCKER et DOCKER-USER permettent d’isoler et
interactions entre le daemon et le par-feu au sein du système hôte.
DOCKER est la chaîne réservée au deamon et il est préférable de ne pas y
toucher. DOCKER-USER est celle dédiée aux règles utilisateurs.
Celles-ci seront vérifiées en premier lors du transfert de paquets : c’est gagné.
Pour appliquer une whitelist, il faut nécessairement, dans un premier temps,
bloquer tout le trafic. Le plus simple est de et de commencer à vider la
chaîne pour y ajouter, en fin de table les règles qui bloque l’intégralité
des flux passant par le bridge créé à l’étape précédente :
iptables -F DOCKER-USER
iptables -A DOCKER-USER -i br-e65ade98d3cb DROP

Avec la commande docker run et l’option –network il est possible
d’isoler un conteneur dans un sous réseau dédié (ce que fait systématiquement docker-compose)
docker run –network=on-his-name –dns="172.18.0.1" –rm bearstech/debian:buster curl -I -m 2 https://golang.org
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 –:–:– –:–:– –:–:– 0
curl: (7) Failed to connect to golang.org port 443: Connection timed out

Comme prévu, le trafic est bloqué par iptables.
Démonstration
Par défaut, côté daemon Docker, les conteneurs héritent de la configuration
DNS de l’hôte, piquée dans /etc/resolv.conf.
Ce comportement change avec l’utilisation de l’option –network.
C’est maintenant le résolveur interne du daemon qui se charge d’effectuer les requêtes DNS pour le conteneur.
Pour aller encore plus loin, il est possible de spécifier la configuration
DNS par conteneur. C’est celui-ci qui sera interrogé par le daemon lors
d’une requête DNS : Bingo !
Pour préparer le terrain, il faut dans un premier temps démarrer
on-his-name
sudo SOCKET_UID=$(id -u) DOCKER_BRIDGE_NAME=on-his-name LISTEN=./tap.sock ./bin/on-his-name "bearstech.com."

Ici, les seuls flux autorisés seront http et https vers bearstech.com
on-his-name nécessite les droits root pour modifier le contenu des tables
iptables.
Pour la résolution DNS, c’est CoreDNS qui servira la démo, l’interface
d’écoute est le bridge lié au network on-his-name, pour la démo il se
contente de transférer les requêtes vers 8.8.8.8, tout est décrit dans la
configuration ci-dessous :
.:53 {
bind 172.18.0.1
forward . 8.8.8.8
log
errors
cache
dnstap ./tap.sock full
}

le fichier tap.sock étant la socket unix qui permet de cafter les requêtes
DNS de CoreDNS vers on-his-name.
L’option full pour dnstap est très importante : elle permet d’avoir l’intégralité du contenu de la requête DNS.
Une fois la stack en place, jouons avec différents conteneurs, ci-dessous
l’état du par-feu au démarrage de on-his-name
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
0 0 DROP all — br-e65ade98d3cb * 0.0.0.0/0 0.0.0.0/0
0 0 DROP all — * br-e65ade98d3cb 0.0.0.0/0 0.0.0.0/0

En passant par le réseau Docker standard le trafic n’est pas perturbé :
docker run –rm bearstech/debian:buster curl -I -m 1 https://golang.org
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 –:–:– –:–:– –:–:– 0
HTTP/2 200
date: Tue, 02 Mar 2021 14:57:03 GMT
content-type: text/html; charset=utf-8
vary: Accept-Encoding
strict-transport-security: max-age=31536000; includeSubDomains; preload
via: 1.1 google

En spécifiant le réseau on-his-name et le DNS local à l’exécution du conteneur avec un domaine interdit
docker run -t -i –network=on-his-name –dns="172.18.0.1" –rm bearstech/debian:buster curl -I -m 2 https://golang.org
curl: (28) Connection timed out after 2000 milliseconds

Ça coince ! Et c’est visible dans les compteurs affichés par iptables :
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
1 60 DROP all — br-e65ade98d3cb * 0.0.0.0/0 0.0.0.0/0

Toutefois si on lance une requête vers un domaine autorisé, dans le réseau isolé :
docker run -t -i –network=on-his-name –dns="172.18.0.1" –rm bearstech/debian:buster curl -I -m 2 https://bearstech.com
HTTP/2 200
server: nginx/1.14.2
date: Tue, 02 Mar 2021 15:01:00 GMT
content-type: text/html; charset=UTF-8
content-length: 37776
last-modified: Fri, 26 Feb 2021 11:51:52 GMT
etag: "6038e0d8-9390"

Le trafic passe et les règles iptables sont dynamiquement mises à jour :
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
15 1690 ACCEPT tcp — br-e65ade98d3cb * 0.0.0.0/0 78.40.125.59 tcp dpt:443
0 0 ACCEPT tcp — br-e65ade98d3cb * 0.0.0.0/0 78.40.125.59 tcp dpt:80
20 1200 DROP all — br-e65ade98d3cb * 0.0.0.0/0 0.0.0.0/0

Les sources sont sur Github : on-his-name.
Conclusion
Il est possible avec des outils Linux standard (netns, dns, dnstap, iptables…) de mettre en place un filtrage de l’egress d’un service.
Quand on est déjà dans un contexte de conteneur, c’est beaucoup plus simple à mettre en place (c’est quand même pour ça qu’ils ont été créés).
Les promesses de XDP donnent clairement envie d’explorer au delà d’iptables avec xdp-filter par exemple.
Aller à la source
Author: Bearstech