diff --git a/app/src/main/java/chat/vojo/proxy/core/upstream/Socks5Upstream.kt b/app/src/main/java/chat/vojo/proxy/core/upstream/Socks5Upstream.kt index ccd4dcc..0879afc 100644 --- a/app/src/main/java/chat/vojo/proxy/core/upstream/Socks5Upstream.kt +++ b/app/src/main/java/chat/vojo/proxy/core/upstream/Socks5Upstream.kt @@ -21,6 +21,9 @@ class Socks5Upstream( private val protector: SocketProtector, ) : Upstream { + // RFC 1929 username/password fields are each 1–255 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() override fun connectTcp(dest: Destination): UpstreamConn { @@ -81,11 +84,16 @@ class Socks5Upstream( out.write(msg) out.flush() 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") } private fun readReply(inp: InputStream) { 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}") // consume BND.ADDR + BND.PORT 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.flush() 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}") val addr: InetAddress when (head[3].toInt() and 0xff) { @@ -145,9 +154,21 @@ private class Socks5UdpAssociation( } val portBytes = inp.readExact(2) 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. - val target = if (addr.isAnyLocalAddress) InetAddress.getByName(cfg.host) else addr + // The relay BND.ADDR is frequently unusable: 0.0.0.0 ("send to the host + // 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) + // 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 watchControl() } catch (e: Throwable) { @@ -204,3 +225,19 @@ private class Socks5UdpAssociation( 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 + } +}