Understanding the worst .NET vulnerability ever: request smuggling and CVE-2025-55315

On 14 October 2025, as part of a routine Patch Tuesday release, Microsoft shipped updates for every supported version of .NET along with a security advisory carrying one of the highest severity scores it has ever assigned to a .NET issue: a CVSS score of 9.9 out of 10. The advisory, CVE-2025-55315, describes an HTTP request smuggling vulnerability in ASP.NET Core that lets an authenticated attacker bypass a security feature over the network. A number that high grabs attention fast, but the real value is in understanding what is actually happening under the hood rather than just reacting to the headline score.

This article walks through what request smuggling is, why the specific variant used in CVE-2025-55315 is hard to spot, how Kestrel’s chunk parsing created the opening, and what you need to do about it as a .NET developer or platform owner. Most of the research and original explanation here comes from Andrew Lock’s detailed write-up on andrewlock.net. I have restructured it and added my own framing, but the credit for digging into the Kestrel source and the original PR belongs to him.

What the advisory actually says

The official summary reads: inconsistent interpretation of HTTP requests, referred to as request or response smuggling, in ASP.NET Core allows an authorized attacker to bypass a security feature over a network. On its own, that sentence does not sound like a 9.9. Barry Dorrans, who leads .NET security at Microsoft, explained the reasoning in a comment on the GitHub issue: request smuggling by itself would not warrant that score, but Microsoft rates issues based on how they could affect applications built on top of ASP.NET Core, not just the framework in isolation.

A smuggled request is dangerous precisely because what it can do depends entirely on your application code. Depending on how a given app is written, the hidden request could let an attacker log in as a different user through an elevation of privilege, make an internal request that behaves like server-side request forgery, bypass CSRF checks, or trigger an injection attack somewhere downstream. Nobody can enumerate every outcome up front because it depends on what the target application does with the request stream, and that uncertainty is exactly why Microsoft erred on the side of a very high score.

What HTTP request smuggling actually is

Request smuggling is not new. It was first documented back in 2005, and it shows up whenever two systems process the same HTTP request but disagree about where one request ends and the next one begins. The canonical setup is a proxy in front of an origin server, where the proxy forwards a request unmodified, but the destination server parses that same byte stream differently and ends up treating part of it as a second, hidden request.

The classic illustration from the original 2005 paper uses two conflicting Content-Length headers in a single request. The proxy honours one value, the origin server honours the other, and the mismatch is what makes the attack work.

POST /some_script.jsp HTTP/1.0
Connection: Keep-Alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 9
Content-Length: 204
 
this=thatPOST /vuln_page.jsp HTTP/1.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 95
 
param1=value1&data=<script>alert(document.cookie)</script>&foobar

Walk through what happens to this request. The proxy sees two Content-Length headers, picks the second one (204), and treats the entire remaining payload as the body of a single request, so it looks completely normal from the proxy’s point of view and gets forwarded as-is. The origin server also sees both headers, but picks the first one (9). It reads exactly nine bytes, this=that, and considers that request complete.

Everything after that, starting with the second POST /vuln_page.jsp, gets parsed by the server as a brand new, independent HTTP request that the proxy never actually inspected or approved. That second request has effectively been smuggled past the proxy. The canonical version of this attack pairs a Content-Length header with a Transfer-Encoding: chunked header instead of two Content-Length headers, but the underlying idea, exploiting a disagreement between two parsers, is the same.

Why this matters in practice

It is easy to underestimate request smuggling at first glance. If an attacker can already send a request, why does it matter if the server sees it as two requests instead of one? The answer is that the mismatch between the proxy and the destination server is what an attacker abuses. Depending on what the proxy and application are doing, this class of bug has been used to reflect malicious data into other users’ responses, poison shared caches, exfiltrate authentication tokens or other data from other users’ requests, reach internal endpoints that should never be publicly reachable, override authentication that the proxy was supposed to enforce, and redirect victims through open-redirect style bugs.

The scenario that tends to worry people most is where a front-end proxy is doing real security work, such as TLS termination and client certificate based identification, and forwarding an identity header like X-SSL-CLIENT-CN once it has verified the caller. If an attacker can smuggle a hidden request past that proxy, they can insert their own value for that header and the origin server, trusting the proxy, will treat it as authoritative.

POST /example HTTP/1.1
Host: some-website.com
Content-Type: x-www-form-urlencoded
Content-Length: 64
Transfer-Encoding: chunked
 
0
 
GET /admin HTTP/1.1
X-SSL-CLIENT-CN: administrator
Foo: x

In this request, the outer request looks harmless. The smuggled GET /admin request, however, carries an X-SSL-CLIENT-CN header claiming to be the administrator, a header the origin server normally trusts because it assumes only the proxy can set it after verifying a client certificate. Since the smuggled request never actually passed through that verification step, the server ends up authorizing a request it should have rejected. It is worth noting that this style of attack does not require the target application to be vulnerable to cross-site scripting or anything else on its own; the vulnerability lives entirely in the disagreement between the two parsers.

You do not need a literal proxy to be at risk

Most explanations of request smuggling assume a proxy and an origin server as two distinct systems, but the underlying requirement is just two places that can disagree about how to interpret the same byte stream. That can happen inside a single ASP.NET Core application too. If your code reads HttpRequest.Body or HttpRequest.BodyReader directly, or otherwise manipulates or forwards the raw request stream instead of relying on the built-in model binding, you can be exposed to this class of bug even without a proxy anywhere in your architecture. It is hard to enumerate every place this could bite you, so it is safer to treat any code that touches the raw request stream as a potential risk area and lean on the framework’s own parsing wherever you can.

The specific bug behind CVE-2025-55315: chunk extensions

The variant exploited in CVE-2025-55315 was first documented publicly in June 2025 by Jeppe Bonde Weikop, and it relies on Transfer-Encoding: chunked combined with a lesser-known part of HTTP 1.1 called chunk extensions. To understand it, it helps to first understand chunked transfer encoding itself.

When a client does not know the total size of a request body up front, for example when serializing an object to JSON on the fly, it can send the body in a series of chunks instead of computing the full Content-Length in advance. Each chunk starts with a header giving its size in hexadecimal, followed by a CRLF line ending, then the chunk’s actual bytes, followed by another CRLF. A final chunk of length zero signals the end of the request. The image below shows a simple POST of a small JSON payload sent as two chunks plus a terminating empty chunk.

A simple HTTP request body sent using chunked transfer encoding, split into two data chunks and a terminating empty chunk.
A simple HTTP request body sent using chunked transfer encoding, split into two data chunks and a terminating empty chunk.

Line endings matter a lot here, so the next diagram shows the exact same request with every CRLF made explicit. Every chunk header and every chunk body is followed by \r\n, and the request ends with a zero-length chunk header followed by its own \r\n.

The same request with every CRLF line ending shown explicitly.
The same request with every CRLF line ending shown explicitly.

Chunk extensions are an HTTP 1.1 feature that lets a sender attach key-value metadata to an individual chunk, indicated by a semicolon after the chunk length. Almost nobody uses them in practice. Clients do not send them and servers generally ignore them, but that near-universal indifference is exactly what created the opening here.

The same request with a chunk extension, foo=bar, attached to the second chunk header.
The same request with a chunk extension, foo=bar, attached to the second chunk header.

The parsing bug: lenient handling of line endings

HTTP implementations generally follow the robustness principle: be strict about what you send, and lenient about what you accept. That leniency is convenient until it becomes a security problem, and it has bitten HTTP parsers before; it was exactly this kind of leniency around conflicting Content-Length and Transfer-Encoding headers that caused the original request smuggling exploits, which is why the HTTP 1.1 RFC now explicitly forbids forwarding a request that has both.

Because nobody actually processes chunk extensions, most server implementations, including Kestrel prior to the fix, just skip past them by scanning ahead for a carriage return and checking that it is followed by a line feed. A simplified version of the pre-fix Kestrel logic looked roughly like this.

private void ParseExtension(ReadOnlySequence<byte> buffer)
{
    while (true)
    {
        // Chunk-extensions not currently parsed
        // Just drain the data
        var extensionCursor = buffer.PositionOf(ByteCR);
        var suffixBuffer = buffer.Slice(extensionCursor);
 
        // skips over extensionCursor bytes
        var suffixSpan = suffixBuffer.Slice(0, 2).ToSpan();
        if (suffixSpan[1] == '\n')
        {
            // We consumed the \r\n at the end of the extension,
            // so switch modes.
            return;
        }
 
        // Otherwise, keep reading data until we do find \r\n
        buffer = ReadMoreData();
    }
}

The important question this code answers incorrectly is what to do about a lone \r or a lone \n that is not part of a proper \r\n pair. The RFC is explicit that only \r\n counts as a valid line terminator here, and that standalone \r or \n characters must not appear inside a chunk header at all. The code above does not enforce that. It searches for a CR and then checks the next byte; if that next byte happens to be an LF, it treats the pair as the end of the extension, but it never rejects a request where the extension ends in a bare \n without a preceding \r, or other malformed variants. That gap between what the RFC requires and what the implementation actually validated is the root cause of CVE-2025-55315.

The same request, but the chunk extension on the second chunk ends in a bare \n instead of \r\n, an invalid line ending that Kestrel used to accept.
The same request, but the chunk extension on the second chunk ends in a bare \n instead of \r\n, an invalid line ending that Kestrel used to accept.

How the smuggling attack plays out

Once you have a chunk header ending that a proxy and an origin server interpret differently, you have everything you need for a request smuggling attack. Imagine a proxy that is configured to block direct access to /admin, sitting in front of an ASP.NET Core application that assumes the proxy has already done that filtering. An attacker sends a chunk header of 2;\n instead of the valid 2;\r\n. Because of how each side parses that malformed line ending, the proxy and the server disagree about where the current chunk, and therefore the current request, actually ends.

The proxy sees a single well-formed request, while the vulnerable server sees the same bytes as two separate requests, the second one targeting /admin.
The proxy sees a single well-formed request, while the vulnerable server sees the same bytes as two separate requests, the second one targeting /admin.

The proxy treats the lone \n as an acceptable line ending, reads the declared chunk sizes correctly, and sees exactly one request that never goes anywhere near /admin. The vulnerable server, on the other hand, does not recognise the bare \n as valid, so it keeps scanning forward past the chunk that the proxy already consumed. It ends up treating an earlier zero-length chunk as the end of the first request and everything after that, including a GET /admin HTTP/1.1 line, as a completely separate second request. The proxy’s access control was never applied to that second request because the proxy did not know it existed.

It is worth pointing out that other HTTP servers outside the .NET ecosystem have essentially the same class of bug. The security advisories for Python’s aiohttp and Ruby’s puma rated the equivalent issue as only moderate severity, and Netty rated it low. Microsoft’s 9.9 score reflects how seriously they wanted this particular disclosure to be treated, given how many different downstream applications are built on ASP.NET Core, rather than any unique technical severity compared to those other implementations.

How Microsoft fixed it

The fix, shipped in the ASP.NET Core PR linked from the advisory, tightens up exactly the gap described above. Instead of only checking whether a line ends in \r\n and silently accepting anything else that happens to contain a stray \r or \n, Kestrel now explicitly checks for any line ending and rejects the request outright if it finds one that is not strictly \r\n. When that happens, it throws a KestrelBadHttpRequestException and the connection gets a 400 Bad Request response instead of proceeding with ambiguous input.

The relevant diff from the ASP.NET Core fix, adding an explicit InsecureChunkedParsing check and throwing on invalid chunk extension line endings.
The relevant diff from the ASP.NET Core fix, adding an explicit InsecureChunkedParsing check and throwing on invalid chunk extension line endings.

There is an AppContext switch available to opt back into the old, lenient parsing behaviour after you patch. Do not use it. There is no legitimate reason to re-enable request smuggling protection once your app is on a fixed runtime, and keeping that switch off is the entire point of installing the update in the first place.

Which versions are affected and how to patch

The vulnerability affects every currently supported line of .NET. For .NET 10, anything before 10.0.0-rc2 is vulnerable. For .NET 9, versions 9.0.0 through 9.0.9 are vulnerable, fixed in 9.0.10. For .NET 8, versions 8.0.0 through 8.0.20 are vulnerable, fixed in 8.0.21.

If you are running ASP.NET Core 2.3 on .NET Framework, you need to update the Microsoft.AspNetCore.Server.Kestrel.Core package from the 2.0.0 to 2.3.0 range up to 2.3.6. If you use self-contained deployments, updating the SDK on the build machine is not enough by itself; you need to rebuild and redeploy your application against the patched runtime.

Older, out-of-support versions do not get an official patch at all. That includes .NET Core 3.0, 3.1, .NET 5, .NET 6 outside of extended support arrangements, and .NET 7. HeroDevs, which offers paid extended support for end-of-life .NET versions, has confirmed they are patching .NET 6 for their customers, and testing shows the underlying parsing bug is present at least as far back as .NET Core 3.0. Interestingly, older ASP.NET Framework Web Forms and MVC applications, running on the classic .NET Framework rather than ASP.NET Core, do not appear to be vulnerable to this particular issue, since they never adopted Kestrel’s chunk parsing code in the first place.

If you host on Azure App Service, Microsoft has confirmed that the platform’s own front-end proxy, which is itself a YARP-based ASP.NET Core proxy, has already been patched. That protects your application even before you update it yourself, because the ambiguous request gets rejected at the proxy layer and never reaches your unpatched code. If you use a different hosting provider or a self-managed reverse proxy such as IIS, do not assume the same protection applies until your provider confirms it explicitly; unofficial testing referenced in the original research suggests IIS was still vulnerable to this specific chunk extension variant at the time of disclosure.

How to check whether you are exposed

The most reliable way to know whether you are safe is simply to run dotnet –info and confirm you are on a patched runtime version. There is no generally available scanning tool that reliably detects this specific vulnerability against a black-box target, though a functional test is possible if you control the environment. HeroDevs published a reproduction that sends a chunked request with a deliberately invalid line ending in a chunk extension and observes what happens: an unpatched server hangs waiting for more data because it thinks the request is incomplete, while a patched server immediately throws the new BadRequest exception and returns a 400.

You can get a rough sense of whether a front-end proxy in your own infrastructure is vulnerable using the same idea from a shell, by sending a crafted request with netcat and watching whether the connection hangs until it times out instead of returning a fast response.

# Send a chunked request with an invalid chunk extension line ending,
# and see if the connection hangs until it times out, or if it is
# rejected quickly with a 400.
echo -e "GET / HTTP/1.1\r\nHost: \r\nTransfer-Encoding: chunked\r\n\r\n1;\n" \
  | nc localhost 80

A hang here does not by itself prove the target is exploitable end to end, since that depends on what sits behind the proxy and how your application handles the raw stream, but a long pause followed by a timeout is a strong signal that the invalid line ending was accepted rather than rejected outright. A quick 400 response is what you want to see. Treat this as a rough diagnostic against infrastructure you own, not as a scanning tool to point at systems you do not control.

Practical takeaways

This bug only applies to HTTP/1.0 and HTTP/1.1, since HTTP/2 and HTTP/3 do not use chunked transfer encoding at all; they rely on a different binary framing layer that does not have this ambiguity. If you are stuck on an unpatched, out-of-support runtime and cannot update immediately, restricting Kestrel to HTTP/2 and HTTP/3 only, which you can configure on your Kestrel endpoints, removes this specific attack vector entirely. Be aware that this will break any client that only speaks HTTP/1.1, so it is a stopgap for internal or controlled traffic rather than something you can casually apply to a public-facing endpoint.

For everyone else, the practical checklist is short: update to a patched .NET 8, 9, or 10 patch version, rebuild and redeploy self-contained applications rather than assuming an in-place SDK update is sufficient, confirm with your hosting provider or proxy vendor whether their infrastructure already blocks the malformed request pattern, and avoid the AppContext switch that re-enables the old lenient parsing. If your code reads or forwards raw request streams instead of relying on ASP.NET Core’s model binding, treat that code as higher risk and review it separately, since patching Kestrel does not retroactively fix custom stream-handling logic that has its own parsing assumptions.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading