NewApp Monitoring is liveSign up free
AgentbeamBeta
Open-source package vulnerability · Maven

CVE-2026-39842: Expression Injection in OpenRemote

criticalCVSS 3.1 9.9GHSA-7mqr-33rv-p3mp

Summary The OpenRemote IoT platform's rules engine contains two interrelated critical expression injection vulnerabilities that allow an attacker to execute arbitrary code on the server, ultimately achieving full server compromise.

Published Apr 14, 2026 · Updated Apr 24, 2026

Affected packages

io.openremote:openremote-manager

Ecosystem
Maven
Affected
<= 1.21.0
Fixed in
1.22.0
Check
mvn dependency:tree -Dincludes=io.openremote:openremote-manager

Advisory details

From the GitHub Advisory Database (CC BY 4.0).

Summary

The OpenRemote IoT platform's rules engine contains two interrelated critical expression injection vulnerabilities that allow an attacker to execute arbitrary code on the server, ultimately achieving full server compromise.

  • Unsandboxed Nashorn JavaScript Engine: JavaScript rules are executed via Nashorn's ScriptEngine.eval() with user-supplied script content and no sandboxing, class filtering, or access restrictions. Critically, any non-superuser with the write:rules role can create JavaScript rulesets.
  • Inactive Groovy Sandbox: The Groovy rules engine has a GroovyDenyAllFilter security filter that is defined but never registered (the registration code is commented out), rendering the SandboxTransformer ineffective. While Groovy rules are restricted to superusers, the absence of sandboxing violates the principle of defense in depth.

Details

Attacker-Controllable Source-to-Sink Paths

There are two non-superuser and two superuser exploitable attack paths from the REST API entry point to final code execution. (JavaScript × Realm/Asset + Groovy × Realm/Asset) The most critical path is detailed below.

Path 1: Unsandboxed JavaScript Expression Injection via Realm Ruleset (Non-Superuser Exploitable)

`RulesetDeployment.java` L368:

Object result = scriptEngine.eval(script, engineScope);

The Nashorn JavaScript engine is initialized without a ClassFilter, allowing Java.type() to access any JVM class — including java.lang.Runtime (for RCE), java.io.FileReader (for file read), and java.lang.System (for env theft).

`RulesResourceImpl.java` L309 (and L331, L359, L412, L437):

// VULNERABLE: only restricts Groovy, JavaScript completely unrestricted
if (ruleset.getLang() == Ruleset.Lang.GROOVY && !isSuperUser()) {
    throw new WebApplicationException(Response.Status.FORBIDDEN);
}

Non-superuser attackers with only the write:rules role can submit arbitrary JavaScript rules that execute with full JVM access.

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                          SOURCE → SINK Complete Data Flow                                │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                         │
│  ① HTTP REQUEST (Source / Entry Point)                                                   │
│  POST /api/{realm}/rules/realm                                                          │
│  Content-Type: application/json                                                         │
│  Body: { "type":"realm", "lang":"JAVASCRIPT",                                           │
│          "rules":"<MALICIOUS_SCRIPT>", ... }     ← Attacker-controlled malicious script  │
│                                                                                         │
│                              ↓ JAX-RS Deserialization                                    │
│                                                                                         │
│  ② RulesResource.createRealmRuleset()                                                   │
│     model/.../rules/RulesResource.java:153-158                                          │
│     @POST @Path("realm") @RolesAllowed("write:rules")                                   │
│     Interface: long createRealmRuleset(RequestParams, @Valid RealmRuleset ruleset)       │
│     JSON body → RealmRuleset object (Jackson deserialization)                            │
│     RealmRuleset.rules field ← attacker's malicious script content                      │
│     RealmRuleset.lang  field ← "JAVASCRIPT"                                             │
│                                                                                         │
│                              ↓ Calls implementation                                      │
│                                                                                         │
│  ③ RulesResourceImpl.createRealmRuleset()  ← ⚠️ Authorization flaw here                 │
│     manager/.../rules/RulesResourceImpl.java:250-267                                    │
│     - L255: isRealmActiveAndAccessible(realm) — checks realm accessible ✓                │
│     - L255: isRestrictedUser() — restricted users blocked ✓                              │
│     - L262: if (ruleset.getLang() == Ruleset.Lang.GROOVY && !isSuperUser())              │
│       → Only blocks GROOVY for non-superusers ✓                                          │
│     - ⚠️ NO check for Lang.JAVASCRIPT! JavaScript rules pass through unrestricted ⚠️    │
│     - L265: ruleset = rulesetStorageService.merge(ruleset)                              │
│       → Passes the RealmRuleset (with malicious script) to persistence layer            │
│                                                                                         │
│                              ↓ JPA Persistence                                           │
│                                                                                         │
│  ④ RulesetStorageService.merge()                                                        │
│     manager/.../rules/RulesetStorageService.java:155-159                                │
│     - L157: entityManager.merge(ruleset)                                                │
│       → Persists RealmRuleset entity (with rules and lang fields) to REALM_RULESET table│
│     - After JPA transaction commit, Hibernate event listener is triggered               │
│                                                                                         │
│                              ↓ Hibernate Event → Camel Message                           │
│                                                                                         │
│  ⑤ Hibernate Interceptor → PersistenceService Event Publishing                          │
│     container/.../persistence/PersistenceEventInterceptor.java:51-62, 102-119           │
│     - L55-56: new PersistenceEvent<>(CREATE, entity, propertyNames, state)              │
│       → Hibernate Interceptor captures the JPA entity persist event                     │
│     - L102-119: afterTransactionBegin() registers Synchronization callback;             │
│       afterCompletion() calls eventConsumer.accept(persistenceEvent) (L114)             │
│     container/.../persistence/PersistenceService.java:596-607                           │
│     - PersistenceService implements Consumer<PersistenceEvent<?>> (L84)                 │
│     - L596-606: accept() publishes event to Camel SEDA topic:                           │
│       producerTemplate.withBody(persistenceEvent)                                       │
│       .withHeader(HEADER_ENTITY_TYPE, entity.getClass())                                │
│       .to(PERSISTENCE_TOPIC).asyncSend()                                                │
│     - PERSISTENCE_TOPIC = "seda://PersistenceTopic?multipleConsumers=true&..."           │
│       (PersistenceService.java:209-210)                                                 │
│                                                                                         │
│                              ↓ Camel Route Consumption                                   │
│                                                                                         │
│  ⑥ RulesService.configure() — Camel Route Processor                                     │
│     manager/.../rules/RulesService.java:228-235                                         │
│     - L228: from(PERSISTENCE_TOPIC)                                                     │
│     - L230: .filter(isPersistenceEventForEntityType(Ruleset.class))                     │
│       → Filters for Ruleset-type events only                                             │
│     - L232-234: .process(exchange -> {                                                  │
│         PersistenceEvent<?> pe = exchange.getIn().getBody(PersistenceEvent.class);       │
│         processRulesetChange((Ruleset) pe.getEntity(), pe.getCause());                  │
│       })                                                                                │
│       → Extracts RealmRuleset entity from event and dispatches to change handler        │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑦ RulesService.processRulesetChange()                                                  │
│     manager/.../rules/RulesService.java:503-531                                         │
│     - L504: cause == CREATE (not DELETE), ruleset.isEnabled() == true                   │
│       → Enters the deployment branch (else block)                                       │
│     - L518: ruleset instanceof RealmRuleset → takes realm branch                        │
│     - L520: RulesEngine<RealmRuleset> engine =                                          │
│             deployRealmRuleset((RealmRuleset) ruleset)                                   │
│     - L521: engine.start()                                                              │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑧ RulesService.deployRealmRuleset()                                                    │
│     manager/.../rules/RulesService.java:589-625                                         │
│     - L591: Gets existing engine from realmEngines Map or creates new one               │
│     - L594-613: If new engine: new RulesEngine<>(...), stores in realmEngines           │
│     - (via addRuleset): engine.addRuleset(ruleset)                                      │
│       → Passes RealmRuleset to engine for deployment                                    │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑨ RulesEngine.addRuleset()                                                             │
│     manager/.../rules/RulesEngine.java:252-273                                          │
│     - L264: deployment = new RulesetDeployment(ruleset, this, timerService,             │
│             assetStorageService, executorService, scheduledExecutorService,              │
│             assetsFacade, usersFacade, notificationFacade, webhooksFacade,               │
│             alarmsFacade, historicFacade, predictedFacade)                               │
│       → Creates deployment object wrapping the Ruleset (with malicious script)          │
│     - L265: deployment.init()                                                           │
│       → Triggers compilation and initialization                                         │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑩ RulesetDeployment.init()                                                             │
│     manager/.../rules/RulesetDeployment.java:132-158                                    │
│     - L143: TextUtil.isNullOrEmpty(ruleset.getRules()) → false, script is non-empty     │
│     - L149: ruleset.isEnabled() → true                                                  │
│     - L154: if (!compile()) → calls compile()                                           │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑪ RulesetDeployment.compile()                                                          │
│     manager/.../rules/RulesetDeployment.java:211-228                                    │
│     - L217: switch (ruleset.getLang()) {                                                │
│     - L218:   case JAVASCRIPT:  ← lang field is JAVASCRIPT                              │
│     - L219:     return compileRulesJavascript(ruleset, assetsFacade,                    │
│                   usersFacade, notificationsFacade, historicDatapointsFacade,            │
│                   predictedDatapointsFacade);                                           │
│                                                                                         │
│                              ↓                                                           │
│                                                                                         │
│  ⑫ RulesetDeployment.compileRulesJavascript()  ← SINK (Code Execution Point)        │
│     manager/.../rules/RulesetDeployment.java:306-378                                    │
│                                                                                         │
│     - L307: // TODO https://github.com/pfisterer/scripting-sandbox/...                  │
│       ↑ Sandbox was NEVER implemented (only a TODO comment)                             │
│                                                                                         │
│     - L308: ScriptEngine scriptEngine =                                                 │
│             scriptEngineManager.getEngineByName("nashorn");                              │
│       ↑ Uses Nashorn 15.7 (gradle.properties:59)                                        │
│       ↑ Obtained via ScriptEngineManager — NO ClassFilter applied                       │
│       ↑ Attacker can access ANY Java class via Java.type()                              │
│                                                                                         │
│     - L309-311: Creates ScriptContext and Bindings                                      │
│     - L312-317: Binds internal service objects to engineScope:                          │
│         "LOG" → Logger, "assets" → Assets facade,                                       │
│         "users" → Users facade, "notifications" → Notifications facade                  │
│                                                                                         │
│     - L319: String script = ruleset.getRules();                                         │
│       ↑↑↑ DIRECTLY reads the attacker's malicious script content ↑↑↑                    │
│       ↑↑↑ This is the exact same value from the HTTP Body "rules" field ↑↑↑             │
│       ↑↑↑ passed through step ① with NO sanitization whatsoever ↑↑↑                     │
│                                                                                         │
│     - L322-365: Auto-prepends Java interop prefix:                                      │
│         load("nashorn:mozilla_compat.js")  → provides importPackage()                   │
│         importPackage("java.util.stream", ...)  → auto-imports Java packages            │
│         var Match = Java.type("...AssetQuery$Match")  → pre-registers Java.type()       │
│         ... (12 active Java.type references; 1 commented out)                           │
│       ↑ These prefixes further lower the attack barrier                                 │
│                                                                                         │
│     - L365: + script;                                                                   │
│       ↑ Attacker script appended directly (string concatenation, no checks)             │
│                                                                                         │
│     - L368: scriptEngine.eval(script, engineScope);                                     │
│       ↑↑↑ !!! FINAL SINK — The script string containing attacker's                  │
│       ↑↑↑ malicious code is DIRECTLY EXECUTED by Nashorn ScriptEngine                   │
│       ↑↑↑ Attacker uses Java.type('java.lang.Runtime') etc. to invoke                  │
│       ↑↑↑ arbitrary Java class methods, achieving Remote Code Execution (RCE)           │
│                                                                                         │
└─────────────────────────────────────────────────────────────────────────────────────────┘

Path 2: JavaScript Expression Injection via Asset Ruleset (Non-Superuser Exploitable)

Same data flow as Path 1. Differences only in the first three steps:

| Step | Difference |
| ① Source | `POST /api/{realm}/rules/asset`, body includes `"assetId":"xxx"` |
| ② Entry | `RulesResource.createAssetRuleset()` — `RulesResource.java:200-206` |
| ③ Auth Flaw | `RulesResourceImpl.createAssetRuleset()` — `RulesResourceImpl.java:341-365`: L350 checks realm accessible, L353 checks restricted user's asset link, L359 only blocks `GROOVY`, **`JAVASCRIPT` unrestricted**. L363 `rulesetStorageService.merge(ruleset)` |

Sink Code Analysis

Sink 1: compileRulesJavascript() — Lines 306-378

// RulesetDeployment.java L306-378
protected boolean compileRulesJavascript(Ruleset ruleset, ...) {
    // L307: TODO indicates sandbox was NEVER implemented
    // L308: Gets Nashorn engine via ScriptEngineManager — NO ClassFilter
    ScriptEngine scriptEngine = scriptEngineManager.getEngineByName("nashorn");
    
    // L312-317: Binds internal service objects
    engineScope.put("LOG", LOG);
    engineScope.put("assets", assetsFacade);

    // L319: DIRECTLY reads attacker's script content
    String script = ruleset.getRules();

    // L322-365: Prepends Java interop imports (lowers attack barrier)
    script = "load(\"nashorn:mozilla_compat.js\");\n" + ... + script;

    // L368: SINK — Executes the malicious script!
    scriptEngine.eval(script, engineScope);
}

Why missing ClassFilter is fatal: The Nashorn engine provides a ClassFilter interface to restrict which Java classes scripts can access. The current code uses scriptEngineManager.getEngineByName("nashorn") (L308) which applies no ClassFilter, so the attacker can access any Java class via Java.type().

Sink 2: compileRulesGroovy() — Lines 449-485

// RulesetDeployment.java L449-485
protected boolean compileRulesGroovy(Ruleset ruleset, ...) {
    // L451-452: Sandbox code COMMENTED OUT
    // TODO Implement sandbox
    // new DenyAll().register();   ← COMMENTED OUT!

    // L453: Parses attacker's Groovy script
    Script script = groovyShell.parse(ruleset.getRules());
    
    // L473: SINK — Directly executes Groovy script
    script.run();
}

Why SandboxTransformer is ineffective: The GroovyShell uses SandboxTransformer (L79-81) for AST transformation, but this transformer relies on registered GroovyInterceptor instances at runtime. Since new DenyAll().register() is commented out (L452), no interceptor is registered, making SandboxTransformer a no-op.

Path 3: Groovy Expression Injection via Realm Ruleset (Superuser Only)

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                          SOURCE → SINK Complete Data Flow (Groovy)                       │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                         │
│  ① HTTP POST /api/{realm}/rules/realm                                                   │
│     Body: { "type":"

Credited to qxyuan853.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. No analysis has been written for this entry yet. Methodology

More Maven vulnerabilities

All Maven entries