Skip to content

ssh does not fall back from IPv6 to IPv4 on a refused connect #2441

Description

@steffen-heil-secforge

Summary

On Windows, ssh to a host that resolves to more than one address does not try the remaining addresses when the first connect() is refused. For a dual-stack host whose service answers only on IPv4, ssh tries the AAAA (IPv6) address, the connect is refused, and instead of falling back to the A (IPv4) address it aborts with:

banner exchange: Connection to UNKNOWN port -1: Connection refused

The identical ssh invocation — same config, same host — works on Linux/WSL, where it falls back to IPv4.

Version

OpenSSH_for_Windows_10.0p2 (Win32-OpenSSH); reproduced against current latestw_all. Affects earlier releases too.

Repro

Any host that resolves to ≥2 addresses where the first-tried address refuses the port; easiest is a dual-stack host (A + AAAA) whose sshd / port-forward listens on IPv4 only:

ssh -vvv user@dual-stack-host

Windows (fails — stops at the IPv6 address, never tries IPv4):

debug1: Connecting to host [2a01:db8::1] port 64724.
debug1: Connection established.            <-- the connect was actually refused
debug3: socketio_getpeername - ERROR:10057
debug1: getpeername failed: The socket is not connected
debug1: kex_exchange_identification: write: Connection refused
banner exchange: Connection to UNKNOWN port -1: Connection refused

Linux/WSL (works — detects the refusal and falls back):

debug1: Connecting to host [2a01:db8::1] port 64724.
debug1: connect to address 2a01:db8::1 port 64724: Connection refused
debug1: Connecting to host [198.51.100.10] port 64724.
debug1: Connection established.

Workaround: ssh -4 host (or AddressFamily inet).

Cause

ssh_connect_direct() (sshconnect.c) iterates the getaddrinfo() results and uses timeout_connect() (misc.c): non-blocking connect()poll(POLLOUT)getsockopt(SO_ERROR); a non-zero SO_ERROR means "this address failed, try the next one".

On Windows the asynchronous ConnectEx() failure is delivered through the overlapped completion and captured by socketio_finish_connect() / socketio_is_io_available() into the w32_io write_details.error / read_details.error. Once that completion is consumed, the underlying socket's SO_ERROR is left at 0. But socketio_getsockopt() (contrib/win32/win32compat/socketio.c) forwards SO_ERROR straight to the Winsock getsockopt(), which therefore returns 0 — so timeout_connect() sees optval == 0, treats the refused connect as a success, and the address loop stops at the first (failed) address. getpeername() on the unconnected socket then yields UNKNOWN / -1, and the first banner write returns the original WSAECONNREFUSED (10061).

The same getsockopt(SO_ERROR) idiom is used for forwarded-channel connects in channels.c, which is affected identically.

Fix

Surface the captured async-connect error for SO_ERROR in socketio_getsockopt() (return the stored write_details.error / read_details.error, mapped through errno_from_WSAError()), so the standard non-blocking connect() + getsockopt(SO_ERROR) idiom — and thus the multi-address (IPv6 → IPv4) fallback — behaves as it does on POSIX.

Implemented, built, and verified on Windows x64: the failing repro above now detects the refused IPv6 connect and falls back to IPv4, matching Linux. Includes a win32compat regression test. PR to follow.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions