Résumé

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

Détails de l’avis

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-453

Références