Black Hat 2026: Pre-auth RCE in Enterprise Java: When Middleware Becomes the Exploit
Novee Security researchers uncovered 12 vulnerabilities across major Enterprise Java platforms, demonstrating how unauthenticated attackers can exploit middleware components to achieve remote code execution.
This post describes research conducted under coordinated disclosure. We reported every finding to the affected projects and worked with them before publication. Don’t test systems you don’t own or have explicit permission to assess.
Enterprise Java platforms have been shipping and patching for two decades. You would expect the unauthenticated attack surface to be picked clean by now, but it isn’t, because so many exploitable paths run through the forgotten plumbing: routers, servlet dispatchers, SSO handlers, template engines, serializers. Middleware that was never designed to take untrusted input, sitting between the front door and the internal services that assume nobody can reach them.
That assumption is the bug, a structural mistake that repeats across the ecosystem: an internal-only execution surface becomes reachable from an unauthenticated entry point, and an execution sink turns that reachability into remote code execution.
For our BlackHat 2026 briefing, Pre-auth RCE in Enterprise Java: When Middleware Becomes the Exploit, we audited four enterprise Java platforms and found 12 vulnerabilities, including one sandbox escape and four pre-auth issues. This post focuses on the two that matter most: two complete, independent pre-auth RCE chains:
- Bonita BPM (BadBonita): parser differential, dispatch discrepancy, XStream gadget chain.
- Apache OFBiz (SSOnOf(a)biz): hardcoded signing key, a UI-flag gate, and a denylist bypass
Across two different products and different bug classes, the risk takes the same shape:
Routing reaches an internal surface. An execution sink turns access into pre-auth RCE.
Most security guidance treats bug classes in isolation: “here’s how deserialization works,” “here’s how SSTI works.” But critical enterprise vulnerabilities often live in the seams between routing, authentication glue code, and internal services, and they only become exploitable when you understand how the system is wired and then chain the pieces. Let’s walk both chains end to end.
Chain #1: BadBonita (Bonita BPM 10.4.3)
Bonita is an open-source BPM platform: a drag-and-drop BPMN workflow builder with human task management and SLA tracking, a REST API for full process lifecycle management via HTTP, and a connector framework into SAP, Salesforce, LDAP, and databases. It runs in banking, insurance, government, and telecom, behind loan approvals, claims processing, and employee onboarding. Default deployment is Docker, with Kubernetes and on-prem WAR options.
The stack: Java 11+, Tomcat 9, Spring, XStream (pay attention to this one), Groovy 3.x. We focus on three of those: Tomcat, XStream, and Groovy, because the exploit uses all three.
We picked Bonita to start because it ships XStream as a third-party dependency, and XStream has a long history of permissive type handling that turns deserialization into command execution. The question was never whether XStream could be abused. It was whether an unauthenticated attacker could reach the place where XStream runs.
Bonita’s two worlds
Bonita exposes two API surfaces with very different rules:
- /API/: the public surface. Authenticated (a session is required), CSRF token enforced on POST, standard REST.
- /serverAPI/: the internal surface. BASIC auth only (HTTP_API users), no CSRF protection, and raw XStream deserialization on the other side. It’s protected by a web.xml security constraint, and HTTP_API=true is required for clustered deployments, so it’s enabled in high-value environments
The attacker starts in /API/. Everything interesting lives behind /serverAPI/. The entire first half of the chain is one problem: how do you get from one to the other without credentials?
The answer is that three independent security layers all guard that boundary, and a single URL defeats all three at once. None of the three layers talk to each other.
Layer 1: the parser differential (..;)
Two components in the request path normalize the URL, and they disagree about what ..; means.
URI.normalize() follows RFC 3986. In RFC 3986, a semicolon introduces a matrix parameter, so ..; is a single, literal path segment. To this parser, the path below plainly starts with /API/, and the security check passes:
/API/system/session/..;/..;/..;/serverAPI/... → (Filter Checks) Starts with /API/ CHECK PASSES
Tomcat’s dispatcher does something different. It runs stripPathParams() first, removing the semicolon and leaving a bare .., which normalize() then resolves as a parent-directory traversal:
/API/system/session/..;/..;/..;/serverAPI/... → Resolves to /serverAPI/ FORWARD
This is the parser-logic class Orange Tsai laid out at Black Hat USA 2018, Breaking Parser Logic: Take Your Path Normalization Off and Pop 0days Out, applied here to fresh code paths in CustomPageRequestModifier.java and ApplicationContext.java.
Layer 2: the filter regex bypass
Bonita’s auth and CSRF filters both decide whether a request is exempt using Matcher.find(). Except find() looks for the pattern anywhere in the string and returns true on a partial match; matches() requires the whole string to conform. The intent was clearly the latter.
Because both filters use find(), both are satisfied by a substring and never look at the rest of the URL:
- The auth filter accepts any path matching apps/.+/API/, so a fabricated app name slots right in: apps/FAKE/API/… → matches → true.
- The CSRF filter’s exclude pattern is found inside API/system/session → matches → true.
Neither filter ever sees /serverAPI/. The pattern is present, so the request is waved through.
Layer 3: the missing dispatcher
The Servlet Spec 2.4+ defines four ways a request can reach a servlet (REQUEST, FORWARD, INCLUDE, ERROR) and a constraint can declare which dispatch types it applies to. When no <dispatcher> element is present, only direct requests are protected. Everything else (FORWARD, INCLUDE, ERROR)passes freely.
Every other filter mapping in Bonita’s web.xml declares both REQUEST and FORWARD. The /serverAPI/* security constraint is the one that doesn’t:
The consequence is a clean fork in behavior:

One URL that defeats all three
Put the three layers together and the entire authentication boundary collapses into a single request:
POST /bonita/apps/FAKE/API/system/session/..%3b/..%3b/..%3b/serverAPI/org.bonitasoft.engine.api.LoginAPI/login
Three blind spots, one URL, none of them aware of the others. We’re now unauthenticated and inside the internal API.
The Kill: XStream deserialization to RCE
Here’s how attacker-controlled XML reaches XStream, creating pre-auth remote code execution:

Bonita Summary
Five blind spots, one URL: a parser differential, a partial-match filter exclusion, a dispatcher discrepancy, an AnyTypePermission deserialization sink, and a gadget chain that defeats CC 4.5.0 via Unsafe. The whole thing executes inside fromXML() before the request completes. Every component is individually defensible. Chained, they’re pre-auth RCE.
Chain #2: SSOnOf(a)biz (Apache OFBiz 24.09.05)
Apache OFBiz is an enterprise resource planning suite: accounting, inventory, manufacturing, HR: with an ecommerce storefront, a widget engine that renders templates through FreeMarker and Groovy, and SSO via JWT. It’s been an Apache project since 2006, 18-plus years in production across finance, government, manufacturing, and retail. It deploys as embedded Tomcat, a standalone JAR, or a WAR.
The relevant stack: Java 17+, Tomcat, Groovy, FreeMarker, Derby/PG, and JWT signed with HMAC-SHA512. This chain abuses three of those: Tomcat, the JWT SSO layer and the Groovy template engine.
This is a completely separate chain from BadBonita. Same shape, different mechanism: here an authentication artifact becomes executable input through template expansion. Three failures chain together, and one key holds the whole thing.
Failure 1: the default signing key
OFBiz validates JWTs with Algorithm.HMAC512(key), where key is read from security.properties:159. That key is hardcoded, shipped in the public source repository, and identical on every install that hasn’t replaced it. The same key signs both SSO login tokens and the widget callback tokens we’ll abuse later. One secret, two independent features, zero rotation.
If you can sign a token with that key, OFBiz believes you.
Forging admin without credentials
When SSO is enabled, the SSO preprocessor (checkJWTLogin() / validateToken()) trusts any Bearer token that passes signature verification, and trusts the claims inside it unconditionally. The property check passes because SSO is on; the Authorization header JWT is then accepted on faith.
So we mint a token, sign it with the public key, and set userLoginId = “admin”:
Authorization: Bearer <forged_sso_jwt> # signed with the key from security.properties:159
verify() passes. userLoginId = “admin”. An admin session is created. No password, no existing session, no brute force. We are administrators on the strength of a secret that ships in the repo.
Priming the gate
Admin access alone doesn’t get us code execution: the RCE trigger needs one more condition first. The path that ultimately reaches the Groovy evaluator is gated by a user preference called javaScriptEnabled, a UI/accessibility flag. It’s a session flag, and as admin we can set it ourselves:
GET /ajaxSetUserPreference?userPrefTypeId=javaScriptEnabled&userPrefGroupTypeId=GLOBAL_PREFERENCES&userPrefValue=Y Authorization: Bearer <forged_sso_jwt>
The endpoint requires an authenticated user: satisfied by the forged SSO JWT. The flag is written to both the session and the database. A UI toggle now gates server-side eval.

Failure 2: JWT claim to Groovy eval
OFBiz’s widget engine supports a callback mechanism. A legitimately designed JWT_CALLBACK token looks like this:
{
"areaId": "results",
"areaTarget": "ListProducts",
"iss": "ApacheOFBiz"
}
areaTarget is meant to name a widget area to render. The renderer pulls it straight off the claim with no type check and no content check:
String areaTarget = (String) claims.remove("areaTarget"); // no validation
From there, areaTarget flows through getAreaTarget(ctx), which pipes it through FlexibleStringExpander (FSE) on every render, into expandString(). And expandString() evaluates ${groovy:…} expressions through ScriptUtil.evaluate(). The data path is the same whether the value is ListProducts or a Groovy expression: only the content differs:
areaTarget (raw claim) -> getAreaTarget(ctx) -> FlexibleStringExpander.expandString() -> ${groovy:...} -> eval()
So the weaponized token is the designed token with one field changed:
{
"areaId": "results",
"areaTarget": "${groovy: <expression> }",
"iss": "ApacheOFBiz"
}
The trail through the source is direct: ModelForm.java:2522 → 2529 → 2401, MacroFormRenderer.java:491, MacroCommonRenderer.java:41-108: with zero sanitization anywhere between the JWT claim and the evaluator. MacroFormRenderer:491 is precisely where javaScriptEnabled decides whether attacker input reaches eval(). A UI accessibility flag controls remote code execution.
Failure 3: the denylist that doesn’t work
OFBiz does try to stop script injection, with a regex denylist guarding the expression. It doesn’t survive contact with a real scripting engine. The pattern is compiled with Pattern.compile() and no CASE_INSENSITIVE flag, so it’s case-sensitive, and several of its anchors assume conventions Groovy doesn’t follow:
java\s*\. | import\s | embed[^\w] | process[^\w] | class[^\w] | require[^\w] | ...
Two clean ways through:
- process[^\w] matches lowercase process followed by a non-word character. ProcessBuilder([“/bin/sh”, …]) starts with a capital P: never matched.
- java\s*\. matches a java. prefix before a class name. InetAddress.getByName(…) is auto-imported in Groovy, so it needs no java. prefix: never matched.
A denylist enumerates what’s forbidden and loses to anything it didn’t enumerate. Against a language with case sensitivity and auto-imports, that’s a losing position by construction.
The attack: two GET requests
The full chain is two unauthenticated GETs. Request 1 primes the gate; Request 2 pulls the trigger.
This is CVE-2026-31986 (Critical): pre-auth RCE on any OFBiz install with SSO enabled. Two GET requests, zero credentials. One key buys both full authentication bypass and code execution.
OFBiz in one line
Five blind spots, one key: a hardcoded JWT signing key, a forged admin token, session priming through a UI flag, a template-injection sink reached by an unvalidated claim, and a denylist with two trivial anchor failures.
Two chains, one pattern: where to look in your stack
Strip the product names away and both chains are the same three moves.
| Stage | BadBonita | SSOnOf(a)biz |
|---|---|---|
| Routing | Parser ↔ dispatcher disagreement: URI.normalize() ≠ stripPathParams() | Shared signing key: one key, two features, public default (security.properties:159) |
| Internal surface | /serverAPI/ reached via FORWARD: raw XStream deserialization | FlexibleStringExpander reached via forged claim, gated by a UI flag |
| Execution sink | XStream + CC4 + Groovy through Unsafe; eval(), no sandbox | ScriptUtil.evaluate(), no sandbox; case-anchored denylist bypassed |
The same style of mistake recurs across enterprise Java stacks: XML parsing that turns into blind SSRF and data exfiltration, sandbox escapes through alternative property or reflection semantics, canonicalization bugs that become path traversal or file inclusion. The common thread is always the same: middleware acting on attacker-controlled input that it was never designed to receive.
If you run enterprise Java, these are the patterns worth auditing for directly:
- web.xml security constraints missing <dispatcher>FORWARD</dispatcher> (and INCLUDE/ERROR). Default-REQUEST constraints only protect the front door.
- Filter excludes regexes using Matcher.find() instead of Matcher.matches(). Partial-match exclusions are bypass-by-substring.
- Shared signing keys across independent features, and any signing key that ships in source. Rotate defaults; never let one key authorize two trust domains.
- Denylist regexes guarding eval or template-expansion sinks. Denylists in front of a scripting engine fail; remove the unsafe execution primitive or sandbox it instead.
- UI or preference flags that control server-side branches into dangerous code. A rendering toggle should never be the only thing standing between a request and eval().
- Deserialization on internal surfaces: enforce serializer filtering (JEP-290 allowlists, restrictive XStream type permissions: never AnyTypePermission.ANY), and treat “internal-only” as an attacker-reachable boundary, not a guarantee.
The unifying defensive principle: harden internal routing as if it’s exposed, remove unsafe execution primitives rather than filtering inputs to them, and stop assuming any surface is unreachable just because the front door is locked.
Why this is hard to find, and why it matters
Look at what each of these chains is built from:
- A literal ..;.
- A Matcher.find() instead of Matcher.matches().
- A missing XML element.
- A default key left in a config file.
- A regex without a case flag.
Not one of those is a vulnerability flagged by scanners. Each is “boring” plumbing, unassuming, sitting in code that smart engineers wrote and reviewed. A signature-based tool has nothing to match, and a generic LLM pointed at the codebase produces “possible deserialization” with no reachability and no proof. The exploit exists in the relationships between components, and finding it means understanding how the system routes a request, which surfaces assume isolation, and where attacker-controlled data crosses a boundary it shouldn’t.
This is the type of work we built Novee’s offensive AI to do. Novee owns the entire offensive AI stack – proprietary model and harness – and continuously optimizes the system through reinforcement learning in the Novee Gym. It all runs off an Asset Intelligence Model (AIM), a persistent understanding of every application. This lets Novee reason about trust boundaries and chained paths the way a researcher does, instead of pattern-matching against known classes.
The middleware nobody thinks about is easy to attack precisely because nobody defends it. Reasoning about it, continuously and at scale, is how you find these before an attacker does.
Novee follows the path real attackers take. Book a demo to see what’s already exposed.