From 4239f27f117b803fa99fa07f18308f70b8923a47 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Tue, 20 Aug 2019 05:46:40 +1000 Subject: [PATCH] Add Windows SSPI support and use newer NTLM class (#10) * Use simpler token generator for NTLM auth * Fix up auth after recent changes * Added Windows SSPI support * Satisfy pep8 --- CHANGELOG.md | 2 + README.md | 20 +++-- appveyor.yml | 8 ++ setup.py | 6 +- smbprotocol/session.py | 186 +++++++++++++++++++++++++++++++---------- tests/test_session.py | 3 - 6 files changed, 166 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc549d1..ad0695b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Fix issue where timeout was not being applied to the new connection * Fix various deprecated regex escape patterns +* Added support for Windows Kerberos and implicit credential support through the optional extra library [pywin32](https://github.com/mhammond/pywin32) +* Simplified the fallback NTLM context object ## 0.1.1 - 2018-09-14 diff --git a/README.md b/README.md index 7d8e21a5..fc7de7f4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ backlog for features that would be nice to have in this library. ## Requirements * Python 2.6, 2.7, 3.4+ -* For Kerberos auth [python-gssapi](https://github.com/pythongssapi/python-gssapi) +* For Kerberos auth + * [python-gssapi](https://github.com/pythongssapi/python-gssapi) on Linux + * [pywin32](https://github.com/mhammond/pywin32) on Windows To use Kerberos authentication, further dependencies are required, to install these dependencies run @@ -46,12 +48,13 @@ sudo yum install gcc python-devel krb5-devel krb5-workstation python-devel pip install smbprotocol[kerberos] ``` -Currently Kerberos authentication is not supported on Windows. As part of this -optional extra, the python-gssapi library is installed and smbprotocol requires -a particular GSSAPI extension to be available to work. This extension should -be installed on the majority of MIT or Heimdall Kerberos installs but it isn't -guaranteed. To verify that Kerberos is available you can run the following -check in a Python console +Kerberos auth with Windows just requires the `pywin32` package to be installed +and the Windows host to be joined to that domain. On Linux the python-gssapi +library must be installed and smbprotocol requires a particular GSSAPI +extension to be available to work. This extension should be installed on the +majority of MIT or Heimdal Kerberos installs but it isn't guaranteed. To +verify that Kerberos is available on Linux you can run the following check in +a Python console: ``` try: @@ -73,7 +76,7 @@ To install smbprotocol, simply run ``` pip install smbprotocol -# on a non Windows host, to install with Kerberos support +# To install with Kerberos support pip install smbprotocol[kerberos] ``` @@ -204,7 +207,6 @@ docker run -d -p $SMB_PORT:445 -v $(pwd)/build-scripts:/app -w /app -e SMB_USER= Here is a list of features that I would like to incorporate, PRs are welcome if you want to implement them yourself; -* SSPI integration for Windows and Kerberos authentication * Test and support DFS mounts and not just server shares * Multiple channel support to speed up large data transfers * Create an easier API on top of the `raw` SMB calls that currently exist diff --git a/appveyor.yml b/appveyor.yml index 5e4e48b8..03f1b11f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -45,6 +45,14 @@ install: - cmd: pip install -r requirements-test.txt - cmd: pip install . +# test out pywin32 on some matrixes +- ps: | + $ErrorActionPreference = "SilentlyContinue" + if ($env:PYTHON -in @("Python27", "Python27-x64", "Python36", "Python36-x64")) { + pip install .[kerberos] + } + $ErrorActionPreference = "Stop" + build: off # Do not run MSBuild, build stuff at install step test_script: diff --git a/setup.py b/setup.py index f277e5c4..ff54a233 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ packages=['smbprotocol'], install_requires=[ 'cryptography>=2.0', - 'ntlm-auth', + 'ntlm-auth>=1.2.0', 'pyasn1', 'six', ], @@ -26,7 +26,9 @@ ':python_version<"2.7"': [ 'ordereddict' ], - 'kerberos:sys_platform=="win32"': [], + 'kerberos:sys_platform=="win32"': [ + 'pywin32' + ], 'kerberos:sys_platform!="win32"': [ 'gssapi>=1.4.1' ] diff --git a/smbprotocol/session.py b/smbprotocol/session.py index d928aef5..8354ea65 100644 --- a/smbprotocol/session.py +++ b/smbprotocol/session.py @@ -1,11 +1,10 @@ -import base64 import logging from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.kbkdf import CounterLocation, \ KBKDFHMAC, Mode -from ntlm_auth.ntlm import Ntlm +from ntlm_auth.ntlm import NtlmContext as Ntlm from pyasn1.codec.der import decoder from smbprotocol.connection import Capabilities, Commands, Dialects, \ @@ -17,7 +16,15 @@ Structure from smbprotocol.structure import _bytes_to_hex -HAVE_SSPI = False # TODO: add support for Windows and SSPI +HAVE_SSPI = False +try: # pragma: no cover + import sspi + import sspicon + import win32security + HAVE_SSPI = True +except ImportError: # pragma: no cover + pass + HAVE_GSSAPI = False try: # pragma: no cover import gssapi @@ -154,17 +161,21 @@ def __init__(self, connection, username=None, password=None, 3.2.1.3 Per Session The Session object that is used to store the details for an - authenticated SMB session. There are 3 forms of authentication that are + authenticated SMB session. There are 4 forms of authentication that are supported; - 1. NTLM Auth, requires the username and password - 2. Kerberos Auth, only available in certain circumstances - 3. Guest Auth, the credentials were rejected but the server allows a + 1. SSPI Auth, Windows only if pywin32 is installed. Uses either + Kerberos or NTLM auth depending on the environment setup and can + use the current user's credentials if none are provided here. + 2. NTLM Auth, requires the username and password + 3. Kerberos Auth, only available in certain circumstances + 4. Guest Auth, the credentials were rejected but the server allows a fallback to guest authentication (insecure and non-default) NTLM Auth is the fallback as it should be available in most scenarios while Kerberos only works on a system where python-gssapi is installed - and the GGF extension for inquire_sec_context_by_oid is available. + and the GGF extension for inquire_sec_context_by_oid is available + (Linux), or pywin32 is installed (Windows). If using Kerberos Auth, the username and password can be omitted which means the default user kerb ticket (if available) is used. If the @@ -379,8 +390,9 @@ def _authenticate_session(self, mech): server=self.connection.server_name) elif mech in [MechTypes.KRB5, MechTypes.MS_KRB5, MechTypes.NTLMSSP] \ and HAVE_SSPI: - raise NotImplementedError("SSPI on Windows for authentication is " - "not yet implemented") + context = SSPIContext(username=self.username, + password=self.password, + server=self.connection.server_name) elif mech == MechTypes.NTLMSSP: context = NtlmContext(username=self.username, password=self.password) @@ -388,7 +400,10 @@ def _authenticate_session(self, mech): raise NotImplementedError("Mech Type %s is not yet supported" % mech) - for out_token in context.step(): + response = None + token_gen = context.step() + out_token = next(token_gen) + while not context.complete or out_token is not None: session_setup = SMB2SessionSetupRequest() session_setup['security_mode'] = \ self.connection.client_security_mode @@ -414,7 +429,11 @@ def _authenticate_session(self, mech): session_resp = SMB2SessionSetupResponse() session_resp.unpack(response['data'].get_value()) - context.in_token = session_resp['buffer'].get_value() + in_token = session_resp['buffer'].get_value() + if not in_token: + break + + out_token = token_gen.send(in_token) status = response['status'].get_value() if status == NtStatus.STATUS_MORE_PROCESSING_REQUIRED: log.info("More processing is required for SMB2_SESSION_SETUP") @@ -452,6 +471,14 @@ def _smb3kdf(self, ki, label, context): return kdf.derive(ki) +def _split_username_and_domain(username): + try: + domain, username = username.split("\\", 1) + return domain, username + except ValueError: + return "", username + + class NtlmContext(object): def __init__(self, username, password): @@ -462,39 +489,33 @@ def __init__(self, username, password): raise SMBAuthenticationError("The password must be set when using " "NTLM authentication") - # try and get the domain part from the username log.info("Setting up NTLM Security Context for user %s" % username) - try: - self.domain, self.username = username.split("\\", 1) - except ValueError: - self.username = username - self.domain = '' - self.password = password - self.context = Ntlm() - self.in_token = None + self.domain, self.username = _split_username_and_domain(username) + self.context = Ntlm(self.username, password, domain=self.domain) + + @property + def complete(self): + return self.context.complete def step(self): log.info("NTLM: Generating Negotiate message") - msg1 = self.context.create_negotiate_message(self.domain) - msg1 = base64.b64decode(msg1) + msg1 = self.context.step() log.debug("NTLM: Negotiate message: %s" % _bytes_to_hex(msg1)) - yield msg1 - - log.info("NTLM: Parsing Challenge message") - msg2 = base64.b64encode(self.in_token) - log.debug("NTLM: Challenge message: %s" % _bytes_to_hex(self.in_token)) - self.context.parse_challenge_message(msg2) - - log.info("NTLM: Generating Authenticate message") - msg3 = self.context.create_authenticate_message( - user_name=self.username, - password=self.password, - domain_name=self.domain - ) - yield base64.b64decode(msg3) + msg2 = yield msg1 + + log.info("NTLM: Parsing Challenge message and generating Authentication message") + log.debug("NTLM: Challenge message: %s" % _bytes_to_hex(msg2)) + msg3 = self.context.step(input_token=msg2) + + yield msg3 def get_session_key(self): - return self.context.authenticate_message.exported_session_key + # The session_key was only recently added in ntlm-auth, we have the + # fallback to the non-public interface for older versions where we + # know this still works. This should be removed once ntlm-auth no + # longer requires these older versions (>=1.4.0). + return getattr(self.context, 'session_key', + self.context._session_security.exported_session_key) class GSSAPIContext(object): @@ -510,16 +531,17 @@ def __init__(self, username, password, server): self.context = gssapi.SecurityContext(name=server_name, creds=self.creds, usage='initiate') - self.in_token = None + + @property + def complete(self): + return self.context.complete def step(self): + in_token = None while not self.context.complete: log.info("GSSAPI: gss_init_sec_context called") - out_token = self.context.step(self.in_token) - if out_token: - yield out_token - else: - log.info("GSSAPI: gss_init_sec_context complete") + out_token = self.context.step(in_token) + in_token = yield out_token def get_session_key(self): # GSS_C_INQ_SSPI_SESSION_KEY @@ -530,7 +552,7 @@ def get_session_key(self): return context_data[0] def _acquire_creds(self, username, password): - # 3 use cases with Kerberos AUth + # 3 use cases with Kerberos Auth # 1. Both the user and pass is supplied so we want to create a new # ticket with the pass # 2. Only the user is supplied so we will attempt to get the cred @@ -585,3 +607,77 @@ def _acquire_creds(self, username, password): log.info("GSSAPI: Acquired credentials for user %s" % str(user)) return creds + + +class SSPIContext(object): + + def __init__(self, username, password, server): + log.info("Setting up SSPI Security Context for Windows auth") + self._call_counter = 0 + + flags = sspicon.ISC_REQ_INTEGRITY | \ + sspicon.ISC_REQ_CONFIDENTIALITY | \ + sspicon.ISC_REQ_REPLAY_DETECT | \ + sspicon.ISC_REQ_SEQUENCE_DETECT | \ + sspicon.ISC_REQ_MUTUAL_AUTH + + domain, username = _split_username_and_domain(username) + # We could use the MECH to derive the package name but we are just + # better off using Negotiate and lettings Windows do all the heavy + # lifting. + self._context = sspi.ClientAuth( + pkg_name='Negotiate', + auth_info=(username, domain, password), + targetspn="cifs/%s" % server, + scflags=flags + ) + + @property + def complete(self): + return self._context.authenticated + + def step(self): + in_token = None + while not self.complete: + log.info("SSPI: InitializeSecurityContext called") + out_token = self._step(in_token) + in_token = yield out_token if out_token != b"" else None + + def get_session_key(self): + return self._context.ctxt.QueryContextAttributes(sspicon.SECPKG_ATTR_SESSION_KEY) + + def _step(self, token): + success_codes = [ + sspicon.SEC_E_OK, + sspicon.SEC_I_COMPLETE_AND_CONTINUE, + sspicon.SEC_I_COMPLETE_NEEDED, + sspicon.SEC_I_CONTINUE_NEEDED + ] + + if token: + sec_token = win32security.PySecBufferType( + self._context.pkg_info['MaxToken'], + sspicon.SECBUFFER_TOKEN + ) + sec_token.Buffer = token + + sec_buffer = win32security.PySecBufferDescType() + sec_buffer.append(sec_token) + else: + sec_buffer = None + + rc, out_buffer = self._context.authorize(sec_buffer_in=sec_buffer) + self._call_counter += 1 + if rc not in success_codes: + rc_name = "Unknown Error" + for name, value in vars(sspicon).items(): + if isinstance(value, int) and name.startswith("SEC_") and \ + value == rc: + rc_name = name + break + raise SMBAuthenticationError( + "InitializeSecurityContext failed on call %d: (%d) %s 0x%s" + % (self._call_counter, rc, rc_name, format(rc, 'x')) + ) + + return out_buffer[0].Buffer diff --git a/tests/test_session.py b/tests/test_session.py index 9f612fd4..9ad7171f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -122,19 +122,16 @@ def test_username_without_domain(self): actual = NtlmContext("username", "password") assert actual.domain == "" assert actual.username == "username" - assert actual.password == "password" def test_username_in_netlogon_form(self): actual = NtlmContext("DOMAIN\\username", "password") assert actual.domain == "DOMAIN" assert actual.username == "username" - assert actual.password == "password" def test_username_in_upn_form(self): actual = NtlmContext("username@DOMAIN.LOCAL", "password") assert actual.domain == "" assert actual.username == "username@DOMAIN.LOCAL" - assert actual.password == "password" class TestSession(object):