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.groovymodules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovymodules/nf-commons/src/main/nextflow/plugin/HttpPluginRepository.groovyThe 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 attackernxf_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.0The victim run uses:
NXF_HOME=/tmp/nxf-plugin-cache-two-user-poc/victim-homeNXF_PLUGINS_DIR=/tmp/nxf-plugin-cache-two-user-poc/shared-pluginsNXF_OFFLINE=trueNXF_PLUGINS_ALLOWED=nf-amazonNXF_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/workThe malicious plugin writes a marker file during plugin startup:
/tmp/nxf-plugin-cache-two-user-poc/plugin-started.txtObserved 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-pluginsnxf_attacker_poc nxf_attacker_poc 755 .../shared-plugins/nf-amazon-3.10.0Victim run result:
VICTIM_STATUS=0Marker ownership after victim execution:
nxf_victim_poc nxf_victim_poc 644 .../plugin-started.txtMarker content:
plugin-started-as-victimuser=nxf_victim_pocuid=1202cwd=/tmp/nxf-plugin-cache-two-user-poc/victim-run-dirtimestamp=2026-06-30T15:33:50.992079761ZRelevant Nextflow log lines:
Plugins resolved requirement=[nf-amazon@3.10.0]Installing plugin nf-amazon version: 3.10.0Plugin 'nf-amazon@3.10.0' resolvedStart 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 existsvictim requests nf-amazon@3.10.0victim sets NXF_PLUGINS_ALLOWED=nf-googleObserved:
ALLOWLIST_STATUS=1ALLOWLIST_MARKER_EXISTS=noRefuse to use plugin 'nf-amazon' - allowed plugins are: nf-googleThis 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_DIRNXF_OFFLINE=truevictim requests nf-amazon@3.10.0Observed:
NO_CACHE_STATUS=1NO_CACHE_MARKER_EXISTS=noNo attacker marker is created without the poisoned cache directory.
3. Private victim cache
private cache owned by nxf_victim_pocmode 0700attacker attempts to prepopulate private-plugins/nf-amazon-3.10.0Observed:
PRIVATE_STATUS=1mkdir: cannot create directory '.../private-plugins': Permission deniedThis 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/pluginsat0700) 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