diff --git a/dissect/database/ese/ntds/database.py b/dissect/database/ese/ntds/database.py index 70eb4d0..2728aa6 100644 --- a/dissect/database/ese/ntds/database.py +++ b/dissect/database/ese/ntds/database.py @@ -11,7 +11,7 @@ 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, DatabaseFlags, SearchFlags, encode_value +from dissect.database.ese.ntds.util import DN, DatabaseFlag, SearchFlag, encode_value if TYPE_CHECKING: from collections.abc import Iterator @@ -44,14 +44,14 @@ def __init__(self, fh: BinaryIO): self.data._make_dn.cache_clear() @cached_property - def flags(self) -> DatabaseFlags | None: + def flags(self) -> DatabaseFlag | None: """Return the database flags.""" if self.hiddeninfo is None: return None - result = DatabaseFlags(0) + result = DatabaseFlag(0) flags = self.hiddeninfo.get("flags_col") - for idx, member in enumerate(DatabaseFlags.__members__.values()): + for idx, member in enumerate(DatabaseFlag.__members__.values()): if flags[idx] == ord(b"1"): result = member if result is None else result | member @@ -256,9 +256,9 @@ def _get_index(self, attribute: str) -> Index: if schema.search_flags is None: raise ValueError(f"Attribute is not indexed: {attribute!r}") - if SearchFlags.Indexed in schema.search_flags: + if SearchFlag.Indexed in schema.search_flags: name = f"INDEX_{schema.id:08x}" - elif SearchFlags.TupleIndexed in schema.search_flags: + elif SearchFlag.TupleIndexed in schema.search_flags: name = f"INDEX_T_{schema.id:08x}" else: # TODO add ContainerIndexed diff --git a/dissect/database/ese/ntds/ntds.py b/dissect/database/ese/ntds/ntds.py index 8d8e53e..f783160 100644 --- a/dissect/database/ese/ntds/ntds.py +++ b/dissect/database/ese/ntds/ntds.py @@ -20,6 +20,7 @@ TrustedDomain, User, ) + from dissect.database.ese.ntds.objects.organizationalunit import OrganizationalUnit from dissect.database.ese.ntds.pek import PEK @@ -83,11 +84,11 @@ def search(self, **kwargs: str) -> Iterator[Object]: def groups(self) -> Iterator[Group]: """Get all group objects from the database.""" - yield from self.search(objectCategory="group") + yield from self.search(objectClass="group") def servers(self) -> Iterator[Server]: """Get all server objects from the database.""" - yield from self.search(objectCategory="server") + yield from self.search(objectClass="server") def users(self) -> Iterator[User]: """Get all user objects from the database.""" @@ -95,7 +96,11 @@ def users(self) -> Iterator[User]: def computers(self) -> Iterator[Computer]: """Get all computer objects from the database.""" - yield from self.search(objectCategory="computer") + yield from self.search(objectClass="computer") + + def domains(self) -> Iterator[DomainDNS]: + """Get all domain objects from the database.""" + yield from self.search(objectClass="domainDNS") def trusts(self) -> Iterator[TrustedDomain]: """Get all trust objects from the database.""" @@ -105,6 +110,10 @@ def group_policies(self) -> Iterator[GroupPolicyContainer]: """Get all group policy objects (GPO) objects from the database.""" yield from self.search(objectClass="groupPolicyContainer") + def organizational_units(self) -> Iterator[OrganizationalUnit]: + """Get all organizational unit (OU) objects from the database.""" + yield from self.search(objectClass="organizationalUnit") + def secrets(self) -> Iterator[Secret]: """Get all secret objects from the database.""" yield from self.search(objectClass="secret") diff --git a/dissect/database/ese/ntds/objects/attributeschema.py b/dissect/database/ese/ntds/objects/attributeschema.py index e01389d..fdcb64c 100644 --- a/dissect/database/ese/ntds/objects/attributeschema.py +++ b/dissect/database/ese/ntds/objects/attributeschema.py @@ -1,6 +1,12 @@ from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from dissect.database.ese.ntds.objects.top import Top +from dissect.database.ese.ntds.util import SearchFlag, SystemFlagAttribute + +if TYPE_CHECKING: + from dissect.database.ese.ntds.objects.object import DecoderMap class AttributeSchema(Top): @@ -11,3 +17,12 @@ class AttributeSchema(Top): """ __object_class__ = "attributeSchema" + __decoders__: ClassVar[DecoderMap] = { + "searchFlags": lambda db, value: SearchFlag(value), + "systemFlags": lambda db, value: SystemFlagAttribute(value), + } + + @property + def search_flags(self) -> SearchFlag | None: + """Return the searchFlags of this attribute schema.""" + return self.get("searchFlags") diff --git a/dissect/database/ese/ntds/objects/certificationauthority.py b/dissect/database/ese/ntds/objects/certificationauthority.py index 92ae439..9b80346 100644 --- a/dissect/database/ese/ntds/objects/certificationauthority.py +++ b/dissect/database/ese/ntds/objects/certificationauthority.py @@ -11,3 +11,8 @@ class CertificationAuthority(Top): """ __object_class__ = "certificationAuthority" + + @property + def dns_host_name(self) -> str | None: + """Return the dNSHostName of this Certification Authority.""" + return self.get("dNSHostName") diff --git a/dissect/database/ese/ntds/objects/computer.py b/dissect/database/ese/ntds/objects/computer.py index c267620..19887b0 100644 --- a/dissect/database/ese/ntds/objects/computer.py +++ b/dissect/database/ese/ntds/objects/computer.py @@ -23,6 +23,21 @@ class Computer(User): def __repr_body__(self) -> str: return f"name={self.name!r}" + @property + def dns_host_name(self) -> str | None: + """Return the dNSHostName of this computer.""" + return self.get("dNSHostName") + + @property + def operating_system(self) -> str | None: + """Return the operatingSystem of this computer.""" + return self.get("operatingSystem") + + @property + def operating_system_version(self) -> str | None: + """Return the operatingSystemVersion of this computer.""" + return self.get("operatingSystemVersion") + def fve_recovery_information(self) -> Iterator[MSFVERecoveryInformation]: """Return the BitLocker recovery information objects associated with this computer.""" for child in self.children(): diff --git a/dissect/database/ese/ntds/objects/configuration.py b/dissect/database/ese/ntds/objects/configuration.py index cf7bfc9..77352e2 100644 --- a/dissect/database/ese/ntds/objects/configuration.py +++ b/dissect/database/ese/ntds/objects/configuration.py @@ -11,3 +11,8 @@ class Configuration(Top): """ __object_class__ = "configuration" + + @property + def gp_link(self) -> str | None: + """Return the group policy link of the configuration.""" + return self.get("gPLink") diff --git a/dissect/database/ese/ntds/objects/crossref.py b/dissect/database/ese/ntds/objects/crossref.py index 3845efe..d2597ca 100644 --- a/dissect/database/ese/ntds/objects/crossref.py +++ b/dissect/database/ese/ntds/objects/crossref.py @@ -1,6 +1,12 @@ from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from dissect.database.ese.ntds.objects.top import Top +from dissect.database.ese.ntds.util import SystemFlagCrossRef + +if TYPE_CHECKING: + from dissect.database.ese.ntds.objects.object import DecoderMap class CrossRef(Top): @@ -11,3 +17,11 @@ class CrossRef(Top): """ __object_class__ = "crossRef" + __decoders__: ClassVar[DecoderMap] = { + "systemFlags": lambda db, value: SystemFlagCrossRef(value), + } + + @property + def behavior_version(self) -> int | None: + """Return the msDS-Behavior-Version of this cross-reference.""" + return self.get("msDS-Behavior-Version") diff --git a/dissect/database/ese/ntds/objects/crossrefcontainer.py b/dissect/database/ese/ntds/objects/crossrefcontainer.py index efb4665..cbd7855 100644 --- a/dissect/database/ese/ntds/objects/crossrefcontainer.py +++ b/dissect/database/ese/ntds/objects/crossrefcontainer.py @@ -11,3 +11,8 @@ class CrossRefContainer(Top): """ __object_class__ = "crossRefContainer" + + @property + def behavior_version(self) -> int | None: + """Return the msDS-Behavior-Version of this cross-reference container.""" + return self.get("msDS-Behavior-Version") diff --git a/dissect/database/ese/ntds/objects/domaindns.py b/dissect/database/ese/ntds/objects/domaindns.py index e2ce9c2..352a868 100644 --- a/dissect/database/ese/ntds/objects/domaindns.py +++ b/dissect/database/ese/ntds/objects/domaindns.py @@ -19,3 +19,8 @@ def pek(self) -> PEK | None: if (pek := self.get("pekList")) is not None: return PEK(pek) return None + + @property + def behavior_version(self) -> int | None: + """Return the msDS-Behavior-Version of this domain DNS object.""" + return self.get("msDS-Behavior-Version") diff --git a/dissect/database/ese/ntds/objects/group.py b/dissect/database/ese/ntds/objects/group.py index 1bddac4..1c1e1d1 100644 --- a/dissect/database/ese/ntds/objects/group.py +++ b/dissect/database/ese/ntds/objects/group.py @@ -24,6 +24,16 @@ def sam_account_name(self) -> str: """Return the group's sAMAccountName.""" return self.get("sAMAccountName") + @property + def mail(self) -> str | None: + """Return the mail address of this group.""" + return self.get("mail") + + @property + def admin_count(self) -> int | None: + """Return the group's adminCount.""" + return self.get("adminCount") + def managed_by(self) -> Iterator[Object]: """Return the objects that manage this group.""" self._assert_local() diff --git a/dissect/database/ese/ntds/objects/grouppolicycontainer.py b/dissect/database/ese/ntds/objects/grouppolicycontainer.py index 80c1cb7..00d6789 100644 --- a/dissect/database/ese/ntds/objects/grouppolicycontainer.py +++ b/dissect/database/ese/ntds/objects/grouppolicycontainer.py @@ -11,3 +11,8 @@ class GroupPolicyContainer(Container): """ __object_class__ = "groupPolicyContainer" + + @property + def file_path(self) -> str | None: + """Return the gPCFileSysPath of this group policy container.""" + return self.get("gPCFileSysPath") diff --git a/dissect/database/ese/ntds/objects/ntdsdsa.py b/dissect/database/ese/ntds/objects/ntdsdsa.py index aeca2d8..a82003d 100644 --- a/dissect/database/ese/ntds/objects/ntdsdsa.py +++ b/dissect/database/ese/ntds/objects/ntdsdsa.py @@ -19,6 +19,11 @@ class NTDSDSA(ApplicationSettings): __object_class__ = "nTDSDSA" + @property + def behavior_version(self) -> int | None: + """Return the msDS-Behavior-Version of this NTDS DSA object.""" + return self.get("msDS-Behavior-Version") + def domain(self) -> DomainDNS | None: """Return the domain object associated with this NTDS DSA object, if any.""" self._assert_local() diff --git a/dissect/database/ese/ntds/objects/object.py b/dissect/database/ese/ntds/objects/object.py index 388a80e..d1e23b1 100644 --- a/dissect/database/ese/ntds/objects/object.py +++ b/dissect/database/ese/ntds/objects/object.py @@ -1,19 +1,23 @@ from __future__ import annotations +import struct from functools import cached_property -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias +from uuid import UUID -from dissect.database.ese.ntds.util import InstanceType, decode_value +from dissect.database.ese.ntds.util import InstanceType, SystemFlag, decode_value if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, 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.ntds.util import DN, SystemFlags + from dissect.database.ese.ntds.util import DN from dissect.database.ese.record import Record + DecoderMap: TypeAlias = dict[str, Callable[[Database, Any], Any]] + class Object: """Base class for all objects in the NTDS database. @@ -29,7 +33,19 @@ class Object: - https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/041c6068-c710-4c74-968f-3040e4208701 """ + # Subclasses must override this to specify their object class __object_class__: str + """The objectClass value for this object.""" + + # Decoders for specific attributes to this object + __decoders__: ClassVar[DecoderMap] = { + "Ancestors": lambda db, value: [v[0] for v in struct.iter_unpack(" str: suffix = self.__repr_suffix__() return f"<{self.__class__.__name__} {self.__repr_body__()}{' ' + suffix if suffix else ''}>" @@ -89,7 +111,13 @@ def get(self, name: str, *, raw: bool = False) -> Any: 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) + value = _get_attribute(self.db, self.record, name, raw=raw) + + # Allow custom decoders to override the default decoding logic for specific attributes + # This is convenient for things like enums or timestamps that we want to represent as more meaningful types + if value is not None and name in self.__decoders__: + value = self.__decoders__[name](self.db, value) + return value def as_dict(self) -> dict[str, Any]: """Return the object's attributes as a dictionary.""" @@ -97,7 +125,7 @@ def as_dict(self) -> dict[str, Any]: 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) + result[key] = self.get(key) return result def parent(self) -> Object | None: @@ -221,7 +249,7 @@ def instance_type(self) -> InstanceType | None: return self.get("instanceType") @property - def system_flags(self) -> SystemFlags | None: + def system_flags(self) -> SystemFlag | None: """Return the object's system flags.""" return self.get("systemFlags") @@ -240,9 +268,7 @@ def distinguished_name(self) -> DN | None: @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 + return self.get("nTSecurityDescriptor") @cached_property def well_known_objects(self) -> list[Object]: diff --git a/dissect/database/ese/ntds/objects/organizationalperson.py b/dissect/database/ese/ntds/objects/organizationalperson.py index 9aba498..e6b3564 100644 --- a/dissect/database/ese/ntds/objects/organizationalperson.py +++ b/dissect/database/ese/ntds/objects/organizationalperson.py @@ -13,6 +13,21 @@ class OrganizationalPerson(Person): __object_class__ = "organizationalPerson" @property - def city(self) -> str: + def city(self) -> str | None: """Return the city (l) of this organizational person.""" return self.get("l") # "l" (localityName) represents the city/locality. + + @property + def mail(self) -> str | None: + """Return the mail address of this organizational person.""" + return self.get("mail") + + @property + def title(self) -> str | None: + """Return the title of this organizational person.""" + return self.get("title") + + @property + def allowed_to_act_on_behalf_of_other_identity(self) -> str | None: + """Return the msDS-AllowedToActOnBehalfOfOtherIdentity attribute of this organizational person.""" + return self.get("msDS-AllowedToActOnBehalfOfOtherIdentity") diff --git a/dissect/database/ese/ntds/objects/organizationalunit.py b/dissect/database/ese/ntds/objects/organizationalunit.py index b24c6fd..4802a0a 100644 --- a/dissect/database/ese/ntds/objects/organizationalunit.py +++ b/dissect/database/ese/ntds/objects/organizationalunit.py @@ -1,13 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from dissect.database.ese.ntds.objects.top import Top +from dissect.database.ese.ntds.util import GroupPolicyOption if TYPE_CHECKING: from collections.abc import Iterator from dissect.database.ese.ntds.objects import Object + from dissect.database.ese.ntds.objects.object import DecoderMap class OrganizationalUnit(Top): @@ -18,6 +20,29 @@ class OrganizationalUnit(Top): """ __object_class__ = "organizationalUnit" + __decoders__: ClassVar[DecoderMap] = { + "gPOptions": lambda db, value: GroupPolicyOption(value), + } + + @property + def gp_link(self) -> str | None: + """Return the group policy link of the organizational unit.""" + return self.get("gPLink") + + @property + def gp_options(self) -> int | None: + """Return the group policy options of the organizational unit.""" + return self.get("gPOptions") + + @property + def telephone_number(self) -> str | None: + """Return the telephone number of this organizational unit.""" + return self.get("telephoneNumber") + + @property + def user_password(self) -> str | None: + """Return the userPassword of this organizational unit.""" + return self.get("userPassword") def managed_by(self) -> Iterator[Object]: """Return the objects that manage this organizational unit.""" diff --git a/dissect/database/ese/ntds/objects/person.py b/dissect/database/ese/ntds/objects/person.py index 857c2da..06baf6e 100644 --- a/dissect/database/ese/ntds/objects/person.py +++ b/dissect/database/ese/ntds/objects/person.py @@ -11,3 +11,13 @@ class Person(Top): """ __object_class__ = "person" + + @property + def telephone_number(self) -> str | None: + """Return the telephone number of this person.""" + return self.get("telephoneNumber") + + @property + def user_password(self) -> str | None: + """Return the userPassword attribute of this person.""" + return self.get("userPassword") diff --git a/dissect/database/ese/ntds/objects/pkienrollmentservice.py b/dissect/database/ese/ntds/objects/pkienrollmentservice.py index 3f6f989..a773bb9 100644 --- a/dissect/database/ese/ntds/objects/pkienrollmentservice.py +++ b/dissect/database/ese/ntds/objects/pkienrollmentservice.py @@ -11,3 +11,8 @@ class PKIEnrollmentService(Top): """ __object_class__ = "pKIEnrollmentService" + + @property + def dns_host_name(self) -> str | None: + """Return the dNSHostName of this PKI Enrollment Service.""" + return self.get("dNSHostName") diff --git a/dissect/database/ese/ntds/objects/server.py b/dissect/database/ese/ntds/objects/server.py index 6ca4a56..f9e8625 100644 --- a/dissect/database/ese/ntds/objects/server.py +++ b/dissect/database/ese/ntds/objects/server.py @@ -19,6 +19,11 @@ class Server(Top): __object_class__ = "server" + @property + def dns_host_name(self) -> str | None: + """Return the dNSHostName of this server.""" + return self.get("dNSHostName") + def computer(self) -> Computer | None: """Return the computer object associated with this server, if any.""" self._assert_local() diff --git a/dissect/database/ese/ntds/objects/site.py b/dissect/database/ese/ntds/objects/site.py index b41c61c..86061f7 100644 --- a/dissect/database/ese/ntds/objects/site.py +++ b/dissect/database/ese/ntds/objects/site.py @@ -1,13 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from dissect.database.ese.ntds.objects.top import Top +from dissect.database.ese.ntds.util import GroupPolicyOption if TYPE_CHECKING: from collections.abc import Iterator from dissect.database.ese.ntds.objects import Object + from dissect.database.ese.ntds.objects.object import DecoderMap class Site(Top): @@ -18,6 +20,19 @@ class Site(Top): """ __object_class__ = "site" + __decoders__: ClassVar[DecoderMap] = { + "gPOptions": lambda db, value: GroupPolicyOption(value), + } + + @property + def gp_link(self) -> str: + """Return the group policy link of the site.""" + return self.get("gPLink") + + @property + def gp_options(self) -> int: + """Return the group policy options of the site.""" + return self.get("gPOptions", 0) def managed_by(self) -> Iterator[Object]: """Return the objects that manage this site.""" diff --git a/dissect/database/ese/ntds/objects/top.py b/dissect/database/ese/ntds/objects/top.py index 4fc9870..e3cd6c8 100644 --- a/dissect/database/ese/ntds/objects/top.py +++ b/dissect/database/ese/ntds/objects/top.py @@ -19,3 +19,8 @@ def __repr_body__(self) -> str: def display_name(self) -> str | None: """Return the displayName for this object.""" return self.get("displayName") + + @property + def description(self) -> str | None: + """Return the description for this object.""" + return self.get("description") diff --git a/dissect/database/ese/ntds/objects/trusteddomain.py b/dissect/database/ese/ntds/objects/trusteddomain.py index fbe858e..0f591a2 100644 --- a/dissect/database/ese/ntds/objects/trusteddomain.py +++ b/dissect/database/ese/ntds/objects/trusteddomain.py @@ -1,6 +1,12 @@ from __future__ import annotations +from typing import TYPE_CHECKING, ClassVar + from dissect.database.ese.ntds.objects.leaf import Leaf +from dissect.database.ese.ntds.util import TrustAttribute, TrustDirection, TrustType + +if TYPE_CHECKING: + from dissect.database.ese.ntds.objects.object import DecoderMap class TrustedDomain(Leaf): @@ -11,3 +17,33 @@ class TrustedDomain(Leaf): """ __object_class__ = "trustedDomain" + __decoders__: ClassVar[DecoderMap] = { + "trustType": lambda db, value: TrustType(value), + "trustDirection": lambda db, value: TrustDirection(value), + "trustAttributes": lambda db, value: TrustAttribute(value), + } + + @property + def trust_type(self) -> TrustType | None: + """Return the trustType of this trusted domain.""" + return self.get("trustType") + + @property + def trust_direction(self) -> TrustDirection | None: + """Return the trustDirection of this trusted domain.""" + return self.get("trustDirection") + + @property + def trust_attributes(self) -> TrustAttribute | None: + """Return the trustAttributes of this trusted domain.""" + return self.get("trustAttributes") + + @property + def trust_partner(self) -> str | None: + """Return the trustPartner of this trusted domain.""" + return self.get("trustPartner") + + @property + def security_identifier(self) -> str | None: + """Return the securityIdentifier of this trusted domain.""" + return self.get("securityIdentifier") diff --git a/dissect/database/ese/ntds/objects/user.py b/dissect/database/ese/ntds/objects/user.py index 107e84e..b4954ba 100644 --- a/dissect/database/ese/ntds/objects/user.py +++ b/dissect/database/ese/ntds/objects/user.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar + +from dissect.util.ts import wintimestamp from dissect.database.ese.ntds.objects.organizationalperson import OrganizationalPerson -from dissect.database.ese.ntds.util import UserAccountControl +from dissect.database.ese.ntds.util import SAMAccountType, UserAccountControl if TYPE_CHECKING: from collections.abc import Iterator + from datetime import datetime from dissect.database.ese.ntds.objects.group import Group - from dissect.database.ese.ntds.objects.object import Object - from dissect.database.ese.ntds.util import SAMAccountType + from dissect.database.ese.ntds.objects.object import DecoderMap, Object class User(OrganizationalPerson): @@ -21,6 +23,16 @@ class User(OrganizationalPerson): """ __object_class__ = "user" + __decoders__: ClassVar[DecoderMap] = { + "sAMAccountType": lambda db, value: SAMAccountType(value), + "userAccountControl": lambda db, value: UserAccountControl(value), + "badPasswordTime": lambda db, value: None if value == 0 else wintimestamp(value), + "lastLogonTimestamp": lambda db, value: None if value == 0 else wintimestamp(value), + "lastLogon": lambda db, value: None if value == 0 else wintimestamp(value), + "lastLogoff": lambda db, value: None if value == 0 else wintimestamp(value), + "pwdLastSet": lambda db, value: None if value == 0 else wintimestamp(value), + "accountExpires": lambda db, value: None if value in (0, ((1 << 63) - 1)) else wintimestamp(value), + } 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 @@ -45,6 +57,61 @@ def user_account_control(self) -> UserAccountControl: """Return the user's userAccountControl flags.""" return self.get("userAccountControl") + @property + def user_principal_name(self) -> str | None: + """Return the user's userPrincipalName.""" + return self.get("userPrincipalName") + + @property + def service_principal_name(self) -> list[str]: + """Return the user's servicePrincipalName.""" + return self.get("servicePrincipalName") or [] + + @property + def home_directory(self) -> str | None: + """Return the user's home directory.""" + return self.get("homeDirectory") + + @property + def home_drive(self) -> str | None: + """Return the user's home drive.""" + return self.get("homeDrive") + + @property + def script_path(self) -> str | None: + """Return the user's script path.""" + return self.get("scriptPath") + + @property + def password_last_set(self) -> datetime | None: + """Return the last time the user's password was set.""" + return self.get("pwdLastSet") + + @property + def logon_last_failed(self) -> datetime | None: + """Return the last time the user had a failed logon attempt.""" + return self.get("badPasswordTime") + + @property + def logon_last_local(self) -> datetime | None: + """Return the last time the user had a successful local logon.""" + return self.get("lastLogon") + + @property + def logon_last_replicated(self) -> datetime | None: + """Return the last time the user had a successful logon replicated across domain controllers.""" + return self.get("lastLogonTimestamp") + + @property + def account_expires(self) -> datetime | None: + """Return the time the user's account expires.""" + return self.get("accountExpires") + + @property + def admin_count(self) -> int | None: + """Return the user's adminCount.""" + return self.get("adminCount") + def is_machine_account(self) -> bool: """Return whether this user is a machine account.""" return UserAccountControl.WORKSTATION_TRUST_ACCOUNT in self.user_account_control diff --git a/dissect/database/ese/ntds/schema.py b/dissect/database/ese/ntds/schema.py index f043b41..7677a73 100644 --- a/dissect/database/ese/ntds/schema.py +++ b/dissect/database/ese/ntds/schema.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from dissect.database.ese.ntds.database import Database - from dissect.database.ese.ntds.util import SearchFlags + from dissect.database.ese.ntds.util import SearchFlag # These are fixed columns in the NTDS database # They do not exist in the schema, but are required for basic operation @@ -121,7 +121,7 @@ class AttributeEntry(NamedTuple): om_object_class: bytes | None is_single_valued: bool link_id: int | None - search_flags: SearchFlags | None + search_flags: SearchFlag | None class Schema: @@ -255,7 +255,7 @@ def _add_attribute( om_object_class: bytes | None, is_single_valued: bool, link_id: int | None, - search_flags: SearchFlags | None, + search_flags: SearchFlag | None, ) -> None: entry = AttributeEntry( dnt=dnt, diff --git a/dissect/database/ese/ntds/util.py b/dissect/database/ese/ntds/util.py index 46a4d76..e29dc0d 100644 --- a/dissect/database/ese/ntds/util.py +++ b/dissect/database/ese/ntds/util.py @@ -3,7 +3,6 @@ import struct from enum import Flag, IntEnum, IntFlag, auto 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 @@ -18,7 +17,7 @@ from dissect.database.ese.ntds.schema import AttributeEntry -class DatabaseFlags(Flag): +class DatabaseFlag(Flag): """Database flags that are stored in the hiddentable. The flags are weirdly stored as ``1``, ``0`` or ``\x00`` in a byte array. @@ -45,19 +44,71 @@ class InstanceType(IntFlag): 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/windows/win32/adschema/a-systemflags +# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/1e38247d-8234-4273-9de3-bbf313548631 +class SystemFlag(IntFlag): + # The first 3 flags have an overlap whether it's set on an attributeSchema or crossRef object + # We don't specify them here, see SystemFlagAttribute and SystemFlagCrossRef + + # The following flags are also specific to certain objects, but have no overlap + ATTR_IS_OPERATIONAL = 0x00000008 + SCHEMA_BASE_OBJECT = 0x00000010 + ATTR_IS_RDN = 0x00000020 + DISALLOW_MOVE_ON_DELETE = 0x02000000 + DOMAIN_DISALLOW_MOVE = 0x04000000 + DOMAIN_DISALLOW_RENAME = 0x08000000 + CONFIG_ALLOW_LIMITED_MOVE = 0x10000000 + CONFIG_ALLOW_MOVE = 0x20000000 + CONFIG_ALLOW_RENAME = 0x40000000 + DISALLOW_DELETE = 0x80000000 + + +# System flags that overlap with other flags and are specific to attributeSchema objects +class SystemFlagAttribute(IntFlag): + ATTR_NOT_REPLICATED = 0x00000001 + ATTR_REQ_PARTIAL_SET_MEMBER = 0x00000002 + ATTR_IS_CONSTRUCTED = 0x00000004 + + # TODO: When we drop Python 3.10 support, we can subclass SystemFlag + # For now, just duplicate the flags here + ATTR_IS_OPERATIONAL = 0x00000008 + SCHEMA_BASE_OBJECT = 0x00000010 + ATTR_IS_RDN = 0x00000020 + DISALLOW_MOVE_ON_DELETE = 0x02000000 + DOMAIN_DISALLOW_MOVE = 0x04000000 + DOMAIN_DISALLOW_RENAME = 0x08000000 + CONFIG_ALLOW_LIMITED_MOVE = 0x10000000 + CONFIG_ALLOW_MOVE = 0x20000000 + CONFIG_ALLOW_RENAME = 0x40000000 + DISALLOW_DELETE = 0x80000000 + + +# For better readability when printing attributeSchema objects, we reset the name +SystemFlagAttribute.__name__ = "SystemFlag" + + +# System flags that overlap with other flags and are specific to crossRef objects +class SystemFlagCrossRef(IntFlag): + CR_NTDS_NC = 0x00000001 + CR_NTDS_DOMAIN = 0x00000002 + CR_NTDS_NOT_GC_REPLICATED = 0x00000004 + + # TODO: When we drop Python 3.10 support, we can subclass SystemFlag + # For now, just duplicate the flags here + ATTR_IS_OPERATIONAL = 0x00000008 + SCHEMA_BASE_OBJECT = 0x00000010 + ATTR_IS_RDN = 0x00000020 + DISALLOW_MOVE_ON_DELETE = 0x02000000 + DOMAIN_DISALLOW_MOVE = 0x04000000 + DOMAIN_DISALLOW_RENAME = 0x08000000 + CONFIG_ALLOW_LIMITED_MOVE = 0x10000000 + CONFIG_ALLOW_MOVE = 0x20000000 + CONFIG_ALLOW_RENAME = 0x40000000 + DISALLOW_DELETE = 0x80000000 + + +# For better readability when printing crossRef objects, we reset the name +SystemFlagCrossRef.__name__ = "SystemFlag" # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/dd302fd1-0aa7-406b-ad91-2a6b35738557 @@ -98,7 +149,7 @@ class SAMAccountType(IntEnum): SAM_APP_QUERY_GROUP = 0x40000001 -class SearchFlags(IntFlag): +class SearchFlag(IntFlag): Indexed = 0x00000001 ContainerIndexed = 0x00000002 Anr = 0x00000004 @@ -109,6 +160,41 @@ class SearchFlags(IntFlag): Confidential = 0x00000080 +class TrustType(IntEnum): + DOWNLEVEL = 0x00000001 + UPLEVEL = 0x00000002 + MIT = 0x00000003 + DCE = 0x00000004 + AAD = 0x00000005 + + +class TrustDirection(IntEnum): + DISABLED = 0 + INBOUND = 1 + OUTBOUND = 2 + BIDIRECTIONAL = 3 + + +class TrustAttribute(IntFlag): + NON_TRANSITIVE = 0x00000001 + UPLEVEL_ONLY = 0x00000002 + FILTER_SIDS = 0x00000004 + FOREST_TRANSITIVE = 0x00000008 + CROSS_ORGANIZATION = 0x00000010 + WITHIN_FOREST = 0x00000020 + TREAT_AS_EXTERNAL = 0x00000040 + TRUST_USES_RC4_ENCRYPTION = 0x00000080 + TRUST_USES_AES_KEYS = 0x00000100 + CROSS_ORGANIZATION_NO_TGT_DELEGATION = 0x00000200 + PIM_TRUST = 0x00000400 + TREE_PARENT = 0x00400000 + TREE_ROOT = 0x00800000 + + +class GroupPolicyOption(IntFlag): + BLOCK_POLICY = 0x00000001 + + def _pek_decrypt(db: Database, value: bytes) -> bytes: """Decrypt a PEK-encrypted blob using the database's PEK, if it's unlocked. @@ -255,22 +341,6 @@ def _decode_pwd_history(db: Database, value: list[bytes]) -> list[bytes]: 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(" tuple[int, bytes]: # TODO: Object(DN-String); A DN-String plus a Unicode string 14: (None, None), # NTSecurityDescriptor; A security descriptor - 15: (None, lambda db, value: int.from_bytes(value, byteorder="little")), + 15: (None, lambda db, value: db.sd.sd(int.from_bytes(value, byteorder="little"))), # LargeInteger; A 64-bit number 16: (None, lambda db, value: int(value)), # String(Sid); Security identifier (SID) diff --git a/tests/ese/ntds/test_ntds.py b/tests/ese/ntds/test_ntds.py index da29394..cfc6a8b 100644 --- a/tests/ese/ntds/test_ntds.py +++ b/tests/ese/ntds/test_ntds.py @@ -7,7 +7,15 @@ import pytest from dissect.database.ese.ntds.objects import Computer, Group, GroupPolicyContainer, Server, SubSchema, User -from dissect.database.ese.ntds.util import SAMAccountType +from dissect.database.ese.ntds.util import ( + SAMAccountType, + SystemFlagAttribute, + SystemFlagCrossRef, + TrustAttribute, + TrustDirection, + TrustType, + UserAccountControl, +) if TYPE_CHECKING: from dissect.database.ese.ntds import NTDS @@ -276,6 +284,27 @@ def test_object_repr(goad: NTDS) -> None: ) +def test_object_as_dict(goad: NTDS) -> None: + """Test the as_dict method of the Object class.""" + user = next(goad.search(sAMAccountName="jon.snow")) + assert isinstance(user, User) + + user_dict = user.as_dict() + assert isinstance(user_dict, dict) + assert user_dict["sAMAccountName"] == "jon.snow" + assert user_dict["cn"] == "jon.snow" + assert user_dict["l"] == "Castel Black" + assert user_dict["objectSid"] == "S-1-5-21-459184689-3312531310-188885708-1118" + + # Specifically test that the decoders are applied when using as_dict + assert ( + user_dict["userAccountControl"] + == UserAccountControl.NORMAL_ACCOUNT + | UserAccountControl.DONT_EXPIRE_PASSWORD + | UserAccountControl.TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION + ) + + def test_all_memberships(large: NTDS) -> None: """Test all memberships of all users.""" for user in large.users(): @@ -322,6 +351,72 @@ def test_backup_keys(goad: NTDS) -> None: ) +def test_domains(goad: NTDS) -> None: + """Test retrieval of domain objects.""" + domains = sorted(goad.domains(), key=lambda x: x.distinguished_name) + assert len(domains) == 5 + assert [x.distinguished_name for x in domains] == [ + "DC=DOMAINDNSZONES,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "DC=DOMAINDNSZONES,DC=SEVENKINGDOMS,DC=LOCAL", + "DC=FORESTDNSZONES,DC=SEVENKINGDOMS,DC=LOCAL", + "DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "DC=SEVENKINGDOMS,DC=LOCAL", + ] + + domain = domains[-1] + assert domain.behavior_version == 7 + + +def test_trusts(goad: NTDS) -> None: + """Test retrieval of trust objects.""" + trusts = sorted(goad.trusts(), key=lambda x: x.distinguished_name) + assert len(trusts) == 3 + assert trusts[0].distinguished_name == "CN=ESSOS.LOCAL,CN=SYSTEM,DC=SEVENKINGDOMS,DC=LOCAL" + assert trusts[0].trust_type == TrustType.UPLEVEL + assert trusts[0].trust_direction == TrustDirection.BIDIRECTIONAL + assert trusts[0].trust_attributes == TrustAttribute.FOREST_TRANSITIVE | TrustAttribute.TREAT_AS_EXTERNAL + assert trusts[0].trust_partner == "essos.local" + assert trusts[0].security_identifier == "S-1-5-21-1398578290-1256418943-189470967" + + +def test_organizational_units(goad: NTDS) -> None: + ous = sorted(goad.organizational_units(), key=lambda x: x.distinguished_name) + assert len(ous) == 10 + assert [x.distinguished_name for x in ous] == [ + "OU=CROWNLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=DOMAIN CONTROLLERS,DC=NORTH,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=DOMAIN CONTROLLERS,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=DORNE,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=IRONISLANDS\nDEL:D58E6F7A-DA60-40AE-88C1-27A4FCEE1190,CN=DELETED OBJECTS,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=REACH,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=RIVERLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=STORMLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=VALE,DC=SEVENKINGDOMS,DC=LOCAL", + "OU=WESTERLANDS,DC=SEVENKINGDOMS,DC=LOCAL", + ] + + dc_ou = ous[2] + assert ( + dc_ou.gp_link + == "[LDAP://CN={6AC1786C-016F-11D2-945F-00C04fB984F9},CN=Policies,CN=System,DC=sevenkingdoms,DC=local;0]" + ) + assert dc_ou.gp_options is None + + +def test_system_flags(goad: NTDS) -> None: + """Test the difference between system flags on attributeSchema and crossRef objects.""" + crossref = next(goad.search(objectClass="crossRef")) + assert crossref.system_flags == SystemFlagCrossRef.CR_NTDS_NC + + attribute = next(goad.search(objectClass="attributeSchema", name="ANR")) + assert ( + attribute.system_flags + == SystemFlagAttribute.ATTR_IS_CONSTRUCTED + | SystemFlagAttribute.SCHEMA_BASE_OBJECT + | SystemFlagAttribute.DOMAIN_DISALLOW_RENAME + ) + + def test_fve_recovery_information(fve: NTDS) -> None: """Test retrieval of BitLocker recovery information.""" computer = next(c for c in fve.computers() if c.name == "WIN11") diff --git a/tests/ese/ntds/test_pek.py b/tests/ese/ntds/test_pek.py index 4b87bff..3815f0c 100644 --- a/tests/ese/ntds/test_pek.py +++ b/tests/ese/ntds/test_pek.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from dissect.database.ese.ntds.util import DatabaseFlags +from dissect.database.ese.ntds.util import DatabaseFlag if TYPE_CHECKING: from dissect.database.ese.ntds import NTDS @@ -46,7 +46,7 @@ def test_pek(goad: NTDS) -> None: def test_pek_adam(adam: NTDS) -> None: """Test PEK unlocking and decryption for AD LDS NTDS.dit.""" - assert DatabaseFlags.ADAM in adam.db.flags + assert DatabaseFlag.ADAM in adam.db.flags # The PEK in AD LDS is derived within the database itself assert adam.pek.unlocked