
Why One HTTP Header Parser Bug Reached .NET, FFmpeg, ClickHouse, and Apache Arrow
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.
| Question | What the reporting supports | Still unknown |
|---|---|---|
| Which projects are involved? | PHP side under active attack; HTTP2Shell reported in .NET, FFmpeg, ClickHouse, Apache Arrow | Whether these share one root cause or several lookalikes |
| Where in the code? | Nothing specific named | The exact parser function, the exact malformed input |
| Which versions? | Nothing stated | Affected 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:
- 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.
- 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.
- 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 point | Common behaviors | Consequence |
|---|---|---|
Content-Length + Transfer-Encoding together | TE wins / CL wins / reject / strip one | Request smuggling |
Duplicate Content-Length | first / last / reject / join with comma | Body-length disagreement |
| Duplicate generic headers | first-wins / last-wins / array | Auth and routing bypass |
| Whitespace before colon | reject per RFC 9112 / trim | Header existence disagreement |
| obs-fold | reject / replace with SP / concatenate | Value smuggling |
| Bare LF line endings | accept / reject | Message boundary confusion |
| Oversized single header vs many small | per-field cap / total cap / neither | Memory exhaustion, parser bypass |
| Embedded NUL in value | pass through / truncate / reject | Truncation-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
ffmpegandffprobe—-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
:8123because 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.
| Case | What the spec says | What to record | Why it matters |
|---|---|---|---|
X-Probe : 1 | Reject with 400 (RFC 9112 §5) | Status, whether header exists | Existence disagreement |
| obs-fold continuation | Reject or replace fold with SP | Parsed value string | Value smuggling |
Two Content-Length fields | Error unless identical handling defined | Which value wins | Body-length disagreement |
Content-Length + Transfer-Encoding | Treat as error (RFC 9112 §6.1) | Status, framing chosen | Smuggling precondition |
| 200 small headers | Accept; 431 if over limit | Status, latency | Limit enforcement |
| 1 header of 32 KB | 431 if over limit | Status | Memory / bypass |
| LF-only line endings | Not valid CRLF framing | Status | Boundary confusion |
obs-text in value | Preserve as opaque bytes | Value byte-for-byte | Downstream re-decode |
NUL in value | Invalid | Status, truncation point | Truncation 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.
// 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 artifact | What it suggests |
|---|---|
| Spike in 400/431 from one client or path | Active grammar probing |
Requests with both Content-Length and Transfer-Encoding | Framing ambiguity attempt |
| Duplicate framing headers on a single request | Same |
| Header count far outside the app's normal distribution | Boundary probing or bypass attempt |
| Proxy access log and app log disagree on method, path, or body size | Parser confusion candidate |
| A connection where the proxy logged 1 request and the app logged 2 | Desync 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.
- 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.
- Internal services that accept user-influenced URLs. Anything calling
ffmpegorffprobeon remote input. - Arrow Flight and data-plane services. Usually behind auth, frequently inside a flat network where nothing inspects the traffic.
- 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-LengthandTransfer-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
- RFC 9110 — HTTP Semantics: normative field definitions, field-value rules, and field limits (§5).
- RFC 9112 — HTTP/1.1: message syntax, the whitespace-before-colon MUST, and the
Transfer-Encoding/Content-Lengthframing guidance (§5, §6.1). - CISA Known Exploited Vulnerabilities catalog: check here for the authoritative KEV entry and its CVE ID once published.
- Microsoft Security Response Center update guide: vendor bulletins for .NET and ASP.NET Core.
- FFmpeg security page: where FFmpeg publishes security-relevant release information.
- FFmpeg protocols documentation: describes the HTTP protocol handler that makes outbound requests.
- ClickHouse HTTP interface documentation: the exposure surface in question.
- Apache Arrow Flight RPC format: why Arrow speaks gRPC-over-HTTP/2.
- HTTP Desync Attacks: Request Smuggling Reborn (PortSwigger research): background on why parser disagreement between two components is exploitable.
- Kestrel web server documentation: how .NET terminates HTTP in-process, and the parser options that matter.
More posts

Detecting PHP2Shell: Telemetry and Choke Points From Web Shell to Root
A technical walkthrough of the PHP2Shell and Linux kernel exploit chain that rooted 25 servers, covering the telemetry sources and choke points defenders can use to detect web-shell deployment and privilege escalation before attackers reach root.

Auditing GitHub-to-PyPI Publishing Paths After the LiteLLM Supply-Chain Attack
A hands-on audit of GitHub-to-PyPI publishing paths, tracing how LiteLLM's long-exposed GitHub master key enabled a package compromise and what controls — secret scanning, token revocation, trusted publishing, pinning, and attestations — actually close the gap.

Auditing Reproducibility in Apex-35B, an Agent-Built Qwen3.6 Model
A technical walkthrough of auditing reproducibility in Apex-35B, an agent-built Qwen3.6 model, examining where agentic training loops break down and what applied ML teams should borrow.
Loading comments…