With the death of SecurityManager, isolating untrusted Java code execution (for example, in case of plugins or scripting) becomes even more difficult. But, luckily, the maturity of GraalVM, both from features and stability perspectives, offers a compelling solution to sandboxing Java code.
In today's post, we are going to explore GraalVM polyglot capabilities, more specifically GraalVM Espresso - an implementation of the Java Virtual Machine Specification built upon GraalVM as a Truffle interpreter. To make things more interesting, there are two runtime models that we are going to talk about:
- out of process: isolated host/guest JVM with prebuilt Espresso runtime
- out of process: isolated, potentially even different, host/guest JVM runtimes
At first, the amount of the details that GraalVM polyglot programming throws at you feels overwhelming, but the documentation really helps to navigate over them. The latest version of the GraalVM available as of this writing is 24.2.1 and this is what we are going to use. To keep things simple but meaningful, we are going to sandbox an application that:
- exposes one interface
ApplicationService
public interface ApplicationService { ApplicationInfo getApplicationInfo(); ApplicationInfo setApplicationInfo(ApplicationInfo info); }
- exposes one custom data type (Java record)
public record ApplicationInfo(String env, String version) { }
- provides the default
ApplicationService
implementationpublic class AppRunner { public static void main(String[] args) { System.out.println(getApplication().getApplicationInfo()); } public static ApplicationService getApplication() { return new ApplicationService() { private ApplicationInfo info = new ApplicationInfo("unconstrained", "1.0.0"); @Override public ApplicationInfo getApplicationInfo() { return info; } @Override public ApplicationInfo setApplicationInfo(ApplicationInfo info) { this.info = info; return info; } }; } }
What the host will be doing with this sandboxed application? Quite simple: it will get access to default ApplicationService
implementation, query the ApplicationInfo
and change it to its own provided value. Such an exercise would demonstrate the essential interoperability dance between host and guest in the GraalVM world. What is interesting, the guest (application we are going to sandbox) has no idea it is running inside the GraalVM Espresso managed runtime.
Let us start building with provided GraalVM Espresso runtime (which is based on JDK-21). Here are the dependencies that we need to get started.
<dependency> <groupId>org.graalvm.polyglot</groupId> <artifactId>polyglot</artifactId> <version>24.2.1</version> </dependency> <dependency> <groupId>org.graalvm.espresso</groupId> <artifactId>espresso-language</artifactId> <version>24.2.1</version> </dependency> <dependency> <groupId>org.graalvm.espresso</groupId> <artifactId>polyglot</artifactId> <version>24.2.1</version> </dependency> <dependency> <groupId>org.graalvm.espresso</groupId> <artifactId>espresso-runtime-resources-jdk21</artifactId> <version>24.2.1</version> </dependency> <dependency> <groupId>org.graalvm.polyglot</groupId> <artifactId>java-community</artifactId> <version>24.2.1</version> <type>pom</type> </dependency>
To have access to sandboxed application types, we would also need to include the dependency on its API (just for sake of simplicity, we include the application module itself):
<dependency> <groupId>com.example.graalvmt</groupId> <artifactId>app</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency>
The bootstrapping is pretty straightforward. The first thing the host application has to do is to create a new polyglot engine:
final Engine engine = Engine .newBuilder() .build();
The next step establishes the polyglot context and is a bit more engaging. Please don't panic if some things are not clear from the start, we are going to look at all of them shortly.
final Context context = Context .newBuilder("java") .option("java.Properties.java.class.path", application) .option("java.Polyglot", "true") .option("java.EnableGenericTypeHints", "true") .allowExperimentalOptions(true) .allowNativeAccess(true) .allowCreateThread(false) .allowHostAccess(HostAccess .newBuilder(HostAccess.ALL) .targetTypeMapping( Value.class, ApplicationInfo.class, v -> true, v -> new ApplicationInfo( v.invokeMember("env").asString(), v.invokeMember("version").asString()) ) .build() ) .allowIO(IOAccess.NONE) .allowPolyglotAccess(PolyglotAccess .newBuilder() .allowBindingsAccess("java") .build()) .engine(engine) .build();
Let us go over this snippet line by line. Since this is a polyglot context, we limit its languages to java
only. We also provide the classpath to our sandboxed application JAR file using java.Properties.java.class.path
property. We explicitly prohibiting thread creation with allowCreateThread(false)
and I/O access with allowIO(IOAccess.NONE)
but have to allow native access (allowNativeAccess(true)
), this is a limitation of the GraalVM Espresso engine at the moment (please check [Espresso] Support running without native access. for more details).
The host access configuration needs some wider explanation. We do allow access to host from guest (sandboxed application) with HostAccess.ALL
policy and also provide the type mapping for ApplicationInfo
record class so we could reconstruct it from generic polyglot Value
class. Last but not least, we allow polyglot access java
bindings with allowBindingsAccess("java")
. And by and large, this is it!
With the context bootstrapped, we are ready to interact with the sandboxed application (or in terms of GraalVM, guest) through polyglot bindings. To illustrate the host/guest isolation, let us make an inquiry about their JVM runtime versions.
final Value runtime = context.getBindings("java").getMember("java.lang.Runtime"); System.out.println("Host JVM version: " + Runtime.version()); System.out.println("Guest JVM version: " + runtime.invokeMember("version"));
Depending on your system and settings, you may see something along these lines:
Host JVM version: 24.0.1+9-30 Guest JVM version: 21.0.2+13-LTS-jvmci-23.1-b33
Clearly, the host and guest JVMs are far apart. According to our plan, let us retrieve the instance of the ApplicationService
and ask for ApplicationInfo
:
final ApplicationService service = context.getBindings("java") .getMember("com.example.AppRunner") .invokeMember("getApplication") .as(ApplicationService.class); System.out.println("ApplicationInfo? " + service.getApplicationInfo());
We should see in the console:
ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0]
Now let us change the ApplicationInfo
by construction a new instance on the host and sending it back to the guest, using ApplicationService
instance at hand:
final ApplicationInfo newInfo = context.getBindings("java") .getMember("com.example.ApplicationInfo") .newInstance("sandboxed", "1.0.0") .as(ApplicationInfo.class); final ApplicationInfo info = service.setApplicationInfo(newInfo); System.out.println("ApplicationInfo? " + info);
This time, we should see in the console a different picture:
ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]
With all details (hopefully) explained, we could see how things work in action. The easiest way to run the host application is by using Apache Maven and its Exec Maven Plugin (please change JAVA_HOME
accordingly):
$ export JAVA_HOME=/usr/lib/jvm/java-24-openjdk-amd64/ $ mvn package exec:java -Dexec.mainClass="com.example.graalvm.sandbox.SandboxRunner" -Dexec.args="app/target/app-0.0.1-SNAPSHOT.jar" -f sandbox-bundled-jvm/
Here is how the complete output in the console looks like:
Host JVM version: 24.0.1+9-30 Guest JVM version: 21.0.2+13-LTS-jvmci-23.1-b33 ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0] ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]
Let us recap what we have done: we basically isolated the application we wanted to sandbox into separate JVM instance but preserved full access to its API, limiting some very key JVM capabilities (no threads, no I/O). This is very powerful but has one limitation: we are bounded by the GraalVM Espresso runtime. What if we could have used any other JVM? Good news, it is totally an option and this is what we are going to do next.
As of this writing, GraalVM Espresso allows running either Java 8, Java 11, Java 17, or Java 21 guest JVMs. However, the host could be running any other JVM, and why not latest JDK-24? There aren't actually too many changes that we need to do to the Context
:
final Context context = Context .newBuilder("java") .option("java.JavaHome", jdkHome) .option("java.Classpath", application) .option("java.Properties.java.security.manager", "allow") .option("java.PolyglotInterfaceMappings", getInterfaceMappings()) .option("java.Polyglot", "true") .option("java.EnableGenericTypeHints", "true") .allowExperimentalOptions(true) .allowNativeAccess(true) .allowCreateThread(false) .allowHostAccess(HostAccess .newBuilder(HostAccess.ALL) .targetTypeMapping( Value.class, ApplicationInfo.class, v -> true, v -> new ApplicationInfo( v.invokeMember("env").asString(), v.invokeMember("version").asString()) ) .build() ) .allowIO(IOAccess.NONE) .allowPolyglotAccess(PolyglotAccess .newBuilder() .allowBindingsAccess("java") .build()) .engine(engine) .build();
Notice that we do provide own JDK installation (using java.JavaHome
) and classpath (using java.Classpath
). More to that, we have an opportunity to pass any additional JVM properties (for example, "java.Properties.java.security.manager", "allow"
is equivalent to passing -Djava.security.manager=allow
, however we don't really use it). The new option here is java.PolyglotInterfaceMappings
:
private static String getInterfaceMappings(){ return "com.example.ApplicationService;"; }
It allows to automatically constructing guest proxies for host objects that implement declared interfaces in the list (essentially, we could provide our own ApplicationService
and push it to the guest). And one important note, we don't need espresso-runtime-resources-jdk21
dependency anymore. Everything else stays unchanged.
If we run the host application this time:
$ export JAVA_HOME=/usr/lib/jvm/java-24-openjdk-amd64/ $ mvn package exec:java -Dexec.mainClass="com.example.graalvm.sandbox.SandboxRunner" -Dexec.args="/usr/lib/jvm/java-17-openjdk-amd64/ app/target/app-0.0.1-SNAPSHOT.jar" -f sandbox-custom-jvm/
The output will be slightly different:
Host JVM version: 24.0.1+9-30 Guest JVM version: 17.0.15+6-Ubuntu-0ubuntu124.04 ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0] ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]
Sky is the limit! GraalVM with its powerful polyglot features and GraalVM Espresso engine offers an unique capability to isolate (sandbox) untrusted Java code execution, using a JDK of your choice (you can even run JDK-21 with full-fledged SecurityManager activated if dimed necessary). We have just looked into some foundational concepts (there are still some limitations) however there are much more to uncover here (and please, note that GraalVM evolves very fast, the limitations of today become features of tomorrow). Hope you find it useful.
The complete source code of the project is available on Github.
I πΊπ¦ stand πΊπ¦ with πΊπ¦ Ukraine.