Linux-Paketfilter nftables: Broken by design
Mal was rein Technisches.
Womit ich diese Woche wieder Zeit verbraten habe, die ich eigentlich nicht habe und die mir hinten und vorne fehlt.
Achtung: Ich werde jetzt hier Linux- und Informatikerkram erzählen und nicht für Laien erklären. Wer nicht weiß, was die Linux-Paketfilter sind und wie sie funktionieren, der braucht den Artikel erst gar nicht zu lesen, das ist technisches Admin-Wissen.
Verschiedene, vor allem Unix-artige Betriebssysteme haben im Betriebssystem eingebaute „Paketfilter“, also Regelsatzsysteme, nach denen Internet-Paket bei Eingang, Ausgang und Durchleitung nach einer Liste von Regeln behandelt und letztlich auch durchgelassen („accept“) oder abgelehnt werden, mit („reject“) und ohne („drop“) Fehlermeldung. Die sind und auch sie zu erklären ist ziemlich komplex, und in einem einzelnen Blogartikel sowieso nicht möglich. Und auch nicht erforderlich, denn Anleitungen und Einführungen gibt es reichlich.
Diese Paketfilter dienen unter Linux zwei verschiedenen – und sich, dazu kommen wir noch, in gewisser Weise widersprechenden – Zwecken:
- Konstruktiv
- um Funktionen zu ermöglichen, die ohne den Paketfilter nicht möglich wären, nämlich NAT, und die ganzen Nebenfunktionen, um Pakete zu verändern und markieren, womit sehr leistungsfähige Funktionen implementiert werden können,
- Destruktiv
- um Verkehr zu verhindern, der ohne Paketfilter durchgereicht würde, also Firewalls und andere Sicherheitsfunktionen zu implementieren.
Bitte im Kopf behalten, daraus ergibt sich nämlich ein schweres Problem.
So alle paar Jahre gibt es unter Linux davon eine neue Version, mittlerweile die vierte oder fünfte, ich weiß es nicht mehr genau. Weil die Anforderungen steigen und sich verändern, und man die Ergänzungen der letzten Jahre konsolidieren will, und so wurde gerade iptables (und ipset und arptables und ebtables) durch nftables ersetzt und konsolidiert.
nftables gibt es schon lange. Ich weiß nicht mehr, wie lange, ein paar Jahre hätte ich jetzt spontan geschätzt, aber die erste Eintragung in meinem Notizbuch behauptet, von 2015 zu stammen. Bisher war das optional, zum rumprobieren, und seit einiger Zeit ersetzt man das schleichend, indem man iptables durch eine Pseudoversion ersetzte, die sich wie die alten iptables-Programme benahm, aber iptables durch nftables emulierte. Man konnte also aus Skripten, Firewallprogrammen usw. noch immer agieren, als hätte man iptables, obwohl unter der Haube schon nftables agieren.
Weil man das jetzt bei den neueren Linux-Distributionen als Standard setzt, hatte ich mich die Woche damit beschäftigt, meine Firewalleinstellungen von iptables auf nftables umzustricken. Und bin dabei auf ein übles Problem gelaufen, das mich einige Zeit gekostet hat.
Dazu muss man eine Änderung verstehen.
Der alte Zustand: iptables
iptables war insofern simple, als es nur eine feste Struktur hatte:
Es gibt fünf feste „Tables“ mit verschiedenen Funktionen: filter, nat, mangle, raw, security.
Vereinfacht gesagt: filter sagt, was rein, raus und durch darf. nat steuert die Adressänderungen. mangle ist für Modifikationen der Pakete selbst gedacht. raw ist für besondere Schweinereien, die mit den anderen nicht gehen. Und security ist zur Markierung von Kontexten für SELinux.
Und in „filter“ gibt es drei feste Chains, INPUT, OUTPUT und FORWARD, in die man die Regeln schreibt, die bestimmen, was in die Maschine rein-, was rausdarf und was durchgeleitet wird. Also nur einen einzigen Ort, an den man seine Regeln schreiben kann. Zwar kann man neue Chains anlegen und dies von den drei Hauptchains aus anspringen wie Unterprozeduren, aber: Im Prinzip gibt es jeweils nur einen einzigen Ort, nämlich diese drei Chains, in die man seine Regeln schreiben kann.
Und das war ein organisatorisches Ordnungsproblem, weil man da eben nicht nur seine destruktiven Firewallregeln reinschreibt, sondern allerlei Programme auch ihre konstruktiven Regeln. Beispielsweise Virtualisierungsprogramme wie LXD oder docker schreiben da selbsttätig ihre konstruktiven Regeln rein, und wenn man da nicht höllisch aufpasst, bringen sich verschiedene Programme und die Firewall mit ihren destruktiven Regeln gegenseitig durcheinander.
Wenn man aber aufpasst, dann funktioniert das auch einwandfrei, weil normaleweise erst die konstruktiven und für den Betrieb erforderlichen und dann die destruktiven Regeln ausgeführt werden, die Angriffe abwehren sollen, und in den Regelsätzen gilt, dass wenn die erste Terminalaktion erfolgt (accept, reject, drop) alles vorbei ist, weil dann ultimativ entschieden ist, ob das Paket durch darf oder nicht. Die konstruktiven Regeln sagen zum Beispiel, dass die virtuelle Wirtsmaschine mit dem Host reden darf, etwa um DNS abzufragen.
Vereinfacht an der Analogie der Wohnung beschrieben: Dass man vom Schlafzimmer einfach so ins Bad und vom Wohnzimmer ungehindert in die Küche gehen darf, ist kein Sicherheitsproblem, sondern intern, und hat deshalb Vorrang vor der Regel, dass die Wohnungstür ein Türschloss hat, damit keine Unbefugten reinkommen, weil man ja morgens beim Gang zum Klo nicht mit Schlüssel durch die Wohnungstür will. Deshalb kommt erst die Regel Schlafzimmer nach Klo ist OK, und dann die Regel Finsterling durch Wohnungstür ist nicht OK. Ungefährlicher Innenkram ist frei und hat Vorrang vor dem Außenschutz. Genau so würde man einem LXD- oder Docker-Container gestatten, das DNS seiner Wirtsmaschine abzufragen.
Aber das führte oft zu Durcheinander, kam sich ins Gehege und man konnte auch seine eigenen Regeln nicht geschlossen und am Stück löschen, ohne auch konstruktiven Regeln zu löschen,
Der neue Zustand nftables
Bei nftables hat man nun versucht, diesen verschiedenen Dinge zu entwirren und zu trennen, damit die sich nicht ins Gehege kommen. Eigentlich eine sehr gute Idee.
Man hat als nicht mehr eine feste table „filter“ und darin eine fest chain „INPUT“, die den eingehenden Verkehr regeln (und OUTPUT und FORWARD), sondern man kann beliebig viele tables beliebiger Namen mit beliebigen chains beliebiger Namen anlegen. Damit kann also jede Anwendung ihren Kram schön getrennt in eine eigene Table packen. Aber woher weiß dann der Kernel, wann er welche Chain ausführen soll? Man registriert die chain beim entsprechenden hook. Es gibt also im Prinzip immer noch nur eine einzige Ankerstelle, eigentlich wie vorher, nämlich die hooks für INPUT, OUTPUT, FORWARD, nur dass man da jetzt nicht mehr regeln reinschreibt, sondern ganze Chains. Man hat also im Prinzip einen zusätzlichen Abstraktions- und Ordnungslayer eingezogen: Statt wie bei iptables einen chain INPUT zu haben, in die man seine Regeln schreibt, hat man jetzt einen hook INPUT, in den man chains einhängt, die dann wiederum die Regeln enthalten. Womit man die Regeln nicht mehr durcheinander bringt, sondern in verschiedenen chains ordnen kann.
Eigentlich ist das gar keine so große Neuerung, denn das war bisher mit Ordnungssinn auch schon möglich, denn auch schon bei iptables konnte man, wenn man diszipliniert war, für jede Anwendung eigene Chains schreiben und dann in die Haut-Chain wie INPUT nur die Jump-Anweisungen in die verschiedenen Chains. Man konnte das vorher schon trennen. Insofern bringen nftables keine wirkliche Neuerung, sondern über eine neue Struktur nur ein Ordnungsschema, das man vorher schon betreiben konnte, wenn man denn wollte und Disziplin hielt. Vorher hat man das halt mit Unter-Chains gemacht und in die Hauptchain nur Jump-Anweisung geschrieben, aber das erforderte Disziplin. Und jetzt macht man es mit einem hook und hängt ganze Chains ein.
Was im Ergebnis dieselbe Funktion hätte, nur aufgeräumter aussieht. Wenn da nicht eine ganz böse semantische Falle lauern würde, auf die ich noch komme.
In beiden Versionen, bei den alten iptables, wie auch bei den nftables, kann man die Reihenfolge beeinflussen, in der Regeln in die Hauptchain (iptables) oder Chains in den Hook (nftables) eingehängt werden: bei iptables durch explizite Angabe der Regelnummer oder Parameter wie „am Anfang einsetzen“ oder „Am Ende anhängen“, womit vor allem die Reihenfolge der Änderungen sich auch auf die Reihenfolge der Ausführungen auswirkte. In nftables dagegen werden Chains in der Reihenfolge ihrer angegebenen Priorität abgehandelt. Es ist zwar erlaubt, mehrere Chains mit derselben Priorität einzuhängen, aber dann ist die Reihenfolge undefiniert.
Beispiel Firewallregeln
Ich gebe mal ein vereinfachtes (und hier nur auszugsweise zum Verständnis dargestelltes) Standardbeispiel an, ist auch nicht auf meinem Mist gewachsen, sondern so eine Standardvorgehensweise:
table inet firewall { set allowed_interfaces { type ifname elements = { "lo" } } set allowed_protocols { type inet_proto elements = { icmp, icmpv6 } } set allowed_tcp_dports { type inet_service elements = { ssh } } chain allow { ct state established,related accept meta l4proto @allowed_protocols accept iifname @allowed_interfaces accept tcp dport @allowed_tcp_dports accept } chain input { type filter hook input priority filter + 10; policy accept jump allow reject with icmpx type port-unreachable } }
Wir sehen am Ende die chain “input”, deren Name willkürlich gewählt ist und keine Funktion außer der Lesbarkeit für den Menschen hat. Da steht type filter hook input priority filter + 10;
, was diese chain also funktional zum INPUT-Filter macht in im hook input mit der Priorität filter + 10 einhängt.
Und wie man das bei Firewalls eben so macht, werden erst die erlaubten Verkehrsverbindungen “accepted”, und dann mit “reject” alles andere gesperrt, was nicht rein soll. Ganz typisches Firewall-Grundschema, alle erlaubten Verbindungen aufzulisten und “accept” zu machen, und am Ende der Liste ein “reject” zu stellen.
Und in der Unter-Chain “allow” sehen wir, was erlaubt sein soll:
- zuerst alle Verbindungen, deren Status wir schon kennen und die in Verbindungen stehen, also TCP-Pakete einer schon erlaubten Verbindung u.ä.
- dann Protokolle, die wir immer erlauben und die in der Menge allowed_protocols stehen, hier icmp.
- dann Interfaces, die wir immer zulassen, hier enthält die Menge das loopback interface “lo”.
- und dann noch eine Regel für tcp-ports, hier lassen wir den ssh-Zugang von überall zu.
Und wenn wir diesen Regelsatz laden, funktioniert der einwandfrei. Macht genau das, was er soll.
Beispiel LXD
Ich hatte gesagt, dass verschiedene Programme, besonders Virtualisierungsumgebungen, auch ihr Zeugs in diese Filtertabellen eintragen, damit sie funktionieren können.
Schauen wir uns das mal (wieder nur ein Auszug, nur eine chain dargestellt, sonst würde es zu lang und unübersichtlich) am Beispiel LXD an:
table inet lxd { chain in.lxdbr0 { type filter hook input priority filter; policy accept; iifname "lxdbr0" tcp dport 53 accept iifname "lxdbr0" udp dport 53 accept iifname "lxdbr0" icmp type { destination-unreachable, time-exceeded, parameter-problem } accept iifname "lxdbr0" udp dport 67 accept iifname "lxdbr0" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-neighbor-solicit, nd-neighbor-advert, mld2-listener-report } accept iifname "lxdbr0" udp dport 547 accept } }
Wir sehen also auch hier, dass eine Chain gebaut und in den hook “input” eingehängt wird, mit priorität “filter”, also niedrigerer Zahl als unsere eigenen Firewallregeln, werden also vorher ausgeführt (weil die chains in der Reihenfolge der Zahlen sortiert werden, kleinste zuerst).
Und da steht, dass die virtuelle Gastmaschine den Wirt verschiedene Pakete schicken darf: Er darf DNS abfragen (tcp und udp Port 53). Er darf bestimmte icmp-Pakete schicken. Er darf DHCP verwenden (port 67 für ipv4 und 547 für ipv6).
Auch dieser Regelsatz funktioniert tadellos und einwandfrei.
Das Problem
Jeder der beiden Regelsätze, die ich als Beispiel und auszugsweise dargestellt habe, (Firewall und LXD), funktioniert tadellos und einwandfrei, solange er alleine geladen wird. Aber beide zusammen funktionieren nicht.
Sobald ich die Firewall-Regeln geladen habe, kann die Gastmaschine unter LXD keine DNS-Anfragen mehr an den Host schicken. Und hätte ich es nicht schon gemerkt, als ich an INPUT arbeitete, sondern weitergemacht und die Regeln für FORWARD und OUTPUT geschrieben, könnte die Gastmaschine gar nicht mehr kommunizieren.
Warum?
Weil sich die Semantik in einem Detail deutlich geändert hat, und das nicht einmal so dokumentiert ist, dass man es beim Einlesen merken würde.
Früher, bei iptables, wo man so etwas auf Subchains verteilt hatte, war das so, dass die erste Regel, die „accept“, „reject“ oder „drop“ sagte, auch endgültig war. Damit war die Abarbeitung fertig und erledigt, und nichts, was an Firewall-Regeln danach kam, konnte darauf noch Einfluss nehmen. Wenn da also stand, dass die Gastmaschine den Host auf Port 53 ansprechen darf, dann war das auch so. Felsenfest.
Jetzt ist das anders.
Zwar sind „accept“, „reject“ und „drop“ immer noch terminal innerhalb einer in den hook eingehängten chain, aber nur reject und drop terminieren auch den hook. Accept terminiert die Ausführung nicht, sondern es geht mit der nächsten Chain weiter.
Ich bin allerdings nicht der Erste, dem das aufgefallen ist, denn bei Suchen bin ich auf eine kurze Diskussion gestoßen, in der schon andere darüber geflucht haben, und später habe ich sogar in der Dokumentation noch einen versteckten, anscheinend nachträglich eingefügten Hinweis gefunden:
NOTE: If a packet is accepted and there is another chain, bearing the same hook type and with a later priority, then the packet will subsequently traverse this other chain. Hence, an accept verdict – be it by way of a rule or the default chain policy – isn’t necessarily final. However, the same is not true of packets that are subjected to a drop verdict. Instead, drops take immediate effect, with no further rules or chains being evaluated.
Und das ist katastrophal, weil man damit nämlich nicht mehr konstruktive und destruktive (Firewall-) Regeln zusammen verwenden kann.
Firewallregeln müssen – wie im Beispiel oben – immer am Ende eine „reject alles“-Regel haben, damit nichts durchrutscht. Und deshalb sorgt die reject-Regel in den Firewall-Regeln oben dafür, dass die DNS-Anfrage der Gast-Maschine an die Wirtsmaschine abgelehnt wird, obwohl doch die LXD-Regeln explizit sagen, dass sie erlaubt sei.
Kurioserweise ist da auch mit der Wahl der Priorität nichts zu retten, weil es egal ist, ob erst LXD oder erst Firewall ausgeführt wird: In beiden Fällen setzt sich der reject gegen das accept durch.
Es bleiben nur zwei, drei Workarounds übrig, die aber beide Schrott sind:
- Man könnte das reject in den Firewall-Regeln auf bestimmte Interfaces oder IP-Adressen einschränken, damit nur Pakete „von außen“ abgelehnt werden.
Das ist aber nicht nur hochgradig fehleranfällig und unsicher, weil das weitere Interfaces wie etwa WLAN oder VPN oder weiß der Kuckuck, was alles auftauchen kann, nicht erfassen und offen lassen würde. Es würde auch die LXD-Regeln konterkarieren, weil dann die Gastmaschine den Wirt auf allen Ports und nicht nur DNS und DHCP erreichen könnte, weil am Ende dann doch ein Accept stehen würde.
- Oder man wiederholt die ganzen LXD-Regeln noch einmal in den Firewall-Regeln, was auch großer Mist ist, weil man sich ständig darum kümmern müsste, Änderungen usw. fehlerfrei zu übertragen.
- Man schreibt alles nur noch in eine Table, womit man aber auch bei iptables hätte bleiben können. Vor allem müssten man Pakete wie LXD und docker wieder umschreiben, um wie eine einzige Table zu verwenden.
Das ist katastrophaler Mist.
Im Ergebnis heißt das, dass man eine Maschine, die konstruktive Regeln braucht, nicht mehr in einer Form absichern kann, wie das für „Feindesland“, etwa das offene Internet erforderlich wäre.
Und das alles, weil man – anscheinend ohne nachzudenken und ohne triftigen Grund – an einer subtilen Stelle die Semantik gegenüber den alten, aber bewährten iptables geändert hat. Das ist so ein ganz typischer Halbwissen-Fehler, wie er in der IT, vor allem Security häufig auftritt, und wie ich ihn in der open-source-Szene häufiger beobachte: Leute entwickeln etwas (worauf sich andere dann verlassen und verlassen müssen), haben aber nicht den Überblick, sondern nur einen Horizont irgendeiner individuellen Bastelanforderung oder irgendeines Miniprojektes, wo ihnen das dann als vorteilhaft erscheint, und drücken das dann über die Software anderen aufs Auge. Solche Fehler treten nicht nur, aber sehr häufig dann auf, wenn per political correctness und code of conduct irgendwelche Leute ohne ausreichende Erfahrung in ein Projekt reintoleriert werden mussten – oder die, die Ahnung haben, vergrault wurden. Das riecht sehr nach „Quality is a myth“.
Es gibt nämlich noch etwas anderes, was mich daran noch mehr ärgert: Ich wollte nämlich bei denen, die das schreiben und verantworten, netfilter.org, einen Bugreport auf deren Bugzilla einwerfen.
Ging nicht. Weil die Registrierungsfunktion gesperrt ist. Man kann keinen Account eröffnen, und ohne Account keinen Bug melden. Weil sie, wie sie da schreiben, zuviel Spam bekämen. Was seltsam ist, denn das müsste für alle anderen Bugwebseiten ja auch gelten, es ist mir aber noch nie begegnet. Man muss per E-Mail darum bitten, einen Bug reporten zu dürfen, und sich quasi damit bewerben, was für einen schönen Bug man gefunden hat. Bisher habe ich keine Antwort erhalten.
Ich habe den Verdacht, dass man sich da systematisch gegen Kritik und Bug reports abschottet, was meinen Eindruck der „code of conduct“-Laienwirtschaft verstärken würde.
Ich habe noch keine Antwort bekommen, wie die sich denn die Firewall-Regeln konkret vorstellen.
Im Moment kann ich nur dringend davor warnen, Linux-Maschinen, die auf nftables beruhen, ohne zusätzlichen Firewall-Schutz ins Internet zu packen, ohne das genau zu beobachten, auch weil man jetzt nicht weiß, wie Package-Maintainer reagieren und vielleicht bei einem einfachen Update andere Regeln reinkommen.
Mal sehen, was die bei Ubuntu sagen, da habe ich es auch eingeworfen.