Skip to content

Commit b0618b7

Browse files
author
r0BIT
committed
feat: Add AES key authentication support
- Add --aes-key CLI option for Kerberos AES-128/256 key authentication - Thread aes_key through SMB, LDAP, and RPC authentication paths - Fix Kerberos RPC auth by setting RPC_C_AUTHN_GSS_NEGOTIATE before connect - Fix 'Could not resolve FQDN' warning when target is already FQDN
1 parent 54ea3a9 commit b0618b7

File tree

10 files changed

+155
-62
lines changed

10 files changed

+155
-62
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,20 @@ taskhound -u homer.simpson --hashes :5d41402abc4b2a76b9719d911017c592 -d thesimp
234234
taskhound -u Administrator -p L0c@lAdm1n! -d . -t moe.thesimpsons.local --ldap-user bart.simpson --ldap-password B@rtP@ss --ldap-domain thesimpsons.local
235235
```
236236

237+
### AES Key Authentication
238+
239+
Authenticate using Kerberos AES keys (from a keytab, secretsdump, etc.):
240+
241+
```bash
242+
# AES-256 key (64 hex characters)
243+
taskhound -u svc_backup -d corp.local --aes-key 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -t dc01.corp.local --dc-ip 10.0.0.1
244+
245+
# AES-128 key (32 hex characters)
246+
taskhound -u admin -d corp.local --aes-key 0123456789abcdef0123456789abcdef -t server01.corp.local --dc-ip 10.0.0.1
247+
```
248+
249+
> **NOTE**: AES key authentication implies `-k` (Kerberos). The `--dc-ip` flag is recommended for reliable KDC resolution.
250+
237251
**Why separate LDAP credentials?**
238252
- LDAP SID resolution now uses Impacket's LDAP implementation with NTLM hash support
239253
- You might only have local admin access but need domain LDAP for SID resolution
@@ -331,6 +345,7 @@ Authentication:
331345
-d, --domain Domain (required for online mode)
332346
--hashes HASHES NTLM hashes (LM:NT or NT-only format)
333347
-k, --kerberos Use Kerberos authentication (supports ccache)
348+
--aes-key AES_KEY AES key for Kerberos (128-bit: 32 hex, 256-bit: 64 hex)
334349
335350
Targets:
336351
-t, --target Single target hostname/IP

config/taskhound.toml.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# domain = "CORP.LOCAL"
1212
# password = "Password123!"
1313
# hashes = "aad3b435b51404eeaad3b435b51404ee:..." (optional)
14+
# aes_key = "0123456789abcdef..." (AES-128: 32 hex chars, AES-256: 64 hex chars)
1415
# kerberos = true
1516

1617
[target]

taskhound/auth/context.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class AuthContext:
4848
password: Optional[str] = None
4949
domain: str = ""
5050
hashes: Optional[str] = None
51+
aes_key: Optional[str] = None # AES key for Kerberos (128-bit or 256-bit)
5152
kerberos: bool = False
5253
dc_ip: Optional[str] = None
5354
timeout: int = 60
@@ -62,7 +63,7 @@ class AuthContext:
6263
@property
6364
def has_credentials(self) -> bool:
6465
"""Check if valid credentials are configured."""
65-
return bool(self.username and (self.password or self.hashes or self.kerberos))
66+
return bool(self.username and (self.password or self.hashes or self.aes_key or self.kerberos))
6667

6768
@property
6869
def ldap_effective_domain(self) -> str:
@@ -104,5 +105,6 @@ def __repr__(self) -> str:
104105
f"AuthContext(username={self.username!r}, domain={self.domain!r}, "
105106
f"kerberos={self.kerberos}, dc_ip={self.dc_ip!r}, "
106107
f"has_password={self.password is not None}, "
107-
f"has_hashes={self.hashes is not None})"
108+
f"has_hashes={self.hashes is not None}, "
109+
f"has_aes_key={self.aes_key is not None})"
108110
)

taskhound/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,15 @@ def main():
192192
targets = normalize_targets(targets, args.domain)
193193

194194
# Build AuthContext from args
195+
# AES key implies Kerberos authentication
196+
kerberos_enabled = args.kerberos or getattr(args, "aes_key", None) is not None
195197
auth = AuthContext(
196198
username=args.username,
197199
password=args.password,
198200
domain=args.domain,
199201
hashes=args.hashes,
200-
kerberos=args.kerberos,
202+
aes_key=getattr(args, "aes_key", None),
203+
kerberos=kerberos_enabled,
201204
dc_ip=args.dc_ip,
202205
timeout=args.timeout,
203206
dns_tcp=getattr(args, "dns_tcp", False),

taskhound/config.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ def load_config() -> Dict[str, Any]:
180180
defaults["hashes"] = auth["hashes"]
181181
if "kerberos" in auth:
182182
defaults["kerberos"] = auth["kerberos"]
183+
if "aes_key" in auth:
184+
defaults["aes_key"] = auth["aes_key"]
183185

184186
# Target
185187
target = config_data.get("target", {})
@@ -354,6 +356,11 @@ def build_parser() -> argparse.ArgumentParser:
354356
"--hashes", help="NTLM hashes in LM:NT format (or NT-only 32-hex) to use instead of password"
355357
)
356358
auth.add_argument("-k", "--kerberos", action="store_true", help="Use Kerberos authentication (supports ccache)")
359+
auth.add_argument(
360+
"--aes-key",
361+
dest="aes_key",
362+
help="AES key for Kerberos authentication (AES-128: 32 hex chars, AES-256: 64 hex chars). Implies -k."
363+
)
357364

358365
# Target selection
359366
target = ap.add_argument_group("Target options")
@@ -773,12 +780,13 @@ def validate_args(args):
773780
print("[!] Either --target or --targets-file is required for online mode")
774781
sys.exit(1)
775782

776-
# Authentication method validation - require either password, hash, or Kerberos
777-
if not args.password and not args.hashes and not args.kerberos:
783+
# Authentication method validation - require either password, hash, AES key, or Kerberos
784+
if not args.password and not args.hashes and not getattr(args, "aes_key", None) and not args.kerberos:
778785
print("[!] ERROR: Authentication required for online mode")
779786
print("[!] You must specify one of:")
780787
print("[!] -p PASSWORD (password authentication)")
781788
print("[!] --hashes HASH (NTLM hash authentication)")
789+
print("[!] --aes-key KEY (Kerberos with AES key)")
782790
print("[!] -k (Kerberos authentication with ccache)")
783791
print()
784792
if "KRB5CCNAME" in os.environ:

taskhound/engine/online.py

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def process_target(
161161
username = auth.username
162162
password = auth.password
163163
hashes = auth.hashes
164+
aes_key = auth.aes_key
164165
kerberos = auth.kerberos
165166
dc_ip = auth.dc_ip
166167
timeout = auth.timeout
@@ -267,7 +268,7 @@ def process_target(
267268
else:
268269
# Standard mode: Direct SMB connection
269270
smb = smb_connect(
270-
target, domain, username, hashes or password, kerberos=kerberos, dc_ip=dc_ip, timeout=timeout
271+
target, domain, username, hashes or password, kerberos=kerberos, dc_ip=dc_ip, timeout=timeout, aes_key=aes_key
271272
)
272273

273274
good(f"{target}: Connected via SMB")
@@ -421,48 +422,55 @@ def process_target(
421422

422423
# Credential validation via Task Scheduler RPC (if requested and not in OPSEC mode)
423424
if validate_creds and not opsec and password_task_paths:
424-
info(f"{target}: Querying Task Scheduler RPC for credential validation...")
425-
try:
426-
# Parse hashes for RPC auth
427-
lm_hash = ""
428-
nt_hash = ""
429-
if hashes:
430-
hash_parts = hashes.split(":")
431-
if len(hash_parts) == 2:
432-
lm_hash, nt_hash = hash_parts
433-
elif len(hash_parts) == 1 and len(hash_parts[0]) == 32:
434-
nt_hash = hash_parts[0]
435-
436-
rpc_client = TaskSchedulerRPC(
437-
target=target,
438-
domain=domain,
439-
username=username,
440-
password=password or "",
441-
lm_hash=lm_hash,
442-
nt_hash=nt_hash,
443-
)
425+
# Skip if using ccache-only Kerberos (no credentials to use for RPC)
426+
if kerberos and not password and not hashes and not aes_key:
427+
warn(f"{target}: Credential validation not supported with ccache-only Kerberos (use password, --hashes, or --aes-key)")
428+
else:
429+
info(f"{target}: Querying Task Scheduler RPC for credential validation...")
430+
try:
431+
# Parse hashes for RPC auth
432+
lm_hash = ""
433+
nt_hash = ""
434+
if hashes:
435+
hash_parts = hashes.split(":")
436+
if len(hash_parts) == 2:
437+
lm_hash, nt_hash = hash_parts
438+
elif len(hash_parts) == 1 and len(hash_parts[0]) == 32:
439+
nt_hash = hash_parts[0]
440+
441+
rpc_client = TaskSchedulerRPC(
442+
target=target,
443+
domain=domain,
444+
username=username,
445+
password=password or "",
446+
lm_hash=lm_hash,
447+
nt_hash=nt_hash,
448+
aes_key=aes_key or "",
449+
kerberos=kerberos,
450+
dc_ip=dc_ip or "",
451+
)
444452

445-
if rpc_client.connect():
446-
# Validate only the tasks we know have Password logon type
447-
cred_validation_results = rpc_client.validate_specific_tasks(password_task_paths)
448-
rpc_client.disconnect()
449-
450-
if cred_validation_results:
451-
valid_count = sum(1 for r in cred_validation_results.values() if r.password_valid)
452-
invalid_count = sum(1 for r in cred_validation_results.values()
453-
if r.credential_status == CredentialStatus.INVALID)
454-
unknown_count = sum(1 for r in cred_validation_results.values()
455-
if r.credential_status == CredentialStatus.UNKNOWN)
456-
good(f"{target}: Validated {len(cred_validation_results)} password tasks "
457-
f"({valid_count} valid, {invalid_count} invalid, {unknown_count} unknown)")
453+
if rpc_client.connect():
454+
# Validate only the tasks we know have Password logon type
455+
cred_validation_results = rpc_client.validate_specific_tasks(password_task_paths)
456+
rpc_client.disconnect()
457+
458+
if cred_validation_results:
459+
valid_count = sum(1 for r in cred_validation_results.values() if r.password_valid)
460+
invalid_count = sum(1 for r in cred_validation_results.values()
461+
if r.credential_status == CredentialStatus.INVALID)
462+
unknown_count = sum(1 for r in cred_validation_results.values()
463+
if r.credential_status == CredentialStatus.UNKNOWN)
464+
good(f"{target}: Validated {len(cred_validation_results)} password tasks "
465+
f"({valid_count} valid, {invalid_count} invalid, {unknown_count} unknown)")
466+
else:
467+
info(f"{target}: No run info available for password tasks")
458468
else:
459-
info(f"{target}: No run info available for password tasks")
460-
else:
461-
warn(f"{target}: Failed to connect to Task Scheduler RPC")
462-
except Exception as e:
463-
warn(f"{target}: Credential validation failed: {e}")
464-
if debug:
465-
traceback.print_exc()
469+
warn(f"{target}: Failed to connect to Task Scheduler RPC")
470+
except Exception as e:
471+
warn(f"{target}: Credential validation failed: {e}")
472+
if debug:
473+
traceback.print_exc()
466474
elif validate_creds and not password_task_paths:
467475
info(f"{target}: No password-authenticated tasks found - skipping credential validation")
468476
elif validate_creds and opsec:
@@ -594,6 +602,7 @@ def process_target(
594602
password=ldap_auth_pass,
595603
hashes=ldap_auth_hashes,
596604
kerberos=kerberos,
605+
aes_key=aes_key,
597606
attributes=["pwdLastSet", "sAMAccountName"],
598607
)
599608

@@ -634,6 +643,7 @@ def process_target(
634643
auth_password=ldap_auth_pass,
635644
hashes=ldap_auth_hashes,
636645
kerberos=kerberos,
646+
aes_key=aes_key,
637647
)
638648

639649
if tier0_cache:

taskhound/smb/connection.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,27 @@ def smb_connect(
4646
kerberos: bool = False,
4747
dc_ip: str = None,
4848
timeout: int = 60,
49+
aes_key: str = None,
4950
) -> SMBConnection:
5051
# Create and authenticate an SMBConnection to `target`.
5152
#
5253
# This function prefers passing an explicit lm/nthash when provided and
5354
# falls back to a cleartext password. For Kerberos mode we delegate to
5455
# Impacket's kerberosLogin (which supports a KDC host if provided).
56+
# If an AES key is provided, Kerberos authentication is used automatically.
5557
smb = SMBConnection(remoteName=target, remoteHost=target, sess_port=445, timeout=timeout)
5658

5759
pwd, lmhash, nthash = _parse_hashes(password)
5860

59-
if kerberos:
61+
# AES key implies Kerberos authentication
62+
if kerberos or aes_key:
6063
smb.kerberosLogin(
6164
user=username,
6265
password=pwd,
6366
domain=domain,
6467
lmhash=lmhash,
6568
nthash=nthash,
66-
aesKey=None,
69+
aesKey=aes_key or "",
6770
TGT=None,
6871
TGS=None,
6972
kdcHost=dc_ip,
@@ -106,6 +109,7 @@ def smb_login(
106109
password: str = None,
107110
kerberos: bool = False,
108111
dc_ip: str = None,
112+
aes_key: str = None,
109113
) -> None:
110114
"""
111115
Authenticate an existing SMBConnection.
@@ -120,17 +124,19 @@ def smb_login(
120124
password: Password or NTLM hash
121125
kerberos: Use Kerberos authentication
122126
dc_ip: Domain controller IP (for Kerberos)
127+
aes_key: AES key for Kerberos authentication (128 or 256 bit)
123128
"""
124129
pwd, lmhash, nthash = _parse_hashes(password)
125130

126-
if kerberos:
131+
# AES key implies Kerberos authentication
132+
if kerberos or aes_key:
127133
smb.kerberosLogin(
128134
user=username,
129135
password=pwd,
130136
domain=domain,
131137
lmhash=lmhash,
132138
nthash=nthash,
133-
aesKey=None,
139+
aesKey=aes_key or "",
134140
TGT=None,
135141
TGS=None,
136142
kdcHost=dc_ip,
@@ -357,10 +363,11 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
357363
Extract the server's FQDN from an established SMB connection.
358364
359365
Attempts multiple resolution methods in order:
360-
1. SMB DNS hostname (most reliable)
361-
2. Constructed from SMB hostname + DNS domain
362-
3. DNS PTR lookup using DC as nameserver (if dc_ip provided)
363-
4. System DNS PTR lookup (fallback)
366+
1. If target is already an FQDN (has dots, not an IP), use it directly
367+
2. SMB DNS hostname (most reliable)
368+
3. Constructed from SMB hostname + DNS domain
369+
4. DNS PTR lookup using DC as nameserver (if dc_ip provided)
370+
5. System DNS PTR lookup (fallback)
364371
365372
Args:
366373
smb: Established SMBConnection
@@ -371,6 +378,10 @@ def get_server_fqdn(smb: SMBConnection, target_ip: Optional[str] = None, dc_ip:
371378
Returns:
372379
FQDN string (e.g., "DC.badsuccessor.lab") or "UNKNOWN_HOST"
373380
"""
381+
# Method 0: If target already looks like an FQDN (has dots and isn't an IP), use it
382+
if target_ip and "." in target_ip and not _is_ip_address(target_ip):
383+
return target_ip
384+
374385
try:
375386
# Method 1: Try to get the full DNS hostname directly from SMB
376387
fqdn = smb.getServerDNSHostName()

0 commit comments

Comments
 (0)