Project

General

Profile

Actions

Bug #8795

open
VJ VJ

TLS: ClientHello/ServerHello extension loop: `processed_len` uint16_t wrap and `input`/`processed_len` desync

Bug #8795: TLS: ClientHello/ServerHello extension loop: `processed_len` uint16_t wrap and `input`/`processed_len` desync

Added by Victor Julien about 11 hours ago. Updated about 10 hours ago.

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

Description

## Summary
In the TLS ClientHello/ServerHello extension parser, the running counter `processed_len` is a `uint16_t` that is incremented by `ext_len + 4` each iteration. An attacker who supplies a single extension with `ext_len >= 0xFFFC`, or many smaller extensions whose cumulative `ext_len + 4` exceeds `0x10000`, wraps `processed_len`, causing the `while (processed_len < extensions_len)` loop to continue past the declared extensions block. Independently, several extension sub-parsers (`SNI`, `EllipticCurves`, `EllipticCurvePF`, `SigAlgorithms`, `SupportedVersions`) return the *actually consumed* byte count which can be `< ext_len`, while the loop always advances `processed_len` by `ext_len + 4`; this desynchronises `input` and `processed_len`. Both issues are *bounded* by the `HAS_SPACE` macro (which checks against the full handshake-message buffer, not `extensions_len`), so there is **no out-of-bounds read or write** — but the parser silently walks into bytes outside the declared extensions block and may misparse them as extensions, polluting JA3/JA4 fingerprints, ALPN list, supported-versions, etc. This is attacker-controlled (any TLS client/server) and the impact is parser-state confusion / detection bypass, not a crash.

## Affected code
- File: `src/app-layer-ssl.c`
- Function: `TLSDecodeHSHelloExtensions`
- Lines: 1287–1437 (at suricata commit 17dc06532)

```c
uint16_t extensions_len = (uint16_t)(*input << 8) | *(input + 1);
input += 2;

if (!(HAS_SPACE(extensions_len)))
    goto invalid_length;

uint16_t processed_len = 0;
/* coverity[tainted_data] */
while (processed_len < extensions_len)
{
    if (!(HAS_SPACE(2)))
        goto invalid_length;
    uint16_t ext_type = (uint16_t)(*input << 8) | *(input + 1);
    input += 2;
    if (!(HAS_SPACE(2)))
        goto invalid_length;
    uint16_t ext_len = (uint16_t)(*input << 8) | *(input + 1);
    input += 2;
    if (!(HAS_SPACE(ext_len)))         /* checks vs handshake input_len, NOT vs extensions_len */
        goto invalid_length;

    switch (ext_type) {
        case SSL_EXTENSION_SNI:
            ret = TLSDecodeHSHelloExtensionSni(ssl_state, input, ext_len);
            if (ret < 0) goto end;
            input += ret;              /* ret may be < ext_len */
            break;
        ...
        default:
            input += ext_len;
            break;
    }
    ...
    processed_len += ext_len + 4;      /* uint16_t — wraps at 65536 */
}
```

## The bug
There are two coupled defects:

1. **`processed_len` is `uint16_t` and can wrap.** `ext_len` is also `uint16_t` (max 65535). The `HAS_SPACE(ext_len)` check at line 1309 validates `ext_len` against the *outer* `input_len` (the remaining bytes of the handshake message — which is bounded only by the 24-bit handshake `message_length`, up to 16 MB via the `hs_buffer` reassembly path), **not** against `extensions_len`. So a single extension may legally have `ext_len = 65535` even if `extensions_len` is small. After processing it, `processed_len += 65535 + 4` wraps to `3`, and the `while (processed_len < extensions_len)` test re-enters the loop body even though we have already consumed more than `extensions_len` bytes.

2. **`input` advance and `processed_len` advance disagree** for the `SNI`, `ELLIPTIC_CURVES`, `EC_POINT_FORMATS`, `SIGNATURE_ALGORITHMS`, and `SUPPORTED_VERSIONS` cases, which do `input += ret;` where `ret` is what the sub-parser actually consumed. The sub-parsers can return *less* than `ext_len` (e.g. `TLSDecodeHSHelloExtensionSni` only parses the first server-name entry; `TLSDecodeHSHelloExtensionEllipticCurves` consumes one fewer byte if `elliptic_curves_len` is odd; `TLSDecodeHSHelloExtensionSupportedVersions` returns `0` if neither client- nor server-hello flag is set). After this, `input` lags `processed_len` by `ext_len - ret` bytes, so the next iteration parses extension type/length from inside the previous extension's body.

Why this is **not** a memory-safety bug: every dereference of `input` is preceded by `HAS_SPACE(n)` which evaluates `(uint64_t)(input - initial_input) + (uint64_t)(n) <= (uint64_t)(input_len)`. `input_len` here is the remaining bytes of the (already buffered) handshake message, so all reads stay inside `hs_buffer` / the stream slice. The loop also cannot spin forever: `input` is monotonically advanced by at least 4 every iteration (the type+length header), so eventually `HAS_SPACE(2)` fails → `invalid_length`.

**Trigger path from wire bytes:**
1. TCP stream to port 443 (or any TLS-detected port), client→server.
2. TLS record: `0x16 0x03 0x01 <reclen>` (handshake, TLS 1.0). `record_length` ≤ 17 408 enforced at `app-layer-ssl.c:2473`.
3. Handshake header: type `0x01` (ClientHello), 24-bit `message_length` set to a large value (e.g. `0x010100` = 65 792). The handshake-fragment reassembly path (`hs_buffer`, lines 1701–1771 / 1812–1839) buffers data across multiple records until `message_length` bytes are available, so the parser ultimately calls `SSLv3ParseHandshakeType` → `TLSDecodeHandshakeHello` with `input_len = message_length`.
4. ClientHello body: version(2) + random(32) + session_id_len=0(1) + cipher_suites_len=2(2) + one cipher(2) + compression_len=1(1) + null compression(1) = 41 bytes consumed before extensions.
5. Extensions: `extensions_len = 0x0008`. First extension: type `0xeeee` (default branch), `ext_len = 0xFFFC`. The 65 532 body bytes are arbitrary. `processed_len += 0xFFFC + 4 = 0x10000` → wraps to `0`. Loop re-enters with `0 < 8`. `input` is now 65 538 bytes past `initial_input`; remaining `input_len - 65 538 ≈ 213` bytes are parsed as further extensions until exhausted.

## Validation results
- [x] Code-path traced manually from entry point — `SSLDecode` → `SSLv3Decode` → `SSLv3ParseHandshakeProtocol` (records loop, builds `hs_buffer` of `message_length` bytes) → `SSLv3ParseHandshakeType` → `TLSDecodeHandshakeHello` → `TLSDecodeHSHelloExtensions`. Confirmed `input_len` passed to the extensions parser is `message_length - 41` (≫ 65 538), so `HAS_SPACE(0xFFFC)` passes.
- [ ] Compiler/analyzer warning
- [ ] ASan/UBSan runtime hit
- [ ] Reproducer pcap/rule/input attached
- [x] Could NOT trigger a crash at runtime — by inspection, `HAS_SPACE` bounds every read. The defect is a logic/state bug only.

Confidence: high (the arithmetic is unambiguous). Severity: **low** — no memory corruption; impact limited to incorrect extension parsing → wrong/poisoned JA3/JA4/ALPN/SNI metadata, missed `TLS_DECODER_EVENT_HANDSHAKE_INVALID_LENGTH` event, possible detection bypass for rules keying on those buffers.

## Expected fix
1. Make `processed_len` a `uint32_t`.
2. Reject any extension whose `4 + ext_len` would push `processed_len` past `extensions_len` (i.e. enforce the inner length, not just `HAS_SPACE` against the outer buffer):
   ```c
   if ((uint32_t)processed_len + 4 + ext_len > extensions_len)
       goto invalid_length;
   ```
3. For the sub-parser cases that currently do `input += ret;`, advance by `ext_len` instead (the sub-parser already validated everything it needed inside `[input, input+ext_len)`), or have the sub-parsers always return `ext_len` on success.

Subtasks 1 (1 open0 closed)

Bug #8796: TLS: ClientHello/ServerHello extension loop: `processed_len` uint16_t wrap and `input`/`processed_len` desync (8.0.x backport)AssignedVictor JulienActions

OT Updated by OISF Ticketbot about 11 hours ago Actions #1

  • Subtask #8796 added

OT Updated by OISF Ticketbot about 11 hours ago Actions #2

  • Label deleted (Needs backport to 8.0)

VJ Updated by Victor Julien about 10 hours ago Actions #3

  • Status changed from In Progress to In Review
Actions

Also available in: PDF Atom