From 7eb2cdca1457bc5261a4a4db55476a5b227d288b Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 23 Jan 2026 15:04:35 +0100 Subject: [PATCH 01/32] issue exchange attributes on 2012 R2 --- dissect/database/ese/tools/ntds.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 dissect/database/ese/tools/ntds.py diff --git a/dissect/database/ese/tools/ntds.py b/dissect/database/ese/tools/ntds.py new file mode 100644 index 0000000..0c9f71f --- /dev/null +++ b/dissect/database/ese/tools/ntds.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from dissect.database.ese.ntds import NTDS + + +def main() -> None: + parser = argparse.ArgumentParser(description="dissect.database.ese NTDS parser") + parser.add_argument("input", help="NTDS database to read") + parser.add_argument("-o", "--objectClass", help="show only 'object'", required=True) + parser.add_argument("-j", "--json", help="output in JSON format", action="store_true", default=False) + args = parser.parse_args() + + with Path(args.input).open("rb") as fh: + ntds = NTDS(fh) + + for record in ntds.search(objectClass=args.objectClass): + if args.json: + print(json.dumps(record, default=str)) + else: + print(record) + + +if __name__ == "__main__": + main() From 794994ca64de06d43cdd78cd0f3a33fd319f3fdb Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 23 Jan 2026 15:04:54 +0100 Subject: [PATCH 02/32] dns node --- .../database/ese/ntds/objects/c_dns_record.py | 101 ++++++ dissect/database/ese/ntds/objects/dnsnode.py | 290 ++++++++++++++++++ tests/_data/ese/ntds/large/ntds.dit.gz | 3 - tests/ese/ntds/test_dns_nodename.py | 18 ++ 4 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 dissect/database/ese/ntds/objects/c_dns_record.py delete mode 100644 tests/_data/ese/ntds/large/ntds.dit.gz create mode 100644 tests/ese/ntds/test_dns_nodename.py diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py new file mode 100644 index 0000000..62a206b --- /dev/null +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -0,0 +1,101 @@ +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 : uint16 { + 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]. +}; + +typedef struct DNS_RECORD_HEADER { + uint16 DataLength; + DNS_RECORD_TYPE Type; + uint8 Version; // Must be 0x05 + uint8 Rank; // Must be 0x05 + uint16 Flags; // Must be 0x00 + uint32 Serial; + uint32 TtlSeconds; // Big Endian + uint32 Reserved; // MUST be 0x00000000. + uint32 TimeStamp; + BYTE 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{ + uint8 NameLength; + char dnsName[NameLength+1]; +} + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a +typedef struct DNS_RPC_RECORD_SRV { + uint16 Priority; + uint16 Weight; + uint16 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 { + uint16 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 { + uint32 Serial; + uint32 Refresh; + uint32 Retry; + uint32 Expire; + uint32 MinimumTtl; + DNS_RPC_NAME namePrimaryServer; + DNS_RPC_NAME ZoneAdministratorEmail; +}; +""" +c_dns_record = cstruct(dns_record_def) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index e581dba..83ca25f 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -1,13 +1,303 @@ from __future__ import annotations +import datetime +import logging +import socket +import struct +from typing import NamedTuple + +from dissect.cstruct.utils import hexdump + +from dissect.database.ese.ntds.objects.c_dns_record import c_dns_record from dissect.database.ese.ntds.objects.top import Top +log = logging.getLogger(__name__) + + +def swap_endianess(data: int, int_len: int = 2, unsigned: bool = True) -> int: + """ + Swap endianess for a integer value + Args: + data: integer to conver + int_len: 1, 2, 4 or 8 + unsigned: if integer must be considered a signed or unsigned int + + Returns: + + """ + struct_letter = "h" + match int_len: + case 1: + struct_letter = "b" + case 2: + struct_letter = "h" + case 4: + struct_letter = "i" + case 8: + struct_letter = "q" + + if unsigned: + struct_letter = struct_letter.upper() + return struct.unpack(f">{struct_letter}", struct.pack(f"<{struct_letter}", int(data)))[0] + + +class DnsARecord(NamedTuple): + ipv4_address: str + + @property + def ip_address(self) -> str: + return self.ipv4_address + + +class DnsAAAARecord(NamedTuple): + ipv6_address: str + + @property + def ip_address(self) -> str: + return self.ipv6_address + + +class SOARecord(NamedTuple): + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066""" + + name_primary_server: str + serial: int + refresh: int + retry: int + minimum_ttl: int + zone_administrator_email: str + + +class NodeNameRecord(NamedTuple): + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e""" + + name_node: str + + +class StringRecord(NamedTuple): + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447""" + + stringData: str + + +class NamePreferenceRecord(NamedTuple): + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6""" + + name_exchange: str + preference: int + + +class SRVRecord(NamedTuple): + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a""" + + name_target: str + port: int + weight: int + priority: int + + +class DnsRecord: + def __init__(self, dns_records_bytes: bytes): + self.raw = dns_records_bytes + self.c_record_header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) + self.type = self.c_record_header.Type + self.ttl_seconds = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) + self.timestamp = self._timestamp() + + def __repr__(self): + return f"type={self.type!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" + + def _timestamp(self) -> datetime.datetime | None: + """timestamp is stored in hours""" + if self.c_record_header.TimeStamp == 0: + return None + try: + # 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.c_record_header.TimeStamp) + except OverflowError: + return None + + @property + def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePreferenceRecord | StringRecord| None: + data = bytearray(self.c_record_header.Data) + DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE + match self.type: + case DNS_RECORD_TYPE.A: + return self._parse_a_record(data) + case c_dns_record.DNS_RECORD_TYPE.AAAA: + return self._parse_aaaa_record(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 self._parse_node_name_record(data) + case DNS_RECORD_TYPE.MX | DNS_RECORD_TYPE.AFSDB | DNS_RECORD_TYPE.RT: + return self._parse_name_preference_record(data) + case DNS_RECORD_TYPE.SRV: + return self._parse_srv_record(data) + case DNS_RECORD_TYPE.SOA: + return self._parse_soa_record(data) + case DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC: + return self._parse_string_record(data) + return data + + @classmethod + def _parse_a_record(cls, data: bytes) -> DnsARecord | None: + """Parse A record (IPv4 address)""" + if len(data) >= 4: + ip = socket.inet_ntop(socket.AF_INET, data[:4]) + return DnsARecord(ipv4_address=ip) + return None + + @classmethod + def _parse_aaaa_record(cls, data: bytes) -> DnsAAAARecord | None: + """Parse AAAA record (IPv4 address)""" + if len(data) >= 16: + print() + ip = socket.inet_ntop(socket.AF_INET6, data[:16]) + return DnsAAAARecord(ipv6_address=ip) + return None + + @classmethod + def _parse_soa_record(cls, data: bytes) -> SOARecord | None: + """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 + Todo parse all fields + """ + try: + dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) + + return SOARecord( + name_primary_server=cls._parse_dns_name(dns_rpc_record_soa.namePrimaryServer.dnsName), + serial=swap_endianess(dns_rpc_record_soa.Serial, int_len=4), + refresh=swap_endianess(dns_rpc_record_soa.Refresh, int_len=4), + retry=swap_endianess(dns_rpc_record_soa.Retry, int_len=4), + minimum_ttl=swap_endianess(dns_rpc_record_soa.MinimumTtl, int_len=4), + zone_administrator_email=cls._parse_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), + ) + except EOFError: + return None + + @classmethod + def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: + """Parse Node Name type record, used for following record type : + DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, + DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF + + """ + try: + return NodeNameRecord(cls._parse_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) + except EOFError: + log.warning("Error while processing node name record%s", data) + hexdump(data) + return None + + @classmethod + def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | None: + """Parse DNS_RPC_RECORD_NAME_PREFERENCE record (E.g Mx)""" + try: + dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) + return NamePreferenceRecord( + preference=dns_rpc_record_name_preference.Preference, + name_exchange=cls._parse_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), + ) + except EOFError: + return None + + @classmethod + def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: + """Parse SRV record""" + try: + dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) + target = cls._parse_dns_name(dns_rpc_record_srv.nameTarget.dnsName) + return SRVRecord( + priority=dns_rpc_record_srv.Priority, + weight=dns_rpc_record_srv.Weight, + port=dns_rpc_record_srv.Port, + name_target=target, + ) + except EOFError: + return None + + @classmethod + def _parse_string_record(cls, data: bytes) -> StringRecord | None: + """Parse Node Name type record, used for following record type : + DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC + """ + try: + return StringRecord(c_dns_record.DNS_RPC_NAME(data).dnsName.decode('utf-8', errors="backslashreplace")) + except EOFError: + log.warning("Error while processing node name record%s", data) + hexdump(data) + return None + + @classmethod + def _parse_dns_name(cls, data: bytes) -> str: + """Parse DNS name as specified in rfc1035#section-3.1 format + + Args: + data: + + Returns: + + References: + - https://datatracker.ietf.org/doc/html/rfc1035#section-3.1 + """ + if not data: + return "" + _nb_segment = data[0] + data = data[1:] + name_parts = [] + offset = 0 + # 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 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" + + def __repr_body__(self) -> str: + return f"name={self.name!r}, records=|{'|'.join(repr(d) for d in self.dns_record)}|" + + @property + def dns_record(self) -> list[DnsRecord]: + dns_record = self.get("dnsRecord") + if dns_record is None: + return [] + return [DnsRecord(x) for x in dns_record] diff --git a/tests/_data/ese/ntds/large/ntds.dit.gz b/tests/_data/ese/ntds/large/ntds.dit.gz deleted file mode 100644 index 92b337e..0000000 --- a/tests/_data/ese/ntds/large/ntds.dit.gz +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ac1f9f526c817633ef3d6a73a26d6cfd5490a86e0ff8a1f64791fef12e95506f -size 126730533 diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py new file mode 100644 index 0000000..b7703c9 --- /dev/null +++ b/tests/ese/ntds/test_dns_nodename.py @@ -0,0 +1,18 @@ +import pytest + +from dissect.database.ese.ntds.objects.dnsnode import DnsRecord, _parse_dns_name + + +@pytest.mark.parametrize( + ("data", "expected_output"), + [(b"\x03\x0ckingslanding\rsevenkingdoms\x05local", "kingslanding.sevenkingdoms.local")], +) +def test_parse_dns_name(data: bytes, expected_output: str) -> None: + assert _parse_dns_name(data) == expected_output + + +@pytest.mark.parametrize( + ("data", "expected_output"), [(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00", "dc2-eu.test.lan.")], ids=["odd_length"] +) +def test_parse_dns_node_name(data: bytes, expected_output: str) -> None: + assert DnsRecord._parse_node_name_record(data).name_node == expected_output From 1f83a05def1cde4fb1eab9f2a01ce4e742f1d36c Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 28 Jan 2026 13:09:10 +0100 Subject: [PATCH 03/32] revert change --- dissect/database/ese/tools/ntds.py | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 dissect/database/ese/tools/ntds.py diff --git a/dissect/database/ese/tools/ntds.py b/dissect/database/ese/tools/ntds.py deleted file mode 100644 index 0c9f71f..0000000 --- a/dissect/database/ese/tools/ntds.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from dissect.database.ese.ntds import NTDS - - -def main() -> None: - parser = argparse.ArgumentParser(description="dissect.database.ese NTDS parser") - parser.add_argument("input", help="NTDS database to read") - parser.add_argument("-o", "--objectClass", help="show only 'object'", required=True) - parser.add_argument("-j", "--json", help="output in JSON format", action="store_true", default=False) - args = parser.parse_args() - - with Path(args.input).open("rb") as fh: - ntds = NTDS(fh) - - for record in ntds.search(objectClass=args.objectClass): - if args.json: - print(json.dumps(record, default=str)) - else: - print(record) - - -if __name__ == "__main__": - main() From 7be45061af510306155a98c2c6965e5b8e2b3212 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 28 Jan 2026 13:13:19 +0100 Subject: [PATCH 04/32] revert change --- tests/_data/ese/ntds/large/ntds.dit.gz | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/_data/ese/ntds/large/ntds.dit.gz diff --git a/tests/_data/ese/ntds/large/ntds.dit.gz b/tests/_data/ese/ntds/large/ntds.dit.gz new file mode 100644 index 0000000..92b337e --- /dev/null +++ b/tests/_data/ese/ntds/large/ntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac1f9f526c817633ef3d6a73a26d6cfd5490a86e0ff8a1f64791fef12e95506f +size 126730533 From 04548ec4fa89e8c78cea3b11c20ce2391b72823a Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 26 Mar 2026 15:41:13 +0100 Subject: [PATCH 05/32] Add stubs + ruff linting --- .../database/ese/ntds/objects/c_dns_record.py | 3 + .../ese/ntds/objects/c_dns_record.pyi | 107 ++++++++++++++++++ dissect/database/ese/ntds/objects/dnsnode.py | 90 +++++++++------ tests/ese/ntds/test_dns_nodename.py | 2 + 4 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 dissect/database/ese/ntds/objects/c_dns_record.pyi diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 62a206b..f92d7b4 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dissect.cstruct import cstruct dns_record_def = """ @@ -50,6 +52,7 @@ 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 { uint16 DataLength; DNS_RECORD_TYPE Type; diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi new file mode 100644 index 0000000..6d9d8ac --- /dev/null +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -0,0 +1,107 @@ +# 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__.Array[_c_dns_record.uint8] + @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__.Array[_c_dns_record.uint8] | 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 + 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 = ..., ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME | 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 diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 83ca25f..d2f2ce9 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -15,15 +15,12 @@ def swap_endianess(data: int, int_len: int = 2, unsigned: bool = True) -> int: - """ - Swap endianess for a integer value + """Swap endianess for a integer value. + Args: data: integer to conver int_len: 1, 2, 4 or 8 unsigned: if integer must be considered a signed or unsigned int - - Returns: - """ struct_letter = "h" match int_len: @@ -58,7 +55,11 @@ def ip_address(self) -> str: class SOARecord(NamedTuple): - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066""" + """The DNS_RPC_RECORD_SOA structure contains information about an SOA record. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 + """ name_primary_server: str serial: int @@ -69,26 +70,46 @@ class SOARecord(NamedTuple): class NodeNameRecord(NamedTuple): - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e""" + """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of 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. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e + """ name_node: str class StringRecord(NamedTuple): - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447""" + """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types: + DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + """ stringData: str class NamePreferenceRecord(NamedTuple): - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6""" + """The DNS_RPC_RECORD_NAME_PREFERENCE structure specifies information about a DNS + record of any of the following types: DNS_TYPE_MX, DNS_TYPE_AFSDB, DNS_TYPE_RT. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6 + """ name_exchange: str preference: int class SRVRecord(NamedTuple): - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a""" + """SRV ressource records. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a + """ name_target: str port: int @@ -97,18 +118,23 @@ class SRVRecord(NamedTuple): class DnsRecord: + """The dnsRecord attribute is used to store DNS resource record definitions. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 + """ def __init__(self, dns_records_bytes: bytes): - self.raw = dns_records_bytes - self.c_record_header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) - self.type = self.c_record_header.Type - self.ttl_seconds = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) - self.timestamp = self._timestamp() + self.raw : bytes = dns_records_bytes + self.c_record_header : c_dns_record.DNS_RECORD_HEADER = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) + self.type : c_dns_record.DNS_RECORD_TYPE = self.c_record_header.Type + self.ttl_seconds : int = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) + self.timestamp : datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): return f"type={self.type!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" - def _timestamp(self) -> datetime.datetime | None: - """timestamp is stored in hours""" + def get_timestamp_as_datetime(self) -> datetime.datetime | None: + """Timestamp is stored in hours.""" if self.c_record_header.TimeStamp == 0: return None try: @@ -145,13 +171,14 @@ def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePref return self._parse_srv_record(data) case DNS_RECORD_TYPE.SOA: return self._parse_soa_record(data) - case DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC: + case (DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, + DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC): return self._parse_string_record(data) return data @classmethod def _parse_a_record(cls, data: bytes) -> DnsARecord | None: - """Parse A record (IPv4 address)""" + """Parse A record (IPv4 address).""" if len(data) >= 4: ip = socket.inet_ntop(socket.AF_INET, data[:4]) return DnsARecord(ipv4_address=ip) @@ -159,16 +186,18 @@ def _parse_a_record(cls, data: bytes) -> DnsARecord | None: @classmethod def _parse_aaaa_record(cls, data: bytes) -> DnsAAAARecord | None: - """Parse AAAA record (IPv4 address)""" + """Parse AAAA record (IPv4 address).""" if len(data) >= 16: - print() ip = socket.inet_ntop(socket.AF_INET6, data[:16]) return DnsAAAARecord(ipv6_address=ip) return None @classmethod def _parse_soa_record(cls, data: bytes) -> SOARecord | None: - """https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 + """Parse SOA records. + + References: + https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 Todo parse all fields """ try: @@ -189,7 +218,7 @@ def _parse_soa_record(cls, data: bytes) -> SOARecord | None: def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: """Parse Node Name type record, used for following record type : DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, - DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF + DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF. """ try: @@ -201,7 +230,7 @@ def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: @classmethod def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | None: - """Parse DNS_RPC_RECORD_NAME_PREFERENCE record (E.g Mx)""" + """Parse DNS_RPC_RECORD_NAME_PREFERENCE record (E.g Mx).""" try: dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return NamePreferenceRecord( @@ -213,7 +242,7 @@ def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | No @classmethod def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: - """Parse SRV record""" + """Parse SRV record.""" try: dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) target = cls._parse_dns_name(dns_rpc_record_srv.nameTarget.dnsName) @@ -229,10 +258,10 @@ def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: @classmethod def _parse_string_record(cls, data: bytes) -> StringRecord | None: """Parse Node Name type record, used for following record type : - DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC + DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. """ try: - return StringRecord(c_dns_record.DNS_RPC_NAME(data).dnsName.decode('utf-8', errors="backslashreplace")) + return StringRecord(c_dns_record.DNS_RPC_NAME(data).dnsName.decode("utf-8", errors="backslashreplace")) except EOFError: log.warning("Error while processing node name record%s", data) hexdump(data) @@ -240,12 +269,7 @@ def _parse_string_record(cls, data: bytes) -> StringRecord | None: @classmethod def _parse_dns_name(cls, data: bytes) -> str: - """Parse DNS name as specified in rfc1035#section-3.1 format - - Args: - data: - - Returns: + """Parse DNS name as specified in rfc1035#section-3.1 format. References: - https://datatracker.ietf.org/doc/html/rfc1035#section-3.1 diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index b7703c9..20cae9d 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from dissect.database.ese.ntds.objects.dnsnode import DnsRecord, _parse_dns_name From 0ac165a3301a86ada1f8464085b9a6179ec7c0d1 Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 27 Mar 2026 09:56:04 +0100 Subject: [PATCH 06/32] Docs + tombstoned records --- .../database/ese/ntds/objects/c_dns_record.py | 7 ++ .../ese/ntds/objects/c_dns_record.pyi | 7 ++ dissect/database/ese/ntds/objects/dnsnode.py | 103 ++++++++++++------ 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index f92d7b4..4e0e527 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -100,5 +100,12 @@ DNS_RPC_NAME namePrimaryServer; 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 { + uint64 EntombedTime; +}; + """ c_dns_record = cstruct(dns_record_def) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index 6d9d8ac..f3755ef 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -103,5 +103,12 @@ class _c_dns_record(__cs__.cstruct): @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 diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index d2f2ce9..78b4a81 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -55,11 +55,7 @@ def ip_address(self) -> str: class SOARecord(NamedTuple): - """The DNS_RPC_RECORD_SOA structure contains information about an SOA record. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 - """ + """The DNS_RPC_RECORD_SOA structure contains information about an SOA record.""" name_primary_server: str serial: int @@ -71,11 +67,8 @@ class SOARecord(NamedTuple): class NodeNameRecord(NamedTuple): """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of 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. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e + 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 @@ -83,10 +76,7 @@ class NodeNameRecord(NamedTuple): class StringRecord(NamedTuple): """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types: - DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. """ stringData: str @@ -95,9 +85,6 @@ class StringRecord(NamedTuple): class NamePreferenceRecord(NamedTuple): """The DNS_RPC_RECORD_NAME_PREFERENCE structure specifies information about a DNS record of any of the following types: DNS_TYPE_MX, DNS_TYPE_AFSDB, DNS_TYPE_RT. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6 """ name_exchange: str @@ -105,11 +92,7 @@ class NamePreferenceRecord(NamedTuple): class SRVRecord(NamedTuple): - """SRV ressource records. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a - """ + """SRV ressource records.""" name_target: str port: int @@ -117,18 +100,25 @@ class SRVRecord(NamedTuple): priority: int +class TombStonedRecord(NamedTuple): + """ZERO ressource records.""" + + entombed_time: datetime.datetime + + class DnsRecord: """The dnsRecord attribute is used to store DNS resource record definitions. References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 """ + def __init__(self, dns_records_bytes: bytes): - self.raw : bytes = dns_records_bytes - self.c_record_header : c_dns_record.DNS_RECORD_HEADER = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) - self.type : c_dns_record.DNS_RECORD_TYPE = self.c_record_header.Type - self.ttl_seconds : int = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) - self.timestamp : datetime.datetime | None = self.get_timestamp_as_datetime() + self.raw: bytes = dns_records_bytes + self.c_record_header: c_dns_record.DNS_RECORD_HEADER = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) + self.type: c_dns_record.DNS_RECORD_TYPE = self.c_record_header.Type + self.ttl_seconds: int = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) + self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): return f"type={self.type!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" @@ -145,7 +135,7 @@ def get_timestamp_as_datetime(self) -> datetime.datetime | None: return None @property - def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePreferenceRecord | StringRecord| None: + def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePreferenceRecord | StringRecord | None: data = bytearray(self.c_record_header.Data) DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE match self.type: @@ -171,14 +161,22 @@ def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePref return self._parse_srv_record(data) case DNS_RECORD_TYPE.SOA: return self._parse_soa_record(data) - case (DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, - DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC): + case ( + DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, + DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC, + ): return self._parse_string_record(data) + case DNS_RECORD_TYPE.ZERO: + return self._parse_tombstoned_record(data) return data @classmethod def _parse_a_record(cls, data: bytes) -> DnsARecord | None: - """Parse A record (IPv4 address).""" + """Parse A record (IPv4 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/117c2ff9-9094-45b2-83c2-5e44518e0bac + """ if len(data) >= 4: ip = socket.inet_ntop(socket.AF_INET, data[:4]) return DnsARecord(ipv4_address=ip) @@ -186,7 +184,11 @@ def _parse_a_record(cls, data: bytes) -> DnsARecord | None: @classmethod def _parse_aaaa_record(cls, data: bytes) -> DnsAAAARecord | None: - """Parse AAAA record (IPv4 address).""" + """Parse AAAA record (IPv4 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a + """ if len(data) >= 16: ip = socket.inet_ntop(socket.AF_INET6, data[:16]) return DnsAAAARecord(ipv6_address=ip) @@ -198,7 +200,6 @@ def _parse_soa_record(cls, data: bytes) -> SOARecord | None: References: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 - Todo parse all fields """ try: dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) @@ -220,6 +221,8 @@ def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF. + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e """ try: return NodeNameRecord(cls._parse_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) @@ -230,7 +233,11 @@ def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: @classmethod def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | None: - """Parse DNS_RPC_RECORD_NAME_PREFERENCE record (E.g Mx).""" + """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 + """ try: dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return NamePreferenceRecord( @@ -242,7 +249,11 @@ def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | No @classmethod def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: - """Parse SRV record.""" + """Parse SRV record. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a + """ try: dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) target = cls._parse_dns_name(dns_rpc_record_srv.nameTarget.dnsName) @@ -259,6 +270,9 @@ def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: def _parse_string_record(cls, data: bytes) -> StringRecord | None: """Parse Node Name type record, used for following record type : DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 """ try: return StringRecord(c_dns_record.DNS_RPC_NAME(data).dnsName.decode("utf-8", errors="backslashreplace")) @@ -267,6 +281,25 @@ def _parse_string_record(cls, data: bytes) -> StringRecord | None: hexdump(data) return None + @classmethod + def _parse_tombstoned_record(cls, data: bytes) -> TombStonedRecord | None: + """The DNS_RPC_RECORD_TS specifies information for a node that has been tombstoned, + used for following record type : DNS_TYPE_ZERO. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + """ + try: + ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime + if ts_hundred_nano_seconds == 0: + return None + base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + return TombStonedRecord(base_date + datetime.timedelta(microseconds=ts_hundred_nano_seconds / 10)) + except EOFError: + log.warning("Error while processing node name record%s", data) + hexdump(data) + return None + @classmethod def _parse_dns_name(cls, data: bytes) -> str: """Parse DNS name as specified in rfc1035#section-3.1 format. From 09d7aa5317673dcde54a6379dc3d762c853dac29 Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 27 Mar 2026 10:33:02 +0100 Subject: [PATCH 07/32] Add tests --- .../ese/ntds/objects/c_dns_record.pyi | 37 ++++++++++-- dissect/database/ese/ntds/objects/dnsnode.py | 18 +++++- tests/ese/ntds/test_dns_nodename.py | 56 ++++++++++++++++++- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index f3755ef..ef7dd88 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -60,7 +60,19 @@ class _c_dns_record(__cs__.cstruct): TimeStamp: _c_dns_record.uint32 Data: __cs__.Array[_c_dns_record.uint8] @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__.Array[_c_dns_record.uint8] | None = ...): ... + 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__.Array[_c_dns_record.uint8] | None = ..., + ): ... @overload def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... @@ -78,7 +90,13 @@ class _c_dns_record(__cs__.cstruct): 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 = ...): ... + 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, /): ... @@ -86,7 +104,9 @@ class _c_dns_record(__cs__.cstruct): 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 = ...): ... + 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, /): ... @@ -99,7 +119,16 @@ class _c_dns_record(__cs__.cstruct): namePrimaryServer: _c_dns_record.DNS_RPC_NAME 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 = ..., ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME | None = ...): ... + 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 = ..., + ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME | None = ..., + ): ... @overload def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 78b4a81..7d760ba 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -135,7 +135,18 @@ def get_timestamp_as_datetime(self) -> datetime.datetime | None: return None @property - def data(self) -> bytes | DnsARecord | DnsAAAARecord | NodeNameRecord | NamePreferenceRecord | StringRecord | None: + def data( + self, + ) -> ( + bytes + | DnsARecord + | DnsAAAARecord + | NodeNameRecord + | NamePreferenceRecord + | StringRecord + | TombStonedRecord + | None + ): data = bytearray(self.c_record_header.Data) DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE match self.type: @@ -201,6 +212,7 @@ def _parse_soa_record(cls, data: bytes) -> SOARecord | None: References: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 """ + print(data) try: dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) @@ -259,8 +271,8 @@ def _parse_srv_record(cls, data: bytes) -> SRVRecord | None: target = cls._parse_dns_name(dns_rpc_record_srv.nameTarget.dnsName) return SRVRecord( priority=dns_rpc_record_srv.Priority, - weight=dns_rpc_record_srv.Weight, - port=dns_rpc_record_srv.Port, + weight=swap_endianess(dns_rpc_record_srv.Weight, 2), + port=swap_endianess(dns_rpc_record_srv.Port, 2), name_target=target, ) except EOFError: diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index 20cae9d..d09591f 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -1,16 +1,21 @@ from __future__ import annotations +import datetime +import typing + import pytest -from dissect.database.ese.ntds.objects.dnsnode import DnsRecord, _parse_dns_name +from dissect.database.ese.ntds.objects.dnsnode import DnsRecord +if typing.TYPE_CHECKING: + from dissect.database.ese.ntds.objects.dnsnode import SOARecord, SRVRecord @pytest.mark.parametrize( ("data", "expected_output"), [(b"\x03\x0ckingslanding\rsevenkingdoms\x05local", "kingslanding.sevenkingdoms.local")], ) def test_parse_dns_name(data: bytes, expected_output: str) -> None: - assert _parse_dns_name(data) == expected_output + assert DnsRecord._parse_dns_name(data) == expected_output @pytest.mark.parametrize( @@ -18,3 +23,50 @@ def test_parse_dns_name(data: bytes, expected_output: str) -> None: ) def test_parse_dns_node_name(data: bytes, expected_output: str) -> None: assert DnsRecord._parse_node_name_record(data).name_node == expected_output + + +def test_parse_dns_tombstoned_record() -> None: + assert DnsRecord._parse_tombstoned_record(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 DnsRecord._parse_string_record(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_soa_record() -> None: + pass + + +def test_parse_dns_srv_record() -> None: + srv_record: SRVRecord = DnsRecord._parse_srv_record(b"\x00\x00\x00d\x01\x85\x10\x03\x04dc01\x03twi\x05local\x00") + assert srv_record.priority == 0 + assert srv_record.weight == 100 + assert srv_record.port == 389 # LDAP + assert srv_record.name_target == "dc01.twi.local." + + +def test_parse_name_preference_record() -> None: + soa_record: SOARecord = DnsRecord._parse_soa_record( + b"\x00\x00\x00\x1a\x00\x00\x03\x84\x00\x00\x02X\x00\x01Q\x80\x00\x00\x0e\x10\x10\x03\x04dc01\x03twi\x05local\x00\x16\x03\nhostmaster\x03twi\x05local\x00" + ) + assert soa_record.name_primary_server == "dc01.twi.local." + assert soa_record.serial == 26 + assert soa_record.refresh == 900 + assert soa_record.retry == 600 + assert soa_record.minimum_ttl == 3600 + assert soa_record.zone_administrator_email == "hostmaster.twi.local." + + + +def test_parse_name_aaaa_record() -> None: + pass + + +def test_parse_name_a_record() -> None: + assert DnsRecord._parse_a_record(b"\xc0\xa8d\x1d").ipv4_address == "192.168.100.29" + # Error + assert DnsRecord._parse_a_record(b"\xc0") is None From 96a087e0ee468fabeea60ef232a552ed960420b2 Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 27 Mar 2026 14:32:51 +0100 Subject: [PATCH 08/32] Add tests --- .../database/ese/ntds/objects/c_dns_record.py | 3 +- .../ese/ntds/objects/c_dns_record.pyi | 38 +------ dissect/database/ese/ntds/objects/dnsnode.py | 29 +++-- tests/ese/ntds/test_dns_nodename.py | 102 +++++++++++++----- 4 files changed, 101 insertions(+), 71 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 4e0e527..7b88be6 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -71,7 +71,7 @@ // But observed reality is a null terminated string (null char not counted in NameLength) typedef struct DNS_RPC_NAME{ uint8 NameLength; - char dnsName[NameLength+1]; + char dnsName[NameLength]; } // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a @@ -98,6 +98,7 @@ uint32 Expire; uint32 MinimumTtl; DNS_RPC_NAME namePrimaryServer; + uint8 _pad; DNS_RPC_NAME ZoneAdministratorEmail; }; diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index ef7dd88..73ed144 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -60,19 +60,7 @@ class _c_dns_record(__cs__.cstruct): TimeStamp: _c_dns_record.uint32 Data: __cs__.Array[_c_dns_record.uint8] @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__.Array[_c_dns_record.uint8] | None = ..., - ): ... + 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__.Array[_c_dns_record.uint8] | None = ...): ... @overload def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... @@ -90,13 +78,7 @@ class _c_dns_record(__cs__.cstruct): 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 = ..., - ): ... + 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, /): ... @@ -104,9 +86,7 @@ class _c_dns_record(__cs__.cstruct): 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 = ... - ): ... + 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, /): ... @@ -117,18 +97,10 @@ class _c_dns_record(__cs__.cstruct): 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 = ..., - ZoneAdministratorEmail: _c_dns_record.DNS_RPC_NAME | None = ..., - ): ... + 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, /): ... diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 7d760ba..db15bbf 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -149,6 +149,7 @@ def data( ): data = bytearray(self.c_record_header.Data) DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE + match self.type: case DNS_RECORD_TYPE.A: return self._parse_a_record(data) @@ -173,8 +174,11 @@ def data( case DNS_RECORD_TYPE.SOA: return self._parse_soa_record(data) case ( - DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN | DNS_RECORD_TYPE.TXT, - DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC, + DNS_RECORD_TYPE.HINFO + | DNS_RECORD_TYPE.ISDN + | DNS_RECORD_TYPE.TXT + | DNS_RECORD_TYPE.X25 + | DNS_RECORD_TYPE.LOC ): return self._parse_string_record(data) case DNS_RECORD_TYPE.ZERO: @@ -212,7 +216,6 @@ def _parse_soa_record(cls, data: bytes) -> SOARecord | None: References: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 """ - print(data) try: dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) @@ -253,7 +256,7 @@ def _parse_name_preference_record(cls, data: bytes) -> NamePreferenceRecord | No try: dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return NamePreferenceRecord( - preference=dns_rpc_record_name_preference.Preference, + preference=swap_endianess(dns_rpc_record_name_preference.Preference, 2), name_exchange=cls._parse_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), ) except EOFError: @@ -283,14 +286,22 @@ def _parse_string_record(cls, data: bytes) -> StringRecord | None: """Parse Node Name type record, used for following record type : DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + 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 """ + records = [] try: - return StringRecord(c_dns_record.DNS_RPC_NAME(data).dnsName.decode("utf-8", errors="backslashreplace")) + data_consumed = 0 + + while data_consumed < len(data): + rpc_name = c_dns_record.DNS_RPC_NAME(data[data_consumed:]) + data_consumed += len(rpc_name) + 1 # Null byte present + records.append(rpc_name.dnsName.decode("utf-8", errors="backslashreplace")) + return StringRecord("\n".join(records)) except EOFError: - log.warning("Error while processing node name record%s", data) - hexdump(data) + log.warning("Error while processing node name record %s", data, exc_info=True) return None @classmethod @@ -322,9 +333,8 @@ def _parse_dns_name(cls, data: bytes) -> str: if not data: return "" _nb_segment = data[0] - data = data[1:] name_parts = [] - offset = 0 + 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 @@ -347,7 +357,6 @@ def _parse_dns_name(cls, data: bytes) -> str: part = data[offset : offset + length].decode("utf-8", errors="backslashreplace") name_parts.append(part) offset += length - return ".".join(name_parts) if name_parts else "" diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index d09591f..28ca011 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -3,26 +3,40 @@ import datetime import typing -import pytest - from dissect.database.ese.ntds.objects.dnsnode import DnsRecord if typing.TYPE_CHECKING: - from dissect.database.ese.ntds.objects.dnsnode import SOARecord, SRVRecord + from dissect.database.ese.ntds.objects.dnsnode import NamePreferenceRecord, SOARecord, SRVRecord + + +def test_parse_dns_name() -> None: + assert ( + DnsRecord._parse_dns_name(b"\x03\x0ckingslanding\rsevenkingdoms\x05local") == "kingslanding.sevenkingdoms.local" + ) -@pytest.mark.parametrize( - ("data", "expected_output"), - [(b"\x03\x0ckingslanding\rsevenkingdoms\x05local", "kingslanding.sevenkingdoms.local")], -) -def test_parse_dns_name(data: bytes, expected_output: str) -> None: - assert DnsRecord._parse_dns_name(data) == expected_output + assert ( + DnsRecord._parse_dns_name(b"\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local") + == "test.with.multiple.secion.sevenkingdoms.local" + ) -@pytest.mark.parametrize( - ("data", "expected_output"), [(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00", "dc2-eu.test.lan.")], ids=["odd_length"] -) -def test_parse_dns_node_name(data: bytes, expected_output: str) -> None: - assert DnsRecord._parse_node_name_record(data).name_node == expected_output +def test_parse_dns_node_name_record() -> None: + """Test a NodeName records (CNAME, PTR etc...).""" + assert ( + DnsRecord._parse_node_name_record(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00").name_node == "dc2-eu.test.lan" + ) # odd length + + assert ( + DnsRecord._parse_node_name_record(b"&\x04\nWINTERFELL\x05north\rsevenkingdoms\x05local\x00").name_node + == "WINTERFELL.north.sevenkingdoms.local" + ) + + assert ( + DnsRecord._parse_node_name_record( + b"/\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local\x00" + ).name_node + == "test.with.multiple.secion.sevenkingdoms.local" + ) def test_parse_dns_tombstoned_record() -> None: @@ -32,41 +46,75 @@ def test_parse_dns_tombstoned_record() -> None: def test_parse_dns_string_record() -> None: - assert DnsRecord._parse_string_record(b"\xf1\xba\x0c\xa5\xc8 \xdc\x01").entombed_time == datetime.datetime( - 2025, 9, 8, 13, 58, 24, 889522, tzinfo=datetime.timezone.utc + assert DnsRecord._parse_string_record( + 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" + ).stringData == ( + "TXT record made for dissect. Quite long to test if there is some limit size, " + "like over 64 characters or something like that.\n" + "Two new line above, and an special char (euro) : €" ) -def test_parse_dns_soa_record() -> None: - pass +def test_parse_dns_string_null_record() -> None: + """Test an empty TXT record.""" + assert DnsRecord._parse_string_record(b"\x00").stringData == "" + + +def test_parse_name_preference_record() -> None: + """Test with MX records, with two different preferences.""" + mx_record: NamePreferenceRecord = DnsRecord._parse_name_preference_record(b"\x00\x14\x0b\x01\tmailhost2\x00") + assert mx_record.name_exchange == "mailhost2" + assert mx_record.preference == 20 + + mx_record: NamePreferenceRecord = DnsRecord._parse_name_preference_record(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: - srv_record: SRVRecord = DnsRecord._parse_srv_record(b"\x00\x00\x00d\x01\x85\x10\x03\x04dc01\x03twi\x05local\x00") + """Parse an SRV record related to LDAP.""" + srv_record: SRVRecord = DnsRecord._parse_srv_record( + 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 == "dc01.twi.local." + assert srv_record.name_target == "winterfell.north.sevenkingdoms.local" -def test_parse_name_preference_record() -> None: +def test_parse_dns_soa_record() -> None: soa_record: SOARecord = DnsRecord._parse_soa_record( - b"\x00\x00\x00\x1a\x00\x00\x03\x84\x00\x00\x02X\x00\x01Q\x80\x00\x00\x0e\x10\x10\x03\x04dc01\x03twi\x05local\x00\x16\x03\nhostmaster\x03twi\x05local\x00" + 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 == "dc01.twi.local." - assert soa_record.serial == 26 + assert soa_record.name_primary_server == "kingslanding.sevenkingdoms.local" + assert soa_record.serial == 35 assert soa_record.refresh == 900 assert soa_record.retry == 600 assert soa_record.minimum_ttl == 3600 - assert soa_record.zone_administrator_email == "hostmaster.twi.local." - + assert soa_record.zone_administrator_email == "hostmaster.sevenkingdoms.local" def test_parse_name_aaaa_record() -> None: - pass + """Test multiple AAAA (IPv6) records.""" + assert ( + DnsRecord._parse_aaaa_record(b" \x01\r\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00").ipv6_address + == "2001:db8::1:0" + ) + assert ( + DnsRecord._parse_aaaa_record(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01").ipv6_address + == "::1" + ) + assert ( + DnsRecord._parse_aaaa_record(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 DnsRecord._parse_a_record(b"\xc0\xa8d\x1d").ipv4_address == "192.168.100.29" # Error assert DnsRecord._parse_a_record(b"\xc0") is None From 7db93dc79fca739d261444d3e24a2ade29580a2d Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 27 Mar 2026 15:32:12 +0100 Subject: [PATCH 09/32] Fix tests --- dissect/database/ese/ntds/objects/dnsnode.py | 5 +++-- tests/ese/ntds/test_dns_nodename.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index db15bbf..1ba0cfd 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -297,11 +297,12 @@ def _parse_string_record(cls, data: bytes) -> StringRecord | None: while data_consumed < len(data): rpc_name = c_dns_record.DNS_RPC_NAME(data[data_consumed:]) - data_consumed += len(rpc_name) + 1 # Null byte present + data_consumed += len(rpc_name) + records.append(rpc_name.dnsName.decode("utf-8", errors="backslashreplace")) return StringRecord("\n".join(records)) except EOFError: - log.warning("Error while processing node name record %s", data, exc_info=True) + log.warning("Error while processing node name record %s : %s", data, records, exc_info=True) return None @classmethod diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index 28ca011..0abf051 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -52,10 +52,20 @@ def test_parse_dns_string_record() -> None: b"and an special char (euro) : \xe2\x82\xac" ).stringData == ( "TXT record made for dissect. Quite long to test if there is some limit size, " - "like over 64 characters or something like that.\n" + "like over 64 characters or something like that.\n\n" "Two new line above, and an special char (euro) : €" ) + assert DnsRecord._parse_string_record( + 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" + ).stringData == ("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 (DnsRecord._parse_string_record(b"\x01q\x02qw\x03qwe\x04qwer\x05qwert\x06qwerty\x08qwertyui").stringData + == ("q\nqw\nqwe\nqwer\nqwert\nqwerty\nqwertyui")) + def test_parse_dns_string_null_record() -> None: """Test an empty TXT record.""" From f8a71f1b594df880243629b0540eab0a32d8e787 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 1 Apr 2026 10:50:54 +0200 Subject: [PATCH 10/32] Add tests, and move unpacking of dns records to a constructor --- dissect/database/ese/ntds/ntds.py | 5 + .../database/ese/ntds/objects/c_dns_record.py | 1 + .../ese/ntds/objects/c_dns_record.pyi | 38 +- dissect/database/ese/ntds/objects/dnsnode.py | 399 +++++++++--------- tests/ese/ntds/test_dns_nodename.py | 127 ++++-- 5 files changed, 346 insertions(+), 224 deletions(-) diff --git a/dissect/database/ese/ntds/ntds.py b/dissect/database/ese/ntds/ntds.py index e62e742..0ddcc04 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, @@ -109,6 +110,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_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 7b88be6..1c83d01 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -110,3 +110,4 @@ """ 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_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index 73ed144..bff8c70 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -60,7 +60,19 @@ class _c_dns_record(__cs__.cstruct): TimeStamp: _c_dns_record.uint32 Data: __cs__.Array[_c_dns_record.uint8] @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__.Array[_c_dns_record.uint8] | None = ...): ... + 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__.Array[_c_dns_record.uint8] | None = ..., + ): ... @overload def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... @@ -78,7 +90,13 @@ class _c_dns_record(__cs__.cstruct): 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 = ...): ... + 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, /): ... @@ -86,7 +104,9 @@ class _c_dns_record(__cs__.cstruct): 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 = ...): ... + 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, /): ... @@ -100,7 +120,17 @@ class _c_dns_record(__cs__.cstruct): _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 = ...): ... + 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, /): ... diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 1ba0cfd..6d1e0b4 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -4,11 +4,10 @@ import logging import socket import struct -from typing import NamedTuple +from functools import cached_property +from typing import Any, NamedTuple -from dissect.cstruct.utils import hexdump - -from dissect.database.ese.ntds.objects.c_dns_record import c_dns_record +from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE, c_dns_record from dissect.database.ese.ntds.objects.top import Top log = logging.getLogger(__name__) @@ -38,6 +37,42 @@ def swap_endianess(data: int, int_len: int = 2, unsigned: bool = True) -> int: return struct.unpack(f">{struct_letter}", struct.pack(f"<{struct_letter}", int(data)))[0] +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): ipv4_address: str @@ -45,6 +80,18 @@ class DnsARecord(NamedTuple): def ip_address(self) -> str: return self.ipv4_address + @classmethod + def from_bytes(cls, data: bytes) -> DnsARecord | None: + """Parse A record (IPv4 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/117c2ff9-9094-45b2-83c2-5e44518e0bac + """ + if len(data) >= 4: + ip = socket.inet_ntop(socket.AF_INET, data[:4]) + return cls(ipv4_address=ip) + return None + class DnsAAAARecord(NamedTuple): ipv6_address: str @@ -53,17 +100,54 @@ class DnsAAAARecord(NamedTuple): def ip_address(self) -> str: return self.ipv6_address + @classmethod + def from_bytes(cls, data: bytes) -> DnsAAAARecord | None: + """Parse AAAA record (IPv4 address). + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a + """ + if len(data) >= 16: + ip = socket.inet_ntop(socket.AF_INET6, data[:16]) + return cls(ipv6_address=ip) + return None + class SOARecord(NamedTuple): """The DNS_RPC_RECORD_SOA structure contains information about an SOA record.""" name_primary_server: str - serial: int + # 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 + """ + try: + dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) + return cls( + name_primary_server=parse_rfc1035_dns_name(dns_rpc_record_soa.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=swap_endianess(dns_rpc_record_soa.Serial, int_len=4), + refresh=swap_endianess(dns_rpc_record_soa.Refresh, int_len=4), + retry=swap_endianess(dns_rpc_record_soa.Retry, int_len=4), + minimum_ttl=swap_endianess(dns_rpc_record_soa.MinimumTtl, int_len=4), + zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), + ) + except EOFError: + log.warning("Error while processing SOA record %s", data) + return None + class NodeNameRecord(NamedTuple): """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of the following types: @@ -73,6 +157,21 @@ class NodeNameRecord(NamedTuple): name_node: str + @classmethod + def from_bytes(cls, data: bytes) -> NodeNameRecord | None: + """Parse Node Name type record, used for following record type : + DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, + DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e + """ + try: + return NodeNameRecord(parse_rfc1035_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) + except EOFError: + log.warning("Error while processing node name record %s", data) + return None + class StringRecord(NamedTuple): """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types: @@ -81,6 +180,30 @@ class StringRecord(NamedTuple): stringData: str + @classmethod + def from_bytes(cls, data: bytes) -> StringRecord | None: + """Parse Node Name type record, used for following record type : + DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + + 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 + """ + records = [] + try: + 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)) + except EOFError: + log.warning("Error while processing node name record %s : %s", data, records, exc_info=True) + return None + class NamePreferenceRecord(NamedTuple): """The DNS_RPC_RECORD_NAME_PREFERENCE structure specifies information about a DNS @@ -90,6 +213,23 @@ class NamePreferenceRecord(NamedTuple): 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 + """ + try: + dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) + return cls( + preference=swap_endianess(dns_rpc_record_name_preference.Preference, 2), + name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), + ) + except EOFError: + log.warning("Error while processing name preference record %s", data) + return None + class SRVRecord(NamedTuple): """SRV ressource records.""" @@ -99,12 +239,50 @@ class SRVRecord(NamedTuple): 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 + """ + try: + dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) + target = parse_rfc1035_dns_name(dns_rpc_record_srv.nameTarget.dnsName) + return SRVRecord( + priority=dns_rpc_record_srv.Priority, + weight=swap_endianess(dns_rpc_record_srv.Weight, 2), + port=swap_endianess(dns_rpc_record_srv.Port, 2), + name_target=target, + ) + except EOFError: + log.warning("Error while processing SRV record %s", data) + return None + class TombStonedRecord(NamedTuple): """ZERO ressource 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 following record type : DNS_TYPE_ZERO. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + """ + try: + ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime + if ts_hundred_nano_seconds == 0: + return None + base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + return TombStonedRecord(base_date + datetime.timedelta(microseconds=ts_hundred_nano_seconds / 10)) + except EOFError: + log.warning("Error while processing node name record%s", data) + return None + class DnsRecord: """The dnsRecord attribute is used to store DNS resource record definitions. @@ -132,6 +310,7 @@ def get_timestamp_as_datetime(self) -> datetime.datetime | None: base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) return base_date + datetime.timedelta(hours=self.c_record_header.TimeStamp) except OverflowError: + log.warning("Overflow error will trying to parse dns node timestamp") return None @property @@ -148,13 +327,13 @@ def data( | None ): data = bytearray(self.c_record_header.Data) - DNS_RECORD_TYPE = c_dns_record.DNS_RECORD_TYPE + # Process most commons DNS records type match self.type: case DNS_RECORD_TYPE.A: - return self._parse_a_record(data) + return DnsARecord.from_bytes(data) case c_dns_record.DNS_RECORD_TYPE.AAAA: - return self._parse_aaaa_record(data) + return DnsAAAARecord.from_bytes(data) case ( DNS_RECORD_TYPE.PTR | DNS_RECORD_TYPE.NS @@ -166,13 +345,13 @@ def data( | DNS_RECORD_TYPE.MD | DNS_RECORD_TYPE.MF ): - return self._parse_node_name_record(data) + return NodeNameRecord.from_bytes(data) case DNS_RECORD_TYPE.MX | DNS_RECORD_TYPE.AFSDB | DNS_RECORD_TYPE.RT: - return self._parse_name_preference_record(data) + return NamePreferenceRecord.from_bytes(data) case DNS_RECORD_TYPE.SRV: - return self._parse_srv_record(data) + return SRVRecord.from_bytes(data) case DNS_RECORD_TYPE.SOA: - return self._parse_soa_record(data) + return SOARecord.from_bytes(data) case ( DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN @@ -180,186 +359,11 @@ def data( | DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC ): - return self._parse_string_record(data) + return StringRecord.from_bytes(data) case DNS_RECORD_TYPE.ZERO: - return self._parse_tombstoned_record(data) + return TombStonedRecord.from_bytes(data) return data - @classmethod - def _parse_a_record(cls, data: bytes) -> DnsARecord | None: - """Parse A record (IPv4 address). - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/117c2ff9-9094-45b2-83c2-5e44518e0bac - """ - if len(data) >= 4: - ip = socket.inet_ntop(socket.AF_INET, data[:4]) - return DnsARecord(ipv4_address=ip) - return None - - @classmethod - def _parse_aaaa_record(cls, data: bytes) -> DnsAAAARecord | None: - """Parse AAAA record (IPv4 address). - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a - """ - if len(data) >= 16: - ip = socket.inet_ntop(socket.AF_INET6, data[:16]) - return DnsAAAARecord(ipv6_address=ip) - return None - - @classmethod - def _parse_soa_record(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 - """ - try: - dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) - - return SOARecord( - name_primary_server=cls._parse_dns_name(dns_rpc_record_soa.namePrimaryServer.dnsName), - serial=swap_endianess(dns_rpc_record_soa.Serial, int_len=4), - refresh=swap_endianess(dns_rpc_record_soa.Refresh, int_len=4), - retry=swap_endianess(dns_rpc_record_soa.Retry, int_len=4), - minimum_ttl=swap_endianess(dns_rpc_record_soa.MinimumTtl, int_len=4), - zone_administrator_email=cls._parse_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), - ) - except EOFError: - return None - - @classmethod - def _parse_node_name_record(cls, data: bytes) -> NodeNameRecord | None: - """Parse Node Name type record, used for following record type : - DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, - DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e - """ - try: - return NodeNameRecord(cls._parse_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) - except EOFError: - log.warning("Error while processing node name record%s", data) - hexdump(data) - return None - - @classmethod - def _parse_name_preference_record(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 - """ - try: - dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) - return NamePreferenceRecord( - preference=swap_endianess(dns_rpc_record_name_preference.Preference, 2), - name_exchange=cls._parse_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), - ) - except EOFError: - return None - - @classmethod - def _parse_srv_record(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 - """ - try: - dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) - target = cls._parse_dns_name(dns_rpc_record_srv.nameTarget.dnsName) - return SRVRecord( - priority=dns_rpc_record_srv.Priority, - weight=swap_endianess(dns_rpc_record_srv.Weight, 2), - port=swap_endianess(dns_rpc_record_srv.Port, 2), - name_target=target, - ) - except EOFError: - return None - - @classmethod - def _parse_string_record(cls, data: bytes) -> StringRecord | None: - """Parse Node Name type record, used for following record type : - DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. - - 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 - """ - records = [] - try: - 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 StringRecord("\n".join(records)) - except EOFError: - log.warning("Error while processing node name record %s : %s", data, records, exc_info=True) - return None - - @classmethod - def _parse_tombstoned_record(cls, data: bytes) -> TombStonedRecord | None: - """The DNS_RPC_RECORD_TS specifies information for a node that has been tombstoned, - used for following record type : DNS_TYPE_ZERO. - - References: - - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 - """ - try: - ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime - if ts_hundred_nano_seconds == 0: - return None - base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) - return TombStonedRecord(base_date + datetime.timedelta(microseconds=ts_hundred_nano_seconds / 10)) - except EOFError: - log.warning("Error while processing node name record%s", data) - hexdump(data) - return None - - @classmethod - def _parse_dns_name(cls, 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 DnsNode(Top): """Represents a DNS node object in the Active Directory. @@ -372,7 +376,7 @@ class DnsNode(Top): __object_class__ = "dnsNode" def __repr_body__(self) -> str: - return f"name={self.name!r}, records=|{'|'.join(repr(d) for d in self.dns_record)}|" + return f"dns_name={self.distinguished_name_as_dns_name}, records=|{'|'.join(repr(d) for d in self.dns_record)}|" @property def dns_record(self) -> list[DnsRecord]: @@ -380,3 +384,18 @@ def dns_record(self) -> list[DnsRecord]: if dns_record is None: return [] return [DnsRecord(x) for x in dns_record] + + @cached_property + def distinguished_name_as_dns_name(self) -> str: + 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]: + ret = super().as_dict() + ret["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name + ret["parsed_dns_records"] = self.dns_record + return ret diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index 0abf051..6457d49 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -3,19 +3,30 @@ import datetime import typing -from dissect.database.ese.ntds.objects.dnsnode import DnsRecord +from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE +from dissect.database.ese.ntds.objects.dnsnode import ( + DnsAAAARecord, + DnsARecord, + NamePreferenceRecord, + NodeNameRecord, + SOARecord, + SRVRecord, + StringRecord, + TombStonedRecord, + parse_rfc1035_dns_name, +) if typing.TYPE_CHECKING: - from dissect.database.ese.ntds.objects.dnsnode import NamePreferenceRecord, SOARecord, SRVRecord + from dissect.database.ese.ntds import NTDS + from dissect.database.ese.ntds.objects.dnsnode import DnsRecord def test_parse_dns_name() -> None: - assert ( - DnsRecord._parse_dns_name(b"\x03\x0ckingslanding\rsevenkingdoms\x05local") == "kingslanding.sevenkingdoms.local" - ) + """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 ( - DnsRecord._parse_dns_name(b"\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local") + parse_rfc1035_dns_name(b"\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local") == "test.with.multiple.secion.sevenkingdoms.local" ) @@ -23,30 +34,28 @@ def test_parse_dns_name() -> None: def test_parse_dns_node_name_record() -> None: """Test a NodeName records (CNAME, PTR etc...).""" assert ( - DnsRecord._parse_node_name_record(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00").name_node == "dc2-eu.test.lan" + NodeNameRecord.from_bytes(b"\x11\x03\x06dc2-eu\x04test\x03lan\x00").name_node == "dc2-eu.test.lan" ) # odd length assert ( - DnsRecord._parse_node_name_record(b"&\x04\nWINTERFELL\x05north\rsevenkingdoms\x05local\x00").name_node + NodeNameRecord.from_bytes(b"&\x04\nWINTERFELL\x05north\rsevenkingdoms\x05local\x00").name_node == "WINTERFELL.north.sevenkingdoms.local" ) assert ( - DnsRecord._parse_node_name_record( - b"/\x06\x04test\x04with\x08multiple\x06secion\rsevenkingdoms\x05local\x00" - ).name_node + 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 DnsRecord._parse_tombstoned_record(b"\xf1\xba\x0c\xa5\xc8 \xdc\x01").entombed_time == datetime.datetime( + 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 DnsRecord._parse_string_record( + 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" @@ -56,36 +65,39 @@ def test_parse_dns_string_record() -> None: "Two new line above, and an special char (euro) : €" ) - assert DnsRecord._parse_string_record( + 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" - ).stringData == ("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 (DnsRecord._parse_string_record(b"\x01q\x02qw\x03qwe\x04qwer\x05qwert\x06qwerty\x08qwertyui").stringData - == ("q\nqw\nqwe\nqwer\nqwert\nqwerty\nqwertyui")) + ).stringData == ( + "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").stringData == ( + "q\nqw\nqwe\nqwer\nqwert\nqwerty\nqwertyui" + ) def test_parse_dns_string_null_record() -> None: """Test an empty TXT record.""" - assert DnsRecord._parse_string_record(b"\x00").stringData == "" + assert StringRecord.from_bytes(b"\x00").stringData == "" def test_parse_name_preference_record() -> None: """Test with MX records, with two different preferences.""" - mx_record: NamePreferenceRecord = DnsRecord._parse_name_preference_record(b"\x00\x14\x0b\x01\tmailhost2\x00") + 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 = DnsRecord._parse_name_preference_record(b"\x00\n\x0b\x01\tmailhost1\x00") + 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 = DnsRecord._parse_srv_record( + srv_record: SRVRecord = SRVRecord.from_bytes( b"\x00\x00\x00d\x01\x85&\x04\nwinterfell\x05north\rsevenkingdoms\x05local\x00" ) assert srv_record.priority == 0 @@ -95,12 +107,12 @@ def test_parse_dns_srv_record() -> None: def test_parse_dns_soa_record() -> None: - soa_record: SOARecord = DnsRecord._parse_soa_record( + 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 + # 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 @@ -110,21 +122,76 @@ def test_parse_dns_soa_record() -> None: def test_parse_name_aaaa_record() -> None: """Test multiple AAAA (IPv6) records.""" assert ( - DnsRecord._parse_aaaa_record(b" \x01\r\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00").ipv6_address + 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 ( - DnsRecord._parse_aaaa_record(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01").ipv6_address + DnsAAAARecord.from_bytes(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01").ipv6_address == "::1" ) assert ( - DnsRecord._parse_aaaa_record(b"\xfd\x17b\\\xf07\x00\x020\x84\x07n\x83\xeb\xab\x1b").ipv6_address + 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 DnsRecord._parse_a_record(b"\xc0\xa8d\x1d").ipv4_address == "192.168.100.29" + assert DnsARecord.from_bytes(b"\xc0\xa8d\x1d").ipv4_address == "192.168.100.29" # Error - assert DnsRecord._parse_a_record(b"\xc0") is None + assert DnsARecord.from_bytes(b"\xc0") is None + + +def test_dns_nodes(goad: NTDS) -> None: + """Test multiple DNS records from GOAD NTDS.""" + 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 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.distinguished_name_as_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 + + _msdcs = next(node for node in dns_nodes if node.distinguished_name_as_dns_name == "_msdcs.sevenkingdoms.local") + soa_record: DnsRecord = next(record for record in _msdcs.dns_record 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.distinguished_name_as_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 From 0a1f0f4c35c740078bb90fa297dc93def7c1bd69 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 1 Apr 2026 10:56:00 +0200 Subject: [PATCH 11/32] Fix as dict method --- dissect/database/ese/ntds/objects/dnsnode.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 6d1e0b4..9c857fc 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -364,6 +364,14 @@ def data( return TombStonedRecord.from_bytes(data) return data + def as_dict(self) -> dict[str, Any]: + return { + "type": self.type, + "ttl_seconds": self.ttl_seconds, + "timestamp": self.timestamp, + "data": self.data._asdict(), + } + class DnsNode(Top): """Represents a DNS node object in the Active Directory. @@ -397,5 +405,5 @@ def distinguished_name_as_dns_name(self) -> str: def as_dict(self) -> dict[str, Any]: ret = super().as_dict() ret["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name - ret["parsed_dns_records"] = self.dns_record + ret["parsed_dns_records"] = [r.as_dict() for r in self.dns_record] return ret From f63f033b043a8154f020a39c866e59158dcaf48c Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 1 Apr 2026 11:19:36 +0200 Subject: [PATCH 12/32] Add tests of repr and as dict --- dissect/database/ese/ntds/objects/dnsnode.py | 7 ++--- tests/ese/ntds/test_dns_nodename.py | 27 +++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 9c857fc..7d08aba 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -299,7 +299,7 @@ def __init__(self, dns_records_bytes: bytes): self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): - return f"type={self.type!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" + return f"type={self.type.name!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" def get_timestamp_as_datetime(self) -> datetime.datetime | None: """Timestamp is stored in hours.""" @@ -366,10 +366,11 @@ def data( def as_dict(self) -> dict[str, Any]: return { - "type": self.type, + "type": str(self.type.name), "ttl_seconds": self.ttl_seconds, "timestamp": self.timestamp, - "data": self.data._asdict(), + # isinstance(X, NamedTuple) does not work, but NamedTuple are subtype of tuple + "data": self.data._asdict() if isinstance(self.data, tuple) else self.data, } diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index 6457d49..d1263e6 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -143,11 +143,12 @@ def test_parse_name_a_record() -> None: def test_dns_nodes(goad: NTDS) -> None: - """Test multiple DNS records from GOAD NTDS.""" + """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) == "type='A' ttl_seconds=3600 timestamp=None data=DnsARecord(ipv4_address='10.0.2.15')" 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" @@ -168,6 +169,18 @@ def test_dns_nodes(goad: NTDS) -> None: 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) == "type='SRV' ttl_seconds=600 timestamp=2025-12-18 17:00:00+00:00 " + "data=SRVRecord(name_target='winterfell.north.sevenkingdoms.local', port=389, weight=100, priority=0)" + ) + + 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.distinguished_name_as_dns_name == "_msdcs.sevenkingdoms.local") soa_record: DnsRecord = next(record for record in _msdcs.dns_record if record.type == DNS_RECORD_TYPE.SOA) assert isinstance(soa_record.data, SOARecord) @@ -195,3 +208,15 @@ def test_dns_nodes(goad: NTDS) -> None: 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) == ( + "type='AAAA' ttl_seconds=0 timestamp=None data=DnsAAAARecord(ipv6_address='2001:500:9f::42')" + ) + assert aaaa_record.as_dict() == { + "data": { + "ipv6_address": "2001:500:9f::42", + }, + "timestamp": None, + "ttl_seconds": 0, + "type": "AAAA", + } From 3a7b6098a10cdf960e0644d45d3df7b3e8d0b4cf Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 1 Apr 2026 11:48:31 +0200 Subject: [PATCH 13/32] Add doc strings. --- dissect/database/ese/ntds/objects/dnsnode.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 7d08aba..5245398 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -396,6 +396,12 @@ def dns_record(self) -> list[DnsRecord]: @cached_property def distinguished_name_as_dns_name(self) -> str: + """Create a DNS name from node and parent names. + + E.g : + * 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"]: From 9c23e478070d8a6aa5452ae5d14135354ed5a164 Mon Sep 17 00:00:00 2001 From: william billaud <23636016+william-billaud@users.noreply.github.com> Date: Wed, 6 May 2026 11:55:05 +0200 Subject: [PATCH 14/32] Apply suggestions from code review Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/database/ese/ntds/ntds.py | 2 +- dissect/database/ese/ntds/objects/c_dns_record.py | 12 ++++++------ dissect/database/ese/ntds/objects/dnsnode.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dissect/database/ese/ntds/ntds.py b/dissect/database/ese/ntds/ntds.py index 0094f9f..e39c140 100644 --- a/dissect/database/ese/ntds/ntds.py +++ b/dissect/database/ese/ntds/ntds.py @@ -121,7 +121,7 @@ def secrets(self) -> Iterator[Secret]: def dns_nodes(self) -> Iterator[DnsNode]: """Get all DnsNode objects from the database.""" - yield from self.search(objectClass="DnsNode") + yield from self.search(objectClass="dnsNode") def backup_keys(self) -> Iterator[BackupKey]: """Get all DPAPI backup keys from the database.""" diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 1c83d01..b997d99 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -56,18 +56,18 @@ typedef struct DNS_RECORD_HEADER { uint16 DataLength; DNS_RECORD_TYPE Type; - uint8 Version; // Must be 0x05 - uint8 Rank; // Must be 0x05 - uint16 Flags; // Must be 0x00 + uint8 Version; // Must be 0x05 + uint8 Rank; // Must be 0x05 + uint16 Flags; // Must be 0x00 uint32 Serial; - uint32 TtlSeconds; // Big Endian - uint32 Reserved; // MUST be 0x00000000. + uint32 TtlSeconds; // Big Endian + uint32 Reserved; // MUST be 0x00000000. uint32 TimeStamp; BYTE 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. +// 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{ uint8 NameLength; diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 5245398..64ed88a 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -82,7 +82,7 @@ def ip_address(self) -> str: @classmethod def from_bytes(cls, data: bytes) -> DnsARecord | None: - """Parse A record (IPv4 address). + """Parse ``A`` record (IPv4 address). References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/117c2ff9-9094-45b2-83c2-5e44518e0bac @@ -102,7 +102,7 @@ def ip_address(self) -> str: @classmethod def from_bytes(cls, data: bytes) -> DnsAAAARecord | None: - """Parse AAAA record (IPv4 address). + """Parse ``AAAA`` record (IPv4 address). References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a @@ -145,7 +145,7 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), ) except EOFError: - log.warning("Error while processing SOA record %s", data) + log.warning("Error parsing SOA record %s", data) return None @@ -169,7 +169,7 @@ def from_bytes(cls, data: bytes) -> NodeNameRecord | None: try: return NodeNameRecord(parse_rfc1035_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) except EOFError: - log.warning("Error while processing node name record %s", data) + log.warning("Error parsing node name record %s", data) return None @@ -299,7 +299,7 @@ def __init__(self, dns_records_bytes: bytes): self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): - return f"type={self.type.name!r} ttl_seconds={self.ttl_seconds!r} timestamp={self.timestamp} data={self.data}" + return f"" def get_timestamp_as_datetime(self) -> datetime.datetime | None: """Timestamp is stored in hours.""" @@ -328,7 +328,7 @@ def data( ): data = bytearray(self.c_record_header.Data) - # Process most commons DNS records type + # Process most common DNS records types match self.type: case DNS_RECORD_TYPE.A: return DnsARecord.from_bytes(data) @@ -385,7 +385,7 @@ class DnsNode(Top): __object_class__ = "dnsNode" def __repr_body__(self) -> str: - return f"dns_name={self.distinguished_name_as_dns_name}, records=|{'|'.join(repr(d) for d in self.dns_record)}|" + return f"dns_name={self.distinguished_name_as_dns_name} records=|{'|'.join(repr(d) for d in self.dns_record)}|" @property def dns_record(self) -> list[DnsRecord]: From 75314bf2b32752f44097359505038f7c7d47f2b0 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 13:40:47 +0200 Subject: [PATCH 15/32] Satisfy obsession with aligned text --- .../database/ese/ntds/objects/c_dns_record.py | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index b997d99..4fd3aa6 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -6,50 +6,50 @@ // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/39b03b89-2264-4063-8198-d62f62a6441a enum DNS_RECORD_TYPE : uint16 { - 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]. + 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 From 8ee8c4699a70ae18c963afc7ecd5e6df33d8910b Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 13:45:22 +0200 Subject: [PATCH 16/32] Replace swap_endianess with dissect.cstruct.utils.swap* --- dissect/database/ese/ntds/objects/dnsnode.py | 46 ++++++-------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 64ed88a..6dce6b1 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -3,40 +3,17 @@ import datetime import logging import socket -import struct from functools import cached_property from typing import Any, NamedTuple +from dissect.cstruct.utils import swap16, swap32 + from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE, c_dns_record from dissect.database.ese.ntds.objects.top import Top log = logging.getLogger(__name__) -def swap_endianess(data: int, int_len: int = 2, unsigned: bool = True) -> int: - """Swap endianess for a integer value. - - Args: - data: integer to conver - int_len: 1, 2, 4 or 8 - unsigned: if integer must be considered a signed or unsigned int - """ - struct_letter = "h" - match int_len: - case 1: - struct_letter = "b" - case 2: - struct_letter = "h" - case 4: - struct_letter = "i" - case 8: - struct_letter = "q" - - if unsigned: - struct_letter = struct_letter.upper() - return struct.unpack(f">{struct_letter}", struct.pack(f"<{struct_letter}", int(data)))[0] - - def parse_rfc1035_dns_name(data: bytes) -> str: """Parse DNS name as specified in rfc1035#section-3.1 format. @@ -139,9 +116,9 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: # 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=swap_endianess(dns_rpc_record_soa.Serial, int_len=4), - refresh=swap_endianess(dns_rpc_record_soa.Refresh, int_len=4), - retry=swap_endianess(dns_rpc_record_soa.Retry, int_len=4), - minimum_ttl=swap_endianess(dns_rpc_record_soa.MinimumTtl, int_len=4), + refresh=swap32(dns_rpc_record_soa.Refresh), + retry=swap32(dns_rpc_record_soa.Retry), + minimum_ttl=swap32(dns_rpc_record_soa.MinimumTtl), zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), ) except EOFError: @@ -223,7 +200,7 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: try: dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return cls( - preference=swap_endianess(dns_rpc_record_name_preference.Preference, 2), + preference=swap16(dns_rpc_record_name_preference.Preference), name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), ) except EOFError: @@ -251,8 +228,8 @@ def from_bytes(cls, data: bytes) -> SRVRecord | None: target = parse_rfc1035_dns_name(dns_rpc_record_srv.nameTarget.dnsName) return SRVRecord( priority=dns_rpc_record_srv.Priority, - weight=swap_endianess(dns_rpc_record_srv.Weight, 2), - port=swap_endianess(dns_rpc_record_srv.Port, 2), + weight=swap16(dns_rpc_record_srv.Weight), + port=swap16(dns_rpc_record_srv.Port), name_target=target, ) except EOFError: @@ -295,11 +272,14 @@ def __init__(self, dns_records_bytes: bytes): self.raw: bytes = dns_records_bytes self.c_record_header: c_dns_record.DNS_RECORD_HEADER = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) self.type: c_dns_record.DNS_RECORD_TYPE = self.c_record_header.Type - self.ttl_seconds: int = swap_endianess(self.c_record_header.TtlSeconds, int_len=4) + self.ttl_seconds: int = swap32(self.c_record_header.TtlSeconds) self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): - return f"" + return ( + f"" + ) def get_timestamp_as_datetime(self) -> datetime.datetime | None: """Timestamp is stored in hours.""" From dd17b9c9a9f20c4cfa01f925e6d010ee734a9b1f Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 14:06:09 +0200 Subject: [PATCH 17/32] Fix tests + docstring changes. --- dissect/database/ese/ntds/objects/dnsnode.py | 40 +++++++++++++------- tests/ese/ntds/test_dns_nodename.py | 10 +++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 6dce6b1..53851e6 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -127,18 +127,24 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: class NodeNameRecord(NamedTuple): - """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of 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. + """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of 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, used for following record type : - DNS_TYPE_PTR, DNS_TYPE_N, DNS_TYPE_CNAM, DNS_TYPE_DNAM, - DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_M, DNS_TYPE_MF. + """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 @@ -151,16 +157,20 @@ def from_bytes(cls, data: bytes) -> NodeNameRecord | None: class StringRecord(NamedTuple): - """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types: - DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types. + + - DNS_TYPE_HINFO + - DNS_TYPE_ISDN + - DNS_TYPE_TXT + - DNS_TYPE_X25 + - DNS_TYPE_LOC """ stringData: str @classmethod def from_bytes(cls, data: bytes) -> StringRecord | None: - """Parse Node Name type record, used for following record type : - DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC. + """Parse Node Name type record (E.g TXT). Test using GUI does not allow to create record with a line length > 255 char. @@ -184,7 +194,11 @@ def from_bytes(cls, data: bytes) -> StringRecord | None: class NamePreferenceRecord(NamedTuple): """The DNS_RPC_RECORD_NAME_PREFERENCE structure specifies information about a DNS - record of any of the following types: DNS_TYPE_MX, DNS_TYPE_AFSDB, DNS_TYPE_RT. + record of any of the following types. + + - DNS_TYPE_MX + - DNS_TYPE_AFSDB + - DNS_TYPE_RT """ name_exchange: str @@ -378,8 +392,8 @@ def dns_record(self) -> list[DnsRecord]: def distinguished_name_as_dns_name(self) -> str: """Create a DNS name from node and parent names. - E.g : - * DC=NORTH,DC=SEVENKINGDOMS.LOCAL,CN=MICROSOFTDNS,DC=DOMAINDNSZONES,DC=SEVENKINGDOMS,DC=LOCAL -> + Examples: + DC=NORTH,DC=SEVENKINGDOMS.LOCAL,CN=MICROSOFTDNS,DC=DOMAINDNSZONES,DC=SEVENKINGDOMS,DC=LOCAL -> north.sevenkingdoms.local """ node = self.distinguished_name diff --git a/tests/ese/ntds/test_dns_nodename.py b/tests/ese/ntds/test_dns_nodename.py index d1263e6..cc14cb9 100644 --- a/tests/ese/ntds/test_dns_nodename.py +++ b/tests/ese/ntds/test_dns_nodename.py @@ -148,7 +148,9 @@ def test_dns_nodes(goad: NTDS) -> None: 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) == "type='A' ttl_seconds=3600 timestamp=None data=DnsARecord(ipv4_address='10.0.2.15')" + 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" @@ -170,8 +172,8 @@ def test_dns_nodes(goad: NTDS) -> None: assert srv_record.ttl_seconds == 600 assert ( - repr(srv_record) == "type='SRV' ttl_seconds=600 timestamp=2025-12-18 17:00:00+00:00 " - "data=SRVRecord(name_target='winterfell.north.sevenkingdoms.local', port=389, weight=100, priority=0)" + repr(srv_record) == "" ) assert srv_record.as_dict() == { @@ -210,7 +212,7 @@ def test_dns_nodes(goad: NTDS) -> None: assert aaaa_record.ttl_seconds == 0 assert repr(aaaa_record) == ( - "type='AAAA' ttl_seconds=0 timestamp=None data=DnsAAAARecord(ipv6_address='2001:500:9f::42')" + "" ) assert aaaa_record.as_dict() == { "data": { From 680020f9a515b91e79dcf2d64be14527b2123dcc Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 14:07:20 +0200 Subject: [PATCH 18/32] Move test_dns_nodename.py to ntds/objects --- tests/ese/ntds/objects/__init__.py | 0 tests/ese/ntds/{ => objects}/test_dns_nodename.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/ese/ntds/objects/__init__.py rename tests/ese/ntds/{ => objects}/test_dns_nodename.py (100%) 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/test_dns_nodename.py b/tests/ese/ntds/objects/test_dns_nodename.py similarity index 100% rename from tests/ese/ntds/test_dns_nodename.py rename to tests/ese/ntds/objects/test_dns_nodename.py From 05a8ecc78cf105c2f61b4aa92949e5dd50d09f11 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 14:15:22 +0200 Subject: [PATCH 19/32] Rename c_record_header to header. Add typing hint. --- dissect/database/ese/ntds/objects/c_dns_record.pyi | 1 + dissect/database/ese/ntds/objects/dnsnode.py | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index bff8c70..4ff3e58 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -143,3 +143,4 @@ class _c_dns_record(__cs__.cstruct): # 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 53851e6..7715b4c 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -284,9 +284,9 @@ class DnsRecord: def __init__(self, dns_records_bytes: bytes): self.raw: bytes = dns_records_bytes - self.c_record_header: c_dns_record.DNS_RECORD_HEADER = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) - self.type: c_dns_record.DNS_RECORD_TYPE = self.c_record_header.Type - self.ttl_seconds: int = swap32(self.c_record_header.TtlSeconds) + self.header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) + self.type: c_dns_record.DNS_RECORD_TYPE = self.header.Type + self.ttl_seconds: int = swap32(self.header.TtlSeconds) self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): @@ -297,12 +297,12 @@ def __repr__(self): def get_timestamp_as_datetime(self) -> datetime.datetime | None: """Timestamp is stored in hours.""" - if self.c_record_header.TimeStamp == 0: + if self.header.TimeStamp == 0: return None try: # 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.c_record_header.TimeStamp) + return base_date + datetime.timedelta(hours=self.header.TimeStamp) except OverflowError: log.warning("Overflow error will trying to parse dns node timestamp") return None @@ -320,7 +320,7 @@ def data( | TombStonedRecord | None ): - data = bytearray(self.c_record_header.Data) + data = bytearray(self.header.Data) # Process most common DNS records types match self.type: From 8c46e81d21b4b0a0be03fc17c53523f1484ee1cd Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 14:58:58 +0200 Subject: [PATCH 20/32] Remove useless usage of bytearray. --- .../database/ese/ntds/objects/c_dns_record.py | 2 +- dissect/database/ese/ntds/objects/dnsnode.py | 59 +++++++++---------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 4fd3aa6..e9d2923 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -63,7 +63,7 @@ uint32 TtlSeconds; // Big Endian uint32 Reserved; // MUST be 0x00000000. uint32 TimeStamp; - BYTE Data[DataLength]; + char Data[DataLength]; }; // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/3fd41adc-c69e-407b-979e-721251403132 diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 7715b4c..a4e1775 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -115,7 +115,7 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: name_primary_server=parse_rfc1035_dns_name(dns_rpc_record_soa.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=swap_endianess(dns_rpc_record_soa.Serial, int_len=4), + # serial=swap32(dns_rpc_record_soa.Serial, int_len=4), refresh=swap32(dns_rpc_record_soa.Refresh), retry=swap32(dns_rpc_record_soa.Retry), minimum_ttl=swap32(dns_rpc_record_soa.MinimumTtl), @@ -237,18 +237,15 @@ def from_bytes(cls, data: bytes) -> SRVRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a """ - try: - dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) - target = parse_rfc1035_dns_name(dns_rpc_record_srv.nameTarget.dnsName) - return SRVRecord( - priority=dns_rpc_record_srv.Priority, - weight=swap16(dns_rpc_record_srv.Weight), - port=swap16(dns_rpc_record_srv.Port), - name_target=target, - ) - except EOFError: - log.warning("Error while processing SRV record %s", data) - return None + log.warning("Date : %s", data) + dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) + target = parse_rfc1035_dns_name(dns_rpc_record_srv.nameTarget.dnsName) + return SRVRecord( + priority=dns_rpc_record_srv.Priority, + weight=swap16(dns_rpc_record_srv.Weight), + port=swap16(dns_rpc_record_srv.Port), + name_target=target, + ) class TombStonedRecord(NamedTuple): @@ -287,7 +284,6 @@ def __init__(self, dns_records_bytes: bytes): self.header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) self.type: c_dns_record.DNS_RECORD_TYPE = self.header.Type self.ttl_seconds: int = swap32(self.header.TtlSeconds) - self.timestamp: datetime.datetime | None = self.get_timestamp_as_datetime() def __repr__(self): return ( @@ -295,17 +291,14 @@ def __repr__(self): f"timestamp={self.timestamp} data={self.data}>" ) - def get_timestamp_as_datetime(self) -> datetime.datetime | None: + @property + def timestamp(self) -> datetime.datetime | None: """Timestamp is stored in hours.""" if self.header.TimeStamp == 0: return None - try: - # 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) - except OverflowError: - log.warning("Overflow error will trying to parse dns node timestamp") - 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( @@ -318,16 +311,18 @@ def data( | NamePreferenceRecord | StringRecord | TombStonedRecord + | SRVRecord + | SOARecord | None ): - data = bytearray(self.header.Data) + header_data = self.header.Data # Process most common DNS records types match self.type: case DNS_RECORD_TYPE.A: - return DnsARecord.from_bytes(data) + return DnsARecord.from_bytes(header_data) case c_dns_record.DNS_RECORD_TYPE.AAAA: - return DnsAAAARecord.from_bytes(data) + return DnsAAAARecord.from_bytes(header_data) case ( DNS_RECORD_TYPE.PTR | DNS_RECORD_TYPE.NS @@ -339,13 +334,13 @@ def data( | DNS_RECORD_TYPE.MD | DNS_RECORD_TYPE.MF ): - return NodeNameRecord.from_bytes(data) + return NodeNameRecord.from_bytes(header_data) case DNS_RECORD_TYPE.MX | DNS_RECORD_TYPE.AFSDB | DNS_RECORD_TYPE.RT: - return NamePreferenceRecord.from_bytes(data) + return NamePreferenceRecord.from_bytes(header_data) case DNS_RECORD_TYPE.SRV: - return SRVRecord.from_bytes(data) + return SRVRecord.from_bytes(header_data) case DNS_RECORD_TYPE.SOA: - return SOARecord.from_bytes(data) + return SOARecord.from_bytes(header_data) case ( DNS_RECORD_TYPE.HINFO | DNS_RECORD_TYPE.ISDN @@ -353,10 +348,10 @@ def data( | DNS_RECORD_TYPE.X25 | DNS_RECORD_TYPE.LOC ): - return StringRecord.from_bytes(data) + return StringRecord.from_bytes(header_data) case DNS_RECORD_TYPE.ZERO: - return TombStonedRecord.from_bytes(data) - return data + return TombStonedRecord.from_bytes(header_data) + return header_data def as_dict(self) -> dict[str, Any]: return { From 0ed50423dbb9949fc6fe728a226af2e03f5f303b Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 15:44:29 +0200 Subject: [PATCH 21/32] change error catching/logging logic. --- .../database/ese/ntds/objects/c_dns_record.py | 2 +- dissect/database/ese/ntds/objects/dnsnode.py | 132 +++++++++++------- tests/ese/ntds/objects/test_dns_nodename.py | 42 +++++- 3 files changed, 119 insertions(+), 57 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index e9d2923..9aefad3 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -57,7 +57,7 @@ uint16 DataLength; DNS_RECORD_TYPE Type; uint8 Version; // Must be 0x05 - uint8 Rank; // Must be 0x05 + uint8 Rank; uint16 Flags; // Must be 0x00 uint32 Serial; uint32 TtlSeconds; // Big Endian diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index a4e1775..0ec6a40 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -58,16 +58,19 @@ def ip_address(self) -> str: return self.ipv4_address @classmethod - def from_bytes(cls, data: bytes) -> DnsARecord | None: + 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) - return None + raise EOFError("A records with less than 4 bytes") class DnsAAAARecord(NamedTuple): @@ -78,16 +81,19 @@ def ip_address(self) -> str: return self.ipv6_address @classmethod - def from_bytes(cls, data: bytes) -> DnsAAAARecord | None: + def from_bytes(cls, data: bytes) -> DnsAAAARecord: """Parse ``AAAA`` record (IPv4 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) - return None + raise EOFError("AAAA records with less than 16 bytes") class SOARecord(NamedTuple): @@ -108,22 +114,21 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: References: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 + + Raises: + EOFError: Issue while unpacking structure. """ - try: - dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) - return cls( - name_primary_server=parse_rfc1035_dns_name(dns_rpc_record_soa.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(dns_rpc_record_soa.Refresh), - retry=swap32(dns_rpc_record_soa.Retry), - minimum_ttl=swap32(dns_rpc_record_soa.MinimumTtl), - zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), - ) - except EOFError: - log.warning("Error parsing SOA record %s", data) - return None + dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) + return cls( + name_primary_server=parse_rfc1035_dns_name(dns_rpc_record_soa.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(dns_rpc_record_soa.Refresh), + retry=swap32(dns_rpc_record_soa.Retry), + minimum_ttl=swap32(dns_rpc_record_soa.MinimumTtl), + zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), + ) class NodeNameRecord(NamedTuple): @@ -148,12 +153,11 @@ def from_bytes(cls, data: bytes) -> NodeNameRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/8f986756-f151-4f5b-bfcf-0d85be8b0d7e + + Raises: + EOFError: Issue while unpacking structure. """ - try: - return NodeNameRecord(parse_rfc1035_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) - except EOFError: - log.warning("Error parsing node name record %s", data) - return None + return NodeNameRecord(parse_rfc1035_dns_name(c_dns_record.DNS_RPC_NAME(data).dnsName)) class StringRecord(NamedTuple): @@ -176,20 +180,19 @@ def from_bytes(cls, data: bytes) -> StringRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + + Raises: + EOFError: Issue while unpacking structure. """ records = [] - try: - data_consumed = 0 + data_consumed = 0 - while data_consumed < len(data): - rpc_name = c_dns_record.DNS_RPC_NAME(data[data_consumed:]) - data_consumed += len(rpc_name) + 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)) - except EOFError: - log.warning("Error while processing node name record %s : %s", data, records, exc_info=True) - return None + records.append(rpc_name.dnsName.decode("utf-8", errors="backslashreplace")) + return cls("\n".join(records)) class NamePreferenceRecord(NamedTuple): @@ -210,16 +213,15 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/f647d391-6614-4c3e-b38b-4df971590eb6 + + Raises: + EOFError: Issue while unpacking structure. """ - try: - dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) - return cls( - preference=swap16(dns_rpc_record_name_preference.Preference), - name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), - ) - except EOFError: - log.warning("Error while processing name preference record %s", data) - return None + dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) + return cls( + preference=swap16(dns_rpc_record_name_preference.Preference), + name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), + ) class SRVRecord(NamedTuple): @@ -236,6 +238,9 @@ def from_bytes(cls, data: bytes) -> SRVRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a + + Raises: + EOFError: Issue while unpacking structure. """ log.warning("Date : %s", data) dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) @@ -260,16 +265,15 @@ def from_bytes(cls, data: bytes) -> TombStonedRecord | None: References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/69166ff5-36c1-4542-9243-13b8931fa447 + + Raises: + EOFError: Issue while unpacking structure. """ - try: - ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime - if ts_hundred_nano_seconds == 0: - return None - base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) - return TombStonedRecord(base_date + datetime.timedelta(microseconds=ts_hundred_nano_seconds / 10)) - except EOFError: - log.warning("Error while processing node name record%s", data) + ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime + if ts_hundred_nano_seconds == 0: return None + base_date = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) + return TombStonedRecord(base_date + datetime.timedelta(microseconds=ts_hundred_nano_seconds / 10)) class DnsRecord: @@ -293,7 +297,11 @@ def __repr__(self): @property def timestamp(self) -> datetime.datetime | None: - """Timestamp is stored in hours.""" + """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 @@ -315,6 +323,11 @@ def data( | 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 @@ -354,12 +367,23 @@ def data( return header_data def as_dict(self) -> dict[str, Any]: + try: + data = self.data + except EOFError: + log.warning("Issue while processing dns record : fail to parse data. Record type : %s.", str(self.type.name)) + data = None + + try: + timestamp = self.timestamp + except OverflowError: + log.warning("Issue while processing dns record : invalid record timestamp.") + timestamp = None return { "type": str(self.type.name), "ttl_seconds": self.ttl_seconds, - "timestamp": self.timestamp, + "timestamp": timestamp, # isinstance(X, NamedTuple) does not work, but NamedTuple are subtype of tuple - "data": self.data._asdict() if isinstance(self.data, tuple) else self.data, + "data": data._asdict() if isinstance(data, tuple) else data, } diff --git a/tests/ese/ntds/objects/test_dns_nodename.py b/tests/ese/ntds/objects/test_dns_nodename.py index cc14cb9..86f3100 100644 --- a/tests/ese/ntds/objects/test_dns_nodename.py +++ b/tests/ese/ntds/objects/test_dns_nodename.py @@ -1,12 +1,16 @@ from __future__ import annotations import datetime +import logging import typing +import pytest + from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE from dissect.database.ese.ntds.objects.dnsnode import ( DnsAAAARecord, DnsARecord, + DnsRecord, NamePreferenceRecord, NodeNameRecord, SOARecord, @@ -18,7 +22,6 @@ if typing.TYPE_CHECKING: from dissect.database.ese.ntds import NTDS - from dissect.database.ese.ntds.objects.dnsnode import DnsRecord def test_parse_dns_name() -> None: @@ -139,7 +142,8 @@ 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 - assert DnsARecord.from_bytes(b"\xc0") is None + with pytest.raises(EOFError): + DnsARecord.from_bytes(b"\xc0") def test_dns_nodes(goad: NTDS) -> None: @@ -222,3 +226,37 @@ def test_dns_nodes(goad: NTDS) -> 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 "Issue while processing dns record : fail 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 "Issue while processing dns record : invalid record timestamp." in caplog.text From 730e6d5da017e43b7368b31d7e05ed76b20447c5 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 15:46:13 +0200 Subject: [PATCH 22/32] Linting. --- dissect/database/ese/ntds/objects/dnsnode.py | 4 +++- tests/ese/ntds/objects/test_dns_nodename.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 0ec6a40..a6a45f9 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -370,7 +370,9 @@ def as_dict(self) -> dict[str, Any]: try: data = self.data except EOFError: - log.warning("Issue while processing dns record : fail to parse data. Record type : %s.", str(self.type.name)) + log.warning( + "Issue while processing dns record : fail to parse data. Record type : %s.", str(self.type.name) + ) data = None try: diff --git a/tests/ese/ntds/objects/test_dns_nodename.py b/tests/ese/ntds/objects/test_dns_nodename.py index 86f3100..4ae6bff 100644 --- a/tests/ese/ntds/objects/test_dns_nodename.py +++ b/tests/ese/ntds/objects/test_dns_nodename.py @@ -228,7 +228,7 @@ def test_dns_nodes(goad: NTDS) -> None: } -def test_errored_dns_node(caplog : pytest.LogCaptureFixture) -> None: +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" ) From 0857474d13ee4dd05630dcff4730610d009cf3e4 Mon Sep 17 00:00:00 2001 From: william billaud <23636016+william-billaud@users.noreply.github.com> Date: Wed, 6 May 2026 15:49:47 +0200 Subject: [PATCH 23/32] Apply suggestions from code review Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/database/ese/ntds/objects/dnsnode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index a6a45f9..c89d9c1 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -427,5 +427,5 @@ def distinguished_name_as_dns_name(self) -> str: def as_dict(self) -> dict[str, Any]: ret = super().as_dict() ret["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name - ret["parsed_dns_records"] = [r.as_dict() for r in self.dns_record] + ret["dns_records"] = [r.as_dict() for r in self.dns_record] return ret From 47914684efe237966cef1a71da3cb0649f4af7b4 Mon Sep 17 00:00:00 2001 From: wbi Date: Wed, 6 May 2026 15:56:07 +0200 Subject: [PATCH 24/32] Delete duplicate entry. --- dissect/database/ese/ntds/objects/dnsnode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index c89d9c1..1bb18f5 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -428,4 +428,5 @@ def as_dict(self) -> dict[str, Any]: ret = super().as_dict() ret["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name ret["dns_records"] = [r.as_dict() for r in self.dns_record] + del ret["dnsRecord"] return ret From 813b183be7ce7a11649a375cfc641c5aed3ac877 Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 7 May 2026 09:40:03 +0200 Subject: [PATCH 25/32] Use windows type in structure. --- .../database/ese/ntds/objects/c_dns_record.py | 52 +++++++++---------- .../ese/ntds/objects/c_dns_record.pyi | 4 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 9aefad3..1a8d60f 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -5,7 +5,7 @@ dns_record_def = """ // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/39b03b89-2264-4063-8198-d62f62a6441a -enum DNS_RECORD_TYPE : uint16 { +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 @@ -54,58 +54,58 @@ // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 typedef struct DNS_RECORD_HEADER { - uint16 DataLength; + WORD DataLength; DNS_RECORD_TYPE Type; - uint8 Version; // Must be 0x05 - uint8 Rank; - uint16 Flags; // Must be 0x00 - uint32 Serial; - uint32 TtlSeconds; // Big Endian - uint32 Reserved; // MUST be 0x00000000. - uint32 TimeStamp; - char Data[DataLength]; + 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{ - uint8 NameLength; - char dnsName[NameLength]; + 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 { - uint16 Priority; - uint16 Weight; - uint16 Port; + 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 { - uint16 Preference; - DNS_RPC_NAME nameExchange; + 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 { - uint32 Serial; - uint32 Refresh; - uint32 Retry; - uint32 Expire; - uint32 MinimumTtl; - DNS_RPC_NAME namePrimaryServer; - uint8 _pad; - DNS_RPC_NAME ZoneAdministratorEmail; + 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 { - uint64 EntombedTime; + QWORD EntombedTime; }; """ diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dns_record.pyi index 4ff3e58..522e37a 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.pyi +++ b/dissect/database/ese/ntds/objects/c_dns_record.pyi @@ -58,7 +58,7 @@ class _c_dns_record(__cs__.cstruct): TtlSeconds: _c_dns_record.uint32 Reserved: _c_dns_record.uint32 TimeStamp: _c_dns_record.uint32 - Data: __cs__.Array[_c_dns_record.uint8] + Data: __cs__.CharArray @overload def __init__( self, @@ -71,7 +71,7 @@ class _c_dns_record(__cs__.cstruct): TtlSeconds: _c_dns_record.uint32 | None = ..., Reserved: _c_dns_record.uint32 | None = ..., TimeStamp: _c_dns_record.uint32 | None = ..., - Data: __cs__.Array[_c_dns_record.uint8] | None = ..., + Data: __cs__.CharArray | None = ..., ): ... @overload def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... From d8f5e60b15de8423fbee875443e6559def8714ec Mon Sep 17 00:00:00 2001 From: william billaud <23636016+william-billaud@users.noreply.github.com> Date: Thu, 7 May 2026 15:06:19 +0200 Subject: [PATCH 26/32] Apply suggestions from code review Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- .../database/ese/ntds/objects/c_dns_record.py | 60 +++++++++---------- dissect/database/ese/ntds/objects/dnsnode.py | 53 ++++++++-------- 2 files changed, 54 insertions(+), 59 deletions(-) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dns_record.py index 1a8d60f..6b6f7cf 100644 --- a/dissect/database/ese/ntds/objects/c_dns_record.py +++ b/dissect/database/ese/ntds/objects/c_dns_record.py @@ -54,60 +54,56 @@ // 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]; + 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]; -} + 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; + 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; + 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; + 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; + 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/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 1bb18f5..48c514c 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -82,7 +82,7 @@ def ip_address(self) -> str: @classmethod def from_bytes(cls, data: bytes) -> DnsAAAARecord: - """Parse ``AAAA`` record (IPv4 address). + """Parse ``AAAA`` record (IPv6 address). References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/ee33fef1-6e82-42d0-8107-0f6d21be072a @@ -97,7 +97,7 @@ def from_bytes(cls, data: bytes) -> DnsAAAARecord: class SOARecord(NamedTuple): - """The DNS_RPC_RECORD_SOA structure contains information about an SOA record.""" + """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 @@ -110,7 +110,7 @@ class SOARecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> SOARecord | None: - """Parse SOA records. + """Parse ``SOA`` records. References: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/dcd3ec16-d6bf-4bb4-9128-6172f9e5f066 @@ -132,7 +132,7 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: class NodeNameRecord(NamedTuple): - """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of the following types. + """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record of any of the following types: - DNS_TYPE_PTR - DNS_TYPE_NS @@ -149,7 +149,7 @@ class NodeNameRecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> NodeNameRecord | None: - """Parse Node Name type record (E.g CNAME, PTR). + """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 @@ -161,7 +161,7 @@ def from_bytes(cls, data: bytes) -> NodeNameRecord | None: class StringRecord(NamedTuple): - """The DNS_RPC_RECORD_STRING structure contains information about a DNS record of any of the following types. + """The ``DNS_RPC_RECORD_STRING`` structure contains information about a DNS record of any of the following types: - DNS_TYPE_HINFO - DNS_TYPE_ISDN @@ -174,7 +174,7 @@ class StringRecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> StringRecord | None: - """Parse Node Name type record (E.g TXT). + """Parse Node Name type record (E.g ``TXT``). Test using GUI does not allow to create record with a line length > 255 char. @@ -196,8 +196,8 @@ def from_bytes(cls, data: bytes) -> StringRecord | None: class NamePreferenceRecord(NamedTuple): - """The DNS_RPC_RECORD_NAME_PREFERENCE structure specifies information about a DNS - record of any of the following types. + """The ``DNS_RPC_RECORD_NAME_PREFERENCE`` structure specifies information about a DNS + record of any of the following types: - DNS_TYPE_MX - DNS_TYPE_AFSDB @@ -209,7 +209,7 @@ class NamePreferenceRecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: - """Parse DNS_RPC_RECORD_NAME_PREFERENCE record (E.g Mx). + """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 @@ -217,7 +217,7 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: Raises: EOFError: Issue while unpacking structure. """ - dns_rpc_record_name_preference = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) + record = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return cls( preference=swap16(dns_rpc_record_name_preference.Preference), name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), @@ -225,7 +225,7 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: class SRVRecord(NamedTuple): - """SRV ressource records.""" + """``SRV`` ressource records.""" name_target: str port: int @@ -234,7 +234,7 @@ class SRVRecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> SRVRecord | None: - """Parse SRV record. + """Parse ``SRV`` record. References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/db37cab7-f121-43ba-81c5-ca0e198d4b9a @@ -254,14 +254,14 @@ def from_bytes(cls, data: bytes) -> SRVRecord | None: class TombStonedRecord(NamedTuple): - """ZERO ressource records.""" + """``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 following record type : DNS_TYPE_ZERO. + """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 @@ -286,10 +286,10 @@ class DnsRecord: def __init__(self, dns_records_bytes: bytes): self.raw: bytes = dns_records_bytes self.header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) - self.type: c_dns_record.DNS_RECORD_TYPE = self.header.Type - self.ttl_seconds: int = swap32(self.header.TtlSeconds) + self.type = self.header.Type + self.ttl_seconds = swap32(self.header.TtlSeconds) - def __repr__(self): + def __repr__(self) -> str: return ( f"" @@ -371,17 +371,17 @@ def as_dict(self) -> dict[str, Any]: data = self.data except EOFError: log.warning( - "Issue while processing dns record : fail to parse data. Record type : %s.", str(self.type.name) + "Error processing DNS record: failed to parse data (record type: %s)", self.type.name ) data = None try: timestamp = self.timestamp except OverflowError: - log.warning("Issue while processing dns record : invalid record timestamp.") + log.warning("Error processing DNS record: invalid record timestamp") timestamp = None return { - "type": str(self.type.name), + "type": self.type.name, "ttl_seconds": self.ttl_seconds, "timestamp": timestamp, # isinstance(X, NamedTuple) does not work, but NamedTuple are subtype of tuple @@ -425,8 +425,7 @@ def distinguished_name_as_dns_name(self) -> str: return ".".join(ret).replace("\n", "\\n") def as_dict(self) -> dict[str, Any]: - ret = super().as_dict() - ret["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name - ret["dns_records"] = [r.as_dict() for r in self.dns_record] - del ret["dnsRecord"] - return ret + result = super().as_dict() + result["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name + result["dns_record"] = [r.as_dict() for r in self.dns_record] + return result From 3acdf99288c61c2604eeca0221242241b4ddaeed Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 7 May 2026 17:26:58 +0200 Subject: [PATCH 27/32] Apply suggestion from review + modification of docstring with list --- dissect/database/ese/ntds/objects/dnsnode.py | 89 ++++++++++--------- .../{test_dns_nodename.py => test_dnsnode.py} | 0 2 files changed, 47 insertions(+), 42 deletions(-) rename tests/ese/ntds/objects/{test_dns_nodename.py => test_dnsnode.py} (100%) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 48c514c..61a9b38 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -118,31 +118,32 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: Raises: EOFError: Issue while unpacking structure. """ - dns_rpc_record_soa = c_dns_record.DNS_RPC_RECORD_SOA(data) + record = c_dns_record.DNS_RPC_RECORD_SOA(data) return cls( - name_primary_server=parse_rfc1035_dns_name(dns_rpc_record_soa.namePrimaryServer.dnsName), + 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(dns_rpc_record_soa.Refresh), - retry=swap32(dns_rpc_record_soa.Retry), - minimum_ttl=swap32(dns_rpc_record_soa.MinimumTtl), - zone_administrator_email=parse_rfc1035_dns_name(dns_rpc_record_soa.ZoneAdministratorEmail.dnsName), + 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 of any of 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 + """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 @@ -161,13 +162,14 @@ def from_bytes(cls, data: bytes) -> NodeNameRecord | None: class StringRecord(NamedTuple): - """The ``DNS_RPC_RECORD_STRING`` structure contains information about a DNS record of any of the following types: - - - DNS_TYPE_HINFO - - DNS_TYPE_ISDN - - DNS_TYPE_TXT - - DNS_TYPE_X25 - - DNS_TYPE_LOC + """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. """ stringData: str @@ -197,11 +199,12 @@ def from_bytes(cls, data: bytes) -> StringRecord | None: class NamePreferenceRecord(NamedTuple): """The ``DNS_RPC_RECORD_NAME_PREFERENCE`` structure specifies information about a DNS - record of any of the following types: + record referring to another DNS name with a preference. - - DNS_TYPE_MX - - DNS_TYPE_AFSDB - - DNS_TYPE_RT + This corresponds to the following types: + - DNS_TYPE_MX + - DNS_TYPE_AFSDB + - DNS_TYPE_RT. """ name_exchange: str @@ -219,8 +222,8 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: """ record = c_dns_record.DNS_RPC_RECORD_NAME_PREFERENCE(data) return cls( - preference=swap16(dns_rpc_record_name_preference.Preference), - name_exchange=parse_rfc1035_dns_name(dns_rpc_record_name_preference.nameExchange.dnsName), + preference=swap16(record.Preference), + name_exchange=parse_rfc1035_dns_name(record.nameExchange.dnsName), ) @@ -242,13 +245,12 @@ def from_bytes(cls, data: bytes) -> SRVRecord | None: Raises: EOFError: Issue while unpacking structure. """ - log.warning("Date : %s", data) - dns_rpc_record_srv = c_dns_record.DNS_RPC_RECORD_SRV(data) - target = parse_rfc1035_dns_name(dns_rpc_record_srv.nameTarget.dnsName) + record = c_dns_record.DNS_RPC_RECORD_SRV(data) + target = parse_rfc1035_dns_name(record.nameTarget.dnsName) return SRVRecord( - priority=dns_rpc_record_srv.Priority, - weight=swap16(dns_rpc_record_srv.Weight), - port=swap16(dns_rpc_record_srv.Port), + priority=record.Priority, + weight=swap16(record.Weight), + port=swap16(record.Port), name_target=target, ) @@ -269,11 +271,11 @@ def from_bytes(cls, data: bytes) -> TombStonedRecord | None: Raises: EOFError: Issue while unpacking structure. """ - ts_hundred_nano_seconds = c_dns_record.DNS_RPC_RECORD_TS(data).EntombedTime - if ts_hundred_nano_seconds == 0: + 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=ts_hundred_nano_seconds / 10)) + return TombStonedRecord(base_date + datetime.timedelta(microseconds=record / 10)) class DnsRecord: @@ -284,6 +286,11 @@ class DnsRecord: """ def __init__(self, dns_records_bytes: bytes): + """Unpack dns_records_bytes. + + Raises: + EOFError: Issue while unpacking structure. + """ self.raw: bytes = dns_records_bytes self.header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) self.type = self.header.Type @@ -370,9 +377,7 @@ def as_dict(self) -> dict[str, Any]: try: data = self.data except EOFError: - log.warning( - "Error processing DNS record: failed to parse data (record type: %s)", self.type.name - ) + log.warning("Error processing DNS record: failed to parse data (record type: %s)", self.type.name) data = None try: diff --git a/tests/ese/ntds/objects/test_dns_nodename.py b/tests/ese/ntds/objects/test_dnsnode.py similarity index 100% rename from tests/ese/ntds/objects/test_dns_nodename.py rename to tests/ese/ntds/objects/test_dnsnode.py From 035306d8427a40b1038859f45c46460baff2616b Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 7 May 2026 17:33:37 +0200 Subject: [PATCH 28/32] Fix tests --- dissect/database/ese/ntds/objects/dnsnode.py | 2 +- tests/ese/ntds/objects/test_dnsnode.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 61a9b38..dddc9a2 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -172,7 +172,7 @@ class StringRecord(NamedTuple): - DNS_TYPE_LOC. """ - stringData: str + string_data: str @classmethod def from_bytes(cls, data: bytes) -> StringRecord | None: diff --git a/tests/ese/ntds/objects/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py index 4ae6bff..52a04e7 100644 --- a/tests/ese/ntds/objects/test_dnsnode.py +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -62,7 +62,7 @@ def test_parse_dns_string_record() -> None: 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" - ).stringData == ( + ).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) : €" @@ -72,19 +72,19 @@ def test_parse_dns_string_record() -> None: 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" - ).stringData == ( + ).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").stringData == ( + 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").stringData == "" + assert StringRecord.from_bytes(b"\x00").string_data == "" def test_parse_name_preference_record() -> None: From 8e2fff4da32f2025a197e228859f2bf6999f83df Mon Sep 17 00:00:00 2001 From: wbi Date: Thu, 7 May 2026 17:35:49 +0200 Subject: [PATCH 29/32] Fix tests --- tests/ese/ntds/objects/test_dnsnode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ese/ntds/objects/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py index 52a04e7..7685b5c 100644 --- a/tests/ese/ntds/objects/test_dnsnode.py +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -242,7 +242,7 @@ def test_errored_dns_node(caplog: pytest.LogCaptureFixture) -> None: _ = a_record.data with caplog.at_level(logging.WARNING): assert a_record.as_dict() == {"data": None, "timestamp": None, "ttl_seconds": 3600, "type": "A"} - assert "Issue while processing dns record : fail to parse data. Record type : A." in caplog.text + 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" @@ -259,4 +259,4 @@ def test_errored_dns_node(caplog: pytest.LogCaptureFixture) -> None: "ttl_seconds": 3600, "type": "A", } - assert "Issue while processing dns record : invalid record timestamp." in caplog.text + assert "Error processing DNS record: invalid record timestamp" in caplog.text From 78ddf6844f54ce723a0f033cc9f63eb9775579df Mon Sep 17 00:00:00 2001 From: wbi Date: Fri, 8 May 2026 11:01:07 +0200 Subject: [PATCH 30/32] Use DecoderMap --- dissect/database/ese/ntds/objects/dnsnode.py | 20 ++++++++++++++------ tests/ese/ntds/objects/test_dnsnode.py | 3 ++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index dddc9a2..f3108cf 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -3,14 +3,17 @@ import datetime import logging import socket +import typing from functools import cached_property -from typing import Any, NamedTuple +from typing import Any, ClassVar, NamedTuple from dissect.cstruct.utils import swap16, swap32 from dissect.database.ese.ntds.objects.c_dns_record 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__) @@ -404,15 +407,19 @@ class DnsNode(Top): __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.distinguished_name_as_dns_name} records=|{'|'.join(repr(d) for d in self.dns_record)}|" @property def dns_record(self) -> list[DnsRecord]: - dns_record = self.get("dnsRecord") - if dns_record is None: - return [] - return [DnsRecord(x) for x in dns_record] + """Return dns records as objects. + + Raises: + EOFError: Issue while unpacking structure. + """ + return self.get("dnsRecord") @cached_property def distinguished_name_as_dns_name(self) -> str: @@ -432,5 +439,6 @@ def distinguished_name_as_dns_name(self) -> str: def as_dict(self) -> dict[str, Any]: result = super().as_dict() result["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name - result["dns_record"] = [r.as_dict() for r in self.dns_record] + if "dnsRecord" in result: + result["dnsRecord"] = [r.as_dict() for r in result.get("dnsRecord", [])] return result diff --git a/tests/ese/ntds/objects/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py index 7685b5c..3c1a5f5 100644 --- a/tests/ese/ntds/objects/test_dnsnode.py +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -188,7 +188,8 @@ def test_dns_nodes(goad: NTDS) -> None: } _msdcs = next(node for node in dns_nodes if node.distinguished_name_as_dns_name == "_msdcs.sevenkingdoms.local") - soa_record: DnsRecord = next(record for record in _msdcs.dns_record if record.type == DNS_RECORD_TYPE.SOA) + # 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 From 10892af64aabaaa6b6a905fdb6956686a0d54f02 Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Fri, 8 May 2026 14:25:40 +0200 Subject: [PATCH 31/32] Small textual changes --- dissect/database/ese/ntds/objects/dnsnode.py | 73 ++++++++++---------- dissect/database/ese/ntds/util.py | 3 + tests/ese/ntds/objects/test_dnsnode.py | 9 +-- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index f3108cf..9537daa 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -18,7 +18,7 @@ def parse_rfc1035_dns_name(data: bytes) -> str: - """Parse DNS name as specified in rfc1035#section-3.1 format. + """Parse DNS name as specified in ``rfc1035#section-3.1`` format. References: - https://datatracker.ietf.org/doc/html/rfc1035#section-3.1 @@ -54,6 +54,8 @@ def parse_rfc1035_dns_name(data: bytes) -> str: class DnsARecord(NamedTuple): + """``A`` resource records.""" + ipv4_address: str @property @@ -77,6 +79,8 @@ def from_bytes(cls, data: bytes) -> DnsARecord: class DnsAAAARecord(NamedTuple): + """``AAAA`` resource records.""" + ipv6_address: str @property @@ -135,25 +139,25 @@ def from_bytes(cls, data: bytes) -> SOARecord | None: class NodeNameRecord(NamedTuple): - """The DNS_RPC_RECORD_NODE_NAME structure contains information about a DNS record referring to another DNS name. + """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. + - ``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``). + """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 @@ -168,18 +172,18 @@ 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. + - ``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``). + """Parse Node Name type record (e.g. ``TXT``). Test using GUI does not allow to create record with a line length > 255 char. @@ -205,9 +209,9 @@ class NamePreferenceRecord(NamedTuple): record referring to another DNS name with a preference. This corresponds to the following types: - - DNS_TYPE_MX - - DNS_TYPE_AFSDB - - DNS_TYPE_RT. + - ``DNS_TYPE_MX`` + - ``DNS_TYPE_AFSDB`` + - ``DNS_TYPE_RT`` """ name_exchange: str @@ -215,7 +219,7 @@ class NamePreferenceRecord(NamedTuple): @classmethod def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: - """Parse ``DNS_RPC_RECORD_NAME_PREFERENCE`` record (e.g ``MX``). + """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 @@ -231,7 +235,7 @@ def from_bytes(cls, data: bytes) -> NamePreferenceRecord | None: class SRVRecord(NamedTuple): - """``SRV`` ressource records.""" + """``SRV`` resource records.""" name_target: str port: int @@ -282,20 +286,15 @@ def from_bytes(cls, data: bytes) -> TombStonedRecord | None: class DnsRecord: - """The dnsRecord attribute is used to store DNS resource record definitions. + """DNS resource record definitions. References: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 """ - def __init__(self, dns_records_bytes: bytes): - """Unpack dns_records_bytes. - - Raises: - EOFError: Issue while unpacking structure. - """ - self.raw: bytes = dns_records_bytes - self.header = c_dns_record.DNS_RECORD_HEADER(dns_records_bytes) + 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) @@ -377,6 +376,7 @@ def 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: @@ -406,15 +406,14 @@ class DnsNode(Top): """ __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.distinguished_name_as_dns_name} records=|{'|'.join(repr(d) for d in self.dns_record)}|" + return f"dns_name={self.dns_name} dns_record={self.dns_record}" @property def dns_record(self) -> list[DnsRecord]: - """Return dns records as objects. + """Return DNS records as objects. Raises: EOFError: Issue while unpacking structure. @@ -422,7 +421,7 @@ def dns_record(self) -> list[DnsRecord]: return self.get("dnsRecord") @cached_property - def distinguished_name_as_dns_name(self) -> str: + def dns_name(self) -> str: """Create a DNS name from node and parent names. Examples: @@ -438,7 +437,7 @@ def distinguished_name_as_dns_name(self) -> str: def as_dict(self) -> dict[str, Any]: result = super().as_dict() - result["distinguished_name_as_dns_name"] = self.distinguished_name_as_dns_name + 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/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py index 3c1a5f5..ab1b40d 100644 --- a/tests/ese/ntds/objects/test_dnsnode.py +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -164,8 +164,7 @@ def test_dns_nodes(goad: NTDS) -> None: srv_record = next( node for node in dns_nodes - if node.distinguished_name_as_dns_name - == "_ldap._tcp.3c45e4c9-7d10-44d6-ba1f-6177134e58fd.domains._msdcs.sevenkingdoms.local" + 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" @@ -187,7 +186,7 @@ def test_dns_nodes(goad: NTDS) -> None: "type": "SRV", } - _msdcs = next(node for node in dns_nodes if node.distinguished_name_as_dns_name == "_msdcs.sevenkingdoms.local") + _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) @@ -207,9 +206,7 @@ def test_dns_nodes(goad: NTDS) -> None: assert ns_records.timestamp is None assert ns_records.ttl_seconds == 3600 - aaaa_record = next( - node for node in dns_nodes if node.distinguished_name_as_dns_name == "l.root-servers.net.RootDNSServers" - ).dns_record[0] + 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" From 18494ac5cf5f93c298a42be9bfc8e0c64a76608a Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Fri, 8 May 2026 16:35:19 +0200 Subject: [PATCH 32/32] Rename cstruct file --- .../database/ese/ntds/objects/{c_dns_record.py => c_dnsnode.py} | 0 .../ese/ntds/objects/{c_dns_record.pyi => c_dnsnode.pyi} | 0 dissect/database/ese/ntds/objects/dnsnode.py | 2 +- tests/ese/ntds/objects/test_dnsnode.py | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename dissect/database/ese/ntds/objects/{c_dns_record.py => c_dnsnode.py} (100%) rename dissect/database/ese/ntds/objects/{c_dns_record.pyi => c_dnsnode.pyi} (100%) diff --git a/dissect/database/ese/ntds/objects/c_dns_record.py b/dissect/database/ese/ntds/objects/c_dnsnode.py similarity index 100% rename from dissect/database/ese/ntds/objects/c_dns_record.py rename to dissect/database/ese/ntds/objects/c_dnsnode.py diff --git a/dissect/database/ese/ntds/objects/c_dns_record.pyi b/dissect/database/ese/ntds/objects/c_dnsnode.pyi similarity index 100% rename from dissect/database/ese/ntds/objects/c_dns_record.pyi rename to dissect/database/ese/ntds/objects/c_dnsnode.pyi diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py index 9537daa..68edb20 100644 --- a/dissect/database/ese/ntds/objects/dnsnode.py +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -9,7 +9,7 @@ from dissect.cstruct.utils import swap16, swap32 -from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE, c_dns_record +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: diff --git a/tests/ese/ntds/objects/test_dnsnode.py b/tests/ese/ntds/objects/test_dnsnode.py index ab1b40d..b689011 100644 --- a/tests/ese/ntds/objects/test_dnsnode.py +++ b/tests/ese/ntds/objects/test_dnsnode.py @@ -6,7 +6,7 @@ import pytest -from dissect.database.ese.ntds.objects.c_dns_record import DNS_RECORD_TYPE +from dissect.database.ese.ntds.objects.c_dnsnode import DNS_RECORD_TYPE from dissect.database.ese.ntds.objects.dnsnode import ( DnsAAAARecord, DnsARecord,