Skip to content

Commit 67d90e0

Browse files
authored
Merge pull request Pennyw0rth#1183 from azoxlpf/fix/mssql-integrated-auth-tds-error-handling
fix(mssql): handle TDS error when NTLM challenge absent and fix local-auth flow
2 parents 1f4acea + 31472fb commit 67d90e0

7 files changed

Lines changed: 117 additions & 50 deletions

File tree

netexec.spec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ a = Analysis(
4949
'nxc.helpers.bloodhound',
5050
'nxc.helpers.even6_parser',
5151
'nxc.helpers.msada_guids',
52-
'nxc.helpers.ntlm_parser',
52+
'nxc.helpers.negotiate_parser',
5353
'paramiko',
5454
'pefile',
5555
'pypsrp.client',

nxc/helpers/negotiate_parser.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Parsing helpers for auth negotiation: NTLM challenges and TDS ERROR/INFO on MSSQL LOGIN7.
2+
# Original NTLM parsing from: https://github.com/fortra/impacket/blob/master/examples/DumpNTLMInfo.py#L568
3+
4+
import struct
5+
6+
from impacket import ntlm
7+
from impacket.smb3 import WIN_VERSIONS
8+
from impacket.tds import TDS_ERROR_TOKEN, TDS_INFO_TOKEN, TDS_INFO_ERROR
9+
import contextlib
10+
11+
12+
def parse_challenge(challange):
13+
target_info = {
14+
"hostname": None,
15+
"domain": None,
16+
"os_version": None
17+
}
18+
challange = ntlm.NTLMAuthChallenge(challange)
19+
av_pairs = ntlm.AV_PAIRS(challange["TargetInfoFields"][:challange["TargetInfoFields_len"]])
20+
if av_pairs[ntlm.NTLMSSP_AV_HOSTNAME] is not None:
21+
with contextlib.suppress(Exception):
22+
target_info["hostname"] = av_pairs[ntlm.NTLMSSP_AV_HOSTNAME][1].decode("utf-16le")
23+
if av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME] is not None:
24+
with contextlib.suppress(Exception):
25+
target_info["domain"] = av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME][1].decode("utf-16le")
26+
if "Version" in challange.fields:
27+
version = challange["Version"]
28+
if len(version) >= 4:
29+
major_version = version[0]
30+
minor_version = version[1]
31+
product_build = struct.unpack("<H", version[2:4])[0]
32+
if product_build in WIN_VERSIONS:
33+
target_info["os_version"] = f"{WIN_VERSIONS[product_build]} Build {product_build}"
34+
else:
35+
target_info["os_version"] = f"{major_version}.{minor_version} Build {product_build}"
36+
return target_info
37+
38+
39+
def decode_tds_info_error_msgtext(data, offset):
40+
"""Extract MsgText from a TDS ERROR (0xAA) or INFO (0xAB) token at *offset*.
41+
42+
Official spec: [MS-TDS] Tabular Data Stream Protocol (Microsoft Learn).
43+
Token layout per MS-TDS 2.2.7.9 (INFO) / 2.2.7.10 (ERROR):
44+
TokenType BYTE 0xAA | 0xAB
45+
Length USHORT LE byte count of the remaining fields
46+
Number LONG LE error / info number
47+
State BYTE
48+
Class BYTE severity
49+
MsgText US_VARCHAR (2-byte LE length prefix + UTF-16LE)
50+
... (ServerName, ProcName, LineNumber follow but are unused here)
51+
52+
The minimum *Length* value for a valid token is 8: Number(4) + State(1) +
53+
Class(1) + MsgText length prefix(2, may be zero-length string).
54+
55+
References (Microsoft Learn, MS-TDS):
56+
INFO: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/284bb815-d083-4ed5-b33a-bdc2492e322b
57+
ERROR: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/9805e9fa-1f8b-4cf8-8f78-8d2602228635
58+
Data packet stream tokens: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/f79bb5b8-5919-439a-a696-48064b78b091
59+
"""
60+
remaining = len(data) - offset
61+
if remaining < 3 or data[offset] not in (TDS_ERROR_TOKEN, TDS_INFO_TOKEN):
62+
return None
63+
64+
# Length (USHORT LE) after TokenType, see MS-TDS INFO/ERROR links in docstring
65+
payload_len = int.from_bytes(data[offset + 1 : offset + 3], "little")
66+
67+
if payload_len < 8 or remaining < 3 + payload_len:
68+
return None
69+
try:
70+
token = TDS_INFO_ERROR(data[offset:])
71+
text = token["MsgText"].decode("utf-16le").strip()
72+
except Exception:
73+
return None
74+
return text or None
75+
76+
77+
def login7_integrated_auth_error_message(packet_data, data_after_login_header):
78+
"""Scan raw LOGIN7 response buffers for the first ERROR/INFO message.
79+
80+
When a server does not support Integrated Windows Authentication it replies
81+
to the LOGIN7 NTLMSSP negotiate with a TDS error token instead of an
82+
NTLMSSP challenge. This helper locates the first ERROR (0xAA) or INFO
83+
(0xAB) token in either the full packet or the payload after the 3-byte
84+
LOGIN7 response header and returns its MsgText.
85+
"""
86+
token_markers = (TDS_ERROR_TOKEN, TDS_INFO_TOKEN)
87+
for buf in filter(None, (packet_data, data_after_login_header)):
88+
for offset in (i for i in range(len(buf)) if buf[i] in token_markers):
89+
msg = decode_tds_info_error_msgtext(buf, offset)
90+
if msg:
91+
return msg
92+
return None

nxc/helpers/ntlm_parser.py

Lines changed: 0 additions & 34 deletions
This file was deleted.

nxc/protocols/ldap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
4848
from nxc.protocols.ldap.kerberos import KerberosAttacks
4949
from nxc.parsers.ldap_results import parse_result_attributes
50-
from nxc.helpers.ntlm_parser import parse_challenge
50+
from nxc.helpers.negotiate_parser import parse_challenge
5151
from nxc.paths import CONFIG_PATH
5252

5353
ldap_error_status = {

nxc/protocols/mssql.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from nxc.helpers.misc import gen_random_string
1010
from nxc.logger import NXCAdapter
1111
from nxc.helpers.bloodhound import add_user_bh
12-
from nxc.helpers.ntlm_parser import parse_challenge
12+
from nxc.helpers.negotiate_parser import parse_challenge, login7_integrated_auth_error_message
1313
from nxc.helpers.powershell import create_ps_command
1414
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
1515

@@ -43,7 +43,7 @@ def __init__(self, args, db, host):
4343
self.os_arch = None
4444
self.lmhash = ""
4545
self.nthash = ""
46-
self.is_mssql = False
46+
self.no_ntlm = False
4747

4848
connection.__init__(self, args, db, host)
4949

@@ -67,7 +67,6 @@ def create_conn_obj(self):
6767
self.conn.disconnect()
6868
return False
6969
else:
70-
self.is_mssql = True
7170
return True
7271

7372
def reconnect_mssql(func):
@@ -128,18 +127,27 @@ def enum_host_info(self):
128127
self.conn.tlsSocket = None
129128

130129
tdsx = self.conn.recvTDS()
131-
challenge = tdsx["Data"][3:]
132-
self.logger.debug(f"NTLM challenge: {challenge!s}")
130+
login_response = tdsx["Data"]
131+
# Impacket historically slices 3 bytes before treating payload as NTLMSSP (LOGIN7 response).
132+
challenge = login_response[3:]
133+
self.logger.debug(f"LOGIN7 response SSPI slice: {challenge!s}")
133134
except Exception as e:
134135
self.logger.info(f"Failed to receive NTLM challenge, reason: {e!s}")
135136
return False
136137
else:
137-
ntlm_info = parse_challenge(challenge)
138-
self.targetDomain = self.domain = ntlm_info["domain"]
139-
self.hostname = ntlm_info["hostname"]
140-
self.server_os = ntlm_info["os_version"]
141-
self.logger.extra["hostname"] = self.hostname
142-
self.db.add_host(self.host, self.hostname, self.targetDomain, self.server_os, len(self.mssql_instances),)
138+
if challenge.startswith(b"NTLMSSP\x00"):
139+
ntlm_info = parse_challenge(challenge)
140+
self.targetDomain = self.domain = ntlm_info["domain"]
141+
self.hostname = ntlm_info["hostname"]
142+
self.server_os = ntlm_info["os_version"]
143+
self.logger.extra["hostname"] = self.hostname
144+
else:
145+
error_msg = login7_integrated_auth_error_message(login_response, challenge)
146+
detail = f": {error_msg}" if error_msg else ""
147+
self.logger.debug(f"Server does not support NTLM{detail}")
148+
self.no_ntlm = True
149+
150+
self.db.add_host(self.host, self.hostname, self.domain, self.server_os, len(self.mssql_instances))
143151

144152
if self.args.domain:
145153
self.domain = self.args.domain
@@ -155,7 +163,8 @@ def enum_host_info(self):
155163

156164
def print_host_info(self):
157165
encryption = colored(f"EncryptionReq:{self.encryption}", host_info_colors[0 if self.encryption else 1], attrs=["bold"])
158-
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.targetDomain}) ({encryption})")
166+
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
167+
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.targetDomain}) ({encryption}) {ntlm}")
159168

160169
@reconnect_mssql
161170
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):

nxc/protocols/winrm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from nxc.helpers.bloodhound import add_user_bh
2323
from nxc.helpers.logger import highlight
2424
from nxc.helpers.misc import gen_random_string
25-
from nxc.helpers.ntlm_parser import parse_challenge
25+
from nxc.helpers.negotiate_parser import parse_challenge
2626
from nxc.logger import NXCAdapter
2727
from nxc.paths import TMP_PATH
2828

nxc/protocols/wmi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
from io import StringIO
33

4-
from nxc.helpers.ntlm_parser import parse_challenge
4+
from nxc.helpers.negotiate_parser import parse_challenge
55
from nxc.config import process_secret
66
from nxc.connection import connection, dcom_FirewallChecker, requires_admin
77
from nxc.logger import NXCAdapter

0 commit comments

Comments
 (0)