New Approach to RCE Zimbra Mail Server with admin access (CVE-2025-68645)
Khi đã có token admin, một attack chain phổ biến có lẽ đã khá quen thuộc là dùng extension ClientUploader ngay trong Zimbra để upload webshell và RCE được server.
Tuy nhiên hướng này đã không còn có thể lợi dụng do từ năm 2022, Zimbra đã vá lỗ hổng cũng như extension trên không còn được cài đặt mặc định, lần lượt qua hai mã là CVE-2022-45912 và CVE-2023-34193.
Bài viết sẽ ghi lại một hướng khác có thể RCE được Zimbra thông qua chain 2 lỗ hổng mà mình tìm ra gần đây, tạm gán mã CVE-2025-68645, với điều kiện có account admin và truy cập được web admin console port 7071, khá khoặt nghèo nhưng mình nghĩ vẫn có tính ứng dụng trong các đợt Redteam.
Unauth Local File Inclusion
Tại 2 thư viện là zm-ajax và zm-admin-ajax có class RestFilter lọt ngay vào tầm mắt mình với method doFilter() thực hiện set request attributes với key/value được lấy từ request parameters tương ứng.
public class RestFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
Map<String, String[]> attrMap = request.getParameterMap();
for (Entry<String, String[]> entry : attrMap.entrySet()) {
for (String value : entry.getValue()) {
request.setAttribute(entry.getKey(), value);
}
}
chain.doFilter(request, response);
} else {
chain.doFilter(request, response);
}
}
}
Việc set được request attributes khá tương tự lỗ hổng Tomcat Ghostcat (CVE-2020-1938) khi thông qua khai thác giao thức AJP để đọc hay include file (thêm file upload thì có thể RCE).
Trở lại với Zimbra, filter này sẽ được áp dụng với các request có prefix là /h/* theo cấu hình trong file web.xml:
<filter-mapping>
<filter-name>RestFilter</filter-name>
<url-pattern>/h/*</url-pattern>
</filter-mapping>
Cùng với đó là định nghĩa các url được handle bởi JspServlet như sau:
<jsp-config>
<jsp-property-group>
<url-pattern>/h/changepass</url-pattern>
<url-pattern>/h/imessage</url-pattern>
<url-pattern>/h/postLoginRedirect</url-pattern>
<url-pattern>/h/printcalls</url-pattern>
<url-pattern>/h/printcalendar</url-pattern>
<url-pattern>/h/printvoicemails</url-pattern>
<url-pattern>/h/printappointments</url-pattern>
<url-pattern>/h/rest</url-pattern>
<!-- truncated -->
</jsp-property-group>
</jsp-config>
JettyJspServlet là class thực hiện handle các request đến file Jsp, có 3 request attributes đặc biệt là:
javax.servlet.include.request_uri // [1]
javax.servlet.include.servlet_path // [2]
javax.servlet.include.path_info // [3]
Mà nếu ta kiểm soát được thì có thể override lại pathInContext để trỏ tới file bất kỳ [4]:
public class JettyJspServlet extends JspServlet {
public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpServletRequest request = null;
if (req instanceof HttpServletRequest) {
Object var4 = null;
Object var5 = null;
String var10;
String var11;
if (req.getAttribute("javax.servlet.include.request_uri") != null) { // [1]
var10 = (String)req.getAttribute("javax.servlet.include.servlet_path"); // [2]
var11 = (String)req.getAttribute("javax.servlet.include.path_info"); // [3]
if (var10 == null) {
var10 = req.getServletPath();
var11 = req.getPathInfo();
}
} else {
var10 = req.getServletPath();
var11 = req.getPathInfo();
}
String pathInContext = this.addPaths(var10, var11); // [4]
String jspFile = this.getInitParameter("jspFile");
if (jspFile == null) {
if (pathInContext != null && pathInContext.endsWith("/")) {
this.getServletContext().getNamedDispatcher("default").forward(req, resp);
return;
}
String realPath = this.getServletContext().getRealPath(pathInContext); // [5]
if (realPath != null) {
Path asPath = Paths.get(realPath);
if (Files.exists(asPath, new LinkOption[0]) && Files.isDirectory(asPath, new LinkOption[0])) {
this.getServletContext().getNamedDispatcher("default").forward(req, resp);
return;
}
}
}
super.service(req, resp);
Tại [5], Jetty dựa vào pathInContext để lấy realPath trong ServletContext, tức chỉ giới hạn trong webroot của app hiện tại. Do đó, hướng đọc file localconfig.xml (nằm ngoài webroot) để lấy admin account là không thể.
Jetty sau đó sẽ dùng path này để tìm và thực thi file này như một file Jsp. Đây là một chuẩn trong servlet specification mà servlet server nào cũng phải triển khai lại, nên đây cũng là vector mà lỗ hổng Ghoshcat trong Tomcat đã dùng.
Việc kiểm soát được attributes của request khá nghiêm trọng, đặc biệt nếu các app dùng request attributes để lưu các cấu hình như xác thực hay phân quyền người dùng, tuy nhiên với Zimbra thì không quá phụ thuộc vào điều này, do đó attack vector chỉ dừng lại ở đọc hoặc include file.
PoC

/WEB-INF/web.xml được compile thành class như 1 file Jsp:

Arbitrary File Write via file export
Để RCE, điều còn lại là tìm một lỗ hổng có thể ghi hay sửa file trong webroot.
Sau khi dành “kha khá” thời gian đọc code lẫn trace các sink file write của java mà không có kết quả, mình chuyển qua đọc Zimbra SOAP API docs: https://files.zimbra.com/docs/soap_api/10.1.0/api-reference/index.html
SELECT INTO OUTFILE seem interesting:

ExportAndDeleteItems cho phép export mailbox items ra một file với sqlExportDir được lấy từ [1] mà không hề qua kiểm tra:
public class ExportAndDeleteItems extends AdminDocumentHandler {
public Element handle(Element requst, Map<String, Object> context) throws ServiceException {
ZimbraSoapContext zsc = getZimbraSoapContext(context);
this.checkRight(zsc, context, null, AdminRight.PR_SYSTEM_ADMIN_ONLY);
ExportAndDeleteItemsRequest req = zsc.elementToJaxb(requst);
ExportAndDeleteMailboxSpec mailbox = req.getMailbox();
if (mailbox == null) {
throw ServiceException.INVALID_REQUEST("empty mbox id", null);
} else {
// truncated
String dirPath = req.getExportDir(); // [1]
String prefix = req.getExportFilenamePrefix();
mbox.lock.lock();
try {
DbPool.DbConnection conn = null;
try {
conn = DbPool.getConnection();
if (dirPath != null) {
File exportDir = new File(dirPath);
if (!exportDir.isDirectory()) {
DbPool.quietClose(conn);
throw ServiceException.INVALID_REQUEST(dirPath + " is not a directory", null);
}
String filePath = this.makePath(dirPath, "mail_item", prefix); // [2]
this.export(conn, mbox, "mail_item", "id", idRevs, filePath); // [3]
// truncated
Tại [2], dirPath được dùng để tạo filePath qua method makePath():
String makePath(String dirPath, String tableName, String prefix) {
if (prefix == null) {
prefix = "";
}
return dirPath + "/" + prefix + tableName + ".txt";
}
[3] Và gọi DbBlobConsistency.export():
public class DbBlobConsistency {
public static void export(DbPool.DbConnection conn, Mailbox mbox, String tableName, String idColName, Multimap<Integer, Integer> idRevs, String path) throws ServiceException {
// truncated
PreparedStatement stmt = null;
if (!(Db.getInstance() instanceof MySQL)) {
throw ServiceException.INVALID_REQUEST("export is only supported for MySQL", null);
} else {
ZimbraLog.sqltrace.info("Exporting %d items in table %s to %s.", new Object[]{idRevs.size(), tableName, path});
try {
StringBuffer sql = new StringBuffer();
boolean revisionTable = tableName.startsWith("revision");
sql.append("SELECT * FROM ").append(DbMailbox.qualifyTableName(mbox, tableName)).append(" WHERE ").append(DbMailItem.IN_THIS_MAILBOX_AND);
// truncated
sql.append(" INTO OUTFILE ?");
stmt = conn.prepareStatement(sql.toString());
// truncated
stmt.setString(pos++, path);
stmt.execute();
// truncated
Nhờ đó ta có thể export file .txt ra một ví trí bất kỳ trên filesystem thông qua tham số exportDir. Để làm được điều này ta có thể tự gửi một mail vào chính account hiện tại, sau đó export mail item này thông qua SOAP API ExportAndDeleteItems.
ExportAndDeleteItems thuộc namespace urn:zimbraAdmin nên chỉ có thể truy cập thông qua port admin 7071 cũng như yêu cầu token admin, đây chính là điểm hạn chế của chain này.
PoC
-
Đăng nhập vào Zimbra mailbox và gửi mail với nội dung là một jsp webshell:

-
Lấy item id:

-
Gọi SOAP API
ExportAndDeleteItemsvới token admin dùng port7071:
-
Có thể xem trước nội dung file vừa export qua
RestFiltermà không qua các url-pattern trongjsp-property-groupđể không bị compile:
-
Include file trên, PoC chạy command tạo file
/tmp/rce_from_web1:
Command thực thi thành công:
