August 14, 2026
CVE-2026-19135: How a Low-Privileged User Bypasses the OpenNMS JEXL Sandbox to Load Arbitrary…
There’s a certain kind of bug I go looking for on purpose. Not the low-hanging “someone forgot an auth check” stuff — the ones that live…

By Xanlar Agamalizade
7 min read
There's a certain kind of bug I go looking for on purpose. Not the low-hanging "someone forgot an auth check" stuff — the ones that live inside a security control that was added to fix an earlier vulnerability. Sandboxes, denylists, permission filters. The theory is simple: when a team ships a fix under time pressure, the fix closes the exact path that was reported, and leaves the shape of the problem intact one call away.
OpenNMS Horizon has a JEXL sandbox. JEXL — Apache Commons' expression language — has a long, colorful history of sandbox escapes across many products, so any codebase that hands user-controlled JEXL to an engine is worth a careful read. This particular sandbox was introduced years ago to remediate a JEXL RCE (CVE-2021–3396). That's exactly the kind of control I like to pull apart: it's supposed to be the safe version. So I sat down with it.
It took about an afternoon to find a way through. This is CVE-2026–19135.
What's the context here?
OpenNMS lets a user define JEXL expressions in the Measurements API — the feature that renders performance graphs. You submit a "filter" of type JEXL, and the server evaluates your expression against the metrics data for a resource. Useful feature, and it has to run some attacker-influenced expression by design, so the whole thing leans on the sandbox to keep that expression from touching anything dangerous.
The sandbox is two pieces:
OnmsJexlUberspect— the class JEXL calls to resolve every method and property access at runtime.OnmsJexlSandbox— a per-class permission table. A class is either whitelisted (all names allowed) or governed by an explicit allow/deny set.
And the Measurements filter sets it up like this (JEXL.java):
jexl = new OnmsJexlEngine();
jexl.white(Math.class.getName());
jexl.white(StrictMath.class.getName());
jexl.white(TreeBasedTable.class.getName());
...
jexlValues.put("table", qrAsTable); // a live TreeBasedTable instance in the context
final JexlContext context = new MapContext(jexlValues);jexl = new OnmsJexlEngine();
jexl.white(Math.class.getName());
jexl.white(StrictMath.class.getName());
jexl.white(TreeBasedTable.class.getName());
...
jexlValues.put("table", qrAsTable); // a live TreeBasedTable instance in the context
final JexlContext context = new MapContext(jexlValues);So three classes are whitelisted — Math, StrictMath, and Guava's TreeBasedTable — and a live TreeBasedTable instance is placed into the evaluation context under the name table. Nothing here looks alarming. TreeBasedTable is a data-structure class; whitelisting it just lets the expression call table.get(...), table.rowMap(), and friends. That's the intent.
The mistake isn't in what they whitelisted. It's in how the sandbox decides whether a call is allowed.
The actual bug
Here's OnmsJexlUberspect.getMethod() — the function that runs on every method call inside an expression:
public JexlMethod getMethod(final Object obj, final String method, final Object[] args, final JexlInfo info) {
if (obj != null && method != null) {
final String className;
if (obj instanceof Class) {
Class<?> clazz = (Class) obj;
className = clazz.getName(); // <-- keys on the class the object REPRESENTS
} else {
className = obj.getClass().getName();
}
String actual = this.sandbox.execute(className, method);
if (actual != null) {
return this.getMethodExecutor(obj, actual, args);
}
}
return null;
}public JexlMethod getMethod(final Object obj, final String method, final Object[] args, final JexlInfo info) {
if (obj != null && method != null) {
final String className;
if (obj instanceof Class) {
Class<?> clazz = (Class) obj;
className = clazz.getName(); // <-- keys on the class the object REPRESENTS
} else {
className = obj.getClass().getName();
}
String actual = this.sandbox.execute(className, method);
if (actual != null) {
return this.getMethodExecutor(obj, actual, args);
}
}
return null;
}Read the if (obj instanceof Class) branch slowly, because that's the whole bug.
When the object a method is being called on is itself a java.lang.Class instance, the sandbox keys its permission lookup on ((Class) obj).getName() — the name of the class that Class object represents — instead of on java.lang.Class, which is the object's actual type.
Now pair that with how the sandbox treats a whitelisted class:
public Permissions white(String clazz) {
return permissions(clazz, true, true, true); // read+write+execute, "all names"
}public Permissions white(String clazz) {
return permissions(clazz, true, true, true); // read+write+execute, "all names"
}A whitelisted class allows every method name. So the exploit writes itself:
tableis aTreeBasedTable, andTreeBasedTableis whitelisted.table.getClass()returns ajava.lang.Classobject that representsTreeBasedTable.- Call any method on that
Classobject.getMethod()seesobj instanceof Class, keys the lookup on"com.google.common.collect.TreeBasedTable"— which is whitelisted — and allows the call.
In other words: a Class object that stands for a whitelisted type unlocks the entire java.lang.Class method surface. getClassLoader(), getProtectionDomain(), getResource(), forName() — all of it.
There's a nice tell that this is genuinely an oversight and not a design decision. The sibling function in the same class, getPropertyGet(), keys correctly:
public JexlPropertyGet getPropertyGet(final Object obj, final Object identifier, final JexlInfo info) {
if (obj != null && identifier != null) {
String actual = this.sandbox.read(obj.getClass().getName(), identifier.toString()); // always the object's OWN type
...
}
}public JexlPropertyGet getPropertyGet(final Object obj, final Object identifier, final JexlInfo info) {
if (obj != null && identifier != null) {
String actual = this.sandbox.read(obj.getClass().getName(), identifier.toString()); // always the object's OWN type
...
}
}getPropertyGet always uses obj.getClass().getName(). So the property form — table.class.classLoader — is correctly keyed as java.lang.Class, which isn't whitelisted, and it's blocked. Only the method path is mis-keyed. That asymmetry is why the escape has to go through table.getClass().getClassLoader() (a method call) rather than table.class.classLoader (a property). One of the two paths got the check right. The other didn't.
Reproducing it
I built a small harness around the verbatim 36.0.2 sandbox classes — OnmsJexlUberspect, OnmsJexlSandbox, OnmsJexlEngine — on the exact commons-jexl 2.1.1 OpenNMS ships, with the real whitelist and a TreeBasedTable bound as table, so I was testing the actual control, not a paraphrase of it.
The escape expressions, and what they returned:
table.getClass().getClassLoader() -> the live AppClassLoader
table.getClass().getProtectionDomain() -> the code source (absolute jar path)
table.getClass().getResource('/') -> absolute filesystem path of the webapp
table.getClass().forName('java.lang.Runtime') -> loads + static-initializes an arbitrary classtable.getClass().getClassLoader() -> the live AppClassLoader
table.getClass().getProtectionDomain() -> the code source (absolute jar path)
table.getClass().getResource('/') -> absolute filesystem path of the webapp
table.getClass().forName('java.lang.Runtime') -> loads + static-initializes an arbitrary classAnd the negative controls, which matter just as much:
table.class.classLoader -> BLOCKED (property path is keyed correctly as java.lang.Class)
getRuntime() -> BLOCKED (returned object re-keys to a non-whitelisted class)
loadClass(...) / getCodeSource() -> BLOCKED (same reason)table.class.classLoader -> BLOCKED (property path is keyed correctly as java.lang.Class)
getRuntime() -> BLOCKED (returned object re-keys to a non-whitelisted class)
loadClass(...) / getCodeSource() -> BLOCKED (same reason)That last set is the interesting boundary, and it's where I had to be honest with myself.
How close is this to RCE?
Here's the thing I want to be straight about, because it's the part most write-ups get wrong in the exciting direction.
The primitive I had was arbitrary class loading with static-initializer execution via forName(...). That is not a small thing. In Java, Class.forName("X") doesn't just hand you a Class — it loads and runs the static initializer of X. An arbitrary-class-load primitive is, in the general case, one gadget away from code execution: you find a class already on the server's classpath whose <clinit> (or a trivially reachable path) does something dangerous with input you can influence, and you're through.
So instead of stopping at "info disclosure" and instead of claiming "RCE" — I went and looked for that gadget. Properly.
I took the real OpenNMS 36.0.2 classpath — the shipped libopennmsdeps-java and libopennms-java packages, 929 jars, about 180,000 classes — and did a bytecode call-graph scan for every class with a static initializer that reaches a dangerous sink (Runtime.exec, ProcessBuilder, JNDI lookup, System.load, file write, outbound URL) within a few hops. 28,892 classes have a <clinit>. 91 of them reach a sink.
Then I went through the RCE-shaped candidates by hand. Every single one used fixed or ambient input, never something a Measurements-API caller can set: Drools' executor holder reads a JNDI name from a system property; oshi and jnr shell out to hard-coded lshw/id; the native loaders just System.loadLibrary a bundled .so. I even confirmed dynamically — through the actual escape — that these static initializers do fire (a native lib got extracted, a hard-coded id -u ran under a disarmed shim), which proves the primitive is real and loaded, but also proves the inputs aren't attacker-controlled.
So: the escape gets you to the doorstep of RCE — arbitrary class load is exactly the primitive you'd weaponize — but on the default classpath there is no reachable gadget with attacker-influenced input. I scored it as what I could actually prove: information disclosure plus a protection-mechanism failure, CVSS 5.4. Not RCE. The gap is a single classpath gadget; a third-party OpenNMS plugin that ships a class with an attacker-influenceable <clinit> would close it, which is itself a good reason to fix the mis-keying rather than rely on "there's no gadget today."
That's the difference between a demo and an assessment: a demo shows the sandbox breaks; an assessment tells you exactly how far the break goes, and proves the ceiling instead of guessing at it.
Reachability — who can actually do this?
None of this matters if it needs an admin. It doesn't.
applicationContext-spring-security.xml:
<intercept-url pattern="/rest/measurements" method="POST"
access="ROLE_REST,ROLE_ADMIN,ROLE_USER"/><intercept-url pattern="/rest/measurements" method="POST"
access="ROLE_REST,ROLE_ADMIN,ROLE_USER"/>POST /rest/measurements is authorized for ROLE_USER — the lowest authenticated role in OpenNMS. That request flows into MeasurementsRestService.query, through filterEngine.filter(getFilters(), table), and the JEXL filter in the request body is evaluated against the seeded table context. Any low-privileged, authenticated user can submit the escape.
Root cause
The sandbox was built on an implicit assumption: that the object arriving at getMethod() is a normal instance, so keying on "the class it represents" and "the class it is" are the same thing. For every ordinary object, they are. For a java.lang.Class object, they diverge completely — the represented class and the actual class (java.lang.Class) are different, and the code picked the wrong one.
Layer on top of that a whitelist model where "trusted" means "all methods allowed," and a single trusted data-structure class becomes a skeleton key for reflection. The whitelist wasn't wrong to trust TreeBasedTable as a container. It was wrong to let a Class object borrow that trust.
The fix
Two things close it, and OpenNMS did both in PR #8754:
- Stop treating a runtime
java.lang.Classvalue as a static-type reference. Key it onjava.lang.Class(the waygetPropertyGetalready does), or rejectobj instanceof Classoutright. - Deny the reflective surface at the sandbox regardless of keying —
getClass,forName,getClassLoader,getProtectionDomain,getResource.
If you want the durable version of the fix: a name-only whitelist where "trusted" equals "all methods" is fragile by construction. A per-class method allowlist would mean a newly-trusted class only exposes the handful of methods it actually needs, and reflection never comes along for the ride.
It's fixed in OpenNMS Meridian 2024.3.12 and 2025.0.9, and Horizon 36.0.3. If you run OpenNMS and it's reachable by any authenticated user, update.
Takeaway
Sandbox escapes rarely come from the sandbox missing a rule. They come from the sandbox applying a correct rule to the wrong identity — here, keying a permission check on what a Class object points at instead of what it is. The control was there. The whitelist was reasonable. One branch resolved the wrong name, and the entire reflective surface fell open behind a data-structure class nobody would think twice about.
Two habits did most of the work on this one. First: read the controls that were added to fix past bugs — that's where the residue lives. Second: when you get a powerful primitive like arbitrary class loading, resist the urge to write "RCE" and instead prove precisely how far it goes. The honest ceiling is usually more useful to the vendor than the scary headline — and it's the difference between a finding they can act on and one they have to re-triage.
CVE-2026-19135. Xanlar Agamalizade LinkedIn · GitHub · xanlaragamalizade.com