diff --git a/app/src/main/java/chat/vojo/proxy/core/crypto/ShadowsocksStream.kt b/app/src/main/java/chat/vojo/proxy/core/crypto/ShadowsocksStream.kt index f69ae70..0bd7585 100644 --- a/app/src/main/java/chat/vojo/proxy/core/crypto/ShadowsocksStream.kt +++ b/app/src/main/java/chat/vojo/proxy/core/crypto/ShadowsocksStream.kt @@ -1,9 +1,9 @@ package chat.vojo.proxy.core.crypto import chat.vojo.proxy.core.net.readExact +import chat.vojo.proxy.core.net.readFully import chat.vojo.proxy.core.net.u16be import java.io.ByteArrayOutputStream -import java.io.EOFException import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -74,27 +74,40 @@ class SsInputStream( private var plainPos = 0 private fun fill(): Boolean { - try { - if (subkey == null) { - val salt = inp.readExact(aead.saltSize) - subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize) - } - val key = subkey!! - val encLen = inp.readExact(2 + aead.tagSize) - val lenPlain = aead.open(key, nonce, encLen) - ShadowsocksCrypto.incrementNonce(nonce) - val payloadLen = u16be(lenPlain[0].toInt(), lenPlain[1].toInt()) - if (payloadLen <= 0 || payloadLen > MAX_PAYLOAD) { - throw IOException("invalid shadowsocks chunk length $payloadLen") - } - val encPayload = inp.readExact(payloadLen + aead.tagSize) - plain = aead.open(key, nonce, encPayload) - ShadowsocksCrypto.incrementNonce(nonce) - plainPos = 0 - return true - } catch (e: EOFException) { - return false + if (subkey == null) { + // Clean EOF before any salt = the peer closed without sending anything. + val first = inp.read() + if (first < 0) return false + val salt = ByteArray(aead.saltSize) + salt[0] = first.toByte() + inp.readFully(salt, 1, salt.size - 1) + subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize) } + val key = subkey!! + // EOF *at a frame boundary* is a clean end of stream (-1). EOF *inside* a + // frame is truncation: once we've consumed any byte of the length header the + // payload must follow, so a short read there throws EOFException and propagates + // as an error — the relay then full-closes instead of signalling "complete". + val firstLen = inp.read() + if (firstLen < 0) return false + val encLen = ByteArray(2 + aead.tagSize) + encLen[0] = firstLen.toByte() + inp.readFully(encLen, 1, encLen.size - 1) + val lenPlain = aead.open(key, nonce, encLen) + ShadowsocksCrypto.incrementNonce(nonce) + val payloadLen = u16be(lenPlain[0].toInt(), lenPlain[1].toInt()) + // Accept payloadLen == 0 (a tag-only keep-alive / empty frame): shadowsocks-rust and + // sing-shadowsocks both accept it, so rejecting it would desync against a compliant + // peer. An empty record opens to an empty payload and read() simply fills the next + // frame. Only an over-MAX length is a real protocol violation. + if (payloadLen > MAX_PAYLOAD) { + throw IOException("invalid shadowsocks chunk length $payloadLen") + } + val encPayload = inp.readExact(payloadLen + aead.tagSize) + plain = aead.open(key, nonce, encPayload) + ShadowsocksCrypto.incrementNonce(nonce) + plainPos = 0 + return true } @Synchronized diff --git a/app/src/main/java/chat/vojo/proxy/core/upstream/ShadowsocksUpstream.kt b/app/src/main/java/chat/vojo/proxy/core/upstream/ShadowsocksUpstream.kt index dd3f191..87f74c5 100644 --- a/app/src/main/java/chat/vojo/proxy/core/upstream/ShadowsocksUpstream.kt +++ b/app/src/main/java/chat/vojo/proxy/core/upstream/ShadowsocksUpstream.kt @@ -53,7 +53,9 @@ private class ShadowsocksUdpAssociation( ) : UdpAssociation { private val server = InetSocketAddress(InetAddress.getByName(cfg.host), cfg.port) - private val udp = DatagramSocket().also { protector.protect(it) } + // Pinned to the SS server so the kernel drops datagrams from any other source. + // (AEAD already authenticates each datagram; this just sheds spoofed noise early.) + private val udp = DatagramSocket().also { protector.protect(it); runCatching { it.connect(server) } } override fun send(dest: Destination, data: ByteArray, off: Int, len: Int) { val plain = ByteArray(dest.raw.size + len)