GHSA-7mqr-33rv-p3mp: Expression Injection in OpenRemote
SummaryThe 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.DetailsAttacker-Controllable Source-to-Sink PathsThere 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:**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):**Non-superuser attackers with only the `write:rules` role can submit arbitrary JavaScript rules that execute with full JVM access.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 AnalysisSink 1: `compileRulesJavascript()` — Lines 306-378Why 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-485Why 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)Path 4: Groovy Expression Injection via Asset Ruleset (Superuser Only)Same as Path 3; first three steps differ as in Path 2.PoCTest Environment| Component | Details |
|-----------|---------|
| Host OS | macOS Darwin 25.1.0 |
| Docker | Docker Compose with official OpenRemote images |
| OpenRemote | `openremote/manager:latest` (v1.20.2) |
| Keycloak | `openremote/keycloak:latest` |
| PostgreSQL | `openremote/postgresql:latest-slim` |
| Target URL | `https://localhost` |Multi-Tenant Test TopologyDeploymentOpenRemote was started using the project's official `docker-compose.yml`:Containers running:Reproduction Steps & EvidenceStep 0: Obtain Admin TokensStep 1: Setup Multi-Tenant EnvironmentCreated Realm B (victim) with sensitive assets, and Realm A (attacker) with a non-superuser.Key point: The attacker user has write:rules role but is NOT a superuser.Step 2: Verify Tenant Isolation Works via Normal APIConclusion: 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):API response: HTTP 200 (rule accepted and deployed)Server log evidence (captured from docker compose logs manager):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:API response: HTTP 200
Server log evidence:ATTACK 3 — Environment Variable Theft (Database Credentials)**Payload:****API response**: `HTTP 200`**Server log evidence** (key variables extracted):**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:**The `assets` object bound into JavaScript is an `AssetsFacade` instance`AssetsFacade.getResults()` enforces realm isolation by overwriting `assetQuery.realm`The attacker uses Java reflection to extract the internal `assetStorageService` fieldCalls `assetStorageService.findAll()` directly, bypassing realm restriction AND `excludeAttributes()`**Payload:****API response**: `HTTP 200`**Server log evidence — Stolen data from Realm B:****Cross-realm enumeration — all assets across all realms:****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` realmBypassed both the realm restriction AND the `excludeAttributes()` protection in `AssetsFacade`Authorization Bypass VerificationThe same attacker user attempted to create a Groovy rule (which is correctly restricted to superusers):**Root cause in source code** (`RulesResourceImpl.java:262`):ImpactRemote code execution.
CVSS v3.1: 9.9