Project

General

Profile

Actions

Security #8857

open
SB VJ

tls: use-after-free of JA3 elliptic-curve buffers on malloc failure

Security #8857: tls: use-after-free of JA3 elliptic-curve buffers on malloc failure

Added by Shivani Bhardwaj about 1 month ago. Updated about 9 hours ago.

Status:
In Review
Priority:
Normal
Assignee:
Target version:
Affected Versions:
Label:
CVE:
Git IDs:
Severity:
LOW
Disclosure Date:
11/02/2026

Description

Reported by Communications Security Establishment (CSE):

## Summary

The TLS ClientHello extension parser passes JA3 scratch buffers into per-extension
helper functions **by value** (`JA3Buffer *`). When `Ja3BufferAddValue()` hits an
allocation failure inside one of those helpers, it frees the underlying `JA3Buffer` and
NULLs only the helper's *local* copy of the pointer; the caller in
`TLSDecodeHSHelloExtensions()` still holds the now-dangling pointer and, on the
`goto end;` error path, hands it to `Ja3BufferAppendBuffer()`. That function
dereferences the freed struct (use-after-free read), passes its stale `->data` pointer
to `snprintf`, and finally calls `Ja3BufferFree()` on it again — a double-free of the
`JA3Buffer` and a wild `SCFree` of whatever `->data` now contains. The defect is
reachable from a network ClientHello whenever JA3 fingerprinting is enabled, but the
corrupting branch is gated on `SCMalloc`/`SCRealloc` returning NULL, so practical
exploitation requires concurrent memory pressure.

## Affected Piece of Code

- **File:** `src/app-layer-ssl.c`
- **Function / Location:** `TLSDecodeHSHelloExtensionEllipticCurves()` ~L1046–1100,
  `TLSDecodeHSHelloExtensionEllipticCurvePF()` ~L1102–1153, and the `end:` label of
  `TLSDecodeHSHelloExtensions()` ~L1439–1473
- **Subsystem:** al-ssl — SSL/TLS handshake parser (record layer, extensions,
  certificates)

```c
/* src/app-layer-ssl.c */
1046  static inline int TLSDecodeHSHelloExtensionEllipticCurves(SSLState *ssl_state,
1047                                            const uint8_t * const initial_input,
1048                                            const uint32_t input_len,
1049                                            JA3Buffer *ja3_elliptic_curves)   /* <-- BY VALUE */
...
1077              if (TLSDecodeValueIsGREASE(elliptic_curve) != 1) {
1078                  int rc = Ja3BufferAddValue(&ja3_elliptic_curves,           /* address of LOCAL copy */
1079                                             elliptic_curve);
1080                  if (rc != 0)
1081                      return -1;                                             /* buffer already freed */
...
1326              case SSL_EXTENSION_ELLIPTIC_CURVES:
1327              {
1328                  /* coverity[tainted_data] */
1329                  ret = TLSDecodeHSHelloExtensionEllipticCurves(ssl_state, input,
1330                                                                ext_len,
1331                                                                ja3_elliptic_curves);
1332                  if (ret < 0)
1333                      goto end;                                              /* caller's pointer is dangling */
...
1439  end:
1440      if (ja3) {
...
1446          if (ssl_state->current_flags & SSL_AL_FLAG_STATE_CLIENT_HELLO) {
1447              rc = Ja3BufferAppendBuffer(&ssl_state->curr_connp->ja3_str,
1448                                         &ja3_elliptic_curves);              /* UAF read + double-free */
```

Supporting code in `src/util-ja3.c`:

```c
172  int Ja3BufferAddValue(JA3Buffer **buffer, uint32_t value)
...
179      if ((*buffer)->data == NULL) {
180          (*buffer)->data = SCMalloc(JA3_BUFFER_INITIAL_SIZE);
181          if ((*buffer)->data == NULL) {
182              SCLogError("Error allocating memory for JA3 data");
183              Ja3BufferFree(buffer);          /* frees struct, NULLs *buffer (the LOCAL one) */
184              return -1;
185          }
...
191      int rc = Ja3BufferResizeIfFull(*buffer, value_len);
192      if (rc != 0) {
193          Ja3BufferFree(buffer);              /* same on realloc failure */
194          return -1;
195      }
```

## The Bug

### Call chain from the wire

A TCP stream on a TLS port (e.g. 443) is delivered by the app-layer engine to the
registered TOSERVER parser:

```
RegisterSSLParsers()              src/app-layer-ssl.c:3219
  → SSLParseClientRecord()        :2807
    → SSLDecode()                 :2658
      → SSLv3Decode()             :2756 → :2379
        → SSLv3ParseHandshakeProtocol()   :2590 → :1688
          → SSLv3ParseHandshakeType()     :1607
              handshake_type == SSLV3_HS_CLIENT_HELLO
              sets SSL_AL_FLAG_STATE_CLIENT_HELLO   :1617
            → TLSDecodeHandshakeHello()   :1622 → :1477
              → TLSDecodeHSHelloExtensions()        :1529 → :1251
```

Inside `TLSDecodeHSHelloExtensions()`, with JA3 enabled
(`SC_ATOMIC_GET(ssl_config.enable_ja3) == 1`, line 1260–1261) and
`SSL_AL_FLAG_STATE_CLIENT_HELLO` set, the function allocates three local `JA3Buffer *`
variables via `Ja3BufferInit()` (lines 1268, 1273, 1277). When the extension loop
encounters extension type `0x000a` (`SSL_EXTENSION_ELLIPTIC_CURVES`, line 1326), it
calls:

```c
ret = TLSDecodeHSHelloExtensionEllipticCurves(ssl_state, input, ext_len,
                                              ja3_elliptic_curves);   /* :1329-1331 */
```

The `JA3Buffer *` is passed **by value**. Inside the helper (`:1046-1049`) the parameter
`ja3_elliptic_curves` is therefore an independent local copy of the caller's pointer.

### The free that the caller cannot see

For each non-GREASE curve in the extension, the helper does:

```c
int rc = Ja3BufferAddValue(&ja3_elliptic_curves, elliptic_curve);   /* :1078 */
```

Note `&ja3_elliptic_curves` — the address of the *local parameter*.
`Ja3BufferAddValue()` (`util-ja3.c:172`) is designed so that on allocation failure it
cleans up after itself: if `SCMalloc(JA3_BUFFER_INITIAL_SIZE)` at `util-ja3.c:180`
returns NULL, or if `Ja3BufferResizeIfFull()` at `:191` fails its `SCRealloc`, the
function executes `Ja3BufferFree(buffer)` (`:183` / `:193`). `Ja3BufferFree()` calls
`SCFree((*buffer)->data)`, `SCFree(*buffer)`, and writes `*buffer = NULL`. Because
`buffer` here is `&<local parameter>`, only the helper's stack slot is NULLed. The
helper then returns `-1` (`:1081`).

### The dangling pointer is reused

Back in `TLSDecodeHSHelloExtensions()`:

```c
if (ret < 0)
    goto end;          /* :1332-1333  -- NOT goto error */
```

The caller's `ja3_elliptic_curves` was never updated; it still points at the `JA3Buffer`
struct that was just `SCFree()`d. Control jumps to the `end:` label (`:1439`). Because
`ja3` is true and `SSL_AL_FLAG_STATE_CLIENT_HELLO` is set, the cleanup-and-merge block
runs:

```c
rc = Ja3BufferAppendBuffer(&ssl_state->curr_connp->ja3_str,
                           &ja3_elliptic_curves);      /* :1447-1448 */
```

`Ja3BufferAppendBuffer()` (`util-ja3.c:108`) begins with a NULL guard:

```c
if (*buffer1 == NULL || *buffer2 == NULL) { ... return -1; }   /* :110 */
```

This guard **passes** — `*buffer2` is the dangling, non-NULL pointer. The function then:

1. Reads `(*buffer2)->data`, `(*buffer2)->used`, `(*buffer2)->size` (`:118-119`, `:125`,
   `:132`) — **use-after-free reads** of a freed heap chunk.
2. If `(*buffer1)->data != NULL`, passes the stale `(*buffer2)->data` pointer to
   `snprintf("%s", ...)` (`:136-138`) — a wild read of arbitrary length terminated only
   by a NUL byte in whatever memory now occupies that address.
3. Finally calls `Ja3BufferFree(buffer2)` (`:141`, or `SCFree(*buffer2)` at `:121` on
   the copy path) — a **double-free** of the `JA3Buffer` struct, plus an `SCFree()` of
   the stale `->data` field (a **wild free** of an attacker-influenceable pointer if the
   freed chunk was reallocated in the interim).

Note that even if step 1 had failed the NULL guard, the alternative `goto error;` path
at `:1466-1472` would *also* re-free the dangling pointer via
`Ja3BufferFree(&ja3_elliptic_curves)` (`:1469-1470`), since that block only checks
`!= NULL`. There is no safe exit once the helper has freed the buffer.

### Twin instance: ec_point_formats

The identical pattern exists for extension type `0x000b`
(`SSL_EXTENSION_EC_POINT_FORMATS`, `:1340`) via
`TLSDecodeHSHelloExtensionEllipticCurvePF()` (`:1102-1105`, by-value parameter;
`Ja3BufferAddValue(&ja3_elliptic_curves_pf, ...)` at `:1131`; `return -1` at `:1134`;
`goto end;` at `:1346-1347`; reuse at `:1452-1453`).

### Required input

To reach the vulnerable call site the attacker must send a TLS record with content-type
`0x16` (Handshake), record version `0x0301`–`0x0303`, handshake type `0x01`
(ClientHello), at least one cipher suite, and an extensions block containing type
`0x000a` with at least one non-GREASE curve ID (e.g. `0x0017`). JA3 fingerprinting must
be enabled in the sensor configuration. To reach the *defective branch*, `SCMalloc(128)`
or the subsequent `SCRealloc` must return NULL while that curve list is being processed
— a process-level memory-exhaustion condition, not a value the packet itself can encode.

**Vulnerability class:** network-reachable use-after-free / double-free (CWE-416 /
CWE-415), conditional on allocator failure.

## Reproduction Results

1. **Configuration prerequisite.** Enable JA3 fingerprinting so that
   `SC_ATOMIC_GET(ssl_config.enable_ja3) == 1`. Either set in `suricata.yaml`:
   ```yaml
   app-layer:
     protocols:
       tls:
         ja3-fingerprints: yes
   ```
   or load any rule that uses `ja3.hash` / `ja3.string`, which auto-enables JA3.

2. **Drive the parser to the vulnerable site.** Send a single TCP segment (client →
   server, e.g. dst port 443) carrying a TLS 1.2 ClientHello with a `supported_groups`
   (elliptic_curves) extension. Hex layout of the TCP payload:

   ```
   16 03 01 00 47          ; TLS record: Handshake, v1.0, length 0x47 (71)
   01 00 00 43             ; Handshake: ClientHello, length 0x43 (67)
   03 03                   ; client_version TLS 1.2
   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ; 32-byte random
   00                      ; session_id length = 0
   00 02  c0 2c            ; cipher_suites_len=2, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
   01 00                   ; compression_methods_len=1, null
   00 18                   ; extensions_len = 24
     00 0a 00 14           ; ext_type=0x000a (elliptic_curves), ext_len=20
       00 12               ; elliptic_curves_len = 18 (9 curves)
       00 17 00 18 00 19 00 1d 00 1e 00 1f 00 20 00 21 00 22
                           ; 9 non-GREASE curve IDs
   ```

   This deterministically reaches `TLSDecodeHSHelloExtensionEllipticCurves()` and
   invokes `Ja3BufferAddValue()` once per curve.

3. **Force the allocation-failure branch.** This is the non-deterministic gate:
   `SCMalloc(128)` at `util-ja3.c:180` (first curve) or `SCRealloc` inside
   `Ja3BufferResizeIfFull` (subsequent curves) must return NULL while step 2 is being
   parsed. Practical lab approximations:
   - **(a)** Run Suricata under a tight cgroup memory limit / `ulimit -v` and flood it
     with many concurrent large flows so the heap is exhausted at the moment the
     ClientHello arrives; or
   - **(b)** Use fault injection: build with `-Wl,--wrap=malloc` (or `LD_PRELOAD` a
     failmalloc shim) configured to fail the N-th allocation while replaying the pcap
     from step 2.

4. **Observed effect when the allocation fails.** `Ja3BufferAddValue()` frees the
   `JA3Buffer` and the helper returns `-1`; control reaches `end:` and
   `Ja3BufferAppendBuffer()` dereferences and re-frees the dangling pointer → glibc
   `double free or corruption` abort, or under AddressSanitizer a `heap-use-after-free`
   followed by `attempting double-free` report pointing at `util-ja3.c:118` /
   `util-ja3.c:141`.

**Status: analytical only.** A deterministic network-only trigger could not be
constructed because the defective branch is gated on `SCMalloc`/`SCRealloc` returning
NULL (`util-ja3.c:180-184` / `191-194`), which depends on process memory state rather
than on any byte in the ClientHello. The packet in step 2 reliably reaches the
vulnerable call site; step 3 (allocation failure) is the blocker for a pure-pcap PoC and
requires external memory pressure or fault injection to reproduce.

## Severity

**MEDIUM**

This is a use-after-free read of a freed `JA3Buffer` struct followed by a double-free of
that struct — and a wild `SCFree` of its stale `->data` field — inside the TLS app-layer
parser, which runs on untrusted network input. On glibc the double-free typically aborts
the Suricata process: denial of service of the IDS/IPS sensor, and therefore a
detection-bypass window while it is down or restarting.

Because the freed chunk's contents are re-read and passed both to `snprintf("%s", ...)`
and to `SCFree()`, an attacker who can (a) induce memory pressure to hit the failing
`SCMalloc`/`SCRealloc` and (b) groom the heap so that the freed `JA3Buffer` slot is
reallocated with controlled bytes between the free and the reuse could in principle
escalate to an arbitrary-free / heap-metadata-corruption primitive, raising the
theoretical ceiling toward remote code execution. In practice, exploitation is heavily
constrained by the very small window between free and reuse (same packet-processing call
frame) and by the OOM precondition.

Net realistic impact: the bug turns a recoverable allocation failure into a hard crash
of the sensor.

## Suggested Fix

Pass the JA3 buffers to the per-extension helpers **by reference** (`JA3Buffer **`) so
that when `Ja3BufferAddValue()` frees-and-NULLs on allocation failure, the caller's
variable is NULLed as well. The existing NULL checks in `Ja3BufferAppendBuffer()`
(`util-ja3.c:110`) and in the `error:` cleanup (`app-layer-ssl.c:1467-1472`) then make
the path safe. Apply the same change to the `EllipticCurvePF` helper.

```diff
--- a/src/app-layer-ssl.c
+++ b/src/app-layer-ssl.c
@@ -1046,7 +1046,7 @@
 static inline int TLSDecodeHSHelloExtensionEllipticCurves(SSLState *ssl_state,
                                           const uint8_t * const initial_input,
                                           const uint32_t input_len,
-                                          JA3Buffer *ja3_elliptic_curves)
+                                          JA3Buffer **ja3_elliptic_curves)
 {
@@ -1066,7 +1066,7 @@
-    if ((ssl_state->current_flags & SSL_AL_FLAG_STATE_CLIENT_HELLO) && ja3_elliptic_curves) {
+    if ((ssl_state->current_flags & SSL_AL_FLAG_STATE_CLIENT_HELLO) && *ja3_elliptic_curves) {
@@ -1078,7 +1078,7 @@
-                int rc = Ja3BufferAddValue(&ja3_elliptic_curves,
+                int rc = Ja3BufferAddValue(ja3_elliptic_curves,
                                            elliptic_curve);
@@ -1102,7 +1102,7 @@
 static inline int TLSDecodeHSHelloExtensionEllipticCurvePF(SSLState *ssl_state,
                                             const uint8_t * const initial_input,
                                             const uint32_t input_len,
-                                            JA3Buffer *ja3_elliptic_curves_pf)
+                                            JA3Buffer **ja3_elliptic_curves_pf)
 {
@@ -1122,7 +1122,7 @@
-    if ((ssl_state->current_flags & SSL_AL_FLAG_STATE_CLIENT_HELLO) && ja3_elliptic_curves_pf) {
+    if ((ssl_state->current_flags & SSL_AL_FLAG_STATE_CLIENT_HELLO) && *ja3_elliptic_curves_pf) {
@@ -1131,7 +1131,7 @@
-                int rc = Ja3BufferAddValue(&ja3_elliptic_curves_pf,
+                int rc = Ja3BufferAddValue(ja3_elliptic_curves_pf,
                                            elliptic_curve_pf);
@@ -1329,7 +1329,7 @@
                 ret = TLSDecodeHSHelloExtensionEllipticCurves(ssl_state, input,
                                                               ext_len,
-                                                              ja3_elliptic_curves);
+                                                              &ja3_elliptic_curves);
@@ -1343,7 +1343,7 @@
                 ret = TLSDecodeHSHelloExtensionEllipticCurvePF(ssl_state, input,
                                                                ext_len,
-                                                               ja3_elliptic_curves_pf);
+                                                               &ja3_elliptic_curves_pf);
```

**Alternative minimal fix:** change `goto end;` at lines 1333 and 1347 to `goto error;`
**and** remove the `Ja3BufferFree()` calls inside `Ja3BufferAddValue()` so that
ownership stays with the caller on failure. The double-pointer approach above is
preferred because it preserves the existing free-on-failure contract of
`Ja3BufferAddValue()` for every other call site that already relies on it.

Subtasks 1 (1 open0 closed)

Security #8926: tls: use-after-free of JA3 elliptic-curve buffers on malloc failure (8.0.x backport)AssignedVictor JulienActions

VJ Updated by Victor Julien about 1 month ago Actions #1

  • Subject changed from Use-after-free / double-free of JA3 elliptic-curve buffers on allocation failure to tls/ja3: Use-after-free / double-free of JA3 elliptic-curve buffers on allocation failure
  • Severity set to LOW

Many parts of the engine are currently not safe from malloc failures, sadly. So setting severity to LOW.

VJ Updated by Victor Julien about 1 month ago Actions #2

  • Status changed from New to In Progress
  • Assignee changed from OISF Dev to Victor Julien

JI Updated by Jason Ish 16 days ago Actions #3

  • Description updated (diff)

JI Updated by Jason Ish 16 days ago Actions #4

  • GHSA set to GHSA-7fvh-vrjw-pq5r

JI Updated by Jason Ish 15 days ago Actions #5

  • Status changed from In Progress to In Review
  • Label Needs backport to 8.0 added

MR on GL for review.

OT Updated by OISF Ticketbot 15 days ago Actions #6

  • Subtask #8926 added

OT Updated by OISF Ticketbot 15 days ago Actions #7

  • Label deleted (Needs backport to 8.0)

SB Updated by Shivani Bhardwaj 5 days ago Actions #8

  • Subject changed from tls/ja3: Use-after-free / double-free of JA3 elliptic-curve buffers on allocation failure to tls: use-after-free of JA3 elliptic-curve buffers on malloc failure

VJ Updated by Victor Julien 1 day ago Actions #9

  • Status changed from In Review to Resolved

Staged.

JI Updated by Jason Ish 1 day ago Actions #11

  • Disclosure Date set to 11/02/2026

VJ Updated by Victor Julien about 9 hours ago Actions #12

  • Private changed from Yes to No

VJ Updated by Victor Julien about 9 hours ago Actions #13

  • Status changed from Resolved to In Review
Actions

Also available in: PDF Atom