Verteilte Erkennung von fehlgeschlagenen SSH-Loginversuchen
Die derzeitige Situation
Auf den über SSH zugänglichen Rechnern (Gruenau- und Vogel-Rechner) laufen zwei verschiedene Betriebssysteme – OpenSUSE (Linux) und Solaris.
Die Behandlung fehlgeschlagener SSH-Loginversuche ist je nach Betriebssystem etwas unterschiedlich implementiert.
OpenSUSE
Fehlgeschlagene SSH-Logins werden unter OpenSUSE vom System in /var/log/btmp protokolliert. Ältere Einträge können sich auch in /var/log/btmp.* befinden. btmp ist üblicherweise nur für root les- und beschreibbar.
Das Shell-Skript /usr/bin/check-sshd wird in regelmäßigen Abständen von crond aufgerufen. Es listet alle btmp-Einträge mit Hilfe des Befehls lastb -a -i auf.
Ausschnitt aus einer Ausgabe von lastb -a -i unter Linux (hier Ubuntu~14.01:
oliversc ssh:notty Thu Oct 2 11:22 - 11:22 (00:00) 141.20.198.10 derp ssh:notty Thu Oct 2 11:17 - 11:17 (00:00) 141.20.193.124 maximili ssh:notty Thu Oct 2 11:17 - 11:17 (00:00) 141.20.192.234 admin ssh:notty Wed Oct 1 15:43 - 15:43 (00:00) 61.174.50.225 bizz ssh:notty Wed Oct 1 15:32 - 15:32 (00:00) 127.0.0.1
lastb -a -i liefert einen btmp-Eintrag pro Zeile in Textform. Das erste Feld enthält den beim Login-Versuch verwendeten Benutzernamen, z.B. „derp“. Das zweite Feld gibt das Gerät an, von dem aus der Login vorgenommen wurde, im Falle von SSH-Logins „ssh:notty“. Die folgenden Felder geben das Datum und die Uhrzeit des Loginversuchs an. Das letzte Feld die IP-Adresse des entfernten Rechners. Die IP-Adresse kann als IPv4- oder IPv6-Adresse ausgegeben werden. Aus der Ausgabe von lastb filtert das Skript nur das letzte Feld, also die IP-Adresse, heraus.
Das Skript sucht nach IP-Adressen, die mindestens -mal direkt aufeinander folgen, wobei eine im Skript festgelegte Konstante ist. Diese IP-Adressen werden behalten, alle anderen verworfen.
Alle von iptables blockierten Adressen werden entfernt.
Danach werden Adressen der Netzwerke 141.20.0.0/16 (Humboldt-Universität) und 127.0.0.0/8 (Loopback), sowie weitere Adressen, die in /etc/hosts der Humboldt-Universität zugeordnet sind, aus der Liste entfernt.
Die übrigen Adressen werden über einen Aufruf von iptables auf unbegrenzte Zeit für SSH (TCP, Port 22) blockiert.
Solaris
Unter Solaris werden fehlgeschlagen SSH-Loginversuche vom System in der Datei /var/log/sshd.log protokolliert. Ältere Einträge können sich auch in /var/log/sshd.log.* befinden. Die Logdateien sind üblicherweise nur für root les- und beschreibbar.
Ausschnitt aus /var/log/sshd.log unter Solaris:
Aug 8 04:48:12 eule sshd[25199]: [ID 800047 local7.notice] Failed none for root from 141.20.198.10 port 1204 ssh2 Aug 8 04:48:12 eule sshd[25199]: [ID 800047 local7.notice] Failed password for root from 141.20.193.124 port 1204 ssh2 Aug 8 04:48:16 eule sshd[25199]: [ID 800047 local7.info] Disconnecting: Too many authentication failures for root Aug 8 21:08:20 eule sshd[9925]: [ID 800047 local7.info] Illegal user admin from 141.20.192.234 Aug 8 21:08:20 eule sshd[9925]: [ID 800047 local7.info] Failed none for <invalid username> from 61.174.50.225 port 55581 ssh2 Aug 8 21:08:21 eule sshd[9925]: [ID 800047 local7.info] Failed keyboard-interactive for <invalid username> from 141.20.198.10 port 55581 ssh2
crond ruft regelmäßig das Shell-Skript /usr/sbin/check-sshd auf.
Das Skript filtert diejenigen Zeilen heraus, die die Zeichenketten „Failed keyboard-interactive“, „Illegal user“, „Failed password for <invalid“ oder „Failed password for“ enthalten. Da die Zeilen kein einheitliches Format besitzen, wird anhand dieser Zeichenketten entschieden, an welcher Position sich die IP-Adresse des entfernten Rechners befindet. Die Adresse wird extrahiert.
Das Skript sucht nach IP-Adressen, die mindestens -mal direkt aufeinander folgen, wobei eine im Skript festgelegte Konstante ist. Diese IP-Adressen werden behalten, alle anderen verworfen.
Wie unter OpenSUSE werden Adressen der Netzwerke 141.20.0.0/16 und 127.0.0.0/8 aus der Liste entfernt. Einträge in /etc/hosts werden allerdings nicht beachtet.
Alle von ippool blockierten Adressen werden entfernt.
Die restlichen Adressen werden mit Hilfe von ippool auf unbegrenzte Zeit blockiert.
Problemanalyse und Design
Erkennung von Angriffen
Da die Rechner derzeit nicht miteinander kommunizieren, werden Angriffe nur von einzelnen Rechnern erkannt. Die IP-Adresse des Angreifers wird lokal blockiert.
Es wird gewünscht, dass ein auf einem Rechner erkannter Angriff dazu führt, dass die Angreiferadresse möglichst schnell auf allen Rechnern blockiert wird.
Außerdem sollen aufeinanderfolgende, fehlgeschlagene Loginversuche an mehreren Rechnern, genauso als Angriff erkannt werden, als wären sie auf einem einzigen Rechner durchgeführt worden.
Client-Server-Architektur
Beim Austausch von Daten über NFS würde der Komplexität der Netzwerkkommunikation von NFS übernommen. Die einzelnen Rechner könnten Daten über fehlgeschlagene Loginversuche in eigene Dateien schreiben. Die anderen Rechner könnten die Daten leicht auslesen. Wenn es möglich wäre, dass die Rechner über Änderungen an den Dateien informiert würden, z.B. über select oder inotify, könnten verteilte Angriffe schnell erkannt und blockiert werden. Ist das nicht möglich, müssen die Daten in regelmäßigen Abständen ausgetauscht werden, was einem Angreifer ein größeres Zeitfenster zwischen erstem Versuch und Blockieren lassen würde.
Eine rein verteilte Lösung ohne Server würde vermutlich eine komplexe Kommunikation zwischen den Rechnern erfordern. Jeder Rechner müsste alle anderen über Loginversuche informieren, was möglicherweise zu stärkerem Netzwerkverkehr führen würde, als bei den anderen Lösungen. Die Menge der beteiligten Rechner müsste verwaltet werden. Alle Rechner müssten für ihre Kommunikation einen bekannten Port öffnen.
Bei mehreren Clients und einem zentralen Server wäre die Kommunikation zwischen den Clients und dem zentralen Server etwas einfacher. Der Server könnte alle Daten über Loginversuche von den Clients annehmen, darin Angriffe erkennen und dann die Clients darüber informieren. Wenn die Clients die Verbindung zum Server aufbauen, bräuchten sie keinen eigenen Server-Port. Die Verbindung kann dauerhaft gehalten werden. Dadurch kennt der Server immer alle beteiligten Clients, die er informieren muss. Angriffe könnten unmittelbar erkannt und blockiert werden. Im Falle eines Serverausfalls könnten die Clients auf eine lokale Erkennung zurückfallen, so wie derzeit schon implementiert.
In Abwägung der Vor- und Nachteile der einzelnen Lösungen, haben wir uns für eine Client-Server-Lösung entschieden, wobei auch die NFS-Lösung sehr vielversprechend aussah. Da das Institutsnetzwerk sehr stabil ist, bestünde auch nur geringe Gefahr, dass der Server ausfiele und damit die verteilte Erkennung lahmlege.
Verlässlichkeitsforderungen
Es kann jederzeit passieren, dass ein Client ausfällt. Entweder durch einen Programmfehler, einen Neustart oder einen Ausfall des ganzen Rechners. Ebenso kann es zu einem Ausfall des Servers kommen. Wir wollen, dass alle Beteiligten mit Ausfällen zurecht kommen.
Diese Toleranz vereinfacht auch das Starten des Systems. Es soll keine Rolle spielen, in welcher Reihenfolge Clients und Server gestartet werden. Insbesondere sollen die Clients weiterlaufen, wenn einmal keine Verbindung zu dem Server möglich sein sollte. Unschön wäre, wenn sich alle Clients bei einem Server-Ausfall beenden würden.
Um diese Zuverlässigkeit zu ermöglichen, müssen wir
- auf alle Fehler während der Socket-Kommunikation reagieren und abgebrochene Verbindungen automatisch wieder aufbauen,
- bei jedem Verbindungsaufbau zwischen Client und Server alle notwendigen Daten austauschen, damit beide Seiten wieder den gemeinsamen Zustand kennen.
Bei einem Ausfall des Servers können die Clients nicht mehr über fehlgeschlagene Loginversuche informiert werden. In diesem Fall sollen sie Angreifer lokal sperren und Informationen darüber austauschen, sobald der Server wieder verfügbar ist.
Nachrichten
Client und Server tauschen drei Typen von Nachrichten aus.
FailedLogin-Nachrichten werden vom Client verschickt. Die Nachrichten enthalten eine eindeutige Nachrichten-ID und Daten über fehlgeschlagene SSH-Loginversuche, wie IP-Adresse des Rechners, bei dem der Login versucht wurde, die IP-Adresse des entfernten Rechners, von dem der Login ausging, der Login-Name, sowie Datum und Uhrzeit des Versuchs.
Ack-Nachrichten werden vom Server verschickt und benachrichtigen den Client darüber, dass alle mit der zugehörigen FailedLogin-Nachricht übertragenen Loginversuche persistent gespeichert wurden. Zu jede Ack-Nachricht ist über die Nachrichten-ID einer zuvor verschickten FailedLogin-Nachricht zugeordnet.
BlockIPs-Nachrichten werden vom Server verschickt. Sie enthalten eine Menge von IP-Adressen, die vom empfangenden Client blockiert werden sollen.
Behandlung fehlerhafter Nachrichten
Fehlerhafte Nachrichten führen dazu, dass die zugehörige Socket-Verbindung vom Empfänger abgebrochen wird.
Ack-Nachrichten, deren Nachrichten-ID dem Client unbekannt ist, können vom Client ignoriert werden, ohne die Verbindung abzubrechen.
Datenbank des Servers
Es reicht, eine persistente Speicherung aller Loginversuche aus FailedLogin-Nachrichten. Eine Ack-Nachricht darf erst gesendet werden, wenn Daten wirklich persistent gespeichert sind, also nach einem Server-Neustart wieder zur Verfügung stehen.
Der Algorithmus
Client und Server sind rein ereignisgetrieben. Bis auf den Programmstart sind die Programme passiv.
Ereignisse sind entweder fehlgeschlagene Loginversuche, die vom Betriebssystem des Clients kommen, oder über das Netzwerk eingehende Nachrichten, sowie Verbindungsaufbauten und -abbrüche zwischen Client und Server.
Client
Server
Implementierung
Im Folgenden soll auf die konkrete Implementierung genauer eingegangen werden. Sie basiert auf dem im vorhergehenden Kapitel beschriebenen Design.
Programmiersprache
Bei der Wahl der Programmiersprache wurden folgende Aspekte beachtet:
- Platformunabhängigkeit
- Die Implementierung muss auf verschiedenen Betriebssystemen und Architekturen laufen.
- Verfügbarkeit von Bibliotheken
- Vor allem solche für die Kommunikation über TCP/IP und die Umsetzung nebenläufiger Prozesse.
- Hoher Abstraktionsgrad
- um einzelne Komponenten später austauschen zu können, beispielsweise einfache TCP-Kommunikation durch TLS-Verschlüsselung zu ersetzen.
In der engeren Auswahl wurden Java, Go und Python betrachtet. Die Wahl fiel letztendlich auf Java, da hier alle drei Aspekte am besten vertreten waren.
Protokolle und Nachrichten
Sowohl die Kommunikation zwischen Client und Server, als auch die zwischen den einzelnen Komponenten eines Programms wurde in Form asynchroner Protokolle implementiert. Diese basieren auf dem Austausch von Nachrichten zwischen den einzelnen Teilnehmern. Als einheitliche Schnittstelle für dient eine abstrakte Klasse AbstractMessage
, von der alle Nachrichten (interne und Netzwerknachrichten) abgeleitet sind.
Intern
Sowohl Client als auch Server bestehen aus mehreren Threads, welche jeweils mit einer blockierenden Warteschlange zum Empfang von Nachrichten ausgestattet sind. Dies ist in der Klasse AbstractProtocol
implementiert, von der die meisten Klassen in der Implementierung abgeleitet sind.
Möchte ein Thread einem anderen Informationen zukommen lassen, werden diese ans Ende der Warteschlange angefügt. Der Empfänger wertet die eingehenden Nachrichten in der Reihenfolge FIFO (first in – first out) aus.
Dadurch lassen sich sowohl asynchrone als auch synchrone Prozesse realisieren: Threads können einerseits lediglich Nachrichten versenden, ohne auf eine Antwort zu warten, andererseits jedoch blockieren, bis eine Antwort eintrifft.
Netzwerk
Nachrichten zwischen Client und Server (FailedLogin
-, Ack
- und BlockIP
-Nachrichten) werden über TCP-Sockets verschickt. Jedes dieser Sockets wird von einem ConnectionManager
verwaltet. Dieser nimmt einerseits Nachrichten vom Socket entgegen und leitet diese an die für die Verarbeitung zuständigen Threads weiter. Andererseits Nimmt er von beliebigen Threads Nachrichten entgegen, um sie über das Socket zu verschicken.
Konfiguration
Die Konfiguration der Programme erfolgt über .properties
-Dateien, welche beim Start eines Programms eingelesen werden. Dadurch lassen sich die verwendeten Ports, die Adresse des Servers, die Anzahl der erlaubten fehlerhaften Loginversuche pro IP-Adresse und weitere Eigenschaften festlegen.
Client-Seite
Alle für den Client relevanten Funktionen sind in den Dateien Client*.java
implementiert. Die Hauptklasse Client
ist für den Start de Programms und aller weiteren Threads verantwortlich.
Nach dem Start des Clients versucht dieser, sich mit dem Server unter der vorher konfigurierten Adresse zu verbinden. Ist dies erfolgreich, so wird diese Verbindung durch einen ConnectionManager
verwaltet. Bricht die Verbindung ab, so wird in festgelegten Zeitintervallen versucht, eine neue Verbindung aufzubauen. Dies trägt zur Robustheit des Systems bei, da bei Ausfall oder Neustart des Servers keinerlei Änderungen an den laufenden Clients vorgenommen werden müssen.
Hilfsprogramme und Skripte
Da die verwendeten Betriebssysteme – wie oben beschrieben – fehlgeschlagene Loginversuche auf unterschiedliche Weise erkennen und zudem verschiedene Firewalls zur Blockierung von IPs verwenden, wurden diese Vorgänge in Hilfsprogramme ausgelagert. Dadurch ist es möglich, auf allen Rechnern denselben Client zu verwenden, lediglich diese Hilfsprogramme müssen angepasst werden.
Einlesen der Daten über fehlerhafte Logins
Fehlerhafte Loginversuche werden über eine Named Pipe (siehe man mkfifo
) eingelesen. Dies geschieht in der Klasse ClientBtmpEventReader
. Ein betriebssystemabhängiges Hilfsprogramm schreibt auf diese Pipe, sobald ein fehlgeschlagener Loginversuch entdeckt wurde. Die eingelesenen Versuche werden in Form einer FailedLoginMessage
an einen Thread übergeben, welcher die Information mithilfe des ConnectionManager
s an den Server schickt und die Antwort abwartet.
Blockierung von IPs
Beim Eingang einer BlockIpMessage
wird diese ebenfalls an ein Hilfsprogramm übergeben. Dieses überprüft, ob die IP bereits blockiert wurde. Wenn nicht, wird dies veranlasst.
Server-Seite
Die Implementation des Servers findet sich in den Dateien Server*.java
. Die Hauptklasse heißt dabei Server
. Beim Start des Servers wird ein Thread ServerSocketListener
erstellt, welcher auf eingehende Verbindungen an einem vorher definierten TCP-Socket wartet. Geht eine Verbindung von einem Client ein, so wird ein ConnectionManager
gestartet, welcher diese verwaltet.
Datenbank
Zur persistenten Speicherung der fehlgeschlagenen Loginversuche kommt eine SQLite-Datenbank zum Einsatz. Diese wird mithilfe eines ServerDatabaseManager
s verwaltet. Informiert ein Client den Server über fehlgeschlagene Logins, so werden zunächst für jeden dieser Loginversuche die IP, von der der Versuch ausging, der Verwendete Benutzername, die Adresse des Clients und ein Zeitstempel in die Datenbank eingetragen. Im Anschluss wird überprüft, ob die Zahl der Fehlversuche von einer der eingetragenen IP-Adressen einen vorher festgelegten Grenzwert übersteigt. Ist dies der Fall, werden alle verbundenen Clients mit einer BlockIpMessage
zur Blockierung der entsprechenden IP-Adresse aufgefordert. In jedem Fall erhält der Client, der dem Server die fehlgeschlagenen Logins mitgeteilt hat, eine AckMessage
, um diesen darüber zu informieren, dass die Versuche erfolgreich in die Datenbank eingetragen wurden.
Init-Protokoll
Bei jedem Verbindungsaufbau eines Clients wird dieser zunächst über alle bisher zu blockierenden IP-Adressen informiert. Dazu werden aus der Datenbank all die IPs bestimmt, deren Anzahl an Fehlversuchen über dem festgelegten Grenzwert liegt. Diese werden dem Client dann mittels einer BlockIpMessage
mitgeteilt.
TODOs und mögliche Verbesserungen
Um die Implementierung in einer realen Umgebung einsetzen zu können, müssen für alle Clients die entsprechenden Hilfsprogramme zum Einlesen fehlerhafter Logins bzw. zur Sperrung von IP-Adressen implementiert werden. Bisher existiert dafür lediglich das Programm zum Einlesen fehlerhafter Logins aus /var/log/btmp
auf Linux-Rechnern.
Weiterhin sollten Clients in der Lage sein, auch ohne Verbindung zum Server eigenständig arbeiten zu können. Dies ist bisher nicht implementiert.
Um Angriffe gegen das System selbst auszuschließen ist es außerdem nötig, die Kommunikation zwischen Server und Clients abzusichern. Besonders die Authentizität der Clients und des Servers sowie die Integrität der versandten Nachrichten muss dabei gewährleistet werden. Dies lässt sich beispielsweise durch Verwendung von SSL-Sockets erreichen.
Download
Der Quellcode kann hier herunter geladen werden.