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):

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 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:

In Java 11 this produced a warning, but still worked. Since then two new obstacles were added.

First, Java reflection now filters out a small set of particularly sensitive fields, such as 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:

We can steal the Lookup from there instead:

That brings us to the second obstacle.

Calling 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:

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:

Dear 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).

- 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.

The function name tells the JVM that this implements the native 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:

Trusted Lookup accomplished. But since JDK 24 (JEP 472), the JDK spews a warning when loading a library with System.load.

- 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.

We can build our agent jar at runtime in another temporary file. It must contain a 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.

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:

Unfortunately, since JDK 21 (JEP 451) dynamically loading an agent comes with a warning.

- 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:

We use the FFM API to load that library and call the setAllowedModes function:

Just like 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.

- 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:

But then a voice in the back of your head yells "MOOORE reflection!!!".

You could do MyClass.class.getMethod("helloWorld").invoke(null). That would be reflection-from-reflection.

But that is weak, compared to doing reflection-through-reflection:

Madness. Well, in Method 5 we had the weak JNI-from-FFM. Now we go JNI-through-FFM, to abuse JNI's superpowers straight from Java.

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.

The first lines in our old C implementation were:

We can now do that in Java like this:

Next up, we need to call GetEnv. In C that was:

Let's look at 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:

We see there that we can get GetEnv at index 6 in that struct:

Similarly, 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.

Now that we've got our environment set up, we want to start accessing Java classes and objects.

We hit an interesting twist here.

The 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).

With that, we can now translate the next line in our C code to Java:

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.

And finally, what it's all about, setting lookup.allowedModes = -1, translating the last 3 lines of our C code:

And with that, our trusted Lookup is alive!

Bolting JNI and FFM together was questionable but awesome science.

Dr. Frankenstein would be proud.

- 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:

The hard part is finding the address where the 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:

But when looking at the raw contents of heap memory, the 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:

Now we can scan the heap and find our Victim by searching for those values:

That gives us the address (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:

We add an extra field of that type to our Victim class:

Now, assuming OOPs are 64 bits (8 bytes), we can copy victim.lookup to victim.dontLookup.

Then by updating victim.dontLookup.allowedModes we actually update victim.lookup.allowedModes:

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:

Field ordering is tricky because field values have different sizes, and they come with alignment requirements; e.g. a 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):

There we see that our Victim object has a 12-byte header (metadata managed by the JVM). The long 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:

But when "Compact Object Headers" is enabled (available as an option since JDK 25, enabled by default since JDK 27), the object header is only 8 bytes, and then the JVM ends up putting that padding field between 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:

This works on all released JDK versions that support FFM (22+), with or without compressed OOPs, with or without compact object headers.

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)

- 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:

Where can we look for this code? Unfortunately the JVM provides no simple way to get the address of the metaspace (where class metadata, including bytecode lives); no equivalent to the 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.

Then we search for the bytecode of setAllowedModes in those regions, and patch the code to effectively replace dontLookup.allowedModes = -1 with lookup.allowedModes = -1:

Now we can make a Lookup trusted by passing it into the patched version of setAllowedModes:

Pretty neat. Turns out you don't need an instrumentation agent to manipulate Java code at runtime!

- 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:

On Windows, there is no file equivalent to /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.

In our C#/PowerShell script 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:

And with that we have a way on Linux and Windows to obtain a trusted 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:

Every class in that package has access to the static package-visible 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:

This patched class has to be the exact same size as the original one to fit in the same location in the jimage file.

With our extra fields it is too big, so we compile it with -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:

Now we can use that to patch the JDK, grab the Lookup, and patch it back to leave it as if nothing had happened:

- 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:

Given a class and a constructor of that same class, this friendly bugger makes that constructor accessible.

We can use that to access 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:

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.

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.