VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-cx6f-vjhg-g3cc

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)

Published Oct 23, 2026 CVSS 0.0

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

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)        /tmp/ve-pwn)y)          /tmp/ve-pwn)y)          /tmp/rc  ``\` ``, `\` -> `\\`). 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.

```sh
    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.

Affected AI Products

ollama
Get the weekly digest. Every Monday: top AI security stories of the week. Free.