Project

General

Profile

Actions

Security #8608

closed
PA PA

mime: buffer over read in quoted printable decoding

Security #8608: mime: buffer over read in quoted printable decoding

Added by Philippe Antoine 2 months ago. Updated 8 days ago.

Status:
Closed
Priority:
Normal
Target version:
Affected Versions:
Label:
Git IDs:

19880f9d5bbe2b8f8e8867a577848dce2b532c86

Severity:
HIGH
Disclosure Date:

Description

As reported

  1. Vulnerability Header

- Vulnerability Title & ID: Heap-buffer-overflow in `ProcessQuotedPrintableBodyLine` (Suricata SMTP MIME) — ANT-2026-00330
- Security-Relevant: Yes
- Severity Rating: High
- Bug Category: Heap OOB read (1 byte) — incomplete bounds check on inter-call quoted-printable continuation buffer.
- Source Commit: https://github.com/OISF/suricata @ 3f99f073424423f7c8c5e133baa6419f82d4d94d (branch `main-7.0.x`, 2026-03-26)

  1. Executive Summary

Suricata's MIME decoder in the `main-7.0.x` branch contains a 1-byte
out-of-bounds heap read in `ProcessQuotedPrintableBodyLine`
(`src/util-decode-mime.c:1432`). When the previous SMTP DATA chunk ends
with a bare `=` and no end-of-line delimiter, the parser saves the `=`
in `state->bvremain` with `state->bvr_len = 1` and waits for the next
chunk to supply the two hex digits that complete the QP escape.
On the next call the function blindly reads `buf0` and `buf1`
without checking that `len >= 2`; if the next reassembled segment is
only a single byte, `buf1` is read past the end of the heap allocation.

Suricata ingests untrusted network traffic in real time, so an attacker
who can deliver SMTP through a monitored link can split a quoted-printable
body across TCP segments to trigger the read. The leak is one byte of
adjacent heap data per crash; the most realistic impact is sensor DoS
(ASAN abort, watchdog kill) and small adjacent-heap disclosure to
attacker-influenced log/alert sinks.

  1. Root Cause Analysis
  1. Technical Description

`ProcessQuotedPrintableBodyLine(buf, len, state)` is called per
reassembled MIME body line. To handle quoted-printable escapes that
span line/segment boundaries, the function maintains a small
inter-call buffer in `state->bvremain` (size `B64_BLOCK 4`) with a
length `state->bvr_len`.

The producer side (lines 1522-1541) populates this buffer when a line
ends with `=` and there is no line terminator (`current_line_delimiter_len 0`):

```c
} else if (state->current_line_delimiter_len == 0) {
state->bvr_len = (uint8_t)remaining;
state->bvremain0 = '=';
if (remaining > 1) {
state->bvremain1 = *(buf + offset + 1);
remaining--;
offset++;
}
}
```

If `remaining == 1` (the line ended on `=` alone), this leaves
`bvr_len = 1` with only `bvremain0 = '='` stored.

The consumer side at the top of the function (lines 1451-1459) then
unconditionally reads two bytes from the next buffer:

```c
if (state->bvr_len > 0 && state->bvremain0 == '=') {
if (state->bvr_len > 1) {
h1 = state->bvremain1;
h2 = *(buf + offset);
} else {
h1 = *(buf + offset);
h2 = *(buf + offset + 1); // OOB if len < 2
}
...
}
```

When `bvr_len == 1`, the parser needs two bytes from the new `buf` to
finish decoding `=XY`. There is no length check, so if the next
reassembled segment is only one byte long, `*(buf + offset + 1)`
reads one byte past the end of the heap allocation. ASAN flags it as
`heap-buffer-overflow READ of size 1` on a 1-byte region.

The same lack of a length check also affects the `bvr_len > 1`
branch when `len 0` (reads `buf[0]` of an empty allocation), but
that branch is harder to reach from the harness.

  1. First Faulty Condition

The first faulty divergence happens at line 1458 where the function
indexes `buf + offset + 1` without first checking that `len > 1`. The
producer at line 1533 already accepts the case `remaining 1` (it
sets `bvr_len = 1` and skips the `bvremain1` store), so the consumer
is required to defend against an undersized continuation buffer — and
does not.

  1. Trace Analysis

```
26ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6fc9781f5611 ...
READ of size 1 at 0x6fc9781f5611 thread T0 (Suricata-Main)
#0 ProcessQuotedPrintableBodyLine src/util-decode-mime.c
#1 ProcessBodyLine src/util-decode-mime.c:1631
#2 ProcessMimeBody src/util-decode-mime.c:2375
#3 ProcessMimeEntity src/util-decode-mime.c:2448
#4 MimeDecParseLine src/util-decode-mime.c:2641
#5 SMTPProcessCommandDATA src/app-layer-smtp.c:875
#6 SMTPProcessRequest src/app-layer-smtp.c:1309
#7 SMTPPreProcessCommands src/app-layer-smtp.c:1403
#8 SMTPParse src/app-layer-smtp.c:1444
#9 SMTPParseClientRecord src/app-layer-smtp.c:1516
#10 AppLayerParserParse src/app-layer-parser.c:1427
#11 LLVMFuzzerTestOneInput src/tests/fuzz/fuzz_applayerparserparse.c:204

0x6fc9781f5611 is located 0 bytes after 1-byte region [0x6fc9781f5610,0x6fc9781f5611)
allocated by thread T0 here:
#1 LLVMFuzzerTestOneInput .../fuzz_applayerparserparse.c:199:26
```

The 1-byte allocation is the harness's `isolatedBuffer` malloc'd at
`fuzz_applayerparserparse.c:199` for the final 1-byte chunk. The
overflow is at offset `+1` past the start, which exactly matches the
`*(buf + offset + 1)` read with `offset == 0`.

  1. PoC walk-through

`poc.bin` (77 bytes) decomposes per the `fuzz_applayerparserparse7_smtp`
harness's `{0x01, 0xD5, 0xCA, 0x7A}` separator-delimited format:

Chunk Direction Bytes Effect
------: :---------- :------ :-------
header `03 06 fe d5 ca 7a` alproto=SMTP, proto=TCP, ports
1 TOSERVER `"DATA\n"` client issues SMTP DATA
2 TOCLIENT `"354\n"` server "start mail input"
3 TOSERVER `"Content-Transfer-Encoding:quoted-printable\n\n="` MIME headers + body line ending in `=` (no EOL)
(empty) TOCLIENT direction-toggle only
4 TOSERVER, STREAM_EOF `"\n"` the 1-byte continuation that triggers the OOB

Chunk 3 ends with `=` and the line has no delimiter, so the parser
stores `bvr_len = 1`, `bvremain0 = '='`. Chunk 4's 1-byte
allocation is the heap region the next call reads past.

  1. Exploitability Assessment
  1. Attack Vector & Reachability

- Remote, unauthenticated. Suricata routinely ingests SMTP traffic
flowing across a monitored interface. No interaction with the SMTP
endpoints is required — the attacker only needs the traffic to
traverse the link.
- TCP segmentation suffices to split a `=` at a line tail from the
following hex digits, so the trigger is realistic on a real wire,
not just in fuzz harnesses.

  1. Technical Primitive

- 1-byte OOB read at `buf + 1` where `buf` is a heap allocation of
controlled size. The byte is consumed by `DecodeQPChar` and folded
into the QP-decoded output (`val += res`), which is then written to
`state->data_chunk` and ultimately may be flushed into a file
reassembly buffer (`output-file-store`, EVE alerts, etc.). With
carefully chosen heap layout an attacker may exfiltrate a single
adjacent-heap byte per attempt via the file extraction or alert
channels.
- The crash itself, on builds without `abort_on_error=1`, is a
process-terminating SIGABRT under ASAN; on production (non-ASAN)
builds it is a one-byte read of adjacent heap memory and typically
silent unless the byte happens to be inside a guard page.

  1. Mitigation Analysis

- ASLR / DEP / stack canaries: irrelevant — this is a heap read, not a
control-flow primitive.
- Suricata-internal mitigations: none for this code path. The MIME
decoder is enabled by default for SMTP. As a workaround until
patched, set `app-layer.protocols.smtp.mime.decode-quoted-printable: no`
in `suricata.yaml`.

  1. Reproduction Steps

Using a fresh OSS-Fuzz checkout:

```
python3 infra/helper.py build_fuzzers --sanitizer address suricata
python3 infra/helper.py reproduce suricata fuzz_applayerparserparse7_smtp poc.bin
```

ASAN reports `heap-buffer-overflow READ of size 1` in
`ProcessQuotedPrintableBodyLine` at `src/util-decode-mime.c:1458`, with
the 1-byte allocation site at `src/tests/fuzz/fuzz_applayerparserparse.c:199`.

  1. Patch

`patch.diff` adds the missing length check at the top of
`ProcessQuotedPrintableBodyLine`. When the saved `=` continuation
needs more bytes than the new buffer supplies, the patch buffers the
new bytes into `bvremain` (capped at `B64_BLOCK = 4`) and returns,
deferring the decode until the parser is called again with more data.
For the unreachable-from-harness `len 0` corner (where the original
code also OOB-reads `buf[0]`), the partial QP escape is dropped as an
anomaly so the existing `len 0` flush path can reuse `bvremain` for
delimiter bytes.

The Rust SMTP MIME path on the `main` branch
(`rust/src/mime/smtp.rs:569`) already gates the equivalent read with
`if i.len() >= 2`, so this bug is unique to the C decoder retained on
the `main-7.0.x` stable branch.

  1. Verification

- The vulnerable code is present at the captured `main-7.0.x` HEAD
(`3f99f07`): `src/util-decode-mime.c` contains the unguarded
`h2 = *(buf + offset + 1)` read at line 1458.
- The patched `bvr_len` accumulation has been reviewed against the
four reachable `(bvr_len, len)` pairs:
- `(1, 1)` (PoC trigger): buffers `buf0` into `bvremain1` and
returns — no OOB.
- `(1, 2)` and `(1, >=2)`: decode normally with `h1 = buf0`,
`h2 = buf1` (in-bounds because `len >= 2`).
- `(2, 1)`: decodes with `h1 = bvremain1`, `h2 = buf0`
(in-bounds).
- `(2, 0)`: drops the partial as `ANOM_INVALID_QP` and lets the
existing `len == 0` flush path take over `bvremain`.
- Independent corroboration: the equivalent Rust path on `main`
(`rust/src/mime/smtp.rs:569` — `if i.len() >= 2 { ... let (h1, h2) = ...; }`)
implements exactly this length check, so the same bug class was
already considered and fixed in the rewrite. The `main-7.0.x` C
decoder simply missed the gate.

[Credits]
Trail of Bits, in collaboration with Anthropic


Related issues 1 (1 open0 closed)

Related to Suricata - Bug #8238: Unsigned integer underflow in quoted-printable decoder with small input chunksFeedbackActions

PA Updated by Philippe Antoine 2 months ago Actions #1

  • Status changed from New to In Review

Gitlab MR

PA Updated by Philippe Antoine 2 months ago Actions #2

  • Related to Bug #8238: Unsigned integer underflow in quoted-printable decoder with small input chunks added

JI Updated by Jason Ish 2 months ago Actions #3

  • Description updated (diff)

VJ Updated by Victor Julien about 2 months ago Actions #4

  • Target version changed from 7.0.16 to 7.0.17

PA Updated by Philippe Antoine about 1 month ago Actions #5

  • Severity set to HIGH

Severity high : reachable through network traffic with default config, and could crash Suricata

JI Updated by Jason Ish about 1 month ago Actions #6

  • GHSA set to GHSA-qxm4-q7vx-7xj4

JI Updated by Jason Ish about 1 month ago Actions #7

  • CVE set to 2026-57228

SB Updated by Shivani Bhardwaj 22 days ago Actions #8

  • Status changed from In Review to Resolved

JI Updated by Jason Ish 22 days ago Actions #9

  • Git IDs updated (diff)

VJ Updated by Victor Julien 21 days ago Actions #10

  • Status changed from Resolved to Closed

JI Updated by Jason Ish 8 days ago Actions #11

  • Private changed from Yes to No
Actions

Also available in: PDF Atom