Decapsulation: Breaking Java Strong Encapsulation
Sep 29, 2026Did you know you can call JNI without writing native code, patch bytecode without an agent, and use Unsafe without warnings?
11 sneaky ways into JDK internals through reflection, agents, FFM, type confusion, bytecode manipulation and more.
Java has been, over the last decade and many releases, on a mission of Strong Encapsulation of JDK Internals to provide "Integrity by Default". It is building a wall of encapsulation around its internals to prevent libraries from harming the JDK's integrity. In this post we explore which gaps in the unfinished parts of the wall we can sneak through, and how, if we really want to, we can bust straight through.
The wall has gates that an application developer can open: command line options like --add-exports and --add-opens give code permission to bypass parts of the encapsulation.
The application developer is assumed to be in control of the options the application is launched with.
So in this setup there are three players divided into two teams.
On one side there are the JDK and the application developers, who control and guard the wall.
On the other side are library developers who tend to disregard borders, accessing any JDK internals that help get the job done.
Welcome to team Library Developer.
Rules of the game
In this blog post, we're playing a game against the JDK. Let's start by making the rules concrete.
What does it mean to access JDK internals?
A MethodHandles.Lookup is an object that, among other things, can hold the ability to access private members.
For example, if class A creates a Lookup object using MethodHandles.lookup() and gives that object to code in module B, then B can use that to effectively act on behalf of A:
it can access everything that A has access to, including private fields and methods of A.
Lookups can have various levels of access.
The most interesting one for us is a trusted Lookup, that can access any class's internals without restrictions.
Concretely, the way it is currently implemented in the JDK, that is a Lookup whose private final int allowedModes is set to -1 (the value of the constant Lookup.TRUSTED).
Once we have such a Lookup, we are unconstrained. It's a single object that unlocks everything else in the kingdom. We can for example mutate the value of a String (an example chosen because I enjoy messing with Strings):
// Implementing makeLookup() in a way that allows this is the goal
Lookup lookup = makeLookup();
String foo = "foo"; // a String which is supposed to be immutable
// Use Lookup to grab the private `value` field of the String.
MethodHandle methodHandle = lookup.findGetter(String.class, "value", byte[].class);
byte[] value = (byte[]) methodHandle.invokeExact(foo);
value[0] = 'm'; // Mutate the String
System.out.println(foo); // prints "moo"
We play the role of a fictitious library developer with absolutely no self-control.
Our goal is to obtain a trusted Lookup by any means necessary, in as many and as creative ways as we can.
Our code executes in a JVM that is already running; we cannot choose the command line options that the JVM was launched with. Triggering warnings at runtime is tolerated but undesirable. Compile-time warnings are irrelevant.
This somewhat resembles the situation of library developers in reality, but we push it to the extreme.
The real point behind all of this is to learn about strong encapsulation in the JDK, about Java in general, and most of all to have fun doing it.
Overview
We start with the most basic, well-known ways, and then progress towards more outlandish approaches, with one surprise at the end.
For simplicity, we only consider OpenJDK on HotSpot in this post. The code was tested on JDK versions 11 through 27 on Linux and Windows (where applicable). Here is a summary of the eleven ways we came up with, in which JDK versions they work silently (✔️), whether they trigger a warning (⚠️) or fail (❌) or are not available in that version (empty):
| Method | 11-15 | 16-20 | 21 | 22-23 | 24-27 |
|---|---|---|---|---|---|
| 1: Reflection | ⚠️ | ❌ | ❌ | ❌ | ❌ |
| 2: Unsafe | ✔️ | ✔️ | ✔️ | ✔️ | ⚠️ |
| 3: JNI | ✔️ | ✔️ | ✔️ | ✔️ | ⚠️ |
| 4: Java Agent | ✔️ | ✔️ | ⚠️ | ⚠️ | ⚠️ |
| 5: FFM - Load a Library | ⚠️ | ⚠️ | |||
| 6: FFM - Use libjvm | ⚠️ | ⚠️ | |||
| 7: FFM - Heap | ⚠️ | ⚠️ | |||
| 8: FFM - Bytecode | ⚠️ | ⚠️ | |||
| 9: Memory manipulation through the OS | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| 10: Patch the JDK | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| 11: newConstructorForSerialization | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Method 1: Reflection
The oldest trick in the book to access private methods and fields of other classes is reflection. Just callingField.set on a private field throws an IllegalAccessException.
But first calling Field.setAccessible(true) suppresses access control checks, allowing us to access private fields, and even to write to final ones.
So we could create a trusted Lookup like this:
Lookup makeLookup() throws Exception {
Lookup lookup = MethodHandles.lookup();
Field allowedModes = Lookup.class.getDeclaredField("allowedModes");
allowedModes.setAccessible(true);
allowedModes.set(lookup, -1); // -1 is Lookup.TRUSTED
return lookup;
}Lookup.allowedModes, making them invisible:
Lookup.class.getDeclaredField("allowedModes") now throws a NoSuchFieldException even though the field still exists.
It's not clear to me what exactly they intended to accomplish by hiding that field, because next to it in the Lookup class sits this field that is not filtered:
static final Lookup IMPL_LOOKUP = new Lookup(Object.class, null, TRUSTED);Lookup from there instead:
Field implLookup = Lookup.class.getDeclaredField("IMPL_LOOKUP");
implLookup.setAccessible(true);
return (Lookup) implLookup.get(null);setAccessible on members of JDK classes that aren't opened to us is no longer allowed by default since JEP 396: Strongly Encapsulate JDK Internals by Default in JDK 16.
That JEP enforced module boundaries, essentially closing the main entrance into JDK internals.
- Result
- JDK 11-15: ⚠️ Works with warnings
- JDK 16+: ❌ Fails
- Warning
WARNING: An illegal reflective access operation has occurred WARNING: Illegal reflective access by decapsulation.SetAccessible (file:/.../) to field java.lang.invoke.MethodHandles$Lookup.IMPL_LOOKUP WARNING: Please consider reporting this to the maintainers of decapsulation.SetAccessible WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations WARNING: All illegal access operations will be denied in a future release
- Error
java.lang.reflect.InaccessibleObjectException: Unable to make field static final java.lang.invoke.MethodHandles$Lookup java.lang.invoke.MethodHandles$Lookup.IMPL_LOOKUP accessible: module java.base does not "opens java.lang.invoke" to unnamed module- JEPs
- Review
-
- It's a classic that served our desires to break restrictions for many years.
- It's broken. Specifying
--add-opencommand-line options is sooo annoying. I want a refund.
Method 2: Unsafe
sun.misc.Unsafe is a class in the JDK that provides methods for performing low-level, unsafe operations.
This class was only supposed to be used by the JDK internally, but so many libraries and applications use it that it was left open while other internal APIs were locked down.
There exists an Unsafe.getUnsafe() method, but when that is called from outside the JDK it throws a SecurityException.
Using reflection, we can still get our hands on an instance of it because sun.misc is still open to reflection.
Although it seems clear that you aren't supposed to do that, this is what a lot of real-world code does:
static Unsafe getUnsafe() throws ReflectiveOperationException {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (Unsafe) f.get(null);
}
What can we do with Unsafe?
It has methods to directly read from and write to objects based on offsets in memory, bypassing access restrictions and even type checking.
The offset here means the number of bytes between the start of the object in memory and where the value of the field is stored.
We could set the allowedModes of our Lookup instance that way.
But to avoid the complication of reflection hiding that field, we grab the static Lookup.IMPL_LOOKUP instance instead.
Offsets of static fields are not relative to an instance of the class, but to a static field base object, which we get from Unsafe.staticFieldBase:
Lookup makeLookup() throws ReflectiveOperationException {
Unsafe unsafe = getUnsafe();
Field field = Lookup.class.getDeclaredField("IMPL_LOOKUP");
Object staticFieldBase = unsafe.staticFieldBase(field);
long offset = unsafe.staticFieldOffset(field);
return (Lookup) unsafe.getObject(staticFieldBase, offset);
}Unsafe, thank you for the trusted Lookup. I'll keep it safe for you.
Sadly, since JDK 24 (JEP 498) this prints a warning because the memory-access methods are deprecated for removal (JEP 471).
- Source
- UnsafeGet.java
- Result
- JDK 11-23: ✔️ Works
- JDK 24-27: ⚠️ Works with warnings
- Warning
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called WARNING: sun.misc.Unsafe::staticFieldBase has been called by decapsulation.UnsafeGet (file:/.../) WARNING: Please consider reporting this to the maintainers of class decapsulation.UnsafeGet WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
- JEPs
- Review
-
- Well-known mechanism. Unsafe is an old friend.
- Pesky warning is hard to ignore.
Method 3: JNI
JNI (Java Native Interface) allows us to load native code into the JVM, and call it from Java code. The native code can access JNI APIs to manipulate Java objects without access restrictions; it is not bound by reflection filtering, module boundaries and can write to final fields.
Here's a simple native library in C that uses the JNI SetIntField function, to set the value of Lookup.allowedModes.
#include <jni.h>
JNIEXPORT void JNICALL Java_decapsulation_UseJNI_setAllowedModes(
JNIEnv *env, jclass cls, jobject lookup) {
jclass lookupClass = (*env)->GetObjectClass(env, lookup);
jfieldID fieldId = (*env)->GetFieldID(env, lookupClass, "allowedModes", "I");
(*env)->SetIntField(env, lookup, fieldId, -1);
}setAllowedModes method in the decapsulation.UseJNI class.
We need to compile that code with a C compiler, generating a .so on Linux, .dll on Windows (or .dylib on macOS).
Then we can load that library into the JVM, add the Java counterpart to this native function and call it:
package decapsulation;
class UseJNI {
static {
String libFile = System.getProperty("os.name").startsWith("Windows")
? "usejni.dll" : "usejni.so";
System.load(new File(libFile).getAbsolutePath());
}
private static native void setAllowedModes(Object lookup);
Lookup makeLookup() {
Lookup lookup = MethodHandles.lookup();
setAllowedModes(lookup);
return lookup;
}
}Lookup accomplished. But since JDK 24 (JEP 472), the JDK spews a warning when loading a library with System.load.
- Source
- UseJNI.java
- Result
- JDK 11-23: ✔️ Works
- JDK 24-27: ⚠️ Works with warnings
- Warning
WARNING: A restricted method in java.lang.System has been called WARNING: java.lang.System::load has been called by decapsulation.UseJNI in an unnamed module (file:/.../) WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module WARNING: Restricted methods will be blocked in a future release unless native access is enabled
- Review
-
- Uses supported public APIs.
- Yada yada will be blocked yada yada. Pfff.
- Requires compiling and shipping platform-specific native code.
Method 4: Java Agent
Another great way to interface with the JVM is through agents. Agents provide a means for tools such as profilers, debuggers and monitoring tools to inspect and control a JVM. Java supports two types of agents: a JVMTI agent written in native code, and "Java agents" written in plain Java. We've already gone the native route, so let's go for a pure Java way.
There are two ways to load an agent into the JVM: you can specify it on the command line when launching the JVM, or load it dynamically into a running JVM. In our scenario the command line is out of our hands, so dynamic loading it is.
The JDK provides the com.sun.tools.attach.VirtualMachine class, which can attach to a JVM given its PID. It can then tell it to do things such as loading an agent provided as a jar.
A naive attempt to VirtualMachine.attach(Long.toString(ProcessHandle.current().pid())) fails with a sad java.io.IOException: Can not attach to current VM.
You can only attach to a different JVM, unless permitted by a -Djdk.attach.allowAttachSelf=true command-line option.
But asking for permission is not our thing. We can easily get around that by spawning a child process that loads the agent into our original process.
We could use jcmd for that when that's available, but we build our own little Java agent loader tool.
Path agentLoader = Files.createTempFile("JavaAgentLoader", ".java");
Files.writeString(agentLoader,
"public class JavaAgentLoader {" +
" public static void main(String[] args) throws Exception {" +
" com.sun.tools.attach.VirtualMachine.attach(args[0]).loadAgent(args[1]);" +
" }" +
"}");
agentLoader.toFile().deleteOnExit();
new ProcessBuilder()
.command(System.getProperty("java.home") + "/bin/java",
agentLoader.toString(),
Long.toString(ProcessHandle.current().pid()),
"our-agent.jar")
.start()
.waitFor();META-INF/MANIFEST.MF file that points to the agent's main class.
In our case it can contain just Agent-Class: decapsulation.Agent.
The Agent's agentmain method is given an instance of Instrumentation.
That is a powerful API that allows modifying the code of any Java method in the JVM.
We sure could abuse that here, but we'll come back with a sneakier way to modify bytecode without instrumentation later.
Instead, Instrumentation hands us a much simpler way to gain access:
using Instrumentation.redefineModule we can open up the java.lang.invoke package (which contains Lookup) to our code.
This has the same effect as if --add-opens java.base/java.lang.invoke=ALL-UNNAMED had been given on the command line.
class Agent {
public static void agentmain(String arg, Instrumentation instrumentation) {
// module where our other code lives too
Module unnamedModule = Agent.class.getModule();
var extraOpens = Map.of("java.lang.invoke", Set.of(unnamedModule));
instrumentation.redefineModule(Lookup.class.getModule(),
Set.of(), Map.of(), extraOpens, Set.of(), Map.of());
}
}
With that package opened up, we are now allowed to access
Lookup's private members using reflection.
Concretely, after loading the agent, the IMPL_LOOKUP reflection code from method 1 works:
Field implLookup = Lookup.class.getDeclaredField("IMPL_LOOKUP");
implLookup.setAccessible(true);
return (Lookup) implLookup.get(null);
Unfortunately, since JDK 21 (JEP 451) dynamically loading an agent comes with a warning.
- Source
- AttachJavaAgent.java
- Result
- JDK 11-20: ✔️ Works
- JDK 21-27: ⚠️ Works with warnings
- Warning
WARNING: A Java agent has been loaded dynamically (/tmp/decapsulation123.jar) WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information WARNING: Dynamic loading of agents will be disallowed by default in a future release
- Review
-
- Instrumentation is a nifty tool.
- Trivially circumvented self-attach restrictions are funny.
- 100% Java
- The warning signs are ruining the beautiful view.
Method 5: FFM - Load a Library
The Foreign Function and Memory (FFM) is an API finalized in JDK 22 to call into native functions and access memory directly from Java. It is similar to JNI, but one important difference is that it can be used to call practically any native API: unlike with JNI, the functions don't need to be exposed in a way that is particularly tailored to being called from Java.
We'll look at multiple ways to use FFM to break encapsulation.
The first is basically the same as what we did with JNI above: we write some C code that uses the JNI API, compile it, and load it, only this time through FFM instead of System.load.
This feels a bit repetitive, but it's good preparation for the next method where we build further upon this.
Because the FFM API isn't really made to call functions that then use the JNI API, we don't get handed a JNIEnv for free; we have to go look for it ourselves.
Another slight complication: FindClass wouldn't find our class here because in this environment it's looking in the wrong class loader.
We work around that by passing the Lookup to our native code by abusing system properties as global variables this time:
void setAllowedModes() {
JavaVM *vm;
JNI_GetCreatedJavaVMs(&vm, 1, NULL);
JNIEnv *env;
(*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_8);
// Get the Lookup from `System.getProperties().get("decapsulation.lookup")`.
jclass systemClass = (*env)->FindClass(env, "java/lang/System");
jmethodID getPropsMid = (*env)->GetStaticMethodID(env, systemClass,
"getProperties", "()Ljava/util/Properties;");
jobject props = (*env)->CallStaticObjectMethod(env, systemClass, getPropsMid);
jclass propsClass = (*env)->GetObjectClass(env, props);
jmethodID getMid = (*env)->GetMethodID(env, propsClass,
"get", "(Ljava/lang/Object;)Ljava/lang/Object;");
jstring keyStr = (*env)->NewStringUTF(env, "decapsulation.lookup");
jobject lookup = (*env)->CallObjectMethod(env, props, getMid, keyStr);
jclass lookupClass = (*env)->GetObjectClass(env, lookup);
jfieldID allowedModesId = (*env)->GetFieldID(env, lookupClass, "allowedModes", "I");
(*env)->SetIntField(env, lookup, allowedModesId, -1);
}setAllowedModes function:
Lookup lookup = MethodHandles.lookup();
System.getProperties().put("decapsulation.lookup", lookup);
Path lib = Path.of(System.getProperty("os.name").startsWith("Windows")
? "ffmlibrary.dll" : "ffmlibrary.so");
try (Arena arena = Arena.ofConfined()) {
SymbolLookup symbols = SymbolLookup.libraryLookup(lib, arena);
MethodHandle setAllowedModes = Linker.nativeLinker().downcallHandle(
symbols.findOrThrow("setAllowedModes"),
FunctionDescriptor.ofVoid());
setAllowedModes.invoke();
}System.load, SymbolLookup.libraryLookup is a restricted method: it works but comes with a warning, which our upcoming couple methods using FFM also run into.
- Source
- FfmLibrary.java
- Result
- JDK <=21: ❌ FFM not available
- JDK 22-27: ⚠️ Works with warnings
- Warning
WARNING: A restricted method in java.lang.foreign.SymbolLookup has been called WARNING: java.lang.foreign.SymbolLookup::libraryLookup has been called by decapsulation.FfmLibrary in an unnamed module (file:/.../) WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module WARNING: Restricted methods will be blocked in a future release unless native access is enabled
- Review
-
- FFM is a (relatively) shiny new toy.
- Using the shiny new toy just as another way to load native code is unimaginative. Boring.
- The C is invading my Java blog.
- The warnings look serious, man. Maybe we should listen to them?
Method 6: FFM - Use libjvm
What's exciting about FFM is that it can call any native function, so we can skip our C code entirely and call the JNI functions (JNI_GetCreatedJavaVMs, GetEnv,...) directly from Java.
No need to compile C and ship a native library.
First, an analogy to help understand what we're doing.
Take this modern (java.lang.IO!) version of our classic first program: IO.println("Hello world").
Since you're such a fan of reflection, you say hello through reflection:
public static void helloWorld() throws Exception {
Class<?> ioClass = Class.forName("java.lang.IO");
Method println = ioClass.getMethod("println", Object.class);
println.invoke(null, "Hello world");
}MyClass.class.getMethod("helloWorld").invoke(null). That would be reflection-from-reflection.
But that is weak, compared to doing reflection-through-reflection:
// Class<?> ioClass = Class.forName("java.lang.IO");
Class<?> clazz = Class.forName("java.lang.Class");
Method classForName = clazz.getMethod("forName", Class.forName("java.lang.String"));
Object ioClass = classForName.invoke(null, "java.lang.IO");
// Method println = ioClass.getMethod("println", Object.class);
Method getMethod = clazz.getMethod("getMethod", String.class, Class[].class);
Object println = getMethod.invoke(ioClass, "println", new Class[]{Object.class});
// println.invoke(null, "Hello world");
Class<?> methodClass = Class.forName("java.lang.reflect.Method");
Method invoke = methodClass.getMethod("invoke", Object.class, Object[].class);
invoke.invoke(println, null, new Object[]{"Hello world"});
We start with a libraryLookup on libjvm.so/jvm.dll to be able to look up symbols in the JVM.
Unlike in Method 5, that's not loading a new library; it is getting a reference to the already loaded JVM implementation.
Linker linker = Linker.nativeLinker();
try (Arena arena = Arena.ofConfined()) {
Path libjvmPath = System.getProperty("os.name").startsWith("Windows")
? Path.of(System.getProperty("java.home"), "bin", "server", "jvm.dll")
: Path.of(System.getProperty("java.home"), "lib", "server", "libjvm.so");
SymbolLookup jvmSymbols = SymbolLookup.libraryLookup(libjvmPath, arena);JavaVM *vm;
JNI_GetCreatedJavaVMs(&vm, 1, NULL);MethodHandle getCreatedVMs = linker.downcallHandle(
jvmSymbols.findOrThrow("JNI_GetCreatedJavaVMs"),
FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT, ADDRESS));
MemorySegment vmBuf = arena.allocate(ADDRESS);
getCreatedVMs.invoke(vmBuf, 1, MemorySegment.NULL);
MemorySegment vm = vmBuf.get(ADDRESS, 0).reinterpret(Long.MAX_VALUE);GetEnv. In C that was:
JNIEnv *env;
(*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_8);jni.h to understand what that code is doing.
JavaVM is (from C's perspective, ignoring C++) a JNIInvokeInterface_* pointer.
JNIInvokeInterface_ is a struct containing function pointers:
typedef const struct JNIInvokeInterface_ *JavaVM;
struct JNIInvokeInterface_ {
void *reserved0;
void *reserved1;
void *reserved2;
jint (JNICALL *DestroyJavaVM)(JavaVM *vm);
jint (JNICALL *AttachCurrentThread)(JavaVM *vm, void **penv, void *args);
jint (JNICALL *DetachCurrentThread)(JavaVM *vm);
jint (JNICALL *GetEnv)(JavaVM *vm, void **penv, jint version);
jint (JNICALL *AttachCurrentThreadAsDaemon)(JavaVM *vm, void **penv, void *args);
};GetEnv at index 6 in that struct:
// (*vm)->
MemorySegment invokeInterface = vm.get(ADDRESS, 0).reinterpret(Long.MAX_VALUE);
// GetEnv(vm, (void **)&env, JNI_VERSION_1_8);
MethodHandle getEnv = linker.downcallHandle(
invokeInterface.getAtIndex(ADDRESS, 6),
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, JAVA_INT));
MemorySegment envBuf = arena.allocate(ADDRESS);
getEnv.invoke(vm, envBuf, 0x00010008 /* JNI_VERSION_1_8 */);
MemorySegment env = envBuf.get(ADDRESS, 0).reinterpret(Long.MAX_VALUE);JNIEnv is a JNINativeInterface_* pointer, another function pointer struct which we use for the other JNI function calls.
In there we will use indexes from jni.h in the same way: FindClass at 6, GetStaticMethodID at 113, etc.
MemorySegment nativeInterface = env.get(ADDRESS, 0).reinterpret(Long.MAX_VALUE);jobjects returned by calls to JNI functions are local references.
Those are only valid until the native method that created them returns.
But we aren't in a native method; we've skipped that layer, and we're calling the JNI functions directly from Java.
The JDK isn't prepared for this construct. We're on very thin ice here.
When we get such a reference, anything we do next may invalidate it (e.g. any other native method call may clear it).
Therefore, as soon as we can, we turn each one into a global reference using NewGlobalRef (and if we cared we'd free those afterwards).
MethodHandle newGlobalRef = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 21),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS));// jclass systemClass = (*env)->FindClass(env, "java/lang/System");
MethodHandle findClass = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 6),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS));
MemorySegment systemClass = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) findClass.invoke(env, arena.allocateFrom("java/lang/System")));
The rest of the Java translation of the C translation of System.getProperties().get("decapsulation.lookup") is straightforward but verbose.
Note that we still need that detour to pass our Lookup around, even though we're in the Java world where we already have our lookup instance. FFM can't hand a Java object to native code.
We end up with a MemorySegment lookupRef which is a Java reference to the JNI reference to the Java lookup object.
Click here to expand the full implementation.
// jmethodID getPropsMid = (*env)->GetStaticMethodID(env, systemClass,
// "getProperties", "()Ljava/util/Properties;");
MethodHandle getStaticMethodID = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 113),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MemorySegment getPropsMid = (MemorySegment) getStaticMethodID.invoke(env, systemClass,
arena.allocateFrom("getProperties"),
arena.allocateFrom("()Ljava/util/Properties;"));
// jobject props = (*env)->CallStaticObjectMethod(env, systemClass, getPropsMid);
MethodHandle callStaticObjectMethod = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 114),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MemorySegment props = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) callStaticObjectMethod.invoke(env, systemClass, getPropsMid));
// jmethodID getMid = (*env)->GetMethodID(env, (*env)->GetObjectClass(env, props),
// "get", "(Ljava/lang/Object;)Ljava/lang/Object;");
MethodHandle getObjectClass = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 31),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS));
MemorySegment propsClass = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) getObjectClass.invoke(env, props));
MethodHandle getMethodID = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 33),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MemorySegment getMid = (MemorySegment) getMethodID.invoke(env, propsClass,
arena.allocateFrom("get"),
arena.allocateFrom("(Ljava/lang/Object;)Ljava/lang/Object;"));
// jstring keyStr = (*env)->NewStringUTF(env, "decapsulation.lookup");
MethodHandle newStringUTF = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 167),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS));
MemorySegment keyStr = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) newStringUTF.invoke(env, arena.allocateFrom("decapsulation.lookup")));
// jobject lookup = (*env)->CallObjectMethod(env, props, getMid, keyStr);
MethodHandle callObjectMethod = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 34),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MemorySegment lookupRef = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) callObjectMethod.invoke(env, props, getMid, keyStr));
And finally, what it's all about, setting lookup.allowedModes = -1, translating the last 3 lines of our C code:
// jclass lookupClass = (*env)->GetObjectClass(env, lookup);
MemorySegment lookupClass = (MemorySegment) newGlobalRef.invoke(env,
(MemorySegment) getObjectClass.invoke(env, lookupRef));
// jfieldID allowedModesId = (*env)->GetFieldID(env, lookupClass, "allowedModes", "I");
MethodHandle getFieldID = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 94),
FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS, ADDRESS, ADDRESS));
MemorySegment allowedModesId = (MemorySegment) getFieldID.invoke(env, lookupClass,
arena.allocateFrom("allowedModes"),
arena.allocateFrom("I"));
// (*env)->SetIntField(env, lookup, allowedModesId, -1);
MethodHandle setIntField = linker.downcallHandle(
nativeInterface.getAtIndex(ADDRESS, 109),
FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, JAVA_INT));
setIntField.invoke(env, lookupRef, allowedModesId, -1);Lookup is alive!
Bolting JNI and FFM together was questionable but awesome science.
Dr. Frankenstein would be proud.
- Source
- FfmLibJvm.java
- Result
- JDK <=21: ❌ FFM not available
- JDK 22-27: ⚠️ Works with warnings
- Warning
- Same as in previous FFM method
- Review
-
- Java method calls expressed as native method calls expressed as Java method calls. Inception!
- I don't need your stinking C compiler.
- I went to the moon and all I got was this lousy warning.
- TL;DR
Lo and behold, it is
ok to botch
up encapsulation
Method 7: FFM - Heap
So far we've focussed on the first two letters of FFM (Foreign Function). Now let's focus on the last one: Memory.
The MemorySegment class in the FFM API allows reading from and writing to arbitrary locations in memory, meant for interacting with "foreign" code through "foreign" memory.
But we can also abuse it to manipulate Java objects in memory.
If we knew the memory address of our Lookup object, and the offset of the allowedModes field inside it, then we could set it to -1 (TRUSTED) like this:
MemorySegment lookupSegment = MemorySegment.ofAddress(addressOfLookup)
.reinterpret(sizeOfLookup);
lookupSegment.set(JAVA_INT, offsetOfAllowedModesField, -1);Lookup object lives in memory.
Java objects usually (ignoring optimizations that aren't relevant now) live in the heap, so we start by locating the heap.
There is no direct API that gives us the address of the heap, but we can make use of JFR (Java Flight Recorder).
JFR is an observability and monitoring framework built into the JVM, which sends events with low-level information about how a JVM and Java applications are behaving.
One of those events is jdk.GCHeapSummary, an event sent when the JVM runs garbage collection, which contains the start address and size of the heap.
We listen for the event, trigger garbage collection, and then get the info from the event.
That way we make a MemorySegment pointing to the heap:
MemorySegment findHeap() throws Exception {
CompletableFuture<MemorySegment> future = new CompletableFuture<>();
try (RecordingStream rs = new RecordingStream()) {
rs.enable("jdk.GCHeapSummary");
rs.onEvent("jdk.GCHeapSummary", e -> {
if (e.getString("when").equals("After GC")) {
long start = e.getLong("heapSpace.start");
long end = e.getLong("heapSpace.committedEnd");
future.complete(MemorySegment.ofAddress(start).reinterpret(end - start));
}
});
rs.startAsync();
System.gc();
return future.get(5, SECONDS);
}
}Lookup object is hard to recognize.
Instead, we look for a different object that is easier to recognize, our Victim, which holds a reference to the Lookup:
class Victim {
// Semi-random values that will help us find this object in memory
long a = 0xF108F703F405F602L;
long b = 0xF207F170E3457833L;
// the lookup that we will be modifying
Lookup lookup = MethodHandles.lookup();
}Victim by searching for those values:
/// Returns a [MemorySegment] pointing at the [Victim#lookup] field.
private MemorySegment findVictim(MemorySegment heap) {
for (long i = 0; i < heap.byteSize() - 32; i += 8) {
if (heap.get(JAVA_LONG, i) == victim.a
&& heap.get(JAVA_LONG, i + 8) == victim.b) {
return heap.asSlice(i + 16);
}
}
throw new RuntimeException("Failed to find victim in memory");
}MemorySegment) of the lookup field in our Victim instance.
That is an OOP ("ordinary object pointer") pointing at the Lookup.
One way we could go from here is to get the actual address of the Lookup from that, find the offset into the Lookup where the allowedModes field value is, and update that.
But there are some complications with that. We would have to decode the OOP into an address, and how exactly OOPs are encoded depends on heap size, which garbage collector is active and more.
Let's avoid getting into that.
Instead, we let the JVM itself follow that OOP and update the field, by doing it in plain Java.
If the JVM knew we were doing that to a Lookup, it wouldn't allow it.
But we trick it into thinking it's an object of a different class, by copying the OOP into a field of a different type.
This technique is known as type confusion.
The Lookup class has three fields. We make a class that has the same fields, giving it the same layout in memory, but where allowedModes is mutable:
class DontLookup {
Class<?> lookupClass;
Class<?> prevLookupClass;
int allowedModes;
}Victim class:
class Victim {
long a = 0xF108F703F405F602L;
long b = 0xF207F170E3457833L;
Lookup lookup = MethodHandles.lookup();
DontLookup dontLookup;
}victim.lookup to victim.dontLookup.
Then by updating victim.dontLookup.allowedModes we actually update victim.lookup.allowedModes:
MemorySegment victimSegment = findVictim(findHeap());
long lookupOop = victimSegment.get(JAVA_LONG, 0);
victimSegment.set(JAVA_LONG, 8, lookupOop);
victim.dontLookup.allowedModes = -1;
Under the right circumstances this works, but there are two issues: We assumed that OOPs are 64 bits (the size of a long), and we assumed that fields appear in memory in the same order as they are defined in code.
When the JVM heap is small enough, OOPs are usually compressed into 32 bits. We can check whether that's the case with HotSpotDiagnosticMXBean:
boolean useCompressedOops() {
return ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class)
.getVMOption("UseCompressedOops").getValue().equals("true");
}long is stored at an address/offset that is a multiple of 8 bytes (64 bits).
That influences the in-memory order of the fields of Victim, which also depends on the JDK version, and factors such as whether we're using compressed OOPs or not, and other JVM options.
A handy tool to understand how an object is represented in memory is JOL (Java Object Layout).
For example, let's look at it on JDK 25 with default options, with 32-bit OOPs (trimmed for readability):
$ java -jar jol-cli.jar internals -cp target 'decapsulation.FfmHeapSimple$Victim'
...
decapsulation.FfmHeapSimple$Victim object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x010c4000
12 4 Lookup Victim.lookup (object)
16 8 long Victim.a -1078340514404239870
24 8 long Victim.b -1006570524542404557
32 4 DontLookup Victim.dontLookup null
36 4 (object alignment gap)
Instance size: 40 bytes
a field must start at a multiple of 8 bytes, which leaves a 4-byte gap before it, and the JVM squeezes the lookup in that gap.
We can avoid that by adding a dummy int (4 bytes) padding field that can fill that gap instead:
class Victim {
int padding;
long a = 0xF108F703F405F602L;
long b = 0xF207F170E3457833L;
Lookup lookup = MethodHandles.lookup();
DontLookup dontLookup;
}b and lookup.
Ugh, handling these memory layout differences feels like whack-a-mole.
But here's how we can deal with both 32-bit and 64-bit OOPs, and skip that gap between b and lookup when it's there:
MemorySegment victimSegment = findVictim(findHeap());
if (useCompressedOops()) { // 32-bit OOPs
// offset for padding gap
int offset = victimSegment.get(JAVA_INT, 0) == 0 ? 4 : 0;
int lookupOop = victimSegment.get(JAVA_INT, offset);
victimSegment.set(JAVA_INT, offset + 4, lookupOop);
} else { // 64-bit OOPs
int offset = victimSegment.get(JAVA_LONG, 0) == 0 ? 8 : 0;
long lookupOop = victimSegment.get(JAVA_LONG, offset);
victimSegment.set(JAVA_LONG, offset + 8, lookupOop);
}
victim.dontLookup.allowedModes = -1;
One more improvement we want to make: Garbage collection involves moving objects to a different location, and that may leave an unused copy behind. Our findVictim may stumble upon one of those.
We can detect that by changing the value of Victim.b and checking if that also changed the value at the address that we found.
(click to see the code if you care)
private MemorySegment findVictim(MemorySegment heap) {
for (long i = 0; i < heap.byteSize() - 32; i += 8) {
if (heap.get(JAVA_LONG, i) == victim.a
&& heap.get(JAVA_LONG, i + 8) == victim.b) {
// modify the value and read it again, to be sure we found the right
// value and not another copy of it (e.g. a garbage-collected version)
long originalB = victim.b;
victim.b = 0x5555555555555555L;
VarHandle.fullFence(); // avoid that optimizations reorder the write and the read of victim.b
long again = heap.get(JAVA_LONG, i + 8);
if (again == 0x5555555555555555L) { // it matches our updated value, we found Victim
return heap.asSlice(i + 16);
} else {
// False positive, continue searching
victim.b = originalB;
}
}
}
throw new RuntimeException("Failed to find victim in memory");
}
- Source
- FfmHeapSimple.java, FfmHeap.java
- Result
- JDK <=21: ❌ FFM not available
- JDK 22-27: ⚠️ Works with warnings
- Warning
- Same as in previous FFM methods
- Review
-
- Smashing the heap for fun and type confusion is exciting.
- Fiddling with offsets and field ordering is annoying and fragile. Even more so than our other methods, likely to break in future versions.
- I heard you the first time, mister "will be blocked".
- "Works for me" every time in practice... but theoretically dangerous.
Method 8: FFM - Bytecode
Instead of manipulating objects in memory, we can also manipulate code.
Java code gets compiled to bytecode, in .class files. When the JVM loads a class it verifies that the bytecode is valid.
If we write Java code that tries to directly assign a Lookup to a variable of type DontLookup, then the compiler rejects it.
If we write our own bytecode in a .class file that tries to do that, then the verifier rejects it when the class is loaded.
So instead, we load some valid code, and after the verifier has inspected it, we update the code to treat a Lookup like a DontLookup.
Similar to our Victim class earlier (Method 7), we now make a method that first has a recognizable pattern we can search for, followed by a place where a Lookup can get confused with a DontLookup.
We don't use a particular String or large constant (like we did with long a = 0xF108F703F405F602L earlier), because in bytecode those turn into entries in the constant pool, which lives separately from the code that accesses them.
Instead, we do weird arithmetic to get some unique-looking bytecode:
static void setAllowedModes(DontLookup dontLookup, Lookup lookup) {
// nonsense code that makes this method unique and easy to find in memory
int x = 2;
x = ((((x * x) ^ x) | x) + x) / x;
// code to patch
dontLookup.allowedModes = -1;
}jdk.GCHeapSummary JFR event we used to locate the heap.
So we search through the process's memory, on Linux using /proc/self/maps as our guide.
That pseudo-file describes the virtual address layout of a process, telling us what different memory regions it has in use, where libraries are loaded, etc.
The bytecode lives in a (compared to the heap) relatively small memory chunk, so we only search the smaller regions.
List<MemorySegment> findRegions() throws IOException {
try (var lines = Files.lines(Path.of("/proc/self/maps"))) {
return lines.map(line -> line.split(" +"))
// example of a line we want to match and parse:
// 707600000-717000000 rw-p 00000000 00:00 0
.filter(line -> line.length == 5
&& line[1].startsWith("rw-")
&& line[4].equals("0"))
.map(split -> {
String[] startAndEnd = split[0].split("-");
long start = Long.parseUnsignedLong(startAndEnd[0], 16);
long end = Long.parseUnsignedLong(startAndEnd[1], 16);
return MemorySegment.ofAddress(start).reinterpret(end - start);
})
// optimization: the code we're looking for lives in a small metaspace chunk
.filter(s -> s.byteSize() < 32_000_000)
.toList();
}
}setAllowedModes in those regions, and patch the code to effectively replace dontLookup.allowedModes = -1 with lookup.allowedModes = -1:
void patchSetAllowedModes() throws IOException {
// Implementation of setAllowedModes(), in bytecode format
byte[] search = {
0x05, 0x3d, // x=2 (iconst_2, istore_2)
0x1c, 0x1c, 0x68, // x*x (iload_2, iload_2, imul)
0x1c, (byte) 0x82, // ^x (iload_2, ixor)
0x1c, (byte) 0x80, // |x (iload_2, ior)
0x1c, 0x60, // +x (iload_2, iadd)
0x1c, 0x6c, // /x (iload_2, idiv)
0x3d, // x=... (istore_2)
0x2a, // dontLookup (aload_0)
// in the real `setAllowedModes` implementation the next byte is 0x02 (iconst_m1).
// we deliberately put a different dummy value here to distinguish between
// finding `setAllowedModes`, and finding this byte[] itself.
0x00
};
MemorySegment searchSegment = MemorySegment.ofArray(search);
for (MemorySegment region : findRegions()) {
for (long i = 0; i <= region.byteSize() - search.length; i++) {
// if all bytes match except the last one, then we found `setAllowedModes`
if (region.asSlice(i, search.length).mismatch(searchSegment)
== search.length - 1) {
// change accessed value from dontLookup (aload_0, loading first parameter),
// to lookup (0x2b = aload_1, loading the second parameter)
region.set(JAVA_BYTE, i + search.length - 2, (byte) 0x2b);
}
}
}
}Lookup trusted by passing it into the patched version of setAllowedModes:
patchSetAllowedModes();
Lookup lookup = MethodHandles.lookup();
setAllowedModes(null, lookup);
- Source
- FfmBytecode.java
- Result
- JDK <=21: ❌ FFM not available
- JDK 22-27: ⚠️ Works with warnings
- Warning
- Same as in previous FFM methods
- Review
-
- Bytecode of our nonsense arithmetic is far more predictable than heap layout, avoiding the struggle we had dealing with that. Search & replace for the win.
- Manipulating code is more exciting than manipulating data.
- Warnings Shwarnings
- Getting virtual address layout is platform-specific (
/proc/self/mapsis Linux-only).
Method 9: Memory manipulation through the OS
All decapsulation techniques covered so far used mechanisms that newer JDKs block or have announced they will block. Let's move beyond that. Jump ahead to the future where all of that is blocked. Can we still get in? The JVM restricts what code we can execute and memory we can access inside it, but not what happens outside: We can read and write files and launch other processes to get around the restrictions through the operating system.
The first mechanism we look at is the /proc/self/mem pseudo-file in Linux.
It represents the memory of a running process as a file: reading and writing to the file reads and writes to memory.
With that we do the same bytecode manipulation as we did through FFM, patching setAllowedModes, using a variant of findRegions (from Method 8) that returns plain address ranges:
void patchSetAllowedModes() throws IOException {
// Implementation of setAllowedModes(), in bytecode format
byte[] search = { ... };
try (RandomAccessFile procMem = new RandomAccessFile("/proc/self/mem", "rw")) {
for (AddressRange region : findRegions()) {
byte[] buf = new byte[(int) (region.end - region.start)];
procMem.seek(region.start);
procMem.readFully(buf);
for (int i = 0; i < buf.length - search.length; i++) {
// if all bytes match except the last one, then we found `setAllowedModes`
if (Arrays.mismatch(buf, i, i + search.length, search, 0, search.length)
== search.length - 1) {
// change accessed value from dontLookup (aload_0, loading first parameter),
// to lookup (0x2b = aload_1, loading the second parameter)
procMem.seek(region.start + i + search.length - 2);
procMem.writeByte((byte) 0x2b);
}
}
}
}
}/proc/self/mem, but there are APIs to read and write to the memory of another process.
So we launch another process and do the memory manipulation from there.
The simplest way to do that without requiring extra tools to be installed is to write that program in C# embedded in a PowerShell script.
Lookup makeLookup() throws Exception {
new ProcessBuilder(
"powershell", "-executionpolicy", "bypass", "-File", "bin/WinMemBytecode.ps1",
"-processId", Long.toUnsignedString(ProcessHandle.current().pid()))
.start()
.waitFor();
Lookup lookup = MethodHandles.lookup();
setAllowedModes(null, lookup);
return lookup;
}WinMemBytecode.ps1 we then use the Windows OpenProcess, VirtualQueryEx (to find the virtual memory regions), ReadProcessMemory and WriteProcessMemory functions.
If you're curious how a Java-on-Linux programmer, with some help from Claude, calls Windows APIs from C# inside PowerShell, check out the full source at WinMemBytecode.ps1.
Here's the gist of it:
param(
[Parameter(Mandatory=$true)][UInt32]$processId=0
)
$typeDefinition = @"
using System;
using System.Runtime.InteropServices;
public static partial class WinMemBytecode {
[StructLayout(LayoutKind.Sequential)]
public struct MEMORY_BASIC_INFORMATION64 {
public ulong BaseAddress;
...
}
[DllImport("kernel32.dll")]
public static extern IntPtr OpenProcess(
uint dwAccess, bool bInheritHandle, uint dwProcId);
[DllImport("kernel32.dll")]
public static extern int VirtualQueryEx(
IntPtr hProc, ulong lpAddr, out MEMORY_BASIC_INFORMATION64 lpBuf, int dwLen);
[DllImport("kernel32.dll")]
public static extern Boolean ReadProcessMemory(
IntPtr hProc, ulong lpBaseAddr, byte[] lpBuf, UInt32 nSize, ref UInt32 lpNBytes);
[DllImport("kernel32.dll")]
static extern bool WriteProcessMemory(IntPtr hProcess, ulong lpBaseAddress,
byte[] lpBuffer, UInt32 dwSize, IntPtr lpNBytes);
public static void Go(uint processId) {
IntPtr hProc = OpenProcess(0x1F0FFF /* PROCESS_ALL_ACCESS */, false, processId);
...
VirtualQueryEx(hProc, ...);
...
byte[] buf = new byte[region.RegionSize];
ReadProcessMemory(hProc, region.BaseAddress, buf, (uint)buf.Length, ref read);
...
WriteProcessMemory(hProc, region.BaseAddress + (ulong)i,
new byte[] { 0x2b }, 1, IntPtr.Zero);
}
}
"@
Add-Type -TypeDefinition $typeDefinition -Language CSharp
[WinMemBytecode]::Go($processId)Lookup without the JDK crying about it.
The implementation for other operating systems is left as an exercise for the reader.
- Result
- JDK 11-27: ✔️ Works
- Review
-
- Woooo! Look ma, no warnings!
- Crazy!
- Crazy.
- Platform-specific. Even on Linux,
/proc/self/memmay not always be available.
Method 10: Patch the JDK
Of all the tricks we've pulled today, this one feels the dirtiest.
We can patch the JDK on disk to give us the Lookup we want.
What makes it a fun challenge is that we do it while the JDK is running.
The JDK's classes are stored in $JAVA_HOME/lib/modules, a file in the JDK-internal jimage format.
The JDK also comes with a little utility called jimage to inspect it.
jimage list --verbose shows every class in it, including at what offset it is stored.
We're looking for one in java.lang.invoke, the package containing Lookup:
$ jimage list --verbose $JAVA_HOME/lib/modules | grep java/lang/invoke
7135144 1844 0 java/lang/invoke/AbstractConstantGroup$AsIterator.class
7136988 3038 0 java/lang/invoke/AbstractConstantGroup$AsList.class
7140026 2321 0 java/lang/invoke/AbstractConstantGroup$BSCIWithCache.class
...
Lookup.IMPL_LOOKUP field, which holds an instance of a trusted Lookup.
We can't simply add another class, because the index of the jimage that lists the classes has already been loaded in memory; it's too late to change that.
It's tempting to patch the Lookup class itself to just make what we need public, but in the real world that class is likely to already be loaded too.
So we have to pick an existing class in there to patch, that hasn't been loaded yet, and for simplicity it better be a small one.
We go for StringConcatException. Its javadoc says it is thrown by StringConcatFactory when linkage invariants are violated; something that's not impossible, but relatively unlikely to have been loaded.
It's a basic exception class with two trivial constructors.
We make our own version of that class, adding two new fields. One that leaks the Lookup we need in a public field, and a padding field that we'll explain in a minute:
package java.lang.invoke;
import java.lang.invoke.MethodHandles.Lookup;
public class StringConcatException extends Exception {
public static Lookup L = Lookup.IMPL_LOOKUP;
static int padding;
// original implementation
private static final long serialVersionUID = 301L;
public StringConcatException(String msg) {
super(msg);
}
public StringConcatException(String msg, Throwable cause) {
super(msg, cause);
}
}-g:none, which leaves out debugging information like line numbers.
But that shrinks it too much. Loading the now too small version would cause a java.lang.ClassFormatError: Extra bytes at the end of class file java/lang/invoke/StringConcatException.
That's why we added the padding field. We can increase the size of the class by increasing the length of the name of that field by just enough characters.
In practice the size of the original StringConcatException is the same in every JDK I tested, but hardcoding that doesn't feel right.
To compile this class that belongs to an existing module, we also have to specify --patch-module, so we end up with:
javac -g:none --patch-module java.base=src/java StringConcatException.java.
Let's call the output of that StringConcatException_patch.class.
This method generates a version of that class that is dynamically sized:
byte[] generatePatchedClass(int targetSize) throws Exception {
byte[] base = getResourceAsBytes("decapsulation/StringConcatException_patch.class");
if (base.length > targetSize)
throw new RuntimeException("Patched class is larger than slot size");
int delta = targetSize - base.length;
// The constant for "padding" looks like: tag(0x01) + length(0x00, 0x07) + "padding"
String original = "\1\0\7padding";
int newLength = "padding".length() + delta;
String replacement = "\1" + (char)(newLength >> 8) + (char)(newLength & 0xFF)
+ "padding" + "a".repeat(delta);
// Abusing ISO_8859_1 (which has a one-to-one mapping between chars and bytes)
// is the easiest way to do a search & replace in a byte array
return new String(base, ISO_8859_1)
.replace(original, replacement)
.getBytes(ISO_8859_1);
}Lookup, and patch it back to leave it as if nothing had happened:
Lookup makeLookup() throws Exception {
byte[] original = getResourceAsBytes("java/lang/invoke/StringConcatException.class");
byte[] patched = generatePatchedClass(original.length);
File modulesFile = new File(System.getProperty("java.home"), "lib/modules");
try (RandomAccessFile raf = new RandomAccessFile(modulesFile, "rw")) {
long offset = findOffsetInFile(raf, original);
raf.seek(offset);
raf.write(patched);
try {
return (Lookup) Class.forName("java.lang.invoke.StringConcatException")
.getDeclaredField("L").get(null);
} finally {
// Restore original code so the JDK is not permanently modified
raf.seek(offset);
raf.write(original);
}
}
}
- Source
- PatchJimage.java
- Result
- JDK 11-27: ✔️ Works
- Review
-
- No warnings from the JDK. That means this is totally fine, right?
- Depends on the environment, doesn't work if the JDK is not in a writable location (e.g. a read-only container image).
- Mucking with the JDK on-disk, infecting a shared resource. Ugh, I need a shower now.
Method 11: newConstructorForSerialization
As I was finishing up this blog post, I stumbled upon this little gem.
Reading JEP 260 I was reminded that "Critical internal APIs not encapsulated in JDK 9" not only includes sun.misc.Unsafe but also sun.reflect.ReflectionFactory.
Its javadoc says that "ReflectionFactory supports custom serialization. Its methods support the creation of uninitialized objects, invoking serialization private methods for readObject, writeObject, readResolve, and writeReplace."
Skimming over the methods in that class, most of them only seem to apply to serializable classes, but then there is newConstructorForSerialization(Class<?> cl, Constructor<?> constructorToCall).
It "returns an accessible constructor capable of creating instances of the given class, initialized by the given constructor".
The implementation is delegated to jdk.internal.reflect.ReflectionFactory (same class name, different package) where it looks like this:
public final Constructor<?> newConstructorForSerialization(Class<?> cl,
Constructor<?> constructorToCall) {
if (constructorToCall.getDeclaringClass() == cl) {
constructorToCall.setAccessible(true);
return constructorToCall;
}
return generateConstructor(cl, constructorToCall);
}Lookup's private constructor.
That setAccessible call works (while our attempt at that in Method 1 didn't) because it comes from ReflectionFactory, a class in the java.base module.
A small catch here is that in different JDK versions the constructor has a different number of parameters.
We deal with that by trying both versions:
Lookup makeLookup() throws ReflectiveOperationException {
ReflectionFactory reflectionFactory = ReflectionFactory.getReflectionFactory();
try {
Constructor<?> ctor = reflectionFactory.newConstructorForSerialization(
Lookup.class,
Lookup.class.getDeclaredConstructor(Class.class, Class.class, int.class));
return (Lookup) ctor.newInstance(Object.class, null, -1);
} catch (NoSuchMethodException e) {
Constructor<?> ctor = reflectionFactory.newConstructorForSerialization(
Lookup.class,
Lookup.class.getDeclaredConstructor(Class.class, int.class));
return (Lookup) ctor.newInstance(Object.class, -1);
}
}
That's it. Works on all JDK versions we tested (11–27), and no warnings in sight. After all the effort put into closing the other loopholes, this is a surprising find. Notice that there's no actual serialization involved. Just mentioning serialization is enough to let us in. Serialization is the sudo of Java.
I dug into the history to understand the story behind newConstructorForSerialization...
It seems that at some point this method was removed but then added back.
In a comment on another ticket someone notes that
"it allows breaking strong encapsulation as it allows calling any constructor, such as those of MethodHandles.Lookup".
Hah, that's exactly what we're doing with it now.
Apparently it's an issue known to some, but partly hiding in obscurity.
- Source
- CtorForSerialization.java
- Result
- JDK 11-27: ✔️ Works
- Review
-
- Simple, reliable.
- No warnings or formal deprecation, but don't expect this to continue working.
Conclusion
Experimenting with such a variety of features and approaches to breaking encapsulation was fun, and I sure learned a bunch along the way. But what does this mean in the real world?
Disclaimer: This conclusion is subjective; I don't expect everyone to agree. Also, it's important to realize that integrity/encapsulation in the JDK is not a security boundary. This is not a security vulnerability report. If it were, then the JDK's reaction to violating it would be fiercer than a warning label.
You can put our encapsulation bypasses in three categories.
First, those using (more or less) documented mechanisms: already blocked, or, as their warnings foretell, on their way there.
Next, the nasty ones using memory manipulation and updating the JDK on-disk.
And third, our new friend newConstructorForSerialization.
Undermining the integrity of the JDK through memory and file manipulation is not a new idea. The JEP draft: Integrity by Default has this to say about Integrity beyond the Java Platform:
Java code can use standard facilities of the Platform to reach outside the Java runtime and violate integrity. Java code can, e.g., alter the content of a class file in the file system before the class is loaded. However, a good principle in matters of integrity is thatThere's no denying that running your applications in an immutable container with minimal privileges is good practice. And that mostly blocks our shenanigans. But I believe that more important than these technical measures is human judgment: what we, as a community, accept. On the one hand, there are many tricks that we apparently tolerate libraries pulling on us. Using reflection to getThe integrity of components is best enforced by the infrastructure that provides them.The integrity of the file system and its content is the responsibility of the operating system, not the Java runtime. The OS or, if appropriate, an OS-level container, should always be configured so as to protect the integrity of the Java runtime's files and memory, and the integrity of the application’s files, regardless of the measures taken by the Java runtime to protect its own integrity and that of the application it is running.
Unsafe is business as usual.
Byte Buddy, a popular Java library (also used by Mockito), self-attaches an agent.
There are even libraries like Narcissus that use JNI similarly to how we did here to provide "a small subset of the Java reflection API, while bypassing all of Java's access/visibility checks".
On the other hand, if a library I used switched to doing memory manipulation through /proc/self/mem behind the scenes, or patching files in the JDK, then I would lose all trust in that library and its author.
Even if they could make it work reliably and cross-platform, it would be insane.
There is a certain level of shenanigans we tolerate, but I think most would agree that that is well over the line.
Once breaking encapsulation requires crossing the line, the Integrity by Default project has achieved its main goal. Right? No proper library would depend on JDK internals anymore.
That brings us to our last method, newConstructorForSerialization.
What if a library actually used that?
Yeah, that library is setting itself up to break in a future JDK version. But that doesn't seem crazier than the tricks we already condoned.
What if a library used it to access some JDK internals for which there still isn't a supported alternative?
What if a library, too slow or stubborn to move away from Unsafe, getting complaints from its users about the warnings, then used that to suppress the warning?
What if your AI agent, which surely read this awesome blog post, upon noticing the Unsafe warning, pulled that out of its hat?
You be the judge.