Skip to content

Commit d5b7986

Browse files
committed
More cleanup
1 parent 5299040 commit d5b7986

7 files changed

Lines changed: 198 additions & 38 deletions

File tree

‎dissect/database/ese/ntds/database.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def _lookup_dnt(self, dnt: int) -> Object:
7777
Args:
7878
dnt: The DNT to look up.
7979
"""
80-
record = self.table.index("DNT_index").cursor().find(DNT_col=dnt)
80+
record = self.table.index("DNT_index").cursor().search(DNT_col=dnt)
8181
return Object.from_record(self.db, record)
8282

8383
def _make_dn(self, dnt: int) -> str:

‎dissect/database/ese/ntds/ntds.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class NTDS:
2929
def __init__(self, fh: BinaryIO):
3030
self.db = Database(fh)
3131

32+
def top(self) -> Object:
33+
"""Return the top-level object in the NTDS database."""
34+
return self.db.data._lookup_dnt(2) # DNT 2 is always the top object
35+
3236
def query(self, query: str, *, optimize: bool = True) -> Iterator[Object]:
3337
"""Execute an LDAP query against the NTDS database.
3438

‎dissect/database/ese/ntds/object.py‎

Lines changed: 169 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import struct
34
from functools import cached_property
45
from typing import TYPE_CHECKING, Any, ClassVar
56

@@ -8,9 +9,10 @@
89

910
if TYPE_CHECKING:
1011
from collections.abc import Iterator
12+
from datetime import datetime
1113

1214
from dissect.database.ese.ntds.database import Database
13-
from dissect.database.ese.ntds.sd import ACL, SecurityDescriptor
15+
from dissect.database.ese.ntds.sd import SecurityDescriptor
1416
from dissect.database.ese.record import Record
1517

1618

@@ -78,11 +80,63 @@ def as_dict(self) -> dict[str, Any]:
7880

7981
return result
8082

83+
def parent(self) -> Object | None:
84+
"""Return the parent object of this object, if any."""
85+
if (pdnt := self.get("Pdnt")) is not None:
86+
return self.db.data._lookup_dnt(pdnt)
87+
return None
88+
89+
def ancestors(self) -> Iterator[Object]:
90+
"""Yield all ancestor objects of this object."""
91+
for (dnt,) in list(struct.iter_unpack("<I", self.get("Ancestors")))[::-1]:
92+
yield self.db.data._lookup_dnt(dnt)
93+
94+
def children(self) -> Iterator[Object]:
95+
"""Yield all child objects of this object."""
96+
# Our query code currently doesn't really work nicely on this specific query, so just do it manually for now
97+
cursor = self.db.data.table.index("PDNT_index").cursor()
98+
cursor.seek(PDNT_col=self.DNT + 1)
99+
end = cursor.record()
100+
101+
cursor.reset()
102+
cursor.seek(PDNT_col=self.DNT)
103+
104+
record = cursor.record()
105+
while record != end:
106+
yield Object.from_record(self.db, record)
107+
record = cursor.next()
108+
109+
# Some commonly used properties, for convenience and type hinting
110+
@property
111+
def dnt(self) -> int:
112+
"""Return the object's Directory Number Tag (DNT)."""
113+
return self.get("DNT")
114+
115+
@property
116+
def pdnt(self) -> int | None:
117+
"""Return the object's Parent Directory Number Tag (PDNT)."""
118+
return self.get("Pdnt")
119+
120+
@property
121+
def ncdnt(self) -> int | None:
122+
"""Return the object's Naming Context Directory Number Tag (NCDNT)."""
123+
return self.get("NCDNT")
124+
125+
@property
126+
def name(self) -> str | None:
127+
"""Return the object's name."""
128+
return self.get("name")
129+
81130
@property
82131
def sid(self) -> str | None:
83132
"""Return the object's Security Identifier (SID)."""
84133
return self.get("objectSid")
85134

135+
@property
136+
def guid(self) -> str | None:
137+
"""Return the object's GUID."""
138+
return self.get("objectGUID")
139+
86140
@property
87141
def distinguishedName(self) -> str | None:
88142
"""Return the fully qualified Distinguished Name (DN) for this object."""
@@ -100,18 +154,122 @@ def sd(self) -> SecurityDescriptor | None:
100154
return None
101155

102156
@property
103-
def sacl(self) -> ACL | None:
104-
"""Return the System Access Control List (SACL) for this object."""
105-
if (sd := self.sd) is not None:
106-
return sd.sacl
107-
return None
157+
def when_created(self) -> datetime | None:
158+
"""Return the object's creation time."""
159+
return self.get("whenCreated")
108160

109161
@property
110-
def dacl(self) -> ACL | None:
111-
"""Return the Discretionary Access Control List (DACL) for this object."""
112-
if (sd := self.sd) is not None:
113-
return sd.dacl
114-
return None
162+
def when_changed(self) -> datetime | None:
163+
"""Return the object's last modification time."""
164+
return self.get("whenChanged")
165+
166+
167+
class Domain(Object):
168+
"""Represents a domain object in the Active Directory."""
169+
170+
__object_class__ = "domain"
171+
172+
def __repr__(self) -> str:
173+
return f"<Domain name={self.name!r}>"
174+
175+
176+
class DomainDNS(Domain):
177+
"""Represents a domain DNS object in the Active Directory."""
178+
179+
__object_class__ = "domainDNS"
180+
181+
def __repr__(self) -> str:
182+
return f"<DomainDNS name={self.name!r}>"
183+
184+
185+
class BuiltinDomain(Object):
186+
"""Represents a built-in domain object in the Active Directory."""
187+
188+
__object_class__ = "builtinDomain"
189+
190+
def __repr__(self) -> str:
191+
return f"<BuiltinDomain name={self.name!r}>"
192+
193+
194+
class Configuration(Object):
195+
"""Represents a configuration object in the Active Directory."""
196+
197+
__object_class__ = "configuration"
198+
199+
def __repr__(self) -> str:
200+
return f"<Configuration name={self.name!r}>"
201+
202+
203+
class QuotaContainer(Object):
204+
"""Represents a quota container object in the Active Directory."""
205+
206+
__object_class__ = "msDS-QuotaContainer"
207+
208+
def __repr__(self) -> str:
209+
return f"<QuotaContainer name={self.name!r}>"
210+
211+
212+
class CrossRefContainer(Object):
213+
"""Represents a cross-reference container object in the Active Directory."""
214+
215+
__object_class__ = "crossRefContainer"
216+
217+
def __repr__(self) -> str:
218+
return f"<CrossRefContainer name={self.name!r}>"
219+
220+
221+
class SitesContainer(Object):
222+
"""Represents a sites container object in the Active Directory."""
223+
224+
__object_class__ = "sitesContainer"
225+
226+
def __repr__(self) -> str:
227+
return f"<SitesContainer name={self.name!r}>"
228+
229+
230+
class Locality(Object):
231+
"""Represents a locality object in the Active Directory."""
232+
233+
__object_class__ = "locality"
234+
235+
def __repr__(self) -> str:
236+
return f"<Locality name={self.name!r}>"
237+
238+
239+
class PhysicalLocation(Object):
240+
"""Represents a physical location object in the Active Directory."""
241+
242+
__object_class__ = "physicalLocation"
243+
244+
def __repr__(self) -> str:
245+
return f"<PhysicalLocation name={self.name!r}>"
246+
247+
248+
class Container(Object):
249+
"""Represents a container object in the Active Directory."""
250+
251+
__object_class__ = "container"
252+
253+
def __repr__(self) -> str:
254+
return f"<Container name={self.name!r}>"
255+
256+
257+
class OrganizationalUnit(Object):
258+
"""Represents an organizational unit object in the Active Directory."""
259+
260+
__object_class__ = "organizationalUnit"
261+
262+
def __repr__(self) -> str:
263+
return f"<OrganizationalUnit name={self.name!r}>"
264+
265+
266+
class LostAndFound(Object):
267+
"""Represents a lost and found object in the Active Directory."""
268+
269+
__object_class__ = "lostAndFound"
270+
271+
def __repr__(self) -> str:
272+
return f"<LostAndFound name={self.name!r}>"
115273

116274

117275
class Group(Object):

‎dissect/database/ese/ntds/query.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,15 @@ def _query_database(self, filter: SearchFilter) -> Iterator[Record]:
7979
raise ValueError(f"No column mapping found for attribute {filter.attribute!r}")
8080

8181
# Get the database index for this attribute
82-
if (index := self.db.data.table.find_index(column_name)) is None:
82+
if (index := self.db.data.table.find_index([column_name])) is None:
8383
raise ValueError(f"Index for attribute {column_name!r} not found in the NTDS database")
8484

85-
if "*" in filter.value and filter.value.endswith("*"):
85+
if "*" in filter.value:
8686
# Handle wildcard searches differently
87-
yield from _process_wildcard(index, column_name, filter.value)
87+
if filter.value.endswith("*"):
88+
yield from _process_wildcard_tail(index, column_name, filter.value)
89+
else:
90+
raise NotImplementedError("Wildcards in the middle or start of the value are not yet supported")
8891
else:
8992
# Exact match query
9093
encoded_value = encode_value(self.db, filter.attribute, filter.value)
@@ -157,7 +160,7 @@ def _filter_records(self, filter: SearchFilter, records: list[Record]) -> Iterat
157160
yield record
158161

159162

160-
def _process_wildcard(index: Index, column_name: str, filter_value: str) -> Iterator[Record]:
163+
def _process_wildcard_tail(index: Index, column_name: str, filter_value: str) -> Iterator[Record]:
161164
"""Handle wildcard queries using range searches.
162165
163166
Args:
@@ -173,15 +176,15 @@ def _process_wildcard(index: Index, column_name: str, filter_value: str) -> Iter
173176
# Create search bounds
174177
value = filter_value.replace("*", "")
175178
cursor.seek(**{column_name: _increment_last_char(value)})
176-
end_record = cursor.record()
179+
end = cursor.record()
177180

178181
# Seek back to the start
179182
cursor.reset()
180183
cursor.seek(**{column_name: value})
181184

182185
# Yield all records in range
183186
record = cursor.record()
184-
while record != end_record:
187+
while record != end:
185188
yield record
186189
record = cursor.next()
187190

‎dissect/database/ese/ntds/schema.py‎

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,14 @@ def from_database(cls, db: ESE) -> Schema:
141141

142142
# Load objectClasses (e.g. "person", "user", "group", etc.)
143143
for record in cursor.find_all(**{FIXED_COLUMN_MAP["objectClass"]: FIXED_OBJ_MAP["classSchema"]}):
144-
ldap_name = record.get(FIXED_COLUMN_MAP["lDAPDisplayName"])
145144
attrtyp = int(record.get(FIXED_COLUMN_MAP["governsID"]))
146-
oid = attrtyp_to_oid(attrtyp)
147-
dnt = record.get(FIXED_COLUMN_MAP["DNT"])
148145

149146
schema_index.add(
150147
SchemaEntry(
151-
dnt=dnt,
152-
oid=oid,
148+
dnt=record.get(FIXED_COLUMN_MAP["DNT"]),
149+
oid=attrtyp_to_oid(attrtyp),
153150
attrtyp=attrtyp,
154-
ldap_name=ldap_name,
151+
ldap_name=record.get(FIXED_COLUMN_MAP["lDAPDisplayName"]),
155152
)
156153
)
157154

@@ -161,22 +158,17 @@ def from_database(cls, db: ESE) -> Schema:
161158
for record in cursor.find_all(**{FIXED_COLUMN_MAP["objectClass"]: FIXED_OBJ_MAP["attributeSchema"]}):
162159
attrtyp = record.get(FIXED_COLUMN_MAP["attributeID"])
163160
type_oid = attrtyp_to_oid(record.get(FIXED_COLUMN_MAP["attributeSyntax"]))
164-
link_id = record.get(FIXED_COLUMN_MAP["linkId"])
165-
if link_id is not None:
166-
link_id = link_id // 2
167161

168-
ldap_name = record.get(FIXED_COLUMN_MAP["lDAPDisplayName"])
169-
column_name = f"ATT{OID_TO_TYPE[type_oid]}{attrtyp}"
170-
oid = attrtyp_to_oid(attrtyp)
171-
dnt = record.get(FIXED_COLUMN_MAP["DNT"])
162+
if (link_id := record.get(FIXED_COLUMN_MAP["linkId"])) is not None:
163+
link_id = link_id // 2
172164

173165
schema_index.add(
174166
SchemaEntry(
175-
dnt=dnt,
176-
oid=oid,
167+
dnt=record.get(FIXED_COLUMN_MAP["DNT"]),
168+
oid=attrtyp_to_oid(attrtyp),
177169
attrtyp=attrtyp,
178-
ldap_name=ldap_name,
179-
column_name=column_name,
170+
ldap_name=record.get(FIXED_COLUMN_MAP["lDAPDisplayName"]),
171+
column_name=f"ATT{OID_TO_TYPE[type_oid]}{attrtyp}",
180172
type_oid=type_oid,
181173
link_id=link_id,
182174
)

‎dissect/database/ese/ntds/util.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from dissect.database.ese.ntds.ntds import NTDS
1313

1414

15-
ATTRIBUTE_NORMALIZERS: dict[str, Callable[[NTDS, Any], Any]] = {
15+
ATTRIBUTE_DECODE_MAP: dict[str, Callable[[NTDS, Any], Any]] = {
1616
"badPasswordTime": lambda _, value: wintimestamp(int(value)),
1717
"lastLogonTimestamp": lambda _, value: wintimestamp(int(value)),
1818
"lastLogon": lambda _, value: wintimestamp(int(value)),
@@ -152,8 +152,11 @@ def decode_value(db: Database, attribute: str, value: Any) -> Any:
152152
Returns:
153153
The decoded value in the appropriate Python type for the attribute.
154154
"""
155+
if value is None:
156+
return value
157+
155158
# First check the list of deviations
156-
if (decode := ATTRIBUTE_NORMALIZERS.get(attribute)) is None:
159+
if (decode := ATTRIBUTE_DECODE_MAP.get(attribute)) is None:
157160
# Next, try it using the regular OID_ENCODE_DECODE_MAP mapping
158161
if (attr_entry := db.data.schema.lookup(ldap_name=attribute)) is None:
159162
return value

‎tests/ese/ntds/test_sd.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def test_dacl_specific_user(ntds_small: NTDS) -> None:
1717
user = next(u for u in ntds_small.users() if u.name == "RACHELLE_LYNN")
1818

1919
# Checked using Active Directory User and Computers (ADUC) GUI for user RACHELLE_LYNN
20-
ace = next(ace for ace in esm.dacl.ace if ace.sid == user.sid)
20+
ace = next(ace for ace in esm.sd.dacl.ace if ace.sid == user.sid)
2121
assert ACE_FLAGS.CONTAINER_INHERIT_ACE in ace.flags
2222
assert ACE_FLAGS.INHERITED_ACE in ace.flags
2323

0 commit comments

Comments
 (0)