Project

General

Profile

Security #8608

Updated by Jason Ish 2 months ago

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 `buf[0]` and `buf[1]` 
 without checking that `len >= 2`; if the next reassembled segment is 
 only a single byte, `buf[1]` 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->bvremain[0] = '='; 
     if (remaining > 1) { 
         state->bvremain[1] = *(buf + offset + 1); 
         remaining--; 
         offset++; 
     } 
 } 
 ``` 

 If `remaining == 1` (the line ended on `=` alone), this leaves 
 `bvr_len = 1` with only `bvremain[0] = '='` 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->bvremain[0] == '=') { 
     if (state->bvr_len > 1) { 
         h1 = state->bvremain[1]; 
         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 `bvremain[1]` store), so the consumer 
 is required to defend against an undersized continuation buffer — and 
 does not. 

 ### Trace Analysis 

 ``` 
 ==26==ERROR: 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`, `bvremain[0] = '='`. 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 `buf[0]` into `bvremain[1]` and 
     returns — no OOB. 
   - `(1, 2)` and `(1, >=2)`: decode normally with `h1 = buf[0]`, 
     `h2 = buf[1]` (in-bounds because `len >= 2`). 
   - `(2, 1)`: decodes with `h1 = bvremain[1]`, `h2 = buf[0]` 
     (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 

Back