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):
- 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.
- 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.
- 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.
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 aT_RENDERheader with the maximum advertised payload length, and then never finishes (or never starts) sending the body. The daemon blocks inread_fullon the accepted socket with no per-message or per-socket timeout, and sincePROTOCOL.mddeclares "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.cppline 217-221 (theserve_clientloop,T_RENDERbranch) and the read primitive at lines 109-118:The upper bound on the RENDER payload is the constant
MAX_RENDER = 4096(line 64). A client that sends a valid 8-byte header withlen=4096and 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_readis a plainread()/recv()and the listening side never setsSO_RCVTIMEO(seedaemon/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)
In another shell:
On Windows the same shape works against the TCP loopback listener (
127.0.0.1:47999by default; seefd_listen.hFD_DEFAULT_PORT).Expected behaviour
Per
PROTOCOL.mdthe daemon should enforce a per-message receive bound so a stuck peer cannot block the single-client service. Acceptable forms (any one of these):SO_RCVTIMEOon 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.T_RENDERwhoselenis not exactly10 + 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 validatinglen == expectedand short-circuiting withE_BAD_PAYLOADotherwise.select()/poll()and a timeout, so the daemon's outerwhile (run)loop inmain()can service a watchdog PING while a client is misbehaving (per the "Health" contract rule in PROTOCOL.md).Environment
Scottcjn/feverdream-enginemain(393-linedaemon/fd-daemon.cpp)./tmp/feverdream.sock) and Windows TCP loopback (127.0.0.1:47999) are both affected.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.has the trust model). The bug is reproducible from a script in under 30 lines and leaves no trace on the wire.