Project

General

Profile

Actions

Bug #8825

open
SB OD

unix-socket: corrupt program stack iff Suricata started with open file limit > 1024

Bug #8825: unix-socket: corrupt program stack iff Suricata started with open file limit > 1024

Added by Shivani Bhardwaj 9 days ago. Updated 2 days ago.

Status:
New
Priority:
Normal
Assignee:
Target version:
Affected Versions:
Effort:
Difficulty:
Label:

Description

Reported by Communications Security Establishment (CSE):

## Summary

The Unix-socket command server (`UnixManager` thread) builds a `select()` read-set by iterating every connected client and calling `FD_SET(uclient->fd, &select_set)` into a stack-resident `fd_set`, with no check that the descriptor is below `FD_SETSIZE` (1024 on Linux/glibc) and no cap on the number of simultaneous clients. A local user with write access to the 0660 command socket can open ~1100 connections; once `accept()` returns an fd ≥ 1024, the next `FD_SET()` writes a bit past the 128-byte on-stack `fd_set`, corrupting adjacent locals and the saved frame of `UnixMain()`. On `_FORTIFY_SOURCE=2` builds glibc aborts via `__fdelt_chk` (DoS); on non-fortified builds the result is silent stack corruption and a crash. The issue is reachable only when the Suricata process runs with `RLIMIT_NOFILE` > 1024.

## Affected Piece of Code

- **File:** `src/unix-manager.c`
- **Function / Location:** `UnixMain()` ~L626-650 and `UnixCommandRun()` ~L585-594; client accepted without limit in `UnixCommandAccept()` ~L330-435; `UnixCommandSetMaxFD()` ~L208-223 has no clamp
- **Subsystem:** unix-runmodes — Unix socket command server, runmodes, main, privilege drop, landlock

```c
/* src/unix-manager.c:626-650 — primary overflow site */
626 static int UnixMain(UnixCommand * this)
627 {
628     struct timeval tv;
629     int ret;
630     fd_set select_set;
631     UnixClient *uclient;
632     UnixClient *tclient;
...
641     /* Wait activity on the socket */
642     FD_ZERO(&select_set);
643     FD_SET(this->socket, &select_set);
644     TAILQ_FOREACH(uclient, &this->clients, next) {
645         FD_SET(uclient->fd, &select_set);
646     }
647
648     tv.tv_sec = 0;
649     tv.tv_usec = 200 * 1000;
650     ret = select(this->select_max, &select_set, NULL, NULL, &tv);
```

```c
/* src/unix-manager.c:585-594 — secondary site, same pattern */
585                 struct timeval tv;
586                 fd_set select_set;
...
589                     FD_ZERO(&select_set);
590                     FD_SET(client->fd, &select_set);
591                     tv.tv_sec = 0;
592                     tv.tv_usec = 200 * 1000;
593                     try++;
594                     ret = select(client->fd, &select_set, NULL, NULL, &tv);
```

```c
/* src/unix-manager.c:344-350,433 — missing guard at accept */
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     }
...
433     TAILQ_INSERT_TAIL(&this->clients, uclient, next);
```

## The Bug

`UnixMain()` declares `fd_set select_set;` on the stack (L630) and then populates it by walking the entire `this->clients` tail-queue and calling `FD_SET(uclient->fd, &select_set)` for every connected client (L644-645). On glibc, `fd_set` is a fixed 128-byte bitmap sized for exactly `FD_SETSIZE` (1024) descriptors, and `FD_SET(fd, set)` is a raw `set->__fds_bits[fd / __NFDBITS] |= (1UL << (fd % __NFDBITS))` — there is no bounds check in the macro itself. If `fd >= 1024`, the store lands past the end of `select_set`, overwriting whatever the compiler placed after it on the `UnixMain()` stack frame: the `uclient` / `tclient` loop pointers, the saved frame pointer, and ultimately the return address. The identical unguarded `FD_SET(client->fd, &select_set)` appears in `UnixCommandRun()` at L590 on a second on-stack `fd_set`.

Nothing in the accept path prevents such a descriptor from being inserted. `UnixCommandAccept()` (L330) calls `accept()` (L344) and only checks `client < 0` (L346); it never tests `client >= FD_SETSIZE`, never caps the number of concurrent clients, and `UnixCommandSetMaxFD()` (L208-223) simply records `max(fd)+1` into `this->select_max` with no clamp. After a successful version handshake the new `UnixClient` is appended unconditionally via `TAILQ_INSERT_TAIL(&this->clients, uclient, next)` at L433.

**Call chain to the vulnerable store.** `SuricataMain()` (`src/suricata.c:3183`) calls `UnixManagerThreadSpawnNonRunmode()` → `UnixManagerInit()` (`src/unix-manager.c:1063`), which runs `UnixNew()` to create the `AF_UNIX` / `SOCK_STREAM` listening socket at `<localstatedir>/run/suricata/suricata-command.socket` and `chmod()` it to 0660 (L193). `UnixManagerThreadSpawn()` (L1187) then creates the management thread via `TmThreadCreateCmdThreadByName("UnixManager")` / `TmThreadSpawn()`. The thread entry `UnixManager()` (L1150) loops every 200 ms on `UnixMain(&command)` (L1162). Inside `UnixMain()`, when the listening socket is readable it calls `UnixCommandAccept()` (L672 → L330): `accept()` yields a new fd, the function `recv()`s up to 199 bytes, requires the client to send the JSON `{"version":"0.2"}` (or `"0.1"`), replies `{"return":"OK"}`, and inserts the client into the tail-queue at L433. On the **next** iteration of `UnixMain()`, the `TAILQ_FOREACH` at L644 reaches the new entry and executes `FD_SET(uclient->fd, &select_set)` — if `uclient->fd >= 1024`, this is the out-of-bounds write.

**Required field values to reach the sink.** Each connection must be `AF_UNIX` / `SOCK_STREAM`, must `connect()` to the socket path, must send exactly the raw bytes `{"version":"0.2"}` (less than `UNIX_PROTO_VERSION_LENGTH` = 200 bytes, no trailing newline required), and must keep the socket open so the server-side fd remains allocated. Repeating this drives the kernel's lowest-free-fd allocator upward until `accept()` returns a value ≥ 1024.

**Precondition.** The Suricata process must have a soft `RLIMIT_NOFILE` > 1024. Suricata does not raise this limit itself; the operator must have configured it (e.g. `ulimit -n 65535` before launch, or `LimitNOFILE=65535` in the systemd unit). High-throughput capture deployments routinely do this. If the soft limit is ≤ 1024, `accept()` fails with `EMFILE` at L346 before any fd ≥ 1024 is ever returned, and the bug is unreachable — this is the only blocker, and it is the default on stock Linux.

**Classification note.** The originally assigned class label "heap-buffer-overflow" is inaccurate: `select_set` is an automatic (stack) variable in both `UnixMain()` and `UnixCommandRun()`. This is a **stack** out-of-bounds bit-set write.

## Reproduction Results

This is an **analytically-derived trigger**, validated by source review of every guard on the path; it was not executed end-to-end in this audit because it requires a live Suricata instance started with `RLIMIT_NOFILE > 1024`. No code path between `accept()` and `FD_SET()` checks the fd against `FD_SETSIZE` or limits the client count, so the only environmental gate is the open-file limit.

**Environment.** Suricata built with `BUILD_UNIX_SOCKET` (default on Linux when libjansson is present), `unix-command.enabled: yes` in `suricata.yaml` (auto-enabled in many capture runmodes), and the process started with an open-file soft limit > 1024 — e.g. `ulimit -n 65535 && suricata -c suricata.yaml -i eth0`, or a systemd unit with `LimitNOFILE=65535`. The attacker is a local user in the Suricata user's group (the socket is mode 0660 per L193).

**Steps.**

1. Locate the command socket: by default `<localstatedir>/run/suricata/suricata-command.socket` (or the value of `unix-command.filename`).

2. Open ~1100 simultaneous connections and complete the version handshake on each so the server appends them to the clients TAILQ. Minimal Python PoC:

   ```python
   import socket
   SOCK = "/var/run/suricata/suricata-command.socket" 
   keep = []
   for i in range(1100):
       s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
       s.connect(SOCK)
       s.sendall(b'{"version":"0.2"}')   # exact bytes; <200 B; no newline needed
       s.recv(4096)                       # consume {"return":"OK"}\n
       keep.append(s)                     # keep fd open so server fd stays allocated
   input("holding sockets...")
   ```

   Each successful handshake reaches `TAILQ_INSERT_TAIL` at `unix-manager.c:433`. Suricata already holds dozens of fds (log files, pcap rings, per-thread pipes, eve output, etc.), so well before the 1024th client `accept()` will return an fd ≥ 1024.

3. On the very next 200 ms tick of `UnixMain()`, the `TAILQ_FOREACH` at L644 reaches the high-fd client and `FD_SET(uclient->fd, &select_set)` writes bit `(fd % 8)` at byte offset `(fd / 8)` from the start of `select_set` — i.e. past the 128-byte stack object.

4. **Observed result:**
   - With `-D_FORTIFY_SOURCE=2` (distro packages, or `--enable-gccprotect`): glibc's `__fdelt_chk` fires and the process aborts with `*** buffer overflow detected ***` — denial of service.
   - Without FORTIFY: silent stack corruption of `uclient` / `tclient` / saved RBP / return address in `UnixMain()`; typically `SIGSEGV` on the next TAILQ dereference or on function return.

If `RLIMIT_NOFILE ≤ 1024` the trigger cannot be constructed: `accept()` returns `-1`/`EMFILE` at L346 before any fd ≥ 1024 is allocated and the offending client is never inserted.

## Severity

**LOW.**

This is an out-of-bounds bit-set write past a stack-resident `fd_set` in the `UnixManager` thread. The realistic outcome is process crash / denial of service: on `_FORTIFY_SOURCE=2` builds (the norm for distro packages and `--enable-gccprotect`) glibc aborts immediately via `__fdelt_chk`; on non-fortified builds the adjacent stack pointers (`uclient`, `tclient`) and the saved frame of `UnixMain()` are corrupted, leading to `SIGSEGV`.

Escalation from memory corruption to RCE is theoretically possible on non-fortified, non-stack-protector builds — the attacker influences *which* out-of-range bits get set via the fd numbers the kernel hands out — but control over the written value is extremely coarse (single bits at kernel-chosen offsets), so practical exploitation beyond a crash is unlikely.

The attack is **local-only** against a 0660 administrative socket. Authorized users of that socket can already issue the `shutdown` command to terminate Suricata cleanly, so the trust boundary actually crossed is minimal. Combined with the non-default `RLIMIT_NOFILE > 1024` precondition, the overall risk is low.

## Suggested Fix

Reject any accepted descriptor that cannot be represented in an `fd_set`, and (optionally) cap concurrent clients. Minimal patch in `UnixCommandAccept()` immediately after the `accept()` error check:

```diff
--- a/src/unix-manager.c
+++ b/src/unix-manager.c
@@ -346,6 +346,12 @@ static int UnixCommandAccept(UnixCommand *this)
     if (client < 0) {
         SCLogInfo("Unix socket: accept() error: %s",
                   strerror(errno));
         return 0;
     }
+    if (client >= (int)FD_SETSIZE) {
+        SCLogWarning("Unix socket: rejecting client fd %d >= FD_SETSIZE (%d)",
+                client, (int)FD_SETSIZE);
+        close(client);
+        return 0;
+    }
     SCLogDebug("Unix socket: client connection");
```

Additionally, clamp `this->select_max` to `FD_SETSIZE` in `UnixCommandSetMaxFD()` so `select()` is never handed an `nfds` above the limit, and add `if (uclient->fd >= (int)FD_SETSIZE) continue;` before each `FD_SET` at L645 and L590 as defence-in-depth.

The robust long-term fix is to replace `select()` / `fd_set` with `poll()` (or `epoll`), which has no `FD_SETSIZE` limitation and removes the entire bug class.
Actions

Also available in: PDF Atom