Project

General

Profile

Actions

Bug #8861

open
SB OD

output: Double-free of prefix/sensor_name in threaded LogFileCtx teardown

Bug #8861: output: Double-free of prefix/sensor_name in threaded LogFileCtx teardown

Added by Shivani Bhardwaj 7 days ago. Updated 7 days ago.

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

Description

Reported by Communications Security Establishment (CSE):

## Summary

When EVE JSON logging is configured with `threaded: true` and either a `sensor-name` or an `outputs.eve-log.prefix` string is set, Suricata creates one child `LogFileCtx` per worker thread by structure-assigning the parent context. The shallow copy aliases the heap-allocated `prefix` and `sensor_name` pointers into every child without transferring or duplicating ownership. During output teardown, `LogFileFreeCtx()` is invoked on every child and then on the parent, each unconditionally calling `SCFree()` on those same two allocations, producing an (N_threads + 1)-way double/multi-free. The result is a glibc tcache double-free abort (or an ASAN double-free report) on shutdown, and — in the unix-socket pcap-processing runmode — mid-life termination of a daemon that was meant to keep running.

## Affected Piece of Code

- **File:** `src/util-logopenfile.c`
- **Function / Location:** `LogFileNewThreadedCtx()` ~L886-946 and `LogFileFreeCtx()` ~L952-1020
- **Subsystem:** util-path-conf — Path handling, config parsers (classification/reference/threshold), log file open

```c
src/util-logopenfile.c
 886  static bool LogFileNewThreadedCtx(LogFileCtx *parent_ctx, const char *log_path, const char *append,
 887          ThreadLogFileHashEntry *entry)
 888  {
 889      LogFileCtx *thread = SCCalloc(1, sizeof(LogFileCtx));
 ...
 895      *thread = *parent_ctx;                       /* shallow copy: prefix, sensor_name aliased */
 ...
 927      thread->threaded = false;
 928      thread->parent = parent_ctx;
 ...
 968      if (lf_ctx->threaded) {
 ...
 973          if (lf_ctx->threads->ht) {
 974              HashTableFree(lf_ctx->threads->ht);  /* -> ThreadLogFileHashFreeFunc -> LogFileFreeCtx(child) */
 975          }
 976          SCFree(lf_ctx->threads);
 977      } else {
 ...
 984      }
 985
 986      if (lf_ctx->prefix != NULL) {                /* no lf_ctx->parent guard -> freed by every child */
 987          SCFree(lf_ctx->prefix);
 988          lf_ctx->prefix_len = 0;
 989      }
 990
 991      if(lf_ctx->filename != NULL)
 992          SCFree(lf_ctx->filename);
 993
 994      if (lf_ctx->sensor_name)                     /* same: freed N+1 times */
 995          SCFree(lf_ctx->sensor_name);
```

## The Bug

`LogFileNewThreadedCtx()` (line 886) manufactures a per-thread child `LogFileCtx` by performing a full structure assignment from the parent: `*thread = *parent_ctx;` (line 895). This is a shallow copy. Every heap pointer held by the parent — in particular `prefix` and `sensor_name`, both `SCStrdup`'d earlier in `OutputJsonInitCtx()` — is duplicated by value into the child without a fresh allocation. The only field that is subsequently re-allocated is `filename`, and only in the `LOGFILE_TYPE_FILE` branch (line 909); for `LOGFILE_TYPE_FILETYPE` even `filename` remains aliased. The child is then marked `threaded = false` (line 927) and `parent = parent_ctx` (line 928), but no ownership flag is recorded for the aliased string members and nothing NULLs them on the child.

**Setup chain.** `SuricataInit → RunModeDispatch → RunModeInitializeOutputs → OutputJsonInitCtx()` (`src/output-json.c:1147`) reads the YAML keys `sensor-name` and `outputs.eve-log.prefix`, `SCStrdup`'s them into the parent `LogFileCtx` (lines 1176 and 1221), and sets `file_ctx->threaded = true` when `outputs.eve-log.threaded: true` is present (line 1233).

**Per-worker chain.** `TmThreadsSlotPktAcqLoop → ThreadInit callbacks → JsonLogThreadInit()` / `CreateEveThreadCtx()` (`src/output-json-common.c:41,107`) → `LogFileEnsureExists()` (`src/util-logopenfile.c:787`) → `LogFileNewThreadedCtx()` (line 886). The `*thread = *parent_ctx;` at line 895 shallow-copies the parent's `prefix` and `sensor_name` heap pointers into every child; only `filename` is re-`SCStrdup`'d, and only for `LOGFILE_TYPE_FILE` (line 909).

**Teardown chain.** `main() → SuricataShutdown()` (`src/suricata.c:3194`) → `PostRunDeinit()` (lines 2389/3200) → `RunModeShutDown()` (`src/runmodes.c:576`) → `RunOutputFreeList()` (line 524) → `output_ctx->DeInit == OutputJsonDeInitCtx()` (`src/output-json.c:1333`) → `LogFileFreeCtx(parent)` (`src/util-logopenfile.c:952`). The parent takes the `lf_ctx->threaded` branch (line 968) and calls `HashTableFree(threads->ht)` (line 974). The hash table's free callback, `ThreadLogFileHashFreeFunc()` (lines 376-392), invokes `LogFileFreeCtx()` on each child. Each child has `threaded == false` (set at line 927) and `parent != NULL` (set at line 928), so it skips the threaded branch and falls through to lines 986-995. Those lines have no `lf_ctx->parent == NULL` ownership guard, so every child executes `SCFree(lf_ctx->prefix)` and `SCFree(lf_ctx->sensor_name)` on the parent-owned allocations. After `HashTableFree()` returns, the parent's own `LogFileFreeCtx()` invocation continues past line 977 to lines 986-995 and frees the same two pointers once more. With N worker threads this is an (N + 1)-way multi-free of two heap chunks.

**Alternate, non-process-exit trigger.** The unix-socket pcap runmode reaches the same teardown while the daemon is still expected to be alive: `UnixSocketPcapFilesCheck()` (`src/runmode-unix-socket.c:440`) → `PostRunDeinit(RUNMODE_PCAP_FILE)` (line 454) → `RunModeShutDown()` runs after each queued pcap finishes, so the double-free fires between jobs in a long-lived process rather than only at final exit.

**Exact field values needed.** YAML `sensor-name: <any non-empty string>` (global or under `eve-log`) and/or `outputs.eve-log.prefix: <any non-empty string>`, combined with `outputs.eve-log.threaded: true` and at least one worker thread. No specific packet bytes are required; the bug is purely a function of configuration plus normal teardown.

**Vulnerability class:** double-free.

## Reproduction Results

The trigger below is **analytically derived** from source review of the call chains cited above; it was not executed in this environment because a built Suricata binary was not available. The path is deterministic given the stated configuration — every line on the chain is unconditional once `threaded: true` and a non-NULL `prefix`/`sensor_name` are in place.

1. Create `suricata.yaml` containing:
   ```yaml
   sensor-name: testbox
   outputs:
     - eve-log:
         enabled: yes
         filetype: regular
         filename: eve.json
         threaded: true
         prefix: "@cee: " 
         types: [ alert ]
   ```
2. Run with at least two packet-processing threads, e.g.:
   ```
   suricata -c suricata.yaml -r any.pcap --runmode autofp
   ```
   (or `-i eth0` then send SIGINT). Each worker's `JsonLogThreadInit → LogFileEnsureExists → LogFileNewThreadedCtx` clones the parent ctx, aliasing `prefix` (`"@cee: "`) and `sensor_name` (`"testbox"`).
3. Let the pcap finish (or send SIGTERM/SIGINT for live mode). `PostRunDeinit → RunModeShutDown → OutputJsonDeInitCtx → LogFileFreeCtx(parent) → HashTableFree → ThreadLogFileHashFreeFunc → LogFileFreeCtx(child_1)` frees `prefix`/`sensor_name`; `LogFileFreeCtx(child_2)` frees them again → glibc `free(): double free detected in tcache 2` abort. Under ASAN: heap-use-after-free / double-free at `util-logopenfile.c:987` and `:995`.
4. Variant that crashes a still-running daemon (not just at exit): start `suricata -c suricata.yaml --unix-socket`, then via `suricatasc` send `pcap-file any.pcap /tmp/out`. When the pcap completes, `UnixSocketPcapFilesCheck → PostRunDeinit(RUNMODE_PCAP_FILE) → RunModeShutDown` executes the same teardown and the long-lived unix-socket daemon aborts.

No network packet content controls the freed data; the trigger is purely the documented config combination plus normal shutdown / pcap-job completion.

## Severity

**LOW** — Crash / DoS of the Suricata process during output teardown. With glibc tcache the second `SCFree()` of the same small chunk aborts the process; with ASAN it is a deterministic double-free report. In the normal IDS/IPS runmodes the frees occur after all packet and management threads have been joined (`suricata.c:2415-2428`), so the process was already on its way out — impact there is a dirty exit / core dump instead of a clean shutdown. In unix-socket pcap-processing mode (`runmode-unix-socket.c:454`) the same path runs between jobs while the daemon is intended to stay alive, so the abort terminates an otherwise long-running service. The freed buffers' contents (`sensor-name` / `prefix` strings from local YAML) are not attacker-controlled from the network, the frees happen back-to-back on a single thread with no intervening attacker-influenced allocations, and modern allocators detect the duplicate free immediately, so escalation beyond DoS is not realistic.

## Suggested Fix

Make the child contexts non-owning for the pointers that were shallow-copied from the parent. Two equivalent options:

**Option A — guard the frees on `parent`** (matches the existing pattern at line 1004):

```diff
--- a/src/util-logopenfile.c
+++ b/src/util-logopenfile.c
@@ -985,12 +985,15 @@ int LogFileFreeCtx(LogFileCtx *lf_ctx)
-
-    if (lf_ctx->prefix != NULL) {
-        SCFree(lf_ctx->prefix);
-        lf_ctx->prefix_len = 0;
-    }
-
-    if(lf_ctx->filename != NULL)
-        SCFree(lf_ctx->filename);
-
-    if (lf_ctx->sensor_name)
-        SCFree(lf_ctx->sensor_name);
+    /* Children created by LogFileNewThreadedCtx() share these pointers
+     * with the parent via struct assignment; only the parent owns them. */
+    if (lf_ctx->parent == NULL) {
+        if (lf_ctx->prefix != NULL) {
+            SCFree(lf_ctx->prefix);
+            lf_ctx->prefix_len = 0;
+        }
+        if (lf_ctx->sensor_name)
+            SCFree(lf_ctx->sensor_name);
+    }
+    if (lf_ctx->filename != NULL)
+        SCFree(lf_ctx->filename);   /* per-child SCStrdup for TYPE_FILE */
```

Additionally, for the `LOGFILE_TYPE_FILETYPE` branch in `LogFileNewThreadedCtx()` the child's `filename` is also the parent's pointer (no `SCStrdup` at line 909 for that branch); either NULL it after the struct copy (`thread->filename = NULL;` for non-FILE types) or include `filename` under the same `parent == NULL` guard.

**Option B — deep-copy in `LogFileNewThreadedCtx()`** right after line 895:

```c
    thread->prefix      = parent_ctx->prefix      ? SCStrdup(parent_ctx->prefix)      : NULL;
    thread->sensor_name = parent_ctx->sensor_name ? SCStrdup(parent_ctx->sensor_name) : NULL;
```

and bail to `error:` on allocation failure. Option A is simpler and avoids N redundant copies.
Actions

Also available in: PDF Atom