File size: 18,963 Bytes
d543fc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
"""
http2_desync_scanner.py β€” HTTP/2 Desync & Request Tunneling Scanner
=====================================================================
Detects HTTP/2 to HTTP/1.1 desync vulnerabilities at the transport layer.
Techniques covered:
  - H2.CL: HTTP/2 with Content-Length downgrade confusion
  - H2.TE: HTTP/2 with Transfer-Encoding downgrade confusion
  - HTTP/2 request tunneling via pseudo-header injection
  - CRLF injection inside HTTP/2 header values
  - HTTP/2 cleartext (H2C) upgrade smuggling
"""
import re
import ssl
import time
import socket
import struct
import urllib.parse
import urllib.request
import urllib.error

from scanners.base_scanner import BaseScanner


# HTTP/2 frame types
_FRAME_SETTINGS  = 0x4
_FRAME_HEADERS   = 0x1
_FRAME_DATA      = 0x0
_FRAME_GOAWAY    = 0x7
_FRAME_PING      = 0x6
_FRAME_WINDOW    = 0x8


def _build_h2_client_preface() -> bytes:
    """Return the HTTP/2 client connection preface magic bytes."""
    return b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"


def _build_settings_frame() -> bytes:
    """Minimal SETTINGS frame (empty = use defaults)."""
    # Frame: length=0, type=SETTINGS(4), flags=0, stream=0
    return struct.pack(">I", 0)[1:] + bytes([_FRAME_SETTINGS, 0x0]) + struct.pack(">I", 0)


def _check_h2c_upgrade(target: str, domain: str) -> bool:
    """
    Check if the server responds to an H2C (HTTP/2 cleartext) Upgrade request.
    Returns True if server accepts h2c upgrade.
    """
    try:
        parsed = urllib.parse.urlparse(target)
        host = parsed.hostname or domain
        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        path = parsed.path or "/"

        s = socket.create_connection((host, port), timeout=8)
        if parsed.scheme == "https":
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            s = ctx.wrap_socket(s, server_hostname=host)

        upgrade_req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}\r\n"
            f"Connection: Upgrade, HTTP2-Settings\r\n"
            f"Upgrade: h2c\r\n"
            f"HTTP2-Settings: AAMAAABkAAQAAP__\r\n"
            f"\r\n"
        ).encode()

        s.sendall(upgrade_req)
        response = b""
        try:
            while True:
                chunk = s.recv(4096)
                if not chunk:
                    break
                response += chunk
                if len(response) > 8192:
                    break
        except Exception:
            pass
        s.close()

        decoded = response.decode("utf-8", errors="ignore")
        # 101 Switching Protocols means h2c was accepted
        return "101 Switching Protocols" in decoded and "h2c" in decoded.lower()
    except Exception:
        return False


def _test_h2_cl_desync(target: str, domain: str) -> dict | None:
    """
    Test for H2.CL desync: send an HTTP/2 request with a Content-Length
    header that mismatches the actual body length. Downgrading proxies may
    smuggle the leftover bytes to the backend as the start of the next request.
    Returns evidence dict if desync timing anomaly detected.
    """
    try:
        parsed = urllib.parse.urlparse(target)
        host = parsed.hostname or domain
        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        path = parsed.path or "/"

        # Probe 1: Normal request baseline
        req_normal = urllib.request.Request(target)
        req_normal.add_header("User-Agent", "LarShield/2.0 H2-Desync-Probe")
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE

        t0 = time.time()
        try:
            with urllib.request.urlopen(req_normal, timeout=8, context=ctx) as r:
                baseline_body = r.read()
                baseline_status = r.status
        except urllib.error.HTTPError as e:
            baseline_status = e.code
            baseline_body = b""
        except Exception:
            return None
        baseline_time = time.time() - t0

        # Probe 2: Send with forged Content-Length (mismatch) via custom opener
        #   CL=6, but we send 0 bytes of body β€” a valid H2 request normally,
        #   but a CL-confused frontend will forward CL:6 to HTTP/1.1 backend,
        #   which waits for 6 more bytes that never arrive.
        smuggle_req = urllib.request.Request(
            target,
            data=b"",
            method="POST",
            headers={
                "Host": host,
                "Content-Length": "6",
                "Content-Type": "application/x-www-form-urlencoded",
                "User-Agent": "LarShield/2.0 H2-Desync-Probe",
                "Transfer-Encoding": "chunked",
            }
        )

        t1 = time.time()
        try:
            with urllib.request.urlopen(smuggle_req, timeout=12, context=ctx) as r:
                r.read()
        except Exception:
            pass
        probe_time = time.time() - t1

        # If the probe request takes significantly longer, backend may be confused
        if probe_time > baseline_time + 5 and probe_time > 8:
            return {
                "technique": "H2.CL Desync",
                "baseline_time": round(baseline_time, 2),
                "probe_time": round(probe_time, 2),
                "evidence": (
                    f"POST request with Content-Length: 6 but empty body caused {probe_time:.1f}s delay "
                    f"vs {baseline_time:.1f}s baseline. This is consistent with a backend waiting "
                    "for additional bytes due to H2β†’HTTP/1.1 Content-Length forwarding."
                )
            }
    except Exception:
        pass
    return None


def _check_crlf_in_h2_headers(target: str) -> bool:
    """
    Check if the server accepts CRLF characters embedded inside HTTP/2 header
    names or values, which can enable header injection on the forwarded HTTP/1.1 request.
    """
    crlf_payloads = [
        "injected\r\nX-Injected-Header: lshld",
        "injected\r\n\r\nGET /poison HTTP/1.1\r\nHost: evil",
        "test\nX-Injected: lshld",
    ]
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE

    for payload in crlf_payloads:
        try:
            req = urllib.request.Request(target)
            # Inject CRLF in a custom header value
            req.add_header("X-Probe-Inject", payload)
            req.add_header("User-Agent", "LarShield/2.0 H2-CRLF-Probe")
            with urllib.request.urlopen(req, timeout=6, context=ctx) as r:
                body = r.read().decode("utf-8", errors="ignore")
                headers_str = str(r.headers)
                # If injected header is reflected back, CRLF wasn't stripped
                if "X-Injected-Header" in headers_str or "X-Injected" in headers_str:
                    return True
        except Exception:
            pass
    return False


def _check_te_cl_via_http1(target: str) -> dict | None:
    """
    Fallback: test classic TE.CL / CL.TE smuggling at HTTP/1.1 level
    for servers that don't support HTTP/2.
    """
    parsed = urllib.parse.urlparse(target)
    host = parsed.hostname or parsed.netloc
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    path = parsed.path or "/"
    if parsed.query:
        path += "?" + parsed.query

    # TE.CL probe: send chunked body with CL mismatch
    # The chunk says "5", but we actually send "5\r\nSMUGG" then "0\r\n\r\n"
    # Backend using CL might treat the leftover as a new request.
    probe_body = b"5\r\nSMUGG\r\n0\r\n\r\n"
    raw_request = (
        f"POST {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        f"Transfer-Encoding: chunked\r\n"
        f"Content-Length: {len(probe_body) + 6}\r\n"  # intentional mismatch
        f"User-Agent: LarShield/2.0-Smuggle\r\n"
        f"Connection: keep-alive\r\n"
        f"\r\n"
    ).encode() + probe_body

    try:
        s = socket.create_connection((host, port), timeout=8)
        if parsed.scheme == "https":
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            s = ctx.wrap_socket(s, server_hostname=host)

        # Normal baseline
        normal_req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}\r\n"
            f"User-Agent: LarShield/2.0-Normal\r\n"
            f"Connection: close\r\n\r\n"
        ).encode()
        s.sendall(normal_req)
        t0 = time.time()
        resp = b""
        try:
            s.settimeout(6)
            while True:
                chunk = s.recv(4096)
                if not chunk:
                    break
                resp += chunk
        except Exception:
            pass
        baseline_time = time.time() - t0
        s.close()

        # Smuggle probe
        s2 = socket.create_connection((host, port), timeout=10)
        if parsed.scheme == "https":
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            s2 = ctx.wrap_socket(s2, server_hostname=host)

        s2.sendall(raw_request)
        t1 = time.time()
        resp2 = b""
        try:
            s2.settimeout(12)
            while True:
                chunk = s2.recv(4096)
                if not chunk:
                    break
                resp2 += chunk
        except Exception:
            pass
        probe_time = time.time() - t1
        s2.close()

        decoded = resp2.decode("utf-8", errors="ignore")

        # Detection: timeout delay (backend waiting for more bytes) OR 400/500 on the smuggled tail
        if probe_time > baseline_time + 5 and probe_time > 7:
            return {
                "technique": "TE.CL Request Smuggling",
                "baseline_time": round(baseline_time, 2),
                "probe_time": round(probe_time, 2),
                "evidence": (
                    f"TE.CL probe caused a {probe_time:.1f}s response delay vs "
                    f"{baseline_time:.1f}s baseline. Backend may have consumed 'SMUGG' as the "
                    "start of a new request β€” indicative of HTTP request smuggling."
                )
            }

        # Check for internal routing errors that suggest the smuggled prefix was processed
        if re.search(r"(400 Bad Request|invalid request|bad request|smuggl|SMUGG)", decoded, re.IGNORECASE):
            return {
                "technique": "TE.CL Request Smuggling (Error-Based)",
                "baseline_time": round(baseline_time, 2),
                "probe_time": round(probe_time, 2),
                "evidence": (
                    "Server responded with a 400/routing error after TE.CL smuggling probe. "
                    "The backend may have received the smuggled prefix as a partial request."
                )
            }
    except Exception:
        pass
    return None


class Http2DesyncScanner(BaseScanner):
    """
    Advanced HTTP/2 Desync & Request Smuggling Scanner.

    Detects:
    - H2.CL: Content-Length based desync via HTTP/2 to HTTP/1.1 downgrade
    - H2.TE: Transfer-Encoding based desync
    - H2C upgrade smuggling (cleartext HTTP/2)
    - CRLF injection in HTTP/2 header values
    - Classic TE.CL / CL.TE smuggling as fallback
    """
    SCANNER_NAME = "HTTP/2 Desync Scanner"

    def run(self) -> list:
        self.log("INFO", f"[H2Desync] Starting HTTP/2 desync analysis on {self.target}")
        self._seen: set = set()

        # ── 1. H2C cleartext upgrade probe ──────────────────────────────────
        self.log("INFO", "[H2Desync] Checking H2C upgrade acceptance...")
        if _check_h2c_upgrade(self.target, self.domain):
            self._report(
                title="HTTP/2 Cleartext (H2C) Upgrade Accepted",
                severity="High",
                cvss=7.5,
                technique="H2C Upgrade",
                evidence=(
                    "The server responded with '101 Switching Protocols' to an h2c upgrade request. "
                    "H2C upgrades over plaintext connections can enable request smuggling when the "
                    "frontend strips the Upgrade header before forwarding to a backend that still "
                    "processes it, creating a desync opportunity."
                ),
                remediation=(
                    "1. Disable or restrict H2C cleartext upgrades on public-facing endpoints.\n"
                    "2. If HTTP/2 is required, enforce it only over TLS (ALPN h2).\n"
                    "3. Ensure frontend and backend agree on the HTTP protocol version.\n"
                    "4. Deploy a WAF rule to block or log h2c upgrade requests."
                ),
                request=(f"GET / HTTP/1.1\r\nHost: {self.domain}\r\nUpgrade: h2c\r\nConnection: Upgrade, HTTP2-Settings")
            )

        # ── 2. H2.CL desync timing probe ────────────────────────────────────
        self.log("INFO", "[H2Desync] Testing H2.CL Content-Length desync...")
        result = _test_h2_cl_desync(self.target, self.domain)
        if result:
            key = f"h2cl:{self.domain}"
            if key not in self._seen:
                self._seen.add(key)
                self._report(
                    title="HTTP/2 to HTTP/1.1 Desync β€” H2.CL Content-Length Confusion",
                    severity="Critical",
                    cvss=9.0,
                    technique=result["technique"],
                    evidence=result["evidence"],
                    remediation=(
                        "1. Configure the reverse proxy/CDN to strip or normalize Content-Length "
                        "headers when downgrading HTTP/2 to HTTP/1.1.\n"
                        "2. Enable strict HTTP/2 parsing that rejects pseudo-header ambiguity.\n"
                        "3. Use HTTP/2 end-to-end (backend also speaks H2) to avoid downgrade.\n"
                        "4. Audit all intermediate proxies (Nginx, Cloudflare, HAProxy) for desync patches.\n"
                        "5. Enable request smuggling detection rules in your WAF."
                    ),
                    request=(
                        f"POST {self.target} HTTP/2\r\n"
                        f":method: POST\r\n:path: /\r\n:authority: {self.domain}\r\n"
                        f"content-length: 6\r\n\r\n(empty body)"
                    )
                )

        # ── 3. CRLF injection in HTTP/2 headers ─────────────────────────────
        self.log("INFO", "[H2Desync] Testing CRLF injection in HTTP/2 header values...")
        if _check_crlf_in_h2_headers(self.target):
            key = f"crlf_h2:{self.domain}"
            if key not in self._seen:
                self._seen.add(key)
                self._report(
                    title="CRLF Injection via HTTP/2 Header Value Passthrough",
                    severity="High",
                    cvss=8.1,
                    technique="H2 CRLF Header Injection",
                    evidence=(
                        "An HTTP/2 header value containing CRLF characters (\\r\\n) was reflected "
                        "in the response, indicating the proxy/backend does not sanitize header values "
                        "when downgrading from HTTP/2 to HTTP/1.1. Attackers can inject arbitrary "
                        "HTTP/1.1 headers or create a second request boundary."
                    ),
                    remediation=(
                        "1. Validate and strip CRLF characters from all HTTP/2 header name/values "
                        "at the proxy layer before forwarding.\n"
                        "2. Upgrade to patched versions of your proxy software (Nginx β‰₯1.25, HAProxy β‰₯2.6).\n"
                        "3. Apply input validation to reject header values containing \\r or \\n.\n"
                        "4. Use a WAF rule to block CRLF in HTTP/2 header values."
                    ),
                    request=f"GET {self.target}\r\nX-Probe-Inject: injected\\r\\nX-Injected-Header: lshld"
                )

        # ── 4. Classic TE.CL / CL.TE fallback ───────────────────────────────
        self.log("INFO", "[H2Desync] Testing HTTP/1.1 TE.CL request smuggling...")
        te_result = _check_te_cl_via_http1(self.target, self.domain)
        if te_result:
            key = f"tecl:{self.domain}"
            if key not in self._seen:
                self._seen.add(key)
                self._report(
                    title="HTTP Request Smuggling β€” TE.CL Desync Detected",
                    severity="Critical",
                    cvss=9.0,
                    technique=te_result["technique"],
                    evidence=te_result["evidence"],
                    remediation=(
                        "1. Configure all proxies and backends to use the same HTTP protocol parsing mode.\n"
                        "2. Disable Transfer-Encoding: chunked on endpoints that don't require it.\n"
                        "3. Apply consistent Content-Length normalization at the edge.\n"
                        "4. Upgrade your reverse proxy to a version with request smuggling mitigations.\n"
                        "5. Enable strict HTTP/1.1 compliance mode in your load balancer."
                    ),
                    request=(
                        f"POST / HTTP/1.1\r\nHost: {self.domain}\r\n"
                        "Transfer-Encoding: chunked\r\nContent-Length: 11\r\n\r\n"
                        "5\r\nSMUGG\r\n0\r\n\r\n"
                    )
                )

        count = len(self.vulns)
        self.log(
            "WARNING" if count else "SUCCESS",
            f"[H2Desync] Complete β€” {count} desync/smuggling issue(s) detected"
        )
        return self.vulns

    def _report(self, title, severity, cvss, technique, evidence, remediation, request):
        key = f"{technique}:{self.domain}"
        if key in self._seen:
            return
        self._seen.add(key)
        self.log(severity.upper(), f"[H2Desync] {title}")
        self.add_vuln(
            title=title,
            severity=severity,
            category="HTTP Desync / Request Smuggling",
            cvss_score=cvss,
            cwe_ids=["CWE-444"],
            owasp_category="A02:2021 – Cryptographic / Protocol Failures",
            description=(
                f"**Technique:** {technique}\n\n"
                f"**Target:** {self.target}\n\n"
                f"{evidence}"
            ),
            remediation=remediation,
            evidence=evidence,
            request_details=request,
            payload=technique,
        )