|
| 1 | +# LAPS Encrypted Password Decryption (Windows LAPS / DPAPI-NG) |
| 2 | +import json |
| 3 | +from dataclasses import dataclass, field |
| 4 | +from typing import Any |
| 5 | + |
| 6 | +from impacket.dcerpc.v5 import transport |
| 7 | +from impacket.dcerpc.v5.epm import hept_map |
| 8 | +from impacket.dcerpc.v5.gkdi import MSRPC_UUID_GKDI, GkdiGetKey, GroupKeyEnvelope |
| 9 | +from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY |
| 10 | +from impacket.dpapi_ng import ( |
| 11 | + EncryptedPasswordBlob, |
| 12 | + KeyIdentifier, |
| 13 | + compute_kek, |
| 14 | + create_sd, |
| 15 | + decrypt_plaintext, |
| 16 | + unwrap_cek, |
| 17 | +) |
| 18 | +from pyasn1.codec.der import decoder |
| 19 | +from pyasn1_modules import rfc5652 |
| 20 | + |
| 21 | +from ..utils.logging import debug |
| 22 | +from .exceptions import LAPSError |
| 23 | + |
| 24 | +# ============================================================================= |
| 25 | +# Decryption Context |
| 26 | +# ============================================================================= |
| 27 | + |
| 28 | + |
| 29 | +@dataclass |
| 30 | +class LAPSDecryptionContext: |
| 31 | + """ |
| 32 | + Context for LAPS encrypted password decryption via MS-GKDI. |
| 33 | +
|
| 34 | + Holds authentication credentials and connection parameters needed |
| 35 | + to establish the RPC connection to the Group Key Distribution Service. |
| 36 | + """ |
| 37 | + |
| 38 | + domain: str |
| 39 | + username: str |
| 40 | + password: str | None = None |
| 41 | + lmhash: str = "" |
| 42 | + nthash: str = "" |
| 43 | + kerberos: bool = False |
| 44 | + kdc_host: str | None = None |
| 45 | + dns_server: str | None = None |
| 46 | + |
| 47 | + # Cache for Group Key Envelopes to avoid repeated RPC calls |
| 48 | + _gke_cache: dict[bytes, Any] = field(default_factory=dict) |
| 49 | + |
| 50 | + @classmethod |
| 51 | + def from_credentials( |
| 52 | + cls, |
| 53 | + domain: str, |
| 54 | + username: str, |
| 55 | + password: str | None = None, |
| 56 | + hashes: str | None = None, |
| 57 | + kerberos: bool = False, |
| 58 | + kdc_host: str | None = None, |
| 59 | + dns_server: str | None = None, |
| 60 | + ) -> "LAPSDecryptionContext": |
| 61 | + """Create context from standard authentication parameters.""" |
| 62 | + lmhash = "" |
| 63 | + nthash = "" |
| 64 | + if hashes: |
| 65 | + if ":" in hashes: |
| 66 | + lmhash, nthash = hashes.split(":") |
| 67 | + else: |
| 68 | + nthash = hashes |
| 69 | + |
| 70 | + return cls( |
| 71 | + domain=domain, |
| 72 | + username=username, |
| 73 | + password=password, |
| 74 | + lmhash=lmhash, |
| 75 | + nthash=nthash, |
| 76 | + kerberos=kerberos, |
| 77 | + kdc_host=kdc_host, |
| 78 | + dns_server=dns_server, |
| 79 | + ) |
| 80 | + |
| 81 | + |
| 82 | +# ============================================================================= |
| 83 | +# Decryption Functions |
| 84 | +# ============================================================================= |
| 85 | + |
| 86 | + |
| 87 | +def decrypt_laps_password( |
| 88 | + encrypted_blob: bytes, |
| 89 | + ctx: LAPSDecryptionContext, |
| 90 | +) -> tuple[str, str]: |
| 91 | + """ |
| 92 | + Decrypt an msLAPS-EncryptedPassword blob using MS-GKDI. |
| 93 | +
|
| 94 | + Windows LAPS encrypts passwords using DPAPI-NG (also known as CNG DPAPI). |
| 95 | + Decryption requires: |
| 96 | + 1. Parsing the encrypted blob structure |
| 97 | + 2. Extracting the Key Identifier and SID protection descriptor |
| 98 | + 3. Connecting to MS-GKDI (Group Key Distribution Interface) on the DC |
| 99 | + 4. Calling GetKey to retrieve the Group Key Envelope |
| 100 | + 5. Computing the KEK (Key Encryption Key) |
| 101 | + 6. Unwrapping the CEK (Content Encryption Key) |
| 102 | + 7. Decrypting the password JSON |
| 103 | +
|
| 104 | + Args: |
| 105 | + encrypted_blob: Raw bytes from msLAPS-EncryptedPassword attribute |
| 106 | + ctx: Decryption context with authentication credentials |
| 107 | +
|
| 108 | + Returns: |
| 109 | + Tuple of (password, username) from the decrypted JSON |
| 110 | +
|
| 111 | + Raises: |
| 112 | + LAPSError: If decryption fails |
| 113 | + """ |
| 114 | + debug("LAPS: Unpacking encrypted password blob...") |
| 115 | + |
| 116 | + try: |
| 117 | + # Parse the encrypted blob structure |
| 118 | + encrypted_laps = EncryptedPasswordBlob(encrypted_blob) |
| 119 | + cms_blob = encrypted_laps["Blob"] |
| 120 | + |
| 121 | + # Decode the CMS (PKCS#7) structure |
| 122 | + parsed_cms, remaining = decoder.decode(cms_blob, asn1Spec=rfc5652.ContentInfo()) |
| 123 | + enveloped_data_blob = parsed_cms["content"] |
| 124 | + parsed_enveloped, _ = decoder.decode(enveloped_data_blob, asn1Spec=rfc5652.EnvelopedData()) |
| 125 | + |
| 126 | + # Extract recipient info (contains the encrypted key) |
| 127 | + recipient_infos = parsed_enveloped["recipientInfos"] |
| 128 | + kek_recipient_info = recipient_infos[0]["kekri"] |
| 129 | + kek_identifier = kek_recipient_info["kekid"] |
| 130 | + |
| 131 | + # Parse the Key Identifier |
| 132 | + key_id = KeyIdentifier(bytes(kek_identifier["keyIdentifier"])) |
| 133 | + |
| 134 | + # Extract the SID from the protection descriptor |
| 135 | + tmp, _ = decoder.decode(kek_identifier["other"]["keyAttr"]) |
| 136 | + sid = tmp["field-1"][0][0][1].asOctets().decode("utf-8") |
| 137 | + target_sd = create_sd(sid) |
| 138 | + |
| 139 | + debug(f"LAPS: Key ID root: {key_id['RootKeyId']}, SID: {sid}") |
| 140 | + |
| 141 | + except Exception as e: |
| 142 | + raise LAPSError(f"Failed to parse encrypted LAPS blob: {e}") from e |
| 143 | + |
| 144 | + # Check cache for Group Key Envelope |
| 145 | + root_key_id = key_id["RootKeyId"] |
| 146 | + gke = ctx._gke_cache.get(root_key_id) |
| 147 | + |
| 148 | + if not gke: |
| 149 | + debug("LAPS: Connecting to MS-GKDI for key retrieval...") |
| 150 | + gke = _get_group_key_envelope(ctx, target_sd, key_id) |
| 151 | + ctx._gke_cache[root_key_id] = gke |
| 152 | + else: |
| 153 | + debug("LAPS: Using cached Group Key Envelope") |
| 154 | + |
| 155 | + try: |
| 156 | + # Compute the Key Encryption Key (KEK) |
| 157 | + kek = compute_kek(gke, key_id) |
| 158 | + debug(f"LAPS: Computed KEK: {kek.hex()[:32]}...") |
| 159 | + |
| 160 | + # Extract IV from content encryption parameters |
| 161 | + enc_content_param = bytes( |
| 162 | + parsed_enveloped["encryptedContentInfo"]["contentEncryptionAlgorithm"]["parameters"] |
| 163 | + ) |
| 164 | + iv, _ = decoder.decode(enc_content_param) |
| 165 | + iv = bytes(iv[0]) |
| 166 | + |
| 167 | + # Unwrap the Content Encryption Key (CEK) |
| 168 | + cek = unwrap_cek(kek, bytes(kek_recipient_info["encryptedKey"])) |
| 169 | + debug(f"LAPS: Unwrapped CEK: {cek.hex()[:32]}...") |
| 170 | + |
| 171 | + # Decrypt the password |
| 172 | + # The 'remaining' data contains the encrypted content (not in PKCS#7 structure) |
| 173 | + plaintext = decrypt_plaintext(cek, iv, remaining) |
| 174 | + |
| 175 | + # Remove padding (last 18 bytes are padding/signature) |
| 176 | + json_data = plaintext[:-18].decode("utf-16-le") |
| 177 | + debug(f"LAPS: Decrypted JSON: {json_data}") |
| 178 | + |
| 179 | + # Parse the JSON to extract password and username |
| 180 | + data = json.loads(json_data) |
| 181 | + password = data.get("p", "") |
| 182 | + username = data.get("n", "Administrator") |
| 183 | + |
| 184 | + return password, username |
| 185 | + |
| 186 | + except Exception as e: |
| 187 | + raise LAPSError(f"Failed to decrypt LAPS password: {e}") from e |
| 188 | + |
| 189 | + |
| 190 | +def _get_group_key_envelope( |
| 191 | + ctx: LAPSDecryptionContext, |
| 192 | + target_sd: bytes, |
| 193 | + key_id: KeyIdentifier, |
| 194 | +) -> GroupKeyEnvelope: |
| 195 | + """ |
| 196 | + Connect to MS-GKDI RPC service and retrieve the Group Key Envelope. |
| 197 | +
|
| 198 | + Args: |
| 199 | + ctx: Decryption context with authentication credentials |
| 200 | + target_sd: Security descriptor bytes for the target SID |
| 201 | + key_id: Key identifier from the encrypted blob |
| 202 | +
|
| 203 | + Returns: |
| 204 | + GroupKeyEnvelope containing the key material |
| 205 | +
|
| 206 | + Raises: |
| 207 | + LAPSError: If RPC connection or GetKey call fails |
| 208 | + """ |
| 209 | + try: |
| 210 | + # Resolve the MS-GKDI endpoint |
| 211 | + dest_host = ctx.dns_server if ctx.dns_server else ctx.domain |
| 212 | + string_binding = hept_map( |
| 213 | + destHost=dest_host, |
| 214 | + remoteIf=MSRPC_UUID_GKDI, |
| 215 | + protocol="ncacn_ip_tcp", |
| 216 | + ) |
| 217 | + |
| 218 | + debug(f"LAPS: MS-GKDI binding: {string_binding}") |
| 219 | + |
| 220 | + # Create RPC transport |
| 221 | + rpc_transport = transport.DCERPCTransportFactory(string_binding) |
| 222 | + |
| 223 | + if hasattr(rpc_transport, "set_credentials"): |
| 224 | + rpc_transport.set_credentials( |
| 225 | + username=ctx.username, |
| 226 | + password=ctx.password or "", |
| 227 | + domain=ctx.domain, |
| 228 | + lmhash=ctx.lmhash, |
| 229 | + nthash=ctx.nthash, |
| 230 | + ) |
| 231 | + |
| 232 | + if ctx.kerberos: |
| 233 | + rpc_transport.set_kerberos(True, kdcHost=ctx.kdc_host) |
| 234 | + |
| 235 | + # Connect and bind |
| 236 | + dce = rpc_transport.get_dce_rpc() |
| 237 | + dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) |
| 238 | + |
| 239 | + debug("LAPS: Connecting to MS-GKDI...") |
| 240 | + dce.connect() |
| 241 | + |
| 242 | + debug("LAPS: Binding to MS-GKDI interface...") |
| 243 | + dce.bind(MSRPC_UUID_GKDI) |
| 244 | + |
| 245 | + # Call GetKey |
| 246 | + debug("LAPS: Calling GetKey...") |
| 247 | + resp = GkdiGetKey( |
| 248 | + dce, |
| 249 | + target_sd=target_sd, |
| 250 | + l0=key_id["L0Index"], |
| 251 | + l1=key_id["L1Index"], |
| 252 | + l2=key_id["L2Index"], |
| 253 | + root_key_id=key_id["RootKeyId"], |
| 254 | + ) |
| 255 | + |
| 256 | + # Parse response into GroupKeyEnvelope |
| 257 | + gke = GroupKeyEnvelope(b"".join(resp["pbbOut"])) |
| 258 | + |
| 259 | + debug(f"LAPS: Got Group Key Envelope (Root Key: {gke['RootKeyId']})") |
| 260 | + |
| 261 | + return gke |
| 262 | + |
| 263 | + except Exception as e: |
| 264 | + raise LAPSError(f"MS-GKDI GetKey failed: {e}") from e |
0 commit comments