This article is also available in English
Ich hätte nie gedacht, einmal einen Artikel über das Java Naming and Directory Interface (JNDI) zu schreiben. Lange kannte ich JNDI nur als Mechanismus, um in einem Anwendungsserver wie Apache Tomcat an eine Datenbankverbindung zu kommen. Doch dann rollte im Dezember 2021 die Sicherheitslücke Log4Shell über uns und JNDI war plötzlich wieder in aller Munde. Zeit also, uns einmal damit zu beschäftigen, wofür JNDI eigentlich gedacht ist und welche Rolle es in der Sicherheitslücke gespielt hat.
Wie so häufig in Java handelt es sich bei JNDI um eine Abstraktion über verschiedenen konkreten Technologien. In diesem Fall abstrahiert die Programmierschnittstelle den Zugriff auf Namens- und Verzeichnisdienste. Die beiden hierfür wohl bekanntesten Vertreter sind das Domain Name System (DNS) und Lightweight Directory Access Protocol (LDAP).
Damit auch weitere Technologien über die Abstraktion von JNDI angebunden werden können, besteht JNDI aus zwei Teilen. Das Application Programming Interface (API) ist dabei der Teil, den wir innerhalb einer Anwendung nutzen. Zusätzlich bietet das Service Provider Interface (SPI) die genannte Möglichkeit, weitere Implementierungen für JNDI zur Verfügung zu stellen.
In diesem Artikel wollen wir uns aber ausschließlich mit der API-Seite beschäftigen. Dazu werden wir uns nach einer kurzen Einführung in die generellen Konzepte von JNDI einige Beispiele mit DNS und LDAP anschauen. Anschließend bewegen wir uns dann noch einmal Richtung Log4Shell und lernen, welche Funktionalität von JNDI hier ausgenutzt wurde.
Konzepte von JNDI
Die Kernidee von Namensdiensten besteht darin, ein Objekt unter einem Namen
abzulegen. Beispielsweise hinterlegen wir in einem DNS-Server die IP-Adresse zu
einem Domainnamen. In JNDI wird dies mit den beiden Interfaces
javax.naming.Binding
und javax.naming.Name
abgebildet.
Zudem benötigen wir einen Kontext (javax.naming.Context
), der eine Klammer
über mehrere Bindings darstellt. Ein Kontext kann dabei auch weitere Subkontexte
enthalten, sollte der Dienst hierarchisch aufgebaut sein.
Zusätzlich dazu gibt es mit javax.naming.directory
noch ein zweites Paket, das
für Verzeichnisdienste gedacht ist. Verzeichnisdienste erweitern Namensdienste
um Attribute (javax.naming.directory.Attribute
), die an Objekte gebunden
werden können. Um mit diesen zu arbeiten, brauchen wir jedoch einen speziellen
Kontext (javax.naming.directory.DirContext
). Dieser Kontext ermöglicht es uns,
auch eine Suche nach Objekten mit bestimmten Attributwerten durchzuführen.
DNS
Eine der standardmäßig im JDK vorhandenen JNDI-Implementierungen ist DNS. Der Hauptanwendungsfall von DNS besteht darin, eine Zuordnung von Domainnamen zu IP-Adressen zu speichern, sogenannte A-Records. Neben diesen gibt es jedoch noch weitere mögliche Records, die mittels DNS verwaltet werden. So verweisen MX-Records auf die für eine Domain verantwortlichen Mailserver und TXT-Records lassen uns beliebige Einträge zu einer Domain vornehmen. Diese werden beispielsweise von Let’s Encrypt verwendet, um nachzuweisen, dass mir eine Domain wirklich gehört.
Die JNDI-Implementierung hat sich dafür entschieden, die einzelnen Records als
Attribute an ein Objekt zu hängen. Deshalb nutzen wir für eine DNS-Abfrage,
siehe Listing 1, den DirContext
in Kombination mit der Methode
getAttributes
.
Dabei zeigt sich vor allem an zwei Stellen das Alter der API. Zum einen müssen
wir bei der Erzeugung des InitialDirContext
zwangsweise eine Hashtable
zur
Konfiguration nutzen. Außerdem liefert uns die Methode getAll
eine
NamingEnumeration<? extends Attribute>
zurück. Um also über alle Attribute zu
iterieren, können wir keine for-each-Schleife nutzen, und auch das Erzeugen
eines Stream
s ist nicht ohne Umweg möglich, sondern wir nutzen eine
while-Schleife in Kombination mit hasMore
und next
.
Davon abgesehen gibt der Code das aus, was wir erwarten würden. Wir erhalten eine Auflistung, siehe Listing 2, der A-, TXT- und MX-Records der Domain mvitz.de.
LDAP
Neben DNS ist auch eine Implementierung für LDAP-Abfragen Teil von JNDI. LDAP ist vor allem dafür bekannt, Benutzer und deren Passwort, sowie weiteren Eigenschaften, zu verwalten. Der wohl bekannteste Vertreter hierfür ist ActiveDirectory von Microsoft.
Eigentlich ist LDAP jedoch eine generische Datenbank inklusive Abfragesprache, in der wir Objekte und Attribute in einem Baum ablegen und abfragen können. LDAP setzt dabei, wie auch SQL-Datenbanken, auf Schemas, die Objekte und deren Attribute beschreiben. Wir können dort also nicht beliebige Dinge ablegen, sondern brauchen vorab eine genau Beschreibung der Struktur.
Für die folgenden Beispiele nutzen wir OpenLDAP, den wir per Docker, siehe
Listing 3, starten. Anschließend ist der LDAP-Server lokal auf localhost unter
den Ports 389 und 689 erreichbar und wir können diesen per JNDI abfragen. Hierzu
erzeugen wir analog zur vorherigen DNS-Abfrage einen InitialDirContext
mit
passendem Environment, wie in Listing 4 zu sehen.
Mittels listBindings
erhalten wir, auch wieder analog zu getAttributes
aus
dem DNS-Beispiel, eine NamingEnumeration
. Dieses Mal enthält diese jedoch
Bindings
. Wir nutzen nun den Namen dieser Bindings, um uns über
getAttributes
die Attribute zum Eintrag zu holen und anschließend das Attribut
cn auszugeben. In diesem Fall hätten wir anstatt listBindings
auch list
nutzen können, da wir das zurückgegebene Objekt nicht nutzen, sondern nur den
Namen für das Abrufen der Attribute verwenden.
Neben dem Auflisten können wir mit JNDI auch eine Suche, wie in Listing 5 zu sehen, ausführen. In diesem Falle suchen wir unterhalb von ou=People nach allen Objekten, bei denen das Attribut ou auf Jedi gesetzt ist. Außerdem soll das Ergebnis der Suche neben dem Namen auch das Attribut cn enthalten, welches wir anschließend ausgeben. Außerdem stellt uns das JNDI API natürlich auch Methoden zur Verfügung, um Objekte und Attribute zu modifizieren. In Listing 6 ändern wir dazu das Attribut cn des Objekts mit dem Namen uid=leia,ou=People.
Da LDAP der wohl prominenteste Vertreter für JNDI ist, hätten wir für diese
Abfragen auch die Interfaces aus dem dedizierten Paket javax.naming.ldap
nutzen können. Diese bilden zusätzliche spezielle LDAP-Konzepte ab, die über die
generischere JNDI API nicht genutzt werden können. Diese werden allerdings für
die hier gezeigten Beispiele nicht benötigt.
Code aus LDAP laden
Bisher haben wir gesehen, wie mit JNDI DNS- und LDAP-Abfragen beziehungsweise Modifikationen aussehen. Doch was hiervon ist Teil einer so verheerenden Sicherheitslücke?
Bisher haben wir zwar mittels listBindings
Bindings vom Server abgefragt,
welche einen Namen mit einem Objekt verknüpfen, haben allerdings das Objekt
nicht verwendet. Und genau in dieser Verwendung besteht das Problem. JNDI bietet
uns nämlich die Möglichkeit, serialisierte Java-Objekte abzufragen und
anschließend auch zu nutzen.
Dazu legen wir im LDAP-Server einige Objekte, siehe Listing 7, ab. Durch die Angabe von objectclass weiß der LDAP-Server, welchem Schema die Objekte folgen und welche Attribute möglich sind. Zudem setzen wir die beiden Attribute javaclassname und javaSerializedData.
Diese Einträge können wir nun wie bereits gesehen mit JNDI abfragen, siehe
Listing 8. Sobald wir jetzt jedoch das gebundene Objekt nutzen, wird der im LDAP
abgelegte serialisierte Objektzustand geladen. Der direkte lookup
dient hier
nur dazu, das Objekt direkt casten zu können. Wir hätten alternativ während der
Iteration über alle Bindings mit getObject
Zugriff auf das Objekt erhalten
können. Die Ausgabe, siehe Listing 9, zeigt, dass wir dabei sowohl Objekte aus
dem JDK selbst, HashMap
, als auch eigene Klassen, Person
, ablegen und
abrufen können, solange diese Klasse das Interface java.io.Serializable
implementiert.
Somit ist es also möglich, Code innerhalb einer Anwendung nachzuladen. Aber natürlich ermöglicht es das nicht nur uns, sondern potenziell auch Angreifenden. Bislang beschränkt sich diese Möglichkeit auf Klassen, die unsere Anwendung kennt. Der Angreifende müsste also eine Kombination von vorhandenen Klassen finden, die durch weitere Schwachstellen ausnutzbar sind.
JNDI ermöglicht es uns allerdings auch, der Anwendung unbekannte Klasse
dynamisch nachzuladen. Hierzu setzen wir im LDAP neben den beiden Attributen
javaclassname und javaSerializedData zusätzlich das Attribut javaCodebase.
Beim Eintrag aus Listing 10 setzen wir diesen Wert auf eine URL, unter der eine
jar-Datei über HTTP erreichbar ist. JNDI lädt nun beim Lookup, siehe Listing 11
nicht nur das Objekt, sondern auch die bisher unbekannte Klasse
de.mvitz.jndi.remote.Hack
dynamisch nach. Die beiden Aufrufe von
System.out.println
mit dem geladenen Objekt führen anschließend die
toString
-Methode auf der Instanz aus. Somit ist es also möglich, beliebigen
Code nachzuladen, solange die Anwendung den unter javaCodebase angegebenen Ort
erreichen kann.
Dieses dynamische Nachladen von Klassen mit JNDI funktioniert allerdings, zum
Glück, in neueren Versionen des JDKs nicht mehr automatisch. Sollten wir diese
Funktionalität benötigen, müssen wir explizit beim Starten der Anwendung das
Java-System-Property com.sun.jndi.ldap.object.trustURLCodebase
auf true
setzen.
Log4Shell
Wie hängt nun JNDI mit Log4Shell zusammen. Im Grunde ist es hier wie bei vielen Sicherheitslücken eine Kombination aus mehreren Faktoren, die kombiniert zum Problem werden.
Log4j erlaubt es innerhalb von Lognachrichten, mittels der Syntax ${...}
Parameter in die Nachricht einzubinden. Die Parameter können dabei aus
unterschiedlichen Quellen geladen werden. Eine Möglichkeit
hierbei ist es auch, mittels ${jndi:...}
einen JNDI-Lookup auszuführen.
Schafft es der Angreifende nun, dass wir einen solchen String loggen, beispielsweise indem er einen passenden HTTP-Header oder Query-Parameter mitsendet, ist es, wie gesehen, in Kombination mit JNDI möglich, Code nachzuladen. Ist das dynamische Nachladen von Klassen aktiviert, kann nun beliebiger Code ausgeführt werden. Sollte dies, wie standardmäßig, deaktiviert sein, kann noch versucht werden, bereits in der Anwendung vorhandene Klassen auszunutzen. Schematisch sehen die Aufrufe dann aus wie in Listing 12 und führen zur Ausgabe aus Listing 13.
Wir können hier sehen, dass der erste Lookup erfolgreich ausgeführt wurde und
wir als Ausgabe den Wert der toString
-Methode unserer Instanz aus dem LDAP
erhalten.
Sollte die Deserialisierung, aus dem ersten Logstatement, keinen Angriff erlauben, können wir in Verbindung mit einem Lookup für Umgebungsvariablen noch versuchen, Werte abzugreifen. Listing 14 zeigt das Log des LDAP-Servers für das zweite Logstatement, in dem wir den konkreten Wert der Umgebungsvariable HOME vom Server ablesen können.
Um uns gegen diese konkrete Lücke abzusichern, sollte auf jeden Fall Log4j auf mindestens Version 2.17.1 upgedatet werden. Zudem ist es ratsam, ein aktuelles JDK zu verwenden. Zusätzlich kann es sinnvoll sein, den ausgehenden Netzwerkverkehr einzuschränken und so nur noch Verbindungen zu bekannten Zielen zu erlauben.
Wer dann noch weiter gehen möchte, kann zudem mittels Java Security Manager noch weiter einschränken, welcher Code das JNDI API überhaupt aufrufen darf. Der Security Manager wurde jedoch in JDK 17 mit JEP 411 deprecated und zur Entfernung markiert. Es ist also fraglich, ob diese Option noch lange verfügbar ist.