fix(shadowsocks): treat frame-boundary EOF as clean end; accept empty AEAD chunks like canon

This commit is contained in:
heaven 2026-06-16 12:30:55 +03:00
parent f303cf2892
commit 0a01bbe3f6
2 changed files with 37 additions and 22 deletions

View file

@ -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

View file

@ -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)