diff --git a/.github/workflows/dissect-ci.yml b/.github/workflows/dissect-ci.yml index b7b844a..efa3318 100644 --- a/.github/workflows/dissect-ci.yml +++ b/.github/workflows/dissect-ci.yml @@ -12,6 +12,8 @@ jobs: ci: uses: fox-it/dissect-workflow-templates/.github/workflows/dissect-ci-template.yml@main secrets: inherit + with: + run-benchmarks: true publish: if: ${{ github.ref_name == 'main' || github.ref_type == 'tag' }} diff --git a/MANIFEST.in b/MANIFEST.in index 9ae349b..23519f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ +exclude .gitattributes exclude .gitignore recursive-exclude .github/ * +recursive-exclude tests/_data/ * diff --git a/dissect/database/ese/btree.py b/dissect/database/ese/btree.py index 6840453..f4c64e7 100644 --- a/dissect/database/ese/btree.py +++ b/dissect/database/ese/btree.py @@ -116,7 +116,8 @@ def search(self, key: bytes, exact: bool = True) -> Node: """ page = self._page while True: - node = find_node(page, key) + num = find_node(page, key) + node = page.node(num) if page.is_branch: page = self.db.page(node.child) @@ -132,35 +133,45 @@ def search(self, key: bytes, exact: bool = True) -> Node: return self.node() -def find_node(page: Page, key: bytes) -> Node: +def find_node(page: Page, key: bytes) -> int: """Search a page for a node matching ``key``. + Referencing Extensible-Storage-Engine source, they bail out early if they find an exact match. + However, we prefer to always find the _first_ node that is greater than or equal to the key, + so we can handle cases where there are duplicate index keys. This is important for "range" searches + where we want to find all keys matching a certain prefix, and not end up somewhere in the middle of the range. + Args: page: The page to search. key: The key to search. + + Returns: + The node number of the first node that's greater than or equal to the key. """ - first_node_idx = 0 - last_node_idx = page.node_count - 1 + lo, hi = 0, page.node_count - 1 + res = 0 node = None - while first_node_idx < last_node_idx: - node_idx = (first_node_idx + last_node_idx) // 2 - node = page.node(node_idx) + while lo < hi: + mid = (lo + hi) // 2 + node = page.node(mid) # It turns out that the way BTree keys are compared matches 1:1 with how Python compares bytes # First compare data, then length - if key < node.key: - last_node_idx = node_idx - elif key == node.key: - if page.is_branch: - # If there's an exact match on a key on a branch page, the actual leaf nodes are in the next branch - # Page keys for branch pages appear to be non-inclusive upper bounds - node_idx = min(node_idx + 1, page.node_count - 1) - node = page.node(node_idx) + res = (key < node.key) - (key > node.key) - return node + if res < 0: + lo = mid + 1 else: - first_node_idx = node_idx + 1 + hi = mid + + # Final comparison on the last node + node = page.node(lo) + res = (key < node.key) - (key > node.key) + + if page.is_branch and res == 0: + # If there's an exact match on a key on a branch page, the actual leaf nodes are in the next branch + # Page keys for branch pages appear to be non-inclusive upper bounds + lo = min(lo + 1, page.node_count - 1) - # We're at the last node - return page.node(first_node_idx) + return lo diff --git a/dissect/database/ese/c_ese.py b/dissect/database/ese/c_ese.py index edd5857..018e603 100644 --- a/dissect/database/ese/c_ese.py +++ b/dissect/database/ese/c_ese.py @@ -231,7 +231,7 @@ BKINFOTYPE bkinfoTypeCopyPrev; // Type of Last successful Incremental backup BKINFOTYPE bkinfoTypeDiffPrev; // Type of Last successful Differential backup -// 476 bytes +// 476 bytes ULONG ulIncrementalReseedCount; // number of times incremental reseed has been initiated on this database LOGTIME logtimeIncrementalReseed; // the date of the last time that incremental reseed was initiated on this database ULONG ulIncrementalReseedCountOld; // number of times incremental reseed was initiated on this database before the last defrag @@ -240,7 +240,7 @@ LOGTIME logtimePagePatch; // the date of the last time that a page was patched as a part of incremental reseed ULONG ulPagePatchCountOld; // number of pages patched in the database as a part of incremental reseed before the last defrag -// 508 bytes +// 508 bytes QWORD qwSortVersion; // DEPRECATED: In old versions had "default" (?English?) LCID version, in new versions has 0xFFFFFFFFFFFF. // 516 bytes // checksum during recovery state @@ -399,6 +399,22 @@ Encrypted = 0x40, // fEncrypted }; +flag FIELDFLAG : uint16 { + NotNull = 0x0001, // NULL values not allowed + Version = 0x0002, // Version field + Autoincrement = 0x0004, // Autoincrement field + Multivalued = 0x0008, // Multi-valued column + Default = 0x0010, // Column has ISAM default value + EscrowUpdate = 0x0020, // Escrow updated column + Finalize = 0x0040, // Finalizable column + UserDefinedDefault = 0x0080, // The default value is generated through a callback + TemplateColumnESE98 = 0x0100, // Template table column created in ESE98 (ie. fDerived bit will be set in TAGFLD of records of derived tables) + DeleteOnZero = 0x0200, // DeleteOnZero column + PrimaryIndexPlaceholder = 0x0800, // Field is no longer in primary index, but must be retained as a placeholder + Compressed = 0x1000, // Data stored in the column should be compressed + Encrypted = 0x2000, // Data stored in the column is encrypted +}; + flag JET_bitIndex : uint32 { Unique = 0x00000001, Primary = 0x00000002, @@ -467,5 +483,6 @@ TAGFLD_HEADER = c_ese.TAGFLD_HEADER CODEPAGE = c_ese.CODEPAGE COMPRESSION_SCHEME = c_ese.COMPRESSION_SCHEME +FIELDFLAG = c_ese.FIELDFLAG IDBFLAG = c_ese.IDBFLAG IDXFLAG = c_ese.IDXFLAG diff --git a/dissect/database/ese/c_ese.pyi b/dissect/database/ese/c_ese.pyi index abc1fbe..94b0832 100644 --- a/dissect/database/ese/c_ese.pyi +++ b/dissect/database/ese/c_ese.pyi @@ -426,6 +426,21 @@ class _c_ese(__cs__.cstruct): Null = ... Encrypted = ... + class FIELDFLAG(__cs__.Flag): + NotNull = ... + Version = ... + Autoincrement = ... + Multivalued = ... + Default = ... + EscrowUpdate = ... + Finalize = ... + UserDefinedDefault = ... + TemplateColumnESE98 = ... + DeleteOnZero = ... + PrimaryIndexPlaceholder = ... + Compressed = ... + Encrypted = ... + class JET_bitIndex(__cs__.Flag): Unique = ... Primary = ... @@ -487,5 +502,6 @@ TAG_FLAG: TypeAlias = c_ese.TAG_FLAG TAGFLD_HEADER: TypeAlias = c_ese.TAGFLD_HEADER CODEPAGE: TypeAlias = c_ese.CODEPAGE COMPRESSION_SCHEME: TypeAlias = c_ese.COMPRESSION_SCHEME +FIELDFLAG: TypeAlias = c_ese.FIELDFLAG IDBFLAG: TypeAlias = c_ese.IDBFLAG IDXFLAG: TypeAlias = c_ese.IDXFLAG diff --git a/dissect/database/ese/cursor.py b/dissect/database/ese/cursor.py index 8e78bd7..529453b 100644 --- a/dissect/database/ese/cursor.py +++ b/dissect/database/ese/cursor.py @@ -3,12 +3,14 @@ from typing import TYPE_CHECKING from dissect.database.ese.btree import BTree -from dissect.database.ese.exception import NoNeighbourPageError +from dissect.database.ese.exception import KeyNotFoundError, NoNeighbourPageError from dissect.database.ese.record import Record if TYPE_CHECKING: from collections.abc import Iterator + from typing_extensions import Self + from dissect.database.ese.index import Index from dissect.database.ese.page import Node from dissect.database.ese.util import RecordValue @@ -30,13 +32,10 @@ def __init__(self, index: Index): self._secondary = None if index.is_primary else BTree(self.db, self.table.root) def __iter__(self) -> Iterator[Record]: - while True: - yield self._record() - - try: - self._primary.next() - except NoNeighbourPageError: - break + record = self.record() + while record is not None: + yield record + record = self.next() def _node(self) -> Node: """Return the node the cursor is currently on. Resolves the secondary index if needed. @@ -50,7 +49,7 @@ def _node(self) -> Node: node = self._secondary.search(node.data.tobytes(), exact=True) return node - def _record(self) -> Record: + def record(self) -> Record: """Return the record the cursor is currently on. Returns: @@ -58,25 +57,76 @@ def _record(self) -> Record: """ return Record(self.table, self._node()) - def reset(self) -> None: + def reset(self) -> Self: """Reset the internal state.""" self._primary.reset() if self._secondary: self._secondary.reset() + return self + + def next(self) -> Record | None: + """Move the cursor to the next record and return it. + + Can move the cursor to the next page as a side effect. - def search(self, **kwargs: RecordValue) -> Record: + Returns: + A :class:`~dissect.database.ese.record.Record` object of the next record. + """ + try: + self._primary.next() + except NoNeighbourPageError: + return None + return self.record() + + def prev(self) -> Record | None: + """Move the cursor to the previous node and return it. + + Can move the cursor to the previous page as a side effect. + + Returns: + A :class:`~dissect.database.ese.record.Record` object of the previous record. + """ + try: + self._primary.prev() + except NoNeighbourPageError: + return None + return self.record() + + def make_key(self, *args: RecordValue, **kwargs: RecordValue) -> bytes: + """Generate a key for this index from the given values. + + Args: + *args: The values to generate a key for. + **kwargs: The columns and values to generate a key for. + + Returns: + The generated key as bytes. + """ + if not args and not kwargs: + raise ValueError("At least one value must be provided") + + if args and kwargs: + raise ValueError("Cannot mix positional and keyword arguments in make_key") + + if args and not len(args) == 1 and not isinstance(args[0], list): + raise ValueError("When using positional arguments, provide a single list of values") + + return self.index.make_key(args[0] if args else kwargs) + + def search(self, *args: RecordValue, **kwargs: RecordValue) -> Record: """Search the index for the requested values. Searching modifies the cursor state. Searching again will search from the current position. Reset the cursor with :meth:`reset` to start from the beginning. Args: + *args: The values to search for. **kwargs: The columns and values to search for. Returns: A :class:`~dissect.database.ese.record.Record` object of the found record. """ - key = self.index.make_key(kwargs) + key = self.make_key(*args, **kwargs) return self.search_key(key, exact=True) def search_key(self, key: bytes, exact: bool = True) -> Record: @@ -88,24 +138,27 @@ def search_key(self, key: bytes, exact: bool = True) -> Record: next record that is greater than or equal to the key. """ self._primary.search(key, exact) - return self._record() + return self.record() - def seek(self, **kwargs: RecordValue) -> None: + def seek(self, *args: RecordValue, **kwargs: RecordValue) -> Self: """Seek to the record with the given values. Args: + *args: The values to seek to. **kwargs: The columns and values to seek to. """ - key = self.index.make_key(kwargs) + key = self.make_key(*args, **kwargs) self.search_key(key, exact=False) + return self - def seek_key(self, key: bytes) -> None: + def seek_key(self, key: bytes) -> Self: """Seek to the record with the given ``key``. Args: key: The key to seek to. """ self._primary.search(key, exact=False) + return self def find(self, **kwargs: RecordValue) -> Record | None: """Find a record in the index. @@ -130,7 +183,10 @@ def find_all(self, **kwargs: RecordValue) -> Iterator[Record]: other_columns = kwargs # We need at least an exact match on the indexed columns - self.search(**indexed_columns) + try: + self.search(**indexed_columns) + except KeyNotFoundError: + return current_key = self._primary.node().key @@ -150,7 +206,7 @@ def find_all(self, **kwargs: RecordValue) -> Iterator[Record]: if current_key != self._primary.node().key: break - record = self._record() + record = self.record() for k, v in other_columns.items(): value = record.get(k) # If the record value is a list, we do a check based on the queried value @@ -172,39 +228,3 @@ def find_all(self, **kwargs: RecordValue) -> Iterator[Record]: self._primary.next() except NoNeighbourPageError: break - - def record(self) -> Record: - """Return the record the cursor is currently on. - - Returns: - A :class:`~dissect.database.ese.record.Record` object of the current record. - """ - return self._record() - - def next(self) -> Record: - """Move the cursor to the next record and return it. - - Can move the cursor to the next page as a side effect. - - Returns: - A :class:`~dissect.database.ese.record.Record` object of the next record. - """ - try: - self._primary.next() - except NoNeighbourPageError: - raise IndexError("No next record") - return self._record() - - def prev(self) -> Record: - """Move the cursor to the previous node and return it. - - Can move the cursor to the previous page as a side effect. - - Returns: - A :class:`~dissect.database.ese.record.Record` object of the previous record. - """ - try: - self._primary.prev() - except NoNeighbourPageError: - raise IndexError("No previous record") - return self._record() diff --git a/dissect/database/ese/ese.py b/dissect/database/ese/ese.py index b13cc6c..88ab392 100755 --- a/dissect/database/ese/ese.py +++ b/dissect/database/ese/ese.py @@ -36,6 +36,7 @@ def __init__(self, fh: BinaryIO, impacket_compat: bool = False): self.fh = fh self.impacket_compat = impacket_compat + self.fh.seek(0) self.header = c_ese.DBFILEHDR(fh) if self.header.ulMagic != ulDAEMagic: raise InvalidDatabase("invalid file header signature") diff --git a/dissect/database/ese/index.py b/dissect/database/ese/index.py index 3523884..cd5b871 100644 --- a/dissect/database/ese/index.py +++ b/dissect/database/ese/index.py @@ -74,16 +74,17 @@ def cursor(self) -> Cursor: """Create a new cursor for this index.""" return Cursor(self) - def search(self, **kwargs) -> Record: + def search(self, *args, **kwargs) -> Record: """Search the index for the requested values. Args: + *args: The values to search for. **kwargs: The columns and values to search for. Returns: A :class:`~dissect.database.ese.record.Record` object of the found record. """ - return self.cursor().search(**kwargs) + return self.cursor().search(*args, **kwargs) def search_key(self, key: bytes) -> Node: """Search the index for a specific ``key``. @@ -105,20 +106,27 @@ def key_from_record(self, record: Record) -> bytes: values = {c.name: record[c.name] for c in self.columns} return self.make_key(values) - def make_key(self, values: dict[str, RecordValue]) -> bytes: + def make_key(self, values: list[RecordValue] | dict[str, RecordValue]) -> bytes: """Generate a key out of the given values. Args: - values: A map of the column names and values to generate a key for. + values: A map of the column names and values to generate a key for, or a list of values in the order of the + index columns. """ key_buf = [] key_remaining = self._key_most - for column in self.columns: - if column.name not in values: - break + if isinstance(values, dict): + tmp = [] + for column in self.columns: + if column.name not in values: + break + tmp.append(values[column.name]) + + values = tmp - key_part = encode_key(self, column, values[column.name], self._var_seg_mac) + for column, value in zip(self.columns, values, strict=False): + key_part = encode_key(self, column, value, self._var_seg_mac) key_buf.append(key_part) key_remaining -= len(key_part) diff --git a/dissect/database/ese/lcmapstring.py b/dissect/database/ese/lcmapstring.py index 6940787..97024c8 100644 --- a/dissect/database/ese/lcmapstring.py +++ b/dissect/database/ese/lcmapstring.py @@ -1,5 +1,6 @@ # Based on Wine source # https://github.com/wine-mirror/wine/blob/master/dlls/kernelbase/locale.c +# http://www.flounder.com/localeexplorer.htm from enum import IntEnum, IntFlag diff --git a/dissect/database/ese/ntds/__init__.py b/dissect/database/ese/ntds/__init__.py new file mode 100644 index 0000000..9d89503 --- /dev/null +++ b/dissect/database/ese/ntds/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.ntds import NTDS +from dissect.database.ese.ntds.objects import Computer, Group, Object, Server, User + +__all__ = [ + "NTDS", + "Computer", + "Group", + "Object", + "Server", + "User", +] diff --git a/dissect/database/ese/ntds/c_ds.py b/dissect/database/ese/ntds/c_ds.py new file mode 100644 index 0000000..f521b44 --- /dev/null +++ b/dissect/database/ese/ntds/c_ds.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dissect.cstruct import cstruct + +ds_def = """ +typedef struct _USER_PROPERTY { + WORD NameLength; + WORD ValueLength; + WORD PropertyFlag; + WCHAR PropertyName[NameLength / 2]; + CHAR PropertyValue[ValueLength]; +} USER_PROPERTY; + +typedef struct _USER_PROPERTIES_HEADER { + DWORD Reserved1; + DWORD Length; + WORD Reserved2; + WORD Reserved3; + CHAR Reserved4[96]; + WORD PropertySignature; + WORD PropertyCount; +} USER_PROPERTIES_HEADER; + +typedef struct _ADAM_PROPERTIES_HEADER { // For lack of a better name + DWORD Reserved1; + DWORD Reserved2; + DWORD Reserved3; + DWORD Reserved4; + DWORD Reserved5; + DWORD Reserved6; +} ADAM_PROPERTIES_HEADER; + +typedef struct _KERB_KEY_DATA { + WORD Reserved1; + WORD Reserved2; + DWORD Reserved3; + DWORD KeyType; + DWORD KeyLength; + DWORD KeyOffset; +} KERB_KEY_DATA; + +typedef struct _KERB_STORED_CREDENTIAL { + WORD Revision; + WORD Flags; + WORD CredentialCount; + WORD OldCredentialCount; + WORD DefaultSaltLength; + WORD DefaultSaltMaximumLength; + DWORD DefaultSaltOffset; + KERB_KEY_DATA Credentials[CredentialCount]; + KERB_KEY_DATA OldCredentials[OldCredentialCount]; + // CHAR DefaultSalt[DefaultSaltLength]; + // CHAR KeyValues[...]; +} KERB_STORED_CREDENTIAL; + +typedef struct _KERB_KEY_DATA_NEW { + WORD Reserved1; + WORD Reserved2; + DWORD Reserved3; + DWORD IterationCount; + DWORD KeyType; + DWORD KeyLength; + DWORD KeyOffset; +} KERB_KEY_DATA_NEW; + +typedef struct _KERB_STORED_CREDENTIAL_NEW { + WORD Revision; + WORD Flags; + WORD CredentialCount; + WORD ServiceCredentialCount; + WORD OldCredentialCount; + WORD OlderCredentialCount; + WORD DefaultSaltLength; + WORD DefaultSaltMaximumLength; + DWORD DefaultSaltOffset; + DWORD DefaultIterationCount; + KERB_KEY_DATA_NEW Credentials[CredentialCount]; + KERB_KEY_DATA_NEW ServiceCredentials[ServiceCredentialCount]; + KERB_KEY_DATA_NEW OldCredentials[OldCredentialCount]; + KERB_KEY_DATA_NEW OlderCredentials[OlderCredentialCount]; + // CHAR DefaultSalt[DefaultSaltLength]; + // CHAR KeyValues[...]; +} KERB_STORED_CREDENTIAL_NEW; + +typedef struct _WDIGEST_CREDENTIALS { + BYTE Reserved1; + BYTE Reserved2; + BYTE Version; + BYTE NumberOfHashes; + CHAR Reserved3[12]; + CHAR Hash[29][16]; // The formal definition has Hash1, Hash2, ..., Hash29 +} WDIGEST_CREDENTIALS; +""" +c_ds = cstruct(ds_def) diff --git a/dissect/database/ese/ntds/c_ds.pyi b/dissect/database/ese/ntds/c_ds.pyi new file mode 100644 index 0000000..696aec0 --- /dev/null +++ b/dissect/database/ese/ntds/c_ds.pyi @@ -0,0 +1,329 @@ +# Generated by cstruct-stubgen +from typing import BinaryIO, Literal, TypeAlias, overload + +import dissect.cstruct as __cs__ + +class _c_ds(__cs__.cstruct): + class _USER_PROPERTY(__cs__.Structure): + NameLength: _c_ds.uint16 + ValueLength: _c_ds.uint16 + PropertyFlag: _c_ds.uint16 + PropertyName: __cs__.WcharArray + PropertyValue: __cs__.CharArray + @overload + def __init__( + self, + NameLength: _c_ds.uint16 | None = ..., + ValueLength: _c_ds.uint16 | None = ..., + PropertyFlag: _c_ds.uint16 | None = ..., + PropertyName: __cs__.WcharArray | None = ..., + PropertyValue: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + USER_PROPERTY: TypeAlias = _USER_PROPERTY + class _USER_PROPERTIES_HEADER(__cs__.Structure): + Reserved1: _c_ds.uint32 + Length: _c_ds.uint32 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint16 + Reserved4: __cs__.CharArray + PropertySignature: _c_ds.uint16 + PropertyCount: _c_ds.uint16 + @overload + def __init__( + self, + Reserved1: _c_ds.uint32 | None = ..., + Length: _c_ds.uint32 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint16 | None = ..., + Reserved4: __cs__.CharArray | None = ..., + PropertySignature: _c_ds.uint16 | None = ..., + PropertyCount: _c_ds.uint16 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + USER_PROPERTIES_HEADER: TypeAlias = _USER_PROPERTIES_HEADER + class _ADAM_PROPERTIES_HEADER(__cs__.Structure): + Reserved1: _c_ds.uint32 + Reserved2: _c_ds.uint32 + Reserved3: _c_ds.uint32 + Reserved4: _c_ds.uint32 + Reserved5: _c_ds.uint32 + Reserved6: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint32 | None = ..., + Reserved2: _c_ds.uint32 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + Reserved4: _c_ds.uint32 | None = ..., + Reserved5: _c_ds.uint32 | None = ..., + Reserved6: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ADAM_PROPERTIES_HEADER: TypeAlias = _ADAM_PROPERTIES_HEADER + class _KERB_KEY_DATA(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + KERB_KEY_DATA: TypeAlias = _KERB_KEY_DATA + class _KERB_STORED_CREDENTIAL(__cs__.Structure): + Revision: _c_ds.uint16 + Flags: _c_ds.uint16 + CredentialCount: _c_ds.uint16 + OldCredentialCount: _c_ds.uint16 + DefaultSaltLength: _c_ds.uint16 + DefaultSaltMaximumLength: _c_ds.uint16 + DefaultSaltOffset: _c_ds.uint32 + class _KERB_KEY_DATA(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + Credentials: __cs__.Array[_KERB_KEY_DATA] + class _KERB_KEY_DATA(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + OldCredentials: __cs__.Array[_KERB_KEY_DATA] + @overload + def __init__( + self, + Revision: _c_ds.uint16 | None = ..., + Flags: _c_ds.uint16 | None = ..., + CredentialCount: _c_ds.uint16 | None = ..., + OldCredentialCount: _c_ds.uint16 | None = ..., + DefaultSaltLength: _c_ds.uint16 | None = ..., + DefaultSaltMaximumLength: _c_ds.uint16 | None = ..., + DefaultSaltOffset: _c_ds.uint32 | None = ..., + Credentials: __cs__.Array[_KERB_KEY_DATA] | None = ..., + OldCredentials: __cs__.Array[_KERB_KEY_DATA] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + KERB_STORED_CREDENTIAL: TypeAlias = _KERB_STORED_CREDENTIAL + class _KERB_KEY_DATA_NEW(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + IterationCount: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + IterationCount: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + KERB_KEY_DATA_NEW: TypeAlias = _KERB_KEY_DATA_NEW + class _KERB_STORED_CREDENTIAL_NEW(__cs__.Structure): + Revision: _c_ds.uint16 + Flags: _c_ds.uint16 + CredentialCount: _c_ds.uint16 + ServiceCredentialCount: _c_ds.uint16 + OldCredentialCount: _c_ds.uint16 + OlderCredentialCount: _c_ds.uint16 + DefaultSaltLength: _c_ds.uint16 + DefaultSaltMaximumLength: _c_ds.uint16 + DefaultSaltOffset: _c_ds.uint32 + DefaultIterationCount: _c_ds.uint32 + class _KERB_KEY_DATA_NEW(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + IterationCount: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + IterationCount: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + Credentials: __cs__.Array[_KERB_KEY_DATA_NEW] + class _KERB_KEY_DATA_NEW(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + IterationCount: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + IterationCount: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ServiceCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] + class _KERB_KEY_DATA_NEW(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + IterationCount: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + IterationCount: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + OldCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] + class _KERB_KEY_DATA_NEW(__cs__.Structure): + Reserved1: _c_ds.uint16 + Reserved2: _c_ds.uint16 + Reserved3: _c_ds.uint32 + IterationCount: _c_ds.uint32 + KeyType: _c_ds.uint32 + KeyLength: _c_ds.uint32 + KeyOffset: _c_ds.uint32 + @overload + def __init__( + self, + Reserved1: _c_ds.uint16 | None = ..., + Reserved2: _c_ds.uint16 | None = ..., + Reserved3: _c_ds.uint32 | None = ..., + IterationCount: _c_ds.uint32 | None = ..., + KeyType: _c_ds.uint32 | None = ..., + KeyLength: _c_ds.uint32 | None = ..., + KeyOffset: _c_ds.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + OlderCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] + @overload + def __init__( + self, + Revision: _c_ds.uint16 | None = ..., + Flags: _c_ds.uint16 | None = ..., + CredentialCount: _c_ds.uint16 | None = ..., + ServiceCredentialCount: _c_ds.uint16 | None = ..., + OldCredentialCount: _c_ds.uint16 | None = ..., + OlderCredentialCount: _c_ds.uint16 | None = ..., + DefaultSaltLength: _c_ds.uint16 | None = ..., + DefaultSaltMaximumLength: _c_ds.uint16 | None = ..., + DefaultSaltOffset: _c_ds.uint32 | None = ..., + DefaultIterationCount: _c_ds.uint32 | None = ..., + Credentials: __cs__.Array[_KERB_KEY_DATA_NEW] | None = ..., + ServiceCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] | None = ..., + OldCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] | None = ..., + OlderCredentials: __cs__.Array[_KERB_KEY_DATA_NEW] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + KERB_STORED_CREDENTIAL_NEW: TypeAlias = _KERB_STORED_CREDENTIAL_NEW + class _WDIGEST_CREDENTIALS(__cs__.Structure): + Reserved1: _c_ds.uint8 + Reserved2: _c_ds.uint8 + Version: _c_ds.uint8 + NumberOfHashes: _c_ds.uint8 + Reserved3: __cs__.CharArray + Hash: __cs__.Array[__cs__.CharArray] + @overload + def __init__( + self, + Reserved1: _c_ds.uint8 | None = ..., + Reserved2: _c_ds.uint8 | None = ..., + Version: _c_ds.uint8 | None = ..., + NumberOfHashes: _c_ds.uint8 | None = ..., + Reserved3: __cs__.CharArray | None = ..., + Hash: __cs__.Array[__cs__.CharArray] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + WDIGEST_CREDENTIALS: TypeAlias = _WDIGEST_CREDENTIALS + +# Technically `c_ds` is an instance of `_c_ds`, but then we can't use it in type hints +c_ds: TypeAlias = _c_ds diff --git a/dissect/database/ese/ntds/c_pek.py b/dissect/database/ese/ntds/c_pek.py new file mode 100644 index 0000000..c7c613e --- /dev/null +++ b/dissect/database/ese/ntds/c_pek.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dissect.cstruct import cstruct + +pek_def = """ +typedef QWORD FILETIME; + +#define PEK_PRE_2012R2_VERSION 2 +#define PEK_2016_TP4_VERSION 3 + +#define PEK_ENCRYPTION 0x10 +#define PEK_ENCRYPTION_WITH_SALT 0x11 +#define PEK_ENCRYPTION_WITH_AES 0x13 + +typedef struct _ENCRYPTED_PEK_LIST { + ULONG Version; + ULONG BootOption; + CHAR Salt[16]; + CHAR EncryptedData[EOF]; +} ENCRYPTED_PEK_LIST; + +typedef struct _PEK { + ULONG KeyId; + CHAR Key[16]; +} PEK; + +typedef struct _CLEAR_PEK_LIST { + CHAR Authenticator[16]; + FILETIME LastKeyGenerationTime; + ULONG CurrentKey; + ULONG CountOfKeys; + PEK PekArray[CountOfKeys]; +} CLEAR_PEK_LIST; + +typedef struct _ENCRYPTED_DATA { + USHORT AlgorithmId; + USHORT Flags; + ULONG KeyId; + CHAR EncryptedData[EOF]; +} ENCRYPTED_DATA; + +typedef struct _ENCRYPTED_DATA_WITH_SALT { + USHORT AlgorithmId; + USHORT Flags; + ULONG KeyId; + CHAR Salt[16]; + CHAR EncryptedData[EOF]; +} ENCRYPTED_DATA_WITH_SALT; + +typedef struct _ENCRYPTED_DATA_WITH_AES { + USHORT AlgorithmId; + USHORT Flags; + ULONG KeyId; + CHAR IV[16]; + ULONG Length; + CHAR EncryptedData[EOF]; +} ENCRYPTED_DATA_WITH_AES; +""" +c_pek = cstruct(pek_def) diff --git a/dissect/database/ese/ntds/c_pek.pyi b/dissect/database/ese/ntds/c_pek.pyi new file mode 100644 index 0000000..4f79a69 --- /dev/null +++ b/dissect/database/ese/ntds/c_pek.pyi @@ -0,0 +1,125 @@ +# Generated by cstruct-stubgen +from typing import BinaryIO, Literal, TypeAlias, overload + +import dissect.cstruct as __cs__ + +class _c_pek(__cs__.cstruct): + PEK_PRE_2012R2_VERSION: Literal[2] = ... + PEK_2016_TP4_VERSION: Literal[3] = ... + PEK_ENCRYPTION: Literal[16] = ... + PEK_ENCRYPTION_WITH_SALT: Literal[17] = ... + PEK_ENCRYPTION_WITH_AES: Literal[19] = ... + FILETIME: TypeAlias = _c_pek.uint64 + class _ENCRYPTED_PEK_LIST(__cs__.Structure): + Version: _c_pek.uint32 + BootOption: _c_pek.uint32 + Salt: __cs__.CharArray + EncryptedData: __cs__.CharArray + @overload + def __init__( + self, + Version: _c_pek.uint32 | None = ..., + BootOption: _c_pek.uint32 | None = ..., + Salt: __cs__.CharArray | None = ..., + EncryptedData: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ENCRYPTED_PEK_LIST: TypeAlias = _ENCRYPTED_PEK_LIST + class _PEK(__cs__.Structure): + KeyId: _c_pek.uint32 + Key: __cs__.CharArray + @overload + def __init__(self, KeyId: _c_pek.uint32 | None = ..., Key: __cs__.CharArray | None = ...): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + PEK: TypeAlias = _PEK + class _CLEAR_PEK_LIST(__cs__.Structure): + Authenticator: __cs__.CharArray + LastKeyGenerationTime: _c_pek.uint64 + CurrentKey: _c_pek.uint32 + CountOfKeys: _c_pek.uint32 + class _PEK(__cs__.Structure): + KeyId: _c_pek.uint32 + Key: __cs__.CharArray + @overload + def __init__(self, KeyId: _c_pek.uint32 | None = ..., Key: __cs__.CharArray | None = ...): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + PekArray: __cs__.Array[_PEK] + @overload + def __init__( + self, + Authenticator: __cs__.CharArray | None = ..., + LastKeyGenerationTime: _c_pek.uint64 | None = ..., + CurrentKey: _c_pek.uint32 | None = ..., + CountOfKeys: _c_pek.uint32 | None = ..., + PekArray: __cs__.Array[_PEK] | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + CLEAR_PEK_LIST: TypeAlias = _CLEAR_PEK_LIST + class _ENCRYPTED_DATA(__cs__.Structure): + AlgorithmId: _c_pek.uint16 + Flags: _c_pek.uint16 + KeyId: _c_pek.uint32 + EncryptedData: __cs__.CharArray + @overload + def __init__( + self, + AlgorithmId: _c_pek.uint16 | None = ..., + Flags: _c_pek.uint16 | None = ..., + KeyId: _c_pek.uint32 | None = ..., + EncryptedData: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ENCRYPTED_DATA: TypeAlias = _ENCRYPTED_DATA + class _ENCRYPTED_DATA_WITH_SALT(__cs__.Structure): + AlgorithmId: _c_pek.uint16 + Flags: _c_pek.uint16 + KeyId: _c_pek.uint32 + Salt: __cs__.CharArray + EncryptedData: __cs__.CharArray + @overload + def __init__( + self, + AlgorithmId: _c_pek.uint16 | None = ..., + Flags: _c_pek.uint16 | None = ..., + KeyId: _c_pek.uint32 | None = ..., + Salt: __cs__.CharArray | None = ..., + EncryptedData: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ENCRYPTED_DATA_WITH_SALT: TypeAlias = _ENCRYPTED_DATA_WITH_SALT + class _ENCRYPTED_DATA_WITH_AES(__cs__.Structure): + AlgorithmId: _c_pek.uint16 + Flags: _c_pek.uint16 + KeyId: _c_pek.uint32 + IV: __cs__.CharArray + Length: _c_pek.uint32 + EncryptedData: __cs__.CharArray + @overload + def __init__( + self, + AlgorithmId: _c_pek.uint16 | None = ..., + Flags: _c_pek.uint16 | None = ..., + KeyId: _c_pek.uint32 | None = ..., + IV: __cs__.CharArray | None = ..., + Length: _c_pek.uint32 | None = ..., + EncryptedData: __cs__.CharArray | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ENCRYPTED_DATA_WITH_AES: TypeAlias = _ENCRYPTED_DATA_WITH_AES + +# Technically `c_pek` is an instance of `_c_pek`, but then we can't use it in type hints +c_pek: TypeAlias = _c_pek diff --git a/dissect/database/ese/ntds/c_sd.py b/dissect/database/ese/ntds/c_sd.py new file mode 100644 index 0000000..3d2337b --- /dev/null +++ b/dissect/database/ese/ntds/c_sd.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from dissect.cstruct import cstruct + +# Largely copied from dissect.ntfs +sd_def = """ +flag SECURITY_DESCRIPTOR_CONTROL : WORD { + SE_OWNER_DEFAULTED = 0x0001, + SE_GROUP_DEFAULTED = 0x0002, + SE_DACL_PRESENT = 0x0004, + SE_DACL_DEFAULTED = 0x0008, + SE_SACL_PRESENT = 0x0010, + SE_SACL_DEFAULTED = 0x0020, + SE_DACL_AUTO_INHERIT_REQ = 0x0100, + SE_SACL_AUTO_INHERIT_REQ = 0x0200, + SE_DACL_AUTO_INHERITED = 0x0400, + SE_SACL_AUTO_INHERITED = 0x0800, + SE_DACL_PROTECTED = 0x1000, + SE_SACL_PROTECTED = 0x2000, + SE_RM_CONTROL_VALID = 0x4000, + SE_SELF_RELATIVE = 0x8000, +}; + +flag ACCESS_MASK : DWORD { + ADS_RIGHT_DS_CREATE_CHILD = 0x00000001, + ADS_RIGHT_DS_DELETE_CHILD = 0x00000002, + ADS_RIGHT_DS_LIST_CONTENTS = 0x00000004, // Undocumented? + ADS_RIGHT_DS_SELF = 0x00000008, + ADS_RIGHT_DS_READ_PROP = 0x00000010, + ADS_RIGHT_DS_WRITE_PROP = 0x00000020, + ADS_RIGHT_DS_CONTROL_ACCESS = 0x00000100, + + DELETE = 0x00010000, + READ_CONTROL = 0x00020000, + WRITE_DACL = 0x00040000, + WRITE_OWNER = 0x00080000, + SYNCHRONIZE = 0x00100000, + ACCESS_SYSTEM_SECURITY = 0x01000000, + MAXIMUM_ALLOWED = 0x02000000, + GENERIC_ALL = 0x10000000, + GENERIC_EXECUTE = 0x20000000, + GENERIC_WRITE = 0x40000000, + GENERIC_READ = 0x80000000, +}; + +enum ACE_TYPE : BYTE { + ACCESS_ALLOWED = 0x00, + ACCESS_DENIED = 0x01, + SYSTEM_AUDIT = 0x02, + SYSTEM_ALARM = 0x03, + ACCESS_ALLOWED_COMPOUND = 0x04, + ACCESS_ALLOWED_OBJECT = 0x05, + ACCESS_DENIED_OBJECT = 0x06, + SYSTEM_AUDIT_OBJECT = 0x07, + SYSTEM_ALARM_OBJECT = 0x08, + ACCESS_ALLOWED_CALLBACK = 0x09, + ACCESS_DENIED_CALLBACK = 0x0A, + ACCESS_ALLOWED_CALLBACK_OBJECT = 0x0B, + ACCESS_DENIED_CALLBACK_OBJECT = 0x0C, + SYSTEM_AUDIT_CALLBACK = 0x0D, + SYSTEM_ALARM_CALLBACK = 0x0E, + SYSTEM_AUDIT_CALLBACK_OBJECT = 0x0F, + SYSTEM_ALARM_CALLBACK_OBJECT = 0x10, + SYSTEM_MANDATORY_LABEL = 0x11, + SYSTEM_RESOURCE_ATTRIBUTE = 0x12, + SYSTEM_SCOPED_POLICY_ID = 0x13, + SYSTEM_PROCESS_TRUST_LABEL = 0x14, + SYSTEM_ACCESS_FILTER = 0x15, +}; + +flag ACE_FLAGS : BYTE { + OBJECT_INHERIT_ACE = 0x01, + CONTAINER_INHERIT_ACE = 0x02, + NO_PROPAGATE_INHERIT_ACE = 0x04, + INHERIT_ONLY_ACE = 0x08, + INHERITED_ACE = 0x10, + SUCCESSFUL_ACCESS_ACE_FLAG = 0x40, + FAILED_ACCESS_ACE_FLAG = 0x80, +}; + +flag ACE_OBJECT_FLAGS : DWORD { + ACE_OBJECT_TYPE_PRESENT = 0x01, + ACE_INHERITED_OBJECT_TYPE_PRESENT = 0x02, +}; + +enum COMPOUND_ACE_TYPE : USHORT { + COMPOUND_ACE_IMPERSONATION = 0x01, +}; + +typedef struct _ACL { + BYTE AclRevision; + BYTE Sbz1; + WORD AclSize; + WORD AceCount; + WORD Sbz2; +} ACL; + +typedef struct _ACE_HEADER { + ACE_TYPE AceType; + ACE_FLAGS AceFlags; + WORD AceSize; +} ACE_HEADER; + +typedef struct _SECURITY_DESCRIPTOR_HEADER { + ULONG HashId; + ULONG SecurityId; + ULONG64 Offset; + ULONG Length; +} SECURITY_DESCRIPTOR_HEADER; + +typedef struct _SECURITY_DESCRIPTOR_RELATIVE { + BYTE Revision; + BYTE Sbz1; + SECURITY_DESCRIPTOR_CONTROL Control; + ULONG Owner; + ULONG Group; + ULONG Sacl; + ULONG Dacl; +} SECURITY_DESCRIPTOR_RELATIVE; +""" +c_sd = cstruct(sd_def) diff --git a/dissect/database/ese/ntds/c_sd.pyi b/dissect/database/ese/ntds/c_sd.pyi new file mode 100644 index 0000000..220e1d6 --- /dev/null +++ b/dissect/database/ese/ntds/c_sd.pyi @@ -0,0 +1,158 @@ +# Generated by cstruct-stubgen +from typing import BinaryIO, TypeAlias, overload + +import dissect.cstruct as __cs__ + +class _c_sd(__cs__.cstruct): + class SECURITY_DESCRIPTOR_CONTROL(__cs__.Flag): + SE_OWNER_DEFAULTED = ... + SE_GROUP_DEFAULTED = ... + SE_DACL_PRESENT = ... + SE_DACL_DEFAULTED = ... + SE_SACL_PRESENT = ... + SE_SACL_DEFAULTED = ... + SE_DACL_AUTO_INHERIT_REQ = ... + SE_SACL_AUTO_INHERIT_REQ = ... + SE_DACL_AUTO_INHERITED = ... + SE_SACL_AUTO_INHERITED = ... + SE_DACL_PROTECTED = ... + SE_SACL_PROTECTED = ... + SE_RM_CONTROL_VALID = ... + SE_SELF_RELATIVE = ... + + class ACCESS_MASK(__cs__.Flag): + ADS_RIGHT_DS_CREATE_CHILD = ... + ADS_RIGHT_DS_DELETE_CHILD = ... + ADS_RIGHT_DS_SELF = ... + ADS_RIGHT_DS_READ_PROP = ... + ADS_RIGHT_DS_WRITE_PROP = ... + ADS_RIGHT_DS_CONTROL_ACCESS = ... + DELETE = ... + READ_CONTROL = ... + WRITE_DACL = ... + WRITE_OWNER = ... + SYNCHRONIZE = ... + ACCESS_SYSTEM_SECURITY = ... + MAXIMUM_ALLOWED = ... + GENERIC_ALL = ... + GENERIC_EXECUTE = ... + GENERIC_WRITE = ... + GENERIC_READ = ... + + class ACE_TYPE(__cs__.Enum): + ACCESS_ALLOWED = ... + ACCESS_DENIED = ... + SYSTEM_AUDIT = ... + SYSTEM_ALARM = ... + ACCESS_ALLOWED_COMPOUND = ... + ACCESS_ALLOWED_OBJECT = ... + ACCESS_DENIED_OBJECT = ... + SYSTEM_AUDIT_OBJECT = ... + SYSTEM_ALARM_OBJECT = ... + ACCESS_ALLOWED_CALLBACK = ... + ACCESS_DENIED_CALLBACK = ... + ACCESS_ALLOWED_CALLBACK_OBJECT = ... + ACCESS_DENIED_CALLBACK_OBJECT = ... + SYSTEM_AUDIT_CALLBACK = ... + SYSTEM_ALARM_CALLBACK = ... + SYSTEM_AUDIT_CALLBACK_OBJECT = ... + SYSTEM_ALARM_CALLBACK_OBJECT = ... + SYSTEM_MANDATORY_LABEL = ... + SYSTEM_RESOURCE_ATTRIBUTE = ... + SYSTEM_SCOPED_POLICY_ID = ... + SYSTEM_PROCESS_TRUST_LABEL = ... + SYSTEM_ACCESS_FILTER = ... + + class ACE_FLAGS(__cs__.Flag): + OBJECT_INHERIT_ACE = ... + CONTAINER_INHERIT_ACE = ... + NO_PROPAGATE_INHERIT_ACE = ... + INHERIT_ONLY_ACE = ... + INHERITED_ACE = ... + SUCCESSFUL_ACCESS_ACE_FLAG = ... + FAILED_ACCESS_ACE_FLAG = ... + + class ACE_OBJECT_FLAGS(__cs__.Flag): + ACE_OBJECT_TYPE_PRESENT = ... + ACE_INHERITED_OBJECT_TYPE_PRESENT = ... + + class COMPOUND_ACE_TYPE(__cs__.Enum): + COMPOUND_ACE_IMPERSONATION = ... + + class _ACL(__cs__.Structure): + AclRevision: _c_sd.uint8 + Sbz1: _c_sd.uint8 + AclSize: _c_sd.uint16 + AceCount: _c_sd.uint16 + Sbz2: _c_sd.uint16 + @overload + def __init__( + self, + AclRevision: _c_sd.uint8 | None = ..., + Sbz1: _c_sd.uint8 | None = ..., + AclSize: _c_sd.uint16 | None = ..., + AceCount: _c_sd.uint16 | None = ..., + Sbz2: _c_sd.uint16 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ACL: TypeAlias = _ACL + class _ACE_HEADER(__cs__.Structure): + AceType: _c_sd.ACE_TYPE + AceFlags: _c_sd.ACE_FLAGS + AceSize: _c_sd.uint16 + @overload + def __init__( + self, + AceType: _c_sd.ACE_TYPE | None = ..., + AceFlags: _c_sd.ACE_FLAGS | None = ..., + AceSize: _c_sd.uint16 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + ACE_HEADER: TypeAlias = _ACE_HEADER + class _SECURITY_DESCRIPTOR_HEADER(__cs__.Structure): + HashId: _c_sd.uint32 + SecurityId: _c_sd.uint32 + Offset: _c_sd.uint64 + Length: _c_sd.uint32 + @overload + def __init__( + self, + HashId: _c_sd.uint32 | None = ..., + SecurityId: _c_sd.uint32 | None = ..., + Offset: _c_sd.uint64 | None = ..., + Length: _c_sd.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + SECURITY_DESCRIPTOR_HEADER: TypeAlias = _SECURITY_DESCRIPTOR_HEADER + class _SECURITY_DESCRIPTOR_RELATIVE(__cs__.Structure): + Revision: _c_sd.uint8 + Sbz1: _c_sd.uint8 + Control: _c_sd.SECURITY_DESCRIPTOR_CONTROL + Owner: _c_sd.uint32 + Group: _c_sd.uint32 + Sacl: _c_sd.uint32 + Dacl: _c_sd.uint32 + @overload + def __init__( + self, + Revision: _c_sd.uint8 | None = ..., + Sbz1: _c_sd.uint8 | None = ..., + Control: _c_sd.SECURITY_DESCRIPTOR_CONTROL | None = ..., + Owner: _c_sd.uint32 | None = ..., + Group: _c_sd.uint32 | None = ..., + Sacl: _c_sd.uint32 | None = ..., + Dacl: _c_sd.uint32 | None = ..., + ): ... + @overload + def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ... + + SECURITY_DESCRIPTOR_RELATIVE: TypeAlias = _SECURITY_DESCRIPTOR_RELATIVE + +# Technically `c_sd` is an instance of `_c_sd`, but then we can't use it in type hints +c_sd: TypeAlias = _c_sd diff --git a/dissect/database/ese/ntds/database.py b/dissect/database/ese/ntds/database.py new file mode 100644 index 0000000..fef9124 --- /dev/null +++ b/dissect/database/ese/ntds/database.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from functools import cached_property, lru_cache +from io import BytesIO +from typing import TYPE_CHECKING, BinaryIO + +from dissect.database.ese.ese import ESE +from dissect.database.ese.exception import KeyNotFoundError +from dissect.database.ese.ntds.objects import DomainDNS, Object +from dissect.database.ese.ntds.pek import PEK +from dissect.database.ese.ntds.query import Query +from dissect.database.ese.ntds.schema import Schema +from dissect.database.ese.ntds.sd import SecurityDescriptor +from dissect.database.ese.ntds.util import DN, SearchFlags, encode_value + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.index import Index + from dissect.database.ese.ntds.objects import Top + + +class Database: + """Interact with an NTDS.dit Active Directory Domain Services (AD DS) database. + + The main purpose of this class is to group interaction with the various tables and + remove some clutter from the NTDS class. + """ + + def __init__(self, fh: BinaryIO): + self.ese = ESE(fh) + + self.data = DataTable(self) + self.link = LinkTable(self) + self.sd = SecurityDescriptorTable(self) + + self.data.schema.load(self) + + +class DataTable: + """Represents the ``datatable`` in the NTDS database.""" + + def __init__(self, db: Database): + self.db = db + self.table = self.db.ese.table("datatable") + + self.schema = Schema() + + # Cache frequently used and "expensive" methods + self.get = lru_cache(4096)(self.get) + self._make_dn = lru_cache(4096)(self._make_dn) + + def root(self) -> Top: + """Return the top-level object in the NTDS database.""" + if (root := next(self.children_of(0), None)) is None: + raise ValueError("No root object found") + return root + + def root_domain(self) -> DomainDNS | None: + """Return the root domain object in the NTDS database. For AD LDS, this will return ``None``.""" + stack = [self.root()] + while stack: + if (obj := stack.pop()).is_deleted: + continue + + if isinstance(obj, DomainDNS) and obj.is_head_of_naming_context: + return obj + + stack.extend(obj.children()) + + return None + + @cached_property + def pek(self) -> PEK | None: + """Return the PEK.""" + if (root_domain := self.root_domain()) is None: + # Maybe this is an AD LDS database + if (root_pek := self.root().get("pekList")) is None: + # It's not + return None + + # Lookup the schema pek and permutate the boot key + # https://www.synacktiv.com/publications/using-ntdissector-to-extract-secrets-from-adam-ntds-files + schema_pek = self.lookup(objectClass="dMD").get("pekList") + boot_key = bytes( + [root_pek[i] for i in [2, 4, 25, 9, 7, 27, 5, 11]] + + [schema_pek[i] for i in [37, 2, 17, 36, 20, 11, 22, 7]] + ) + + # Lookup the actual PEK and unlock it + pek = PEK(self.lookup(objectClass="configuration").get("pekList")) + pek.unlock(boot_key) + return pek + return root_domain.pek + + def walk(self) -> Iterator[Object]: + """Walk through all objects in the NTDS database.""" + stack = [self.root()] + while stack: + yield (obj := stack.pop()) + stack.extend(obj.children()) + + def iter(self) -> Iterator[Object]: + """Iterate over all objects in the NTDS database.""" + for record in self.table.records(): + yield Object.from_record(self.db, record) + + def get(self, dnt: int) -> Object: + """Retrieve an object by its Directory Number Tag (DNT) value. + + Args: + dnt: The DNT of the object to retrieve. + """ + record = self.table.index("DNT_index").search([dnt]) + return Object.from_record(self.db, record) + + def lookup(self, **kwargs) -> Object: + """Retrieve an object by a single indexed attribute. + + Args: + **kwargs: Single keyword argument specifying the attribute and value. + """ + if len(kwargs) != 1: + raise ValueError("Exactly one keyword argument must be provided") + + ((key, value),) = kwargs.items() + # TODO: Check if the attribute is indexed, use (and expand) _get_index + if (schema := self.schema.lookup_attribute(name=key)) is None: + raise ValueError(f"Attribute {key!r} is not found in the schema") + + index = self.table.find_index(schema.column) + record = index.search([encode_value(self.db, key, value)]) + return Object.from_record(self.db, record) + + def query(self, query: str, *, optimize: bool = True) -> Iterator[Object]: + """Execute an LDAP query against the NTDS database. + + Args: + query: The LDAP query string to execute. + optimize: Whether to optimize the query, default is ``True``. + + Yields: + Object instances matching the query. Objects are cast to more specific types when possible. + """ + for record in Query(self.db, query, optimize=optimize).process(): + yield Object.from_record(self.db, record) + + def search(self, **kwargs: str) -> Iterator[Object]: + """Perform an attribute-value query. If multiple attributes are provided, it will be treated as an "AND" query. + + Args: + **kwargs: Keyword arguments specifying the attributes and values. + + Yields: + Object instances matching the attribute-value pair. + """ + query = "".join([f"({attr}={value})" for attr, value in kwargs.items()]) + yield from self.query(f"(&{query})") + + def child_of(self, dnt: int, name: str) -> Object | None: + """Get a specific child object by name for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve the child object for. + name: The name of the child object to retrieve. + """ + cursor = self.db.data.table.index("PDNT_index").cursor() + return Object.from_record(self.db, cursor.search([dnt, name])) + + def children_of(self, dnt: int) -> Iterator[Object]: + """Get all child objects of a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve child objects for. + """ + cursor = self.db.data.table.index("PDNT_index").cursor() + end = cursor.seek([dnt + 1]).record() + + cursor.reset() + cursor.seek([dnt]) + + record = cursor.record() + while record is not None and record != end: + yield Object.from_record(self.db, record) + record = cursor.next() + + def _make_dn(self, dnt: int) -> DN: + """Construct Distinguished Name (DN) from a Directory Number Tag (DNT) value. + + This method walks up the parent hierarchy to build the full DN path. + + Args: + dnt: The DNT to construct the DN for. + """ + obj = self.get(dnt) + + if obj.dnt in (0, 2): # Root object + return "" + + rdn_key = obj.get("RdnType") + rdn_value = obj.get("name").replace(",", "\\,") + if not rdn_key or not rdn_value: + return "" + + parent_dn = self._make_dn(obj.pdnt) + dn = f"{rdn_key}={rdn_value}".upper() + (f",{parent_dn}" if parent_dn else "") + + return DN(dn, obj, parent_dn if parent_dn else None) + + def _get_index(self, attribute: str) -> Index: + """Get the index for a given attribute name. + + Args: + attribute: The attribute name to get the index for. + """ + if (schema := self.schema.lookup_attribute(name=attribute)) is None: + raise ValueError(f"Attribute not found in schema: {attribute!r}") + + if schema.search_flags is None: + raise ValueError(f"Attribute is not indexed: {attribute!r}") + + if SearchFlags.Indexed in schema.search_flags: + name = f"INDEX_{schema.id:08x}" + elif SearchFlags.TupleIndexed in schema.search_flags: + name = f"INDEX_T_{schema.id:08x}" + else: + # TODO add ContainerIndexed + raise ValueError(f"Attribute is not indexed: {attribute!r}") + + return self.table.index(name) + + +class LinkTable: + """Represents the ``link_table`` in the NTDS database. + + This table contains link records representing relationships between directory objects. + """ + + def __init__(self, db: Database): + self.db = db + self.table = self.db.ese.table("link_table") + + def links(self, dnt: int, name: str | None = None) -> Iterator[Object]: + """Get all linked objects for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve linked objects for. + name: An optional link name to filter the linked objects. + """ + yield from (obj for _, obj in self._links(dnt, self._link_base(name) if name else None)) + + def all_links(self, dnt: int) -> Iterator[tuple[str, Object]]: + """Get all linked objects along with their link names for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve linked objects for. + """ + for base, obj in self._links(dnt): + if (schema := self.db.data.schema.lookup_attribute(link_id=base * 2)) is not None: + yield schema.name, obj + + def backlinks(self, dnt: int, name: str | None = None) -> Iterator[Object]: + """Get all backlink objects for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve backlink objects for. + name: An optional link name to filter the backlink objects. + """ + yield from (obj for _, obj in self._backlinks(dnt, self._link_base(name) if name else None)) + + def all_backlinks(self, dnt: int) -> Iterator[tuple[str, Object]]: + """Get all backlink objects along with their link names for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve backlink objects for. + """ + for base, obj in self._backlinks(dnt): + if (schema := self.db.data.schema.lookup_attribute(link_id=(base * 2) + 1)) is not None: + yield schema.name, obj + + def has_link(self, link_dnt: int, name: str, backlink_dnt: int) -> bool: + """Check if a specific link exists between two DNTs and a given link name. + + Args: + link_dnt: The DNT of the link object. + name: The link name to check against. + backlink_dnt: The DNT of the backlink object. + """ + return self._has_link(link_dnt, self._link_base(name), backlink_dnt) + + def has_backlink(self, backlink_dnt: int, name: str, link_dnt: int) -> bool: + """Check if a specific backlink exists between two DNTs and a given link name. + + Args: + backlink_dnt: The DNT of the backlink object. + name: The link name to check against. + link_dnt: The DNT of the link object. + """ + return self._has_backlink(backlink_dnt, self._link_base(name), link_dnt) + + def _link_base(self, name: str) -> int: + """Get the link ID for a given link name. + + Args: + name: The link name to retrieve the link ID for. + """ + if (schema := self.db.data.schema.lookup_attribute(name=name)) is None: + raise ValueError(f"Link name '{name}' not found in schema") + return schema.link_id // 2 + + def _links(self, dnt: int, base: int | None = None) -> Iterator[tuple[int, Object]]: + """Get all linked objects for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve linked objects for. + base: An optional base DNT to filter the linked objects. + + Returns: + An iterator of tuples containing the link base and the linked object. + """ + cursor = self.table.index("link_index").cursor() + cursor.seek([dnt] if base is None else [dnt, base]) + + record = cursor.record() + while record is not None and record.get("link_DNT") == dnt: + if base is not None and record.get("link_base") != base: + break + + yield record.get("link_base"), self.db.data.get(dnt=record.get("backlink_DNT")) + record = cursor.next() + + def _has_link(self, link_dnt: int, base: int, backlink_dnt: int) -> bool: + """Check if a specific link exists between two DNTs and a given link base. + + Args: + link_dnt: The DNT of the link object. + base: The link base to check against. + backlink_dnt: The DNT of the backlink object. + """ + cursor = self.table.index("link_index").cursor() + + try: + cursor.search([link_dnt, base, backlink_dnt]) + except KeyNotFoundError: + return False + else: + return True + + def _has_backlink(self, backlink_dnt: int, base: int, link_dnt: int) -> bool: + """Check if a specific backlink exists between two DNTs and a given link base. + + Args: + backlink_dnt: The DNT of the backlink object. + base: The link base to check against. + link_dnt: The DNT of the link object. + """ + cursor = self.table.index("backlink_index").cursor() + + try: + cursor.search([backlink_dnt, base, link_dnt]) + except KeyNotFoundError: + return False + else: + return True + + def _backlinks(self, dnt: int, base: int | None = None) -> Iterator[tuple[int, Object]]: + """Get all backlink objects for a given Directory Number Tag (DNT). + + Args: + dnt: The DNT to retrieve backlink objects for. + base: An optional base DNT to filter the backlink objects. + + Returns: + An iterator of tuples containing the link base and the backlinked object. + """ + cursor = self.table.index("backlink_index").cursor() + cursor.seek([dnt] if base is None else [dnt, base]) + + record = cursor.record() + while record is not None and record.get("backlink_DNT") == dnt: + if base is not None and record.get("link_base") != base: + break + + yield record.get("link_base"), self.db.data.get(dnt=record.get("link_DNT")) + record = cursor.next() + + +class SecurityDescriptorTable: + """Represents the ``sd_table`` in the NTDS database. + + This table contains security descriptors associated with directory objects. + """ + + def __init__(self, db: Database): + self.db = db + self.table = self.db.ese.table("sd_table") + + def sd(self, id: int) -> SecurityDescriptor | None: + """Get the Discretionary Access Control List (DACL), if available. + + Args: + id: The ID of the security descriptor. + """ + index = self.table.index("sd_id_index") + cursor = index.cursor() + + # Get the SecurityDescriptor from the sd_table + if (record := cursor.search([id])) is None: + return None + + if (value := record.get("sd_value")) is None: + return None + + return SecurityDescriptor(BytesIO(value)) diff --git a/dissect/database/ese/ntds/ntds.py b/dissect/database/ese/ntds/ntds.py new file mode 100644 index 0000000..1dd4047 --- /dev/null +++ b/dissect/database/ese/ntds/ntds.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, BinaryIO + +from dissect.database.ese.ntds.database import Database + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Computer, DomainDNS, Group, Object, Server, User + from dissect.database.ese.ntds.objects.trusteddomain import TrustedDomain + from dissect.database.ese.ntds.pek import PEK + + +class NTDS: + """NTDS.dit Active Directory Domain Services (AD DS) database parser. + + For the curious, NTDS.dit stands for "New Technology Directory Services Directory Information Tree". + + Allows convenient querying and extraction of data from an NTDS.dit file, including users, computers, groups, + and their relationships. + + If you're a brave soul reading this code, you're about to go past the LDAP fairy tale + and into the "ntds internals are cursed" zone. + + Args: + fh: A file-like object of the NTDS.dit database. + """ + + def __init__(self, fh: BinaryIO): + self.db = Database(fh) + + def root(self) -> Object: + """Return the root object of the Active Directory.""" + return self.db.data.root() + + def root_domain(self) -> DomainDNS | None: + """Return the root domain object of the Active Directory.""" + return self.db.data.root_domain() + + @property + def pek(self) -> PEK | None: + """Return the PEK associated with the root domain.""" + return self.db.data.pek + + def walk(self) -> Iterator[Object]: + """Walk through all objects in the NTDS database.""" + yield from self.db.data.walk() + + def query(self, query: str, *, optimize: bool = True) -> Iterator[Object]: + """Execute an LDAP query against the NTDS database. + + Args: + query: The LDAP query string to execute. + optimize: Whether to optimize the query, default is ``True``. + + Yields: + Object instances matching the query. Objects are cast to more specific types when possible. + """ + yield from self.db.data.query(query, optimize=optimize) + + def search(self, **kwargs: str) -> Iterator[Object]: + """Perform an attribute-value query. If multiple attributes are provided, it will be treated as an "AND" query. + + Args: + **kwargs: Keyword arguments specifying the attributes and values. + + Yields: + Object instances matching the attribute-value pair. + """ + yield from self.db.data.search(**kwargs) + + def groups(self) -> Iterator[Group]: + """Get all group objects from the database.""" + yield from self.search(objectCategory="group") + + def servers(self) -> Iterator[Server]: + """Get all server objects from the database.""" + yield from self.search(objectCategory="server") + + def users(self) -> Iterator[User]: + """Get all user objects from the database.""" + yield from self.search(objectCategory="person") + + def computers(self) -> Iterator[Computer]: + """Get all computer objects from the database.""" + yield from self.search(objectCategory="computer") + + def trusts(self) -> Iterator[TrustedDomain]: + """Get all trust objects from the database.""" + yield from self.search(objectClass="trustedDomain") diff --git a/dissect/database/ese/ntds/objects/__init__.py b/dissect/database/ese/ntds/objects/__init__.py new file mode 100644 index 0000000..566ae34 --- /dev/null +++ b/dissect/database/ese/ntds/objects/__init__.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +# Oh god oh why what did I do +# Is this better than a giant object.py? Who knows, we're rolling with it for now +from dissect.database.ese.ntds.objects.applicationsettings import ApplicationSettings +from dissect.database.ese.ntds.objects.attributeschema import AttributeSchema +from dissect.database.ese.ntds.objects.builtindomain import BuiltinDomain +from dissect.database.ese.ntds.objects.certificationauthority import CertificationAuthority +from dissect.database.ese.ntds.objects.classschema import ClassSchema +from dissect.database.ese.ntds.objects.classstore import ClassStore +from dissect.database.ese.ntds.objects.computer import Computer +from dissect.database.ese.ntds.objects.configuration import Configuration +from dissect.database.ese.ntds.objects.container import Container +from dissect.database.ese.ntds.objects.controlaccessright import ControlAccessRight +from dissect.database.ese.ntds.objects.crldistributionpoint import CRLDistributionPoint +from dissect.database.ese.ntds.objects.crossref import CrossRef +from dissect.database.ese.ntds.objects.crossrefcontainer import CrossRefContainer +from dissect.database.ese.ntds.objects.dfsconfiguration import DfsConfiguration +from dissect.database.ese.ntds.objects.displayspecifier import DisplaySpecifier +from dissect.database.ese.ntds.objects.dmd import DMD +from dissect.database.ese.ntds.objects.dnsnode import DnsNode +from dissect.database.ese.ntds.objects.dnszone import DnsZone +from dissect.database.ese.ntds.objects.domain import Domain +from dissect.database.ese.ntds.objects.domaindns import DomainDNS +from dissect.database.ese.ntds.objects.domainpolicy import DomainPolicy +from dissect.database.ese.ntds.objects.dsuisettings import DSUISettings +from dissect.database.ese.ntds.objects.filelinktracking import FileLinkTracking +from dissect.database.ese.ntds.objects.foreignsecurityprincipal import ForeignSecurityPrincipal +from dissect.database.ese.ntds.objects.group import Group +from dissect.database.ese.ntds.objects.grouppolicycontainer import GroupPolicyContainer +from dissect.database.ese.ntds.objects.infrastructureupdate import InfrastructureUpdate +from dissect.database.ese.ntds.objects.intersitetransport import InterSiteTransport +from dissect.database.ese.ntds.objects.intersitetransportcontainer import InterSiteTransportContainer +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase +from dissect.database.ese.ntds.objects.ipsecfilter import IpsecFilter +from dissect.database.ese.ntds.objects.ipsecisakmppolicy import IpsecISAKMPPolicy +from dissect.database.ese.ntds.objects.ipsecnegotiationpolicy import IpsecNegotiationPolicy +from dissect.database.ese.ntds.objects.ipsecnfa import IpsecNFA +from dissect.database.ese.ntds.objects.ipsecpolicy import IpsecPolicy +from dissect.database.ese.ntds.objects.leaf import Leaf +from dissect.database.ese.ntds.objects.linktrackobjectmovetable import LinkTrackObjectMoveTable +from dissect.database.ese.ntds.objects.linktrackvolumetable import LinkTrackVolumeTable +from dissect.database.ese.ntds.objects.locality import Locality +from dissect.database.ese.ntds.objects.lostandfound import LostAndFound +from dissect.database.ese.ntds.objects.msauthz_centralaccesspolicies import MSAuthzCentralAccessPolicies +from dissect.database.ese.ntds.objects.msauthz_centralaccessrules import MSAuthzCentralAccessRules +from dissect.database.ese.ntds.objects.msdfsr_content import MSDFSRContent +from dissect.database.ese.ntds.objects.msdfsr_contentset import MSDFSRContentSet +from dissect.database.ese.ntds.objects.msdfsr_globalsettings import MSDFSRGlobalSettings +from dissect.database.ese.ntds.objects.msdfsr_localsettings import MSDFSRLocalSettings +from dissect.database.ese.ntds.objects.msdfsr_member import MSDFSRMember +from dissect.database.ese.ntds.objects.msdfsr_replicationgroup import MSDFSRReplicationGroup +from dissect.database.ese.ntds.objects.msdfsr_subscriber import MSDFSRSubscriber +from dissect.database.ese.ntds.objects.msdfsr_subscription import MSDFSRSubscription +from dissect.database.ese.ntds.objects.msdfsr_topology import MSDFSRTopology +from dissect.database.ese.ntds.objects.msdns_serversettings import MSDNSServerSettings +from dissect.database.ese.ntds.objects.msds_authnpolicies import MSDSAuthNPolicies +from dissect.database.ese.ntds.objects.msds_authnpolicysilos import MSDSAuthNPolicySilos +from dissect.database.ese.ntds.objects.msds_claimstransformationpolicies import MSDSClaimsTransformationPolicies +from dissect.database.ese.ntds.objects.msds_claimtype import MSDSClaimType +from dissect.database.ese.ntds.objects.msds_claimtypepropertybase import MSDSClaimTypePropertyBase +from dissect.database.ese.ntds.objects.msds_claimtypes import MSDSClaimTypes +from dissect.database.ese.ntds.objects.msds_optionalfeature import MSDSOptionalFeature +from dissect.database.ese.ntds.objects.msds_passwordsettingscontainer import MSDSPasswordSettingsContainer +from dissect.database.ese.ntds.objects.msds_quotacontainer import MSDSQuotaContainer +from dissect.database.ese.ntds.objects.msds_resourceproperties import MSDSResourceProperties +from dissect.database.ese.ntds.objects.msds_resourceproperty import MSDSResourceProperty +from dissect.database.ese.ntds.objects.msds_resourcepropertylist import MSDSResourcePropertyList +from dissect.database.ese.ntds.objects.msds_shadowprincipalcontainer import MSDSShadowPrincipalContainer +from dissect.database.ese.ntds.objects.msds_valuetype import MSDSValueType +from dissect.database.ese.ntds.objects.msimaging_psps import MSImagingPSPs +from dissect.database.ese.ntds.objects.mskds_provserverconfiguration import MSKDSProvServerConfiguration +from dissect.database.ese.ntds.objects.msmqenterprisesettings import MSMQEnterpriseSettings +from dissect.database.ese.ntds.objects.mspki_enterpriseoid import MSPKIEnterpriseOID +from dissect.database.ese.ntds.objects.mspki_privatekeyrecoveryagent import MSPKIPrivateKeyRecoveryAgent +from dissect.database.ese.ntds.objects.msspp_activationobjectscontainer import MSSPPActivationObjectsContainer +from dissect.database.ese.ntds.objects.mstpm_informationobjectscontainer import MSTPMInformationObjectsContainer +from dissect.database.ese.ntds.objects.ntdsconnection import NTDSConnection +from dissect.database.ese.ntds.objects.ntdsdsa import NTDSDSA +from dissect.database.ese.ntds.objects.ntdsservice import NTDSService +from dissect.database.ese.ntds.objects.ntdssitesettings import NTDSSiteSettings +from dissect.database.ese.ntds.objects.ntfrssettings import NTFRSSettings +from dissect.database.ese.ntds.objects.object import Object +from dissect.database.ese.ntds.objects.organizationalperson import OrganizationalPerson +from dissect.database.ese.ntds.objects.organizationalunit import OrganizationalUnit +from dissect.database.ese.ntds.objects.person import Person +from dissect.database.ese.ntds.objects.physicallocation import PhysicalLocation +from dissect.database.ese.ntds.objects.pkicertificatetemplate import PKICertificateTemplate +from dissect.database.ese.ntds.objects.pkienrollmentservice import PKIEnrollmentService +from dissect.database.ese.ntds.objects.querypolicy import QueryPolicy +from dissect.database.ese.ntds.objects.ridmanager import RIDManager +from dissect.database.ese.ntds.objects.ridset import RIDSet +from dissect.database.ese.ntds.objects.rpccontainer import RpcContainer +from dissect.database.ese.ntds.objects.rrasadministrationdictionary import RRASAdministrationDictionary +from dissect.database.ese.ntds.objects.samserver import SamServer +from dissect.database.ese.ntds.objects.secret import Secret +from dissect.database.ese.ntds.objects.securityobject import SecurityObject +from dissect.database.ese.ntds.objects.server import Server +from dissect.database.ese.ntds.objects.serverscontainer import ServersContainer +from dissect.database.ese.ntds.objects.site import Site +from dissect.database.ese.ntds.objects.sitelink import SiteLink +from dissect.database.ese.ntds.objects.sitescontainer import SitesContainer +from dissect.database.ese.ntds.objects.subnetcontainer import SubnetContainer +from dissect.database.ese.ntds.objects.subschema import SubSchema +from dissect.database.ese.ntds.objects.top import Top +from dissect.database.ese.ntds.objects.trusteddomain import TrustedDomain +from dissect.database.ese.ntds.objects.user import User + +__all__ = [ + "DMD", + "NTDSDSA", + "ApplicationSettings", + "AttributeSchema", + "BuiltinDomain", + "CRLDistributionPoint", + "CertificationAuthority", + "ClassSchema", + "ClassStore", + "Computer", + "Configuration", + "Container", + "ControlAccessRight", + "CrossRef", + "CrossRefContainer", + "DSUISettings", + "DfsConfiguration", + "DisplaySpecifier", + "DnsNode", + "DnsZone", + "Domain", + "DomainDNS", + "DomainPolicy", + "FileLinkTracking", + "ForeignSecurityPrincipal", + "Group", + "GroupPolicyContainer", + "InfrastructureUpdate", + "InterSiteTransport", + "InterSiteTransportContainer", + "IpsecBase", + "IpsecFilter", + "IpsecISAKMPPolicy", + "IpsecNFA", + "IpsecNegotiationPolicy", + "IpsecPolicy", + "Leaf", + "LinkTrackObjectMoveTable", + "LinkTrackVolumeTable", + "Locality", + "LostAndFound", + "MSAuthzCentralAccessPolicies", + "MSAuthzCentralAccessRules", + "MSDFSRContent", + "MSDFSRContentSet", + "MSDFSRGlobalSettings", + "MSDFSRLocalSettings", + "MSDFSRMember", + "MSDFSRReplicationGroup", + "MSDFSRSubscriber", + "MSDFSRSubscription", + "MSDFSRTopology", + "MSDNSServerSettings", + "MSDSAuthNPolicies", + "MSDSAuthNPolicySilos", + "MSDSClaimType", + "MSDSClaimTypePropertyBase", + "MSDSClaimTypes", + "MSDSClaimsTransformationPolicies", + "MSDSOptionalFeature", + "MSDSPasswordSettingsContainer", + "MSDSQuotaContainer", + "MSDSResourceProperties", + "MSDSResourceProperty", + "MSDSResourcePropertyList", + "MSDSShadowPrincipalContainer", + "MSDSValueType", + "MSImagingPSPs", + "MSKDSProvServerConfiguration", + "MSMQEnterpriseSettings", + "MSPKIEnterpriseOID", + "MSPKIPrivateKeyRecoveryAgent", + "MSSPPActivationObjectsContainer", + "MSTPMInformationObjectsContainer", + "NTDSConnection", + "NTDSService", + "NTDSSiteSettings", + "NTFRSSettings", + "Object", + "OrganizationalPerson", + "OrganizationalUnit", + "PKICertificateTemplate", + "PKIEnrollmentService", + "Person", + "PhysicalLocation", + "QueryPolicy", + "RIDManager", + "RIDSet", + "RRASAdministrationDictionary", + "RpcContainer", + "SamServer", + "Secret", + "SecurityObject", + "Server", + "ServersContainer", + "Site", + "SiteLink", + "SitesContainer", + "SubSchema", + "SubnetContainer", + "Top", + "TrustedDomain", + "User", +] diff --git a/dissect/database/ese/ntds/objects/applicationsettings.py b/dissect/database/ese/ntds/objects/applicationsettings.py new file mode 100644 index 0000000..5aa21a0 --- /dev/null +++ b/dissect/database/ese/ntds/objects/applicationsettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ApplicationSettings(Top): + """Represents an application settings object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-applicationsettings + """ + + __object_class__ = "applicationSettings" diff --git a/dissect/database/ese/ntds/objects/attributeschema.py b/dissect/database/ese/ntds/objects/attributeschema.py new file mode 100644 index 0000000..e01389d --- /dev/null +++ b/dissect/database/ese/ntds/objects/attributeschema.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class AttributeSchema(Top): + """Represents an attribute schema object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-attributeschema + """ + + __object_class__ = "attributeSchema" diff --git a/dissect/database/ese/ntds/objects/builtindomain.py b/dissect/database/ese/ntds/objects/builtindomain.py new file mode 100644 index 0000000..4e45c7f --- /dev/null +++ b/dissect/database/ese/ntds/objects/builtindomain.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class BuiltinDomain(Top): + """Represents a built-in domain object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-builtindomain + """ + + __object_class__ = "builtinDomain" diff --git a/dissect/database/ese/ntds/objects/certificationauthority.py b/dissect/database/ese/ntds/objects/certificationauthority.py new file mode 100644 index 0000000..92ae439 --- /dev/null +++ b/dissect/database/ese/ntds/objects/certificationauthority.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class CertificationAuthority(Top): + """Represents a Certification Authority object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-certificationauthority + """ + + __object_class__ = "certificationAuthority" diff --git a/dissect/database/ese/ntds/objects/classschema.py b/dissect/database/ese/ntds/objects/classschema.py new file mode 100644 index 0000000..4604d7b --- /dev/null +++ b/dissect/database/ese/ntds/objects/classschema.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ClassSchema(Top): + """Represents a class schema object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-classschema + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/ccd55373-2fa6-4237-9f66-0d90fbd866f5 + """ + + __object_class__ = "classSchema" + + @property + def system_must_contain(self) -> list[str]: + """Return a list of LDAP display names of attributes this class system must contain.""" + if (system_must_contain := self.get("systemMustContain")) is not None: + return system_must_contain + return [] + + @property + def system_may_contain(self) -> list[str]: + """Return a list of LDAP display names of attributes this class system may contain.""" + if (system_may_contain := self.get("systemMayContain")) is not None: + return system_may_contain + return [] + + @property + def must_contain(self) -> list[str]: + """Return a list of LDAP display names of attributes this class must contain.""" + if (must_contain := self.get("mustContain")) is not None: + return must_contain + return [] + + @property + def may_contain(self) -> list[str]: + """Return a list of LDAP display names of attributes this class may contain.""" + if (may_contain := self.get("mayContain")) is not None: + return may_contain + return [] diff --git a/dissect/database/ese/ntds/objects/classstore.py b/dissect/database/ese/ntds/objects/classstore.py new file mode 100644 index 0000000..497af17 --- /dev/null +++ b/dissect/database/ese/ntds/objects/classstore.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ClassStore(Top): + """Represents a class store object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-classstore + """ + + __object_class__ = "classStore" diff --git a/dissect/database/ese/ntds/objects/computer.py b/dissect/database/ese/ntds/objects/computer.py new file mode 100644 index 0000000..28d500e --- /dev/null +++ b/dissect/database/ese/ntds/objects/computer.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.user import User + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class Computer(User): + """Represents a computer object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-computer + """ + + __object_class__ = "computer" + + def __repr_body__(self) -> str: + return f"name={self.name!r}" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this computer.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/configuration.py b/dissect/database/ese/ntds/objects/configuration.py new file mode 100644 index 0000000..cf7bfc9 --- /dev/null +++ b/dissect/database/ese/ntds/objects/configuration.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Configuration(Top): + """Represents a configuration object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-configuration + """ + + __object_class__ = "configuration" diff --git a/dissect/database/ese/ntds/objects/container.py b/dissect/database/ese/ntds/objects/container.py new file mode 100644 index 0000000..850dc18 --- /dev/null +++ b/dissect/database/ese/ntds/objects/container.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Container(Top): + """Represents a container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-container + """ + + __object_class__ = "container" diff --git a/dissect/database/ese/ntds/objects/controlaccessright.py b/dissect/database/ese/ntds/objects/controlaccessright.py new file mode 100644 index 0000000..d939682 --- /dev/null +++ b/dissect/database/ese/ntds/objects/controlaccessright.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ControlAccessRight(Top): + """Represents a control access right object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-controlaccessright + """ + + __object_class__ = "controlAccessRight" diff --git a/dissect/database/ese/ntds/objects/crldistributionpoint.py b/dissect/database/ese/ntds/objects/crldistributionpoint.py new file mode 100644 index 0000000..1a8fe93 --- /dev/null +++ b/dissect/database/ese/ntds/objects/crldistributionpoint.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class CRLDistributionPoint(Top): + """Represents the cRLDistributionPoint object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-crldistributionpoint + """ + + __object_class__ = "cRLDistributionPoint" diff --git a/dissect/database/ese/ntds/objects/crossref.py b/dissect/database/ese/ntds/objects/crossref.py new file mode 100644 index 0000000..3845efe --- /dev/null +++ b/dissect/database/ese/ntds/objects/crossref.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class CrossRef(Top): + """Represents a cross-reference object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-crossref + """ + + __object_class__ = "crossRef" diff --git a/dissect/database/ese/ntds/objects/crossrefcontainer.py b/dissect/database/ese/ntds/objects/crossrefcontainer.py new file mode 100644 index 0000000..efb4665 --- /dev/null +++ b/dissect/database/ese/ntds/objects/crossrefcontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class CrossRefContainer(Top): + """Represents a cross-reference container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-crossrefcontainer + """ + + __object_class__ = "crossRefContainer" diff --git a/dissect/database/ese/ntds/objects/dfsconfiguration.py b/dissect/database/ese/ntds/objects/dfsconfiguration.py new file mode 100644 index 0000000..df5e1bb --- /dev/null +++ b/dissect/database/ese/ntds/objects/dfsconfiguration.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class DfsConfiguration(Top): + """Represents a DFS configuration object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dfsconfiguration + """ + + __object_class__ = "dfsConfiguration" diff --git a/dissect/database/ese/ntds/objects/displayspecifier.py b/dissect/database/ese/ntds/objects/displayspecifier.py new file mode 100644 index 0000000..3e66cbc --- /dev/null +++ b/dissect/database/ese/ntds/objects/displayspecifier.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class DisplaySpecifier(Top): + """Represents a display specifier object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-displayspecifier + """ + + __object_class__ = "displaySpecifier" diff --git a/dissect/database/ese/ntds/objects/dmd.py b/dissect/database/ese/ntds/objects/dmd.py new file mode 100644 index 0000000..98fd1d5 --- /dev/null +++ b/dissect/database/ese/ntds/objects/dmd.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class DMD(Top): + """Represents the DMD (Directory Management Domain) object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dmd + """ + + __object_class__ = "dMD" diff --git a/dissect/database/ese/ntds/objects/dnsnode.py b/dissect/database/ese/ntds/objects/dnsnode.py new file mode 100644 index 0000000..e581dba --- /dev/null +++ b/dissect/database/ese/ntds/objects/dnsnode.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class DnsNode(Top): + """Represents a DNS node object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dnsnode + """ + + __object_class__ = "dnsNode" diff --git a/dissect/database/ese/ntds/objects/dnszone.py b/dissect/database/ese/ntds/objects/dnszone.py new file mode 100644 index 0000000..6dc3e25 --- /dev/null +++ b/dissect/database/ese/ntds/objects/dnszone.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class DnsZone(Top): + """Represents a DNS zone object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dnszone + """ + + __object_class__ = "dnsZone" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this DNS zone.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/domain.py b/dissect/database/ese/ntds/objects/domain.py new file mode 100644 index 0000000..6d221e6 --- /dev/null +++ b/dissect/database/ese/ntds/objects/domain.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Domain(Top): + """Represents a domain object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-domain + """ + + __object_class__ = "domain" diff --git a/dissect/database/ese/ntds/objects/domaindns.py b/dissect/database/ese/ntds/objects/domaindns.py new file mode 100644 index 0000000..e2ce9c2 --- /dev/null +++ b/dissect/database/ese/ntds/objects/domaindns.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.domain import Domain +from dissect.database.ese.ntds.pek import PEK + + +class DomainDNS(Domain): + """Represents a domain DNS object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-domaindns + """ + + __object_class__ = "domainDNS" + + @property + def pek(self) -> PEK | None: + """The PEK list associated with this domain DNS object, if any.""" + if (pek := self.get("pekList")) is not None: + return PEK(pek) + return None diff --git a/dissect/database/ese/ntds/objects/domainpolicy.py b/dissect/database/ese/ntds/objects/domainpolicy.py new file mode 100644 index 0000000..7f5d1b7 --- /dev/null +++ b/dissect/database/ese/ntds/objects/domainpolicy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.leaf import Leaf + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class DomainPolicy(Leaf): + """Represents a domain policy object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-domainpolicy + """ + + __object_class__ = "domainPolicy" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this domain policy.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/dsuisettings.py b/dissect/database/ese/ntds/objects/dsuisettings.py new file mode 100644 index 0000000..ec0c8ca --- /dev/null +++ b/dissect/database/ese/ntds/objects/dsuisettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class DSUISettings(Top): + """Represents a DS-UI settings object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-dsuisettings + """ + + __object_class__ = "dSUISettings" diff --git a/dissect/database/ese/ntds/objects/filelinktracking.py b/dissect/database/ese/ntds/objects/filelinktracking.py new file mode 100644 index 0000000..770f4c5 --- /dev/null +++ b/dissect/database/ese/ntds/objects/filelinktracking.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class FileLinkTracking(Top): + """Represents a file link tracking object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-filelinktracking + """ + + __object_class__ = "fileLinkTracking" diff --git a/dissect/database/ese/ntds/objects/foreignsecurityprincipal.py b/dissect/database/ese/ntds/objects/foreignsecurityprincipal.py new file mode 100644 index 0000000..6c9bef1 --- /dev/null +++ b/dissect/database/ese/ntds/objects/foreignsecurityprincipal.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ForeignSecurityPrincipal(Top): + """Represents a foreign security principal object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-foreignsecurityprincipal + """ + + __object_class__ = "foreignSecurityPrincipal" diff --git a/dissect/database/ese/ntds/objects/group.py b/dissect/database/ese/ntds/objects/group.py new file mode 100644 index 0000000..1bddac4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/group.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object, User + + +class Group(Top): + """Represents a group object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-group + """ + + __object_class__ = "group" + + @property + def sam_account_name(self) -> str: + """Return the group's sAMAccountName.""" + return self.get("sAMAccountName") + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this group.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") + + def members(self) -> Iterator[User]: + """Yield all members of this group.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "member") + + # We also need to include users with primaryGroupID matching the group's RID + yield from self.db.data.search(primaryGroupID=self.rid) + + def is_member(self, user: User) -> bool: + """Return whether the given user is a member of this group. + + Args: + user: The :class:`User` to check membership for. + """ + return any(u.dnt == user.dnt for u in self.members()) diff --git a/dissect/database/ese/ntds/objects/grouppolicycontainer.py b/dissect/database/ese/ntds/objects/grouppolicycontainer.py new file mode 100644 index 0000000..80c1cb7 --- /dev/null +++ b/dissect/database/ese/ntds/objects/grouppolicycontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.container import Container + + +class GroupPolicyContainer(Container): + """Represents a group policy container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-grouppolicycontainer + """ + + __object_class__ = "groupPolicyContainer" diff --git a/dissect/database/ese/ntds/objects/infrastructureupdate.py b/dissect/database/ese/ntds/objects/infrastructureupdate.py new file mode 100644 index 0000000..ba89201 --- /dev/null +++ b/dissect/database/ese/ntds/objects/infrastructureupdate.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class InfrastructureUpdate(Top): + """Represents an infrastructure update object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-infrastructureupdate + """ + + __object_class__ = "infrastructureUpdate" diff --git a/dissect/database/ese/ntds/objects/intersitetransport.py b/dissect/database/ese/ntds/objects/intersitetransport.py new file mode 100644 index 0000000..5994481 --- /dev/null +++ b/dissect/database/ese/ntds/objects/intersitetransport.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class InterSiteTransport(Top): + """Represents an inter-site transport object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-intersitetransport + """ + + __object_class__ = "interSiteTransport" diff --git a/dissect/database/ese/ntds/objects/intersitetransportcontainer.py b/dissect/database/ese/ntds/objects/intersitetransportcontainer.py new file mode 100644 index 0000000..bbefdbb --- /dev/null +++ b/dissect/database/ese/ntds/objects/intersitetransportcontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class InterSiteTransportContainer(Top): + """Represents the interSiteTransportContainer object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-intersitetransportcontainer + """ + + __object_class__ = "interSiteTransportContainer" diff --git a/dissect/database/ese/ntds/objects/ipsecbase.py b/dissect/database/ese/ntds/objects/ipsecbase.py new file mode 100644 index 0000000..6f448e5 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecbase.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class IpsecBase(Top): + """Base class for IPsec objects in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecbase + """ + + __object_class__ = "ipsecBase" diff --git a/dissect/database/ese/ntds/objects/ipsecfilter.py b/dissect/database/ese/ntds/objects/ipsecfilter.py new file mode 100644 index 0000000..7cf770f --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecfilter.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase + + +class IpsecFilter(IpsecBase): + """Represents an IPsec filter object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecfilter + """ + + __object_class__ = "ipsecFilter" diff --git a/dissect/database/ese/ntds/objects/ipsecisakmppolicy.py b/dissect/database/ese/ntds/objects/ipsecisakmppolicy.py new file mode 100644 index 0000000..173d042 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecisakmppolicy.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase + + +class IpsecISAKMPPolicy(IpsecBase): + """Represents an IPsec ISAKMP policy object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecisakmppolicy + """ + + __object_class__ = "ipsecISAKMPPolicy" diff --git a/dissect/database/ese/ntds/objects/ipsecnegotiationpolicy.py b/dissect/database/ese/ntds/objects/ipsecnegotiationpolicy.py new file mode 100644 index 0000000..bfbe0af --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecnegotiationpolicy.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase + + +class IpsecNegotiationPolicy(IpsecBase): + """Represents an IPsec negotiation policy object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecnegotiationpolicy + """ + + __object_class__ = "ipsecNegotiationPolicy" diff --git a/dissect/database/ese/ntds/objects/ipsecnfa.py b/dissect/database/ese/ntds/objects/ipsecnfa.py new file mode 100644 index 0000000..0160d86 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecnfa.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase + + +class IpsecNFA(IpsecBase): + """Represents an IPsec NFA (Network Filter Action) object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecnfa + """ + + __object_class__ = "ipsecNFA" diff --git a/dissect/database/ese/ntds/objects/ipsecpolicy.py b/dissect/database/ese/ntds/objects/ipsecpolicy.py new file mode 100644 index 0000000..149c43e --- /dev/null +++ b/dissect/database/ese/ntds/objects/ipsecpolicy.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.ipsecbase import IpsecBase + + +class IpsecPolicy(IpsecBase): + """Represents an IPsec policy object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ipsecpolicy + """ + + __object_class__ = "ipsecPolicy" diff --git a/dissect/database/ese/ntds/objects/leaf.py b/dissect/database/ese/ntds/objects/leaf.py new file mode 100644 index 0000000..d0f514e --- /dev/null +++ b/dissect/database/ese/ntds/objects/leaf.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Leaf(Top): + """Base class for leaf objects in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-leaf + """ + + __object_class__ = "leaf" diff --git a/dissect/database/ese/ntds/objects/linktrackobjectmovetable.py b/dissect/database/ese/ntds/objects/linktrackobjectmovetable.py new file mode 100644 index 0000000..b97d6b6 --- /dev/null +++ b/dissect/database/ese/ntds/objects/linktrackobjectmovetable.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.filelinktracking import FileLinkTracking + + +class LinkTrackObjectMoveTable(FileLinkTracking): + """Represents a link track object move table in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-linktrackobjectmovetable + """ + + __object_class__ = "linkTrackObjectMoveTable" diff --git a/dissect/database/ese/ntds/objects/linktrackvolumetable.py b/dissect/database/ese/ntds/objects/linktrackvolumetable.py new file mode 100644 index 0000000..ed351e8 --- /dev/null +++ b/dissect/database/ese/ntds/objects/linktrackvolumetable.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.filelinktracking import FileLinkTracking + + +class LinkTrackVolumeTable(FileLinkTracking): + """Represents a Link Track Volume Table in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-linktrackvolumetable + """ + + __object_class__ = "linkTrackVolumeTable" diff --git a/dissect/database/ese/ntds/objects/locality.py b/dissect/database/ese/ntds/objects/locality.py new file mode 100644 index 0000000..003b8f4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/locality.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Locality(Top): + """Represents a locality object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-locality + """ + + __object_class__ = "locality" diff --git a/dissect/database/ese/ntds/objects/lostandfound.py b/dissect/database/ese/ntds/objects/lostandfound.py new file mode 100644 index 0000000..c952cba --- /dev/null +++ b/dissect/database/ese/ntds/objects/lostandfound.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class LostAndFound(Top): + """Represents a lost and found object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-lostandfound + """ + + __object_class__ = "lostAndFound" diff --git a/dissect/database/ese/ntds/objects/msauthz_centralaccesspolicies.py b/dissect/database/ese/ntds/objects/msauthz_centralaccesspolicies.py new file mode 100644 index 0000000..82a20b5 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msauthz_centralaccesspolicies.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSAuthzCentralAccessPolicies(Top): + """Represents the msAuthz-CentralAccessPolicies object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msauthz-centralaccesspolicies + """ + + __object_class__ = "msAuthz-CentralAccessPolicies" diff --git a/dissect/database/ese/ntds/objects/msauthz_centralaccessrules.py b/dissect/database/ese/ntds/objects/msauthz_centralaccessrules.py new file mode 100644 index 0000000..0d565a0 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msauthz_centralaccessrules.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSAuthzCentralAccessRules(Top): + """Represents the msAuthz-CentralAccessRules object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msauthz-centralaccessrules + """ + + __object_class__ = "msAuthz-CentralAccessRules" diff --git a/dissect/database/ese/ntds/objects/msdfsr_content.py b/dissect/database/ese/ntds/objects/msdfsr_content.py new file mode 100644 index 0000000..3203a2b --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_content.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRContent(Top): + """Represents the msDFSR-Content object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-content + """ + + __object_class__ = "msDFSR-Content" diff --git a/dissect/database/ese/ntds/objects/msdfsr_contentset.py b/dissect/database/ese/ntds/objects/msdfsr_contentset.py new file mode 100644 index 0000000..62fb6aa --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_contentset.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRContentSet(Top): + """Represents the msDFSR-ContentSet object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-contentset + """ + + __object_class__ = "msDFSR-ContentSet" diff --git a/dissect/database/ese/ntds/objects/msdfsr_globalsettings.py b/dissect/database/ese/ntds/objects/msdfsr_globalsettings.py new file mode 100644 index 0000000..9f69f68 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_globalsettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRGlobalSettings(Top): + """Represents the MSDFSR-GlobalSettings object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-globalsettings + """ + + __object_class__ = "msDFSR-GlobalSettings" diff --git a/dissect/database/ese/ntds/objects/msdfsr_localsettings.py b/dissect/database/ese/ntds/objects/msdfsr_localsettings.py new file mode 100644 index 0000000..ac3c11a --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_localsettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRLocalSettings(Top): + """Represents the msDFSR-LocalSettings object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-localsettings + """ + + __object_class__ = "msDFSR-LocalSettings" diff --git a/dissect/database/ese/ntds/objects/msdfsr_member.py b/dissect/database/ese/ntds/objects/msdfsr_member.py new file mode 100644 index 0000000..95820c4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_member.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRMember(Top): + """Represents the msDFSR-Member object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-member + """ + + __object_class__ = "msDFSR-Member" diff --git a/dissect/database/ese/ntds/objects/msdfsr_replicationgroup.py b/dissect/database/ese/ntds/objects/msdfsr_replicationgroup.py new file mode 100644 index 0000000..8421609 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_replicationgroup.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRReplicationGroup(Top): + """Represents the msDFSR-ReplicationGroup object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-replicationgroup + """ + + __object_class__ = "msDFSR-ReplicationGroup" diff --git a/dissect/database/ese/ntds/objects/msdfsr_subscriber.py b/dissect/database/ese/ntds/objects/msdfsr_subscriber.py new file mode 100644 index 0000000..0a72604 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_subscriber.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRSubscriber(Top): + """Represents the MSDFSR Subscriber object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-subscriber + """ + + __object_class__ = "msDFSR-Subscriber" diff --git a/dissect/database/ese/ntds/objects/msdfsr_subscription.py b/dissect/database/ese/ntds/objects/msdfsr_subscription.py new file mode 100644 index 0000000..40b77fa --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_subscription.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRSubscription(Top): + """Represents the msDFSR-Subscription object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-subscription + """ + + __object_class__ = "msDFSR-Subscription" diff --git a/dissect/database/ese/ntds/objects/msdfsr_topology.py b/dissect/database/ese/ntds/objects/msdfsr_topology.py new file mode 100644 index 0000000..a72d39c --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdfsr_topology.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDFSRTopology(Top): + """Represents the msDFSR-Topology object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdfsr-topology + """ + + __object_class__ = "msDFSR-Topology" diff --git a/dissect/database/ese/ntds/objects/msdns_serversettings.py b/dissect/database/ese/ntds/objects/msdns_serversettings.py new file mode 100644 index 0000000..0891d16 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msdns_serversettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDNSServerSettings(Top): + """Represents a DNS server settings object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msdns-serversettings + """ + + __object_class__ = "msDNS-ServerSettings" diff --git a/dissect/database/ese/ntds/objects/msds_authnpolicies.py b/dissect/database/ese/ntds/objects/msds_authnpolicies.py new file mode 100644 index 0000000..bb6ea35 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_authnpolicies.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSAuthNPolicies(Top): + """Represents the msDS-AuthNPolicies object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/619b9625-a57b-4591-8689-ee5bdf6bbb93 + """ + + __object_class__ = "msDS-AuthNPolicies" diff --git a/dissect/database/ese/ntds/objects/msds_authnpolicysilos.py b/dissect/database/ese/ntds/objects/msds_authnpolicysilos.py new file mode 100644 index 0000000..c354f10 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_authnpolicysilos.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSAuthNPolicySilos(Top): + """Represents the msDS-AuthNPolicySilos object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/997a1ead-e7b6-4b20-8aa0-3e1e9e0f2bf2 + """ + + __object_class__ = "msDS-AuthNPolicySilos" diff --git a/dissect/database/ese/ntds/objects/msds_claimstransformationpolicies.py b/dissect/database/ese/ntds/objects/msds_claimstransformationpolicies.py new file mode 100644 index 0000000..aa239e6 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_claimstransformationpolicies.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSClaimsTransformationPolicies(Top): + """Represents the msDS-ClaimsTransformationPolicies object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-claimstransformationpolicies + """ + + __object_class__ = "msDS-ClaimsTransformationPolicies" diff --git a/dissect/database/ese/ntds/objects/msds_claimtype.py b/dissect/database/ese/ntds/objects/msds_claimtype.py new file mode 100644 index 0000000..cf504f2 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_claimtype.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.msds_claimtypepropertybase import MSDSClaimTypePropertyBase + + +class MSDSClaimType(MSDSClaimTypePropertyBase): + """Represents a claim type object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-claimtype + """ + + __object_class__ = "msDS-ClaimType" diff --git a/dissect/database/ese/ntds/objects/msds_claimtypepropertybase.py b/dissect/database/ese/ntds/objects/msds_claimtypepropertybase.py new file mode 100644 index 0000000..b6e395d --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_claimtypepropertybase.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSClaimTypePropertyBase(Top): + """Base class for claim type property objects in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-claimtypepropertybase + """ + + __object_class__ = "msDS-ClaimTypePropertyBase" diff --git a/dissect/database/ese/ntds/objects/msds_claimtypes.py b/dissect/database/ese/ntds/objects/msds_claimtypes.py new file mode 100644 index 0000000..a139f47 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_claimtypes.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.msds_claimtypepropertybase import MSDSClaimTypePropertyBase + + +class MSDSClaimTypes(MSDSClaimTypePropertyBase): + """Represents a claim types object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-claimtypes + """ + + __object_class__ = "msDS-ClaimTypes" diff --git a/dissect/database/ese/ntds/objects/msds_optionalfeature.py b/dissect/database/ese/ntds/objects/msds_optionalfeature.py new file mode 100644 index 0000000..bf30806 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_optionalfeature.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSOptionalFeature(Top): + """Represents the msDS-OptionalFeature object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-optionalfeature + """ + + __object_class__ = "msDS-OptionalFeature" diff --git a/dissect/database/ese/ntds/objects/msds_passwordsettingscontainer.py b/dissect/database/ese/ntds/objects/msds_passwordsettingscontainer.py new file mode 100644 index 0000000..3c57eb1 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_passwordsettingscontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSPasswordSettingsContainer(Top): + """Represents the msDS-PasswordSettingsContainer object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-passwordsettingscontainer + """ + + __object_class__ = "msDS-PasswordSettingsContainer" diff --git a/dissect/database/ese/ntds/objects/msds_quotacontainer.py b/dissect/database/ese/ntds/objects/msds_quotacontainer.py new file mode 100644 index 0000000..fbea36a --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_quotacontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSQuotaContainer(Top): + """Represents a quota container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-quotacontainer + """ + + __object_class__ = "msDS-QuotaContainer" diff --git a/dissect/database/ese/ntds/objects/msds_resourceproperties.py b/dissect/database/ese/ntds/objects/msds_resourceproperties.py new file mode 100644 index 0000000..be4a55d --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_resourceproperties.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSResourceProperties(Top): + """Represents the msDS-ResourceProperties object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-resourceproperties + """ + + __object_class__ = "msDS-ResourceProperties" diff --git a/dissect/database/ese/ntds/objects/msds_resourceproperty.py b/dissect/database/ese/ntds/objects/msds_resourceproperty.py new file mode 100644 index 0000000..2c0ebac --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_resourceproperty.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.msds_claimtypepropertybase import MSDSClaimTypePropertyBase + + +class MSDSResourceProperty(MSDSClaimTypePropertyBase): + """Represents a resource property object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-resourceproperty + """ + + __object_class__ = "msDS-ResourceProperty" diff --git a/dissect/database/ese/ntds/objects/msds_resourcepropertylist.py b/dissect/database/ese/ntds/objects/msds_resourcepropertylist.py new file mode 100644 index 0000000..ba92620 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_resourcepropertylist.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSResourcePropertyList(Top): + """Represents the msDS-ResourcePropertyList object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-resourcepropertylist + """ + + __object_class__ = "msDS-ResourcePropertyList" diff --git a/dissect/database/ese/ntds/objects/msds_shadowprincipalcontainer.py b/dissect/database/ese/ntds/objects/msds_shadowprincipalcontainer.py new file mode 100644 index 0000000..f5e68de --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_shadowprincipalcontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSShadowPrincipalContainer(Top): + """Represents the msDS-ShadowPrincipalContainer object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/5e4a3007-10de-479e-b0a4-3a96271e2640 + """ + + __object_class__ = "msDS-ShadowPrincipalContainer" diff --git a/dissect/database/ese/ntds/objects/msds_valuetype.py b/dissect/database/ese/ntds/objects/msds_valuetype.py new file mode 100644 index 0000000..db7dd2e --- /dev/null +++ b/dissect/database/ese/ntds/objects/msds_valuetype.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSDSValueType(Top): + """Represents a value type object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-valuetype + """ + + __object_class__ = "msDS-ValueType" diff --git a/dissect/database/ese/ntds/objects/msimaging_psps.py b/dissect/database/ese/ntds/objects/msimaging_psps.py new file mode 100644 index 0000000..aa9f68a --- /dev/null +++ b/dissect/database/ese/ntds/objects/msimaging_psps.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSImagingPSPs(Top): + """Container for all Enterprise Scan Post Scan Process objects. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msimaging-psps + """ + + __object_class__ = "msImaging-PSPs" diff --git a/dissect/database/ese/ntds/objects/mskds_provserverconfiguration.py b/dissect/database/ese/ntds/objects/mskds_provserverconfiguration.py new file mode 100644 index 0000000..32089a0 --- /dev/null +++ b/dissect/database/ese/ntds/objects/mskds_provserverconfiguration.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSKDSProvServerConfiguration(Top): + """Represents the msKds-ProvServerConfiguration object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-mskds-provserverconfiguration + """ + + __object_class__ = "msKds-ProvServerConfiguration" diff --git a/dissect/database/ese/ntds/objects/msmqenterprisesettings.py b/dissect/database/ese/ntds/objects/msmqenterprisesettings.py new file mode 100644 index 0000000..555ba69 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msmqenterprisesettings.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSMQEnterpriseSettings(Top): + """Represents the mSMQEnterpriseSettings object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msmqenterprisesettings + """ + + __object_class__ = "mSMQEnterpriseSettings" diff --git a/dissect/database/ese/ntds/objects/mspki_enterpriseoid.py b/dissect/database/ese/ntds/objects/mspki_enterpriseoid.py new file mode 100644 index 0000000..2a53d4b --- /dev/null +++ b/dissect/database/ese/ntds/objects/mspki_enterpriseoid.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSPKIEnterpriseOID(Top): + """Represents the msPKI-Enterprise-Oid object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-mspki-enterprise-oid + """ + + __object_class__ = "msPKI-Enterprise-Oid" diff --git a/dissect/database/ese/ntds/objects/mspki_privatekeyrecoveryagent.py b/dissect/database/ese/ntds/objects/mspki_privatekeyrecoveryagent.py new file mode 100644 index 0000000..e0c421b --- /dev/null +++ b/dissect/database/ese/ntds/objects/mspki_privatekeyrecoveryagent.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSPKIPrivateKeyRecoveryAgent(Top): + """Represents the msPKI-PrivateKeyRecoveryAgent object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-mspki-privatekeyrecoveryagent + """ + + __object_class__ = "msPKI-PrivateKeyRecoveryAgent" diff --git a/dissect/database/ese/ntds/objects/msspp_activationobjectscontainer.py b/dissect/database/ese/ntds/objects/msspp_activationobjectscontainer.py new file mode 100644 index 0000000..3c6d790 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msspp_activationobjectscontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSSPPActivationObjectsContainer(Top): + """Represents the msSPP-ActivationObjectsContainer object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msspp-activationobjectscontainer + """ + + __object_class__ = "msSPP-ActivationObjectsContainer" diff --git a/dissect/database/ese/ntds/objects/mstpm_informationobjectscontainer.py b/dissect/database/ese/ntds/objects/mstpm_informationobjectscontainer.py new file mode 100644 index 0000000..6437d65 --- /dev/null +++ b/dissect/database/ese/ntds/objects/mstpm_informationobjectscontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class MSTPMInformationObjectsContainer(Top): + """Represents a TPM information objects container in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-mstpm-informationobjectscontainer + """ + + __object_class__ = "msTPM-InformationObjectsContainer" diff --git a/dissect/database/ese/ntds/objects/ntdsconnection.py b/dissect/database/ese/ntds/objects/ntdsconnection.py new file mode 100644 index 0000000..97d3e7e --- /dev/null +++ b/dissect/database/ese/ntds/objects/ntdsconnection.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.leaf import Leaf + + +class NTDSConnection(Leaf): + """Represents an NTDS connection object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ntdsconnection + """ + + __object_class__ = "nTDSConnection" diff --git a/dissect/database/ese/ntds/objects/ntdsdsa.py b/dissect/database/ese/ntds/objects/ntdsdsa.py new file mode 100644 index 0000000..65469e4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ntdsdsa.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.applicationsettings import ApplicationSettings + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class NTDSDSA(ApplicationSettings): + """Represents an NTDS DSA object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ntdsdsa + """ + + __object_class__ = "nTDSDSA" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this NTDS DSA object.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/ntdsservice.py b/dissect/database/ese/ntds/objects/ntdsservice.py new file mode 100644 index 0000000..c515dd4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ntdsservice.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class NTDSService(Top): + """Represents an NTDS service object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ntdsservice + """ + + __object_class__ = "nTDSService" diff --git a/dissect/database/ese/ntds/objects/ntdssitesettings.py b/dissect/database/ese/ntds/objects/ntdssitesettings.py new file mode 100644 index 0000000..7cffca4 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ntdssitesettings.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class NTDSSiteSettings(Top): + """Represents the nTDSSiteSettings object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ntdssitesettings + """ + + __object_class__ = "nTDSSiteSettings" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this NTDS-Site-Settings object.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/ntfrssettings.py b/dissect/database/ese/ntds/objects/ntfrssettings.py new file mode 100644 index 0000000..094b00b --- /dev/null +++ b/dissect/database/ese/ntds/objects/ntfrssettings.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.applicationsettings import ApplicationSettings + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class NTFRSSettings(ApplicationSettings): + """Represents an NTFRS settings object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ntfrssettings + """ + + __object_class__ = "nTFRSSettings" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this NTFRS settings object.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/object.py b/dissect/database/ese/ntds/objects/object.py new file mode 100644 index 0000000..d7c435b --- /dev/null +++ b/dissect/database/ese/ntds/objects/object.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING, Any, ClassVar + +from dissect.database.ese.ntds.util import DN, InstanceType, SystemFlags, decode_value + +if TYPE_CHECKING: + from collections.abc import Iterator + from datetime import datetime + + from dissect.database.ese.ntds.database import Database + from dissect.database.ese.ntds.sd import SecurityDescriptor + from dissect.database.ese.record import Record + + +class Object: + """Base class for all objects in the NTDS database. + + Within NTDS, this would be the "top" class, but we just call it "Object" here for clarity. + + Args: + db: The database instance associated with this object. + record: The :class:`Record` instance representing this object. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-top + - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/041c6068-c710-4c74-968f-3040e4208701 + """ + + __object_class__: str + __known_classes__: ClassVar[dict[str, type[Object]]] = {} + + def __init__(self, db: Database, record: Record): + self.db = db + self.record = record + + def __init_subclass__(cls): + cls.__known_classes__[cls.__object_class__] = cls + + def __repr__(self) -> str: + suffix = self.__repr_suffix__() + return f"<{self.__class__.__name__} {self.__repr_body__()}{' ' + suffix if suffix else ''}>" + + def __repr_body__(self) -> str: + return f"name={self.name!r} objectCategory={self.object_category!r} objectClass={self.object_class}" + + def __repr_suffix__(self) -> str: + suffix = [] + if self.is_deleted: + suffix.append("(deleted)") + if self.is_phantom: + suffix.append("(phantom)") + return " ".join(suffix) + + def __getattr__(self, name: str) -> Any: + return self.get(name) + + @classmethod + def from_record(cls, db: Database, record: Record) -> Object: + """Create an Object instance from a database record. + + Args: + db: The database instance associated with this object. + record: The :class:`Record` instance representing this object. + """ + if (object_classes := _get_attribute(db, record, "objectClass")) is not None and ( + known_cls := cls.__known_classes__.get(object_classes[0]) + ) is not None: + return known_cls(db, record) + + return cls(db, record) + + def get(self, name: str, *, raw: bool = False) -> Any: + """Get an attribute value by name. Decodes the value based on the schema. + + Args: + name: The attribute name to retrieve. + raw: Whether to return the raw value without decoding. + """ + return _get_attribute(self.db, self.record, name, raw=raw) + + def as_dict(self) -> dict[str, Any]: + """Return the object's attributes as a dictionary.""" + result = {} + for key in self.record.as_dict(): + if (schema := self.db.data.schema.lookup_attribute(column=key)) is not None: + key = schema.name + result[key] = _get_attribute(self.db, self.record, key) + return result + + def parent(self) -> Object | None: + """Return the parent object of this object, if any.""" + return self.db.data.get(dnt=self.pdnt) if self.pdnt != 0 else None + + def partition(self) -> Object | None: + """Return the naming context (partition) object of this object, if any.""" + return self.db.data.get(dnt=self.ncdnt) if self.ncdnt is not None else None + + def ancestors(self) -> Iterator[Object]: + for dnt in (self.get("Ancestors") or [])[::-1]: + yield self.db.data.get(dnt=dnt) + + def child(self, name: str) -> Object | None: + """Return a child object by name, if it exists. + + Args: + name: The name of the child object to retrieve. + """ + return self.db.data.child_of(self.dnt, name) + + def children(self) -> Iterator[Object]: + """Yield all child objects of this object.""" + yield from self.db.data.children_of(self.dnt) + + def links(self) -> Iterator[tuple[str, Object]]: + """Yield all objects linked to this object.""" + yield from self.db.link.all_links(self.dnt) + + def backlinks(self) -> Iterator[tuple[str, Object]]: + """Yield all objects that link to this object.""" + yield from self.db.link.all_backlinks(self.dnt) + + # Some commonly used properties, for convenience and type hinting + @property + def dnt(self) -> int: + """Return the object's Directory Number Tag (DNT).""" + return self.get("DNT") + + @property + def pdnt(self) -> int: + """Return the object's Parent Directory Number Tag (PDNT).""" + return self.get("Pdnt") + + @property + def ncdnt(self) -> int | None: + """Return the object's Naming Context Directory Number Tag (NCDNT).""" + return self.get("Ncdnt") + + @property + def name(self) -> str | None: + """Return the object's name.""" + return self.get("name") + + @property + def object_category(self) -> str | None: + """Return the object's objectCategory.""" + return self.get("objectCategory") + + @property + def object_class(self) -> list[str] | None: + """Return the object's objectClass.""" + return self.get("objectClass") + + @property + def sid(self) -> str | None: + """Return the object's Security Identifier (SID).""" + return self.get("objectSid") + + @property + def rid(self) -> int | None: + """Return the object's Relative Identifier (RID).""" + if (sid := self.sid) is not None: + return int(sid.rsplit("-", 1)[-1]) + return None + + @property + def guid(self) -> str | None: + """Return the object's GUID.""" + return self.get("objectGUID") + + @property + def is_deleted(self) -> bool: + """Return whether the object is marked as deleted.""" + return bool(self.get("isDeleted")) + + @property + def is_local(self) -> bool: + """Return whether the object is local to this domain.""" + return self.instance_type is not None and InstanceType.Writable in self.instance_type + + @property + def is_phantom(self) -> bool: + """Return whether the object is a phantom (cross-domain reference).""" + return self.instance_type is not None and InstanceType.Writable not in self.instance_type + + def _assert_local(self) -> None: + """Raise an error if the object is a phantom.""" + if self.is_phantom: + raise ValueError("Operation not supported for phantom (non-local) objects") + + @property + def when_created(self) -> datetime | None: + """Return the object's creation time.""" + return self.get("whenCreated") + + @property + def when_changed(self) -> datetime | None: + """Return the object's last modification time.""" + return self.get("whenChanged") + + @property + def instance_type(self) -> InstanceType | None: + """Return the object's instance type.""" + return self.get("instanceType") + + @property + def system_flags(self) -> SystemFlags | None: + """Return the object's system flags.""" + return self.get("systemFlags") + + @property + def is_head_of_naming_context(self) -> bool: + """Return whether the object is a head of naming context.""" + return self.instance_type is not None and bool(self.instance_type & InstanceType.HeadOfNamingContext) + + @property + def distinguished_name(self) -> DN | None: + """Return the fully qualified Distinguished Name (DN) for this object.""" + return self.get("distinguishedName") + + dn = distinguished_name + + @cached_property + def sd(self) -> SecurityDescriptor | None: + """Return the Security Descriptor for this object.""" + if (sd_id := self.get("nTSecurityDescriptor")) is not None: + return self.db.sd.sd(sd_id) + return None + + @cached_property + def well_known_objects(self) -> list[Object]: + """Return the list of well-known objects.""" + if (wko := self.get("wellKnownObjects")) is not None: + return [self.db.data.get(dnt=dnt) for dnt, _ in wko] + return [] + + @cached_property + def other_well_known_objects(self) -> list[Object]: + """Return the list of other well-known objects.""" + if (owko := self.get("otherWellKnownObjects")) is not None: + return [self.db.data.get(dnt=dnt) for dnt, _ in owko] + return [] + + +def _get_attribute(db: Database, record: Record, name: str, *, raw: bool = False) -> Any: + """Get an attribute value by name. Decodes the value based on the schema. + + Args: + db: The database instance. + record: The :class:`Record` instance representing the object. + name: The attribute name to retrieve. + raw: Whether to return the raw value without decoding. + """ + if (schema := db.data.schema.lookup_attribute(name=name)) is None: + raise AttributeError(f"Attribute not found: {name!r}") + + value = record.get(schema.column) + + if schema.is_single_valued and isinstance(value, list): + # There are a few attributes that have the flag IsSingleValued but are marked as MultiValue in ESE + value = value[0] + + if raw: + return value + + return decode_value(db, name, value) diff --git a/dissect/database/ese/ntds/objects/organizationalperson.py b/dissect/database/ese/ntds/objects/organizationalperson.py new file mode 100644 index 0000000..9aba498 --- /dev/null +++ b/dissect/database/ese/ntds/objects/organizationalperson.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.person import Person + + +class OrganizationalPerson(Person): + """Represents an organizational person object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-organizationalperson + """ + + __object_class__ = "organizationalPerson" + + @property + def city(self) -> str: + """Return the city (l) of this organizational person.""" + return self.get("l") # "l" (localityName) represents the city/locality. diff --git a/dissect/database/ese/ntds/objects/organizationalunit.py b/dissect/database/ese/ntds/objects/organizationalunit.py new file mode 100644 index 0000000..b24c6fd --- /dev/null +++ b/dissect/database/ese/ntds/objects/organizationalunit.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class OrganizationalUnit(Top): + """Represents an organizational unit object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-organizationalunit + """ + + __object_class__ = "organizationalUnit" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this organizational unit.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/person.py b/dissect/database/ese/ntds/objects/person.py new file mode 100644 index 0000000..857c2da --- /dev/null +++ b/dissect/database/ese/ntds/objects/person.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class Person(Top): + """Represents a person object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-person + """ + + __object_class__ = "person" diff --git a/dissect/database/ese/ntds/objects/physicallocation.py b/dissect/database/ese/ntds/objects/physicallocation.py new file mode 100644 index 0000000..568a987 --- /dev/null +++ b/dissect/database/ese/ntds/objects/physicallocation.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.locality import Locality + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class PhysicalLocation(Locality): + """Represents a physical location object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-physicallocation + """ + + __object_class__ = "physicalLocation" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this physical location.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/pkicertificatetemplate.py b/dissect/database/ese/ntds/objects/pkicertificatetemplate.py new file mode 100644 index 0000000..cb5e4e7 --- /dev/null +++ b/dissect/database/ese/ntds/objects/pkicertificatetemplate.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class PKICertificateTemplate(Top): + """Represents a PKI certificate template object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-pkicertificatetemplate + """ + + __object_class__ = "pKICertificateTemplate" diff --git a/dissect/database/ese/ntds/objects/pkienrollmentservice.py b/dissect/database/ese/ntds/objects/pkienrollmentservice.py new file mode 100644 index 0000000..3f6f989 --- /dev/null +++ b/dissect/database/ese/ntds/objects/pkienrollmentservice.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class PKIEnrollmentService(Top): + """Represents the pKIEnrollmentService object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-pkienrollmentservice + """ + + __object_class__ = "pKIEnrollmentService" diff --git a/dissect/database/ese/ntds/objects/querypolicy.py b/dissect/database/ese/ntds/objects/querypolicy.py new file mode 100644 index 0000000..41d43fa --- /dev/null +++ b/dissect/database/ese/ntds/objects/querypolicy.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class QueryPolicy(Top): + """Represents a query policy object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-querypolicy + """ + + __object_class__ = "queryPolicy" diff --git a/dissect/database/ese/ntds/objects/ridmanager.py b/dissect/database/ese/ntds/objects/ridmanager.py new file mode 100644 index 0000000..1554a0f --- /dev/null +++ b/dissect/database/ese/ntds/objects/ridmanager.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class RIDManager(Top): + """Represents the RID Manager object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ridmanager + """ + + __object_class__ = "rIDManager" diff --git a/dissect/database/ese/ntds/objects/ridset.py b/dissect/database/ese/ntds/objects/ridset.py new file mode 100644 index 0000000..4328014 --- /dev/null +++ b/dissect/database/ese/ntds/objects/ridset.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class RIDSet(Top): + """Represents the RID Set object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-ridset + """ + + __object_class__ = "rIDSet" diff --git a/dissect/database/ese/ntds/objects/rpccontainer.py b/dissect/database/ese/ntds/objects/rpccontainer.py new file mode 100644 index 0000000..bfdf2af --- /dev/null +++ b/dissect/database/ese/ntds/objects/rpccontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class RpcContainer(Top): + """The default container for RPC endpoints. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-rpccontainer + """ + + __object_class__ = "rpcContainer" diff --git a/dissect/database/ese/ntds/objects/rrasadministrationdictionary.py b/dissect/database/ese/ntds/objects/rrasadministrationdictionary.py new file mode 100644 index 0000000..f25c2f7 --- /dev/null +++ b/dissect/database/ese/ntds/objects/rrasadministrationdictionary.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class RRASAdministrationDictionary(Top): + """Represents the rRASAdministrationDictionary object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-rrasadministrationdictionary + """ + + __object_class__ = "rRASAdministrationDictionary" diff --git a/dissect/database/ese/ntds/objects/samserver.py b/dissect/database/ese/ntds/objects/samserver.py new file mode 100644 index 0000000..9ef5dae --- /dev/null +++ b/dissect/database/ese/ntds/objects/samserver.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.securityobject import SecurityObject + + +class SamServer(SecurityObject): + """Represents the Sam-Server object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-samserver + """ + + __object_class__ = "samServer" diff --git a/dissect/database/ese/ntds/objects/secret.py b/dissect/database/ese/ntds/objects/secret.py new file mode 100644 index 0000000..99c1d75 --- /dev/null +++ b/dissect/database/ese/ntds/objects/secret.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.leaf import Leaf + + +class Secret(Leaf): + """Represents a secret object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-secret + """ + + __object_class__ = "secret" diff --git a/dissect/database/ese/ntds/objects/securityobject.py b/dissect/database/ese/ntds/objects/securityobject.py new file mode 100644 index 0000000..11d1bee --- /dev/null +++ b/dissect/database/ese/ntds/objects/securityobject.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class SecurityObject(Top): + """Represents a security object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-securityobject + """ + + __object_class__ = "securityObject" diff --git a/dissect/database/ese/ntds/objects/server.py b/dissect/database/ese/ntds/objects/server.py new file mode 100644 index 0000000..f110a73 --- /dev/null +++ b/dissect/database/ese/ntds/objects/server.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class Server(Top): + """Represents a server object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-server + """ + + __object_class__ = "server" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this server.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/serverscontainer.py b/dissect/database/ese/ntds/objects/serverscontainer.py new file mode 100644 index 0000000..05902ce --- /dev/null +++ b/dissect/database/ese/ntds/objects/serverscontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class ServersContainer(Top): + """Represents a servers container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-serverscontainer + """ + + __object_class__ = "serversContainer" diff --git a/dissect/database/ese/ntds/objects/site.py b/dissect/database/ese/ntds/objects/site.py new file mode 100644 index 0000000..b41c61c --- /dev/null +++ b/dissect/database/ese/ntds/objects/site.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects import Object + + +class Site(Top): + """Represents the site object in Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-site + """ + + __object_class__ = "site" + + def managed_by(self) -> Iterator[Object]: + """Return the objects that manage this site.""" + self._assert_local() + + yield from self.db.link.links(self.dnt, "managedBy") diff --git a/dissect/database/ese/ntds/objects/sitelink.py b/dissect/database/ese/ntds/objects/sitelink.py new file mode 100644 index 0000000..eaa215f --- /dev/null +++ b/dissect/database/ese/ntds/objects/sitelink.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class SiteLink(Top): + """Represents a site link object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-sitelink + """ + + __object_class__ = "siteLink" diff --git a/dissect/database/ese/ntds/objects/sitescontainer.py b/dissect/database/ese/ntds/objects/sitescontainer.py new file mode 100644 index 0000000..a874b9b --- /dev/null +++ b/dissect/database/ese/ntds/objects/sitescontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class SitesContainer(Top): + """Represents a sites container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-sitescontainer + """ + + __object_class__ = "sitesContainer" diff --git a/dissect/database/ese/ntds/objects/subnetcontainer.py b/dissect/database/ese/ntds/objects/subnetcontainer.py new file mode 100644 index 0000000..4f933e5 --- /dev/null +++ b/dissect/database/ese/ntds/objects/subnetcontainer.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class SubnetContainer(Top): + """Represents a subnet container object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-subnetcontainer + """ + + __object_class__ = "subnetContainer" diff --git a/dissect/database/ese/ntds/objects/subschema.py b/dissect/database/ese/ntds/objects/subschema.py new file mode 100644 index 0000000..b3b2738 --- /dev/null +++ b/dissect/database/ese/ntds/objects/subschema.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.top import Top + + +class SubSchema(Top): + """Represents a sub-schema object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-subschema + """ + + __object_class__ = "subSchema" diff --git a/dissect/database/ese/ntds/objects/top.py b/dissect/database/ese/ntds/objects/top.py new file mode 100644 index 0000000..f63d3b1 --- /dev/null +++ b/dissect/database/ese/ntds/objects/top.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.object import Object + + +class Top(Object): + """Represents the top object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-top + """ + + __object_class__ = "top" + + def __repr_body__(self) -> str: + return f"name={self.name!r}" diff --git a/dissect/database/ese/ntds/objects/trusteddomain.py b/dissect/database/ese/ntds/objects/trusteddomain.py new file mode 100644 index 0000000..fbe858e --- /dev/null +++ b/dissect/database/ese/ntds/objects/trusteddomain.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dissect.database.ese.ntds.objects.leaf import Leaf + + +class TrustedDomain(Leaf): + """Represents a trusted domain object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-trusteddomain + """ + + __object_class__ = "trustedDomain" diff --git a/dissect/database/ese/ntds/objects/user.py b/dissect/database/ese/ntds/objects/user.py new file mode 100644 index 0000000..150c748 --- /dev/null +++ b/dissect/database/ese/ntds/objects/user.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.objects.organizationalperson import OrganizationalPerson +from dissect.database.ese.ntds.util import UserAccountControl + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.objects.group import Group + from dissect.database.ese.ntds.objects.object import Object + + +class User(OrganizationalPerson): + """Represents a user object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-user + """ + + __object_class__ = "user" + + def __repr_body__(self) -> str: + return f"name={self.name!r} sam_account_name={self.sam_account_name!r} is_machine_account={self.is_machine_account()}" # noqa: E501 + + @property + def sam_account_name(self) -> str: + """Return the user's sAMAccountName.""" + return self.get("sAMAccountName") + + @property + def primary_group_id(self) -> str | None: + """Return the user's primaryGroupID.""" + return self.get("primaryGroupID") + + @property + def user_account_control(self) -> UserAccountControl: + """Return the user's userAccountControl flags.""" + return self.get("userAccountControl") + + def is_machine_account(self) -> bool: + """Return whether this user is a machine account.""" + return UserAccountControl.WORKSTATION_TRUST_ACCOUNT in self.user_account_control + + def groups(self) -> Iterator[Group]: + """Yield all groups this user is a member of.""" + self._assert_local() + + yield from self.db.link.backlinks(self.dnt, "memberOf") + + # We also need to include the group with primaryGroupID matching the user's primaryGroupID + if self.primary_group_id is not None: + yield from self.db.data.search(objectSid=f"{self.sid.rsplit('-', 1)[0]}-{self.primary_group_id}") + + def is_member_of(self, group: Group) -> bool: + """Return whether the user is a member of the given group. + + Args: + group: The :class:`Group` to check membership for. + """ + return any(g.dnt == group.dnt for g in self.groups()) + + def managed_objects(self) -> Iterator[Object]: + """Yield all objects managed by this user.""" + self._assert_local() + + yield from self.db.link.backlinks(self.dnt, "managedObjects") diff --git a/dissect/database/ese/ntds/pek.py b/dissect/database/ese/ntds/pek.py new file mode 100644 index 0000000..98f5bf1 --- /dev/null +++ b/dissect/database/ese/ntds/pek.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import hashlib +from functools import cached_property +from uuid import UUID + +from dissect.database.ese.ntds.c_pek import c_pek + +try: + from Crypto.Cipher import AES, ARC4 + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + + +AUTHENTICATOR = UUID("4881d956-91ec-11d1-905a-00c04fc2d4cf") + + +class PEK: + """Password Encryption Key (PEK) handler. + + Args: + pek: The raw PEK blob from the NTDS database. + """ + + def __init__(self, pek: bytes): + self.pek = pek + self.encrypted = c_pek.ENCRYPTED_PEK_LIST(pek) + self.decrypted = None + + @property + def version(self) -> int: + """PEK version.""" + return self.encrypted.Version + + @property + def unlocked(self) -> bool: + """Indicates whether the PEK has been unlocked.""" + return self.decrypted is not None + + @cached_property + def keys(self) -> dict[int, bytes]: + """Dictionary of PEK keys by their key ID.""" + if not self.unlocked: + raise RuntimeError("PEK is not unlocked") + + return {pek.KeyId: pek.Key for pek in self.decrypted.PekArray} + + def unlock(self, key: bytes) -> None: + """Unlock the PEK list using the provided "syskey". + + Args: + key: The syskey of the domain controller. + """ + if not HAS_CRYPTO: + raise RuntimeError("Missing pycryptodome dependency") + + if self.unlocked: + return + + if self.version == c_pek.PEK_PRE_2012R2_VERSION: + decrypted = _rc4_decrypt(self.encrypted.EncryptedData, key, self.encrypted.Salt, 1000) + + elif self.version == c_pek.PEK_2016_TP4_VERSION: + decrypted = _aes_decrypt(self.encrypted.EncryptedData, key, self.encrypted.Salt) + + else: + raise NotImplementedError(f"Unsupported PEK version: {self.version}") + + if decrypted[:16] != AUTHENTICATOR.bytes_le: + raise ValueError("Invalid PEK authenticator after unlocking") + + self.decrypted = c_pek.CLEAR_PEK_LIST(decrypted) + + def decrypt(self, data: bytes) -> bytes: + """Decrypt data using the PEK list. + + Args: + data: The encrypted data blob. + """ + if not self.unlocked: + raise RuntimeError("PEK is not unlocked") + + encrypted_data = c_pek.ENCRYPTED_DATA(data) + if (key := self.keys.get(encrypted_data.KeyId)) is None: + raise KeyError(f"PEK key ID {encrypted_data.KeyId} not found") + + if encrypted_data.AlgorithmId == c_pek.PEK_ENCRYPTION: + return _rc4_decrypt(encrypted_data.EncryptedData, key, None, 0) + + if encrypted_data.AlgorithmId == c_pek.PEK_ENCRYPTION_WITH_SALT: + encrypted_data = c_pek.ENCRYPTED_DATA_WITH_SALT(data) + return _rc4_decrypt(encrypted_data.EncryptedData, key, encrypted_data.Salt, 1) + + if encrypted_data.AlgorithmId == c_pek.PEK_ENCRYPTION_WITH_AES: + encrypted_data_aes = c_pek.ENCRYPTED_DATA_WITH_AES(data) + return _aes_decrypt(encrypted_data_aes.EncryptedData, key, encrypted_data_aes.IV)[ + : encrypted_data_aes.Length + ] + + raise NotImplementedError(f"Unsupported PEK encryption algorithm: {encrypted_data.AlgorithmId}") + + +def _rc4_decrypt(data: bytes, key: bytes, salt: bytes | None, iterations: int) -> bytes: + """Decrypt data using RC4. + + Args: + data: Encrypted data. + key: RC4 encryption key. + salt: Optional salt to use in key derivation. + iterations: Number of hash iterations to perform if salt is provided. + """ + ctx = hashlib.md5(key) + if salt is not None: + for _ in range(iterations): + ctx.update(salt) + + cipher = ARC4.new(ctx.digest()) + return cipher.decrypt(data) + + +def _aes_decrypt(data: bytes, key: bytes, iv: bytes) -> bytes: + """Decrypt data using AES-CBC. + + Args: + data: Encrypted data. + key: AES encryption key. + iv: Initialization vector. + """ + cipher = AES.new(key, AES.MODE_CBC, iv) + if (align := -len(data) % 16) != 0: + data += b"\x00" * align + return cipher.decrypt(data) diff --git a/dissect/database/ese/ntds/query.py b/dissect/database/ese/ntds/query.py new file mode 100644 index 0000000..0d93f3c --- /dev/null +++ b/dissect/database/ese/ntds/query.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from dissect.util.ldap import LogicalOperator, SearchFilter + +from dissect.database.ese.ntds.util import encode_value + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.index import Index + from dissect.database.ese.ntds.database import Database + from dissect.database.ese.ntds.objects import Object + from dissect.database.ese.record import Record + + +log = logging.getLogger(__name__) + + +class Query: + def __init__(self, db: Database, query: str, *, optimize: bool = True) -> None: + self.db = db + self.query = query + self._filter = SearchFilter.parse(query, optimize=optimize) + + def process(self) -> Iterator[Object]: + """Process the LDAP query against the NTDS database. + + Yields: + Matching records from the NTDS database. + """ + yield from self._process_query(self._filter) + + def _process_query(self, filter: SearchFilter, records: list[Record] | None = None) -> Iterator[Record]: + """Process LDAP query recursively, handling nested logical operations. + + Args: + filter: The LDAP search filter to process. + records: Optional list of records to filter instead of querying the database. + + Yields: + Records matching the search filter. + """ + if not filter.is_nested(): + if records is None: + try: + yield from self._query_database(filter) + except IndexError: + log.debug("No records found for filter: %s", filter) + else: + yield from self._filter_records(filter, records) + return + + if filter.operator == LogicalOperator.AND: + yield from self._process_and_operation(filter, records) + elif filter.operator == LogicalOperator.OR: + yield from self._process_or_operation(filter, records) + + def _query_database(self, filter: SearchFilter) -> Iterator[Record]: + """Execute a simple LDAP filter against the database. + + Args: + filter: The LDAP search filter to execute. + + Yields: + Records matching the filter. + """ + # Validate attribute exists and get column mapping + if (schema := self.db.data.schema.lookup_attribute(name=filter.attribute)) is None: + raise ValueError(f"Attribute {filter.attribute!r} not found in the NTDS database") + + # Get the database index for this attribute + if (index := self.db.data.table.find_index([schema.column])) is None: + raise ValueError(f"Index for attribute {schema.column!r} not found in the NTDS database") + + if "*" in filter.value: + # Handle wildcard searches differently + if filter.value.endswith("*"): + yield from _process_wildcard_tail(index, filter.value) + else: + raise NotImplementedError("Wildcards in the middle or start of the value are not yet supported") + else: + # Exact match query + encoded_value = encode_value(self.db, filter.attribute, filter.value) + yield from index.cursor().find_all(**{schema.column: encoded_value}) + + def _process_and_operation(self, filter: SearchFilter, records: list[Record] | None) -> Iterator[Record]: + """Process AND logical operation. + + Args: + filter: The LDAP search filter with AND operator. + records: Optional list of records to filter. + + Yields: + Records matching all conditions in the AND operation. + """ + if records is not None: + records_to_process = records + children_to_check = filter.children + else: + # Use the first child as base query, then filter with remaining children + base_query, *remaining_children = filter.children + records_to_process = list(self._process_query(base_query)) + children_to_check = remaining_children + + for record in records_to_process: + if all(any(self._process_query(child, records=[record])) for child in children_to_check): + yield record + + def _process_or_operation(self, filter: SearchFilter, records: list[Record] | None) -> Iterator[Record]: + """Process OR logical operation. + + Args: + filter: The LDAP search filter with OR operator. + records: Optional list of records to filter. + + Yields: + Records matching any condition in the OR operation. + """ + for child in filter.children: + yield from self._process_query(child, records=records) + + def _filter_records(self, filter: SearchFilter, records: list[Record]) -> Iterator[Record]: + """Filter a list of records against a simple LDAP filter. + + Args: + filter: The LDAP search filter to apply. + records: The list of records to filter. + + Yields: + Records that match the filter criteria. + """ + encoded_value = encode_value(self.db, filter.attribute, filter.value) + schema = self.db.data.schema.lookup_attribute(name=filter.attribute) + + if schema is None: + return + + has_wildcard = "*" in filter.value + wildcard_prefix = filter.value.replace("*", "").lower() if has_wildcard else None + + for record in records: + record_value = record.get(schema.column) + + if _value_matches_filter(record_value, encoded_value, has_wildcard, wildcard_prefix): + yield record + + +def _process_wildcard_tail(index: Index, filter_value: str) -> Iterator[Record]: + """Handle wildcard queries using range searches. + + Args: + index: The database index to search. + filter_value: The filter value containing wildcards. + + Yields: + Records matching the wildcard pattern. + """ + cursor = index.cursor() + + # Create search bounds + value = filter_value.replace("*", "") + end = cursor.seek([_increment_last_char(value)]).record() + + # Seek back to the start + cursor.reset() + cursor.seek([value]) + + # Yield all records in range + record = cursor.record() + while record is not None and record != end: + yield record + record = cursor.next() + + +def _value_matches_filter( + record_value: Any, encoded_value: Any, has_wildcard: bool, wildcard_prefix: str | None +) -> bool: + """Return whether a record value matches the filter criteria. + + Args: + record_value: The value from the database record. + encoded_value: The encoded filter value to match against. + has_wildcard: Whether the filter contains wildcard characters. + wildcard_prefix: The prefix to match for wildcard searches. + """ + if isinstance(record_value, list): + return encoded_value in record_value + + if has_wildcard and wildcard_prefix and isinstance(record_value, str): + return record_value.lower().startswith(wildcard_prefix) + + return encoded_value == record_value + + +def _increment_last_char(value: str) -> str: + """Increment the last character in a string to find the next lexicographically sortable key. + + Used for binary tree searching to find the upper bound of a range search. + + Args: + value: The string to increment. + + Returns: + A new string with the last character incremented. + """ + characters = list(value) + i = len(characters) - 1 + + while i >= 0: + if characters[i] != "z" and characters[i] != "Z": + characters[i] = chr(ord(characters[i]) + 1) + return "".join(characters[: i + 1]) + i -= 1 + + return value + "a" diff --git a/dissect/database/ese/ntds/schema.py b/dissect/database/ese/ntds/schema.py new file mode 100644 index 0000000..7fe2d73 --- /dev/null +++ b/dissect/database/ese/ntds/schema.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NamedTuple + +from dissect.database.ese.ntds.objects.object import Object +from dissect.database.ese.ntds.util import OID_TO_TYPE, attrtyp_to_oid + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dissect.database.ese.ntds.database import Database + from dissect.database.ese.ntds.util import SearchFlags + +# These are fixed columns in the NTDS database +# They do not exist in the schema, but are required for basic operation +BOOTSTRAP_COLUMNS = [ + # (lDAPDisplayName, column_name, attributeSyntax) + ("DNT", "DNT_col", 0x00080009), + ("Pdnt", "PDNT_col", 0x00080009), + ("Obj", "OBJ_col", 0x00080008), + ("RdnType", "RDNtyp_col", 0x00080002), + ("CNT", "cnt_col", 0x00080009), + ("AB_cnt", "ab_cnt_col", 0x00080009), + ("Time", "time_col", 0x0008000B), + ("Ncdnt", "NCDNT_col", 0x00080009), + ("RecycleTime", "recycle_time_col", 0x0008000B), + ("Ancestors", "Ancestors_col", 0x0008000A), + ("IsVisibleInAB", "IsVisibleInAB", 0x00080009), # TODO: Confirm syntax + what is this? +] + +# These are required for bootstrapping the schema +# Most of these will be overwritten when the schema is loaded from the database +BOOTSTRAP_ATTRIBUTES = [ + # (lDAPDisplayName, attributeID, attributeSyntax, isSingleValued) + # Essential attributes + ("objectClass", 0, 0x00080002, False), # ATTc0 + ("cn", 3, 0x0008000C, True), # ATTm3 + ("isDeleted", 131120, 0x00080008, True), # ATTi131120 + ("instanceType", 131073, 0x00080009, True), # ATTj131073 + ("name", 589825, 0x0008000C, True), # ATTm589825 + # Common schema + ("lDAPDisplayName", 131532, 0x0008000C, True), # ATTm131532 + # Attribute schema + ("attributeID", 131102, 0x00080002, True), # ATTc131102 + ("attributeSyntax", 131104, 0x00080002, True), # ATTc131104 + ("oMSyntax", 131303, 0x00080009, True), # ATTj131303 + ("oMObjectClass", 131290, 0x0008000A, True), # ATTk131290 + ("isSingleValued", 131105, 0x00080008, True), # ATTi131105 + ("linkId", 131122, 0x00080009, True), # ATTj131122 + ("searchFlags", 131406, 0x00080009, True), # ATTj131406 + # Class schema + ("governsID", 131094, 0x00080002, True), # ATTc131094 +] + +# For convenience, bootstrap some common object classes +# These will also be overwritten when the schema is loaded from the database +BOOTSTRAP_OBJECT_CLASSES = { + "top": 0x00010000, + "classSchema": 0x0003000D, + "attributeSchema": 0x0003000E, +} + + +class ClassEntry(NamedTuple): + dnt: int + oid: str + id: int + name: str + + +class AttributeEntry(NamedTuple): + dnt: int + oid: str + id: int + name: str + column: str + type: str + om_syntax: int | None + om_object_class: bytes | None + is_single_valued: bool + link_id: int | None + search_flags: SearchFlags | None + + +class Schema: + """An index for schema entries providing fast lookups by various keys. + + Provides efficient lookups for schema entries by DNT, OID, ATTRTYP, LDAP display name, and column name. + """ + + def __init__(self): + # Combined indices + self._dnt_index: dict[int, ClassEntry | AttributeEntry] = {} + self._oid_index: dict[str, ClassEntry | AttributeEntry] = {} + self._attrtyp_index: dict[int, ClassEntry | AttributeEntry] = {} + + # Attribute specific indices + self._attribute_id_index: dict[int, AttributeEntry] = {} + self._attribute_name_index: dict[str, AttributeEntry] = {} + self._attribute_link_index: dict[int, AttributeEntry] = {} + self._attribute_column_index: dict[str, AttributeEntry] = {} + + # Class specific indices + self._class_id_index: dict[int, ClassEntry] = {} + self._class_name_index: dict[str, ClassEntry] = {} + + # Bootstrap fixed database columns (these do not exist in the schema) + for ldap_name, column_name, syntax in BOOTSTRAP_COLUMNS: + self._add( + AttributeEntry( + dnt=-1, + oid="", + id=-1, + name=ldap_name, + column=column_name, + type=attrtyp_to_oid(syntax), + om_syntax=None, + om_object_class=None, + is_single_valued=True, + link_id=None, + search_flags=None, + ) + ) + + # Bootstrap initial attributes + for name, id, attribute_syntax, is_single_valued in BOOTSTRAP_ATTRIBUTES: + self._add_attribute( + dnt=-1, + id=id, + name=name, + syntax=attribute_syntax, + om_syntax=None, + om_object_class=None, + is_single_valued=is_single_valued, + link_id=None, + search_flags=None, + ) + + # Bootstrap initial object classes + for name, id in BOOTSTRAP_OBJECT_CLASSES.items(): + self._add_class( + dnt=-1, + id=id, + name=name, + ) + + def load(self, db: Database) -> None: + """Load the classes and attributes from the database into the schema index. + + Args: + db: The database instance to load the schema from. + """ + + def _iter(id: int) -> Iterator[Object]: + # Use the ATTc0 (objectClass) index to iterate over all objects of the given objectClass + # TODO: In the future, maybe use `DataTable._get_index`, but that's not fully implemented yet + cursor = db.data.table.index("INDEX_00000000").cursor() + end = cursor.seek([id + 1]).record() + + cursor.reset() + cursor.seek([id]) + + record = cursor.record() + while record is not None and record != end: + yield Object.from_record(db, record) + record = cursor.next() + + # We bootstrapped these earlier + attribute_schema = self.lookup_class(name="attributeSchema") + class_schema = self.lookup_class(name="classSchema") + + for obj in _iter(attribute_schema.id): + self._add_attribute( + dnt=obj.dnt, + id=obj.get("attributeID", raw=True), + name=obj.get("lDAPDisplayName"), + syntax=obj.get("attributeSyntax", raw=True), + om_syntax=obj.get("oMSyntax"), + om_object_class=obj.get("oMObjectClass"), + is_single_valued=obj.get("isSingleValued"), + link_id=obj.get("linkId"), + search_flags=obj.get("searchFlags"), + ) + + for obj in _iter(class_schema.id): + self._add_class( + dnt=obj.dnt, + id=obj.get("governsID", raw=True), + name=obj.get("lDAPDisplayName"), + ) + + def _add_class(self, dnt: int, id: int, name: str) -> None: + entry = ClassEntry( + dnt=dnt, + oid=attrtyp_to_oid(id), + id=id, + name=name, + ) + self._add(entry) + + def _add_attribute( + self, + dnt: int, + id: int, + name: str, + syntax: int, + om_syntax: int | None, + om_object_class: bytes | None, + is_single_valued: bool, + link_id: int | None, + search_flags: SearchFlags | None, + ) -> None: + type_oid = attrtyp_to_oid(syntax) + entry = AttributeEntry( + dnt=dnt, + oid=attrtyp_to_oid(id), + id=id, + name=name, + column=f"ATT{OID_TO_TYPE[type_oid]}{id}", + type=type_oid, + om_syntax=om_syntax, + om_object_class=om_object_class, + is_single_valued=is_single_valued, + link_id=link_id, + search_flags=search_flags, + ) + self._add(entry) + + def _add(self, entry: ClassEntry | AttributeEntry) -> None: + if entry.dnt != -1: + self._dnt_index[entry.dnt] = entry + if entry.oid != "": + self._oid_index[entry.oid] = entry + if entry.id != -1: + self._attrtyp_index[entry.id] = entry + + if isinstance(entry, ClassEntry): + self._class_name_index[entry.name.lower()] = entry + + if entry.id != -1: + self._class_id_index[entry.id] = entry + + if isinstance(entry, AttributeEntry): + self._attribute_name_index[entry.name.lower()] = entry + self._attribute_column_index[entry.column] = entry + + if entry.id != -1: + self._attribute_id_index[entry.id] = entry + if entry.link_id is not None: + self._attribute_link_index[entry.link_id] = entry + + def lookup_attribute( + self, + *, + id: int | None = None, + name: str | None = None, + link_id: int | None = None, + column: str | None = None, + ) -> AttributeEntry | None: + """Lookup an attribute schema entry by an indexed field. + + Args: + id: The attribute ID to look up. + name: The LDAP display name to look up. + link_id: The link ID to look up. + column: The column name to look up. + + Returns: + The matching attribute schema entry or ``None`` if not found. + """ + if sum(key is not None for key in [id, name, link_id, column]) != 1: + raise ValueError("Exactly one lookup key must be provided") + + if id is not None: + return self._attribute_id_index.get(id) + + if name is not None: + return self._attribute_name_index.get(name.lower()) + + if link_id is not None: + return self._attribute_link_index.get(link_id) + + if column is not None: + return self._attribute_column_index.get(column) + + return None + + def lookup_class( + self, + *, + id: int | None = None, + name: str | None = None, + ) -> ClassEntry | None: + """Lookup a class schema entry by an indexed field. + + Args: + id: The class ID to look up. + name: The LDAP display name to look up. + + Returns: + The matching class schema entry or ``None`` if not found. + """ + if sum(key is not None for key in [id, name]) != 1: + raise ValueError("Exactly one lookup key must be provided") + + if id is not None: + return self._class_id_index.get(id) + + if name is not None: + return self._class_name_index.get(name.lower()) + + return None + + def lookup( + self, + *, + dnt: int | None = None, + oid: str | None = None, + attrtyp: int | None = None, + name: str | None = None, + ) -> ClassEntry | AttributeEntry | None: + """Lookup a schema entry by an indexed field. + + Args: + dnt: The DNT (Distinguished Name Tag) of the schema entry to look up. + oid: The OID (Object Identifier) of the schema entry to look up. + attrtyp: The ATTRTYP (attribute type) of the schema entry to look up. + name: The LDAP display name of the schema entry to look up. + + Returns: + The matching schema entry or ``None`` if not found. + """ + # Ensure exactly one lookup key is provided + if sum(key is not None for key in [dnt, oid, attrtyp, name]) != 1: + raise ValueError("Exactly one lookup key must be provided") + + if dnt is not None: + return self._dnt_index.get(dnt) + + if oid is not None: + return self._oid_index.get(oid) + + if attrtyp is not None: + return self._attrtyp_index.get(attrtyp) + + if name is not None: + name = name.lower() + return self._class_name_index.get(name) or self._attribute_name_index.get(name) + + return None diff --git a/dissect/database/ese/ntds/sd.py b/dissect/database/ese/ntds/sd.py new file mode 100644 index 0000000..8ac08ae --- /dev/null +++ b/dissect/database/ese/ntds/sd.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import io +from typing import BinaryIO +from uuid import UUID + +from dissect.util.sid import read_sid + +from dissect.database.ese.ntds.c_sd import c_sd + +ACE_TYPE = c_sd.ACE_TYPE +ACE_FLAGS = c_sd.ACE_FLAGS +ACE_OBJECT_FLAGS = c_sd.ACE_OBJECT_FLAGS +ACCESS_MASK = c_sd.ACCESS_MASK +COMPOUND_ACE_TYPE = c_sd.COMPOUND_ACE_TYPE + + +class SecurityDescriptor: + """Parse a security descriptor from a file-like object. + + Args: + fh: The file-like object to parse a security descriptor from. + """ + + def __init__(self, fh: BinaryIO): + offset = fh.tell() + self.header = c_sd._SECURITY_DESCRIPTOR_RELATIVE(fh) + + self.owner = None + self.group = None + self.sacl = None + self.dacl = None + + if self.header.Owner: + fh.seek(offset + self.header.Owner) + self.owner = read_sid(fh) + + if self.header.Group: + fh.seek(offset + self.header.Group) + self.group = read_sid(fh) + + if self.header.Sacl: + fh.seek(offset + self.header.Sacl) + self.sacl = ACL(fh) + + if self.header.Dacl: + fh.seek(offset + self.header.Dacl) + self.dacl = ACL(fh) + + def __repr__(self) -> str: + return f"" + + +class ACL: + """Parse an ACL from a file-like object. + + Args: + fh: The file-like object to parse an ACL from. + """ + + def __init__(self, fh: BinaryIO): + self.header = c_sd._ACL(fh) + self.ace = [ACE(fh) for _ in range(self.header.AceCount)] + + def __repr__(self) -> str: + return f"" + + @property + def revision(self) -> int: + """Return the ACL revision.""" + return self.header.AclRevision + + @property + def size(self) -> int: + """Return the ACL size.""" + return self.header.AclSize + + +class ACE: + """Parse an ACE from a file-like object. + + Args: + fh: The file-like object to parse an ACE from. + """ + + def __init__(self, fh: BinaryIO): + self.header = c_sd._ACE_HEADER(fh) + self.data = fh.read(self.header.AceSize - len(c_sd._ACE_HEADER)) + + self.mask: ACCESS_MASK | None = None + self.sid: str | None = None + + self.object_flags: ACE_OBJECT_FLAGS | None = None + self.object_type: UUID | None = None + self.inherited_object_type: UUID | None = None + + self.compound_type: COMPOUND_ACE_TYPE | None = None + self.server_sid: str | None = None + + buf = io.BytesIO(self.data) + if self.is_standard_ace: + self.mask = ACCESS_MASK(buf) + self.sid = read_sid(buf) + + elif self.is_compound_ace: + self.mask = ACCESS_MASK(buf) + self.compound_type = COMPOUND_ACE_TYPE(buf) + c_sd.USHORT(buf) # Reserved + self.server_sid = read_sid(buf) + self.sid = read_sid(buf) + + elif self.is_object_ace: + self.mask = ACCESS_MASK(buf) + self.object_flags = ACE_OBJECT_FLAGS(buf) + + if self.object_flags & ACE_OBJECT_FLAGS.ACE_OBJECT_TYPE_PRESENT: + self.object_type = UUID(bytes_le=buf.read(16)) + if self.object_flags & ACE_OBJECT_FLAGS.ACE_INHERITED_OBJECT_TYPE_PRESENT: + self.inherited_object_type = UUID(bytes_le=buf.read(16)) + + self.sid = read_sid(buf) + + self.application_data = buf.read() or None + + def __repr__(self) -> str: + if self.is_standard_ace: + return f"<{self.type.name} mask={self.mask} sid={self.sid}>" + if self.is_compound_ace: + return ( + f"<{self.type.name} mask={self.mask} type={self.compound_type.name}" + f" server_sid={self.server_sid} client_sid={self.sid}>" + ) + if self.is_object_ace: + return ( + f"<{self.type.name} mask={self.mask} object_type={self.object_type}" + f" inherited_object_type={self.inherited_object_type} sid={self.sid}>" + ) + return f"" + + @property + def type(self) -> ACE_TYPE: + """Return the ACE type.""" + return self.header.AceType + + @property + def flags(self) -> ACE_FLAGS: + """Return the ACE flags.""" + return self.header.AceFlags + + @property + def size(self) -> int: + """Return the ACE size.""" + return self.header.AceSize + + @property + def is_standard_ace(self) -> bool: + """Return whether this ACE is a standard ACE.""" + return self.header.AceType in ( + ACE_TYPE.ACCESS_ALLOWED, + ACE_TYPE.ACCESS_DENIED, + ACE_TYPE.SYSTEM_AUDIT, + ACE_TYPE.SYSTEM_ALARM, + ACE_TYPE.ACCESS_ALLOWED_CALLBACK, + ACE_TYPE.ACCESS_DENIED_CALLBACK, + ACE_TYPE.SYSTEM_AUDIT_CALLBACK, + ACE_TYPE.SYSTEM_ALARM_CALLBACK, + ACE_TYPE.SYSTEM_MANDATORY_LABEL, + ACE_TYPE.SYSTEM_RESOURCE_ATTRIBUTE, + ACE_TYPE.SYSTEM_SCOPED_POLICY_ID, + ACE_TYPE.SYSTEM_PROCESS_TRUST_LABEL, + ACE_TYPE.SYSTEM_ACCESS_FILTER, + ) + + @property + def is_compound_ace(self) -> bool: + """Return whether this ACE is a compound ACE.""" + return self.header.AceType in (ACE_TYPE.ACCESS_ALLOWED_COMPOUND,) + + @property + def is_object_ace(self) -> bool: + """Return whether this ACE is an object ACE.""" + return self.header.AceType in ( + ACE_TYPE.ACCESS_ALLOWED_OBJECT, + ACE_TYPE.ACCESS_DENIED_OBJECT, + ACE_TYPE.SYSTEM_AUDIT_OBJECT, + ACE_TYPE.SYSTEM_ALARM_OBJECT, + ACE_TYPE.ACCESS_ALLOWED_CALLBACK_OBJECT, + ACE_TYPE.ACCESS_DENIED_CALLBACK_OBJECT, + ACE_TYPE.SYSTEM_AUDIT_CALLBACK_OBJECT, + ACE_TYPE.SYSTEM_ALARM_CALLBACK_OBJECT, + ) diff --git a/dissect/database/ese/ntds/util.py b/dissect/database/ese/ntds/util.py new file mode 100644 index 0000000..db0b22d --- /dev/null +++ b/dissect/database/ese/ntds/util.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import struct +from enum import IntFlag +from typing import TYPE_CHECKING, Any +from uuid import UUID + +from dissect.util.sid import read_sid, write_sid +from dissect.util.ts import wintimestamp + +from dissect.database.ese.ntds.c_ds import c_ds + +if TYPE_CHECKING: + from collections.abc import Callable + + from dissect.database.ese.ntds.database import Database + from dissect.database.ese.ntds.objects import Object + + +# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/7cda533e-d7a4-4aec-a517-91d02ff4a1aa +OID_TO_TYPE = { + "2.5.5.1": "b", # DN + "2.5.5.2": "c", # OID + "2.5.5.3": "d", # CaseExactString + "2.5.5.4": "e", # CaseIgnoreString + "2.5.5.5": "f", # IA5String + "2.5.5.6": "g", # NumericString + "2.5.5.7": "h", # DNWithBinary + "2.5.5.8": "i", # Boolean + "2.5.5.9": "j", # Integer + "2.5.5.10": "k", # OctetString + "2.5.5.11": "l", # GeneralizedTime + "2.5.5.12": "m", # UnicodesString + "2.5.5.13": "n", # PresentationAddress + "2.5.5.14": "o", # DNWithString + "2.5.5.15": "p", # NTSecurityDescriptor + "2.5.5.16": "q", # LargeInteger + "2.5.5.17": "r", # Sid +} + + +OID_PREFIX = { + 0x00000000: "2.5.4", + 0x00010000: "2.5.6", + 0x00020000: "1.2.840.113556.1.2", + 0x00030000: "1.2.840.113556.1.3", + 0x00080000: "2.5.5", + 0x00090000: "1.2.840.113556.1.4", + 0x000A0000: "1.2.840.113556.1.5", + 0x00140000: "2.16.840.1.113730.3", + 0x00150000: "0.9.2342.19200300.100.1", + 0x00160000: "2.16.840.1.113730.3.1", + 0x00170000: "1.2.840.113556.1.5.7000", + 0x00180000: "2.5.21", + 0x00190000: "2.5.18", + 0x001A0000: "2.5.20", + 0x001B0000: "1.3.6.1.4.1.1466.101.119", + 0x001C0000: "2.16.840.1.113730.3.2", + 0x001D0000: "1.3.6.1.4.1.250.1", + 0x001E0000: "1.2.840.113549.1.9", + 0x001F0000: "0.9.2342.19200300.100.4", + 0x00200000: "1.2.840.113556.1.6.23", + 0x00210000: "1.2.840.113556.1.6.18.1", + 0x00220000: "1.2.840.113556.1.6.18.2", + 0x00230000: "1.2.840.113556.1.6.13.3", + 0x00240000: "1.2.840.113556.1.6.13.4", + 0x00250000: "1.3.6.1.1.1.1", + 0x00260000: "1.3.6.1.1.1.2", + 0x46080000: "1.2.840.113556.1.8000.2554", # commonly used for custom attributes +} + + +def attrtyp_to_oid(value: int) -> str: + """Return the OID from an ATTRTYP 32-bit integer value. + + Example for attribute ``printShareName``:: + + ATTRTYP: 590094 (hex: 0x9010e) -> 1.2.840.113556.1.4.270 + + Args: + value: The ATTRTYP 32-bit integer value to convert. + + Returns: + The OID string representation. + """ + return f"{OID_PREFIX[value & 0xFFFF0000]:s}.{value & 0x0000FFFF:d}" + + +# https://learn.microsoft.com/en-us/windows/win32/adschema/a-instancetype +class InstanceType(IntFlag): + HeadOfNamingContext = 0x00000001 + ReplicaNotInstantiated = 0x00000002 + Writable = 0x00000004 + ParentNamingContextHeld = 0x00000008 + NamingContextUnderConstruction = 0x00000010 + NamingContextDeleting = 0x00000020 + + +# https://learn.microsoft.com/en-us/windows/win32/adschema/a-useraccountcontrol +class SystemFlags(IntFlag): + NotReplicated = 0x00000001 + ReplicatedToGlobalCatalog = 0x00000002 + Constructed = 0x00000004 + BaseSchema = 0x00000010 + DeletedImmediately = 0x02000000 + CannotBeMoved = 0x04000000 + CannotBeRenamed = 0x08000000 + ConfigurationCanBeMovedWithRestrictions = 0x10000000 + ConfigurationCanBeMoved = 0x20000000 + ConfigurationCanBeRenamedWithRestrictions = 0x40000000 + CannotBeDeleted = 0x80000000 + + +# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/dd302fd1-0aa7-406b-ad91-2a6b35738557 +class UserAccountControl(IntFlag): + SCRIPT = 0x00000001 + ACCOUNTDISABLE = 0x00000002 + HOMEDIR_REQUIRED = 0x00000008 + LOCKOUT = 0x00000010 + PASSWD_NOTREQD = 0x00000020 + PASSWD_CANT_CHANGE = 0x00000040 + ENCRYPTED_TEXT_PASSWORD_ALLOWED = 0x00000080 + TEMP_DUPLICATE_ACCOUNT = 0x00000100 + NORMAL_ACCOUNT = 0x00000200 + INTERDOMAIN_TRUST_ACCOUNT = 0x00000800 + WORKSTATION_TRUST_ACCOUNT = 0x00001000 + SERVER_TRUST_ACCOUNT = 0x00002000 + DONT_EXPIRE_PASSWORD = 0x00010000 + MNS_LOGON_ACCOUNT = 0x00020000 + SMARTCARD_REQUIRED = 0x00040000 + TRUSTED_FOR_DELEGATION = 0x00080000 + NOT_DELEGATED = 0x00100000 + USE_DES_KEY_ONLY = 0x00200000 + DONT_REQUIRE_PREAUTH = 0x00400000 + PASSWORD_EXPIRED = 0x00800000 + TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION = 0x01000000 + + +class SearchFlags(IntFlag): + Indexed = 0x00000001 + ContainerIndexed = 0x00000002 + Anr = 0x00000004 + PreserveTombstone = 0x00000008 + CopyWithObject = 0x00000010 + TupleIndexed = 0x00000020 + VlvIndexed = 0x00000040 + Confidential = 0x00000080 + + +def _pek_decrypt(db: Database, value: bytes) -> bytes: + """Decrypt a PEK-encrypted blob using the database's PEK, if it's unlocked. + + Args: + db: The associated NTDS database instance. + value: The PEK-encrypted data blob. + + Returns: + The decrypted data blob, or the original value if the PEK is locked. + """ + if db.data.pek is None or not db.data.pek.unlocked: + return value + + return db.data.pek.decrypt(value) + + +def _decode_supplemental_credentials(db: Database, value: bytes) -> dict[str, bytes] | bytes: + """Decode the ``supplementalCredentials`` attribute. + + Args: + db: The associated NTDS database instance. + value: The raw bytes of the ``supplementalCredentials`` attribute. + + Returns: + A dictionary mapping credential types to their data blobs, or the original value if the PEK is locked. + """ + if db.data.pek is None or not db.data.pek.unlocked: + return value + + value = db.data.pek.decrypt(value) + header = c_ds.USER_PROPERTIES_HEADER(value) + + result = {} + if header.PropertySignature == 0x50: # 'P' as WORD in UTF-16-LE + for prop in c_ds.USER_PROPERTY[header.PropertyCount](value[len(header) :]): + prop_name = prop.PropertyName + prop_value = bytes.fromhex(prop.PropertyValue.decode()) + + if prop_name == "Packages": + prop_value = prop_value.decode("utf-16-le").split("\x00") + elif prop_name == "Primary:CLEARTEXT": + prop_value = prop_value.decode("utf-16-le") + elif prop_name == "Primary:Kerberos": + parsed = c_ds.KERB_STORED_CREDENTIAL(prop_value) + prop_value = { + "DefaultSalt": prop_value[ + parsed.DefaultSaltOffset : parsed.DefaultSaltOffset + parsed.DefaultSaltLength + ], + "Credentials": [ + {"KeyType": cred.KeyType, "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength]} + for cred in parsed.Credentials + ], + "OldCredentials": [ + {"KeyType": cred.KeyType, "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength]} + for cred in parsed.OldCredentials + ], + } + elif prop_name == "Primary:Kerberos-Newer-Keys": + parsed = c_ds.KERB_STORED_CREDENTIAL_NEW(prop_value) + prop_value = { + "DefaultSalt": prop_value[ + parsed.DefaultSaltOffset : parsed.DefaultSaltOffset + parsed.DefaultSaltLength + ], + "DefaultIterationCount": parsed.DefaultIterationCount, + "Credentials": [ + { + "KeyType": cred.KeyType, + "IterationCount": cred.IterationCount, + "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength], + } + for cred in parsed.Credentials + ], + "ServiceCredentials": [ + { + "KeyType": cred.KeyType, + "IterationCount": cred.IterationCount, + "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength], + } + for cred in parsed.ServiceCredentials + ], + "OldCredentials": [ + { + "KeyType": cred.KeyType, + "IterationCount": cred.IterationCount, + "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength], + } + for cred in parsed.OldCredentials + ], + "OlderCredentials": [ + { + "KeyType": cred.KeyType, + "IterationCount": cred.IterationCount, + "Key": prop_value[cred.KeyOffset : cred.KeyOffset + cred.KeyLength], + } + for cred in parsed.OlderCredentials + ], + } + elif prop_name == "Primary:WDigest": + parsed = c_ds.WDIGEST_CREDENTIALS(prop_value) + prop_value = list(parsed.Hash) + + result[prop_name] = prop_value + else: + # Probably AD LDS format, check some heuristics + # TODO: Properly research AD LDS supplementalCredentials format + header = c_ds.ADAM_PROPERTIES_HEADER(value) + if header.Reserved6 == len(value) - len(header) and header.Reserved3 == len(value) - len(header) + 8: + # Looks like AD LDS format + parsed = c_ds.WDIGEST_CREDENTIALS(value[len(header) :]) + + # Make up some keys to match the other result + result["Packages"] = ["WDigest"] + result["Primary:WDigest"] = list(parsed.Hash) + else: + # Bail out, unknown format + return value + + return result + + +ATTRIBUTE_ENCODE_DECODE_MAP: dict[ + str, tuple[Callable[[Database, Any], Any] | None, Callable[[Database, Any], Any] | None] +] = { + "Ancestors": (None, lambda db, value: [v[0] for v in struct.iter_unpack(" int | str: + """Convert an LDAP display name to its corresponding DNT value. + + Args: + db: The associated NTDS database instance. + value: The LDAP display name to look up. + + Returns: + The DNT value or the original value if not found. + """ + if (schema := db.data.schema.lookup(name=value)) is not None: + return schema.dnt + return value + + +def _DNT_to_ldapDisplayName(db: Database, value: int) -> str | DN | int: + """Convert a DNT value to its corresponding LDAP display name or distinguished name. + + For attributes and classes, the LDAP display name is returned. For objects, the distinguished name is returned. + + Args: + db: The associated NTDS database instance. + value: The Directory Number Tag to look up. + + Returns: + The LDAP display name or the original value if not found. + """ + if (schema := db.data.schema.lookup(dnt=value)) is not None: + return schema.name + + try: + return db.data._make_dn(value) + except Exception: + return value + + +class DN(str): + """A distinguished name (DN) string wrapper. Presents the DN as a string but also retains the underlying object.""" + + __slots__ = ("object", "parent") + + def __new__(cls, value: str, object: Object, parent: DN | None = None): + instance = super().__new__(cls, value) + instance.object = object + instance.parent = parent + return instance + + +def _oid_to_attrtyp(db: Database, value: str) -> int | str: + """Convert OID string or LDAP display name to ATTRTYP value. + + Supports both formats:: + + objectClass=person (LDAP display name) + objectClass=2.5.6.6 (OID string) + + Args: + db: The associated NTDS database instance. + value: Either an OID string (contains dots) or LDAP display name. + + Returns: + ATTRTYP integer value. + """ + if (schema := db.data.schema.lookup(oid=value) if "." in value else db.data.schema.lookup(name=value)) is not None: + return schema.id + + raise ValueError(f"Attribute or class not found for value: {value!r}") + + +def _attrtyp_to_oid(db: Database, value: int) -> str | int: + """Convert ATTRTYP integer value to OID string. + + Args: + db: The associated NTDS database instance. + value: The ATTRTYP integer value. + + Returns: + The OID string or the original value if not found. + """ + if (schema := db.data.schema.lookup(attrtyp=value)) is not None: + return schema.name + return value + + +def _binary_to_dn(db: Database, value: bytes) -> tuple[int, bytes]: + """Convert DN-Binary to the separate (DN, binary) tuple. + + Args: + db: The associated NTDS database instance. + value: The binary DN value. + + Returns: + A tuple of the DNT and the binary data as hex. + """ + dnt, length = struct.unpack(" int | bytes | str: + """Encode a string value according to the attribute's type. + + Args: + db: The associated NTDS database instance. + attribute: The LDAP attribute name. + value: The string value to encode. + + Returns: + The encoded value in the appropriate type for the attribute. + """ + if (schema := db.data.schema.lookup_attribute(name=attribute)) is None: + return value + + # First check the list of deviations + encode, _ = ATTRIBUTE_ENCODE_DECODE_MAP.get(attribute, (None, None)) + if encode is None: + encode, _ = OID_ENCODE_DECODE_MAP.get(schema.type, (None, None)) + + if encode is None: + return value + + return encode(db, value) + + +def decode_value(db: Database, attribute: str, value: Any) -> Any: + """Decode a value according to the attribute's type. + + Args: + db: The associated NTDS database instance. + attribute: The LDAP attribute name. + value: The value to decode. + + Returns: + The decoded value in the appropriate Python type for the attribute. + """ + if value is None: + return value + + # First check the list of deviations + _, decode = ATTRIBUTE_ENCODE_DECODE_MAP.get(attribute, (None, None)) + if decode is None: + # Next, try it using the regular OID_ENCODE_DECODE_MAP mapping + if (schema := db.data.schema.lookup_attribute(name=attribute)) is None: + return value + + # TODO: handle oMSyntax/oMObjectClass deviations? + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/7cda533e-d7a4-4aec-a517-91d02ff4a1aa + _, decode = OID_ENCODE_DECODE_MAP.get(schema.type, (None, None)) + + if decode is None: + return value + + return [decode(db, v) for v in value] if isinstance(value, list) else decode(db, value) diff --git a/dissect/database/ese/record.py b/dissect/database/ese/record.py index 76592a7..639cafd 100644 --- a/dissect/database/ese/record.py +++ b/dissect/database/ese/record.py @@ -61,6 +61,9 @@ def __getattr__(self, attr: str) -> RecordValue: except KeyError: return object.__getattribute__(self, attr) + def __contains__(self, attr: str) -> bool: + return attr in self._table._column_name_map + def __eq__(self, value: object) -> bool: if not isinstance(value, Record): return False @@ -293,10 +296,13 @@ def _parse_value( parse_func = parse_func or noop if tag_field and tag_field.flags & TAGFLD_HEADER.MultiValues: - value = list(map(parse_func, value)) + value = [parse_func(v) for v in value] else: value = parse_func(value) + if column.is_multivalue and not isinstance(value, list): + value = [value] + return value def _parse_multivalue(self, value: bytes, tag_field: TagField) -> list[bytes]: diff --git a/dissect/database/ese/table.py b/dissect/database/ese/table.py index 6a70afd..f862e48 100644 --- a/dissect/database/ese/table.py +++ b/dissect/database/ese/table.py @@ -8,6 +8,7 @@ from dissect.database.ese.btree import BTree from dissect.database.ese.c_ese import ( CODEPAGE, + FIELDFLAG, SYSOBJ, JET_coltyp, ) @@ -239,10 +240,11 @@ def _add_index(self, index: Index) -> None: class Column: - def __init__(self, identifier: int, name: str, type_: JET_coltyp, record: Record | None = None): + def __init__(self, identifier: int, name: str, type: JET_coltyp, flags: FIELDFLAG, record: Record | None = None): self.identifier = identifier self.name = name - self.type = type_ + self.type = type + self.flags = flags # Set by the table when added, only relevant for fixed value columns self._offset = None @@ -250,7 +252,7 @@ def __init__(self, identifier: int, name: str, type_: JET_coltyp, record: Record self.record = record def __repr__(self) -> str: - return f"" + return f"" # noqa: E501 @property def offset(self) -> int: @@ -276,6 +278,10 @@ def is_text(self) -> bool: def is_binary(self) -> bool: return self.type in (JET_coltyp.Binary, JET_coltyp.LongBinary) + @cached_property + def is_multivalue(self) -> bool: + return bool(self.flags & FIELDFLAG.Multivalued) + @cached_property def size(self) -> int: if self.record and self.record.get("SpaceUsage"): @@ -310,34 +316,34 @@ class Catalog: """ CATALOG_COLUMNS = ( - Column(1, "ObjidTable", JET_coltyp.Long), - Column(2, "Type", JET_coltyp.Short), - Column(3, "Id", JET_coltyp.Long), - Column(4, "ColtypOrPgnoFDP", JET_coltyp.Long), - Column(5, "SpaceUsage", JET_coltyp.Long), - Column(6, "Flags", JET_coltyp.Long), - Column(7, "PagesOrLocale", JET_coltyp.Long), - Column(8, "RootFlag", JET_coltyp.Bit), - Column(9, "RecordOffset", JET_coltyp.Short), - Column(10, "LCMapFlags", JET_coltyp.Long), - Column(11, "KeyMost", JET_coltyp.UnsignedShort), - Column(12, "LVChunkMax", JET_coltyp.Long), - Column(128, "Name", JET_coltyp.Text), - Column(129, "Stats", JET_coltyp.Binary), - Column(130, "TemplateTable", JET_coltyp.Text), - Column(131, "DefaultValue", JET_coltyp.Binary), - Column(132, "KeyFldIDs", JET_coltyp.Binary), - Column(133, "VarSegMac", JET_coltyp.Binary), - Column(134, "ConditionalColumns", JET_coltyp.Binary), - Column(135, "TupleLimits", JET_coltyp.Binary), - Column(136, "Version", JET_coltyp.Binary), - Column(137, "SortID", JET_coltyp.Binary), - Column(256, "CallbackData", JET_coltyp.LongBinary), - Column(257, "CallbackDependencies", JET_coltyp.LongBinary), - Column(258, "SeparateLV", JET_coltyp.LongBinary), - Column(259, "SpaceHints", JET_coltyp.LongBinary), - Column(260, "SpaceDeferredLVHints", JET_coltyp.LongBinary), - Column(261, "LocaleName", JET_coltyp.LongBinary), + Column(1, "ObjidTable", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(2, "Type", JET_coltyp.Short, FIELDFLAG.NotNull), + Column(3, "Id", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(4, "ColtypOrPgnoFDP", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(5, "SpaceUsage", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(6, "Flags", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(7, "PagesOrLocale", JET_coltyp.Long, FIELDFLAG.NotNull), + Column(8, "RootFlag", JET_coltyp.Bit, FIELDFLAG(0)), + Column(9, "RecordOffset", JET_coltyp.Short, FIELDFLAG(0)), + Column(10, "LCMapFlags", JET_coltyp.Long, FIELDFLAG(0)), + Column(11, "KeyMost", JET_coltyp.UnsignedShort, FIELDFLAG(0)), + Column(12, "LVChunkMax", JET_coltyp.Long, FIELDFLAG(0)), + Column(128, "Name", JET_coltyp.Text, FIELDFLAG.NotNull), + Column(129, "Stats", JET_coltyp.Binary, FIELDFLAG(0)), + Column(130, "TemplateTable", JET_coltyp.Text, FIELDFLAG(0)), + Column(131, "DefaultValue", JET_coltyp.Binary, FIELDFLAG(0)), + Column(132, "KeyFldIDs", JET_coltyp.Binary, FIELDFLAG(0)), + Column(133, "VarSegMac", JET_coltyp.Binary, FIELDFLAG(0)), + Column(134, "ConditionalColumns", JET_coltyp.Binary, FIELDFLAG(0)), + Column(135, "TupleLimits", JET_coltyp.Binary, FIELDFLAG(0)), + Column(136, "Version", JET_coltyp.Binary, FIELDFLAG(0)), + Column(137, "SortID", JET_coltyp.Binary, FIELDFLAG(0)), + Column(256, "CallbackData", JET_coltyp.LongBinary, FIELDFLAG(0)), + Column(257, "CallbackDependencies", JET_coltyp.LongBinary, FIELDFLAG(0)), + Column(258, "SeparateLV", JET_coltyp.LongBinary, FIELDFLAG(0)), + Column(259, "SpaceHints", JET_coltyp.LongBinary, FIELDFLAG(0)), + Column(260, "SpaceDeferredLVHints", JET_coltyp.LongBinary, FIELDFLAG(0)), + Column(261, "LocaleName", JET_coltyp.LongBinary, FIELDFLAG(0)), ) def __init__(self, db: ESE, root_page: Page): @@ -358,7 +364,13 @@ def __init__(self, db: ESE, root_page: Page): self._table_name_map[rec.get("Name")] = cur_table elif rtype == SYSOBJ.Column: - column = Column(rec.get("Id"), rec.get("Name"), JET_coltyp(rec.get("ColtypOrPgnoFDP")), record=rec) + column = Column( + rec.get("Id"), + rec.get("Name"), + JET_coltyp(rec.get("ColtypOrPgnoFDP")), + FIELDFLAG(rec.get("Flags")), + record=rec, + ) cur_table._add_column(column) elif rtype == SYSOBJ.Index: diff --git a/pyproject.toml b/pyproject.toml index 825c79a..2fb6ff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ "dissect.cstruct>=4,<5", - "dissect.util>=3.5,<4", + "dissect.util>=3.24.dev1,<4", # TODO: update on release! ] dynamic = ["version"] @@ -36,9 +36,13 @@ documentation = "https://docs.dissect.tools/en/latest/projects/dissect.database" repository = "https://github.com/fox-it/dissect.database" [project.optional-dependencies] +full = [ + "pycryptodome" +] dev = [ + "dissect.database[full]", "dissect.cstruct>=4.0.dev,<5.0.dev", - "dissect.util>=3.5.dev,<4.0.dev", + "dissect.util>=3.24.dev,<4.0.dev", ] [dependency-groups] @@ -48,6 +52,7 @@ test = [ lint = [ "ruff==0.13.1", "vermin", + "typing_extensions", ] build = [ "build", diff --git a/tests/_data/ese/ntds/adam/adamntds.dit.gz b/tests/_data/ese/ntds/adam/adamntds.dit.gz new file mode 100644 index 0000000..b797062 --- /dev/null +++ b/tests/_data/ese/ntds/adam/adamntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38f32ca466a1f9430c422f335483444916693174d35952af0287745a5f290eb0 +size 3005023 diff --git a/tests/_data/ese/ntds/goad/ntds.dit.gz b/tests/_data/ese/ntds/goad/ntds.dit.gz new file mode 100644 index 0000000..442fdbb --- /dev/null +++ b/tests/_data/ese/ntds/goad/ntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f38cbda2b136e160f8c7e7ca2e7b4f1389975c4b40098dba1b6f944ba2c8950c +size 2159695 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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..987d913 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import importlib.util + +import pytest + +HAS_BENCHMARK = importlib.util.find_spec("pytest_benchmark") is not None + + +def pytest_configure(config: pytest.Config) -> None: + if not HAS_BENCHMARK: + # If we don't have pytest-benchmark (or pytest-codspeed) installed, register the benchmark marker ourselves + # to avoid pytest warnings + config.addinivalue_line("markers", "benchmark: mark test for benchmarking (requires pytest-benchmark)") + + +def pytest_runtest_setup(item: pytest.Item) -> None: + if not HAS_BENCHMARK and item.get_closest_marker("benchmark") is not None: + pytest.skip("pytest-benchmark is not installed") diff --git a/tests/ese/ntds/__init__.py b/tests/ese/ntds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ese/ntds/conftest.py b/tests/ese/ntds/conftest.py new file mode 100644 index 0000000..5b5f4df --- /dev/null +++ b/tests/ese/ntds/conftest.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest + +from dissect.database.ese.ntds.ntds import NTDS +from tests._util import open_file_gz + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture(scope="module") +def goad() -> Iterator[NTDS]: + """NTDS file from a GOAD lab environment. + + Notes: + - robert.baratheon was deleted BEFORE the recycle bin was enabled + - IronIslands OA was deleted AFTER the recycle bin was enabled + - stannis.baratheon has password history and is disabled + - robb.stark has password history + - syskey: 079f95655b66f16deb28aa1ab3a81eb0 + """ + for fh in open_file_gz("_data/ese/ntds/goad/ntds.dit.gz"): + yield NTDS(fh) + + +@pytest.fixture(scope="module") +def adam() -> Iterator[NTDS]: + """AD LDS NTDS.dit file.""" + for fh in open_file_gz("_data/ese/ntds/adam/adamntds.dit.gz"): + yield NTDS(fh) + + +@pytest.fixture(scope="module") +def large() -> Iterator[NTDS]: + """Large NTDS file for performance testing. + + Notes: + - syskey: d9cf57f38072d3153f42524516e7ac3d + """ + for fh in open_file_gz("_data/ese/ntds/large/ntds.dit.gz"): + # Keep this one decompressed in memory (~110MB) as it is a large file, + # and performing I/O through the gzip layer is too slow + yield NTDS(BytesIO(fh.read())) diff --git a/tests/ese/ntds/test_benchmark.py b/tests/ese/ntds/test_benchmark.py new file mode 100644 index 0000000..e1a2a28 --- /dev/null +++ b/tests/ese/ntds/test_benchmark.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import gzip +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest + +from dissect.database.ese.ntds.ntds import NTDS +from tests._util import absolute_path + +if TYPE_CHECKING: + from pytest_benchmark.fixture import BenchmarkFixture + + +PARAMS = ( + "path", + [ + pytest.param("_data/ese/ntds/goad/ntds.dit.gz", id="goad"), + pytest.param("_data/ese/ntds/large/ntds.dit.gz", id="large"), + ], +) + + +def open_ntds(path: str) -> NTDS: + # Reopen the NTDS file for each benchmark run to prevent caching effects + with gzip.GzipFile(absolute_path(path), "rb") as fh: + return NTDS(BytesIO(fh.read())) + + +@pytest.mark.benchmark +@pytest.mark.parametrize(*PARAMS) +def test_benchmark_users(path: str, benchmark: BenchmarkFixture) -> None: + ntds = open_ntds(path) + benchmark(lambda: list(ntds.users())) + + +@pytest.mark.benchmark +@pytest.mark.parametrize(*PARAMS) +def test_benchmark_groups(path: str, benchmark: BenchmarkFixture) -> None: + ntds = open_ntds(path) + benchmark(lambda: list(ntds.groups())) + + +@pytest.mark.benchmark +@pytest.mark.parametrize(*PARAMS) +def test_benchmark_computers(path: str, benchmark: BenchmarkFixture) -> None: + ntds = open_ntds(path) + benchmark(lambda: list(ntds.computers())) diff --git a/tests/ese/ntds/test_ntds.py b/tests/ese/ntds/test_ntds.py new file mode 100644 index 0000000..c06e5c2 --- /dev/null +++ b/tests/ese/ntds/test_ntds.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from dissect.database.ese.ntds.objects import Computer, Group, Server, SubSchema, User + +if TYPE_CHECKING: + from dissect.database.ese.ntds import NTDS + + +def test_groups(goad: NTDS) -> None: + groups = sorted(goad.groups(), key=lambda x: x.distinguished_name) + + assert len(groups) == 102 + assert isinstance(groups[0], Group) + assert all(isinstance(x, Group) for x in groups) + + north_domain_admins = next( + x for x in groups if x.distinguished_name == "CN=DOMAIN ADMINS,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL" + ) + assert isinstance(north_domain_admins, Group) + + assert north_domain_admins.is_phantom + with pytest.raises(ValueError, match="Operation not supported for phantom \\(non-local\\) objects"): + list(north_domain_admins.members()) + + domain_admins = next( + x for x in groups if x.distinguished_name == "CN=DOMAIN ADMINS,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL" + ) + assert isinstance(domain_admins, Group) + assert sorted([x.sam_account_name for x in domain_admins.members()]) == [ + "Administrator", + "cersei.lannister", + ] + + +def test_servers(goad: NTDS) -> None: + servers = sorted(goad.servers(), key=lambda x: x.name) + assert len(servers) == 2 + assert isinstance(servers[0], Server) + assert [x.name for x in servers] == [ + "KINGSLANDING", + "WINTERFELL", + ] + + +def test_users(goad: NTDS) -> None: + users: list[User] = sorted(goad.users(), key=lambda x: x.distinguished_name) + assert len(users) == 33 + assert isinstance(users[0], User) + assert [x.distinguished_name for x in users] == [ + "CN=ADMINISTRATOR,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ADMINISTRATOR,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ARYA.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=BRANDON.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=CATELYN.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=CERSEI.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=EDDARD.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ESSOS$,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=GUEST,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=GUEST,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=HODOR,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JAIME.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JEOR.MORMONT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JOFFREY.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JON.SNOW,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=KRBTGT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=KRBTGT,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=LORD.VARYS,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=MAESTER.PYCELLE,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=NORTH$,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=PETYER.BAELISH,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=RENLY.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=RICKON.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ROBB.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SAMWELL.TARLY,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SANSA.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SEVENKINGDOMS$,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SQL_SVC,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=STANNIS.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=TYRON.LANNISTER,OU=WESTERLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=TYWIN.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=VAGRANT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=VAGRANT,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + ] + + assert users[3].distinguished_name == "CN=BRANDON.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL" + assert users[3].cn == "brandon.stark" + assert users[3].city == "Winterfell" + + assert users[4].distinguished_name == "CN=CATELYN.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL" + + assert users[-1].displayName == "Vagrant" + + assert users[12].objectSid == "S-1-5-21-459184689-3312531310-188885708-1120" + assert users[12].distinguished_name == "CN=JEOR.MORMONT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL" + assert users[12].description == ["Jeor Mormont"] + + assert users[10].description == ["Brainless Giant"] + + +def test_computers(goad: NTDS) -> None: + computers: list[Computer] = sorted(goad.computers(), key=lambda x: x.name) + assert len(computers) == 3 + assert computers[0].name == "CASTELBLACK" + assert computers[1].name == "KINGSLANDING" + assert computers[2].name == "WINTERFELL" + + assert [g.name for g in computers[1].groups()] == [ + "Cert Publishers", + "Pre-Windows 2000 Compatible Access", + "Domain Controllers", + ] + + +def test_group_membership(goad: NTDS) -> None: + # Prepare objects + domain_admins = next(goad.search(sAMAccountName="Domain Admins")) + domain_users = next(goad.search(sAMAccountName="Domain Users")) + assert isinstance(domain_admins, Group) + assert isinstance(domain_users, Group) + + shame = next(goad.search(sAMAccountName="cersei.lannister")) + assert isinstance(shame, User) + + # Test membership of Cersei Lannister + assert len(list(shame.groups())) == 6 + assert sorted([g.sam_account_name for g in shame.groups()]) == [ + "Administrators", + "Baratheon", + "Domain Admins", + "Domain Users", + "Lannister", + "Small Council", + ] + assert shame.is_member_of(domain_admins) + assert shame.is_member_of(domain_users) + + # Check the members of the Domain Admins group + assert len(list(domain_admins.members())) == 2 + assert sorted([u.sAMAccountName for u in domain_admins.members()]) == [ + "Administrator", + "cersei.lannister", + ] + assert domain_admins.is_member(shame) + + # Check the members of the Domain Users group + assert len(list(domain_users.members())) == 31 # All users except Guest + assert sorted([u.dn for u in domain_users.members()]) == [ + "CN=ADMINISTRATOR,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ADMINISTRATOR,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ARYA.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=BRANDON.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=CATELYN.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=CERSEI.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=EDDARD.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ESSOS$,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=HODOR,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JAIME.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JEOR.MORMONT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JOFFREY.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=JON.SNOW,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=KRBTGT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=KRBTGT,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=LORD.VARYS,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=MAESTER.PYCELLE,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=NORTH$,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=PETYER.BAELISH,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=RENLY.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=RICKON.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=ROBB.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SAMWELL.TARLY,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SANSA.STARK,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SEVENKINGDOMS$,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=SQL_SVC,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=STANNIS.BARATHEON,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=TYRON.LANNISTER,OU=WESTERLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=TYWIN.LANNISTER,OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=VAGRANT,CN=USERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "CN=VAGRANT,CN=USERS,DC=SEVENKINGDOMS,DC=LOCAL", + ] + assert domain_users.is_member(shame) + assert not domain_users.is_member(next(goad.search(sAMAccountName="Guest"))) + + +def test_managed_by(goad: NTDS) -> None: + lannister = next(g for g in goad.groups() if g.sam_account_name == "Lannister") + managed_by = list(lannister.managed_by()) + + assert len(managed_by) == 1 + assert managed_by[0].sam_account_name == "tywin.lannister" + assert next(iter(managed_by[0].managed_objects())).dn == lannister.dn + + +def test_query_specific_users(goad: NTDS) -> None: + specific_records = sorted( + goad.query("(&(objectClass=user)(|(cn=jon.snow)(cn=hodor)))"), key=lambda x: x.sAMAccountName + ) + assert len(specific_records) == 2 + assert specific_records[0].sam_account_name == "hodor" + assert specific_records[1].sam_account_name == "jon.snow" + + +def test_record_to_object_coverage(goad: NTDS) -> None: + """Test _record_to_object method coverage.""" + # Get a real record from the database + users = list(goad.users()) + assert len(users) == 33 + + user = users[0] + assert hasattr(user, "sAMAccountName") + assert isinstance(user, User) + + +def test_sid_lookup(goad: NTDS) -> None: + """Test SID lookup functionality.""" + sid_str = "S-1-5-21-459184689-3312531310-188885708-1120" + user = next(goad.search(objectSid=sid_str)) + assert isinstance(user, User) + assert user.sam_account_name == "jeor.mormont" + + +def test_object_repr(goad: NTDS) -> None: + """Test the ``__repr__`` methods of User, Computer, Object and Group classes.""" + object = next(goad.search(sAMAccountName="Administrator")) + assert isinstance(object, User) + assert repr(object) == "" + + object = next(goad.search(sAMAccountName="KINGSL*")) + assert isinstance(object, Computer) + assert repr(object) == "" + + object = next(goad.search(sAMAccountName="Domain Admins")) + assert isinstance(object, Group) + assert repr(object) == "" + + object = next(goad.search(objectCategory="subSchema")) + assert isinstance(object, SubSchema) + assert repr(object) == "" + + object = next(goad.search(sAMAccountName="eddard.stark")) + assert isinstance(object, User) + assert ( + repr(object) == "" + ) + + object = next(goad.search(sAMAccountName="robert.baratheon")) + assert isinstance(object, User) + assert ( + repr(object) + == "" # noqa: E501 + ) + + +def test_all_memberships(large: NTDS) -> None: + """Test all memberships of all users.""" + for user in large.users(): + # Just iterate all memberships to see if any errors occur + list(user.groups()) diff --git a/tests/ese/ntds/test_pek.py b/tests/ese/ntds/test_pek.py new file mode 100644 index 0000000..49a32a5 --- /dev/null +++ b/tests/ese/ntds/test_pek.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dissect.database.ese.ntds import NTDS + + +def test_pek(goad: NTDS) -> None: + """Test PEK unlocking and decryption.""" + syskey = bytes.fromhex("079f95655b66f16deb28aa1ab3a81eb0") + + user = next(goad.users(), None) + assert user is not None + + encrypted = user.unicodePwd + # Verify encrypted value + assert encrypted == bytes.fromhex( + "130000000000000029fbdaafb52bf724a51052f668152ac5100000006d06616d95c026064fff245bd256f3d4990f7bffb546f76de566723da4855227" + ) + + goad.pek.unlock(syskey) + assert goad.pek.unlocked + + # Test decryption of the user's password + assert goad.pek.decrypt(encrypted) == bytes.fromhex("06bb564317712dc60761a32914e4048c") + # Should work transparently now too + assert user.unicodePwd == bytes.fromhex("06bb564317712dc60761a32914e4048c") + + +def test_pek_adam(adam: NTDS) -> None: + """Test PEK unlocking and decryption for AD LDS NTDS.dit.""" + # The PEK in AD LDS is derived within the database itself + assert adam.pek.unlocked + + user = next(adam.users(), None) + assert user.unicodePwd == bytes.fromhex("8846f7eaee8fb117ad06bdd830b7586c") diff --git a/tests/ese/ntds/test_query.py b/tests/ese/ntds/test_query.py new file mode 100644 index 0000000..31b8f02 --- /dev/null +++ b/tests/ese/ntds/test_query.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from dissect.database.ese.ntds.query import Query, _increment_last_char + +if TYPE_CHECKING: + from dissect.database.ese.ntds.ntds import NTDS + + +def test_simple_AND(goad: NTDS) -> None: + query = Query(goad.db, "(&(objectClass=user)(cn=hodor))") + with patch.object(query, "_query_database", wraps=query._query_database) as mock_fetch: + records = list(query.process()) + assert len(records) == 1 + assert mock_fetch.call_count == 1 + + +def test_simple_OR(goad: NTDS) -> None: + query = Query(goad.db, "(|(objectClass=group)(cn=hodor))") + + with patch.object(query, "_query_database", wraps=query._query_database) as mock_fetch: + records = list(query.process()) + assert len(records) == 103 # 102 groups + 1 user + assert mock_fetch.call_count == 2 + + +def test_nested_OR(goad: NTDS) -> None: + query = Query( + goad.db, + "(|(objectClass=container)(objectClass=organizationalUnit)" + "(sAMAccountType=805306369)(objectClass=group)(&(objectCategory=person)(objectClass=user)))", + ) + with patch.object(query, "_query_database", wraps=query._query_database) as mock_fetch: + records = list(query.process()) + assert len(records) == 582 + assert mock_fetch.call_count == 5 + + +def test_nested_AND(goad: NTDS) -> None: + first_query = Query(goad.db, "(&(objectClass=user)(&(cn=hodor)(sAMAccountName=hodor)))", optimize=False) + with ( + patch.object(first_query, "_query_database", wraps=first_query._query_database) as mock_fetch, + patch.object(first_query, "_process_query", wraps=first_query._process_query) as mock_execute, + ): + records = list(first_query.process()) + # only the first part of the AND should be fetched, so objectClass=user + assert len(records) == 1 + assert mock_fetch.call_count == 1 + assert mock_execute.call_count == 77 + first_run_queries = mock_execute.call_count + + second_query = Query(goad.db, "(&(&(cn=hodor)(sAMAccountName=hodor))(objectClass=user))", optimize=False) + with ( + patch.object(second_query, "_query_database", wraps=second_query._query_database) as mock_fetch, + patch.object(second_query, "_process_query", wraps=second_query._process_query) as mock_execute, + ): + records = list(second_query.process()) + assert len(records) == 1 + assert mock_fetch.call_count == 1 + assert mock_execute.call_count == 5 + second_run_queries = mock_execute.call_count + assert second_run_queries < first_run_queries, "The second query should have fewer calls than the first one." + + # When we allow query optimization, the first query should be similar to the second one, + # that was manually optimized + third_query = Query(goad.db, "(&(objectClass=user)(&(cn=hodor)(sAMAccountName=hodor)))", optimize=True) + with ( + patch.object(third_query, "_query_database", wraps=third_query._query_database) as mock_fetch, + patch.object(third_query, "_process_query", wraps=third_query._process_query) as mock_execute, + ): + records = list(third_query.process()) + assert len(records) == 1 + assert mock_fetch.call_count == 1 + assert mock_execute.call_count == 5 + assert mock_execute.call_count == second_run_queries + + +def test_simple_wildcard(goad: NTDS) -> None: + query = Query(goad.db, "(&(sAMAccountName=hod*)(objectCategory=person))") + with patch.object(query, "_query_database", wraps=query._query_database) as mock_fetch: + records = list(query.process()) + assert len(records) == 1 + assert mock_fetch.call_count == 1 + + +def test_simple_wildcard_in_AND(goad: NTDS) -> None: + query = Query(goad.db, "(&(objectCategory=person)(sAMAccountName=hod*))") + with patch.object(query, "_query_database", wraps=query._query_database) as mock_fetch: + records = list(query.process()) + assert len(records) == 1 + assert mock_fetch.call_count == 1 + + +def test_invalid_attribute(goad: NTDS) -> None: + """Test attribute not found in schema.""" + query = Query(goad.db, "(nonexistent_attribute=test_value)") + with pytest.raises(ValueError, match="Attribute 'nonexistent_attribute' not found in the NTDS database"): + list(query.process()) + + +def test_invalid_index(goad: NTDS) -> None: + """Test index not found for attribute.""" + query = Query(goad.db, "(cn=ThisIsNotExistingInTheDB)") + with ( + patch.object(goad.db.data.table, "find_index", return_value=None), + pytest.raises(ValueError, match=r"Index for attribute.*not found in the NTDS database"), + ): + list(query.process()) + + +def test_increment_last_char() -> None: + """Test incrementing the last character of a string.""" + assert _increment_last_char("test") == "tesu" + assert _increment_last_char("tesz") == "tet" + assert _increment_last_char("a") == "b" + assert _increment_last_char("z") == "za" + assert _increment_last_char("") == "a" diff --git a/tests/ese/ntds/test_schema.py b/tests/ese/ntds/test_schema.py new file mode 100644 index 0000000..5d68610 --- /dev/null +++ b/tests/ese/ntds/test_schema.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from dissect.database.ese.ntds.ntds import NTDS + + +def test_lookup_multiple_keys(goad: NTDS) -> None: + """Test error handling in schema index lookup with multiple keys.""" + with pytest.raises(ValueError, match="Exactly one lookup key must be provided"): + goad.db.data.schema.lookup(name="person", attrtyp=1234) + + goad.db.data.schema.lookup(name="person") # This should work without error diff --git a/tests/ese/ntds/test_sd.py b/tests/ese/ntds/test_sd.py new file mode 100644 index 0000000..545c745 --- /dev/null +++ b/tests/ese/ntds/test_sd.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.database.ese.ntds.sd import ACCESS_MASK + +if TYPE_CHECKING: + from dissect.database.ese.ntds.ntds import NTDS + + +def test_dacl_specific_user(goad: NTDS) -> None: + """Test that DACLs can be retrieved from user objects.""" + jaime = next(u for u in goad.users() if u.name == "jaime.lannister") + joffrey = next(u for u in goad.users() if u.name == "joffrey.baratheon") + + ace = next(ace for ace in joffrey.sd.dacl.ace if ace.sid == jaime.sid) + assert ACCESS_MASK.READ_CONTROL in ace.mask + assert ACCESS_MASK.ADS_RIGHT_DS_WRITE_PROP in ace.mask + assert ACCESS_MASK.ADS_RIGHT_DS_SELF in ace.mask diff --git a/tests/ese/ntds/test_util.py b/tests/ese/ntds/test_util.py new file mode 100644 index 0000000..7f17ba7 --- /dev/null +++ b/tests/ese/ntds/test_util.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from dissect.database.ese.ntds.util import _ldapDisplayName_to_DNT, _oid_to_attrtyp, decode_value, encode_value + +if TYPE_CHECKING: + from dissect.database.ese.ntds.ntds import NTDS + + +@pytest.mark.parametrize( + ("attribute", "decoded", "encoded"), + [ + ("cn", "test_value", "test_value"), + ( + "objectSid", + "S-1-5-21-1957882089-4252948412-2360614479-1134", + bytes.fromhex("010500000000000515000000e9e8b274bcd77efd4f1eb48c0000046e"), + ), + ], +) +def test_encode_decode_value(goad: NTDS, attribute: str, decoded: Any, encoded: Any) -> None: + """Test ``encode_value`` and ``decode_value`` coverage.""" + assert encode_value(goad.db, attribute, decoded) == encoded + assert decode_value(goad.db, attribute, encoded) == decoded + + +def test_oid_to_attrtyp_with_oid_string(goad: NTDS) -> None: + """Test ``_oid_to_attrtyp`` with OID string format.""" + person_entry = goad.db.data.schema.lookup(name="person") + + result = _oid_to_attrtyp(goad.db, person_entry.oid) + assert isinstance(result, int) + assert result == person_entry.id + + +def test_oid_string_to_attrtyp_with_class_name(goad: NTDS) -> None: + """Test ``_oid_to_attrtyp`` with class name (normal case).""" + person_entry = goad.db.data.schema.lookup(name="person") + + result = _oid_to_attrtyp(goad.db, "person") + assert isinstance(result, int) + assert result == person_entry.id + + +def test_get_dnt_coverage(goad: NTDS) -> None: + """Test DNT method coverage.""" + # Test with an attribute + dnt = _ldapDisplayName_to_DNT(goad.db, "cn") + assert isinstance(dnt, int) + assert dnt == 132 + + # Test with a class + dnt = _ldapDisplayName_to_DNT(goad.db, "person") + assert isinstance(dnt, int) + assert dnt == 1554 + + +def test_supplemental_credentials(goad: NTDS) -> None: + """Test decoding of supplementalCredentials attribute.""" + user = next(u for u in goad.users() if u.name == "maester.pycelle") + + assert isinstance(user.get("supplementalCredentials")[0], bytes) + + syskey = bytes.fromhex("079f95655b66f16deb28aa1ab3a81eb0") + goad.pek.unlock(syskey) + assert goad.pek.unlocked + + value = user.get("supplementalCredentials")[0] + assert isinstance(value, dict) + + assert value["Packages"] == ["NTLM-Strong-NTOWF", "Kerberos-Newer-Keys", "Kerberos", "WDigest"] + + assert value["Primary:NTLM-Strong-NTOWF"].hex() == "c63d40b2713f0c0916eeab6e522abef5" + + assert len(value["Primary:WDigest"]) == 29 + + assert value["Primary:Kerberos"]["DefaultSalt"] == "SEVENKINGDOMS.LOCALmaester.pycelle".encode("utf-16-le") + assert value["Primary:Kerberos"]["Credentials"][0]["KeyType"] == 3 + assert value["Primary:Kerberos"]["Credentials"][0]["Key"].hex() == "89379167f87f0b5b" + + assert value["Primary:Kerberos-Newer-Keys"]["DefaultSalt"] == "SEVENKINGDOMS.LOCALmaester.pycelle".encode( + "utf-16-le" + ) + assert value["Primary:Kerberos-Newer-Keys"]["DefaultIterationCount"] == 4096 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][0]["KeyType"] == 18 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][0]["IterationCount"] == 4096 + assert ( + value["Primary:Kerberos-Newer-Keys"]["Credentials"][0]["Key"].hex() + == "25370ba431b262bdf7ca279e88d824cd59b4ce280bbef537a96fe51c8d790042" + ) + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][1]["KeyType"] == 17 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][1]["IterationCount"] == 4096 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][1]["Key"].hex() == "7d375f265062643302a4827719ea541d" + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][2]["KeyType"] == 3 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][2]["IterationCount"] == 4096 + assert value["Primary:Kerberos-Newer-Keys"]["Credentials"][2]["Key"].hex() == "89379167f87f0b5b" + + +def test_supplemental_credentials_adam(adam: NTDS) -> None: + """Test decoding of supplementalCredentials attribute in AD LDS NTDS.dit.""" + user = next(adam.users(), None) + + value = user.get("supplementalCredentials")[0] + assert isinstance(value, dict) + + assert value["Packages"] == ["WDigest"] + assert len(value["Primary:WDigest"]) == 29 diff --git a/tox.ini b/tox.ini index 284a4ba..39d4d53 100755 --- a/tox.ini +++ b/tox.ini @@ -18,10 +18,20 @@ deps = coverage dependency_groups = test commands = - pytest --basetemp="{envtmpdir}" {posargs:--color=yes --cov=dissect --cov-report=term-missing -v tests} + pytest --basetemp="{envtmpdir}" --import-mode="append" {posargs:--color=yes --cov=dissect --cov-report=term-missing -v tests} coverage report coverage xml +[testenv:benchmark] +deps = + pytest-benchmark + pytest-codspeed +dependency_groups = test +passenv = + CODSPEED_ENV +commands = + pytest --basetemp="{envtmpdir}" --import-mode="append" -m benchmark {posargs:--color=yes -v tests} + [testenv:build] package = skip dependency_groups = build