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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions