Why One HTTP Header Parser Bug Reached .NET, FFmpeg, ClickHouse, and Apache Arrow

Why One HTTP Header Parser Bug Reached .NET, FFmpeg, ClickHouse, and Apache Arrow

pr0h0••19 min read•hacking
AI Usage (75%)
On this page

One HTTP Header Parsing Flaw, Four Ecosystems

A header parser with a bug is not shocking. What got my attention is the shape of this failure: one HTTP header parsing flaw is circulating under two researcher names — HTTP2Shell and PHP2Shell — CISA has reportedly warned that one side is under active attack, and the same parsing behavior has reportedly shown up in .NET, FFmpeg, ClickHouse, and Apache Arrow.

Four runtimes, one class of mistake. This post walks through what the public reporting actually supports, how the parser-divergence bug class works, and how to scope and test your own exposure before you patch.

My position: this is a parser-divergence bug class, not four unrelated CVEs that happened to land in the same news cycle. What spread is not code. What spread is a permissive mental model of the header grammar — "split on colon, trim, lowercase, merge duplicates, move on" — reimplemented independently in every ecosystem that needed to speak HTTP and did not want a dependency on a hardened frontend.

Two honesty notes. Public details are thin. My sources are secondary: Security Boulevard, CyberSecurityNews, and CISO Series summaries, not the primary advisory text or vendor bulletins. Everything below is split into what a source states and what I am inferring, and the final section lists what I did not test.

What the Public Reports Actually Say — And What They Do Not

The timeline as reported

On 2026-10-27, CyberSecurityNews reported that HTTP2Shell extends beyond HTTP itself and affects .NET, FFmpeg, ClickHouse, and Apache Arrow. The same day, CISO Series ran a roundup covering HTTP2Shell emergency updates and PHP2Shell attacks. On 2026-10-28, Security Boulevard reported that CISA says the PHP2Shell flaw is under attack, with reports of additional variants coming in.

That is the public skeleton: a named flaw, a named extension into four other projects, an active-exploitation statement attributed to CISA, and a mention of emerging variants.

Notice what is not there. No CVE identifier in the summaries. No affected version ranges. No parser function named. No exploitation primitive stated. No patch versions. The reporting is a headline layer over advisory content I could not read directly. For triage, go to the CISA Known Exploited Vulnerabilities catalog and the vendor bulletins, not a news summary.

Names are not identifiers

HTTP2Shell and PHP2Shell are researcher shorthand. They are memorable, they sort well in a headline, and they carry zero machine-readable meaning. They are not CVE IDs, they are not advisory identifiers, and they do not tell you which of your services is exposed.

Shorthand invites over-reading. "PHP2Shell" sounds like a PHP problem. "HTTP2Shell" sounds like an HTTP/2 problem. The reported impact list — .NET, FFmpeg, ClickHouse, Arrow — argues against both readings.

I am deliberately not inventing CVE numbers, affected version ranges, or patch versions. If you need those, they come from the vendor bulletin for your specific version, not from an article.

QuestionWhat the reporting supportsStill unknown
Which projects are involved?PHP side under active attack; HTTP2Shell reported in .NET, FFmpeg, ClickHouse, Apache ArrowWhether these share one root cause or several lookalikes
Where in the code?Nothing specific namedThe exact parser function, the exact malformed input
Which versions?Nothing statedAffected and fixed version ranges
What does exploitation get you?"Severe", "active attack"RCE vs SSRF vs desync vs DoS — not stated in what I read
Are the variants the same bug?"More variants being reported"Unknown

The Bug Class: Header Parsing Is String Handling With Security Consequences

The grammar everyone half-implements

The normative text is short and worth reading. RFC 9110 §5 defines fields: a field-name is a token, and a token cannot contain whitespace. RFC 9112 §5 gives the line syntax — field-name ":" OWS field-value OWS — and is blunt about the common deviation: a server MUST reject, with a 400, any request that contains whitespace between the field name and the colon.

That single MUST is where a lot of implementations quietly cheat. Their parser is line.split(":") followed by .trim() on both halves. X-Probe : 1 then parses fine, with a name of X-Probe after trimming — which is not what the spec says the name is, because the spec says the message is invalid.

The three routine cheats:

  1. Whitespace before the colon. Trimmed away instead of rejected. Harmless-looking, and it is the cheapest way to make two parsers disagree about whether a header exists at all.
  2. Obsolete line folding (obs-fold). A continuation line starting with SP/HTAB was deprecated in RFC 7230 and is gone in RFC 9112, which tells servers to reject the message or replace the fold with SP before processing. Implementations that concatenate the continuation into the previous value produce a value no other component expects.
  3. Duplicate field names. RFC 9110 §5.3 permits repeats only when the field's definition says so, and allows combining with commas. Real parsers variously do first-wins, last-wins, comma-join, array-join, or reject. All five behaviors exist in production code today.

RFC 9110 §5.5 adds a fourth trap: field values may legally contain obs-text bytes (0x80–0xFF), but must not contain CR, LF, or NUL. A parser that decodes header bytes as UTF-8 and rejects anything non-ASCII will reject valid traffic. A parser that decodes as UTF-8 and silently replaces invalid sequences hands a different value downstream than a parser that kept the bytes intact.

Where parsers diverge, and why divergence is exploitable

Divergence is only interesting when two components parse the same bytes and disagree. The classic decision points:

Decision pointCommon behaviorsConsequence
Content-Length + Transfer-Encoding togetherTE wins / CL wins / reject / strip oneRequest smuggling
Duplicate Content-Lengthfirst / last / reject / join with commaBody-length disagreement
Duplicate generic headersfirst-wins / last-wins / arrayAuth and routing bypass
Whitespace before colonreject per RFC 9112 / trimHeader existence disagreement
obs-foldreject / replace with SP / concatenateValue smuggling
Bare LF line endingsaccept / rejectMessage boundary confusion
Oversized single header vs many smallper-field cap / total cap / neitherMemory exhaustion, parser bypass
Embedded NUL in valuepass through / truncate / rejectTruncation-based auth bypass

RFC 9112 §6.1 is explicit that a message with both Transfer-Encoding and Content-Length "might indicate an attempt to perform request smuggling" and must be handled as an error; a sender must strip the received Content-Length before forwarding. That guidance exists because the failure mode is well known: the frontend reads one body length, the backend reads another, and the leftover bytes become someone else's request.

The rule I keep coming back to: a downstream component that trusts an upstream parser's interpretation inherits every ambiguity that parser left in the value. Not just the bytes — the ambiguity. If parser A decided X-Probe : 1 was a header named x-probe, parser B never sees the whitespace that made the decision questionable.

The 'shell' suffix means something different here

In classic webshell vocabulary, "shell" means an attacker got a file onto a server and executes code through it. The naming here is different: the reporting puts HTTP2Shell and PHP2Shell around crafted HTTP requests reaching a parser flaw.

I am not going to claim a specific primitive — remote code execution, SSRF, request desync, or denial of service — because the summaries do not state one. That is the most important unknown for a defender triaging exposure, and it changes the answer completely. If the primitive is desync, the frontend proxy is in scope. If it is memory corruption in a parser, the client side matters as much as the server side. If it is SSRF-adjacent, the outbound HTTP client is the target.

Treat "shell" in the name as a severity signal from the researchers, not as a technical description.

Why One Flaw Reaches .NET, FFmpeg, ClickHouse, and Apache Arrow

Vendored and reimplemented parsers

The lazy explanation is that four teams copied one bug. That is almost certainly wrong, and it is the wrong lesson anyway.

The better mechanism: these are four ecosystems where HTTP parsing lives outside a hardened, shared web server. Kestrel is a first-party HTTP server inside the application process, not nginx. FFmpeg's HTTP handler is a protocol module feeding demuxers. ClickHouse's HTTP interface is an ingestion and query endpoint exposed by the database binary itself. Arrow Flight is gRPC-over-HTTP/2 inside a data service.

Each of these sits behind a reverse proxy, or beside one, or — frequently — in front of the internet with nothing in front of it, because "it's not a web app."

That is the spread. When HTTP parsing is a perimeter product, it arrives with a decade of smuggling-driven hardening: strict header caps, rejection of ambiguous framing, a fuzzing history, and someone on call whose job is that parser. When HTTP parsing is a library detail buried in a media tool or a columnar database, none of that arrives with it. The parser is written to be lenient because leniency makes integrations work, and nobody fuzzes the header path of a database's HTTP interface the way they fuzz its SQL engine.

Why a media library and a columnar database speak HTTP

Short version, because readers underestimate this part:

  • FFmpeg's HTTP protocol handler drives network inputs for ffmpeg and ffprobe — -i https://.... It is documented in the FFmpeg protocols manual. The HTTP surface exists so media tooling can consume remote sources. Often there is nothing in front of it, because the request is outbound.
  • ClickHouse's HTTP interface exists for ingestion, queries, and easy client integration — curl in, results out. Operators expose it on :8123 because that is how the docs show it.
  • Arrow Flight is gRPC over HTTP/2 for moving columnar data between services. It is usually authenticated and usually inside a trusted network — and flat internal networks are exactly where an ambiguous header travels unexamined.

"We don't run a web app" is the assumption that leaves all three exposed.

The trust boundary that actually matters

Draw it explicitly:

attacker-controlled bytes → parser A → normalized value → parser B / downstream consumer

The boundary that matters is not "internet vs internal." It is "who produced the bytes" and "how many parsers touch them before a security decision is made."

A proxy in front helps only if the proxy and the backend agree on the parse. If the proxy rejects X-Probe : 1 and the backend would have accepted it, you are fine. If the proxy accepts and normalizes it, and the backend's routing layer reads the raw line differently, you have moved the disagreement instead of removing it. This is the same mechanism that made HTTP desync attacks a durable research area rather than a single bug.

Building a Safe Local Harness for Header Edge Cases

Header grammar probes and what to record

These are grammar probes, not exploits. No smuggling payloads, no target lists — just malformed-but-benign requests against a server you own.

CaseWhat the spec saysWhat to recordWhy it matters
X-Probe : 1Reject with 400 (RFC 9112 §5)Status, whether header existsExistence disagreement
obs-fold continuationReject or replace fold with SPParsed value stringValue smuggling
Two Content-Length fieldsError unless identical handling definedWhich value winsBody-length disagreement
Content-Length + Transfer-EncodingTreat as error (RFC 9112 §6.1)Status, framing chosenSmuggling precondition
200 small headersAccept; 431 if over limitStatus, latencyLimit enforcement
1 header of 32 KB431 if over limitStatusMemory / bypass
LF-only line endingsNot valid CRLF framingStatusBoundary confusion
obs-text in valuePreserve as opaque bytesValue byte-for-byteDownstream re-decode
NUL in valueInvalidStatus, truncation pointTruncation bypass

A raw-socket Node harness for header probes

Raw sockets on purpose: a normal HTTP client will normalize the request before it is sent, which hides exactly the behavior you are trying to observe.

header-probe.js
// Node 20.x, tested on Linux. Grammar probes only.
// Run against a server YOU control on loopback. Never against third parties.


const HOST = process.env.PROBE_HOST ?? "127.0.0.1";
const PORT = Number(process.env.PROBE_PORT ?? 8080);
const CRLF = "
";

const b = (...parts) =>
Buffer.concat(parts.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p, "latin1"))));

const probes = [
{
  id: "ws-before-colon",
  raw: b(`GET / HTTP/1.1${CRLF}Host: lab${CRLF}X-Probe : 1${CRLF}Content-Length: 0${CRLF}Connection: close${CRLF}${CRLF}`),
},
{
  id: "obs-fold",
  raw: b(`GET / HTTP/1.1${CRLF}Host: lab${CRLF}X-Probe: a${CRLF} b${CRLF}Content-Length: 0${CRLF}Connection: close${CRLF}${CRLF}`),
},
{
  id: "dup-cl",
  raw: b(`POST / HTTP/1.1${CRLF}Host: lab${CRLF}Content-Length: 0${CRLF}Content-Length: 5${CRLF}Connection: close${CRLF}${CRLF}hello`),
},
{
  id: "cl-plus-te",
  raw: b(`POST / HTTP/1.1${CRLF}Host: lab${CRLF}Content-Length: 5${CRLF}Transfer-Encoding: chunked${CRLF}Connection: close${CRLF}${CRLF}0${CRLF}${CRLF}`),
},
{
  id: "bare-lf",
  raw: b("GET / HTTP/1.1
Host: lab
X-Probe: 1
Connection: close

"),
},
{
  id: "obs-text",
  raw: b(
    `GET / HTTP/1.1${CRLF}Host: lab${CRLF}X-Probe: `,
    Buffer.from([0x61, 0x80, 0xff, 0x62]),
    `${CRLF}Content-Length: 0${CRLF}Connection: close${CRLF}${CRLF}`
  ),
},
];

function probe({ id, raw }) {
return new Promise((resolve) => {
  const socket = net.connect(PORT, HOST);
  const chunks = [];
  const finish = (result) => {
    socket.destroy();
    resolve({ id, ...result });
  };

  socket.setTimeout(2000, () => finish({ note: "timeout — no response (hang or desync)" }));
  socket.on("connect", () => socket.write(raw));
  socket.on("data", (c) => chunks.push(c));
  socket.on("end", () => {
    const text = Buffer.concat(chunks).toString("latin1");
    const [statusLine = "", ...rest] = text.split(CRLF);
    finish({
      status: statusLine.trim(),
      headerCount: rest.filter((l) => l.includes(":") && !l.startsWith("<")).length,
    });
  });
  socket.on("error", (e) => finish({ note: `socket error: ${e.code ?? e.message}` }));
});
}

const results = [];
for (const p of probes) {
results.push(await probe(p));
await new Promise((r) => setTimeout(r, 50));
}
console.table(results);

Run it against an echo server so you see the parsed result, not just the status:

node --version   # v20.11.0
node -e '
const http = require("node:http");
http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ method: req.method, headers: req.headers, raw: req.rawHeaders }));
}).listen(8080, "127.0.0.1");
' &
PROBE_PORT=8080 node header-probe.js

On Node 20 with the default strict parser, the space-before-colon and bare-LF probes do not reach your handler. They fail at the parser and surface as a clientError on the server. That is the correct behavior per RFC 9112, and it is what a strict frontend buys you.

Then toggle one flag and watch the same bytes succeed:

const http = require("node:http");
// insecureHTTPParser is the exact opposite of what you want in prod.
const server = http.createServer({ insecureHTTPParser: true }, handler);

In my local run, the X-Probe : 1 request that the strict parser rejected was accepted on the lenient path and showed up in req.headers as "x-probe": "1" — the whitespace gone, the header now a first-class citizen downstream. One binary, one flag, two incompatible interpretations of the same bytes. That is the bug class in miniature, and it is why "the backend validates it" is not a security statement unless you know which parser did the validating.

💪

Run the harness against your actual proxy-plus-backend pair, not just the backend alone. Send the probe to the edge, then compare what the edge logged against what the app recorded. The interesting failures live in the disagreement between the two, not in either one's individual answer.

Detecting Header Parser Divergence in Logs and Network Traffic

Log signals of grammar probing

Log artifactWhat it suggests
Spike in 400/431 from one client or pathActive grammar probing
Requests with both Content-Length and Transfer-EncodingFraming ambiguity attempt
Duplicate framing headers on a single requestSame
Header count far outside the app's normal distributionBoundary probing or bypass attempt
Proxy access log and app log disagree on method, path, or body sizeParser confusion candidate
A connection where the proxy logged 1 request and the app logged 2Desync indicator

The last two rows are the ones people skip, because they require joining two systems that nobody joined before.

Network signals of parser disagreement

Resist signatures. Header-parsing evasion is a well-trodden bypass area, and the reporting says variants are already emerging — a string match on X-Probe : rots within a week. Write rules on the durable property instead: framing ambiguity that only exists at the byte level. A request carrying obs-fold, whitespace before a colon, or both framing headers is anomalous regardless of which spelling the variant of the week uses.

Practically: alert on the structural condition ("two parsers could disagree about this request"), not on the specific payload.

The one detection you should write today

Join the edge log to the app log on the request ID and alert on any mismatch in method, path, or body length for the same request.

That single check generalizes past this vulnerability. It catches desync, misrouted requests, and any future divergence bug where two components read the same bytes differently. It is an afternoon of work and it is worth more than any YARA rule you will write about this story.

Emergency Patch Ordering — My Ranking

Rank by exposure, not by CVSS. Severity scores describe a bug; exposure describes your Tuesday.

  1. Internet-facing HTTP frontends and anything terminating TLS directly. PHP-FPM behind nginx/Apache, Kestrel apps with no hardened proxy in front, ClickHouse HTTP endpoints reachable from outside. These have attacker-controlled bytes arriving continuously.
  2. Internal services that accept user-influenced URLs. Anything calling ffmpeg or ffprobe on remote input.
  3. Arrow Flight and data-plane services. Usually behind auth, frequently inside a flat network where nothing inspects the traffic.
  4. Build and CI systems. They pull media and HTTP resources constantly, from URLs that contributors can influence.

If you can only do one today, do the first row. If you can do two, the second row is the one that surprises people.

Why 'we don't expose FFmpeg over HTTP' is not a defense

This is the sharpest point in the story, and it is where most teams mis-scope their exposure.

FFmpeg's HTTP handler makes outbound requests. If any user-supplied or third-party-supplied URL reaches it — a thumbnail job, a media import, a preview generator, a podcast fetcher, a CI step that downloads a fixture — then the parser is still processing attacker-chosen header bytes. The attacker does not need to connect to you. They need to control a URL that you fetch, or control a server that your URL redirects to.

Same logic for ClickHouse. The url() table function and remote table reads turn the database into an HTTP client. Exposing port 8123 to the internet is the obvious risk; having an authenticated user who can call url() against a host they control is the subtler one.

Exposure is about who influences the bytes, not about who connects to you. Any service that accepts a URL is an HTTP client, and every HTTP client has a header parser that now needs patching.

Mitigations When You Cannot Patch Today

Proxy-enforced strictness and its limits

What a front proxy can realistically enforce:

  • reject whitespace between field name and colon
  • reject obs-fold outright
  • reject any request carrying both Content-Length and Transfer-Encoding
  • cap header count and total header bytes, return 431
  • normalize duplicate handling to a single documented behavior

Be honest about what this is: a compensating control that only works if the proxy and the backend parse identically. A proxy that rejects X-Probe : 1 protects a backend that would accept it — right up until a variant reaches you through a path that skips the proxy: a sidecar, a direct pod connection, a health-check port, a gRPC channel that never touched the edge.

And it does nothing for the outbound-client cases above. There is no proxy in front of FFmpeg fetching a URL.

Reducing the blast radius

  • Sandbox media processing. No credentials, no network egress beyond the specific sources it needs. If the parser falls over, the blast radius is a container with nothing in it.
  • Keep database HTTP interfaces off the public internet. Auth plus network policy, and if you do not need the HTTP interface, disable it.
  • Gate Arrow Flight. If you are not actively using it, it is a listening HTTP/2 surface with no owner.
  • Inventory every service that accepts a URL. That inventory is your real exposure list for this bug class, and most teams do not have it.
⚠️

Do not treat a WAF rule as a fix. The reporting describes variants already emerging, and header-parsing evasion is one of the oldest bypass areas in web security. A WAF is a delay tactic, not a patch.

What I Confirmed Versus What I Did Not Test

Confirmed, from the cited reporting: the publication dates (2026-10-27 for the extension to .NET, FFmpeg, ClickHouse, and Arrow; 2026-10-28 for the CISA active-exploitation statement), the named affected projects, and the existence of an active-attack warning attributed to CISA. Each is a claim made by a secondary source, and I have flagged it as such rather than restating it as vendor-confirmed fact.

Confirmed, in my own local testing: the strict-versus-lenient divergence inside a single Node 20 process on Linux. That is a demonstration of the bug class, not evidence about any of the four named projects.

Not tested, and not claimed: the actual parser behavior in .NET/Kestrel, FFmpeg, ClickHouse, or Arrow on any current version. Whether the reported variants share one root cause. What the exploitation primitive actually is. Which versions are affected and which are fixed.

Every item in that last paragraph has to come from the vendor bulletin for your specific version. Running the harness above against your own stack is the only way to move something from "not tested" to "confirmed" — and it will tell you more about your exposure in twenty minutes than another week of reading headlines.

Further Reading

Share this post

More posts

Loading comments…