Skip to content

DoS: T_RENDER read_full blocks indefinitely on partial/length-mismatched payload (single-client daemon hangs) #5

Description

@jasonmiao0511-hue

Summary

fd-daemon (the resident POV-Ray renderer) can be hung by a single malicious or buggy client that opens the wire-protocol socket, sends a T_RENDER header with the maximum advertised payload length, and then never finishes (or never starts) sending the body. The daemon blocks in read_full on the accepted socket with no per-message or per-socket timeout, and since PROTOCOL.md declares "one client at a time", this takes the entire resident render service down for as long as the client holds the connection.

Affected code

daemon/fd-daemon.cpp line 217-221 (the serve_client loop, T_RENDER branch) and the read primitive at lines 109-118:

// serve_client() — case T_RENDER:
payload.resize(len);
if (len && !read_full(cfd, payload.data(), len)) return true;   // <-- blocks here
// ...then validate length / parse declares / render
// read_full(): no timeout
static bool read_full(fd_sock_t fd, void* buf, size_t n) {
    unsigned char* p = (unsigned char*)buf;
    while (n) {
        long r = (long)fd_sock_read(fd, p, n);
        if (r <= 0) return false;          // EOF or error => drop client
        p += r; n -= (size_t)r;
    }
    return true;
}

The upper bound on the RENDER payload is the constant MAX_RENDER = 4096 (line 64). A client that sends a valid 8-byte header with len=4096 and then either (a) holds the connection open without writing payload bytes, or (b) writes only the first N < 4096 bytes, blocks the daemon indefinitely. fd_sock_read is a plain read() / recv() and the listening side never sets SO_RCVTIMEO (see daemon/fd_listen.h).

This violates the spirit of PROTOCOL.md's "Contract rules" section — "Short read on the socket = wait, never render a half-applied scene" — which describes the correctness intent (don't render on partial data) but does not specify a bound on "wait". A single attacker on a local socket path or the 127.0.0.1:47999 TCP loopback endpoint (Windows) can stall the service for arbitrary time.

Reproduction (POSIX / Linux)

# 1. Start the daemon (default socket path on POSIX).
./fd-daemon /tmp/feverdream.sock &

# 2. Connect a raw socket and send a T_RENDER header advertising the
#    maximum payload length, but never send the body. Hold the
#    connection open so the kernel never sees EOF.
python3 - <<'PY'
import socket, sys, time
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/tmp/feverdream.sock")
# magic=0xFD, version=0x00, type=0x02 (T_RENDER), flags=0x00,
# length=0x00001000 (4096) little-endian
s.sendall(bytes([0xFD, 0x00, 0x02, 0x00, 0x00, 0x10, 0x00, 0x00]))
sys.stdout.write("connected, holding open; check daemon in another shell
"); sys.stdout.flush()
time.sleep(600)
PY

In another shell:

# 3. Observe the daemon is stuck serving this one client.
#    A second T_RENDER / SCENE_FULL from a legitimate client will not
#    progress; the accept loop at line 372 only runs after
#    serve_client() returns.
ps -o pid,stat,wchan:32 -p $(pgrep fd-daemon)
# Expect: STAT = S (sleeping in a read)

On Windows the same shape works against the TCP loopback listener (127.0.0.1:47999 by default; see fd_listen.h FD_DEFAULT_PORT).

Expected behaviour

Per PROTOCOL.md the daemon should enforce a per-message receive bound so a stuck peer cannot block the single-client service. Acceptable forms (any one of these):

  1. Set SO_RCVTIMEO on the accepted client socket and on the listening socket, with a small value (a few seconds) and drop the client on timeout — matching the stock POV-Ray server-side input model.
  2. Reject T_RENDER whose len is not exactly 10 + sum(1 + namelen + 4) after the 10-byte fixed header is parsed, before reading the rest of the payload. This requires reading the first 10 bytes, computing the expected length, then validating len == expected and short-circuiting with E_BAD_PAYLOAD otherwise.
  3. Use a non-blocking socket with select() / poll() and a timeout, so the daemon's outer while (run) loop in main() can service a watchdog PING while a client is misbehaving (per the "Health" contract rule in PROTOCOL.md).

Environment

  • Source: Scottcjn/feverdream-engine main (393-line daemon/fd-daemon.cpp).
  • Tested against the code, not a running daemon: the bug is static (a missing timeout / a missing length-validation pass).
  • POSIX path (/tmp/feverdream.sock) and Windows TCP loopback (127.0.0.1:47999) are both affected.
  • Python 3 reproducer above uses only the standard library.

Severity

Availability / DoS. The protocol and listener are local-only, so the attacker model is "any local user able to open the socket" — which on POSIX is gated by the 0600 perms on the AF_UNIX file (good), and on Windows is "any local user on the host" because the loopback TCP port is reachable from any process (this is explicitly noted in fd_listen.h as the trust model). The bug is reproducible from a script in under 30 lines and leaves no trace on the wire.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions