Skip to content

fix: SOCKS5 dynamic forwarding rejects IPv6 destination literals (ATYP 0x04) #256

Description

@inureyes

Companion to #255. Both issues are gaps in the SOCKS handlers left behind by the address family work in #246 and #248.

Problem / Background

bssh -D runs a local SOCKS proxy. The SOCKS5 request parser implements address type 0x01 (IPv4 literal) and 0x03 (domain name), but not 0x04 (IPv6 literal). A SOCKS5 client that sends an IPv6 destination gets back reply code 0x08 (address type not supported) and the connection is refused.

This is a plain functional gap on its own. It also directly contradicts the IPv6 support shipped in v2.4.0: bssh -6 -D 1080 is self-contradictory today, because the user has explicitly forced IPv6 and the SOCKS5 handler then refuses every IPv6 destination literal.

Technical Analysis

The 0x04 branch is a stub

src/forwarding/dynamic/socks.rs, handle_socks5_connection (starts at line 109). The destination is parsed by a match address_type block starting at line 172.

0x01, lines 173 to 183, reads 4 address bytes into std::net::Ipv4Addr, then 2 port bytes, and builds format!("{ip}:{port}").

0x03, lines 184 to 199, reads a 1-byte length, that many domain bytes, then 2 port bytes, and builds format!("{domain}:{port}").

0x04, lines 200 to 205, reads nothing:

0x04 => {
    // IPv6 address: 16 bytes + 2 bytes port (not fully implemented)
    let response = [5, 0x08, 0, 1, 0, 0, 0, 0, 0, 0]; // Address type not supported
    tcp_stream.write_all(&response).await?;
    return Err(anyhow::anyhow!("IPv6 address type not yet supported"));
}

It is identical to the _ catch-all arm below it, except for the error message.

The 18 unconsumed bytes

The 0x04 arm returns without consuming the 16 address bytes and 2 port bytes of the request body. In practice this is not a stream desync, because the handler returns Err immediately and the connection is torn down. It becomes a real bug the moment anyone changes this branch to keep the stream alive, so the fix should consume the bytes as part of parsing rather than relying on teardown.

Bracketing is required for the destination string

The parsed destination string is handed to open_direct_tcpip_channel_with_family(destination.as_str(), None, address_family) at line 219, which resolves it and filters the candidates by the forced family. An IPv6 literal must be bracketed to round-trip through to_socket_addrs, so the 0x04 arm must build format!("[{ip}]:{port}"), not format!("{ip}:{port}").

This is the same rule already established elsewhere in the codebase. #251 added bracketed IPv6 literal handling for -H, and src/node.rs lines 28 to 32 document why: an unbracketed trailing numeric segment is indistinguishable from a port.

Documentation currently overstates coverage

Checked, and neither docs/man/bssh.1 nor the -D help text in src/cli/bssh.rs line 328 mentions an IPv6 limitation for SOCKS5. The man page (around line 1186) says bssh resolves the target of a SOCKS5 -D request and filters the candidate list by address family, with no caveat. That description is accurate only for ATYP 0x01 and 0x03 today, so the docs silently overstate what works rather than documenting the gap.

Impact

A SOCKS5 client that resolves the hostname itself and sends the resulting address, which is common (for example curl --socks5 rather than curl --socks5-hostname), cannot reach any IPv6-only destination through a bssh SOCKS proxy.

The failure is confusing to diagnose because the 0x03 domain path is unaffected. The same destination reaches through by name and is refused by literal, and the client reports a generic SOCKS failure either way.

bssh -6 -D 1080 is the worst case: the user has forced IPv6 on every other path in the invocation, and the SOCKS5 handler is the one place that rejects IPv6 outright.

Proposed Solution

Implement the 0x04 arm symmetrically with 0x01: read 16 address bytes into std::net::Ipv6Addr, read the 2 port bytes, and build a bracketed format!("[{ip}]:{port}") destination. It then flows through the existing family-aware channel opener with no further change, and a forced -4 will fail closed through Error::NoAddressForFamily the same way every other path does.

Separately, confirm the reply shape. Every response in this function hardcodes an IPv4 BND.ADDR, [5, rep, 0, 1, 0, 0, 0, 0, 0, 0]. Per RFC 1928 a reply carrying ATYP 0x01 with an all-zero address is accepted by essentially all clients, so keeping the current shape for IPv6 requests is defensible. It should be a deliberate, documented decision rather than an accident of the IPv4-only implementation.

Note that src/forwarding/dynamic/socks.rs has no test module today, so this work adds the first coverage for the request parser.

Acceptance Criteria

  • ATYP 0x04 consumes 16 address bytes plus 2 port bytes and produces a bracketed destination string of the form [2001:db8::1]:443.
  • A SOCKS5 CONNECT to an IPv6 literal succeeds end to end through the tunnel.
  • Behavior under forced -4 with an IPv6 literal destination is defined and tested: it fails closed via the existing Error::NoAddressForFamily path and returns a well-formed SOCKS5 failure reply, rather than silently connecting or dropping the connection.
  • ATYP 0x01 and 0x03 behavior is unchanged, covered by regression tests.
  • The SOCKS5 reply shape returned for an IPv6 request is verified against a real client, and the choice to keep an IPv4-shaped BND.ADDR (or not) is recorded in a code comment.
  • docs/man/bssh.1 and the -D help text in src/cli/bssh.rs describe SOCKS5 destination address type support accurately once the gap is closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions