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
Affected Versions:
Label:
CVE:
Git IDs:
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.
VJ Updated by Victor Julien about 1 month ago
- 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
- Status changed from New to In Progress
- Assignee changed from OISF Dev to Victor Julien
OT Updated by OISF Ticketbot 15 days ago
- Subtask #8926 added
OT Updated by OISF Ticketbot 15 days ago
- Label deleted (
Needs backport to 8.0)
SB Updated by Shivani Bhardwaj 5 days ago
- 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
- Status changed from In Review to Resolved
Staged.
VJ Updated by Victor Julien about 9 hours ago
- Private changed from Yes to No
VJ Updated by Victor Julien about 9 hours ago
- Status changed from Resolved to In Review
Actions