fix(socks5): rewrite unroutable UDP relay to proxy host, connect() socket anti-spoof, validate reply versions

This commit is contained in:
heaven 2026-06-16 12:30:53 +03:00
parent 0a01bbe3f6
commit 6b75c65503

View file

@ -21,6 +21,9 @@ class Socks5Upstream(
private val protector: SocketProtector, private val protector: SocketProtector,
) : Upstream { ) : Upstream {
// RFC 1929 username/password fields are each 1255 bytes, so user/pass auth
// needs a username — a password-only config can't form a valid request (it would
// send ULEN=0). Gate auth on the username; a strict server would reject otherwise.
private val auth: Boolean get() = cfg.username.isNotEmpty() private val auth: Boolean get() = cfg.username.isNotEmpty()
override fun connectTcp(dest: Destination): UpstreamConn { override fun connectTcp(dest: Destination): UpstreamConn {
@ -81,11 +84,16 @@ class Socks5Upstream(
out.write(msg) out.write(msg)
out.flush() out.flush()
val res = inp.readExact(2) val res = inp.readExact(2)
// RFC 1929 reply is [VER=1][STATUS]. Validate the version too: a misframed
// reply whose 2nd byte happens to be 0x00 must not be read as auth success
// and then corrupt the following CONNECT exchange.
if (res[0].toInt() != 0x01) throw IOException("bad socks5 auth version ${res[0].toInt() and 0xff}")
if (res[1].toInt() != 0x00) throw IOException("socks5 auth failed") if (res[1].toInt() != 0x00) throw IOException("socks5 auth failed")
} }
private fun readReply(inp: InputStream) { private fun readReply(inp: InputStream) {
val head = inp.readExact(4) // VER, REP, RSV, ATYP val head = inp.readExact(4) // VER, REP, RSV, ATYP
if (head[0].toInt() != 0x05) throw IOException("bad socks5 reply version ${head[0].toInt() and 0xff}")
if (head[1].toInt() != 0x00) throw IOException("socks5 connect failed, rep=${head[1].toInt() and 0xff}") if (head[1].toInt() != 0x00) throw IOException("socks5 connect failed, rep=${head[1].toInt() and 0xff}")
// consume BND.ADDR + BND.PORT // consume BND.ADDR + BND.PORT
when (head[3].toInt() and 0xff) { when (head[3].toInt() and 0xff) {
@ -131,6 +139,7 @@ private class Socks5UdpAssociation(
out.write(byteArrayOf(0x05, 0x03, 0x00, ATYP_IPV4.toByte(), 0, 0, 0, 0, 0, 0)) out.write(byteArrayOf(0x05, 0x03, 0x00, ATYP_IPV4.toByte(), 0, 0, 0, 0, 0, 0))
out.flush() out.flush()
val head = inp.readExact(4) val head = inp.readExact(4)
if (head[0].toInt() != 0x05) throw IOException("bad socks5 reply version ${head[0].toInt() and 0xff}")
if (head[1].toInt() != 0x00) throw IOException("udp associate failed rep=${head[1].toInt() and 0xff}") if (head[1].toInt() != 0x00) throw IOException("udp associate failed rep=${head[1].toInt() and 0xff}")
val addr: InetAddress val addr: InetAddress
when (head[3].toInt() and 0xff) { when (head[3].toInt() and 0xff) {
@ -145,9 +154,21 @@ private class Socks5UdpAssociation(
} }
val portBytes = inp.readExact(2) val portBytes = inp.readExact(2)
val port = ((portBytes[0].toInt() and 0xff) shl 8) or (portBytes[1].toInt() and 0xff) val port = ((portBytes[0].toInt() and 0xff) shl 8) or (portBytes[1].toInt() and 0xff)
// Some proxies return 0.0.0.0 — fall back to the proxy host. // The relay BND.ADDR is frequently unusable: 0.0.0.0 ("send to the host
val target = if (addr.isAnyLocalAddress) InetAddress.getByName(cfg.host) else addr // you connected to") or an internal/private address only routable inside
// the proxy's own network (this matters in practice — e.g. a proxy that
// returns 10.x). In every such case the relay actually lives at the proxy
// host we already reached over the control channel, so use that and keep
// only the advertised port. Without this, UDP/DNS silently black-holes.
val target = if (addr.isUnroutableRelay()) InetAddress.getByName(cfg.host) else addr
relay = InetSocketAddress(target, port) relay = InetSocketAddress(target, port)
// Pin the UDP socket to the relay endpoint. RFC 1928 §7 requires dropping any
// datagram whose source isn't the association's relay; a kernel connect()
// enforces that on the (IP, port) 2-tuple for free, and the ephemeral local-port
// binding is the off-path DNS-spoofing defence. This is what mainstream clients
// (sing-box, v2ray) do — a compliant relay always answers from BND.PORT (here the
// post-rewrite endpoint), so connecting never black-holes a legitimate reply.
runCatching { udp.connect(relay) }
control.soTimeout = 0 control.soTimeout = 0
watchControl() watchControl()
} catch (e: Throwable) { } catch (e: Throwable) {
@ -204,3 +225,19 @@ private class Socks5UdpAssociation(
runCatching { control.close() } runCatching { control.close() }
} }
} }
/**
* True when a SOCKS5 UDP-associate BND.ADDR can't be reached by this client and the
* relay should instead be addressed at the proxy host: the wildcard (0.0.0.0/::),
* loopback, link-local, or a private/internal address the proxy advertised from inside
* its own network RFC1918 + fec0::/10 (isSiteLocalAddress), CGNAT 100.64/10, ULA fc00::/7.
*/
private fun InetAddress.isUnroutableRelay(): Boolean {
if (isAnyLocalAddress || isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress) return true
val a = address
return when (a.size) {
4 -> (a[0].toInt() and 0xff) == 100 && (a[1].toInt() and 0xff) in 64..127 // CGNAT 100.64/10
16 -> (a[0].toInt() and 0xfe) == 0xfc // ULA fc00::/7
else -> false
}
}