The Zimbra Classic UI ships a servlet filter named RestFilter whose pre-patch doFilter body is six lines: read the request's parameter map, iterate, and for each entry call request.setAttribute(key, value). The key is whatever the URL chose. The value is whatever the URL sent. There is no allowlist, no name-prefix check, no key-format gate.
Five names in the request-attribute namespace belong to the Servlet API. The container writes javax.servlet.include.servlet_path and four siblings during RequestDispatcher.include() to tell an included resource where the original dispatch went. Application code reads them. RestFilter writes to them on every inbound /h/* request, because it writes to every name.
CVE-2025-68645 is the corollary. The patch is an allowlist of forty-three Zimbra attribute names. None of them start with javax.servlet. The Servlet API's reserved namespace is excluded because Zimbra's list does not contain it, not because the patch denies it.
The filter is six lines
src/com/zimbra/webClient/filters/RestFilter.java lives in the zm-ajax repository, the codebase that builds Zimbra's Classic webmail UI. Its web.xml registration maps it to /h/*. Every URL under /h/, mail, calendar, contacts, conversations, the print* family, postLoginRedirect, the rest of the Classic UI, runs through it. The filter runs before authentication.
Pre-patch:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
Map<String,String[]> attrMap = request.getParameterMap();
for (Map.Entry<String, String[]> entry : attrMap.entrySet()) {
for (String value : entry.getValue()) {
request.setAttribute(entry.getKey(), value);
}
}
chain.doFilter(request, response);
}
request.getParameterMap() returns every key from the query string and every key from the form body. The loop calls request.setAttribute(entry.getKey(), value) on each. There is no condition, no validation, no membership test on the key. The filter takes whatever the URL parser produced and reflects it into the request-attribute namespace.
The file's commit history on develop is short. Six commits between May 2013 and February 2017, mostly Perforce integrations and a TODO-cleanup pass. None of them name the doFilter loop. The seventh, in 2025, is the patch.
The Servlet API owns five of those names
Servlet 3.0, section 9.3.1, "Included Request Parameters":
The values for the request URI, context path, servlet path, path info, and query string in the included context are made available to the included servlet via the following request attributes:
javax.servlet.include.request_uri
javax.servlet.include.context_path
javax.servlet.include.servlet_path
javax.servlet.include.path_info
javax.servlet.include.query_string
These attributes are how an included resource introspects the dispatch state the container set up for it. A JSP that has been pulled in through RequestDispatcher.include() reads javax.servlet.include.servlet_path to recover the path the original request hit. Code that wraps include() to do URL aliasing on top of the spec, the standard pattern in webapps that route /h/printcalendar through a single front-controller and then dispatch to per-view JSPs, reads the attribute as the canonical answer to "what path was this request actually for."
The spec does not say the container is the only writer. It says the container is the writer in the dispatch flow it describes. A filter that writes to the same names from a query parameter source is not banned by any prose; it is excluded by the convention that filters in the wild do not do this. Application code reads those attributes the way the spec authorizes, as the truth about the dispatch state. RestFilter is the writer the convention assumed did not exist.
The same shape repeats across the rest of the javax.servlet.* namespace. javax.servlet.forward.* (five names) carries the dispatch state for RequestDispatcher.forward(). javax.servlet.error.* (six names) carries error-page metadata. javax.servlet.async.* (five names) carries async-dispatch state. javax.servlet.request.* carries TLS and certificate data the connector populated at handshake time. Twenty-plus reserved names. The convention is the same. The enforcement is the same: nothing.
The exploit primitive is one query parameter
curl -k "https://target/h/printcalendar?javax.servlet.include.servlet_path=/WEB-INF/web.xml"
The request hits /h/printcalendar. RestFilter runs. The parameter map contains exactly one entry: {"javax.servlet.include.servlet_path": ["/WEB-INF/web.xml"]}. The loop calls request.setAttribute("javax.servlet.include.servlet_path", "/WEB-INF/web.xml"). The filter chain continues. The Classic UI's downstream handler, written against the spec, reads the attribute as the path the container is dispatching to and uses it to compose an include(). The include resolves to /WEB-INF/web.xml under the webapp's document root and writes the file's bytes to the response.
/WEB-INF/ is unreachable through direct external HTTP, by container policy. It is reachable through RequestDispatcher.include() by design, because the container's /WEB-INF/ protection is enforced at the URL parser, not at the dispatcher. The two halves of the request flow disagree about whether /WEB-INF/web.xml is a path the request is allowed to read. The dispatcher, which has the final word, reads it.
The detection PoCs enumerate thirteen /h/* paths the writers found responsive: h/rest, h/changepass, h/imessage, h/postLoginRedirect, h/printcalls, h/printcalendar, h/printvoicemails, h/printappointments, h/printcontacts, h/printconversations, h/printmessage, h/printtasks, h/viewimages. Every one of them runs through RestFilter, because every /h/* URL does. The thirteen are the subset where the downstream handler exercises the include-path attribute predictably enough for a scanner to confirm a hit on <web-app. The filter writes the attribute regardless of which JSP runs next.
The HarisAidhin/Poc_CVE-2025-68645 mass scanner extends the read out of /WEB-INF/: /opt/zimbra/conf/localconfig.xml, /opt/zimbra/conf/ldap-config.xml, /opt/zimbra/conf/attrs/zimbra-attrs.xml. Those paths are not under the webapp document root. They are reachable because the dispatcher resolves what the included JSP asks for through the JSP's own filesystem view, and a default Zimbra install puts those files at the listed absolute paths. The scanner greps the responses for password, secret, and trustedIPs and prints the matches. The advisory's "exposes sensitive information" is the implementation detail; the primitive is "anything the JSP container can read."
The patch enumerates Zimbra's names. The spec's are excluded by absence.
The fix is zm-ajax@24d3f4f, authored by Koichi Kato of Synacor on 2025-07-11 and merged later, shipped to customers in ZCS 10.0.18 and 10.1.13 on 2025-11-06. The body adds a private static final ArrayList<String> allowlist, populates it in init() with forty-three constants, and wraps the existing loop with a single membership test:
Map<String,String[]> attrMap= request.getParameterMap();
for(Map.Entry<String, String[]> entry: attrMap.entrySet()) {
- for(String value : entry.getValue()) {
- request.setAttribute(entry.getKey(), value);
+ if (allowlist.contains(entry.getKey())) {
+ for(String value : entry.getValue()) {
+ request.setAttribute(entry.getKey(), value);
+ }
}
}
The list is forty-three names. Twelve start with zimbra_target_ (item id, type, color, view, name, account name, account id, action targets). Eleven are unprefixed Zimbra-Classic-UI tokens (action, bodypart, color, date, fmt, folderIds, notoolbar, numdays, pstat, refresh, view, ...). Six start with zimbra_target_account_pref (timezone, skin, locale, calendar preferences). The rest are scattered: zimbra_request_uri, zimbra_internal_dispatch, zimbra_authToken, zimbra_csrfEnabled, zimbra_freebusy. None of them start with javax.servlet.
Two comments above the constants name the consumers:
// Reference: com.zimbra.cs.service.formatter.HtmlFormatter
// ...
// Reference: com.zimbra.cs.service.formatter.FreeBusyFormatter
The list is the union of attribute names those two formatters read via request.getAttribute() to drive their rendering. Koichi wrote the patch by walking the read-side and copying every name back to the write-side. The patch is the inverse-engineered shadow of the application's own attribute consumers.
The Servlet API's reserved namespace, javax.servlet.include.*, javax.servlet.forward.*, javax.servlet.error.*, javax.servlet.async.*, javax.servlet.request.*, twenty-plus names, is denied by accident. Those names are off the allowlist because Zimbra's own JSPs and formatters never pull them through request.getAttribute() for their own logic, not because the patch identifies them as a reserved namespace. The container's namespace is on the same side of the gate as the attacker-controlled namespace was. Both are excluded by the same rule.
A future change that adds a Zimbra attribute named, for instance, javax.servlet.include.zimbra_canary to the allowlist would open the gate for that name with no reviewer alarm, because the gate's vocabulary has no concept of "spec-reserved." The allowlist's safety is an observation about its current contents.
This is internal-only by convention
The Servlet API specifies what the container writes during dispatch. Application code reads it. The contract that no other writer exists is held by the convention that filters do not do what RestFilter did, not by enforcement at the attribute store. The same shape repeats wherever a framework's IPC namespace is reachable from network input: Next.js's x-middleware-subrequest was a colon-separated breadcrumb the framework wrote to itself on internal fetch() calls, read by the sandbox runner with auth-bypass authority and ungated on inbound requests. Nginx-UI's restore endpoint authenticated by a clock that reset on every process restart. ActiveMQ's vm:// transport was internal-only by documentation, exposed through any registered JMX caller. Each of those CVEs is internal-only-by-convention with the convention's writer and reader stitched together by an HTTP-shaped channel nobody declared.
Zimbra's RestFilter is the same pattern with the channel inverted. The framework whose namespace gets pierced is not Zimbra's, it is the Servlet API. Zimbra's filter is the public writer the spec assumed nobody would build. The downstream consumers (HtmlFormatter, FreeBusyFormatter, the /h/*.jsp views) are the spec's well-behaved readers. The convention that "only the container writes javax.servlet.include.*" is held by the absence of any other writer in the codebase. RestFilter is the absence resolved.
The advisory's wording, "Addressed an unauthenticated local file inclusion vulnerability in the RestFilter," reads as if the bug is a missing input-validation step the filter should have had. The filter was not missing input validation; the filter was the input-validation step for the request-attribute namespace, and the validation it performed was the empty set. CISA added the CVE to the Known Exploited Vulnerabilities catalog with a federal-civilian remediation deadline of 2026-02-12. The CVE was reported by devme4f from VNPT-VCI. The PoC repositories archive the public disclosure; the working primitive is in the patch diff.
PoC: 0xBlackash/CVE-2025-68645, HarisAidhin/Poc_CVE-2025-68645