Pre-Auth RCE in ManageEngine OPManager

Title

Pre-Auth RCE in ManageEngine OPManager

Product

ManageEngine OpManager

Vulnerable Version

121000 - 125233

Fixed Version

>= 125233

Impact

Critical

Vulnerability Summary

ManageEngine OpManager is a popular Java-based network monitoring solution used by large companies such as NASA, DHL or Siemens. Among other things, it allows the monitoring of network devices such as routers, webcams, servers, firewalls, and others. In this post we present a critical deserialization vulnerability which allows an unauthenticated attacker to execute arbitrary system commands with root or Administrator privileges. The vulnerability not only affects ManageEngine OpManager but also other products that are based upon OpManager such as ManageEngine NetFlow Analyzer.

Vulnerability Details

The vulnerability exists in the SUMCommunicationServlet. The not so Smart Update Manager (SUM) Communication Servlet can be invoked through the endpoint /servlets/com.adventnet.tools.sum.transport.SUMCommunicationServlet without prior authentication checks. The following Listing shows the entry point of the servlet for POST requests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class SUMCommunicationServlet extends HttpServlet {

    public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        HttpSession httpSession = request.getSession();
        response.setStatus(200);
        DataOutputStream dos = new DataOutputStream(response.getOutputStream());
        if (httpSession == null) {
            dos.writeInt(1000);
        } else {
            SUMHttpRequestHandler requestHandler = (SUMHttpRequestHandler)httpSession.getAttribute("requestHandler");
            if (requestHandler == null) {
                dos.writeInt(1000);
            } else {
                byte[] responseData = requestHandler.process(request.getInputStream());
                [...]
            }
        }
        dos.flush();
    }
}
If the request contains a session, the application tries to get an instance of SUMHttpRequestHandler (line 10) from the HttpSession attributes. If successful, the execution reaches line 14, where the body of the POST request is processed by the SUMHttpRequestHandler. We can add the requestHandler to our session by first sending a POST request containing the serialized integer 1002 to the SUMHandshakeServlet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class SUMHandShakeServlet extends HttpServlet {

    public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        ObjectInputStream ois = new ObjectInputStream(request.getInputStream());
        int requestId = ois.readInt();
        HttpSession httpSession;
        if (requestId == 1002) {
            httpSession = request.getSession(true);
            SUMHttpRequestHandler reqHandler = new SUMHttpRequestHandler(request.getRemoteHost());
            httpSession.setAttribute("requestHandler", reqHandler);
         [...]   
        }
    }
}
The function SUMHttpRequestHandler.process converts our payload InputStream to a DataInputStream, reads the payload length from that stream and finally creates a byte array from our payload. After that conversion process, function processSumPDU with the payload byte array as the argument is called.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
   public byte[] process(InputStream is) {
        BufferedInputStream bis = new BufferedInputStream(is);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        byte[] dataa = new byte[4096];
        int lengthx;
 
        while((lengthx = bis.read(dataa, 0, dataa.length)) > 0) {
             bos.write(dataa, 0, lengthx);
         }

        DataInputStream ois = new DataInputStream(new ByteArrayInputStream(bos.toByteArray()));
        int length = ois.readInt();
        byte[] data = new byte[length];
        ois.readFully(data, 0, length);
        byte[] processedData = this.processSumPDU(data);
        [...]
In this function, our payload is passed further to method SUMPDU.deserializePDU in line 8.

1
2
3
4
5
6
7
8
   private byte[] processSumPDU(byte[] pduData) throws Exception {
        if (pduData == null) {
            return null;
        } else if (pduData == SUMPDU.CLOSE_SESSION) {
            this.cleanUp();
            return null;
        } else {
            SUMPDU sumpdu = SUMPDU.deSerializePDU(pduData);
And finally, the payload is deserialized with Java built-in class ObjectInputStream. This means we can deserialize an arbitrary object which can lead to a critical vulnerability when a corresponding gadget chain is available.

1
2
3
4
    public static SUMPDU deSerializePDU(byte[] b) throws Exception {
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(b));
        return (SUMPDU)ois.readObject();
    }

The Chain

In order to leverage this vulnerability into a fully blown RCE, we need a gadget chain that allows executing Java code or system commands. An option is to look for a custom chain in the code base of OPManager and the included libraries or looking for publicly available gadgets. In this case, OpManager uses the commons-beanutils-1.9.3.jar as a dependency. For this library, a publicly known RCE chain exists from ysoserial. The CommonsBeanutils1 chain from ysoserial requires commons-beanutils:1.9.3, commons-collections:3.1 and commons-logging:1.2 and will therefore fail since the library commons-collections is not present in the classpath of OpManager.

We slightly modified the CommonsBeanutils1 chain such that commons-collections is not required anymore. With this chain, we are able to execute arbitrary Java byte code. The chain is based on the three gadgets which we detail in the following sections.

  • java.util.PriorityQueue
    • org.apache.commons.beanutils.BeanComparator
      • com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl

The PriorityQueue Gadget

The class java.util.PriorityQueue is a built-in Java class that implements a priority queue that can be ordered by a custom comparator. It implements a custom deserialization function of the Serializable interface. This custom function readObject is invoked during deserialization and is our entry point. The following Listing shows an excerpt of class PriorityQueue.

  1. An array of objects from the attacker controlled ObjectInputStream is written into property queue.
  2. Function heapify is called in line 8 to order the array by our custom comparator.
  3. Then, shiftDown is called in line 13.
  4. Since we will provide a custom comparator siftDownUsingComparator is called in line 19.
  5. Finally, function compare of our custom comparator is invoked in line 31.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
    private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
        var1.defaultReadObject();
        var1.readInt();
        this.queue = new Object[this.size];
        for(int var2 = 0; var2 < this.size; ++var2) {
            this.queue[var2] = var1.readObject();
        }
        this.heapify();
    }

    private void heapify() {
        for(int var1 = (this.size >>> 1) - 1; var1 >= 0; --var1) {
            this.siftDown(var1, this.queue[var1]);
        }
    }

    private void siftDown(int var1, E var2) {
        if (this.comparator != null) {
            this.siftDownUsingComparator(var1, var2);
        } else {
            this.siftDownComparable(var1, var2);
        }
    }

    private void siftDownUsingComparator(int var1, E var2) {
        int var4;
        for(int var3 = this.size >>> 1; var1 < var3; var1 = var4) {
            var4 = (var1 << 1) + 1;
            Object var5 = this.queue[var4];
            int var6 = var4 + 1;
            if (var6 < this.size && this.comparator.compare(var5, this.queue[var6]) > 0) {
                var4 = var6;
                var5 = this.queue[var6];
            }
        }
    }
As a custom comparator we choose another gadget - the org.apache.commons.beanutils.BeanComparator gadget.

The BeanComparator Gadget

The BeanComparator allows comparing two objects based on a supplied property. This means that the getter function of the property is called on both objects and the results are compared. In particular, calling an arbitrary getter function of an object’s property makes this gadget powerful. As mentioned before, the public chain requires the library commons-collections:3.1 as we can see from the import of class ComparableComparator. However, it is possible to use the overloaded constructor with a native Java comparator such as java.util.Collections.ReverseComparator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.apache.commons.collections.comparators.ComparableComparator;

public class BeanComparator implements Comparator, Serializable {
    private String property;
    private Comparator comparator;

    public BeanComparator(String property, Comparator comparator) {
        this.setProperty(property);
        if (comparator != null) {
            this.comparator = comparator;
        } else {
            this.comparator = ComparableComparator.getInstance();
        }
    }

    public int compare(Object o1, Object o2) {
        if (this.property == null) {
            return this.comparator.compare(o1, o2);
        } else {
                Object value1 = PropertyUtils.getProperty(o1, this.property);
                Object value2 = PropertyUtils.getProperty(o2, this.property);
                return this.comparator.compare(value1, value2);
            }
        }
    }

The TemplatesImpl Gadget

With the ability to call an arbitrary getter function of an arbitrary object we can continue with building the chain to get RCE. The Xalan TemplatesImpl gadget provides the rare feature to define and initialize classes through supplied Java bytecode. The execution of our malicious class constructor can be triggered by invoking the public getter function getOutputProperties through the previous gadget (line 2). In line 6 getTransletInstance is invoked and in line 13 finally, the constructor of our malicious class property is called. By default, OpManager runs with root/Administrator privileges. Therefore our exploit pops a root shell on Linux and an Administrator shell on Windows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    public synchronized Properties getOutputProperties() {
            return this.newTransformer().getOutputProperties();
    }

    public synchronized Transformer newTransformer() throws TransformerConfigurationException {
        TransformerImpl var1 = new TransformerImpl(this.getTransletInstance(), this._outputProperties, this._indentNumber, this._tfactory);
        [...]
    }
   private Translet getTransletInstance() throws TransformerConfigurationException {
        if (this._class == null) {
            this.defineTransletClasses();
        }
        AbstractTranslet var1 = (AbstractTranslet)this._class[this._transletIndex].newInstance();
        var1.postInitialization();
        var1.setTemplates(this);
        var1.setServicesMechnism(this._useServicesMechanism);
        var1.setAllowedProtocols(this._accessExternalStylesheet);
        if (this._auxClasses != null) {
            var1.setAuxiliaryClasses(this._auxClasses);
        }

        return var1;
    }

The Fix

Fixing object injection vulnerabilities is not a big deal if the classes that should be deserialized are known. One solution is creating a wrapper around the built-in ObjectInputStream and only allow classes from a whitelist for deserialization. This can be achieved by overriding the method resolveClass of ObjectInputStream. The following snippet shows the simplified wrapper class that was used to fix the reported RCE which contains a fatal mistake.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class InSecureObjectInputStream extends ObjectInputStream {
    private static List<String> whitlelist = Arrays.asList("[Ljava.lang.String;");
	private boolean classResolved = false;

    protected Class<?> resolveClass(ObjectStreamClass streamclass){
		if (!this.classResolved) {
			String untrustedClassName = streamclass.getName();        
			if(!this.whitelist.contains(untrustedClassName)){
				throw new InvalidClassException("Unsupported Class", streamclass.getName());		
			}
			this.classResolved = true;
		}
		
        return super.resolveClass(streamclass);
    }
}
The function resolveClass tries to resolve the class name of a serialized object. Note, that it can be invoked multiple times if the serialized stream contains nested or chained objects. If this function is called on the first object of the stream, it is checked if the untrusted class name is part of the whitelist. In case it is, the private boolean field classResolved is set to true and the usual class resolving process is continued. Setting the field classResolved to true causes that all subsequent classes within the object stream are not being checked against the whitelist anymore.

That means if the deserialization routine first reads a String array and after that reads any other object we can bypass the whitelist and again gain remote code execution. The following code snippet illustrates the described scenario.

1
2
3
4
ByteArrayInputStream bais = new ByteArrayInputStream(untrustedData)
InSecureObjectInputStream ois = new InSecureObjectInputStream(bais)
String[] arr = (String[]) ois.readObject();
String someObj = (String) ois.readObject();

We have identified multiple usages of that insecure class resolving procedure and reported an unauthenticated RCE as proof-of-concept to the vendor.

Summary

In this post we saw how a broken authentication management combined with an insecure deserialization of untrusted user input lead to a critical vulnerability in ManageEngine OpManager. We explained in detail how an object chain is created and how it can be abused to execute arbitrary code. Furthermore, we explained the fix of that issue and detailed a way to bypass that fix.

Timeline

Date Action
2020-11-07 Reported issue via bugbounty.zoho.com
2020-11-13 Vendor confirmed the vulnerability and released the supposedly fixed versions 125203 and 125233.
2020-11-16 CVE-2020-28653 was assigned to the issue.
2021-01-22 The vendor was informed that the fix was insufficient and a new issue was reported.
2021-01-25 Vendor confirmed the new issue and assigned CVE-2021-3287.
2021-02-09 The fixed version 125329 has been released.