Security #8608
closedmime: buffer over read in quoted printable decoding
Description
As reported
- 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)
- 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.
- Root Cause Analysis
- 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.
- 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.
- 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`.
- 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.
- Exploitability Assessment
- 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.
- 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.
- 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`.
- 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`.
- 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.
- 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