Skip to content

hyper-util SOCKS5: "failed parsing server response" when the CONNECT reply arrives in multiple TCP segments (partial parse consumes the buffer) #4127

Description

@asdasd070511

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
  1. 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_fromremaining() == 1 < 2Incomplete
  2. read_message reads segment 2 and appends → buf = 01 68 1a 0c cd 01 bb (VER/REP/RSV are gone)
  3. Second parse: VER get_u8()0x010x05ParsingError::Other
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions