Proof-of-concept demonstration for CVE-2026-39816, an EXECUTE_CODE
permission bypass in Apache NiFi 2.8.0 that lets a flow designer who has
been deliberately denied code-execution privileges run arbitrary Groovy
in the NiFi JVM via the graph bundle's ExecuteGraphQuery processor.
- Fixed in NiFi 2.9.0
- Discovered by ZeroPath -- full technical write up available here.
NiFi gates code-executing processors behind the EXECUTE_CODE permission
via the @Restricted(requiredPermission = RequiredPermission.EXECUTE_CODE)
annotation. All 16 dedicated scripting components in NiFi —
ExecuteScript, InvokeScriptedProcessor, ExecuteGroovyScript,
ScriptedTransformRecord, etc. — carry this annotation, so a flow
designer must be granted the explicit EXECUTE_CODE permission before
the authorization layer will let them create or configure these
processors.
The graph bundle (nifi-graph-nar + nifi-other-graph-services-nar)
ships a third code-execution path that is missing this annotation:
// nifi-extension-bundles/nifi-graph-bundle/nifi-other-graph-services/
// src/main/java/org/apache/nifi/graph/TinkerpopClientService.java:451
protected Map<String, String> bytecodeSubmission(
String s, Map<String, Object> map, GraphQueryResultCallback cb) {
...
compiled = groovyShell.parse(s); // line 463 — compile attacker string
compiledCode.put(s, compiled);
...
compiled.setBinding(bindings);
Object result = compiled.run(); // line 477 — execute in NiFi JVMThe s parameter is the query string from the ExecuteGraphQuery
processor's "Graph Query" property, passed through unchanged. There is
no sanitization, no allowlist, and no sandbox — any valid Groovy is
compiled and executed with the full privileges of the NiFi process.
Neither TinkerpopClientService nor the ExecuteGraphQuery /
ExecuteGraphQueryRecord processors that drive it carry the
@Restricted annotation. NiFi's authorization layer therefore treats
them as ordinary components, and a flow designer who has been
explicitly denied EXECUTE_CODE can still create them.
A NiFi 2.8.0 server is exploitable when both of the following are true:
-
The optional graph bundle is installed. Specifically the
nifi-other-graph-services-narNAR — this is the bundle that shipsTinkerpopClientService. Servers without this NAR are not affected. -
At least one user has flow-designer-style permissions (
read/writeon a process group and on/controller) withoutEXECUTE_CODE. This is a common policy shape: an organization wants users to be able to build and modify pipelines, but not run arbitrary code. TheEXECUTE_CODEpermission is precisely the security boundary that policy is meant to enforce.
If both conditions hold, the user can configure a
TinkerpopClientService in "ByteCode Submission" mode, point it at any
reachable Gremlin server (the Gremlin server is only used for service
initialization — execution happens locally), and create an
ExecuteGraphQuery processor with Groovy code in the "Graph Query"
property. Starting the processor compiles and runs the Groovy in the
NiFi JVM.
Arbitrary code execution as the NiFi service account. From there:
- Read and modify everything on the NiFi host's filesystem, including the keystore, sensitive properties key, and any flow-encrypted secrets.
- Reach every internal system NiFi can reach. NiFi typically sits next to data-warehouse, message-bus, object-storage, and database credentials in its Parameter Contexts and Controller Services — the Groovy payload can read all of them and pivot.
- Establish persistence by writing to
flow.json.gz, dropping a NAR into the autoload directory, or modifying a controller service.
The exploit bypasses the EXECUTE_CODE permission, so this works even
when the operator has explicitly removed code-execution rights from
the user — exactly the scenario the permission exists to prevent.
This repository ships one POC, covering the most direct trigger:
attacker-controlled Groovy placed directly in the Graph Query
processor property. No upstream connection, no Expression Language,
no FlowFile content needed — just the processor running on its own
timer.
Two additional code paths exist (FlowFile body content used as the
query when Graph Query is empty, and Expression Language
interpolation of attacker-influenced FlowFile attributes into the
query template). They are not demonstrated here because they reach
the same sink with extra preconditions; the direct path is sufficient
to prove the EXECUTE_CODE bypass.
-
setup/— Docker Compose environment. Brings up a NiFi 2.8.0 instance (officialapache/nifi:2.8.0image) with the graph bundle NARs autoloaded, an LDAP server providing two test users (adminandflow_designer), and a Gremlin server for theTinkerpopClientServiceto point at.setup.shbootstraps the policies so thatflow_designerhas flow-editing rights but notEXECUTE_CODE, then verifies the configuration. -
pocs/flow_designer_groovy_rce.py— Self-contained exploit. Authenticates asflow_designer, demonstrates thatExecuteScriptis denied (theEXECUTE_CODEgate is working), then creates aTinkerpopClientService+ExecuteGraphQuerywith a Groovy payload that spawns a bash reverse shell to a listener the POC starts locally. Once the connection is established, the POC upgrades the shell to a fully interactive PTY-backed bash via util-linuxscript, with raw-mode stdio bridging and live window resizing — operator gets a real terminal inside the NiFi container.
Prerequisites: Docker, Python 3.10+, and uv.
cd setup
./setup.shThe first run downloads the graph-bundle NARs from Maven Central and pulls Docker images (~2-3 min). When setup completes it prints the ready-to-paste POC invocation.
Run the POC:
uv run --no-project --with requests \
pocs/flow_designer_groovy_rce.py \
--base-url https://localhost:8443 \
--username flow_designer \
--password 'flowDesigner123!' \
--gremlin-host gremlin-serverThe POC drops the operator into an interactive bash session inside the
NiFi container running as the nifi service account. Type exit or
press Ctrl-D to disconnect — the POC will then clean up the processor
and controller service it created.
On Linux Docker (where host.docker.internal does not resolve by
default), pass --shell-host <host-bridge-ip> or add
extra_hosts: ["host.docker.internal:host-gateway"] to the nifi
service in setup/docker-compose.yml.
Tear down:
cd setup
./teardown.sh