Summary
virtualenv: Bash activator leaves the --prompt value unescaped in PS1 when promptvars is off, so prompt expansion later executes it (regression in 21.14.9)
The bash activator puts the caller-supplied prompt value into PS1 unescaped whenever promptvars is off at activation time, and assumes prompt expansion can never run afterwards. promptvars and posix mode are runtime shell state, not a property of the activation moment: re-enabling prompt expansion in the same shell (shopt -s promptvars, set -o posix) or starting an interactive child bash that inherits the exported PS1 makes bash expand the stored value and run the command substitution in it. An attacker who controls the prompt value gets command execution as the user who sources bin/activate.
This is a regression. 21.14.8 escaped $ and the backtick unconditionally and stays inert under the same sequence. The 21.14.9 fix for GHSA-6w3r-xr62-4xcp (#3418) rewrote that block and left bash with a path that stores the raw value whenever promptvars is off.
Affected area
Activation scripts (activate, activate.bat, activate.csh, activate.fish, activate.nu, Activate.ps1, activate.xsh), specifically the bash bin/activate.
Attacker-controlled input
The --prompt value, which the security policy already lists as a value that may come from project metadata the user did not write. It reaches virtualenv as --prompt on the command line or as the prompt argument of the API, is written into bin/activate as the literal VIRTUAL_ENV_PROMPT assignment (correctly quoted, the value arrives there as data), and the script then copies it into PS1 without escaping $ or the backtick.
Example: a repository or tool config supplies a prompt such as x$(id > /tmp/ve-pwn)y; the victim creates and later activates the environment.
Details
Source of the latest release: src/virtualenv/activation/bash/activate.sh lines 140-160
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] &&
{ [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then
_OLD_VIRTUAL_PS1="${PS1-}"
# bash expands PS1 after decoding its prompt escapes when promptvars is on or in posix mode, so the prompt holds a
# reference to the variable and its value never reaches that expansion; otherwise only the decode runs and
# backslashes need doubling. ...
if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then
PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}"
else
_VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}"
if [ -n "${ZSH_VERSION-}" ]; then
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}"
fi
PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
unset _VIRTUAL_PROMPT
fi
fi
Two separate mistakes combine:
- A runtime shell option is treated as a constant.
shopt -u promptvarsat the moment ofsourcedoes not bind the shell forever:shopt -s promptvarsandset -o posixboth re-enable PS1 expansion in the same shell. The code itself testsshopt -qo posixbecause posix mode expands the prompt regardless ofpromptvars, which shows the expansion state is not fixed by the activation. - The escaping that would keep the value inert in that mode is missing for bash. GHSA-5vjq-rrrf-7h2q ("activate runs commands from the environment name in zsh with PROMPT_SUBST") fixed exactly this shape for zsh, reasoning that "a user can set it after activation", by escaping
$and the backtick. Bash lost the equivalent escaping in the same commit that added the zsh branch.
The rendered prompt shows the regression directly:
21.14.8 PS1 = (x\\$(id > /tmp/ve-pwn)y) <- escaped, inert
21.14.9 PS1 = (x$(id > /tmp/ve-pwn)y) <- raw value
21.14.12 PS1 = (x$(id > /tmp/ve-pwn)y) <- raw value
Relevant commit: 5f48d711 "fix(bash): preserve literal prompts in supported shells (#3418)", which removed the unconditional $/backtick escaping from the bash path. Advisory for that change: GHSA-6w3r-xr62-4xcp.
Proof of concept
Non-destructive payload: it runs id and writes the output to a file.
python -m virtualenv --no-seed --no-periodic-update --activators bash \
--prompt 'x$(id > /tmp/ve-pwn)y' /tmp/venv-demo
cat > /tmp/rc <<'EOF'
shopt -u promptvars
. /tmp/venv-demo/bin/activate
shopt -s promptvars
EOF
# the prompt needs a tty; script(1) provides one
printf 'exit\n' | script -qec 'bash --noprofile --rcfile /tmp/rc -i' /dev/null
cat /tmp/ve-pwn
Observed on 21.14.12:
$ cat /tmp/ve-pwn
uid=1000(asrun) gid=1000(asrun) groups=1000(asrun),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),999(ollama)
The prompt printed as (xy). The same script against 21.14.8 creates no file; against 21.14.9 and 21.14.12 it does.
Triggers, all run in a pty, activation itself never executing anything; execution happens at the next prompt redraw after prompt expansion is enabled:
After . bin/activate (activated with shopt -u promptvars) |
21.14.8 | 21.14.9 | 21.14.12 |
|---|---|---|---|
shopt -s promptvars |
inert | executes | executes |
set -o posix |
inert | executes | executes |
export PS1, then start an interactive child bash (its promptvars defaults to on) |
inert | executes | executes |
| control: activation with default options | inert | inert | inert |
control: activation with set -o posix already on |
inert | inert | inert |
The third trigger needs no explicit option change: the poisoned PS1 is inherited and the child bash expands it on its first prompt.
shopt -u promptvars is a documented way to make an expensive bash prompt cheap and appears in ~/.bashrc files for that reason; it is also the state left by some prompt wrappers. Whoever sources such an environment gets the unescaped value in PS1.
Versions tested
virtualenv 21.14.12 and 21.14.9 (affected), virtualenv 21.14.8 (unaffected control); CPython 3.14.7; Linux 6.x (WSL2); bash 5.1.16(1)-release. --prompt handling is platform independent; zsh/fish/csh/nu/PowerShell activators were not part of this report.
Known mitigation
For a user: activate with promptvars left at its default, or set VIRTUAL_ENV_DISABLE_PROMPT=1 before activating, which skips the whole prompt block. Do not pass an untrusted value to --prompt.
Fix in the activator: escape $ and the backtick for bash as well, in both branches, with a single backslash ($ -> \$, backtick -> \` , \ -> \\). Verified on bash 5.1.16 that a PS1 built that way is inert with promptvars on, with promptvars off, and with promptvars off followed by posix mode, while still rendering the literal value on screen. That is better than both current shapes: 21.14.8's four-level escaping left stray backslashes on screen, which is why the block was rewritten.
else
_VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
if [ -n "${ZSH_VERSION-}" ]; then
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}"
fi
PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
For the tests, tests/unit/activation/test_bash.py::test_bash_prompt_options already parametrises ("before", "after", "expected") and covers before="shopt -u promptvars" with after="". The missing axis is the transition: ("shopt -u promptvars", "shopt -s promptvars", ...) and ("shopt -u promptvars", "set -o posix", ...), with the existing PWNED check asserting no file was created.
Latest release
- The problem reproduces on the latest virtualenv release.
Impact
Command execution as the user who sources bin/activate, from a value the user may not have written (project metadata or tool config reaching --prompt). The value is also persisted in pyvenv.cfg and in the generated script, so it survives the activation that created it and fires later, in a different shell session, on the first prompt redraw with expansion enabled. No privilege elevation beyond the activating user.
Severity: high, matching GHSA-5vjq-rrrf-7h2q for the same class of prompt-expansion command injection reached by toggling a shell option after activation. If you consider the promptvars state a caller-controlled condition rather than a shell condition, this is medium; the fix is the same either way.
Untrusted input reaches a shell command, allowing arbitrary commands to run on the host. Typical impact: code execution in the application's environment.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-115586? CVE-2026-115586 is a high-severity OS command injection vulnerability in virtualenv (pip), affecting versions >= 21.14.9, < 21.14.13. It is fixed in 21.14.13. Untrusted input reaches a shell command, allowing arbitrary commands to run on the host.
- Which versions of virtualenv are affected by CVE-2026-115586? virtualenv (pip) versions >= 21.14.9, < 21.14.13 is affected.
- Is there a fix for CVE-2026-115586? Yes. CVE-2026-115586 is fixed in 21.14.13. Upgrade to this version or later.
- Is CVE-2026-115586 exploitable, and should I be worried? Whether CVE-2026-115586 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-115586 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-115586? Upgrade
virtualenvto 21.14.13 or later.