diff --git a/dissect/database/ese/ntds/objects/object.py b/dissect/database/ese/ntds/objects/object.py index d7c435b..3f232f8 100644 --- a/dissect/database/ese/ntds/objects/object.py +++ b/dissect/database/ese/ntds/objects/object.py @@ -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) @@ -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 diff --git a/dissect/database/ese/ntds/util.py b/dissect/database/ese/ntds/util.py index 6587b77..30c2834 100644 --- a/dissect/database/ese/ntds/util.py +++ b/dissect/database/ese/ntds/util.py @@ -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] ] = { @@ -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. @@ -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 diff --git a/tests/ese/ntds/test_pek.py b/tests/ese/ntds/test_pek.py index 49a32a5..33249a4 100644 --- a/tests/ese/ntds/test_pek.py +++ b/tests/ese/ntds/test_pek.py @@ -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: