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.

VJ Updated by Victor Julien 9 days ago 1Actions #1

  • Subject changed from Unbounded clients allow fd >= FD_SETSIZE leading to stack buffer overflow via FD_SET to unix-socket: Unbounded clients allow fd >= FD_SETSIZE leading to stack buffer overflow via FD_SET

Think this could be a regular bug.

SB Updated by Shivani Bhardwaj 5 days ago Actions #2

  • Subject changed from unix-socket: Unbounded clients allow fd >= FD_SETSIZE leading to stack buffer overflow via FD_SET to unix-socket: corrupt program stack iff Suricata started with open file limit > 1024

VJ Updated by Victor Julien 2 days ago Actions #3

  • Tracker changed from Security to Bug
  • Private changed from Yes to No

We consider this a bug as the unix socket is an admin interface that should not have this many clients in normal use. Access to the socket implies the user has the capability to just stop Suricata as well.

Actions

Also available in: PDF Atom