Skip to content

Commit 915a2c8

Browse files
author
r0BIT
committed
fix: improve SID resolution and credential matching for DPAPI loot
- Add resolved_runas field to TaskRow model to store resolved username - Resolve SIDs once early in processing flow, reuse for credential matching - Pass resolved_runas to format_block() to avoid redundant resolution - Update credential summary to show 'username (SID)' format when resolved - Remove wasteful double SID resolution during credential matching This ensures SID-based tasks (e.g., SIDOnlyTask2) correctly match to decrypted credentials using the session cache from the same run.
1 parent d0d4b3d commit 915a2c8

File tree

6 files changed

+278
-34
lines changed

6 files changed

+278
-34
lines changed

taskhound/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from .opengraph import generate_opengraph_files
2323
from .output.bloodhound import upload_opengraph_to_bloodhound
2424
from .output.printer import print_results
25-
from .output.summary import print_summary_table
25+
from .output.summary import print_decrypted_credentials, print_summary_table
2626
from .output.writer import write_csv, write_json, write_plain
2727
from .parsers.highvalue import HighValueLoader
2828
from .utils.cache_manager import init_cache
@@ -300,6 +300,10 @@ def main():
300300
if args.opengraph:
301301
generate_opengraph_files(args.opengraph, all_rows)
302302

303+
# Print decrypted credentials summary (always shown when credentials found)
304+
# This is printed BEFORE the summary table so high-value findings are visible
305+
print_decrypted_credentials(all_rows)
306+
303307
# Print summary by default (unless disabled)
304308
if not args.no_summary:
305309
backup_dir = args.backup if hasattr(args, "backup") and args.backup else None

taskhound/engine/online.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,70 @@
3838
from ..smb.tasks import crawl_tasks, smb_listdir
3939
from ..utils.logging import debug as log_debug
4040
from ..utils.logging import good, info, status, warn
41+
from ..utils.sid_resolver import format_runas_with_sid_resolution, is_sid
4142
from .helpers import sort_tasks_by_priority
4243

4344

45+
def _match_decrypted_password(runas: str, decrypted_creds: List, resolved_runas: Optional[str] = None) -> Optional[str]:
46+
"""
47+
Match a task's runas field to decrypted credentials and return the password.
48+
49+
Handles various runas formats:
50+
- Simple username: "highpriv"
51+
- Domain\\user: "DOMAIN\\highpriv"
52+
- Raw SID: "S-1-5-21-..." (uses resolved_runas if provided)
53+
54+
Args:
55+
runas: The RunAs field from the task (may be raw SID)
56+
decrypted_creds: List of ScheduledTaskCredential objects
57+
resolved_runas: Pre-resolved username if runas was a SID (from earlier resolution)
58+
59+
Returns:
60+
Decrypted password if found, None otherwise
61+
"""
62+
if not decrypted_creds or not runas:
63+
return None
64+
65+
# Build list of usernames to try matching
66+
usernames_to_try = []
67+
68+
# If we have a pre-resolved username, use it
69+
if resolved_runas:
70+
usernames_to_try.append(resolved_runas.lower())
71+
72+
# Also try the original runas if it's not a raw SID
73+
runas_normalized = runas.lower()
74+
if not is_sid(runas):
75+
if runas_normalized not in usernames_to_try:
76+
usernames_to_try.append(runas_normalized)
77+
78+
# If no valid usernames to try (unresolved SID), can't match
79+
if not usernames_to_try:
80+
return None
81+
82+
for cred in decrypted_creds:
83+
if not cred.username:
84+
continue
85+
86+
cred_user_normalized = cred.username.lower()
87+
88+
for try_username in usernames_to_try:
89+
# Match full domain\user or partial matches
90+
if cred_user_normalized == try_username:
91+
# Exact match
92+
return cred.password
93+
elif "\\" in cred_user_normalized and "\\" not in try_username:
94+
# Cred has domain, try_username doesn't - match on username part only
95+
if cred_user_normalized.split("\\")[-1] == try_username:
96+
return cred.password
97+
elif "\\" in try_username and "\\" not in cred_user_normalized:
98+
# try_username has domain, cred doesn't - match on username part only
99+
if try_username.split("\\")[-1] == cred_user_normalized:
100+
return cred.password
101+
102+
return None
103+
104+
44105
def process_target(
45106
target: str,
46107
all_rows: List[TaskRow],
@@ -538,6 +599,31 @@ def process_target(
538599
else:
539600
row.cred_detail = f"Unknown status (code: {row.cred_return_code})"
540601

602+
# Resolve SID early if runas is a SID - store result for credential matching and output
603+
# This ensures we only resolve once per task, and the result is available for all uses
604+
if is_sid(runas):
605+
_, row.resolved_runas = format_runas_with_sid_resolution(
606+
runas,
607+
hv_loader=hv,
608+
bh_connector=bh_connector,
609+
smb_connection=smb,
610+
no_ldap=no_ldap,
611+
domain=domain,
612+
dc_ip=dc_ip,
613+
username=username,
614+
password=password,
615+
hashes=hashes,
616+
kerberos=kerberos,
617+
ldap_domain=ldap_domain,
618+
ldap_user=ldap_user,
619+
ldap_password=ldap_password,
620+
ldap_hashes=ldap_hashes,
621+
)
622+
623+
# Enrich row with decrypted password if available from DPAPI loot
624+
if decrypted_creds:
625+
row.decrypted_password = _match_decrypted_password(runas, decrypted_creds, row.resolved_runas)
626+
541627
# Add Credential Guard status to each row
542628
row.credential_guard = credguard_status
543629
# Determine if the task stores credentials or runs with token/S4U (no saved credentials)
@@ -597,6 +683,7 @@ def process_target(
597683
decrypted_creds=decrypted_creds,
598684
concise=concise,
599685
cred_validation=row.to_dict() if row.cred_status else None,
686+
resolved_runas=row.resolved_runas,
600687
)
601688
)
602689
priv_count += 1
@@ -630,6 +717,7 @@ def process_target(
630717
decrypted_creds=decrypted_creds,
631718
concise=concise,
632719
cred_validation=row.to_dict() if row.cred_status else None,
720+
resolved_runas=row.resolved_runas,
633721
)
634722
)
635723

taskhound/models/task.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class TaskRow:
5656
cred_last_run: ISO timestamp of last task run
5757
cred_return_code: Hex return code from last execution
5858
cred_detail: Human-readable credential validation detail
59+
resolved_runas: Resolved username if runas was a SID (for credential matching)
5960
"""
6061

6162
# Required fields (set during construction)
@@ -73,6 +74,7 @@ class TaskRow:
7374

7475
# Task identity
7576
runas: Optional[str] = None
77+
resolved_runas: Optional[str] = None # Resolved username if runas was a SID
7678
command: Optional[str] = None
7779
arguments: Optional[str] = None
7880
author: Optional[str] = None
@@ -99,6 +101,9 @@ class TaskRow:
99101
cred_return_code: Optional[str] = None
100102
cred_detail: Optional[str] = None
101103

104+
# DPAPI looted credentials
105+
decrypted_password: Optional[str] = None
106+
102107
def to_dict(self) -> Dict[str, Any]:
103108
"""Convert to dictionary for JSON/CSV export."""
104109
return asdict(self)

taskhound/output/printer.py

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def format_block(
142142
decrypted_creds: Optional[List] = None,
143143
concise: bool = False,
144144
cred_validation: Optional[Dict[str, Any]] = None,
145+
resolved_runas: Optional[str] = None,
145146
) -> List[str]:
146147
# Format a small pretty-print block used by the CLI output.
147148
#
@@ -153,31 +154,66 @@ def format_block(
153154
else:
154155
header = "[TASK]"
155156

156-
# Resolve SID in RunAs field for better display (uses 4-tier fallback: offline BH → API → SMB → LDAP)
157-
display_runas, resolved_username = format_runas_with_sid_resolution(
158-
runas,
159-
hv,
160-
bh_connector,
161-
smb_connection,
162-
no_ldap,
163-
domain,
164-
dc_ip,
165-
username,
166-
password,
167-
hashes,
168-
kerberos,
169-
ldap_domain,
170-
ldap_user,
171-
ldap_password,
172-
ldap_hashes,
173-
)
157+
# Use pre-resolved username if available, otherwise resolve now
158+
if resolved_runas:
159+
# Already resolved - format display string
160+
from ..utils.sid_resolver import is_sid
161+
if is_sid(runas):
162+
display_runas = f"{resolved_runas} ({runas})"
163+
else:
164+
display_runas = runas
165+
resolved_username = resolved_runas
166+
else:
167+
# Resolve SID in RunAs field for better display (uses 4-tier fallback: offline BH → API → SMB → LDAP)
168+
display_runas, resolved_username = format_runas_with_sid_resolution(
169+
runas,
170+
hv,
171+
bh_connector,
172+
smb_connection,
173+
no_ldap,
174+
domain,
175+
dc_ip,
176+
username,
177+
password,
178+
hashes,
179+
kerberos,
180+
ldap_domain,
181+
ldap_user,
182+
ldap_password,
183+
ldap_hashes,
184+
)
174185

175186
if concise:
176187
# Concise output: One line per task
177-
# Format: [KIND] RunAs | Path | What
188+
# Format: [KIND] RunAs | Path | What | (optional reason) | (optional password)
178189
line = f"{header} {display_runas} | {rel_path} | {what}"
179190
if extra_reason:
180191
line += f" | {extra_reason}"
192+
193+
# In concise mode, show decrypted password inline if available
194+
if decrypted_creds and kind in ["TIER-0", "PRIV"]:
195+
# Normalize the runas for comparison
196+
# Handle resolved SID format: "username (S-1-5-21-...)" -> extract just username
197+
runas_normalized = runas.lower()
198+
if " (s-1-5-" in runas_normalized:
199+
runas_normalized = runas_normalized.split(" (s-1-5-")[0].strip()
200+
201+
for cred in decrypted_creds:
202+
if cred.username:
203+
cred_user_normalized = cred.username.lower()
204+
matched = False
205+
if cred_user_normalized == runas_normalized:
206+
matched = True
207+
elif "\\" in cred_user_normalized and "\\" not in runas_normalized:
208+
if cred_user_normalized.split("\\")[-1] == runas_normalized:
209+
matched = True
210+
elif "\\" in runas_normalized and "\\" not in cred_user_normalized:
211+
if runas_normalized.split("\\")[-1] == cred_user_normalized:
212+
matched = True
213+
if matched:
214+
line += f" | PWD: {cred.password}"
215+
break
216+
181217
return [line]
182218

183219
base = [f"\n{header} {rel_path}"]
@@ -252,23 +288,58 @@ def format_block(
252288
# Check if we have a decrypted password for this user
253289
decrypted_password = None
254290
if decrypted_creds:
255-
# Normalize the runas for comparison
291+
# Use resolved_username if available (handles SID-only runas fields)
292+
# Also try the display_runas which may have "username (SID)" format
293+
usernames_to_try = []
294+
295+
# Add resolved username from SID resolution
296+
if resolved_username:
297+
usernames_to_try.append(resolved_username.lower())
298+
299+
# Also try extracting from display_runas format "username (S-1-5-21-...)"
300+
display_runas_lower = display_runas.lower()
301+
if " (s-1-5-" in display_runas_lower:
302+
username_part = display_runas_lower.split(" (s-1-5-")[0].strip()
303+
if username_part and username_part not in usernames_to_try:
304+
usernames_to_try.append(username_part)
305+
elif not display_runas_lower.startswith("s-1-5-"):
306+
# Not a raw SID, add as-is
307+
if display_runas_lower not in usernames_to_try:
308+
usernames_to_try.append(display_runas_lower)
309+
310+
# Try the original runas if it's not a raw SID
256311
runas_normalized = runas.lower()
312+
if not runas_normalized.startswith("s-1-5-"):
313+
if " (s-1-5-" in runas_normalized:
314+
username_part = runas_normalized.split(" (s-1-5-")[0].strip()
315+
if username_part and username_part not in usernames_to_try:
316+
usernames_to_try.append(username_part)
317+
elif runas_normalized not in usernames_to_try:
318+
usernames_to_try.append(runas_normalized)
319+
257320
for cred in decrypted_creds:
258321
if cred.username:
259322
cred_user_normalized = cred.username.lower()
260-
# Match full domain\user or partial matches
261-
if cred_user_normalized == runas_normalized:
262-
# Exact match
263-
decrypted_password = cred.password
264-
break
265-
elif "\\" in cred_user_normalized and "\\" not in runas_normalized:
266-
# Cred has domain, runas doesn't - match on username part only
267-
if cred_user_normalized.split("\\")[-1] == runas_normalized:
323+
324+
for try_username in usernames_to_try:
325+
# Match full domain\user or partial matches
326+
if cred_user_normalized == try_username:
327+
# Exact match
268328
decrypted_password = cred.password
269329
break
270-
# Note: We DON'T match when runas has domain but cred doesn't
271-
# A credential without domain is likely a local account, not domain account
330+
elif "\\" in cred_user_normalized and "\\" not in try_username:
331+
# Cred has domain, try_username doesn't - match on username part only
332+
if cred_user_normalized.split("\\")[-1] == try_username:
333+
decrypted_password = cred.password
334+
break
335+
elif "\\" in try_username and "\\" not in cred_user_normalized:
336+
# try_username has domain, cred doesn't - match on username part only
337+
if try_username.split("\\")[-1] == cred_user_normalized:
338+
decrypted_password = cred.password
339+
break
340+
341+
if decrypted_password:
342+
break
272343

273344
# Show decrypted password if available, otherwise show next step
274345
if decrypted_password:

0 commit comments

Comments
 (0)