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 package chat.vojo.proxy.core.crypto
import chat.vojo.proxy.core.net.readExact import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.net.readFully
import chat.vojo.proxy.core.net.u16be import chat.vojo.proxy.core.net.u16be
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@ -74,27 +74,40 @@ class SsInputStream(
private var plainPos = 0 private var plainPos = 0
private fun fill(): Boolean { private fun fill(): Boolean {
try { if (subkey == null) {
if (subkey == null) { // Clean EOF before any salt = the peer closed without sending anything.
val salt = inp.readExact(aead.saltSize) val first = inp.read()
subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize) if (first < 0) return false
} val salt = ByteArray(aead.saltSize)
val key = subkey!! salt[0] = first.toByte()
val encLen = inp.readExact(2 + aead.tagSize) inp.readFully(salt, 1, salt.size - 1)
val lenPlain = aead.open(key, nonce, encLen) subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize)
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
} }
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 @Synchronized

View file

@ -53,7 +53,9 @@ private class ShadowsocksUdpAssociation(
) : UdpAssociation { ) : UdpAssociation {
private val server = InetSocketAddress(InetAddress.getByName(cfg.host), cfg.port) 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) { override fun send(dest: Destination, data: ByteArray, off: Int, len: Int) {
val plain = ByteArray(dest.raw.size + len) val plain = ByteArray(dest.raw.size + len)