From da7e4b0716c3345030538bfe8a23946a73819642 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 11:06:41 +0000 Subject: [PATCH] Don't confirm Apache path traversal from an HTML passwd example. CVE-2021-41773/42013 used _raw_http and treated any response containing "root:" plus "/bin/" as a retrieved /etc/passwd. A 404 docs page or SPA catch-all quoting a passwd snippet therefore pinned a CRITICAL finding. Require HTTP 200, reject HTML, and match a real passwd root line. Co-authored-by: dmitryflynn --- src/vuln_prober.py | 43 +++++++++++++++++++++++++++++++++++++++++-- test_probers.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/vuln_prober.py b/src/vuln_prober.py index df0807e..2aa716e 100644 --- a/src/vuln_prober.py +++ b/src/vuln_prober.py @@ -225,6 +225,45 @@ def _baseline_filter(status: Optional[int], body: Optional[str]) -> tuple[Option return status, body +# Classic /etc/passwd line: root::::… +_PASSWD_ROOT_LINE = re.compile(r"(?m)^root:[^:\n]*:\d+:\d+:") + + +def _http_status_and_body(raw: Optional[str]) -> tuple[Optional[int], str]: + """Split a raw HTTP response into (status, body). Missing framing → (None, "").""" + if not raw: + return None, "" + normalized = raw.replace("\r\n", "\n").replace("\r", "\n") + head, sep, body = normalized.partition("\n\n") + if not sep: + return None, "" + status = None + first = head.split("\n", 1)[0] + parts = first.split(None, 2) + if len(parts) >= 2 and parts[0].startswith("HTTP/") and parts[1].isdigit(): + status = int(parts[1]) + return status, body + + +def _looks_like_html(body: str) -> bool: + cl = (body or "").lstrip()[:200].lower() + return cl.startswith(" bool: + """True only when the response is HTTP 200 with a raw /etc/passwd body. + + ``_raw_http`` does not apply the SPA/CDN baseline filter, so a 404 tutorial + page (or catch-all HTML) that quotes a passwd example used to confirm + CVE-2021-41773 / CVE-2021-42013 as CRITICAL. Require a 200, reject HTML, + and match a real passwd ``root:…:uid:gid:`` line in the body only. + """ + status, body = _http_status_and_body(raw) + if status != 200 or not body or _looks_like_html(body): + return False + return _PASSWD_ROOT_LINE.search(body) is not None + + # ─── CVE-Specific Probes ───────────────────────────────────────────────────── def probe_apache_path_traversal(host: str, port: int, scheme: str = "http", @@ -237,7 +276,7 @@ def probe_apache_path_traversal(host: str, port: int, scheme: str = "http", path1 = "/cgi-bin/.%2e/.%2e/.%2e/.%2e/etc/passwd" req1 = (f"GET {path1} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").encode() resp1 = _raw_http(host, port, req1, timeout=timeout) - if resp1 and "root:" in resp1 and ("/bin/" in resp1 or "nologin" in resp1 or "daemon:" in resp1): + if _retrieved_etc_passwd(resp1): return CVEProbe( cve_id="CVE-2021-41773", title="Apache 2.4.49 Path Traversal — /etc/passwd Retrieved", @@ -253,7 +292,7 @@ def probe_apache_path_traversal(host: str, port: int, scheme: str = "http", path2 = "/cgi-bin/.%%32%65/.%%32%65/.%%32%65/.%%32%65/etc/passwd" req2 = (f"GET {path2} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").encode() resp2 = _raw_http(host, port, req2, timeout=timeout) - if resp2 and "root:" in resp2 and ("/bin/" in resp2 or "nologin" in resp2): + if _retrieved_etc_passwd(resp2): return CVEProbe( cve_id="CVE-2021-42013", title="Apache 2.4.50 Path Traversal (Double Encoding)", diff --git a/test_probers.py b/test_probers.py index eb4287c..316f5dd 100644 --- a/test_probers.py +++ b/test_probers.py @@ -293,6 +293,50 @@ def test_apache_traversal_detected_vs_clean(monkeypatch): assert vp.probe_apache_path_traversal("h", 80) is None +def test_apache_traversal_ignores_html_passwd_examples(monkeypatch): + """_raw_http skips the SPA/CDN baseline, so a 404/200 HTML page that quotes + a passwd snippet used to pin CVE-2021-41773 CRITICAL. Require HTTP 200 + a + raw passwd body, not HTML chrome.""" + html_404 = ( + "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n" + "Not Found" + "

Linux accounts look like root:x:0:0:root:/root:/bin/bash " + "and daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin

" + ) + monkeypatch.setattr(vp, "_raw_http", lambda *a, **k: html_404) + assert vp.probe_apache_path_traversal("h", 80) is None + + html_200 = ( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n" + "" + "
root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
" + "" + ) + monkeypatch.setattr(vp, "_raw_http", lambda *a, **k: html_200) + assert vp.probe_apache_path_traversal("h", 80) is None + + # Double-encoding branch: same HTML must not confirm CVE-2021-42013 either. + # First request (single encoding) misses; second (double encoding) is the HTML. + calls = {"n": 0} + def fake(*a, **k): + calls["n"] += 1 + return html_200 if calls["n"] > 1 else "HTTP/1.1 404 Not Found\r\n\r\n" + monkeypatch.setattr(vp, "_raw_http", fake) + assert vp.probe_apache_path_traversal("h", 80) is None + + +def test_apache_traversal_double_encoding_still_confirms(monkeypatch): + calls = {"n": 0} + def fake(*a, **k): + calls["n"] += 1 + if calls["n"] == 1: + return "HTTP/1.1 404 Not Found\r\n\r\n" + return "HTTP/1.1 200 OK\r\n\r\nroot:x:0:0:root:/root:/bin/bash\nnologin" + monkeypatch.setattr(vp, "_raw_http", fake) + f = vp.probe_apache_path_traversal("h", 80) + assert f and f.confirmed and f.cve_id == "CVE-2021-42013" + + def test_apache_traversal_handles_none(monkeypatch): monkeypatch.setattr(vp, "_raw_http", lambda *a, **k: None) assert vp.probe_apache_path_traversal("h", 80) is None