Skip to content

Commit 9251ab5

Browse files
author
r0BIT
committed
refactor: split laps.py into laps/ package (Todo #9)
- Created taskhound/laps/ package with 6 modules: - exceptions.py: LAPSError classes + LAPS_ERRORS dict - models.py: LAPSCredential, LAPSCache, LAPSFailure dataclasses - parsing.py: Windows LAPS JSON parsing helpers - decryption.py: MS-GKDI/DPAPI-NG decryption context - query.py: LDAP query functions (get_laps_passwords, query_laps_passwords) - helpers.py: Lookup and summary helpers - __init__.py: Re-exports for backward compatibility - Removed monolithic laps.py (1,231 lines) - Fixed exception chaining with 'from e' pattern - Used contextlib.suppress() for cleaner exception handling
1 parent 5c8981e commit 9251ab5

File tree

8 files changed

+1330
-1230
lines changed

8 files changed

+1330
-1230
lines changed

taskhound/laps.py

Lines changed: 0 additions & 1230 deletions
This file was deleted.

taskhound/laps/__init__.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# LAPS (Local Administrator Password Solution) support for TaskHound
2+
#
3+
# This package handles querying LAPS passwords from Active Directory
4+
# and provides credential lookup for SMB authentication.
5+
#
6+
# Supports:
7+
# - Windows LAPS (msLAPS-Password) - JSON format plaintext
8+
# - Windows LAPS (msLAPS-EncryptedPassword) - Encrypted via DPAPI-NG/MS-GKDI
9+
# - Legacy LAPS (ms-Mcs-AdmPwd) - plaintext
10+
# - Persistent caching via SQLite (respects LAPS expiration times)
11+
#
12+
# Author: TaskHound Contributors
13+
14+
# Exceptions
15+
# Re-export date parsing utilities for backward compatibility
16+
from ..utils.date_parser import parse_ad_timestamp
17+
18+
# Decryption context
19+
from .decryption import (
20+
LAPSDecryptionContext,
21+
decrypt_laps_password,
22+
)
23+
from .exceptions import (
24+
LAPS_ERRORS,
25+
LAPSConnectionError,
26+
LAPSEmptyCacheError,
27+
LAPSError,
28+
LAPSParseError,
29+
LAPSPermissionError,
30+
)
31+
32+
# Helper functions
33+
from .helpers import (
34+
get_laps_credential_for_host,
35+
print_laps_summary,
36+
)
37+
38+
# Data classes
39+
from .models import (
40+
LAPS_CACHE_CATEGORY,
41+
LAPSCache,
42+
LAPSCredential,
43+
LAPSFailure,
44+
)
45+
46+
# Parsing functions
47+
from .parsing import (
48+
parse_filetime,
49+
parse_mslaps_password,
50+
)
51+
52+
# Query functions
53+
from .query import (
54+
get_laps_passwords,
55+
query_laps_passwords,
56+
)
57+
58+
__all__ = [
59+
# Exceptions
60+
"LAPSError",
61+
"LAPSConnectionError",
62+
"LAPSPermissionError",
63+
"LAPSEmptyCacheError",
64+
"LAPSParseError",
65+
"LAPS_ERRORS",
66+
# Data classes
67+
"LAPSCredential",
68+
"LAPSCache",
69+
"LAPSFailure",
70+
"LAPS_CACHE_CATEGORY",
71+
# Decryption
72+
"LAPSDecryptionContext",
73+
"decrypt_laps_password",
74+
# Parsing
75+
"parse_mslaps_password",
76+
"parse_filetime",
77+
"parse_ad_timestamp", # Re-exported for backward compat
78+
# Query
79+
"get_laps_passwords",
80+
"query_laps_passwords",
81+
# Helpers
82+
"get_laps_credential_for_host",
83+
"print_laps_summary",
84+
]

taskhound/laps/decryption.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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

Comments
 (0)