Summary
SocksV5 fails with SOCKS error: failed parsing server response against a fully RFC 1928-compliant SOCKS5 proxy, whenever the proxy happens to send its CONNECT reply across more than one TCP segment.
The cause is that the TryFrom<&mut BytesMut> message parsers consume bytes out of the buffer while returning ParsingError::Incomplete. read_message() treats Incomplete as "read more and retry", but the retry then re-parses a buffer whose first bytes have already been eaten — so the second attempt reads the wrong field at the wrong offset and fails with ParsingError::Other.
This is the same underlying flaw as #210 / seanmonstar/reqwest#2755. That fix (remaining() < 2 → < 3) stopped the panic, but the destructive partial parse is still there, and it now surfaces as a spurious parse error instead.
Root cause
ProxyRes::try_from (src/client/legacy/connect/proxy/socks/v5/messages.rs:174):
fn try_from(buf: &mut BytesMut) -> Result<Self, ParsingError> {
if buf.remaining() < 3 {
return Err(ParsingError::Incomplete);
}
if buf.get_u8() != 0x05 { // <-- advances the cursor
return Err(ParsingError::Other);
}
let status = buf.get_u8().try_into()?; // <-- advances
if buf.get_u8() != 0x00 { // <-- advances
return Err(ParsingError::Other);
}
Address::try_from(buf)?; // <-- may return Incomplete, after 3 bytes are already gone
Ok(Self(status))
}
The guard only ensures 3 bytes are present, but a ATYP=0x01 reply is 10 bytes. If fewer than 10 have arrived, Address::try_from correctly returns Incomplete — but VER/REP/RSV have already been consumed from the BytesMut.
read_message() (socks/mod.rs:44) then does the right thing in isolation:
loop {
let n = crate::rt::read(&mut conn, &mut tmp).await?;
buf.extend_from_slice(&tmp[..n]);
match M::try_from(buf) {
Err(ParsingError::Incomplete) => { /* keep reading */ }
Err(err) => return Err(err.into()),
Ok(res) => return Ok(res),
}
}
…but the retry is operating on a corrupted buffer, so it can never succeed.
Byte-level trace (real proxy, gw.dataimpulse.com:10000)
The proxy is compliant. It sends the 10-byte CONNECT reply in two segments (verified with a raw socket — 4 bytes, then the remaining 6 bytes ~1 ms later):
seg 1: 05 00 00 01
seg 2: 68 1a 0c cd 01 bb
- First parse,
buf = 05 00 00 01:
remaining() == 4 >= 3 → proceed
- VER
get_u8() → 0x05 ✓ (buf now 00 00 01)
- REP
get_u8() → 0x00 ✓ (buf now 00 01)
- RSV
get_u8() → 0x00 ✓ (buf now 01)
Address::try_from → remaining() == 1 < 2 → Incomplete
read_message reads segment 2 and appends → buf = 01 68 1a 0c cd 01 bb (VER/REP/RSV are gone)
- Second parse: VER
get_u8() → 0x01 ≠ 0x05 → ParsingError::Other
- →
SocksError::Parsing → "failed parsing server response"
Reproducer
A SOCKS5 server that is byte-for-byte identical except for how it flushes the reply. Splitting the reply is legal — TCP is a stream, not a message boundary.
# socks5_split.py — minimal SOCKS5 relay, no auth.
# port 1080 => CONNECT reply written as 4 bytes + 6 bytes
# port 1081 => CONNECT reply written as a single 10-byte write
import socket, threading, time
def relay(a, b):
try:
while True:
d = a.recv(65536)
if not d: break
b.sendall(d)
except Exception: pass
finally:
a.close(); b.close()
def handle(c, split):
c.recv(262) # greeting
c.sendall(b"\x05\x00") # no auth
h = c.recv(4) # VER CMD RSV ATYP
if h[3] == 3:
host = c.recv(c.recv(1)[0]).decode()
else:
host = socket.inet_ntoa(c.recv(4))
port = int.from_bytes(c.recv(2), "big")
up = socket.create_connection((host, port), 10)
reply = b"\x05\x00\x00\x01" + socket.inet_aton("127.0.0.1") + b"\x00\x00"
if split:
c.sendall(reply[:4]); time.sleep(0.005); c.sendall(reply[4:])
else:
c.sendall(reply)
threading.Thread(target=relay, args=(c, up), daemon=True).start()
relay(up, c)
def serve(port, split):
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port)); s.listen(16)
while True:
c, _ = s.accept()
threading.Thread(target=handle, args=(c, split), daemon=True).start()
threading.Thread(target=serve, args=(1080, True), daemon=True).start()
threading.Thread(target=serve, args=(1081, False), daemon=True).start()
time.sleep(300)
Client (reqwest 0.13.4 → hyper-util 0.1.20, socks feature):
for (label, proxy) in [
("whole 10-byte reply", "socks5://127.0.0.1:1081"),
("reply split 4 + 6", "socks5://127.0.0.1:1080"),
] {
let client = reqwest::Client::builder()
.no_proxy()
.proxy(reqwest::Proxy::all(proxy).unwrap())
.build()
.unwrap();
println!("{label}: {:?}", client.get("https://api.ipify.org").send().await.map(|r| r.status()));
}
Result:
whole 10-byte reply: Ok(200)
reply split 4 + 6: Err(error sending request)
└─ client error (Connect)
└─ error connecting to socks proxy
└─ SOCKS error: failed parsing server response
Impact
This is not a theoretical edge case. DataImpulse (a large residential proxy provider) splits the reply this way on every connection, so every SOCKS5 request through it fails 100% of the time, while curl --socks5 and any client that loops until it has enough bytes work fine against the same endpoint. The failure is indistinguishable from a bad proxy/credentials, which makes it very hard to diagnose from the outside.
Suggested fix
Make parsing non-destructive: decide on a complete message before mutating the buffer. Either
- parse from a non-consuming view (e.g.
let mut cur = &buf[..]; / Buf::chunk()), and only buf.advance(consumed) once a full message has been parsed; or
- keep the
TryFrom<&mut BytesMut> shape but have read_message hand the parser a clone of the cursor and only advance the real buffer on Ok.
The same hazard applies to every parser reached from read_message (NegRes, AuthRes, v4's ProxyRes) — they all get_u8() before knowing the message is complete, so any of them can corrupt the buffer on a short read. A length check alone (like #210's < 2 → < 3) can't fix this in general, because for ATYP=0x03 the total length isn't known until the domain-length byte has been read.
Happy to send a PR if you'd like.
Versions
- hyper-util 0.1.20 (latest)
- reqwest 0.13.4, features:
native-tls, json, stream, socks, charset, http2, system-proxy
- Windows 11, Rust stable
Summary
SocksV5fails withSOCKS error: failed parsing server responseagainst a fully RFC 1928-compliant SOCKS5 proxy, whenever the proxy happens to send its CONNECT reply across more than one TCP segment.The cause is that the
TryFrom<&mut BytesMut>message parsers consume bytes out of the buffer while returningParsingError::Incomplete.read_message()treatsIncompleteas "read more and retry", but the retry then re-parses a buffer whose first bytes have already been eaten — so the second attempt reads the wrong field at the wrong offset and fails withParsingError::Other.This is the same underlying flaw as #210 / seanmonstar/reqwest#2755. That fix (
remaining() < 2→< 3) stopped the panic, but the destructive partial parse is still there, and it now surfaces as a spurious parse error instead.Root cause
ProxyRes::try_from(src/client/legacy/connect/proxy/socks/v5/messages.rs:174):The guard only ensures 3 bytes are present, but a
ATYP=0x01reply is 10 bytes. If fewer than 10 have arrived,Address::try_fromcorrectly returnsIncomplete— but VER/REP/RSV have already been consumed from theBytesMut.read_message()(socks/mod.rs:44) then does the right thing in isolation:…but the retry is operating on a corrupted buffer, so it can never succeed.
Byte-level trace (real proxy, gw.dataimpulse.com:10000)
The proxy is compliant. It sends the 10-byte CONNECT reply in two segments (verified with a raw socket — 4 bytes, then the remaining 6 bytes ~1 ms later):
buf = 05 00 00 01:remaining() == 4 >= 3→ proceedget_u8()→0x05✓ (buf now00 00 01)get_u8()→0x00✓ (buf now00 01)get_u8()→0x00✓ (buf now01)Address::try_from→remaining() == 1 < 2→Incompleteread_messagereads segment 2 and appends →buf = 01 68 1a 0c cd 01 bb(VER/REP/RSV are gone)get_u8()→0x01≠0x05→ParsingError::OtherSocksError::Parsing→"failed parsing server response"Reproducer
A SOCKS5 server that is byte-for-byte identical except for how it flushes the reply. Splitting the reply is legal — TCP is a stream, not a message boundary.
Client (reqwest 0.13.4 → hyper-util 0.1.20,
socksfeature):Result:
Impact
This is not a theoretical edge case. DataImpulse (a large residential proxy provider) splits the reply this way on every connection, so every SOCKS5 request through it fails 100% of the time, while
curl --socks5and any client that loops until it has enough bytes work fine against the same endpoint. The failure is indistinguishable from a bad proxy/credentials, which makes it very hard to diagnose from the outside.Suggested fix
Make parsing non-destructive: decide on a complete message before mutating the buffer. Either
let mut cur = &buf[..];/Buf::chunk()), and onlybuf.advance(consumed)once a full message has been parsed; orTryFrom<&mut BytesMut>shape but haveread_messagehand the parser a clone of the cursor and only advance the real buffer onOk.The same hazard applies to every parser reached from
read_message(NegRes,AuthRes, v4'sProxyRes) — they allget_u8()before knowing the message is complete, so any of them can corrupt the buffer on a short read. A length check alone (like #210's< 2→< 3) can't fix this in general, because forATYP=0x03the total length isn't known until the domain-length byte has been read.Happy to send a PR if you'd like.
Versions
native-tls, json, stream, socks, charset, http2, system-proxy