Skip to content

Commit 770c6e3

Browse files
author
r0BIT
committed
feat: align CLI output labels + add missing config mappings
Output alignment: - Align all output labels to 18 chars for consistent formatting - Shorten 'Decrypted Password' → 'Decrypted Pwd' - Shorten 'Password Analysis' → 'Pwd Analysis' Config mappings: - Add ldap.tier0 for LDAP-based Tier-0 detection - Add dpapi.key for DPAPI credential decryption - Add target.threads for parallel scanning - Add target.rate_limit for rate limiting - Add new [dpapi] section to taskhound.toml.example Tests: - Update test assertions to match new aligned format
1 parent d695f81 commit 770c6e3

18 files changed

+1593
-179
lines changed

config/taskhound.toml.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Priority: CLI Args > Env Vars > Local Config > User Config > Defaults
77

88
[authentication]
9-
# Default credentials (use with caution!)
9+
# Default credentials
1010
# username = "svc_taskhound"
1111
# domain = "CORP.LOCAL"
1212
# password = "Password123!"
@@ -19,6 +19,8 @@
1919
# target = "10.0.0.1"
2020
# targets_file = "targets.txt"
2121
# timeout = 60
22+
# threads = 10 # Parallel workers for multi-target scans (default: 1)
23+
# rate_limit = 5.0 # Max targets per second (default: unlimited)
2224

2325
[scanning]
2426
# Default scan options
@@ -75,6 +77,11 @@ color = "#8B5CF6"
7577
# password = "password"
7678
# hashes = "LM:NT"
7779
# domain = "domain.local"
80+
# tier0 = false # Enable LDAP-based Tier-0 detection via group membership queries
81+
82+
[dpapi]
83+
# DPAPI credential decryption settings
84+
# key = "0x51e43225e5b43b25d3768a2ae7f99934cb35d3ea" # DPAPI_SYSTEM userkey from LSA secrets
7885

7986
[output]
8087
# plain = "./output/plain"

taskhound/classification.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from dataclasses import dataclass
88
from datetime import datetime
9-
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
9+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
1010

1111
from .utils.logging import warn
1212
from .utils.sid_resolver import looks_like_domain_user
@@ -30,6 +30,9 @@ class ClassificationResult:
3030
# Type alias for pre-fetched password data: username -> pwdLastSet datetime
3131
PwdLastSetCache = Dict[str, datetime]
3232

33+
# Type alias for pre-fetched Tier-0 membership data: username -> (is_tier0, group_list)
34+
Tier0Cache = Dict[str, Tuple[bool, List[str]]]
35+
3336

3437
def _get_task_date_for_analysis(meta: Dict) -> Tuple[Optional[str], bool]:
3538
"""
@@ -96,12 +99,12 @@ def _analyze_password_age(
9699
if pwd_cache and task_date:
97100
try:
98101
from .parsers.highvalue import _analyze_password_freshness
99-
102+
100103
# Normalize username for lookup
101104
norm_user = runas.split("\\")[-1].lower() if "\\" in runas else runas.lower()
102-
105+
103106
pwd_last_set = pwd_cache.get(norm_user)
104-
107+
105108
if pwd_last_set:
106109
risk_level, pwd_analysis = _analyze_password_freshness(task_date, pwd_last_set)
107110
if risk_level != "UNKNOWN":
@@ -122,6 +125,7 @@ def classify_task(
122125
show_unsaved_creds: bool,
123126
include_local: bool,
124127
pwd_cache: Optional[PwdLastSetCache] = None,
128+
tier0_cache: Optional[Tier0Cache] = None,
125129
) -> ClassificationResult:
126130
"""
127131
Classify a task as TIER-0, PRIV, or TASK based on the runas account.
@@ -138,6 +142,7 @@ def classify_task(
138142
show_unsaved_creds: Whether to include tasks without saved credentials
139143
include_local: Whether to include local system accounts
140144
pwd_cache: Pre-fetched dict of username -> pwdLastSet datetime
145+
tier0_cache: Pre-fetched dict of username -> (is_tier0, group_list) from LDAP
141146
142147
Returns:
143148
ClassificationResult with task_type, reason, password_analysis, should_include
@@ -153,8 +158,9 @@ def classify_task(
153158
)
154159

155160
# Check for Tier 0 first, then high-value
161+
# Priority: BloodHound data > LDAP tier0_cache
156162
if hv and hv.loaded:
157-
# Check Tier 0 classification
163+
# Check Tier 0 classification via BloodHound
158164
is_tier0, tier0_reasons = hv.check_tier0(runas)
159165
if is_tier0:
160166
reason = "; ".join(tier0_reasons)
@@ -199,6 +205,35 @@ def classify_task(
199205
should_include=True,
200206
)
201207

208+
# Check LDAP-based Tier-0 detection (when BloodHound not available)
209+
elif tier0_cache:
210+
# Normalize username for lookup
211+
norm_user = runas.split("\\")[-1].lower() if "\\" in runas else runas.lower()
212+
tier0_result = tier0_cache.get(norm_user)
213+
214+
if tier0_result:
215+
is_tier0, groups = tier0_result
216+
if is_tier0:
217+
reason = f"Tier-0 via LDAP: member of {', '.join(groups)}"
218+
password_analysis = None
219+
220+
if has_no_saved_creds:
221+
reason = f"{reason} (no saved credentials — DPAPI dump not applicable; manipulation requires an interactive session)"
222+
else:
223+
password_analysis = _analyze_password_age(hv, runas, meta, rel_path, pwd_cache)
224+
225+
# Update row in place
226+
row.type = TaskType.TIER0.value
227+
row.reason = reason
228+
row.password_analysis = password_analysis
229+
230+
return ClassificationResult(
231+
task_type="TIER-0",
232+
reason=reason,
233+
password_analysis=password_analysis,
234+
should_include=True,
235+
)
236+
202237
# Regular task - still analyze password age if credentials are stored
203238
password_analysis = None
204239
if has_stored_creds:

taskhound/cli.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -209,24 +209,25 @@ def main():
209209
)
210210

211211
# Common kwargs for process_target
212-
process_kwargs = dict(
213-
auth=auth,
214-
include_ms=args.include_ms,
215-
include_local=args.include_local,
216-
hv=hv,
217-
debug=args.debug,
218-
show_unsaved_creds=args.unsaved_creds,
219-
backup_dir=args.backup,
220-
credguard_detect=args.credguard_detect,
221-
no_ldap=args.no_ldap,
222-
loot=args.loot,
223-
dpapi_key=args.dpapi_key,
224-
bh_connector=bh_connector,
225-
concise=not args.verbose,
226-
opsec=args.opsec,
227-
laps_cache=laps_cache,
228-
validate_creds=args.validate_creds,
229-
)
212+
process_kwargs = {
213+
"auth": auth,
214+
"include_ms": args.include_ms,
215+
"include_local": args.include_local,
216+
"hv": hv,
217+
"debug": args.debug,
218+
"show_unsaved_creds": args.unsaved_creds,
219+
"backup_dir": args.backup,
220+
"credguard_detect": args.credguard_detect,
221+
"no_ldap": args.no_ldap,
222+
"loot": args.loot,
223+
"dpapi_key": args.dpapi_key,
224+
"bh_connector": bh_connector,
225+
"concise": not args.verbose,
226+
"opsec": args.opsec,
227+
"laps_cache": laps_cache,
228+
"validate_creds": args.validate_creds,
229+
"ldap_tier0": args.ldap_tier0,
230+
}
230231

231232
# Parallel mode (--threads > 1)
232233
if args.threads > 1:
@@ -240,7 +241,7 @@ def main():
240241

241242
start_time = time.perf_counter()
242243
results = async_engine.run(targets, process_target, **process_kwargs)
243-
elapsed_ms = (time.perf_counter() - start_time) * 1000
244+
_ = (time.perf_counter() - start_time) * 1000 # elapsed_ms for future use
244245

245246
# Aggregate results
246247
all_rows, laps_failures, laps_successes = aggregate_results(results)
@@ -307,7 +308,9 @@ def main():
307308
# Print summary by default (unless disabled)
308309
if not args.no_summary:
309310
backup_dir = args.backup if hasattr(args, "backup") and args.backup else None
310-
print_summary_table(all_rows, backup_dir, hv_loaded)
311+
# Tier-0 detection is available if we have BloodHound data OR --ldap-tier0
312+
has_tier0_detection = hv_loaded or args.ldap_tier0
313+
print_summary_table(all_rows, backup_dir, has_tier0_detection)
311314

312315
# Print LAPS summary if LAPS was used
313316
if laps_cache is not None:

taskhound/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ def load_config() -> Dict[str, Any]:
9191
defaults["target"] = target["target"]
9292
if "targets_file" in target:
9393
defaults["targets_file"] = target["targets_file"]
94+
if "threads" in target:
95+
defaults["threads"] = target["threads"]
96+
if "rate_limit" in target:
97+
defaults["rate_limit"] = target["rate_limit"]
9498

9599
# Scanning
96100
scan = config_data.get("scanning", {})
@@ -134,6 +138,11 @@ def load_config() -> Dict[str, Any]:
134138
if "enabled" in cred_validation:
135139
defaults["validate_creds"] = cred_validation["enabled"]
136140

141+
# DPAPI
142+
dpapi = config_data.get("dpapi", {})
143+
if "key" in dpapi:
144+
defaults["dpapi_key"] = dpapi["key"]
145+
137146
# BloodHound
138147
bh = config_data.get("bloodhound", {})
139148
if "live" in bh:
@@ -190,6 +199,8 @@ def load_config() -> Dict[str, Any]:
190199
defaults["ldap_hashes"] = ldap["hashes"]
191200
if "domain" in ldap:
192201
defaults["ldap_domain"] = ldap["domain"]
202+
if "tier0" in ldap:
203+
defaults["ldap_tier0"] = ldap["tier0"]
193204

194205
# Output
195206
output = config_data.get("output", {})
@@ -421,6 +432,11 @@ def build_parser() -> argparse.ArgumentParser:
421432
ldap.add_argument(
422433
"--ldap-domain", help="Alternative domain for SID lookup (can be different from main auth domain)"
423434
)
435+
ldap.add_argument(
436+
"--ldap-tier0",
437+
action="store_true",
438+
help="Enable LDAP-based Tier-0 detection via group membership queries. Checks if runas accounts are members of privileged groups (Domain Admins, Enterprise Admins, etc.) without requiring BloodHound data.",
439+
)
424440

425441
# LAPS (Local Administrator Password Solution) options
426442
laps_group = ap.add_argument_group(

taskhound/engine/online.py

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from impacket.smbconnection import SessionError
1414

1515
from ..auth import AuthContext
16-
from ..classification import classify_task, PwdLastSetCache
16+
from ..classification import PwdLastSetCache, classify_task
1717
from ..laps import (
1818
LAPS_ERRORS,
1919
LAPSCache,
@@ -38,7 +38,10 @@
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
41+
from ..utils.sid_resolver import (
42+
format_runas_with_sid_resolution,
43+
is_sid,
44+
)
4245
from .helpers import sort_tasks_by_priority
4346

4447

@@ -68,12 +71,11 @@ def _match_decrypted_password(runas: str, decrypted_creds: List, resolved_runas:
6871
# If we have a pre-resolved username, use it
6972
if resolved_runas:
7073
usernames_to_try.append(resolved_runas.lower())
71-
74+
7275
# Also try the original runas if it's not a raw SID
7376
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+
if not is_sid(runas) and runas_normalized not in usernames_to_try:
78+
usernames_to_try.append(runas_normalized)
7779

7880
# If no valid usernames to try (unresolved SID), can't match
7981
if not usernames_to_try:
@@ -122,6 +124,7 @@ def process_target(
122124
opsec: bool = False,
123125
laps_cache: Optional[LAPSCache] = None,
124126
validate_creds: bool = False,
127+
ldap_tier0: bool = False,
125128
) -> Tuple[List[str], Optional[Union[bool, LAPSFailure]]]:
126129
"""
127130
Connect to `target`, enumerate scheduled tasks, and return printable lines.
@@ -145,6 +148,7 @@ def process_target(
145148
opsec: Enable OPSEC mode
146149
laps_cache: LAPS credential cache (if LAPS mode enabled)
147150
validate_creds: Query Task Scheduler RPC to validate stored credentials
151+
ldap_tier0: Check Tier-0 group membership via LDAP (when no BloodHound data)
148152
149153
Returns:
150154
Tuple of (lines, laps_result) where:
@@ -552,7 +556,7 @@ def process_target(
552556
if not no_ldap and not opsec and (not hv or not hv.loaded):
553557
# Collect unique runas users from all tasks with stored credentials
554558
unique_users = set()
555-
for rel_path, xml_bytes in items:
559+
for _rel_path, xml_bytes in items:
556560
meta = parse_task_xml(xml_bytes)
557561
runas = meta.get("runas")
558562
if not runas:
@@ -563,17 +567,17 @@ def process_target(
563567
# Skip SIDs - we can't look them up by SID in LDAP easily
564568
if not is_sid(runas):
565569
unique_users.add(runas)
566-
570+
567571
if unique_users:
568572
info(f"{target}: Querying LDAP for password age data ({len(unique_users)} users)...")
569573
try:
570574
from ..utils.sid_resolver import batch_get_user_attributes
571-
575+
572576
ldap_auth_domain = ldap_domain or domain
573577
ldap_auth_user = ldap_user or username
574578
ldap_auth_pass = ldap_password or password
575579
ldap_auth_hashes = ldap_hashes or hashes
576-
580+
577581
results = batch_get_user_attributes(
578582
usernames=list(unique_users),
579583
domain=ldap_auth_domain,
@@ -584,23 +588,56 @@ def process_target(
584588
kerberos=kerberos,
585589
attributes=["pwdLastSet", "sAMAccountName"],
586590
)
587-
591+
588592
# Build cache: normalized_username -> pwdLastSet datetime
589593
for norm_user, attrs in results.items():
590594
pwd_last_set = attrs.get("pwdLastSet")
591595
if pwd_last_set:
592596
pwd_cache[norm_user] = pwd_last_set
593-
597+
594598
if pwd_cache:
595599
good(f"{target}: Retrieved password age data for {len(pwd_cache)} users")
596600
else:
597601
info(f"{target}: No password age data available from LDAP")
598-
602+
599603
except Exception as e:
600604
warn(f"{target}: LDAP batch query failed: {e}")
601605
if debug:
602606
traceback.print_exc()
603607

608+
# Pre-fetch Tier-0 group members via LDAP (pre-flight approach)
609+
# This queries each Tier-0 group once and builds a lookup cache
610+
# Only enabled with --ldap-tier0 flag (OPSEC: group membership queries may be logged)
611+
tier0_cache: Dict[str, Tuple[bool, list]] = {} # username -> (is_tier0, group_list)
612+
if ldap_tier0 and not no_ldap and (not hv or not hv.loaded):
613+
info(f"{target}: Fetching Tier-0 group members via LDAP (pre-flight)...")
614+
try:
615+
from ..utils.sid_resolver import fetch_tier0_members
616+
617+
ldap_auth_domain = ldap_domain or domain
618+
ldap_auth_user = ldap_user or username
619+
ldap_auth_pass = ldap_password or password
620+
ldap_auth_hashes = ldap_hashes or hashes
621+
622+
tier0_cache = fetch_tier0_members(
623+
domain=ldap_auth_domain,
624+
dc_ip=dc_ip,
625+
auth_username=ldap_auth_user,
626+
auth_password=ldap_auth_pass,
627+
hashes=ldap_auth_hashes,
628+
kerberos=kerberos,
629+
)
630+
631+
if tier0_cache:
632+
good(f"{target}: Loaded {len(tier0_cache)} Tier-0 users from LDAP")
633+
else:
634+
info(f"{target}: No Tier-0 users found in domain")
635+
636+
except Exception as e:
637+
warn(f"{target}: LDAP Tier-0 pre-flight failed: {e}")
638+
if debug:
639+
traceback.print_exc()
640+
604641
for rel_path, xml_bytes in items:
605642
meta = parse_task_xml(xml_bytes)
606643
# Save raw XML to backup directory if requested
@@ -697,6 +734,7 @@ def process_target(
697734

698735
# Use shared classification logic
699736
# pwd_cache was pre-fetched before the loop for password freshness analysis
737+
# tier0_cache was pre-fetched for LDAP-based Tier-0 detection (when --ldap-tier0)
700738
result = classify_task(
701739
row=row,
702740
meta=meta,
@@ -706,6 +744,7 @@ def process_target(
706744
show_unsaved_creds=show_unsaved_creds,
707745
include_local=include_local,
708746
pwd_cache=pwd_cache,
747+
tier0_cache=tier0_cache,
709748
)
710749

711750
if not result.should_include:

0 commit comments

Comments
 (0)