{"id":"CVE-2026-115586","kind":"vulnerability","aliases":["CVE-2026-115586","GHSA-cx6f-vjhg-g3cc"],"related":[],"title":"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)","description":"## Summary\n\nThe 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`.\n\nThis 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.\n\n## Affected area\n\nActivation scripts (activate, activate.bat, activate.csh, activate.fish, activate.nu, Activate.ps1, activate.xsh) — specifically the bash `bin/activate`.\n\n## Attacker-controlled input\n\nThe `--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.\n\nExample: a repository or tool config supplies a prompt such as `x$(id > /tmp/ve-pwn)y`; the victim creates and later activates the environment.\n\n## Details\n\nSource 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)\n\n```sh\nif [ -z \"${VIRTUAL_ENV_DISABLE_PROMPT-}\" ] &&\n    { [ -n \"${BASH_VERSION-}\" ] || [ -n \"${ZSH_VERSION-}\" ]; }; then\n    _OLD_VIRTUAL_PS1=\"${PS1-}\"\n    # bash expands PS1 after decoding its prompt escapes when promptvars is on or in posix mode, so the prompt holds a\n    # reference to the variable and its value never reaches that expansion; otherwise only the decode runs and\n    # backslashes need doubling. ...\n    if [ -n \"${BASH_VERSION-}\" ] && { shopt -q promptvars || shopt -qo posix; }; then\n        PS1='(${VIRTUAL_ENV_PROMPT-}) '\"${PS1-}\"\n    else\n        _VIRTUAL_PROMPT=\"${VIRTUAL_ENV_PROMPT//\\\\/\\\\\\\\}\"\n        if [ -n \"${ZSH_VERSION-}\" ]; then\n            _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\$/\\\\\\$}\"\n            _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\`/\\\\\\`}\"\n            _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\%/%%}\"\n        fi\n        PS1=\"(${_VIRTUAL_PROMPT}) ${PS1-}\"\n        unset _VIRTUAL_PROMPT\n    fi\nfi\n```\n\nTwo separate mistakes combine:\n\n1. 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.\n2. 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.\n\nThe rendered prompt shows the regression directly:\n\n```\n21.14.8   PS1 = (x\\\\$(id > /tmp/ve-pwn)y)       <- escaped, inert\n21.14.9   PS1 = (x$(id > /tmp/ve-pwn)y)         <- raw value\n21.14.12  PS1 = (x$(id > /tmp/ve-pwn)y)         <- raw value\n```\n\nRelevant 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).\n\n## Proof of concept\n\nNon-destructive payload: it runs `id` and writes the output to a file.\n\n```bash\npython -m virtualenv --no-seed --no-periodic-update --activators bash \\\n    --prompt 'x$(id > /tmp/ve-pwn)y' /tmp/venv-demo\n\ncat > /tmp/rc <<'EOF'\nshopt -u promptvars\n. /tmp/venv-demo/bin/activate\nshopt -s promptvars\nEOF\n\n# the prompt needs a tty; script(1) provides one\nprintf 'exit\\n' | script -qec 'bash --noprofile --rcfile /tmp/rc -i' /dev/null\n\ncat /tmp/ve-pwn\n```\n\nObserved on 21.14.12:\n\n```\n$ cat /tmp/ve-pwn\nuid=1000(asrun) gid=1000(asrun) groups=1000(asrun),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),999(ollama)\n```\n\nThe prompt printed as `(xy)`. The same script against 21.14.8 creates no file; against 21.14.9 and 21.14.12 it does.\n\nTriggers, all run in a pty, activation itself never executing anything; execution happens at the next prompt redraw after prompt expansion is enabled:\n\n| After `. bin/activate` (activated with `shopt -u promptvars`) | 21.14.8 | 21.14.9 | 21.14.12 |\n| --- | --- | --- | --- |\n| `shopt -s promptvars` | inert | executes | executes |\n| `set -o posix` | inert | executes | executes |\n| `export PS1`, then start an interactive child `bash` (its `promptvars` defaults to on) | inert | executes | executes |\n| control: activation with default options | inert | inert | inert |\n| control: activation with `set -o posix` already on | inert | inert | inert |\n\nThe third trigger needs no explicit option change: the poisoned `PS1` is inherited and the child bash expands it on its first prompt.\n\n`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 environment gets the unescaped value in `PS1`.\n\n## Impact\n\nCommand execution as the user who sources `bin/activate`, from a value the user may not have written (project metadata or tool config reaching `--prompt`). The value is also persisted in `pyvenv.cfg` and in the generated script, so it survives the activation that created it and fires later, in a different shell session, on the first prompt redraw with expansion enabled. No privilege elevation beyond the activating user.\n\nSeverity: high, matching GHSA-5vjq-rrrf-7h2q for the same class of prompt-expansion command injection reached by toggling a shell option after activation. If you consider the `promptvars` state a caller-controlled condition rather than a shell condition, this is medium; the fix is the same either way.\n\n## Versions tested\n\nvirtualenv 21.14.12 and 21.14.9 (affected), virtualenv 21.14.8 (unaffected control); CPython 3.14.7; Linux 6.x (WSL2); bash 5.1.16(1)-release. `--prompt` handling is platform independent; zsh/fish/csh/nu/PowerShell activators were not part of this report.\n\n## Known mitigation\n\nFor a user: activate with `promptvars` left at its default, or set `VIRTUAL_ENV_DISABLE_PROMPT=1` before activating, which skips the whole prompt block. Do not pass an untrusted value to `--prompt`.\n\nFix in the activator: escape `$` and the backtick for bash as well, in both branches, with a single backslash (`$` -> `\\$`, backtick -> ``\\` ``, `\\` -> `\\\\`). 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.\n\n```sh\n    else\n        _VIRTUAL_PROMPT=\"${VIRTUAL_ENV_PROMPT//\\\\/\\\\\\\\}\"\n        _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\$/\\\\\\$}\"\n        _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\`/\\\\\\`}\"\n        if [ -n \"${ZSH_VERSION-}\" ]; then\n            _VIRTUAL_PROMPT=\"${_VIRTUAL_PROMPT//\\%/%%}\"\n        fi\n        PS1=\"(${_VIRTUAL_PROMPT}) ${PS1-}\"\n```\n\nFor 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.\n\n## Latest release\n\n- [x] The problem reproduces on the latest virtualenv release.","summary":"virtualenv's bash activation script writes the --prompt value into PS1 without escaping $ or backticks when promptvars is off, so the value is later expanded by bash. An attacker who controls the prompt value (for example from project metadata) gets command execution as the user who sources bin/activate.","severity":"high","cvss":7.1,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"virtualenv","fixed":["21.14.13"],"affected":">= 21.14.9, < 21.14.13","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["bash","ci-runners","cwe-78","package","pypi","python","virtualenv"],"not_affected_if":["default-prompt","fixed-version","no-untrusted-prompt-input","non-bash-shells-only"],"how_to_check":"Run: python -m virtualenv --version and check whether it is in 21.14.9 through 21.14.12\nSearch activation scripts for the unescaped pattern: grep -n 'VIRTUAL_PROMPT' <venv>/bin/activate\nCheck whether untrusted values reach --prompt or the prompt API argument in your tooling.","fix":"Upgrade virtualenv to 21.14.13 or later.\nUntil then, do not pass untrusted values to --prompt, or set VIRTUAL_ENV_DISABLE_PROMPT=1 before activating.","status":"open","published_at":"2026-10-23T20:17:21+00:00","first_seen_at":"2026-10-23T20:59:05+00:00","triaged_at":"2026-10-23T21:00:37+00:00","updated_at":"2026-10-23T21:57:34+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T21:00:37Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade virtualenv to 21.14.13 or later.\nUntil then, do not pass untrusted values to --prompt, or set VIRTUAL_ENV_DISABLE_PROMPT=1 before activating.","reach":"local","impact":"high","summary":"virtualenv's bash activation script writes the --prompt value into PS1 without escaping $ or backticks when promptvars is off, so the value is later expanded by bash. An attacker who controls the prompt value (for example from project metadata) gets command execution as the user who sources bin/activate.","category":"package","who_cares":["python","virtualenv","pypi","bash","ci-runners"],"confidence":0.8,"needs_auth":"no","worst_case":"Attacker runs shell commands as the user who activates the environment.","analyst_note":"","how_to_check":"Run: python -m virtualenv --version and check whether it is in 21.14.9 through 21.14.12\nSearch activation scripts for the unescaped pattern: grep -n 'VIRTUAL_PROMPT' <venv>/bin/activate\nCheck whether untrusted values reach --prompt or the prompt API argument in your tooling.","needs_analyst":false,"not_affected_if":["fixed-version","default-prompt","no-untrusted-prompt-input","non-bash-shells-only"],"user_interaction":"yes","needs_special_setup":"yes","severity_if_unknown":"high"},"cost_usd":0.000892,"input_tokens":6450,"output_tokens":493,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-115586':26A,34A '-160':361C '-2026':25A,33A '-78':41B '/pypa/vi':657C '/pypa/virtualenv/blob/21.14.12/src/virtualenv/activation/bash/activate.sh#l140-l160)':364C '/tmp/ve-pwn':342C,623C,631C,639C '1':477C '140':360C '2':545C '21.14.12':635C '21.14.8':197C,619C '21.14.9':23A,211C,627C '3418':219C,654C '4xcp':218C '5f48d711':645C '5vjq':563C '6w3r':216C '7h2q':565C 'activ':3A,49B,97C,113C,136C,240C,242C,349C,544C,566C,592C 'activate.bat':243C 'activate.csh':244C 'activate.fish':245C 'activate.nu':246C 'activate.ps1':247C 'activate.xsh':248C 'add':608C 'affect':238C 'afterward':122C 'alreadi':264C 'api':296C 'area':239C 'argument':293C 'arriv':312C 'assign':307C 'assum':116C 'attack':74B,178C,254C 'attacker-control':253C 'backslash':421C 'backtick':60B,201C,328C,597C 'bash':2A,36B,48B,72B,96C,158C,165C,225C,251C,373C,383C,426C,560C,598C,647C 'bin/activate':93B,192C,252C,300C 'bind':497C 'block':222C 'branch':611C 'caller':101C 'caller-suppli':100C 'child':157C 'ci':38B 'ci-runn':37B 'code':519C 'combin':476C 'come':271C 'command':86B,173C,185C,287C,568C 'commit':606C,644C 'config':334C 'constant':486C 'control':76B,180C,255C 'copi':320C 'correct':308C 'creat':346C 'cve':24A,32A 'cwe':40B 'cx6f':29A 'data':315C 'decod':387C,418C 'detail':352C 'direct':618C 'disabl':370C 'doubl':423C 'els':440C 'enabl':140C,511C 'env':305C,369C,437C,444C 'environ':351C,571C 'equival':601C 'escap':58B,198C,325C,390C,547C,594C,602C,625C 'exact':579C 'exampl':81B,329C 'execut':19A,87B,186C 'expand':70B,166C,384C,528C 'expans':17A,118C,142C,414C,513C,537C 'export':162C 'fi':463C,471C,472C 'fix':212C,541C,578C,646C 'forev':500C 'g3cc':31A 'get':85B,184C 'ghsa':28A,215C,562C 'ghsa-5vjq-rrrf-7h2q':561C 'ghsa-6w3r-xr62-4xcp':214C 'ghsa-cx6f-vjhg-g3cc':27A 'github.com':363C,656C 'github.com/pypa/vi':655C 'github.com/pypa/virtualenv/blob/21.14.12/src/virtualenv/activation/bash/activate.sh#l140-l160)':362C 'hold':402C 'id':341C,622C,630C,638C 'inert':205C,553C,626C 'inherit':160C 'input':256C 'interact':156C 'keep':550C 'later':18A,69B,348C 'latest':356C 'leav':4A 'left':224C 'line':288C,359C 'list':265C 'liter':303C,649C 'lost':599C 'make':164C 'may':270C 'metadata':84B,274C 'miss':558C 'mistak':475C 'mode':126C,398C,527C,556C 'moment':137C,492C 'n':372C,375C,425C,447C 'name':572C 'need':422C 'never':120C,411C 'o':151C,506C 'old':379C 'option':481C 'otherwis':415C 'packag':42B 'path':228C 'polici':263C 'posix':125C,152C,397C,433C,507C,524C,526C 'preserv':648C 'project':83B,273C 'prompt':6A,16A,53B,78B,103C,117C,141C,182C,258C,284C,292C,306C,337C,371C,389C,401C,438C,442C,445C,452C,454C,456C,458C,460C,462C,466C,470C,530C,576C,614C,650C 'promptvar':12A,62B,109C,123C,149C,235C,392C,430C,489C,503C,533C 'properti':133C 'ps1':10A,56B,106C,163C,323C,381C,382C,385C,435C,439C,464C,467C,512C,620C,628C,636C 'put':98C 'pypi':43B 'python':44B 'q':429C 'qo':432C,523C 'quot':309C 'raw':232C,633C,641C 're':139C,510C 're-en':138C,509C 'reach':281C,412C 'reason':584C 'refer':404C 'regardless':531C 'regress':21A,196C,617C 'releas':357C 'relev':643C 'render':613C 'repositori':331C 'rewrot':220C 'rrrf':564C 'run':121C,171C,419C,567C 'runner':39B 'runtim':128C,479C 'script':50B,241C,318C 'secur':262C 'separ':474C 'sequenc':209C 'set':150C,505C,589C 'sh':365C 'shape':581C 'shell':129C,146C,480C,499C,517C,653C 'shopt':147C,428C,431C,487C,501C,522C 'show':535C,615C 'sourc':92B,191C,353C,494C 'specif':249C 'src/virtualenv/activation/bash/activate.sh':358C 'start':154C 'state':130C,538C 'stay':204C 'store':168C,230C 'subst':577C 'substitut':174C 'summari':94C 'suppli':102C,335C 'support':652C 'test':521C 'time':114C 'tool':333C 'treat':483C 'two':473C 'u':488C 'uncondit':202C 'unescap':8A,107C 'unset':468C 'user':90B,189C,276C,587C 'valu':7A,54B,67B,79B,104C,169C,183C,233C,259C,268C,311C,410C,552C,634C,642C 'variabl':407C 'version':374C,377C,427C,449C 'victim':345C 'virtual':304C,368C,380C,436C,441C,443C,451C,453C,455C,457C,459C,461C,465C,469C 'virtualenv':1A,35A,45B,46B,282C 'vjhg':30A 'whenev':108C,234C 'without':57B,324C 'would':549C 'write':51B,279C 'written':298C 'x':340C,621C,629C,637C 'xr62':217C 'y':343C,624C,632C,640C 'z':367C 'zsh':376C,448C,574C,583C,610C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-23T20:17:21+00:00","kev":null,"cvss_vector":"CVSS:4.0/AV:L/AC:H/AT:P/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","cvss_by":"security-advisories@github.com","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-cx6f-vjhg-g3cc","url":"https://github.com/advisories/GHSA-cx6f-vjhg-g3cc","severity":"high","published_at":"2026-10-23T20:47:16+00:00","modified_at":"2026-10-23T20:47:17+00:00","first_seen_at":"2026-10-23T20:59:05+00:00","fetched_at":"2026-10-23T20:59:05+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-115586","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-115586","severity":null,"published_at":"2026-10-23T20:17:21+00:00","modified_at":"2026-10-23T21:17:22+00:00","first_seen_at":"2026-10-23T21:57:34+00:00","fetched_at":"2026-10-23T22:29:04+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}