|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Suricata: a multi-value `Content-Encoding` header silently disables HTTP body inspection</title>
|
|
<style>
|
|
:root{--fg:#1a1a1a;--bg:#fff;--muted:#5a5a5a;--line:#d8d8d8;--code-bg:#f5f5f5;--hdr:#f0f0f0}
|
|
html{-webkit-text-size-adjust:100%}
|
|
body{max-width:60em;margin:0 auto;padding:2.5em 1.5em 6em;background:var(--bg);color:var(--fg);
|
|
font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",sans-serif}
|
|
h1{font-size:1.7em;line-height:1.3;margin:0 0 .6em;padding-bottom:.4em;border-bottom:2px solid var(--line)}
|
|
h2{font-size:1.3em;margin:1.9em 0 .6em;padding-bottom:.25em;border-bottom:1px solid var(--line)}
|
|
h3{font-size:1.08em;margin:1.4em 0 .5em}
|
|
p{margin:.7em 0}
|
|
hr{border:0;border-top:1px solid var(--line);margin:2em 0}
|
|
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.88em;
|
|
background:var(--code-bg);padding:.12em .35em;border-radius:3px}
|
|
pre{background:var(--code-bg);border:1px solid var(--line);border-radius:6px;padding:.85em 1em;overflow-x:auto;margin:1em 0}
|
|
pre code{background:none;padding:0;font-size:.85em;line-height:1.5}
|
|
table{border-collapse:collapse;margin:1em 0;width:100%;font-size:.93em;display:block;overflow-x:auto}
|
|
th,td{border:1px solid var(--line);padding:.42em .65em;text-align:left;vertical-align:top}
|
|
th{background:var(--hdr);font-weight:600;white-space:nowrap}
|
|
ul,ol{margin:.7em 0;padding-left:1.6em}
|
|
li{margin:.25em 0}
|
|
a{color:#0b5fa5}
|
|
strong{font-weight:650}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Suricata: a multi-value <code>Content-Encoding</code> header silently disables HTTP body inspection</h1>
|
|
<p><strong>Reported by:</strong> Zer0Gate <strong>Date:</strong> 2026-09-24 <strong>Component:</strong> <code>rust/htp</code> (libhtp-rs), HTTP/1 body decompression <strong>Verified on:</strong> Suricata 8.0.7 RELEASE; <code>main</code> @ <code>516636f26</code> (9.0.0-dev) for the <code>zstd</code> token <strong>Severity, scored against your policy:</strong> HIGH (Tier 1 feature enabled by default, broad evasion)</p>
|
|
<hr>
|
|
<h2>1. What happens</h2>
|
|
<p>When an HTTP/1 message carries more than one value in <code>Content-Encoding</code>, libhtp-rs can build its decompression chain with a layer missing, and then go ahead and run that chain. Suricata ends up inspecting bytes that are neither the plaintext nor what was on the wire, but whatever is left after the outer layers were undone, and it hands those bytes to the body hooks as if they were the body.</p>
|
|
<p><code>Content-Encoding: br, gzip</code> over a body of <code>gzip(br(P))</code> is the shortest example:</p>
|
|
<ul><li>the header has two values, so libhtp-rs leaves its fast path and re-parses the value as a list</li><li>that list path recognises <code>gzip</code>, <code>deflate</code>, <code>lzma</code>, <code>inflate</code>, <code>none</code> and <code>identity</code></li><li>it does not recognise <code>br</code></li><li><code>br</code> therefore resolves to <code>HtpContentEncoding::None</code>, and <code>None</code> is skipped when the chain is built</li><li>gzip gets undone, brotli does not</li></ul>
|
|
<p>The body hooks then receive the 8,514-byte brotli stream instead of the 65,536-byte plaintext. Every rule looking at <code>http.response_body</code>, <code>http.request_body</code> or <code>file_data</code> is dead for that transaction. Nothing Suricata writes tells an operator that this happened. There is an applayer anomaly and a shipped signature that watches it, and both are described in §4.</p>
|
|
<p>The scale matters here, because "one rule evaded" would understate it. On the affected transaction the entire body-inspection surface is gone at once, in both directions, and neither end needs anything beyond the ability to send a legal header. That is also the traffic class body rules exist for: two attacker-controlled endpoints talking to each other.</p>
|
|
<h2>2. Why it happens</h2>
|
|
<h3>2.1 The fast path and the list path disagree about what they know</h3>
|
|
<p><code>response_initialize_decompressors()</code> first tries to match the whole header value against a handful of literals. Only if that fails does it set <code>slow_path = true</code> and re-parse the value as a comma/space-separated list.</p>
|
|
<p>Suricata 8.0.7, <code>rust/htp/src/response.rs</code>, fast path (<code>:1150-1158</code>):</p>
|
|
<pre><code class="lang-rust">} else if ce.cmp_nocase_nozero(b"br") { // :1153
|
|
HtpContentEncoding::Brotli // :1154
|
|
} else if ce.cmp_nocase_nozero(b"inflate") || ce.cmp_nocase_nozero(b"none") {
|
|
HtpContentEncoding::None
|
|
} else {
|
|
slow_path = true; // :1158
|
|
HtpContentEncoding::None
|
|
}</code></pre>
|
|
<p>The same file, the list path's token table (<code>:1183-1253</code>):</p>
|
|
<pre><code class="lang-rust">for encoding in ce.split(|c| *c == b',' || *c == b' ') { // :1183
|
|
...
|
|
let encoding = if encoding.index_of_nocase(b"gzip").is_some() {
|
|
... HtpContentEncoding::Gzip
|
|
} else if encoding.index_of_nocase(b"deflate").is_some() {
|
|
... HtpContentEncoding::Deflate
|
|
} else if encoding.cmp_slice(b"lzma") == Ordering::Equal {
|
|
... HtpContentEncoding::Lzma
|
|
} else if encoding.cmp_slice(b"inflate") == Ordering::Equal
|
|
|| encoding.cmp_slice(b"none") == Ordering::Equal
|
|
|| encoding.cmp_slice(b"identity")== Ordering::Equal {
|
|
HtpContentEncoding::None
|
|
} else {
|
|
htp_warn!(self.logger, HtpLogCode::ABNORMAL_CE_HEADER,
|
|
"C-E unknown setting"); // :1246-1247
|
|
HtpContentEncoding::None
|
|
};
|
|
self.response_prepend_decompressor(encoding)?; // :1252
|
|
}</code></pre>
|
|
<p><code>br</code> is in the first table and not in the second, so it falls into the <code>else</code> branch. This is not a typo in one place: each time an encoding was added to the fast path, the list path was left alone. <code>a1ff7424e</code> added <code>br</code>, and on <code>main</code> <code>103abc2d3</code> added <code>zstd</code> (<code>response.rs:1156</code>, <code>request.rs:1138</code>) without touching the list either. The two tables have quietly drifted apart.</p>
|
|
<h3>2.2 <code>None</code> is skipped, so the chain is built short and then used</h3>
|
|
<p><code>response_prepend_decompressor()</code> does nothing for <code>None</code> (<code>rust/htp/src/response.rs:1327-1333</code>):</p>
|
|
<pre><code class="lang-rust">fn response_prepend_decompressor(&mut self, encoding: HtpContentEncoding) -> Result<()> {
|
|
...
|
|
if encoding != HtpContentEncoding::None {
|
|
... // push a decompressor
|
|
}
|
|
Ok(())
|
|
}</code></pre>
|
|
<p>So for <code>Content-Encoding: br, gzip</code>:</p>
|
|
<table><thead><tr><th>step</th><th>token</th><th>resolved to</th><th>what it does to the chain</th></tr></thead><tbody><tr><td>1</td><td><code>br</code></td><td><code>None</code> (plus an <code>ABNORMAL_CE_HEADER</code> warning)</td><td>skipped</td></tr><tr><td>2</td><td><code>gzip</code></td><td><code>Gzip</code></td><td>pushed</td></tr></tbody></table>
|
|
<p>The chain contains only the gzip decompressor. It inflates the outer layer without error, leaving <code>br(P)</code> behind, and <code>br(P)</code> is what reaches the body hooks. Nothing fails, nothing restarts, and the only trace is one low-severity anomaly.</p>
|
|
<p>The part I'd call the real defect is that a partially decoded buffer is handed downstream at all. A buffer that is neither ciphertext nor plaintext is worse than a buffer that was never touched, because everything downstream (rules, file extraction, hashing, protocol parsers) treats a successfully inflated stream as trusted content and has no way to tell it is not.</p>
|
|
<h3>2.3 Both directions</h3>
|
|
<p><code>rust/htp/src/request.rs</code> is structurally identical: <code>request_initialize_decompressors()</code> at <code>:1115</code>, fast path at <code>:1134</code> (<code>br</code>) and <code>:1138</code> (<code>zstd</code> on <code>main</code>), list path from <code>:1176</code> with the same <code>"C-E unknown setting"</code> at <code>:1238</code>.</p>
|
|
<p>Request decompression is not something an operator opts into. <code>src/app-layer-htp.c:1948-1958</code> calls <code>htp_config_set_request_decompression(cfg_prec->cfg, 1)</code> unconditionally, and response decompression has no switch at all.</p>
|
|
<h2>3. Affected versions</h2>
|
|
<table><thead><tr><th>Version</th><th>Affected</th><th>Notes</th></tr></thead><tbody><tr><td><code>main</code> (9.0.0-dev) @ <code>516636f26</code></td><td>Yes</td><td>Unknown tokens: <code>br</code>, <code>zstd</code></td></tr><tr><td>8.0.7 and the rest of 8.0.x</td><td>Yes</td><td>Unknown token: <code>br</code> (8.0.x has no zstd decoder)</td></tr><tr><td>7.0.x</td><td>Not tested</td><td>7.0 does not vendor <code>rust/htp</code>; it links the archived C libhtp. I did not build or test it, so I make no claim either way.</td></tr></tbody></table>
|
|
<p>The functions involved in both rows are the same pair: <code>response_initialize_decompressors()</code> in <code>rust/htp/src/response.rs</code> and <code>request_initialize_decompressors()</code> in <code>rust/htp/src/request.rs</code>.</p>
|
|
<h2>4. Impact</h2>
|
|
<p>For an affected transaction, the buffers used by detection hold the inner compressed stream, so all of the following stop working at the same moment:</p>
|
|
<ul><li><code>http.response_body</code> / <code>http.request_body</code> <code>content:</code> and <code>pcre:</code> rules</li><li><code>file_data</code> rules and file extraction; <code>fileinfo</code> reports the inner stream's size and hash, not the payload's</li><li><code>file.magic</code>, hash-based file blacklists, and anything else derived from the extracted file</li><li>downstream parsing and protocol detection that consumes the body</li></ul>
|
|
<p>In IDS the traffic passes. In IPS there is no match to act on, so there is nothing to drop either.</p>
|
|
<p>Operator visibility is the weakest part, and it is easy to overstate in either direction, so here is what I actually observed. The only trace of the condition is the <code>http.abnormal_ce_header</code> applayer anomaly. <code>sid:2221033</code> watches it:</p>
|
|
<pre><code>alert http any any -> any any (
|
|
msg:"SURICATA HTTP Request abnormal Content-Encoding header";
|
|
flow:established,to_server; app-layer-event:http.abnormal_ce_header;
|
|
flowint:http.anomaly.count,+,1; classtype:protocol-command-decode; sid:2221033; rev:1;)</code></pre>
|
|
<p>Two things about that rule are worth your attention. First, it fired on response-only flows in my testing even though it is written <code>to_server</code> (the flow in question carried a single HTTP transaction, a 200 response, no request-direction event; see §6.4). It looks like the anomaly is raised at flow level rather than attached to a direction, so the <code>to_server</code> qualifier does not do what it reads like. Second, and more importantly, the alert says "abnormal Content-Encoding header" and nothing else. An operator who sees it learns that a peer sent an unusual header. Nothing there says that every body signature on that transaction has stopped matching, and by the time this alert is worth looking at, the transaction it describes is long over. For the response direction, where most body signatures live, that is the whole of the signal.</p>
|
|
<h2>5. Reproducer</h2>
|
|
<h3>5.1 The pcap</h3>
|
|
<p><code>f2-repro.pcap</code> holds four HTTP response transactions whose plaintext bodies are byte-identical and which differ only in <code>Content-Encoding</code> and the matching encoding of the body:</p>
|
|
<table><thead><tr><th>#</th><th>request URI</th><th><code>Content-Encoding</code></th><th>body</th></tr></thead><tbody><tr><td>1</td><td><code>/vg/resp/plain</code></td><td><em>(absent)</em></td><td>plaintext</td></tr><tr><td>2</td><td><code>/vg/resp/gzip</code></td><td><code>gzip</code></td><td><code>gzip(P)</code></td></tr><tr><td>3</td><td><code>/vg/resp/br</code></td><td><code>br</code></td><td><code>br(P)</code></td></tr><tr><td>4</td><td><code>/vg/resp/br-gzip</code></td><td><code>br, gzip</code></td><td><code>gzip(br(P))</code></td></tr></tbody></table>
|
|
<ul><li>102 packets, 99,937 bytes</li><li>sha256 <code>4b0422fe75f5dddcde717812dfe48fb253106436603fcd0179b974e42aab1b53</code></li></ul>
|
|
<p>The sessions are synthesised rather than captured. Each has a full three-way handshake, a request, the complete response body and an orderly teardown, generated by <code>make-pcap.py</code> (appendix A). My first attempt was a live <code>tcpdump</code> capture of the same traffic and it came out truncated, with every response body cut roughly in half; Suricata could not reassemble complete bodies, alerted on nothing at all, and the run looked exactly like "this does not reproduce". Appendix B5 has the details. The synthesised pcap removes that variable.</p>
|
|
<p>Treat the pcap as <strong>non-confidential</strong>. It can go into your public CI or QA.</p>
|
|
<h3>5.2 The four bodies are the same after decoding</h3>
|
|
<p>The plaintext <code>P</code> is 65,536 bytes: a short canary block, compressible filler, and 8 KiB of non-compressible random data. <code>sha256(P) = 6453595dd48ae71437fa25b85ed02fe6779edd1d4f915d185dda5dead62f8665</code>. Every variant decodes back to exactly <code>P</code>, checked out of band with the corresponding decoders. All four messages are valid and a compliant client decodes each of them to the same bytes.</p>
|
|
<p>The canaries sit at offset 0 of <code>P</code>, so they are always inside the default 100 KiB body limits.</p>
|
|
<h3>5.3 Replaying it</h3>
|
|
<p>The quickest route is the container image, which needs nothing installed:</p>
|
|
<pre><code>docker run --rm --entrypoint /usr/bin/suricata -v $PWD:/d jasonish/suricata:8.0.7 \
|
|
-r /d/input.pcap -S /d/test.rules -l /d/out -k none</code></pre>
|
|
<p><code>input.pcap</code> is the same file as <code>f2-repro.pcap</code>; <code>test.rules</code> is the single signature from §8. On 8.0.7 this prints <code>read 1 file, 102 packets</code> and writes three alerts to <code>out/fast.log</code>, not four. Verbatim output and EVE are in <code>evidence/docker-repro.txt</code> and <code>evidence/docker-repro-eve.json</code>.</p>
|
|
<p>To build the pcap from scratch instead:</p>
|
|
<pre><code>python3 make-pcap.py --payloads payloads-f2 --out input.pcap</code></pre>
|
|
<h2>6. Evidence</h2>
|
|
<h3>6.1 The reproducer, replayed offline</h3>
|
|
<p>Running the signature from §8 over <code>f2-repro.pcap</code> on Suricata 8.0.7:</p>
|
|
<table><thead><tr><th>URI</th><th><code>Content-Encoding</code></th><th><code>http.length</code> (wire)</th><th><code>fileinfo.size</code> (inspected)</th><th>inspected bytes are plaintext?</th><th>alert</th></tr></thead><tbody><tr><td><code>/vg/resp/plain</code></td><td><em>(absent)</em></td><td>65,536</td><td><strong>65,536</strong></td><td>yes</td><td><strong>matched</strong></td></tr><tr><td><code>/vg/resp/gzip</code></td><td><code>gzip</code></td><td>8,820</td><td><strong>65,536</strong></td><td>yes</td><td><strong>matched</strong></td></tr><tr><td><code>/vg/resp/br</code></td><td><code>br</code></td><td>8,514</td><td><strong>65,536</strong></td><td>yes</td><td><strong>matched</strong></td></tr><tr><td><strong><code>/vg/resp/br-gzip</code></strong></td><td><strong><code>br, gzip</code></strong></td><td>8,506</td><td><strong>8,514</strong></td><td><strong>no, it is the inner brotli stream</strong></td><td><strong>none</strong> (+ <code>ABNORMAL_CE_HEADER</code>)</td></tr></tbody></table>
|
|
<p>Three alerts on a pcap whose four transactions are identical once decoded.</p>
|
|
<p>This table also pins down a trap I fell into early on: <code>http.length</code> is the <strong>wire</strong> body length (<code>htp_tx_response_message_len</code>, <code>src/output-json-http.c:307</code>) and tells you nothing about whether inspection worked. It reads 8,820 for the healthy <code>gzip</code> case and 8,506 for the broken <code>br, gzip</code> case, so it is lower when things are wrong. <code>fileinfo.size</code> is the useful signal, because file extraction happens after decompression.</p>
|
|
<h3>6.2 The same thing on a live instance</h3>
|
|
<p>Offline replay proves the parsing defect. I also wanted to know whether it survives contact with a real deployment, so I ran it against a public instance of mine: Suricata 8.0.7 RELEASE, af-packet on <code>eth0</code>, with the stock ET Open ruleset loaded, 52,901 rules. I changed no configuration and added no rules; the observation used signatures that were already in the loaded ruleset, which is why the result below says something about a default install.</p>
|
|
<p>Signals recorded:</p>
|
|
<ul><li><strong><code>fileinfo.size</code></strong>, the number of bytes that reached the inspection pipeline. Needs no rules.</li><li>whether the real ET signature on the body matched</li><li>the <code>ABNORMAL_CE_HEADER</code> anomaly, which shows the unknown-token branch was taken</li></ul>
|
|
<p>Response direction:</p>
|
|
<table><thead><tr><th>variant</th><th><code>Content-Encoding</code></th><th>plaintext alert</th><th>anomaly</th><th><code>fileinfo.size</code></th><th>verdict</th></tr></thead><tbody><tr><td><code>plain</code></td><td><em>(absent)</em></td><td><strong>matched</strong> (2045883)</td><td>none</td><td>65,536</td><td>normal</td></tr><tr><td><code>gzip</code></td><td><code>gzip</code></td><td><strong>matched</strong></td><td>none</td><td>65,536</td><td>normal</td></tr><tr><td><code>br</code></td><td><code>br</code></td><td><strong>matched</strong></td><td>none</td><td>65,536</td><td>normal</td></tr><tr><td><code>gzip-gzip</code></td><td><code>gzip, gzip</code></td><td><strong>matched</strong></td><td>none</td><td>65,536</td><td>normal</td></tr><tr><td><code>br-space</code></td><td><code>br </code></td><td><strong>matched</strong></td><td>none</td><td>65,536</td><td>normal</td></tr><tr><td><strong><code>br-gzip</code></strong></td><td><strong><code>br, gzip</code></strong></td><td><strong>no match</strong></td><td><code>ABNORMAL_CE_HEADER</code></td><td><strong>8,514</strong></td><td><strong>evaded</strong></td></tr><tr><td><strong><code>br-deflate</code></strong></td><td><strong><code>br, deflate</code></strong></td><td><strong>no match</strong></td><td><code>ABNORMAL_CE_HEADER</code></td><td><strong>8,514</strong></td><td><strong>evaded</strong></td></tr><tr><td><strong><code>gzip-br</code></strong></td><td><strong><code>gzip, br</code></strong></td><td><strong>no match</strong></td><td><code>ABNORMAL_CE_HEADER</code></td><td><strong>8,561</strong></td><td><strong>evaded</strong></td></tr></tbody></table>
|
|
<p>Request direction, same layout:</p>
|
|
<table><thead><tr><th>variant</th><th><code>Content-Encoding</code></th><th>plaintext alert</th><th><code>fileinfo.size</code></th><th>verdict</th></tr></thead><tbody><tr><td><code>plain</code></td><td><em>(absent)</em></td><td><strong>matched</strong> (6 signatures)</td><td>65,536</td><td>normal</td></tr><tr><td><code>gzip</code></td><td><code>gzip</code></td><td><strong>matched</strong></td><td>65,536</td><td>normal</td></tr><tr><td><code>br</code></td><td><code>br</code></td><td><strong>matched</strong></td><td>65,536</td><td>normal</td></tr><tr><td><code>gzip-gzip</code></td><td><code>gzip, gzip</code></td><td><strong>matched</strong></td><td>65,536</td><td>normal</td></tr><tr><td><strong><code>br-gzip</code></strong></td><td><strong><code>br, gzip</code></strong></td><td><strong>no match</strong></td><td><strong>8,514</strong></td><td><strong>evaded</strong></td></tr><tr><td><strong><code>br-deflate</code></strong></td><td><strong><code>br, deflate</code></strong></td><td><strong>no match</strong></td><td><strong>8,514</strong></td><td><strong>evaded</strong></td></tr></tbody></table>
|
|
<p>All 14 variants behaved as predicted. <code>br-space</code> is in there as a sanity check that a single <code>br</code> with trailing whitespace is trimmed and takes the fast path, i.e. that the trigger is genuinely the multi-value list and not whitespace.</p>
|
|
<p>Note the controls: <code>gzip</code>, <code>br</code> on its own, and <code>gzip, gzip</code>. Multi-layer decoding works fine when every token is known, so the list path itself is not broken. Only the missing-token case is.</p>
|
|
<p>Formatted verdicts: <code>evidence/f2-verify-result.txt</code>. Raw EVE from that run: <code>evidence/f2-verify-raw-eve.txt</code>.</p>
|
|
<h3>6.3 The endpoints decode the same bytes without complaint</h3>
|
|
<p>For the request-direction variants the test server decodes each received body using the layers named in <code>Content-Encoding</code> and reports what it got. Every variant comes back as the full 65,536-byte plaintext, <code>br, gzip</code> and <code>br, deflate</code> included (<code>evidence/serve-request-log.txt</code>). That rules out "the encoding combination is malformed" as an explanation for the missing matches. The messages are fine, both ends agree on what they mean, and Suricata is the only party that disagrees.</p>
|
|
<h3>6.4 What the EVE records look like</h3>
|
|
<p><code>/vg/resp/br-gzip</code>, the evaded transaction:</p>
|
|
<pre><code class="lang-json">{"event_type":"alert","src_ip":"115.200.248.4","dest_ip":"172.20.145.41","dest_port":8088,
|
|
"alert":{"signature_id":2221033,"signature":"SURICATA HTTP Request abnormal Content-Encoding header"}}
|
|
{"event_type":"anomaly","anomaly":{"app_proto":"http","type":"applayer",
|
|
"event":"ABNORMAL_CE_HEADER","layer":"proto_parser"}}
|
|
{"event_type":"fileinfo","src_ip":"172.20.145.41","src_port":8088,
|
|
"fileinfo":{"filename":"vg-test.bin","state":"CLOSED","stored":false,"size":8514}}</code></pre>
|
|
<p><code>size: 8514</code> is the inner brotli stream. The control <code>/vg/resp/gzip</code> reports <code>65536</code> and matches the body signature. The single alert in that flow is <code>sid:2221033</code>, the anomaly rule. That flow holds one HTTP transaction, the 200 response, and no request-direction event at all, yet the alert fired even though the signature is written <code>flow:established,to_server</code>. Its message text talks about the request direction, which does not help an operator arriving at it from the alert alone.</p>
|
|
<h3>6.5 The regression test fails today, and that is the point</h3>
|
|
<p>I ran the <code>suricata-verify</code> test from §8 under the same container image, using the official runner (<code>OISF/suricata-verify</code>, <code>run.py</code>). It fails, in the expected way:</p>
|
|
<pre><code>===> http-content-encoding-multivalue: Sub test #2: FAIL : expected 4 matches; got 3
|
|
for filter {'count': 4, 'match': {'event_type': 'alert', 'alert.signature_id': 1000001}}
|
|
|
|
PASSED: 0
|
|
FAILED: 1
|
|
SKIPPED: 0</code></pre>
|
|
<p>The first assertion (four HTTP transactions present) passes; the second (four alerts) does not. So the test is red on 8.0.7 and should go green with the fix. Full runner output: <code>evidence/sv-run.txt</code>; the Suricata invocation the runner built, the EVE and the fast log it produced: <code>evidence/sv-output-cmdline.txt</code>, <code>evidence/sv-output-eve.json</code>, <code>evidence/sv-output-fast.log</code>.</p>
|
|
<h2>7. Suggested fix</h2>
|
|
<h3>7.1 The minimum</h3>
|
|
<p>Make the two tables one table: add <code>br</code> (and <code>zstd</code> on <code>main</code>) to the list path's token mapping in both <code>response.rs</code> and <code>request.rs</code>. That covers everything I verified.</p>
|
|
<p>I would not stop there. Any encoding missing from the list path reproduces this behaviour, and the set of encodings is not fixed; an attacker can send a private or extended token this code has never heard of. Fixing the two tokens removes today's instances, not the class.</p>
|
|
<h3>7.2 What I would prefer</h3>
|
|
<p>Either of these, and I'm happy to prototype whichever you want:</p>
|
|
<ol><li><strong>One source of truth.</strong> Have the list path resolve each token through the same code the fast path</li></ol>
|
|
<p> uses, so the two sets cannot diverge by construction.</p>
|
|
<ol><li><strong>Refuse to build a partial chain.</strong> If a token cannot be resolved, treat the message as</li></ol>
|
|
<p> undecodable and do not pass a partially inflated buffer to the body hooks. Concretely: either skip body detection for that transaction and record an event that says so, or fall back to delivering the wire bytes with the buffer marked accordingly. Both are safer than the current mixture of decoded and undecoded content.</p>
|
|
<p>The second option suggests a piece of hardening that stands on its own. Now that a body buffer can be not-quite-plaintext, nothing downstream can tell. A buffer-level flag for "decompression incomplete, do not trust this as plaintext" would let rule authors and file extraction make an informed choice instead of a blind one.</p>
|
|
<h3>7.3 Either way</h3>
|
|
<p>If partial decoding stays possible in any form, it deserves a signal that says what it is. What fires today reads as a request-side header complaint and says nothing about body coverage having been lost. Something along the lines of "decompression incomplete, body detection skipped for this transaction" would be actionable. What exists now is not.</p>
|
|
<h2>8. Regression test</h2>
|
|
<p><code>suricata-verify/http-content-encoding-multivalue/</code> next to this report contains <code>test.yaml</code>, <code>test.rules</code>, <code>input.pcap</code> and <code>README.md</code>. Its <code>input.pcap</code> is the same file as <code>evidence/f2-repro.pcap</code>.</p>
|
|
<p>It asserts <strong>4 alerts for 4 transactions</strong>. On 8.0.7 it produces 3, so it is red before the fix and green after. I'd suggest landing it together with the fix rather than before, so your CI does not go red in the meantime.</p>
|
|
<h2>9. Disclosure</h2>
|
|
<ul><li>Nothing here has been published. I searched the OISF/suricata tracker and the GitHub security</li></ul>
|
|
<p> advisories and found no existing report or advisory for this behaviour.</p>
|
|
<ul><li>Credit is welcome under my GitHub handle, <strong>Zer0Gate</strong>.</li><li>The pcap and the payloads are non-confidential and may enter public CI or QA.</li><li>All of this was done on instances and infrastructure I own.</li></ul>
|
|
<hr>
|
|
<h2>Appendix A: files</h2>
|
|
<p>In <code>poc/suricata/round-03/ecs-verify/</code>:</p>
|
|
<table><thead><tr><th>file</th><th>what it does</th></tr></thead><tbody><tr><td><code>gen-f2.py</code></td><td>generates the body variants and <code>manifest.json</code>; checks each one by decoding it back and comparing with <code>P</code></td></tr><tr><td><code>serve.py</code></td><td>test server; serves each variant with the matching <code>Content-Encoding</code>, and on request-direction runs decodes the received body and reports the result</td></tr><tr><td><code>collect-f2.py</code></td><td>parses EVE into the §6.2 verdicts</td></tr><tr><td><code>make-pcap.py</code></td><td>synthesises the reproducer: four complete HTTP/1.1 sessions, deterministic, no capture loss</td></tr><tr><td><code>payloads-f2/</code></td><td>the generated bodies</td></tr></tbody></table>
|
|
<p>In <code>evidence/</code>:</p>
|
|
<table><thead><tr><th>file</th><th>what it is</th></tr></thead><tbody><tr><td><code>f2-repro.pcap</code></td><td>the reproducer, 102 packets</td></tr><tr><td><code>f2-repro.manifest.json</code></td><td>sizes and hashes of the generated bodies</td></tr><tr><td><code>docker-repro.txt</code>, <code>docker-repro-eve.json</code>, <code>docker-repro-fast.log</code></td><td>the §5.3 replay and its output</td></tr><tr><td><code>f2-verify-result.txt</code></td><td>formatted verdicts from the §6.2 run</td></tr><tr><td><code>f2-verify-raw-eve.txt</code></td><td>raw EVE from the §6.2 run</td></tr><tr><td><code>serve-request-log.txt</code></td><td>endpoint decoding results, §6.3</td></tr><tr><td><code>sv-run.txt</code></td><td>the suricata-verify runner output from §6.5</td></tr><tr><td><code>sv-output-cmdline.txt</code>, <code>sv-output-eve.json</code>, <code>sv-output-fast.log</code>, <code>sv-output-suricata.log</code></td><td>what the runner's Suricata invocation produced</td></tr></tbody></table>
|
|
<h2>Appendix B: traps, in case they save you time</h2>
|
|
<p>Each of these produced a convincing but wrong conclusion while I was working, and each would do the same to anyone building a test on top of this bug.</p>
|
|
<p><strong>B1. <code>zstd</code> proves nothing in this test.</strong> zstd emits RAW_BLOCKs, so literal plaintext, canary included, ends up inside the zstd stream itself. A <code>Content-Encoding: zstd, gzip</code> body therefore matches a plaintext signature whether or not Suricata drops the layer, and its <code>fileinfo.size</code> stays near the compressed size either way. I first read that as "the bypass does not work on requests" and it was an artefact. I dropped both <code>zstd</code> variants from the table for this reason. Brotli does not have this property, which is why the <code>br</code> variants are the usable ones. Check: <code>gzip.decompress()</code> of the <code>zstd-gzip</code> body still contains the canary; the same on <code>br-gzip</code> and <code>br-deflate</code> does not.</p>
|
|
<p><strong>B2. Direction matters as much as content.</strong> ET Open's C2 signatures mostly read <code>alert http $EXTERNAL_NET any -> $HOME_NET any (... to_client ...)</code>, on the assumption that the client is inside and the C2 server is outside. In a test where the probed server is in <code>$HOME_NET</code> and the tester is outside, the response direction runs <code>$HOME_NET -> $EXTERNAL_NET</code> and those rules cannot fire. Easy to read as "evasion confirmed" when nothing was ever going to match. The response-direction canaries in §6.2 were picked because their direction fits.</p>
|
|
<p><strong>B3. <code>flowbits</code>, <code>bsize</code> and <code>startswith</code> preconditions.</strong> Several otherwise-simple body rules carry a <code>flowbits:isset,...</code> prerequisite that a synthetic test will never satisfy. Three of my first candidates had this problem.</p>
|
|
<p><strong>B4. ET <code>sid:2049403</code> and <code>sid:2049404</code> cannot match on 8.0.7.</strong> They combine <code>content:"127.0.0.1"; content:"localhost|0a|"; fast_pattern; within:10;</code> with <code>pcre:"/^127\.0\.0\.1\s+localhost[\r\n]+/m"</code>, which contradicts itself: <code>127.0.0.1</code> is 9 bytes and the pcre needs at least one whitespace byte before <code>localhost</code>, so <code>localhost\n</code> can never start before offset 10, while <code>within:10</code> limits the scan to <code>[0,10)</code> (<code>detect-engine-content-inspection.c:180-181</code>, <code>depth = prev_buffer_offset + within + distance</code>). <code>fast.log</code> on the instance confirms neither has ever fired. Unrelated to this report; it just quietly invalidated a control I had planned.</p>
|
|
<p><strong>B5. A live capture can be truncated, and truncation looks like "no bug".</strong> My first reproducer was a <code>tcpdump -i eth0 -s 0 -w ...</code> capture of exactly the traffic in §5.1. Every response body came out cut roughly in half: 32,646 / 4,752 / 4,442 / 4,445 bytes instead of 65,536 / 8,820 / 8,514 / 8,506. With bodies it could not reassemble, Suricata alerted on nothing, including the plaintext control, so that run appeared to prove the opposite. If your control does not fire either, suspect the capture before you suspect the finding.</p>
|
|
<p><strong>B6. TCP flow control applies to hand-built pcaps too.</strong> My first synthesised pcap advertised a 29,200-byte receive window and then had the server push a 65,753-byte response in one go. Suricata, correctly, refused to reassemble past the advertised window, so the plaintext control was truncated to 27,740 bytes while the three ~8.5 KB transactions were unaffected. That gives a partial evasion table that looks like an artefact of body size rather than a <code>Content-Encoding</code> problem. <code>make-pcap.py</code> now advertises 65,535 and inserts a client ACK between batches to reopen the window.</p>
|
|
<h2>Appendix C: what I am not claiming</h2>
|
|
<ul><li>No memory-safety impact. This is a detection-coverage defect.</li><li>TLS is out of scope. Like any IDS without decryption, encrypted bodies are not inspectable, and</li></ul>
|
|
<p> this report says nothing about that.</p>
|
|
<ul><li>I did not test 7.0.x, and I make no claim about it.</li><li>I did not test IPS drop behaviour beyond noting that a rule which does not match cannot drop.</li></ul>
|
|
<p> The body is equally invisible in IDS and IPS mode.</p>
|
|
</body>
|
|
</html>
|