Actions
Bug #8824
open
SB
OD
unix-socket: unbounded blocking call to accept new client hangs Unix manager thread
Bug #8824:
unix-socket: unbounded blocking call to accept new client hangs Unix manager thread
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## Summary
When the Unix command socket accepts a new client, it immediately performs a blocking `recv()` to read the version handshake before the descriptor has been put into non-blocking mode or guarded by `select()`. A local client that simply `connect()`s to the socket and never sends a single byte causes this `recv()` to block forever, freezing the sole Unix manager thread. While the thread is wedged, no other clients are serviced, registered background tasks (including the pcap-driver task in unix-socket runmode) stop running, and the thread's shutdown check is never reached. The result is a local, low-privilege denial of service of Suricata's control plane.
## Affected Piece of Code
- **File:** `src/unix-manager.c`
- **Function / Location:** `UnixCommandAccept()` ~L344-367
- **Subsystem:** unix-runmodes — Unix socket command server, runmodes, main, privilege drop, landlock
```c
342 /* accept client socket */
343 socklen_t len = sizeof(this->client_addr);
344 client = accept(this->socket, (struct sockaddr *) &this->client_addr,
345 &len);
346 if (client < 0) {
347 SCLogInfo("Unix socket: accept() error: %s",
348 strerror(errno));
349 return 0;
350 }
351 SCLogDebug("Unix socket: client connection");
352
353 /* read client version */
354 buffer[sizeof(buffer)-1] = 0;
355 ret = recv(client, buffer, sizeof(buffer)-1, 0);
356 if (ret < 0) {
357 SCLogInfo("Command server: client doesn't send version");
358 close(client);
359 return 0;
360 }
361 if (ret >= (int)(sizeof(buffer)-1)) {
362 SCLogInfo("Command server: client message is too long, "
363 "disconnect him.");
364 close(client);
365 return 0;
366 }
367 buffer[ret] = 0;
```
## The Bug
`UnixCommandAccept()` is invoked whenever `select()` reports the listening socket as readable. After `accept()` returns a new client descriptor at line 344, the function immediately calls `recv(client, buffer, sizeof(buffer)-1, 0)` at line 355 to read the JSON version handshake. The accepted descriptor inherits the listening socket's blocking semantics: `UnixNew()` (`src/unix-manager.c:167`) creates the listening socket as a plain `AF_UNIX` / `SOCK_STREAM` socket and never sets `O_NONBLOCK`, nor does any code path apply `SO_RCVTIMEO`, `fcntl()`, or a `select()` readiness check to the freshly accepted fd before this first read. Consequently, if the peer has completed `connect()` but has not written any bytes, `recv()` at line 355 blocks indefinitely.
The call chain that places this blocking read on the critical path is as follows:
- `SuricataMain()` (`src/suricata.c:3183`) calls `UnixManagerThreadSpawnNonRunmode()` when `unix-command.enabled: yes`; alternatively, in unix-socket runmode, `RunModeUnixSocketSingle()` (`src/runmode-unix-socket.c:1764-1790`) calls `UnixManagerInit()` followed by `UnixManagerThreadSpawn(1)`.
- `UnixManagerThreadSpawn()` (`src/unix-manager.c:1187`) creates exactly one management thread whose callback is `UnixManager()` (`src/unix-manager.c:1150`, registered at `:1295`).
- `UnixManager()` loops calling `UnixMain()` (`src/unix-manager.c:626`). `UnixMain()` `select()`s on the listening socket and the set of established clients; when the *listening* fd is readable (line 672) it calls `UnixCommandAccept()` (`src/unix-manager.c:330`).
- `UnixCommandAccept()` performs `accept()` at line 344 and then immediately `recv(client, buffer, 200, 0)` at line 355 on the new fd, with no preceding `select()`/timeout.
Because there is only a single Unix manager thread, blocking inside `recv()` halts the entire control loop. Specifically, while the thread is stuck:
- `UnixCommandBackgroundTasks()` at `src/unix-manager.c:1178` is never invoked again. In `RUNMODE_UNIX_SOCKET` this function drives `UnixSocketPcapFilesCheck`, so queued pcap processing stops entirely.
- No further iterations of `UnixMain()` occur, so other connected clients (and new connections) receive no service.
- The `THV_KILL` shutdown check at `src/unix-manager.c:1167` is never reached, delaying clean shutdown of the process.
Note that this differs from the handling of *established* clients: once a client has been added to the list, `UnixMain()` only calls `UnixCommandRun()` after `select()` has confirmed the client fd is readable. The version-handshake read in `UnixCommandAccept()` is the one place where a `recv()` is issued without that readiness guarantee.
The socket is created at the default path `${localstatedir}/run/suricata/suricata-command.socket` (commonly `/var/run/suricata/suricata-command.socket`), `chmod`'d `0660` inside a `0750` directory, so any local user that is the suricata user or a member of its group can trigger the hang.
**Vulnerability class:** infinite-loop / unbounded blocking syscall (denial of service).
## Reproduction Results
The trigger below is **analytically derived** from source inspection of the call chain and socket-creation flags described above; it was not executed against a live build in this audit environment. The behaviour follows directly from POSIX `recv()` semantics on a blocking `SOCK_STREAM` socket with no data pending, and no code path was found that could alter the blocking mode of the accepted fd before line 355.
1. Configure Suricata with the unix command socket enabled. Either run in unix-socket runmode (`suricata --unix-socket`) or set in `suricata.yaml`:
```yaml
unix-command:
enabled: yes
```
The default socket path is `${localstatedir}/run/suricata/suricata-command.socket`.
2. Start Suricata and wait for the `unix socket '<path>'` log line confirming the listener is up.
3. As any local user with write permission on the socket (owner or group of the `0660` socket), connect and send nothing. Either:
- `nc -U /var/run/suricata/suricata-command.socket` and do not type anything; or
- `python3 -c 'import socket,time; s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); s.connect("/var/run/suricata/suricata-command.socket"); time.sleep(10**9)'`
No bytes are written — the expected version-handshake JSON (`{"version":"0.2"}`) is deliberately withheld.
4. Observe that the UnixManager thread is stuck inside `recv()` at `unix-manager.c:355` (verifiable with `gdb -p <pid>` / `pstack`). A second client such as `suricatasc` receives no response to any command. In unix-socket runmode, queued pcaps stop being processed because `UnixCommandBackgroundTasks()` is never called again.
5. The hang persists until the silent client closes its fd or the Suricata process is killed.
## Severity
**LOW**
This is a local denial of service of the Suricata control plane. The single Unix manager thread blocks indefinitely, with the following concrete effects:
- (a) No further unix-socket commands (`reload-rules`, `dump-counters`, `shutdown`, `dataset-add`, etc.) are serviced for any client.
- (b) Registered background tasks stop. In `RUNMODE_UNIX_SOCKET` this halts `UnixSocketPcapFilesCheck` and therefore all pcap processing.
- (c) The `THV_KILL` shutdown check in `UnixManager()` is never reached, delaying clean shutdown.
There is no memory corruption, no crash, and no remote reachability: the `AF_UNIX` socket is `chmod`'d `0660` under a `0750` directory, so only the suricata user/group can trigger the condition — and such a principal can already legitimately issue the `shutdown` command. The rating is therefore kept at LOW despite the complete stall of the management thread.
## Suggested Fix
Apply a receive timeout (or non-blocking mode) to the accepted fd before the handshake `recv()` in `UnixCommandAccept()`, so a silent client is dropped instead of hanging the manager thread. Minimal patch in `src/unix-manager.c`, immediately after the successful `accept()` at line 350:
```diff
@@ -350,6 +350,14 @@ static int UnixCommandAccept(UnixCommand *this)
}
SCLogDebug("Unix socket: client connection");
+ /* bound the version-handshake read so a silent client cannot stall the
+ * manager thread */
+ struct timeval rcv_to = { .tv_sec = 5, .tv_usec = 0 };
+ if (setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &rcv_to, sizeof(rcv_to)) != 0) {
+ SCLogWarning("Unix socket: unable to set SO_RCVTIMEO: %s", strerror(errno));
+ /* fall through: better to risk a hang than to refuse the client */
+ }
+
/* read client version */
buffer[sizeof(buffer)-1] = 0;
ret = recv(client, buffer, sizeof(buffer)-1, 0);
```
With `SO_RCVTIMEO` set, a client that sends nothing causes `recv()` to return `-1` with `EAGAIN`/`EWOULDBLOCK` after 5 seconds, hitting the existing `ret < 0` branch which logs and closes the fd.
A more thorough fix would defer the version handshake entirely: after `accept()`, add the fd to the client list in a "pending-version" state and let `UnixMain()`'s existing `select()` loop signal readability before any `recv()` is attempted, mirroring how `UnixCommandRun()` already handles established clients.
Actions