Skip to content

Commit 9571a85

Browse files
author
r0BIT
committed
fix: LDAPS connection failure and add DC auto-discovery
- Fixed SSL/TLS connection failure caused by socket.setdefaulttimeout() breaking non-blocking sockets during SSL handshake (WantReadError) - Added DNS SRV record discovery for automatic DC detection - Added --ns/--nameserver flag for explicit DNS server specification - Added connection timeout support for DC discovery phase - Moved DNS utilities to dedicated module (taskhound/utils/dns.py) - Cleaned up orphaned timeout exception handling in LDAP module
1 parent bfa73f5 commit 9571a85

File tree

8 files changed

+585
-30
lines changed

8 files changed

+585
-30
lines changed

config/taskhound.toml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
[target]
1818
# Default domain controller
1919
# dc_ip = "10.0.0.1"
20+
# nameserver = "10.0.0.53" # DNS server for lookups (defaults to dc_ip or system DNS)
2021
# target = "10.0.0.1"
2122
# targets_file = "targets.txt"
2223
# timeout = 60

taskhound/auth/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class AuthContext:
5353
dc_ip: Optional[str] = None
5454
timeout: int = 60
5555
dns_tcp: bool = False # Force DNS queries over TCP (for SOCKS proxies)
56+
nameserver: Optional[str] = None # DNS nameserver (defaults to dc_ip or system DNS)
5657

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

taskhound/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def main():
204204
dc_ip=args.dc_ip,
205205
timeout=args.timeout,
206206
dns_tcp=getattr(args, "dns_tcp", False),
207+
nameserver=getattr(args, "nameserver", None),
207208
ldap_domain=args.ldap_domain,
208209
ldap_user=args.ldap_user,
209210
ldap_password=args.ldap_password,

taskhound/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ def load_config() -> Dict[str, Any]:
187187
target = config_data.get("target", {})
188188
if "dc_ip" in target:
189189
defaults["dc_ip"] = target["dc_ip"]
190+
if "nameserver" in target:
191+
defaults["nameserver"] = target["nameserver"]
190192
if "timeout" in target:
191193
defaults["timeout"] = target["timeout"]
192194
if "target" in target:
@@ -367,6 +369,12 @@ def build_parser() -> argparse.ArgumentParser:
367369
target.add_argument("-t", "--target", action=OnceOnly, help="Target(s) - single host or comma-separated list (e.g., 192.168.1.1,192.168.1.2)")
368370
target.add_argument("--targets-file", help="File with targets, one per line")
369371
target.add_argument("--dc-ip", help="Domain controller IP (required when using Kerberos without DNS)")
372+
target.add_argument(
373+
"--ns", "--nameserver",
374+
dest="nameserver",
375+
help="DNS nameserver for lookups. If not specified, uses --dc-ip or system DNS. "
376+
"Useful when DNS server differs from DC (lab environments, split DNS).",
377+
)
370378
target.add_argument(
371379
"--timeout",
372380
type=int,

taskhound/utils/dns.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# DNS utilities for TaskHound
2+
#
3+
# This module provides DNS discovery and resolution utilities,
4+
# including DC discovery via SRV records and configurable nameserver support.
5+
6+
import socket
7+
from typing import List, Optional
8+
9+
from .logging import debug, warn
10+
11+
# Default timeout for DNS operations (seconds)
12+
DEFAULT_DNS_TIMEOUT = 5
13+
14+
# Default timeout for LDAP connections (seconds)
15+
DEFAULT_LDAP_TIMEOUT = 10
16+
17+
18+
def discover_domain_controllers(
19+
domain: str,
20+
nameserver: Optional[str] = None,
21+
use_tcp: bool = False,
22+
timeout: int = DEFAULT_DNS_TIMEOUT,
23+
) -> List[str]:
24+
"""
25+
Discover domain controllers via DNS SRV records.
26+
27+
Queries _ldap._tcp.dc._msdcs.<domain> SRV record to find all DCs.
28+
Falls back to A record lookup if SRV fails.
29+
30+
Args:
31+
domain: Domain name (e.g., "corp.local")
32+
nameserver: Optional DNS server to use (defaults to system DNS)
33+
use_tcp: Force DNS queries over TCP (required for SOCKS proxies)
34+
timeout: DNS query timeout in seconds
35+
36+
Returns:
37+
List of DC hostnames/IPs (may be empty if discovery fails)
38+
"""
39+
dcs = []
40+
41+
# Try DNS SRV record first (proper AD DC discovery)
42+
try:
43+
import dns.resolver
44+
45+
resolver = dns.resolver.Resolver(configure=True)
46+
if nameserver:
47+
resolver.nameservers = [nameserver]
48+
resolver.timeout = timeout
49+
resolver.lifetime = timeout
50+
51+
# Query SRV record for LDAP service on DCs
52+
srv_name = f"_ldap._tcp.dc._msdcs.{domain}"
53+
debug(f"DNS: Querying SRV record {srv_name}")
54+
55+
answers = resolver.resolve(srv_name, "SRV", tcp=use_tcp)
56+
for rdata in answers:
57+
dc_host = str(rdata.target).rstrip(".")
58+
if dc_host:
59+
dcs.append(dc_host)
60+
debug(f"DNS: Found DC via SRV: {dc_host} (priority={rdata.priority}, weight={rdata.weight})")
61+
62+
# Sort by priority (lower = better), then by weight (higher = better)
63+
# SRV records already come sorted, but let's be explicit
64+
if dcs:
65+
debug(f"DNS: Discovered {len(dcs)} DCs via SRV records")
66+
return dcs
67+
68+
except ImportError:
69+
debug("DNS: dnspython not available, falling back to system DNS")
70+
except Exception as e:
71+
debug(f"DNS: SRV lookup failed: {e}")
72+
73+
# Fallback: Try A record for domain name
74+
try:
75+
debug(f"DNS: Falling back to A record lookup for {domain}")
76+
77+
# If nameserver specified, use dnspython
78+
if nameserver:
79+
try:
80+
import dns.resolver
81+
resolver = dns.resolver.Resolver(configure=False)
82+
resolver.nameservers = [nameserver]
83+
resolver.timeout = timeout
84+
resolver.lifetime = timeout
85+
86+
answers = resolver.resolve(domain, "A", tcp=use_tcp)
87+
for rdata in answers:
88+
ip = str(rdata)
89+
dcs.append(ip)
90+
debug(f"DNS: Found DC via A record: {ip}")
91+
return dcs
92+
except Exception as e:
93+
debug(f"DNS: A record lookup with custom nameserver failed: {e}")
94+
95+
# System DNS fallback
96+
ip = socket.gethostbyname(domain)
97+
if ip:
98+
dcs.append(ip)
99+
debug(f"DNS: Resolved domain to IP: {ip}")
100+
101+
except socket.gaierror as e:
102+
debug(f"DNS: Could not resolve domain {domain}: {e}")
103+
except Exception as e:
104+
debug(f"DNS: Domain resolution failed: {e}")
105+
106+
return dcs
107+
108+
109+
def resolve_hostname(
110+
hostname: str,
111+
nameserver: Optional[str] = None,
112+
use_tcp: bool = False,
113+
timeout: int = DEFAULT_DNS_TIMEOUT,
114+
) -> Optional[str]:
115+
"""
116+
Resolve a hostname to an IP address.
117+
118+
Args:
119+
hostname: Hostname to resolve
120+
nameserver: Optional DNS server to use
121+
use_tcp: Force DNS queries over TCP
122+
timeout: DNS query timeout in seconds
123+
124+
Returns:
125+
IP address string, or None if resolution fails
126+
"""
127+
# If already an IP, return as-is
128+
if _is_ip_address(hostname):
129+
return hostname
130+
131+
try:
132+
if nameserver:
133+
import dns.resolver
134+
resolver = dns.resolver.Resolver(configure=False)
135+
resolver.nameservers = [nameserver]
136+
resolver.timeout = timeout
137+
resolver.lifetime = timeout
138+
139+
answers = resolver.resolve(hostname, "A", tcp=use_tcp)
140+
if answers:
141+
return str(answers[0])
142+
else:
143+
return socket.gethostbyname(hostname)
144+
except Exception as e:
145+
debug(f"DNS: Could not resolve {hostname}: {e}")
146+
147+
return None
148+
149+
150+
def reverse_lookup(
151+
ip: str,
152+
nameserver: Optional[str] = None,
153+
use_tcp: bool = False,
154+
timeout: int = DEFAULT_DNS_TIMEOUT,
155+
) -> Optional[str]:
156+
"""
157+
Perform reverse DNS lookup (PTR record).
158+
159+
Args:
160+
ip: IP address to lookup
161+
nameserver: Optional DNS server to use
162+
use_tcp: Force DNS queries over TCP
163+
timeout: DNS query timeout in seconds
164+
165+
Returns:
166+
Hostname (FQDN), or None if lookup fails
167+
"""
168+
try:
169+
if nameserver:
170+
import dns.resolver
171+
import dns.reversename
172+
173+
resolver = dns.resolver.Resolver(configure=False)
174+
resolver.nameservers = [nameserver]
175+
resolver.timeout = timeout
176+
resolver.lifetime = timeout
177+
178+
rev_name = dns.reversename.from_address(ip)
179+
answers = resolver.resolve(rev_name, "PTR", tcp=use_tcp)
180+
if answers:
181+
return str(answers[0]).rstrip(".")
182+
else:
183+
return socket.gethostbyaddr(ip)[0]
184+
except Exception as e:
185+
debug(f"DNS: Reverse lookup for {ip} failed: {e}")
186+
187+
return None
188+
189+
190+
def _is_ip_address(hostname: str) -> bool:
191+
"""Check if a string is an IPv4 address."""
192+
parts = hostname.split(".")
193+
if len(parts) == 4:
194+
try:
195+
return all(0 <= int(p) <= 255 for p in parts)
196+
except (ValueError, TypeError):
197+
return False
198+
return False
199+
200+
201+
def get_working_dc(
202+
domain: str,
203+
dc_ip: Optional[str] = None,
204+
nameserver: Optional[str] = None,
205+
use_tcp: bool = False,
206+
timeout: int = DEFAULT_LDAP_TIMEOUT,
207+
) -> Optional[str]:
208+
"""
209+
Get a working DC IP for LDAP connections.
210+
211+
If dc_ip is provided, returns it directly (user override).
212+
Otherwise, discovers DCs and tests connectivity.
213+
214+
Args:
215+
domain: Domain name
216+
dc_ip: User-specified DC IP (if provided, used directly)
217+
nameserver: DNS server for discovery
218+
use_tcp: Force DNS over TCP
219+
timeout: Connection timeout for testing
220+
221+
Returns:
222+
DC IP address, or None if no working DC found
223+
"""
224+
# User explicitly specified DC - use it
225+
if dc_ip:
226+
return dc_ip
227+
228+
# Discover DCs
229+
dcs = discover_domain_controllers(domain, nameserver=nameserver, use_tcp=use_tcp)
230+
231+
if not dcs:
232+
warn(f"Could not discover any DCs for domain {domain}")
233+
return None
234+
235+
# Resolve hostnames to IPs and test connectivity
236+
for dc in dcs:
237+
# Resolve if hostname
238+
dc_resolved = resolve_hostname(dc, nameserver=nameserver, use_tcp=use_tcp, timeout=timeout)
239+
if not dc_resolved:
240+
debug(f"DNS: Could not resolve DC hostname {dc}")
241+
continue
242+
243+
# Test LDAP port connectivity (quick check)
244+
if _test_port(dc_resolved, 636, timeout=min(timeout, 3)):
245+
debug(f"DNS: DC {dc_resolved} is reachable on LDAPS (636)")
246+
return dc_resolved
247+
elif _test_port(dc_resolved, 389, timeout=min(timeout, 3)):
248+
debug(f"DNS: DC {dc_resolved} is reachable on LDAP (389)")
249+
return dc_resolved
250+
else:
251+
debug(f"DNS: DC {dc_resolved} not reachable on LDAP ports")
252+
253+
# If no DC responded on LDAP ports, return first one anyway
254+
# (let LDAP connection handle the error with better diagnostics)
255+
if dcs:
256+
first_dc = resolve_hostname(dcs[0], nameserver=nameserver, use_tcp=use_tcp)
257+
if first_dc:
258+
warn(f"No DC responded on LDAP ports, trying {first_dc} anyway")
259+
return first_dc
260+
261+
return None
262+
263+
264+
def _test_port(host: str, port: int, timeout: int = 3) -> bool:
265+
"""Test if a TCP port is reachable."""
266+
try:
267+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
268+
sock.settimeout(timeout)
269+
result = sock.connect_ex((host, port))
270+
sock.close()
271+
return result == 0
272+
except Exception:
273+
return False

taskhound/utils/ldap.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def resolve_dc_hostname(dc_ip: str, domain: str, use_tcp: bool = False) -> Optio
9696

9797

9898
def get_ldap_connection(
99-
dc_ip: str,
99+
dc_ip: Optional[str],
100100
domain: str,
101101
username: str,
102102
password: Optional[str] = None,
@@ -105,6 +105,8 @@ def get_ldap_connection(
105105
aes_key: Optional[str] = None,
106106
dc_host: Optional[str] = None,
107107
use_tcp: bool = False,
108+
nameserver: Optional[str] = None,
109+
timeout: int = 10,
108110
) -> ldap_impacket.LDAPConnection:
109111
"""
110112
Establish LDAP connection to domain controller.
@@ -113,8 +115,10 @@ def get_ldap_connection(
113115
LDAP (port 389) if LDAPS fails. This handles DCs that require channel
114116
binding or LDAP signing (strongerAuthRequired error).
115117
118+
If dc_ip is not provided, attempts to discover DCs via DNS SRV records.
119+
116120
Args:
117-
dc_ip: Domain controller IP address
121+
dc_ip: Domain controller IP address (optional - will auto-discover if not provided)
118122
domain: Domain name (FQDN format, e.g., "domain.local")
119123
username: Username for authentication
120124
password: Password (plaintext)
@@ -123,13 +127,46 @@ def get_ldap_connection(
123127
aes_key: AES key for Kerberos (128-bit or 256-bit hex string)
124128
dc_host: DC hostname for Kerberos SPN (optional, will try to resolve)
125129
use_tcp: Force DNS queries over TCP (required for SOCKS proxies)
130+
nameserver: DNS server for lookups (defaults to dc_ip or system DNS)
131+
timeout: Timeout for DC discovery only (default: 10s). Note: actual LDAP
132+
connection uses OS-level TCP timeout (~75s on most systems) because
133+
impacket doesn't support per-connection timeouts.
126134
127135
Returns:
128136
LDAPConnection object
129137
130138
Raises:
131139
LDAPConnectionError: If connection fails
132140
"""
141+
from .dns import DEFAULT_LDAP_TIMEOUT, get_working_dc
142+
143+
# Use provided timeout or default
144+
effective_timeout = timeout if timeout else DEFAULT_LDAP_TIMEOUT
145+
146+
# NOTE: The timeout parameter only applies to DC discovery, not the LDAP connection.
147+
# We cannot use socket.setdefaulttimeout() because it breaks SSL connections
148+
# (causes WantReadError during handshake). impacket's LDAPConnection doesn't
149+
# support per-connection timeouts, so we rely on OS-level TCP timeout (~75s)
150+
# for unreachable DCs. The DC discovery phase tests port connectivity with
151+
# proper timeouts, so unreachable DCs should be filtered out before we get here.
152+
153+
# If no DC IP provided, try to discover one
154+
if not dc_ip:
155+
# Use nameserver if provided, otherwise let discovery use system DNS
156+
effective_ns = nameserver
157+
dc_ip = get_working_dc(
158+
domain=domain,
159+
nameserver=effective_ns,
160+
use_tcp=use_tcp,
161+
timeout=effective_timeout,
162+
)
163+
if not dc_ip:
164+
raise LDAPConnectionError(
165+
f"Could not discover DC for domain {domain}. "
166+
"Specify --dc-ip explicitly or check DNS configuration."
167+
)
168+
debug(f"LDAP: Auto-discovered DC: {dc_ip}")
169+
133170
# Build base DN from domain
134171
base_dn = ",".join([f"DC={part}" for part in domain.split(".")])
135172

0 commit comments

Comments
 (0)