NetExec/nxc/modules/scan-network.py

382 lines
12 KiB
Python

# Credit to https://twitter.com/snovvcrash/status/1550518555438891009
# Credit to https://github.com/dirkjanm/adidnsdump @_dirkjan
# module by @mpgn_x64
from os.path import expanduser
import codecs
import socket
from builtins import str
from datetime import datetime
from struct import unpack
import dns.name
import dns.resolver
from impacket.structure import Structure
from ldap3 import LEVEL
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 NXCModule:
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.display("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:
context.log.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 = expanduser("~/.nxc/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.display("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