diff --git a/dissect/database/ese/ntds/ntds.py b/dissect/database/ese/ntds/ntds.py index f783160..e39c140 100644 --- a/dissect/database/ese/ntds/ntds.py +++ b/dissect/database/ese/ntds/ntds.py @@ -11,6 +11,7 @@ from dissect.database.ese.ntds.objects import ( Computer, + DnsNode, DomainDNS, Group, GroupPolicyContainer, @@ -118,6 +119,10 @@ def secrets(self) -> Iterator[Secret]: """Get all secret objects from the database.""" yield from self.search(objectClass="secret") + def dns_nodes(self) -> Iterator[DnsNode]: + """Get all DnsNode objects from the database.""" + yield from self.search(objectClass="dnsNode") + def backup_keys(self) -> Iterator[BackupKey]: """Get all DPAPI backup keys from the database.""" if not self.pek.unlocked: diff --git a/dissect/database/ese/ntds/objects/c_dnsnode.py b/dissect/database/ese/ntds/objects/c_dnsnode.py new file mode 100644 index 0000000..6b6f7cf --- /dev/null +++ b/dissect/database/ese/ntds/objects/c_dnsnode.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dissect.cstruct import cstruct + +dns_record_def = """ + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/39b03b89-2264-4063-8198-d62f62a6441a +enum DNS_RECORD_TYPE : WORD { + ZERO = 0x0000, // An empty record type ([RFC1034] section 3.6 and [RFC1035] section 3.2.2). + A = 0x0001, // An A record type, used for storing an IP address ([RFC1035] section 3.2.2). + NS = 0x0002, // An authoritative name-server + // record type ([RFC1034] section 3.6 and [RFC1035] section 3.2.2). + MD = 0x0003, // A mail-destination record type ([RFC1035] section 3.2.2). + MF = 0x0004, // A mail forwarder record type ([RFC1035] section 3.2.2). + CNAME = 0x0005, // A record type that contains the canonical name of a DNS alias ([RFC1035] section 3.2.2). + SOA = 0x0006, // A Start of Authority (SOA) record type ([RFC1035] section 3.2.2). + MB = 0x0007, // A mailbox record type ([RFC1035] section 3.2.2). + MG = 0x0008, // A mail group member record type ([RFC1035] section 3.2.2). + MR = 0x0009, // A mail-rename record type ([RFC1035] section 3.2.2). + NULL = 0x000A, // A record type for completion queries ([RFC1035] section 3.2.2). + WKS = 0x000B, // A record type for a well-known service ([RFC1035] section 3.2.2). + PTR = 0x000C, // A record type containing FQDN pointer ([RFC1035] section 3.2.2). + HINFO = 0x000D, // A host information record type ([RFC1035] section 3.2.2). + MINFO = 0x000E, // A mailbox or mailing list information record type ([RFC1035] section 3.2.2). + MX = 0x000F, // A mail-exchanger record type ([RFC1035] section 3.2.2). + TXT = 0x0010, // A record type containing a text string ([RFC1035] section 3.2.2). + RP = 0x0011, // A responsible-person record type [RFC1183]. + AFSDB = 0x0012, // A record type containing AFS database location [RFC1183]. + X25 = 0x0013, // An X25 PSDN address record type [RFC1183]. + ISDN = 0x0014, // An ISDN address record type [RFC1183]. + RT = 0x0015, // A route through record type [RFC1183]. + SIG = 0x0018, // A cryptographic public key signature record type [RFC2931]. + KEY = 0x0019, // A record type containing public key used in DNSSEC [RFC2535]. + AAAA = 0x001C, // An IPv6 address record type [RFC3596]. + LOC = 0x001D, // A location information record type [RFC1876]. + NXT = 0x001E, // A next-domain record type [RFC2065]. + SRV = 0x0021, // A server selection record type [RFC2782]. + ATMA = 0x0022, // An Asynchronous Transfer Mode (ATM) address record type [ATMA]. + NAPTR = 0x0023, // An NAPTR record type [RFC2915]. + DNAME = 0x0027, // A DNAME record type [RFC2672]. + DS = 0x002B, // A DS record type [RFC4034]. + RRSIG = 0x002E, // An RRSIG record type [RFC4034]. + NSEC = 0x002F, // An NSEC record type [RFC4034]. + DNSKEY = 0x0030, // A DNSKEY record type [RFC4034]. + DHCID = 0x0031, // A DHCID record type [RFC4701]. + NSEC3 = 0x0032, // An NSEC3 record type [RFC5155]. + NSEC3PARAM = 0x0033, // An NSEC3PARAM record type [RFC5155]. + TLSA = 0x0034, // A TLSA record type [RFC6698]. + ALL = 0x00FF, // A query-only type requesting all records [RFC1035]. + WINS = 0xFF01, // A record type containing Windows Internet Name Service (WINS) + // forward lookup data [MS-WINSRADNS_TYPE_WINSR]. + WINSR = 0xFF02 // A record type containing WINS reverse lookup data [MS-WINSRA]. +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 +typedef struct DNS_RECORD_HEADER { + WORD DataLength; + DNS_RECORD_TYPE Type; + BYTE Version; // Must be 0x05 + BYTE Rank; + WORD Flags; // Must be 0x00 + DWORD Serial; + DWORD TtlSeconds; // Big Endian + DWORD Reserved; // MUST be 0x00000000. + DWORD TimeStamp; + CHAR Data[DataLength]; +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/3fd41adc-c69e-407b-979e-721251403132 +// MS docs indicate that structure is 4 byte aligned, and that the string MUST NOT be null-terminated. +// But observed reality is a null terminated string (null char not counted in NameLength) +typedef struct DNS_RPC_NAME{ + BYTE NameLength; + CHAR dnsName[NameLength]; +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a +typedef struct DNS_RPC_RECORD_SRV { + WORD Priority; + WORD Weight; + WORD Port; + DNS_RPC_NAME nameTarget; +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6 +typedef struct DNS_RPC_RECORD_NAME_PREFERENCE { + WORD Preference; + DNS_RPC_NAME nameExchange; +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 +typedef struct DNS_RPC_RECORD_SOA { + DWORD Serial; + DWORD Refresh; + DWORD Retry; + DWORD Expire; + DWORD MinimumTtl; + DNS_RPC_NAME namePrimaryServer; + BYTE _pad; + DNS_RPC_NAME ZoneAdministratorEmail; +}; + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/def7736a-dd09-4b4a-b8d6-6a702a7ecde0 +typedef struct DNS_RPC_RECORD_TS { + QWORD EntombedTime; +}; +""" +c_dns_record = cstruct(dns_record_def) +DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE diff --git a/dissect/database/ese/ntds/objects/c_dnsnode.pyi b/dissect/database/ese/ntds/objects/c_dnsnode.pyi new file mode 100644 index 0000000..522e37a --- /dev/null +++ b/dissect/database/ese/ntds/objects/c_dnsnode.pyi @@ -0,0 +1,146 @@ +# Generated by cstruct-stubgen +from typing import BinaryIO, Literal, TypeAlias, overload + +import dissect.cstruct as __cs__ + +class _c_dns_record(__cs__.cstruct): + class DNS_RECORD_TYPE(__cs__.Enum): + ZERO = ... + A = ... + NS = ... + MD = ... + MF = ... + CNAME = ... + SOA = ... + MB = ... + MG = ... + MR = ... + NULL = ... + WKS = ... + PTR = ... + HINFO = ... + MINFO = ... + MX = ... + TXT = ... + RP = ... + AFSDB = ... + X25 = ... + ISDN = ... + RT = ... + SIG = ... + KEY = ... + AAAA = ... + LOC = ... + NXT = ... + SRV = ... + ATMA = ... + NAPTR = ... + DNAME = ... + DS = ... + RRSIG = ... + NSEC = ... + DNSKEY = ... + DHCID = ... + NSEC3 = ... + NSEC3PARAM = ... + TLSA = ... + ALL = ... + WINS = ... + WINSR = ... + + class DNS_RECORD_HEADER(__cs__.Structure): + DataLength: _c_dns_record.uint16 + Type: _c_dns_record.DNS_RECORD_TYPE + Version: _c_dns_record.uint8 + Rank: _c_dns_record.uint8 + Flags: _c_dns_record.uint16 + Serial: _c_dns_record.uint32 + TtlSeconds: _c_dns_record.uint32 + Reserved: _c_dns_record.uint32 + TimeStamp: _c_dns_record.uint32 + Data: __cs__.CharArray + @overload + def __init__( + self, + DataLength: _c_dns_record.uint16 | None = ..., + Type: _c_dns_record.DNS_RECORD_TYPE | None = ..., + Version: _c_dns_record.uint8 | None = ..., + Rank: _c_dns_record.uint8 | None = ..., + Flags: _c_dns_record.uint16 | None = ..., + Serial: _c_dns_record.uint32 | None = ..., + TtlSeconds: _c_dns_record.uint32 | None = ..., + Reserved: _c_dns_record.uint32 | None = ..., + TimeStamp: _c_dns_record.uint32 | None = ..., + Data: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class DNS_RPC_NAME(__cs__.Structure): + NameLength: _c_dns_record.uint8 + dnsName: __cs__.CharArray + @overload + def __init__(self, NameLength: _c_dns_record.uint8 | None = ..., dnsName: __cs__.CharArray | None = ...): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class DNS_RPC_RECORD_SRV(__cs__.Structure): + Priority: _c_dns_record.uint16 + Weight: _c_dns_record.uint16 + Port: _c_dns_record.uint16 + nameTarget: _c_dns_record.DNS_RPC_NAME + @overload + def __init__( + self, + Priority: _c_dns_record.uint16 | None = ..., + Weight: _c_dns_record.uint16 | None = ..., + Port: _c_dns_record.uint16 | None = ..., + nameTarget: _c_dns_record.DNS_RPC_NAME | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class DNS_RPC_RECORD_NAME_PREFERENCE(__cs__.Structure): + Preference: _c_dns_record.uint16 + nameExchange: _c_dns_record.DNS_RPC_NAME + @overload + def __init__( + self, Preference: _c_dns_record.uint16 | None = ..., nameExchange: _c_dns_record.DNS_RPC_NAME | None = ... + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class DNS_RPC_RECORD_SOA(__cs__.Structure): + Serial: _c_dns_record.uint32 + Refresh: _c_dns_record.uint32 + Retry: _c_dns_record.uint32 + Expire: _c_dns_record.uint32 + MinimumTtl: _c_dns_record.uint32 + namePrimaryServer: _c_dns_record.DNS_RPC_NAME + _pad: _c_dns_record.uint8 + ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME + @overload + def __init__( + self, + Serial: _c_dns_record.uint32 | None = ..., + Refresh: _c_dns_record.uint32 | None = ..., + Retry: _c_dns_record.uint32 | None = ..., + Expire: _c_dns_record.uint32 | None = ..., + MinimumTtl: _c_dns_record.uint32 | None = ..., + namePrimaryServer: _c_dns_record.DNS_RPC_NAME | None = ..., + _pad: _c_dns_record.uint8 | None = ..., + ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + class DNS_RPC_RECORD_TS(__cs__.Structure): + EntombedTime: _c_dns_record.uint64 + @overload + def __init__(self, EntombedTime: _c_dns_record.uint64 | None = ...): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + +# Technically `c_dns_record` is an instance of `_c_dns_record`, but then we can't use it in type hints +c_dns_record: TypeAlias = _c_dns_record +DNS_RECORD_TYPE: TypeAlias = _c_dns_record.DNS_RECORD_TYPE diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index e581dba..68edb20 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -1,13 +1,443 @@ from __future__ import annotations +import datetime +import logging +import socket +import typing +from functools import cached_property +from typing import Any, ClassVar, NamedTuple + +from dissect.cstruct.utils import swap16, swap32 + +from dissect.database.ese.ntds.objects.c_dnsnode import DNS_RECORD_TYPE, c_dns_record from dissect.database.ese.ntds.objects.top import Top +if typing.TYPE_CHECKING: + from dissect.database.ese.ntds.objects.object import DecoderMap +log = logging.getLogger(__name__) + + +def parse_rfc1035_dns_name(data: bytes) -> str: + """Parse DNS name as specified in ``rfc1035#section-3.1`` format. + + References: + - https://datatracker.ietf.org/doc/html/rfc1035#section-3.1 + """ + if not data: + return "" + _nb_segment = data[0] + name_parts = [] + offset = 1 + # Domain names in messages are expressed in terms of a sequence of labels. + # Each label is represented as a one octet length field followed by that + # number of octets. Since every domain name ends with the null label of + # the root, a domain name is terminated by a length byte of zero. + while offset < len(data): + length = data[offset] + if length == 0: + name_parts.append("") + break + # The high order two bits of every length octet must be zero, and the + # remaining six bits of the length field limit the label to 63 octets or + # less. + if length > 63: # Compression pointer + return "" + + offset += 1 + if offset + length > len(data): + return "" + + part = data[offset : offset + length].decode("utf-8", errors="backslashreplace") + name_parts.append(part) + offset += length + return ".".join(name_parts) if name_parts else "" + + +class DnsARecord(NamedTuple): + """``A`` resource records.""" + + ipv4_address: str + + @property + def ip_address(self) -> str: + return self.ipv4_address + + @classmethod + def from_bytes(cls, data: bytes) -> DnsARecord: + """Parse ``A`` record (IPv4 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/117c2ff9-9094-45b2-83c2-5e44518e0bac + + Raises: + EOFError: Issue while unpacking structure. + """ + if len(data) >= 4: + ip = socket.inet_ntop(socket.AF_INET, data[:4]) + return cls(ipv4_address=ip) + raise EOFError("A records with less than 4 bytes") + + +class DnsAAAARecord(NamedTuple): + """``AAAA`` resource records.""" + + ipv6_address: str + + @property + def ip_address(self) -> str: + return self.ipv6_address + + @classmethod + def from_bytes(cls, data: bytes) -> DnsAAAARecord: + """Parse ``AAAA`` record (IPv6 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a + + Raises: + EOFError: Issue while unpacking structure. + """ + if len(data) >= 16: + ip = socket.inet_ntop(socket.AF_INET6, data[:16]) + return cls(ipv6_address=ip) + raise EOFError("AAAA records with less than 16 bytes") + + +class SOARecord(NamedTuple): + """The ``DNS_RPC_RECORD_SOA`` structure contains information about a ``SOA`` record.""" + + name_primary_server: str + # Serial does not match value seen using DNS request/management interface + # As this is not the most important field, we simply ignore it instead a showing a errored value + # serial: int + refresh: int + retry: int + minimum_ttl: int + zone_administrator_email: str + + @classmethod + def from_bytes(cls, data: bytes) -> SOARecord | None: + """Parse ``SOA`` records. + + References: + https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 + + Raises: + EOFError: Issue while unpacking structure. + """ + record = c_dns_record.DNS_RPC_RECORD_SOA(data) + return cls( + name_primary_server=parse_rfc1035_dns_name(record.namePrimaryServer.dnsName), + # Serial does not match value seen using DNS request/management interface + # As this is not the most important field, we simply ignore it instead a showing an errored value + # serial=swap32(dns_rpc_record_soa.Serial, int_len=4), + refresh=swap32(record.Refresh), + retry=swap32(record.Retry), + minimum_ttl=swap32(record.MinimumTtl), + zone_administrator_email=parse_rfc1035_dns_name(record.ZoneAdministratorEmail.dnsName), + ) + + +class NodeNameRecord(NamedTuple): + """The ``DNS_RPC_RECORD_NODE_NAME`` structure contains information about a DNS record referring to another DNS name. + + This corresponds to the following types: + - ``DNS_TYPE_PTR`` + - ``DNS_TYPE_NS`` + - ``DNS_TYPE_CNAME`` + - ``DNS_TYPE_DNAME`` + - ``DNS_TYPE_MB`` + - ``DNS_TYPE_MR`` + - ``DNS_TYPE_MG`` + - ``DNS_TYPE_MD`` + - ``DNS_TYPE_MF`` + """ + + name_node: str + + @classmethod + def from_bytes(cls, data: bytes) -> NodeNameRecord | None: + """Parse Node Name type record (e.g. ``CNAME``, ``PTR``). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e + + Raises: + EOFError: Issue while unpacking structure. + """ + return NodeNameRecord(parse_rfc1035_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) + + +class StringRecord(NamedTuple): + """The ``DNS_RPC_RECORD_STRING`` structure contains information about a DNS record containing text data. + + This corresponds to the following types: + - ``DNS_TYPE_HINFO`` + - ``DNS_TYPE_ISDN`` + - ``DNS_TYPE_TXT`` + - ``DNS_TYPE_X25`` + - ``DNS_TYPE_LOC`` + """ + + string_data: str + + @classmethod + def from_bytes(cls, data: bytes) -> StringRecord | None: + """Parse Node Name type record (e.g. ``TXT``). + + Test using GUI does not allow to create record with a line length > 255 char. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + + Raises: + EOFError: Issue while unpacking structure. + """ + records = [] + data_consumed = 0 + + while data_consumed < len(data): + rpc_name = c_dns_record.DNS_RPC_NAME(data[data_consumed:]) + data_consumed += len(rpc_name) + + records.append(rpc_name.dnsName.decode("utf-8", errors="backslashreplace")) + return cls("\n".join(records)) + + +class NamePreferenceRecord(NamedTuple): + """The ``DNS_RPC_RECORD_NAME_PREFERENCE`` structure specifies information about a DNS + record referring to another DNS name with a preference. + + This corresponds to the following types: + - ``DNS_TYPE_MX`` + - ``DNS_TYPE_AFSDB`` + - ``DNS_TYPE_RT`` + """ + + name_exchange: str + preference: int + + @classmethod + def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: + """Parse ``DNS_RPC_RECORD_NAME_PREFERENCE`` record (e.g. ``MX``). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6 + + Raises: + EOFError: Issue while unpacking structure. + """ + record = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) + return cls( + preference=swap16(record.Preference), + name_exchange=parse_rfc1035_dns_name(record.nameExchange.dnsName), + ) + + +class SRVRecord(NamedTuple): + """``SRV`` resource records.""" + + name_target: str + port: int + weight: int + priority: int + + @classmethod + def from_bytes(cls, data: bytes) -> SRVRecord | None: + """Parse ``SRV`` record. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a + + Raises: + EOFError: Issue while unpacking structure. + """ + record = c_dns_record.DNS_RPC_RECORD_SRV(data) + target = parse_rfc1035_dns_name(record.nameTarget.dnsName) + return SRVRecord( + priority=record.Priority, + weight=swap16(record.Weight), + port=swap16(record.Port), + name_target=target, + ) + + +class TombStonedRecord(NamedTuple): + """``ZERO`` resource records.""" + + entombed_time: datetime.datetime + + @classmethod + def from_bytes(cls, data: bytes) -> TombStonedRecord | None: + """The ``DNS_RPC_RECORD_TS`` specifies information for a node that has been tombstoned, + used for record type ``DNS_TYPE_ZERO``. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + + Raises: + EOFError: Issue while unpacking structure. + """ + record = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime + if record == 0: + return None + base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + return TombStonedRecord(base_date + datetime.timedelta(microseconds=record / 10)) + + +class DnsRecord: + """DNS resource record definitions. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 + """ + + def __init__(self, data: bytes): + self.raw = data + self.header = c_dns_record.DNS_RECORD_HEADER(data) + self.type = self.header.Type + self.ttl_seconds = swap32(self.header.TtlSeconds) + + def __repr__(self) -> str: + return ( + f"" + ) + + @property + def timestamp(self) -> datetime.datetime | None: + """Timestamp is stored in hours since 1601-01-01. + + Raises: + OverflowError: Number of hours cause an overflow. + """ + if self.header.TimeStamp == 0: + return None + # Windows timestamp is hours since 1601-01-01 + base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + return base_date + datetime.timedelta(hours=self.header.TimeStamp) + + @property + def data( + self, + ) -> ( + bytes + | DnsARecord + | DnsAAAARecord + | NodeNameRecord + | NamePreferenceRecord + | StringRecord + | TombStonedRecord + | SRVRecord + | SOARecord + | None + ): + """Parse the data part of a record, which contains a structure that depends on the record type. + + Raises: + EOFError: Issue while unpacking structure. + """ + header_data = self.header.Data + + # Process most common DNS records types + match self.type: + case DNS_RECORD_TYPE.A: + return DnsARecord.from_bytes(header_data) + case c_dns_record.DNS_RECORD_TYPE.AAAA: + return DnsAAAARecord.from_bytes(header_data) + case ( + DNS_RECORD_TYPE.PTR + | DNS_RECORD_TYPE.NS + | DNS_RECORD_TYPE.CNAME + | DNS_RECORD_TYPE.DNAME + | DNS_RECORD_TYPE.MB + | DNS_RECORD_TYPE.MR + | DNS_RECORD_TYPE.MG + | DNS_RECORD_TYPE.MD + | DNS_RECORD_TYPE.MF + ): + return NodeNameRecord.from_bytes(header_data) + case DNS_RECORD_TYPE.MX | DNS_RECORD_TYPE.AFSDB | DNS_RECORD_TYPE.RT: + return NamePreferenceRecord.from_bytes(header_data) + case DNS_RECORD_TYPE.SRV: + return SRVRecord.from_bytes(header_data) + case DNS_RECORD_TYPE.SOA: + return SOARecord.from_bytes(header_data) + case ( + DNS_RECORD_TYPE.HINFO + | DNS_RECORD_TYPE.ISDN + | DNS_RECORD_TYPE.TXT + | DNS_RECORD_TYPE.X25 + | DNS_RECORD_TYPE.LOC + ): + return StringRecord.from_bytes(header_data) + case DNS_RECORD_TYPE.ZERO: + return TombStonedRecord.from_bytes(header_data) + return header_data + + def as_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the record, with parsed data if possible.""" + try: + data = self.data + except EOFError: + log.warning("Error processing DNS record: failed to parse data (record type: %s)", self.type.name) + data = None + + try: + timestamp = self.timestamp + except OverflowError: + log.warning("Error processing DNS record: invalid record timestamp") + timestamp = None + return { + "type": self.type.name, + "ttl_seconds": self.ttl_seconds, + "timestamp": timestamp, + # isinstance(X, NamedTuple) does not work, but NamedTuple are subtype of tuple + "data": data._asdict() if isinstance(data, tuple) else data, + } + class DnsNode(Top): """Represents a DNS node object in the Active Directory. References: - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dnsnode + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 """ __object_class__ = "dnsNode" + __decoders__: ClassVar[DecoderMap] = {"dnsRecord": lambda x, value: [DnsRecord(x) for x in value] if value else []} + + def __repr_body__(self) -> str: + return f"dns_name={self.dns_name} dns_record={self.dns_record}" + + @property + def dns_record(self) -> list[DnsRecord]: + """Return DNS records as objects. + + Raises: + EOFError: Issue while unpacking structure. + """ + return self.get("dnsRecord") + + @cached_property + def dns_name(self) -> str: + """Create a DNS name from node and parent names. + + Examples: + DC=NORTH,DC=SEVENKINGDOMS.LOCAL,CN=MICROSOFTDNS,DC=DOMAINDNSZONES,DC=SEVENKINGDOMS,DC=LOCAL -> + north.sevenkingdoms.local + """ + node = self.distinguished_name + ret = [self.name] if self.name != "@" else [] # @ means same as parent folder + while (i := node.parent).object.__object_class__ in ["dnsNode", "dnsZone"]: + ret.append(i.object.name) + node = i + return ".".join(ret).replace("\n", "\\n") + + def as_dict(self) -> dict[str, Any]: + result = super().as_dict() + result["dns_name"] = self.dns_name + if "dnsRecord" in result: + result["dnsRecord"] = [r.as_dict() for r in result.get("dnsRecord", [])] + return result diff --git a/dissect/database/ese/ntds/util.py b/dissect/database/ese/ntds/util.py index e29dc0d..a35e6f7 100644 --- a/dissect/database/ese/ntds/util.py +++ b/dissect/database/ese/ntds/util.py @@ -405,6 +405,9 @@ class DN(str): __slots__ = ("object", "parent") + object: Object + parent: DN | None + def __new__(cls, value: str, object: Object, parent: DN | None = None): instance = super().__new__(cls, value) instance.object = object diff --git a/tests/ese/ntds/objects/__init__.py b/tests/ese/ntds/objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ese/ntds/objects/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py new file mode 100644 index 0000000..b689011 --- /dev/null +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import datetime +import logging +import typing + +import pytest + +from dissect.database.ese.ntds.objects.c_dnsnode import DNS_RECORD_TYPE +from dissect.database.ese.ntds.objects.dnsnode import ( + DnsAAAARecord, + DnsARecord, + DnsRecord, + NamePreferenceRecord, + NodeNameRecord, + SOARecord, + SRVRecord, + StringRecord, + TombStonedRecord, + parse_rfc1035_dns_name, +) + +if typing.TYPE_CHECKING: + from dissect.database.ese.ntds import NTDS + + +def test_parse_dns_name() -> None: + """Test DNS name as specified in rfc1035#section-3.1 format.""" + assert parse_rfc1035_dns_name(b"\x03\x0ckingslanding\rsevenkingdoms\x05local") == "kingslanding.sevenkingdoms.local" + + assert ( + parse_rfc1035_dns_name(b"\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local") + == "test.with.multiple.secion.sevenkingdoms.local" + ) + + +def test_parse_dns_node_name_record() -> None: + """Test a NodeName records (CNAME, PTR etc...).""" + assert ( + NodeNameRecord.from_bytes(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00").name_node == "dc2-eu.test.lan" + ) # odd length + + assert ( + NodeNameRecord.from_bytes(b"&\x04\nWINTERFELL\x05north\rsevenkingdoms\x05local\x00").name_node + == "WINTERFELL.north.sevenkingdoms.local" + ) + + assert ( + NodeNameRecord.from_bytes(b"/\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local\x00").name_node + == "test.with.multiple.secion.sevenkingdoms.local" + ) + + +def test_parse_dns_tombstoned_record() -> None: + assert TombStonedRecord.from_bytes(b"\xf1\xba\x0c\xa5\xc8 \xdc\x01").entombed_time == datetime.datetime( + 2025, 9, 8, 13, 58, 24, 889522, tzinfo=datetime.timezone.utc + ) + + +def test_parse_dns_string_record() -> None: + assert StringRecord.from_bytes( + b"|TXT record made for dissect. Quite long to test if there is some limit size, " + b"like over 64 characters or something like that.\x004Two new line above, " + b"and an special char (euro) : \xe2\x82\xac" + ).string_data == ( + "TXT record made for dissect. Quite long to test if there is some limit size, " + "like over 64 characters or something like that.\n\n" + "Two new line above, and an special char (euro) : €" + ) + + assert StringRecord.from_bytes( + b"\xd6this is a very long record, with a size over 255, as string size is stored on a unint." + b" Very vey very very very very very very very very vey very very very very very very very very vey very very" + b" very very very veryg\x00\x00\nA new line\x17And we continue tthis i" + ).string_data == ( + "this is a very long record, with a size over 255, as string size is stored on a unint. Very vey" + " very very very very very very very very vey very very very very very very very very vey very" + " very very very very veryg\n\n\nA new line\nAnd we continue tthis i" + ) + assert StringRecord.from_bytes(b"\x01q\x02qw\x03qwe\x04qwer\x05qwert\x06qwerty\x08qwertyui").string_data == ( + "q\nqw\nqwe\nqwer\nqwert\nqwerty\nqwertyui" + ) + + +def test_parse_dns_string_null_record() -> None: + """Test an empty TXT record.""" + assert StringRecord.from_bytes(b"\x00").string_data == "" + + +def test_parse_name_preference_record() -> None: + """Test with MX records, with two different preferences.""" + mx_record: NamePreferenceRecord = NamePreferenceRecord.from_bytes(b"\x00\x14\x0b\x01\tmailhost2\x00") + assert mx_record.name_exchange == "mailhost2" + assert mx_record.preference == 20 + + mx_record: NamePreferenceRecord = NamePreferenceRecord.from_bytes(b"\x00\n\x0b\x01\tmailhost1\x00") + assert mx_record.name_exchange == "mailhost1" + assert mx_record.preference == 10 + + +def test_parse_dns_srv_record() -> None: + """Parse an SRV record related to LDAP.""" + srv_record: SRVRecord = SRVRecord.from_bytes( + b"\x00\x00\x00d\x01\x85&\x04\nwinterfell\x05north\rsevenkingdoms\x05local\x00" + ) + assert srv_record.priority == 0 + assert srv_record.weight == 100 + assert srv_record.port == 389 # LDAP + assert srv_record.name_target == "winterfell.north.sevenkingdoms.local" + + +def test_parse_dns_soa_record() -> None: + soa_record: SOARecord = SOARecord.from_bytes( + b'\x00\x00\x00#\x00\x00\x03\x84\x00\x00\x02X\x00\x01Q\x80\x00\x00\x0e\x10"\x03\x0ckingslanding\r' + b"sevenkingdoms\x05local\x00 \x03\nhostmaster\rsevenkingdoms\x05local\x00" + ) + assert soa_record.name_primary_server == "kingslanding.sevenkingdoms.local" + # assert soa_record.serial == 35 -> UI shows 58 but hex is (00 00 00 23) + assert soa_record.refresh == 900 + assert soa_record.retry == 600 + assert soa_record.minimum_ttl == 3600 + assert soa_record.zone_administrator_email == "hostmaster.sevenkingdoms.local" + + +def test_parse_name_aaaa_record() -> None: + """Test multiple AAAA (IPv6) records.""" + assert ( + DnsAAAARecord.from_bytes(b" \x01\r\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00").ipv6_address + == "2001:db8::1:0" + ) + assert ( + DnsAAAARecord.from_bytes(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01").ipv6_address + == "::1" + ) + assert ( + DnsAAAARecord.from_bytes(b"\xfd\x17b\\\xf07\x00\x020\x84\x07n\x83\xeb\xab\x1b").ipv6_address + == "fd17:625c:f037:2:3084:76e:83eb:ab1b" + ) + + +def test_parse_name_a_record() -> None: + """Test an A (IpV4) record.""" + assert DnsARecord.from_bytes(b"\xc0\xa8d\x1d").ipv4_address == "192.168.100.29" + # Error + with pytest.raises(EOFError): + DnsARecord.from_bytes(b"\xc0") + + +def test_dns_nodes(goad: NTDS) -> None: + """Test multiple DNS records from GOAD NTDS. Also test repr and as dict.""" + dns_nodes = list(goad.dns_nodes()) + assert len(dns_nodes) == 113 + # there is no really guaranty regarding record order, thus we select them using name + a_record = next(node for node in dns_nodes if node.name == "WINTERFELL.north").dns_record[0] + assert repr(a_record) == ( + "" + ) + assert isinstance(a_record.data, DnsARecord) + assert a_record.data.ipv4_address == "10.0.2.15" + assert a_record.data.ip_address == "10.0.2.15" + assert a_record.timestamp is None + assert a_record.ttl_seconds == 3600 + + srv_record = next( + node + for node in dns_nodes + if node.dns_name == "_ldap._tcp.3c45e4c9-7d10-44d6-ba1f-6177134e58fd.domains._msdcs.sevenkingdoms.local" + ).dns_record[0] + assert isinstance(srv_record.data, SRVRecord) + assert srv_record.data.name_target == "winterfell.north.sevenkingdoms.local" + assert srv_record.data.port == 389 + assert srv_record.data.weight == 100 + assert srv_record.data.priority == 0 + assert srv_record.timestamp == datetime.datetime.fromisoformat("2025-12-18 17:00:00+00:00") + assert srv_record.ttl_seconds == 600 + + assert ( + repr(srv_record) == "" + ) + + assert srv_record.as_dict() == { + "data": {"name_target": "winterfell.north.sevenkingdoms.local", "port": 389, "priority": 0, "weight": 100}, + "timestamp": datetime.datetime(2025, 12, 18, 17, 0, tzinfo=datetime.timezone.utc), + "ttl_seconds": 600, + "type": "SRV", + } + + _msdcs = next(node for node in dns_nodes if node.dns_name == "_msdcs.sevenkingdoms.local") + # Test using the DecoderMap + soa_record: DnsRecord = next(record for record in _msdcs.get("dnsRecord") if record.type == DNS_RECORD_TYPE.SOA) + assert isinstance(soa_record.data, SOARecord) + assert soa_record.data.name_primary_server == "winterfell.north.sevenkingdoms.local" + assert soa_record.data.refresh == 900 + assert soa_record.data.retry == 600 + assert soa_record.data.minimum_ttl == 3600 + assert soa_record.data.zone_administrator_email == "hostmaster.sevenkingdoms.local" + assert soa_record.timestamp is None + assert srv_record.ttl_seconds == 600 + + ns_records = sorted( + (record for record in _msdcs.dns_record if record.type == DNS_RECORD_TYPE.NS), key=lambda x: x.data.name_node + )[0] + assert isinstance(ns_records.data, NodeNameRecord) + assert ns_records.data.name_node == "kingslanding.sevenkingdoms.local" + assert ns_records.timestamp is None + assert ns_records.ttl_seconds == 3600 + + aaaa_record = next(node for node in dns_nodes if node.dns_name == "l.root-servers.net.RootDNSServers").dns_record[0] + assert isinstance(aaaa_record.data, DnsAAAARecord) + assert aaaa_record.data.ipv6_address == "2001:500:9f::42" + assert aaaa_record.data.ip_address == "2001:500:9f::42" + assert aaaa_record.timestamp is None + assert aaaa_record.ttl_seconds == 0 + + assert repr(aaaa_record) == ( + "" + ) + assert aaaa_record.as_dict() == { + "data": { + "ipv6_address": "2001:500:9f::42", + }, + "timestamp": None, + "ttl_seconds": 0, + "type": "AAAA", + } + + +def test_errored_dns_node(caplog: pytest.LogCaptureFixture) -> None: + errored_record = ( + b"\x04\x00\x01\x00\x05\x80\x00\x00\x19\x00\x00\x00\x00\x00\x0e\x10\x00\x00\x00\x00\x00\x00\x00\x00\n\x00\x02" + ) + with pytest.raises(EOFError): + _ = DnsRecord(errored_record) + + a_record = DnsRecord( + b"\x03\x00\x01\x00\x05\x80\x00\x00\x19\x00\x00\x00\x00\x00\x0e\x10\x00\x00\x00\x00\x00\x00\x00\x00\n\x00\x02" + ) + with pytest.raises(EOFError): + _ = a_record.data + with caplog.at_level(logging.WARNING): + assert a_record.as_dict() == {"data": None, "timestamp": None, "ttl_seconds": 3600, "type": "A"} + assert "Error processing DNS record: failed to parse data (record type: A)" in caplog.text + overflow_timestamp_record = DnsRecord( + b"\x04\x00\x01\x00\x05\x80\x00\x00\x19\x00\x00\x00\x00\x00\x0e" + b"\x10\x00\x00\x00\x00\xff\xff\xff\xff\n\x00\x02\x02" + ) + + with pytest.raises(OverflowError): + _ = overflow_timestamp_record.timestamp + + caplog.clear() + with caplog.at_level(logging.WARNING): + assert overflow_timestamp_record.as_dict() == { + "data": {"ipv4_address": "10.0.2.2"}, + "timestamp": None, + "ttl_seconds": 3600, + "type": "A", + } + assert "Error processing DNS record: invalid record timestamp" in caplog.text