354 lines
11 KiB
Python
354 lines
11 KiB
Python
# Credit to https://twitter.com/snovvcrash/status/1550518555438891009
|
|
# Credit to https://github.com/dirkjanm/adidnsdump @_dirkjan
|
|
# module by @mpgn_x64
|
|
|
|
from struct import unpack, pack
|
|
from impacket.structure import Structure
|
|
from ldap3 import NTLM, Server, Connection, ALL, LEVEL, BASE, MODIFY_DELETE, MODIFY_ADD, MODIFY_REPLACE, Tls
|
|
from datetime import datetime
|
|
from builtins import str
|
|
import socket
|
|
import codecs
|
|
import dns.resolver
|
|
import dns.name
|
|
|
|
def get_dns_zones(connection, root, debug=False):
|
|
connection.search(root, '(objectClass=dnsZone)', search_scope=LEVEL, attributes=['dc'])
|
|
zones = []
|
|
for entry in connection.response:
|
|
if entry['type'] != 'searchResEntry':
|
|
continue
|
|
zones.append(entry['attributes']['dc'])
|
|
return zones
|
|
|
|
def get_dns_resolver(server, context):
|
|
# Create a resolver object
|
|
dnsresolver = dns.resolver.Resolver()
|
|
# Is our host an IP? In that case make sure the server IP is used
|
|
# if not assume lookups are working already
|
|
try:
|
|
if server.startswith('ldap://'):
|
|
server = server[7:]
|
|
if server.startswith('ldaps://'):
|
|
server = server[8:]
|
|
socket.inet_aton(server)
|
|
dnsresolver.nameservers = [server]
|
|
except socket.error:
|
|
context.info('Using System DNS to resolve unknown entries. Make sure resolving your target domain works here or specify an IP'\
|
|
' as target host to use that server for queries')
|
|
return dnsresolver
|
|
|
|
def ldap2domain(ldap):
|
|
return re.sub(',DC=', '.', ldap[ldap.lower().find('dc='):], flags=re.I)[3:]
|
|
|
|
def new_record(rtype, serial):
|
|
nr = DNS_RECORD()
|
|
nr['Type'] = rtype
|
|
nr['Serial'] = serial
|
|
nr['TtlSeconds'] = 180
|
|
# From authoritive zone
|
|
nr['Rank'] = 240
|
|
return nr
|
|
|
|
# From: https://docs.microsoft.com/en-us/windows/win32/dns/dns-constants
|
|
RECORD_TYPE_MAPPING = {
|
|
0: 'ZERO',
|
|
1: 'A',
|
|
2: 'NS',
|
|
5: 'CNAME',
|
|
6: 'SOA',
|
|
12: 'PTR',
|
|
#15: 'MX',
|
|
#16: 'TXT',
|
|
28: 'AAAA',
|
|
33: 'SRV'
|
|
}
|
|
|
|
def searchResEntry_to_dict(results):
|
|
data = {}
|
|
for attr in results['attributes']:
|
|
key = str(attr['type'])
|
|
value = str(attr['vals'][0])
|
|
data[key] = value
|
|
return data
|
|
|
|
|
|
class CMEModule:
|
|
|
|
name = 'get-network'
|
|
description = ""
|
|
supported_protocols = ['ldap']
|
|
opsec_safe = True
|
|
multiple_hosts = True
|
|
|
|
def options(self, context, module_options):
|
|
"""
|
|
ALL Get DNS and IP (default: false)
|
|
ONLY_HOSTS Get DNS only (no ip) (default: false)
|
|
"""
|
|
|
|
self.showall = False
|
|
self.showhosts = False
|
|
self.showip = True
|
|
|
|
if module_options and 'ALL' in module_options:
|
|
if module_options['ALL'].lower() == "true" or module_options['ALL'] == "1":
|
|
self.showall = True
|
|
else:
|
|
print("Could not parse ALL option.")
|
|
if module_options and 'IP' in module_options:
|
|
if module_options['IP'].lower() == "true" or module_options['IP'] == "1":
|
|
self.showip = True
|
|
else:
|
|
print("Could not parse ONLY_HOSTS option.")
|
|
if module_options and 'ONLY_HOSTS' in module_options:
|
|
if module_options['ONLY_HOSTS'].lower() == "true" or module_options['ONLY_HOSTS'] == "1":
|
|
self.showhosts = True
|
|
else:
|
|
print("Could not parse ONLY_HOSTS option.")
|
|
|
|
|
|
def on_login(self, context, connection):
|
|
zone = ldap2domain(connection.baseDN)
|
|
dnsroot = 'CN=MicrosoftDNS,DC=DomainDnsZones,%s' % connection.baseDN
|
|
searchtarget = 'DC=%s,%s' % (zone, dnsroot)
|
|
context.log.info('Querying zone for records')
|
|
sfilter = '(DC=*)'
|
|
|
|
try:
|
|
list_sites = connection.ldapConnection.search(
|
|
searchBase=searchtarget,
|
|
searchFilter=sfilter,
|
|
attributes=['dnsRecord','dNSTombstoned','name'],
|
|
sizeLimit=100000
|
|
)
|
|
except ldap.LDAPSearchError as e:
|
|
if e.getErrorString().find('sizeLimitExceeded') >= 0:
|
|
logging.debug('sizeLimitExceeded exception caught, giving up and processing the data received')
|
|
# We reached the sizeLimit, process the answers we have already and that's it. Until we implement
|
|
# paged queries
|
|
list_sites = e.getAnswers()
|
|
pass
|
|
else:
|
|
raise
|
|
targetentry = None
|
|
dnsresolver = get_dns_resolver(connection.host, context.log)
|
|
|
|
outdata = []
|
|
|
|
for item in list_sites:
|
|
if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True:
|
|
continue
|
|
site = searchResEntry_to_dict(item)
|
|
recordname = site['name']
|
|
|
|
if "dnsRecord" in site:
|
|
record = bytes(site['dnsRecord'].encode('latin1'))
|
|
dr = DNS_RECORD(record)
|
|
if RECORD_TYPE_MAPPING[dr['Type']] == "A":
|
|
if dr['Type'] == 1:
|
|
address = DNS_RPC_RECORD_A(dr['Data'])
|
|
if str(recordname) != 'DomainDnsZones' and str(recordname) != 'ForestDnsZones':
|
|
outdata.append({'name':recordname, 'type': RECORD_TYPE_MAPPING[dr['Type']], 'value': address.formatCanonical()})
|
|
if dr['Type'] in [a for a in RECORD_TYPE_MAPPING if RECORD_TYPE_MAPPING[a] in ['CNAME', 'NS', 'PTR']]:
|
|
address = DNS_RPC_RECORD_NODE_NAME(dr['Data'])
|
|
if str(recordname) != 'DomainDnsZones' and str(recordname) != 'ForestDnsZones':
|
|
outdata.append({'name':recordname, 'type':RECORD_TYPE_MAPPING[dr['Type']], 'value': address[list(address.fields)[0]].toFqdn()})
|
|
elif dr['Type'] == 28:
|
|
address = DNS_RPC_RECORD_AAAA(dr['Data'])
|
|
if str(recordname) != 'DomainDnsZones' and str(recordname) != 'ForestDnsZones':
|
|
outdata.append({'name':recordname, 'type':RECORD_TYPE_MAPPING[dr['Type']], 'value': address.formatCanonical()})
|
|
|
|
context.log.highlight('Found %d records' % len(outdata))
|
|
path = os.path.expanduser('~/.cme/logs/{}_network_{}.log'.format(connection.domain, datetime.now().strftime("%Y-%m-%d_%H%M%S")))
|
|
with codecs.open(path, 'w', 'utf-8') as outfile:
|
|
for row in outdata:
|
|
if self.showhosts:
|
|
outfile.write('{}\n'.format(row['name'] + '.' + connection.domain))
|
|
elif self.showall:
|
|
outfile.write('{} \t {}\n'.format(row['name'] + '.' + connection.domain, row['value']))
|
|
else:
|
|
outfile.write('{}\n'.format(row['value']))
|
|
context.log.success('Dumped {} records to {}'.format(len(outdata), path))
|
|
if not self.showall and not self.showhosts:
|
|
context.log.info("To extract CIDR from the {} ip, run the following command: cat your_file | mapcidr -aa -silent | mapcidr -a -silent".format(len(outdata)))
|
|
|
|
|
|
|
|
class DNS_RECORD(Structure):
|
|
"""
|
|
dnsRecord - used in LDAP
|
|
[MS-DNSP] section 2.3.2.2
|
|
"""
|
|
structure = (
|
|
('DataLength', '<H-Data'),
|
|
('Type', '<H'),
|
|
('Version', 'B=5'),
|
|
('Rank', 'B'),
|
|
('Flags', '<H=0'),
|
|
('Serial', '<L'),
|
|
('TtlSeconds', '>L'),
|
|
('Reserved', '<L=0'),
|
|
('TimeStamp', '<L=0'),
|
|
('Data', ':')
|
|
)
|
|
|
|
# Note that depending on whether we use RPC or LDAP all the DNS_RPC_XXXX
|
|
# structures use DNS_RPC_NAME when communication is over RPC,
|
|
# but DNS_COUNT_NAME is the way they are stored in LDAP.
|
|
#
|
|
# Since LDAP is the primary goal of this script we use that, but for use
|
|
# over RPC the DNS_COUNT_NAME in the structures must be replaced with DNS_RPC_NAME,
|
|
# which is also consistent with how MS-DNSP describes it.
|
|
|
|
class DNS_RPC_NAME(Structure):
|
|
"""
|
|
DNS_RPC_NAME
|
|
Used for FQDNs in RPC communication.
|
|
MUST be converted to DNS_COUNT_NAME for LDAP
|
|
[MS-DNSP] section 2.2.2.2.1
|
|
"""
|
|
structure = (
|
|
('cchNameLength', 'B-dnsName'),
|
|
('dnsName', ':')
|
|
)
|
|
|
|
class DNS_COUNT_NAME(Structure):
|
|
"""
|
|
DNS_COUNT_NAME
|
|
Used for FQDNs in LDAP communication
|
|
MUST be converted to DNS_RPC_NAME for RPC communication
|
|
[MS-DNSP] section 2.2.2.2.2
|
|
"""
|
|
structure = (
|
|
('Length', 'B-RawName'),
|
|
('LabelCount', 'B'),
|
|
('RawName', ':')
|
|
)
|
|
|
|
def toFqdn(self):
|
|
ind = 0
|
|
labels = []
|
|
for i in range(self['LabelCount']):
|
|
nextlen = unpack('B', self['RawName'][ind:ind+1])[0]
|
|
labels.append(self['RawName'][ind+1:ind+1+nextlen].decode('utf-8'))
|
|
ind += nextlen + 1
|
|
# For the final dot
|
|
labels.append('')
|
|
return '.'.join(labels)
|
|
|
|
class DNS_RPC_NODE(Structure):
|
|
"""
|
|
DNS_RPC_NODE
|
|
[MS-DNSP] section 2.2.2.2.3
|
|
"""
|
|
structure = (
|
|
('wLength', '>H'),
|
|
('wRecordCount', '>H'),
|
|
('dwFlags', '>L'),
|
|
('dwChildCount', '>L'),
|
|
('dnsNodeName', ':')
|
|
)
|
|
|
|
class DNS_RPC_RECORD_A(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_A
|
|
[MS-DNSP] section 2.2.2.2.4.1
|
|
"""
|
|
structure = (
|
|
('address', ':'),
|
|
)
|
|
|
|
def formatCanonical(self):
|
|
return socket.inet_ntoa(self['address'])
|
|
|
|
def fromCanonical(self, canonical):
|
|
self['address'] = socket.inet_aton(canonical)
|
|
|
|
|
|
class DNS_RPC_RECORD_NODE_NAME(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_NODE_NAME
|
|
[MS-DNSP] section 2.2.2.2.4.2
|
|
"""
|
|
structure = (
|
|
('nameNode', ':', DNS_COUNT_NAME),
|
|
)
|
|
|
|
class DNS_RPC_RECORD_SOA(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_SOA
|
|
[MS-DNSP] section 2.2.2.2.4.3
|
|
"""
|
|
structure = (
|
|
('dwSerialNo', '>L'),
|
|
('dwRefresh', '>L'),
|
|
('dwRetry', '>L'),
|
|
('dwExpire', '>L'),
|
|
('dwMinimumTtl', '>L'),
|
|
('namePrimaryServer', ':', DNS_COUNT_NAME),
|
|
('zoneAdminEmail', ':', DNS_COUNT_NAME)
|
|
)
|
|
|
|
class DNS_RPC_RECORD_NULL(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_NULL
|
|
[MS-DNSP] section 2.2.2.2.4.4
|
|
"""
|
|
structure = (
|
|
('bData', ':'),
|
|
)
|
|
|
|
# Some missing structures here that I skipped
|
|
|
|
class DNS_RPC_RECORD_NAME_PREFERENCE(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_NAME_PREFERENCE
|
|
[MS-DNSP] section 2.2.2.2.4.8
|
|
"""
|
|
structure = (
|
|
('wPreference', '>H'),
|
|
('nameExchange', ':', DNS_COUNT_NAME)
|
|
)
|
|
|
|
# Some missing structures here that I skipped
|
|
|
|
class DNS_RPC_RECORD_AAAA(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_AAAA
|
|
[MS-DNSP] section 2.2.2.2.4.17
|
|
"""
|
|
structure = (
|
|
('ipv6Address', '16s'),
|
|
)
|
|
|
|
def formatCanonical(self):
|
|
return socket.inet_ntop(socket.AF_INET6, self['ipv6Address'])
|
|
|
|
class DNS_RPC_RECORD_SRV(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_SRV
|
|
[MS-DNSP] section 2.2.2.2.4.18
|
|
"""
|
|
structure = (
|
|
('wPriority', '>H'),
|
|
('wWeight', '>H'),
|
|
('wPort', '>H'),
|
|
('nameTarget', ':', DNS_COUNT_NAME)
|
|
)
|
|
|
|
class DNS_RPC_RECORD_TS(Structure):
|
|
"""
|
|
DNS_RPC_RECORD_TS
|
|
[MS-DNSP] section 2.2.2.2.4.23
|
|
"""
|
|
structure = (
|
|
('entombedTime', '<Q'),
|
|
)
|
|
def toDatetime(self):
|
|
microseconds = int(self['entombedTime'] / 10)
|
|
try:
|
|
return datetime.datetime(1601,1,1) + datetime.timedelta(microseconds=microseconds)
|
|
except OverflowError:
|
|
return None
|