Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion dissect/database/ese/ntds/objects/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def from_record(cls, db: Database, record: Record) -> Object:
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 (
if (object_classes := _get_attribute(db, record, "objectClass")) and (
known_cls := cls.__known_classes__.get(object_classes[0])
) is not None:
return known_cls(db, record)
Expand Down Expand Up @@ -261,6 +261,10 @@ def _get_attribute(db: Database, record: Record, name: str, *, raw: bool = False
# There are a few attributes that have the flag IsSingleValued but are marked as MultiValue in ESE
value = value[0]

if not schema.is_single_valued and value is None:
# Return an empty list for multi-valued attributes that are not set
value = []

if raw:
return value

Expand Down
38 changes: 37 additions & 1 deletion dissect/database/ese/ntds/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,29 @@ def _decode_supplemental_credentials(db: Database, value: bytes) -> dict[str, by
return result


def _decode_pwd_history(db: Database, value: list[bytes]) -> list[bytes]:
"""Decode the ``ntPwdHistory`` or ``lmPwdHistory`` attribute value.

Args:
db: The associated NTDS database instance.
value: The raw list of bytes values for the password history attribute.

Returns:
A list of decrypted password hashes, or the original value if the PEK is locked.
"""
if db.data.pek is None or not db.data.pek.unlocked:
return value

result = []
for buf in value:
buf = db.data.pek.decrypt(buf)
# The history attributes can contain multiple hashes concatenated together, so we need to split them up
# NT and LM hashes are both 16 bytes long
result.extend(buf[i : i + 16] for i in range(0, len(buf), 16))

return result


ATTRIBUTE_ENCODE_DECODE_MAP: dict[
str, tuple[Callable[[Database, Any], Any] | None, Callable[[Database, Any], Any] | None]
] = {
Expand Down Expand Up @@ -314,6 +337,13 @@ def _decode_supplemental_credentials(db: Database, value: bytes) -> dict[str, by
"msDS-ExecuteScriptPassword": (None, _pek_decrypt),
}

ATTRIBUTE_LIST_ENCODE_DECODE_MAP: dict[
str, tuple[Callable[[Database, list[Any]], list[Any]], Callable[[Database, list[Any]], list[Any]]]
] = {
"ntPwdHistory": (None, _decode_pwd_history),
"lmPwdHistory": (None, _decode_pwd_history),
}


def _ldapDisplayName_to_DNT(db: Database, value: str) -> int | str:
"""Convert an LDAP display name to its corresponding DNT value.
Expand Down Expand Up @@ -494,7 +524,13 @@ def decode_value(db: Database, attribute: str, value: Any) -> Any:
if value is None:
return value

# First check the list of deviations
# First check if we have a special decoder for this attribute
# Check for special handing of multi-valued attributes first
if isinstance(value, list):
_, decode = ATTRIBUTE_LIST_ENCODE_DECODE_MAP.get(attribute, (None, None))
if decode is not None:
return decode(db, value)

_, decode = ATTRIBUTE_ENCODE_DECODE_MAP.get(attribute, (None, None))
if decode is None:
# Next, try it using the regular OID_ENCODE_DECODE_MAP mapping
Expand Down
22 changes: 18 additions & 4 deletions tests/ese/ntds/test_pek.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,36 @@ def test_pek(goad: NTDS) -> None:
"""Test PEK unlocking and decryption."""
syskey = bytes.fromhex("079f95655b66f16deb28aa1ab3a81eb0")

user = next(goad.users(), None)
user = next((u for u in goad.users() if u.name == "ESSOS$"), None)
assert user is not None

encrypted = user.unicodePwd
# Verify encrypted value
assert encrypted == bytes.fromhex(
"130000000000000029fbdaafb52bf724a51052f668152ac5100000006d06616d95c026064fff245bd256f3d4990f7bffb546f76de566723da4855227"
"1300000000000000248a47921aa22e6886017494709f23bb10000000708afcb8360cfb0b4a972c5bc65b2864540436aad24654c2e037c83eafe70d43"
)

assert user.lmPwdHistory == [
bytes.fromhex(
"1300000000000000771e30dbd13e7f1a641ecd8e3ec85765200000005e5c46e1eb6ffaff7bee7fa75a092215e5c9bc34e1223a09322f9c15260310b98b30a2045e2f1bc8dcab1ad8b8ce13c3"
)
]

goad.pek.unlock(syskey)
assert goad.pek.unlocked

# Test decryption of the user's password
assert goad.pek.decrypt(encrypted) == bytes.fromhex("06bb564317712dc60761a32914e4048c")
assert goad.pek.decrypt(encrypted) == bytes.fromhex("909e2178d8b7944d60a5cd2053fef570")
# Should work transparently now too
assert user.unicodePwd == bytes.fromhex("06bb564317712dc60761a32914e4048c")
assert user.unicodePwd == bytes.fromhex("909e2178d8b7944d60a5cd2053fef570")
assert user.lmPwdHistory == [
bytes.fromhex("f4badffd76f158087909e33b4e4b40c1"),
bytes.fromhex("4383d43a2d9bbc9bda43c5a3d0e4f38c"),
]
assert user.ntPwdHistory == [
bytes.fromhex("909e2178d8b7944d60a5cd2053fef570"),
bytes.fromhex("909e2178d8b7944d60a5cd2053fef570"),
]


def test_pek_adam(adam: NTDS) -> None:
Expand Down