Skip to content

Commit 44eb1d5

Browse files
author
r0BIT
committed
feat: add --dns-tcp flag for SOCKS proxy compatibility
- Add --dns-tcp CLI flag to force DNS queries over TCP - Add dns_tcp field to AuthContext dataclass - Update DNS resolution functions with use_tcp parameter: - _dns_ptr_lookup() in smb/connection.py - resolve_dc_hostname() in utils/ldap.py - get_ldap_connection() in utils/ldap.py - Thread dns_tcp through engine/online.py call chain - Add TOML config support for dns_tcp setting - Update taskhound.toml.example with dns_tcp option fix: pass hashes parameter correctly in verify_ldap_connection() - Fix bug where test_hashes was hardcoded to None instead of being passed to resolve_sid_via_ldap(), causing --hashes authentication to fail during LDAP credential validation test: add unit tests for TCP DNS functionality
1 parent 3ddf25c commit 44eb1d5

File tree

9 files changed

+65
-15
lines changed

9 files changed

+65
-15
lines changed

config/taskhound.toml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# timeout = 60
2222
# threads = 10 # Parallel workers for multi-target scans (default: 1)
2323
# rate_limit = 5.0 # Max targets per second (default: unlimited)
24+
# dns_tcp = false # Force DNS over TCP (required for SOCKS proxies/proxychains)
2425

2526
[scanning]
2627
# Default scan options

taskhound/auth/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class AuthContext:
5151
kerberos: bool = False
5252
dc_ip: Optional[str] = None
5353
timeout: int = 60
54+
dns_tcp: bool = False # Force DNS queries over TCP (for SOCKS proxies)
5455

5556
# LDAP-specific credentials (optional override)
5657
ldap_domain: Optional[str] = None

taskhound/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def main():
200200
kerberos=args.kerberos,
201201
dc_ip=args.dc_ip,
202202
timeout=args.timeout,
203+
dns_tcp=getattr(args, "dns_tcp", False),
203204
ldap_domain=args.ldap_domain,
204205
ldap_user=args.ldap_user,
205206
ldap_password=args.ldap_password,

taskhound/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ def load_config() -> Dict[str, Any]:
195195
defaults["threads"] = target["threads"]
196196
if "rate_limit" in target:
197197
defaults["rate_limit"] = target["rate_limit"]
198+
if "dns_tcp" in target:
199+
defaults["dns_tcp"] = target["dns_tcp"]
198200

199201
# Scanning
200202
scan = config_data.get("scanning", {})
@@ -372,6 +374,12 @@ def build_parser() -> argparse.ArgumentParser:
372374
help="Maximum targets per second (default: unlimited). Use to avoid triggering security alerts "
373375
"or hitting Windows SMB connection limits. Example: --rate-limit 5 limits to 5 targets/second.",
374376
)
377+
target.add_argument(
378+
"--dns-tcp",
379+
action="store_true",
380+
help="Force DNS queries over TCP instead of UDP. Required when using SOCKS proxies or proxychains "
381+
"(UDP doesn't traverse SOCKS). Combine with --dc-ip for reliable DNS resolution through tunnels.",
382+
)
375383

376384
# High value / scanning options
377385
scan = ap.add_argument_group("Scanning options")

taskhound/engine/online.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def process_target(
164164
kerberos = auth.kerberos
165165
dc_ip = auth.dc_ip
166166
timeout = auth.timeout
167+
dns_tcp = auth.dns_tcp
167168
ldap_domain = auth.ldap_domain
168169
ldap_user = auth.ldap_user
169170
ldap_password = auth.ldap_password
@@ -197,9 +198,9 @@ def process_target(
197198
if _is_ip_address(target):
198199
# Try DC first, then system DNS
199200
if dc_ip:
200-
discovered_hostname = _dns_ptr_lookup(target, nameserver=dc_ip)
201+
discovered_hostname = _dns_ptr_lookup(target, nameserver=dc_ip, use_tcp=dns_tcp)
201202
if not discovered_hostname:
202-
discovered_hostname = _dns_ptr_lookup(target, nameserver=None)
203+
discovered_hostname = _dns_ptr_lookup(target, nameserver=None, use_tcp=dns_tcp)
203204

204205
if discovered_hostname:
205206
# Extract just the hostname part (before first dot) for LAPS lookup
@@ -275,7 +276,7 @@ def process_target(
275276
# This is critical when target is an IP address - BloodHound needs FQDNs
276277
# Tries: 1) SMB hostname, 2) DNS via DC, 3) System DNS
277278

278-
server_fqdn = get_server_fqdn(smb, target_ip=target, dc_ip=dc_ip)
279+
server_fqdn = get_server_fqdn(smb, target_ip=target, dc_ip=dc_ip, dns_tcp=dns_tcp)
279280
if server_fqdn and server_fqdn != "UNKNOWN_HOST":
280281
if server_fqdn.upper() != target.upper():
281282
info(f"{target}: Resolved FQDN: {server_fqdn}")

taskhound/smb/connection.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def get_server_sid(
352352
return None
353353

354354

355-
def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip: Optional[str] = None) -> str:
355+
def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip: Optional[str] = None, dns_tcp: bool = False) -> str:
356356
"""
357357
Extract the server's FQDN from an established SMB connection.
358358
@@ -366,6 +366,7 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
366366
smb: Established SMBConnection
367367
target_ip: Original target IP address (used for DNS fallback)
368368
dc_ip: Domain Controller IP to use as DNS server
369+
dns_tcp: Force DNS queries over TCP (required for SOCKS proxies)
369370
370371
Returns:
371372
FQDN string (e.g., "DC.badsuccessor.lab") or "UNKNOWN_HOST"
@@ -395,12 +396,12 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
395396
if target_ip and _is_ip_address(target_ip):
396397
# Method 3: Try using DC as DNS server (if provided)
397398
if dc_ip:
398-
fqdn = _dns_ptr_lookup(target_ip, nameserver=dc_ip)
399+
fqdn = _dns_ptr_lookup(target_ip, nameserver=dc_ip, use_tcp=dns_tcp)
399400
if fqdn:
400401
return fqdn
401402

402403
# Method 4: Try system DNS
403-
fqdn = _dns_ptr_lookup(target_ip, nameserver=None)
404+
fqdn = _dns_ptr_lookup(target_ip, nameserver=None, use_tcp=dns_tcp)
404405
if fqdn:
405406
return fqdn
406407

@@ -422,33 +423,38 @@ def _is_ip_address(hostname: str) -> bool:
422423
return False
423424

424425

425-
def _dns_ptr_lookup(ip: str, nameserver: Optional[str] = None) -> Optional[str]:
426+
def _dns_ptr_lookup(ip: str, nameserver: Optional[str] = None, use_tcp: bool = False) -> Optional[str]:
426427
"""
427428
Perform DNS PTR lookup to resolve IP to hostname.
428429
429430
Args:
430431
ip: IP address to resolve
431432
nameserver: Optional DNS server to query (e.g., DC IP)
433+
use_tcp: Force DNS queries over TCP (required for SOCKS proxies)
432434
433435
Returns:
434436
FQDN if successful, None otherwise
435437
"""
436438
try:
437-
# Try using dnspython if available (supports custom nameserver)
438-
if nameserver:
439+
# Try using dnspython if available (supports custom nameserver and TCP)
440+
if nameserver or use_tcp:
439441
try:
440442
import dns.resolver
441443
import dns.reversename
442444

443445
# Create resolver with custom nameserver
444446
resolver = dns.resolver.Resolver(configure=False)
445-
resolver.nameservers = [nameserver]
447+
if nameserver:
448+
resolver.nameservers = [nameserver]
446449
resolver.timeout = 3
447450
resolver.lifetime = 3
448451

452+
# Force TCP if requested (required for SOCKS/proxychains)
453+
tcp_flag = use_tcp
454+
449455
# Perform PTR lookup
450456
rev_name = dns.reversename.from_address(ip)
451-
answers = resolver.resolve(rev_name, "PTR")
457+
answers = resolver.resolve(rev_name, "PTR", tcp=tcp_flag)
452458

453459
if answers:
454460
# Return first answer, strip trailing dot

taskhound/utils/ldap.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def parse_ntlm_hashes(hashes: Optional[str]) -> Tuple[str, str]:
3131
return "", hashes
3232

3333

34-
def resolve_dc_hostname(dc_ip: str, domain: str) -> Optional[str]:
34+
def resolve_dc_hostname(dc_ip: str, domain: str, use_tcp: bool = False) -> Optional[str]:
3535
"""
3636
Resolve DC IP to hostname for Kerberos SPN construction.
3737
@@ -43,6 +43,7 @@ def resolve_dc_hostname(dc_ip: str, domain: str) -> Optional[str]:
4343
Args:
4444
dc_ip: Domain controller IP address
4545
domain: Domain name (for constructing FQDN)
46+
use_tcp: Force DNS queries over TCP (required for SOCKS proxies)
4647
4748
Returns:
4849
DC hostname (FQDN) or None if resolution fails
@@ -59,7 +60,8 @@ def resolve_dc_hostname(dc_ip: str, domain: str) -> Optional[str]:
5960
resolver.lifetime = 3
6061

6162
rev_name = dns.reversename.from_address(dc_ip)
62-
answers = resolver.resolve(rev_name, "PTR")
63+
# Force TCP if requested (required for SOCKS/proxychains)
64+
answers = resolver.resolve(rev_name, "PTR", tcp=use_tcp)
6365
if answers:
6466
hostname = str(answers[0]).rstrip(".")
6567
# If we got a short name, append the domain
@@ -101,6 +103,7 @@ def get_ldap_connection(
101103
hashes: Optional[str] = None,
102104
kerberos: bool = False,
103105
dc_host: Optional[str] = None,
106+
use_tcp: bool = False,
104107
) -> ldap_impacket.LDAPConnection:
105108
"""
106109
Establish LDAP connection to domain controller.
@@ -117,6 +120,7 @@ def get_ldap_connection(
117120
hashes: NTLM hashes in LM:NT or NT format
118121
kerberos: Use Kerberos authentication
119122
dc_host: DC hostname for Kerberos SPN (optional, will try to resolve)
123+
use_tcp: Force DNS queries over TCP (required for SOCKS proxies)
120124
121125
Returns:
122126
LDAPConnection object
@@ -134,7 +138,7 @@ def get_ldap_connection(
134138
# If not provided, try to resolve
135139
kerberos_target = dc_host
136140
if kerberos and not kerberos_target:
137-
kerberos_target = resolve_dc_hostname(dc_ip, domain)
141+
kerberos_target = resolve_dc_hostname(dc_ip, domain, use_tcp=use_tcp)
138142
if kerberos_target:
139143
debug(f"LDAP: Resolved DC hostname for Kerberos SPN: {kerberos_target}")
140144
else:

taskhound/utils/network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def verify_ldap_connection(
6666
info("Using domain SID derived from BloodHound data for realistic testing")
6767

6868
info(f"Testing SID resolution with: {test_sid}")
69-
result = resolve_sid_via_ldap(test_sid, test_domain, dc_ip, test_username, test_password, None, kerberos)
69+
result = resolve_sid_via_ldap(test_sid, test_domain, dc_ip, test_username, test_password, test_hashes, kerberos)
7070

7171
if result:
7272
good(f"LDAP test successful: {test_sid} -> {result}")

tests/test_ldap_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,34 @@ def test_getfqdn_returns_ip_no_hostname(self, mock_gethostbyaddr, mock_getfqdn):
132132

133133
assert result is None
134134

135+
def test_use_tcp_parameter_accepted(self):
136+
"""Should accept use_tcp parameter without error"""
137+
# Just verify the function accepts the parameter (actual DNS lookup will fail/be mocked)
138+
with patch('taskhound.utils.ldap.socket.gethostbyaddr') as mock_gethostbyaddr:
139+
mock_gethostbyaddr.return_value = ("dc01.example.com", [], [])
140+
result = resolve_dc_hostname("192.168.1.1", "example.com", use_tcp=True)
141+
assert result == "dc01.example.com"
142+
143+
@patch('dns.reversename.from_address')
144+
@patch('dns.resolver.Resolver')
145+
def test_use_tcp_passes_to_resolver(self, mock_resolver_class, mock_from_address):
146+
"""Should pass tcp=True to resolver.resolve() when use_tcp=True"""
147+
mock_resolver = MagicMock()
148+
mock_resolver_class.return_value = mock_resolver
149+
mock_from_address.return_value = "1.1.168.192.in-addr.arpa"
150+
151+
mock_answer = MagicMock()
152+
mock_answer.__str__ = MagicMock(return_value="dc01.example.com.")
153+
mock_resolver.resolve.return_value = [mock_answer]
154+
155+
result = resolve_dc_hostname("192.168.1.1", "example.com", use_tcp=True)
156+
157+
# Verify resolve was called with tcp=True
158+
mock_resolver.resolve.assert_called_once()
159+
call_args = mock_resolver.resolve.call_args
160+
assert call_args[1].get('tcp') is True
161+
assert result == "dc01.example.com"
162+
135163

136164
# ============================================================================
137165
# Test: get_ldap_connection

0 commit comments

Comments
 (0)