Skip to content

Local port forward leaks the SSH channel if the client disconnects during channel open #823

Description

@AlexMKX

Summary

I found a reproducible leak in local port forwarding when the local TCP client disconnects while the SSH direct-tcpip channel open is still in progress.

The race is:

  1. SSHLocalForwarder._forward() is waiting in await self._coro(session_factory, *args).
  2. The local TCP transport closes before the channel-open completes, so SSHLocalForwarder.connection_lost() runs and close() sees no peer yet.
  3. The channel-open later succeeds, session_factory() creates the peer forwarder, and the SSH channel/session is started.
  4. The local transport is already gone and no further local connection_lost() will arrive, so the SSH channel remains open and the server-side session never gets connection_lost().

Minimal reproducer

"""Reproduce an AsyncSSH local port-forward channel leak.

A local TCP client disconnects while the SSH direct-tcpip channel open is
still waiting on a slow server-side session factory. The accepted SSH channels
remain open and their sessions don't receive connection_lost().
"""

import asyncio
import socket
import struct

import asyncssh


ITERATIONS = 10
active_channels = set()
lost_callbacks = 0


class CountingSession(asyncssh.SSHTCPSession):
    def connection_made(self, chan):
        self._chan = chan
        active_channels.add(chan)

    def connection_lost(self, exc):
        global lost_callbacks
        lost_callbacks += 1
        active_channels.discard(self._chan)


class SlowDirectTCPServer(asyncssh.SSHServer):
    def begin_auth(self, username):
        return False

    def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
        async def make_session():
            await asyncio.sleep(0.05)
            return CountingSession()

        return make_session()


async def reset_local_connection(port):
    sock = socket.create_connection(('127.0.0.1', port))
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
                    struct.pack('ii', 1, 0))
    sock.close()
    await asyncio.sleep(0)


async def main():
    server = await asyncssh.listen(
        '127.0.0.1', 0,
        server_factory=SlowDirectTCPServer,
        server_host_keys=[asyncssh.generate_private_key('ssh-rsa')])

    async with asyncssh.connect('127.0.0.1', server.get_port(),
                                username='user', known_hosts=None) as conn:
        listener = await conn.forward_local_port('', 0, 'example.invalid', 123)
        listen_port = listener.get_port()

        for _ in range(ITERATIONS):
            await reset_local_connection(listen_port)

        await asyncio.sleep(0.5)

        print(f'asyncssh_version={asyncssh.__version__}')
        print(f'iterations={ITERATIONS}')
        print(f'server_active_channels={len(active_channels)}')
        print(f'server_connection_lost_callbacks={lost_callbacks}')

        for chan in list(active_channels):
            chan.close()

        listener.close()
        await listener.wait_closed()

    server.close()
    await server.wait_closed()


if __name__ == '__main__':
    asyncio.run(main())

Observed output

Run against the clean PyPI release asyncssh==2.23.0:

asyncssh_version=2.23.0
iterations=10
server_active_channels=10
server_connection_lost_callbacks=0

The expected result would be that these channels are closed once the local side has already disconnected, and that the server-side sessions eventually receive connection_lost().

Environment

  • AsyncSSH: 2.23.0 from PyPI
  • Python: 3.14.4
  • OS: Linux 7.0.0-27-generic x86_64

Notes

This appears to be independent of any local modifications: the forwarding control flow around SSHLocalForwarder._forward(), SSHForwarder.close(), and SSHForwarder.set_peer() is unchanged in the relevant path in v2.23.0. I observed this on the clean PyPI release as shown above.

A possible fix direction may be to re-check whether the local transport is still present after await self._coro(...) completes in SSHLocalForwarder._forward() and close the newly-created peer/channel if the local side has already gone away, but of course you may prefer a different cleanup point.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions