Skip to content

Commit 1460ec3

Browse files
author
r0BIT
committed
fix: B6 foreign domain SID resolution - skip LSARPC for cross-domain SIDs
Foreign domain SID detection: - Add get_domain_sid_prefix() to extract domain portion from full SID - Add is_foreign_domain_sid() to compare SID prefixes against local domain - Foreign SIDs detected by comparing domain prefix (S-1-5-21-x-y-z) LSARPC optimization: - Skip LSARPC (Tier 3) for foreign domain SIDs - they always fail with STATUS_NONE_MAPPED - Still attempt LDAP resolution (Tier 4) which may work for trusted domains - Better error message: 'SID - foreign domain/trust' for unresolvable foreign SIDs Performance impact: - Eliminates wasted LSARPC calls for cross-trust task runas accounts - Reduces overall scan time in multi-domain environments Tests: - Add TestGetDomainSidPrefix with 5 test cases - Add TestIsForeignDomainSid with 4 test cases
1 parent 295c8c5 commit 1460ec3

File tree

3 files changed

+153
-4
lines changed

3 files changed

+153
-4
lines changed

taskhound/engine/online.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,10 @@ def process_target(
703703
# This ensures we only resolve once per task, and the result is available for all uses
704704
# Skipped in OPSEC mode to avoid SMB/LSARPC and LDAP queries
705705
if is_sid(runas) and not opsec:
706+
# Derive local domain SID prefix from computer SID for foreign domain detection
707+
from ..utils.sid_resolver import get_domain_sid_prefix
708+
local_domain_prefix = get_domain_sid_prefix(server_sid) if server_sid else None
709+
706710
_, row.resolved_runas = format_runas_with_sid_resolution(
707711
runas,
708712
hv_loader=hv,
@@ -719,6 +723,7 @@ def process_target(
719723
ldap_user=ldap_user,
720724
ldap_password=ldap_password,
721725
ldap_hashes=ldap_hashes,
726+
local_domain_sid_prefix=local_domain_prefix,
722727
)
723728

724729
# Enrich row with decrypted password if available from DPAPI loot

taskhound/utils/sid_resolver.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,52 @@ def is_sid(value: str) -> bool:
103103
return bool(re.match(pattern, value.strip()))
104104

105105

106+
def get_domain_sid_prefix(sid: str) -> Optional[str]:
107+
"""
108+
Extract domain SID prefix from a full SID.
109+
110+
Domain SIDs have the format: S-1-5-21-{domain1}-{domain2}-{domain3}-{RID}
111+
The domain prefix is S-1-5-21-{domain1}-{domain2}-{domain3} (without RID).
112+
113+
Args:
114+
sid: Full SID string (e.g., "S-1-5-21-123-456-789-1001")
115+
116+
Returns:
117+
Domain prefix (e.g., "S-1-5-21-123-456-789") or None if not a domain SID
118+
"""
119+
if not sid or not sid.startswith("S-1-5-21-"):
120+
return None
121+
122+
parts = sid.split("-")
123+
# Domain SID: S-1-5-21-{d1}-{d2}-{d3}-{RID} = 8 parts minimum
124+
if len(parts) < 8:
125+
return None
126+
127+
# Return everything except the RID (last part)
128+
return "-".join(parts[:-1])
129+
130+
131+
def is_foreign_domain_sid(sid: str, local_domain_sid_prefix: Optional[str]) -> bool:
132+
"""
133+
Check if a SID belongs to a foreign (trusted) domain.
134+
135+
Args:
136+
sid: SID to check
137+
local_domain_sid_prefix: Known local domain prefix (e.g., "S-1-5-21-123-456-789")
138+
139+
Returns:
140+
True if SID is from a different domain than local_domain_sid_prefix
141+
"""
142+
if not local_domain_sid_prefix:
143+
return False # Can't determine without local domain info
144+
145+
sid_prefix = get_domain_sid_prefix(sid)
146+
if not sid_prefix:
147+
return False # Not a domain SID (built-in, well-known, etc.)
148+
149+
return sid_prefix != local_domain_sid_prefix
150+
151+
106152
def sid_to_binary(sid_string: str) -> Optional[bytes]:
107153
"""
108154
Convert a SID string (S-1-5-21-...) to binary format for LDAP queries.
@@ -651,6 +697,7 @@ def resolve_sid(
651697
ldap_user: Optional[str] = None,
652698
ldap_password: Optional[str] = None,
653699
ldap_hashes: Optional[str] = None,
700+
local_domain_sid_prefix: Optional[str] = None,
654701
) -> Tuple[str, Optional[str]]:
655702
"""
656703
Comprehensive SID resolution with 4-tier fallback chain.
@@ -659,6 +706,7 @@ def resolve_sid(
659706
1. BloodHound offline data (JSON file from --bloodhound flag)
660707
2. BloodHound live API (if bh_connector provided and has active connection)
661708
3. SMB/LSARPC via existing connection (uses target's LSA to resolve SIDs)
709+
- SKIPPED for foreign domain SIDs (different domain prefix)
662710
4. LDAP queries to domain controller (if credentials provided and not disabled)
663711
664712
Args:
@@ -677,6 +725,7 @@ def resolve_sid(
677725
ldap_user: Separate LDAP username (for local admin case)
678726
ldap_password: Separate LDAP password (for local admin case - plaintext only)
679727
ldap_hashes: Separate LDAP NTLM hashes (for local admin case - use instead of ldap_password)
728+
local_domain_sid_prefix: Known local domain SID prefix for foreign domain detection
680729
681730
Returns:
682731
Tuple of (display_name, resolved_username)
@@ -725,9 +774,15 @@ def _cache_success(resolved_name):
725774
_cache_success(resolved)
726775
return f"{resolved} ({sid})", resolved
727776

728-
# Tier 3: Try SMB/LSARPC if connection available
777+
# Check for foreign domain SID before attempting LSARPC
778+
# Foreign SIDs cannot be resolved via local domain's LSARPC (returns STATUS_NONE_MAPPED)
779+
is_foreign = is_foreign_domain_sid(sid, local_domain_sid_prefix)
780+
if is_foreign:
781+
debug(f"SID {sid} is from foreign domain (prefix mismatch with {local_domain_sid_prefix}), skipping LSARPC")
782+
783+
# Tier 3: Try SMB/LSARPC if connection available (skip for foreign domain SIDs)
729784
# This is very useful when using Kerberos CIFS tickets where LDAP auth might fail
730-
if smb_connection:
785+
if smb_connection and not is_foreign:
731786
resolved = resolve_sid_via_smb(sid, smb_connection)
732787
if resolved:
733788
debug(f"SID {sid} resolved via SMB/LSARPC: {resolved}")
@@ -772,9 +827,12 @@ def _cache_success(resolved_name):
772827
elif not ldap_auth_domain or not ldap_auth_user:
773828
debug(f"SID {sid} not resolved: insufficient authentication information")
774829
return f"{sid} (SID - insufficient auth for LDAP resolution)", None
830+
elif is_foreign:
831+
debug(f"SID {sid} not resolved: foreign domain SID (cross-trust)")
832+
return f"{sid} (SID - foreign domain/trust)", None
775833
else:
776834
debug(f"SID {sid} not resolved: could not find in BloodHound offline, BloodHound API, SMB, or LDAP")
777-
return f"{sid} (SID - could not resolve: deleted user, cross-domain, or access denied)", None
835+
return f"{sid} (SID - could not resolve: deleted user or access denied)", None
778836

779837

780838
def batch_get_user_attributes(
@@ -1035,6 +1093,7 @@ def format_runas_with_sid_resolution(
10351093
ldap_user: Optional[str] = None,
10361094
ldap_password: Optional[str] = None,
10371095
ldap_hashes: Optional[str] = None,
1096+
local_domain_sid_prefix: Optional[str] = None,
10381097
) -> Tuple[str, Optional[str]]:
10391098
"""
10401099
Format RunAs field with SID resolution if needed.
@@ -1055,6 +1114,7 @@ def format_runas_with_sid_resolution(
10551114
ldap_user: Separate LDAP username (for local admin case)
10561115
ldap_password: Separate LDAP password (for local admin case - plaintext only)
10571116
ldap_hashes: Separate LDAP NTLM hashes (for local admin case - use instead of ldap_password)
1117+
local_domain_sid_prefix: Known local domain SID prefix for foreign domain detection
10581118
10591119
Returns:
10601120
Tuple of (display_runas, resolved_username)
@@ -1082,14 +1142,14 @@ def format_runas_with_sid_resolution(
10821142
ldap_user,
10831143
ldap_password,
10841144
ldap_hashes,
1145+
local_domain_sid_prefix,
10851146
)
10861147
else:
10871148
# Regular username, return as-is
10881149
return runas, None
10891150

10901151

10911152

1092-
10931153
# Well-known privileged group RIDs (relative to domain SID)
10941154
# These are the primary Tier-0 groups that grant domain-wide administrative access
10951155
TIER0_GROUP_RIDS = {

tests/test_sid_resolver.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,87 @@ def test_handles_zero_subauthority(self):
260260

261261
assert result is not None
262262
assert binary_to_sid(result) == sid
263+
264+
265+
class TestGetDomainSidPrefix:
266+
"""Tests for get_domain_sid_prefix function"""
267+
268+
def test_extracts_domain_prefix_from_user_sid(self):
269+
"""Should extract domain prefix from user SID (remove RID)"""
270+
from taskhound.utils.sid_resolver import get_domain_sid_prefix
271+
272+
sid = "S-1-5-21-123456789-987654321-111222333-1001"
273+
274+
result = get_domain_sid_prefix(sid)
275+
276+
assert result == "S-1-5-21-123456789-987654321-111222333"
277+
278+
def test_extracts_domain_prefix_from_computer_sid(self):
279+
"""Should extract domain prefix from computer account SID"""
280+
from taskhound.utils.sid_resolver import get_domain_sid_prefix
281+
282+
sid = "S-1-5-21-3570960105-1792075822-554663251-1002"
283+
284+
result = get_domain_sid_prefix(sid)
285+
286+
assert result == "S-1-5-21-3570960105-1792075822-554663251"
287+
288+
def test_returns_none_for_builtin_sid(self):
289+
"""Should return None for builtin SIDs (not domain SIDs)"""
290+
from taskhound.utils.sid_resolver import get_domain_sid_prefix
291+
292+
# Local System
293+
assert get_domain_sid_prefix("S-1-5-18") is None
294+
# Builtin Administrators
295+
assert get_domain_sid_prefix("S-1-5-32-544") is None
296+
297+
def test_returns_none_for_empty_string(self):
298+
"""Should return None for empty string"""
299+
from taskhound.utils.sid_resolver import get_domain_sid_prefix
300+
301+
assert get_domain_sid_prefix("") is None
302+
303+
def test_returns_none_for_none(self):
304+
"""Should return None for None"""
305+
from taskhound.utils.sid_resolver import get_domain_sid_prefix
306+
307+
assert get_domain_sid_prefix(None) is None
308+
309+
310+
class TestIsForeignDomainSid:
311+
"""Tests for is_foreign_domain_sid function"""
312+
313+
def test_detects_foreign_domain_sid(self):
314+
"""Should detect SID from different domain"""
315+
from taskhound.utils.sid_resolver import is_foreign_domain_sid
316+
317+
local_prefix = "S-1-5-21-123456789-987654321-111222333"
318+
foreign_sid = "S-1-5-21-999888777-666555444-333222111-1001"
319+
320+
assert is_foreign_domain_sid(foreign_sid, local_prefix) is True
321+
322+
def test_detects_same_domain_sid(self):
323+
"""Should return False for SID from same domain"""
324+
from taskhound.utils.sid_resolver import is_foreign_domain_sid
325+
326+
local_prefix = "S-1-5-21-123456789-987654321-111222333"
327+
same_domain_sid = "S-1-5-21-123456789-987654321-111222333-500"
328+
329+
assert is_foreign_domain_sid(same_domain_sid, local_prefix) is False
330+
331+
def test_returns_false_for_builtin_sid(self):
332+
"""Should return False for builtin SIDs (not domain SIDs)"""
333+
from taskhound.utils.sid_resolver import is_foreign_domain_sid
334+
335+
local_prefix = "S-1-5-21-123456789-987654321-111222333"
336+
337+
# Local System - not a domain SID
338+
assert is_foreign_domain_sid("S-1-5-18", local_prefix) is False
339+
340+
def test_returns_false_when_no_local_prefix(self):
341+
"""Should return False when local domain prefix is unknown"""
342+
from taskhound.utils.sid_resolver import is_foreign_domain_sid
343+
344+
foreign_sid = "S-1-5-21-999888777-666555444-333222111-1001"
345+
346+
assert is_foreign_domain_sid(foreign_sid, None) is False

0 commit comments

Comments
 (0)