Overview

pinned plugin cache poisoning in nextflow

August 18, 2026
5 min read

TL;DR

I reported GHSA-654x-pf35-q3v4 in Nextflow.

This was a pinned plugin cache poisoning vulnerability in the local plugin resolution logic.

Nextflow accepts a pre-existing local cache directory for an official pinned plugin coordinate such as nf-amazon@3.10.0 without authenticating that the cached content came from the official plugin source.

In a shared-cache deployment, a lower-trust local user who can write to NXF_PLUGINS_DIR or shared $NXF_HOME/plugins can pre-populate nf-amazon-3.10.0 with attacker-controlled PF4J plugin code. When a victim later runs a trusted workflow and requests or allowlists nf-amazon@3.10.0, Nextflow starts the cached plugin inside the victim’s Nextflow process.

This is local/shared-cache code execution as the victim Nextflow user. It is not unauthenticated RCE and not remote RCE.

The bug

The issue is not that plugins execute code. The issue is that Nextflow trusts a pre-existing cache directory for an official pinned plugin ID/version without authenticating that the cached content was downloaded from the official plugin source.

The relevant code paths are:

modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy
modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy
modules/nf-commons/src/main/nextflow/plugin/HttpPluginRepository.groovy

The source-to-sink flow:

PluginsFacade.getPluginsDir()
-> resolves plugin cache path from NXF_PLUGINS_DIR, $NXF_HOME/plugins, or local plugins
PluginsFacade.start()
-> checks whether plugin ID is allowed
-> starts it
PluginsFacade.isAllowed()
-> checks only the plugin ID from NXF_PLUGINS_ALLOWED
PluginUpdater.prefetchMetadata()
-> skips metadata prefetch for pinned plugins that already exist in the cache
PluginUpdater.isAlreadyInstalled()
-> treats a pinned plugin as installed when pluginsStore.resolve("${ref.id}-${ref.version}") exists
PluginUpdater.download0()
-> returns the existing $id-$version path without downloading
PluginUpdater.load0()
-> loads the existing cache path with pluginManager.loadPluginFromPath(pluginPath)

The remote release metadata includes sha512sum, but the pre-existing pinned cache path does not go through that remote metadata/download verification path.

That is the gap. The allowlist checks the plugin ID. The cache lookup treats an existing directory as “already installed.” But nowhere does Nextflow verify that the content in that directory actually came from the official source.

PoC

The PoC was run in a local Docker two-user lab with two real Unix users:

  • nxf_attacker_poc (UID 1201) — the attacker
  • nxf_victim_poc (UID 1202) — the victim

The trusted workflow used in the victim run is static and does not contain attacker-controlled code:

process HELLO {
output:
path 'out.txt'
script:
'''
echo hello > out.txt
'''
}
workflow {
HELLO()
}

The malicious cached plugin is placed at:

/tmp/nxf-plugin-cache-two-user-poc/shared-plugins/nf-amazon-3.10.0

The victim run uses:

Terminal window
NXF_HOME=/tmp/nxf-plugin-cache-two-user-poc/victim-home
NXF_PLUGINS_DIR=/tmp/nxf-plugin-cache-two-user-poc/shared-plugins
NXF_OFFLINE=true
NXF_PLUGINS_ALLOWED=nf-amazon
NXF_POC_MARKER=/tmp/nxf-plugin-cache-two-user-poc/plugin-started.txt
nextflow -log /tmp/nxf-plugin-cache-two-user-poc/nextflow.log run <trusted workflow> \
-plugins nf-amazon@3.10.0 \
-w /tmp/nxf-plugin-cache-two-user-poc/work

The malicious plugin writes a marker file during plugin startup:

/tmp/nxf-plugin-cache-two-user-poc/plugin-started.txt

Observed identities:

attacker: uid=1201(nxf_attacker_poc) gid=1202(nxf_attacker_poc)
victim: uid=1202(nxf_victim_poc) gid=1203(nxf_victim_poc)

Plugin cache ownership before victim execution:

root root 777 .../shared-plugins
nxf_attacker_poc nxf_attacker_poc 755 .../shared-plugins/nf-amazon-3.10.0

Victim run result:

VICTIM_STATUS=0

Marker ownership after victim execution:

nxf_victim_poc nxf_victim_poc 644 .../plugin-started.txt

Marker content:

plugin-started-as-victim
user=nxf_victim_poc
uid=1202
cwd=/tmp/nxf-plugin-cache-two-user-poc/victim-run-dir
timestamp=2026-06-30T15:33:50.992079761Z

Relevant Nextflow log lines:

Plugins resolved requirement=[nf-amazon@3.10.0]
Installing plugin nf-amazon version: 3.10.0
Plugin 'nf-amazon@3.10.0' resolved
Start plugin 'nf-amazon@3.10.0'

That proves attacker-owned cache content for an official pinned plugin ID/version was executed in the victim’s Nextflow process.

Negative controls

1. Allowlist mismatch

poisoned cache exists
victim requests nf-amazon@3.10.0
victim sets NXF_PLUGINS_ALLOWED=nf-google

Observed:

ALLOWLIST_STATUS=1
ALLOWLIST_MARKER_EXISTS=no
Refuse to use plugin 'nf-amazon' - allowed plugins are: nf-google

This confirms the issue is not an allowlist bypass. The issue is that an allowed plugin ID/version is not bound to authentic cache content.

2. No poisoned cache

empty NXF_PLUGINS_DIR
NXF_OFFLINE=true
victim requests nf-amazon@3.10.0

Observed:

NO_CACHE_STATUS=1
NO_CACHE_MARKER_EXISTS=no

No attacker marker is created without the poisoned cache directory.

3. Private victim cache

private cache owned by nxf_victim_poc
mode 0700
attacker attempts to prepopulate private-plugins/nf-amazon-3.10.0

Observed:

PRIVATE_STATUS=1
mkdir: cannot create directory '.../private-plugins': Permission denied

This confirms that a victim-owned private cache is not poisonable by the lower-trust attacker user.

Why the negative controls matter

Each negative control isolates a specific part of the attack chain:

  • Allowlist mismatch proves the bug is not about bypassing NXF_PLUGINS_ALLOWED. The allowlist works correctly. The gap is upstream: whether the cached content is authentic.
  • No poisoned cache proves the bug requires a pre-populated directory. Without attacker write access to the cache, there is no exploitation path.
  • Private victim cache proves the vulnerability is deployment-dependent. The default private per-user cache ($HOME/.nextflow/plugins at 0700) is not poisonable. The attack requires a shared, world-writable cache directory.

Impact

An attacker with write access to a shared Nextflow plugin cache can execute code as a victim user who later runs a trusted workflow and requests or allows the corresponding official pinned plugin ID/version.

The attacker does not need to control:

  • the workflow source
  • the task script
  • the Nextflow config
  • secret values

The attacker only needs write access to the shared plugin cache before the victim run.

The plugin runs inside the victim Nextflow process and can access:

  • the victim’s environment
  • local files
  • workflow credentials
  • cloud credentials
  • scheduler context

This is most relevant to HPC, CI, or shared filesystem environments where NXF_PLUGINS_DIR or $NXF_HOME/plugins is shared across users or jobs.

Security boundary and limitations

This is not unauthenticated remote code execution.

This is not remote code execution from the network.

The attacker needs write access to a plugin cache directory that the victim trusts, such as a shared NXF_PLUGINS_DIR or shared $NXF_HOME/plugins.

If the victim uses a private plugin cache that the attacker cannot write to, the cache poisoning step is not possible.

The maintainers classified this as Moderate because the vulnerable configuration requires the operator to deviate from the secure default ($HOME/.nextflow/plugins at 0700) and assign insecure permissions to a shared cache. The correct CWE mapping is CWE-276 / CWE-732 (incorrect permission assignment on the deployment) rather than an authentication or verification flaw in Nextflow itself.

Versions and details

  • Advisory: GHSA-654x-pf35-q3v4
  • Package: nextflow-io/nextflow
  • CVE: CVE-2026-71552
  • Affected: 26.05.0-edge
  • Patched: None yet
  • Severity: Moderate
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N
  • Weakness: CWE-276 (Incorrect Default Permissions), CWE-732 (Incorrect Permission Assignment for Critical Resource)
  • Credit: baradika

Reference