From c517a6e9b6f45868b2d815a2e9512d70599b9c9b Mon Sep 17 00:00:00 2001 From: Alexandre L Date: Thu, 22 Aug 2019 16:35:46 +0200 Subject: [PATCH 1/3] Rewrite auth mechanism * Try auth_password first (it should be the most common for network equipments and some of them don't like auth_none as first method, requiring reconnection * Use correct display names * Remove redundant code (error handling) * Allow some partial auth support (might not work) * Banner is retrieved and tested at each auth attempt, because some equipments send it only once even is the method failed * Some Python3 compatibility "hack" (get_banner), there's a lot to change to be fully Python3 compatible I think (unicode_literals from __future__ everywhere, as str in Py2 is basically bytes) --- Exscript/protocols/ssh2.py | 112 ++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/Exscript/protocols/ssh2.py b/Exscript/protocols/ssh2.py index 7697e650..330e5083 100644 --- a/Exscript/protocols/ssh2.py +++ b/Exscript/protocols/ssh2.py @@ -54,9 +54,15 @@ for key in keymap: PrivateKey.keytypes.add(key) -auth_types = {'publickey': ('_paramiko_auth_agent', '_paramiko_auth_autokey'), - 'keyboard-interactive': ('_paramiko_auth_interactive',), - 'password': ('_paramiko_auth_password',)} +auth_types = [ + # This one automatically falls back to keyb-interecative with internal handler + {'ssh_method': 'password', 'function': '_paramiko_auth_password'}, + {'ssh_method': 'none', 'function': '_paramiko_auth_none'}, + {'ssh_method': 'publickey', 'function': '_paramiko_auth_agent', 'display_name': 'publickey (agent)'}, + {'ssh_method': 'publickey', 'function': '_paramiko_auth_autokey', 'display_name': 'publickey (autokey)'}, + # https://superuser.com/questions/894608/ssh-o-preferredauthentications-whats-the-difference-between-password-and-k + {'ssh_method': 'keyboard-interactive', 'function': '_paramiko_auth_interactive'} +] class SSH2(Protocol): @@ -189,7 +195,7 @@ def _paramiko_connect(self): return t def _paramiko_auth_none(self, username, password=None): - self.client.auth_none(username) + return self.client.auth_none(username) def _paramiko_auth_interactive(self, username, password=None): if password is None: @@ -213,10 +219,10 @@ def handler(title, instructions, prompt_list): response.append(password) break return response - self.client.auth_interactive(username, handler) + return self.client.auth_interactive(username, handler) def _paramiko_auth_password(self, username, password): - self.client.auth_password(username, password or '') + return self.client.auth_password(username, password or '') def _paramiko_auth_agent(self, username, password=None): keys = paramiko.Agent().get_keys() @@ -230,8 +236,7 @@ def _paramiko_auth_agent(self, username, password=None): try: fp = hexlify(key.get_fingerprint()) self._dbg(1, 'Trying SSH agent key %s' % fp) - self.client.auth_publickey(username, key) - return + return self.client.auth_publickey(username, key) except SSHException as e: saved_exception = e raise saved_exception @@ -248,8 +253,7 @@ def _paramiko_auth_key(self, username, keys, password): key = pkey_class.from_private_key_file(filename, password) fp = hexlify(key.get_fingerprint()) self._dbg(1, 'Trying key %s in %s' % (fp, filename)) - self.client.auth_publickey(username, key) - return + return self.client.auth_publickey(username, key) except SSHException as e: saved_exception = e except IOError as e: @@ -265,57 +269,56 @@ def _paramiko_auth_autokey(self, username, password): file = os.path.expanduser(file) if os.path.isfile(file): keyfiles.append((cls, file)) - self._paramiko_auth_key(username, keyfiles, password) + return self._paramiko_auth_key(username, keyfiles, password) - def _get_auth_methods(self, allowed_types): - auth_methods = [] - for method in allowed_types: - if method not in auth_types: - self._dbg(1, 'Unsupported auth method %s' % repr(method)) - continue - for type_name in auth_types[method]: - auth_methods.append(getattr(self, type_name)) - return auth_methods + def _add_auth_error(self, error, level=1): + self._dbg(level, error) + self.auth_errors.append(error) - def _paramiko_auth(self, username, password): - # Try authentication using auth_none. This should (almost) always fail, - # but provides us with info about allowed authentication types. - try: - self.client.auth_none(username) - except BadAuthenticationType as err: - self._dbg(1, 'auth_none failed, supported: %s' % err.allowed_types) - auth_methods = self._get_auth_methods(err.allowed_types) + def _paramiko_auth(self, username, password, partial_methods=[]): + if partial_methods == []: + self.auth_errors = [] + allowed_methods = None else: - return - - # Finally try all supported login methods. - errors = [] - for method in auth_methods: + allowed_methods = partial_methods + for method in auth_types: + method_name = method.get('display_name', method['ssh_method']) + if allowed_methods is not None and method_name not in allowed_methods: + self._dbg(1, 'Skipping authentification method %s (unsupported)' % method_name) + continue # Some OSes (e.g. JunOS ERX OS, Huawei) do not accept further login # attempts after failing one. So in this hack, we # re-connect after each attempt... - if self.get_driver().reconnect_between_auth_methods or not self.client.active: + # But do not reconnect in case of partial auth + if self.get_driver().reconnect_between_auth_methods or (partial_methods == [] and not self.client.active): self.close(force=True) self.client = self._paramiko_connect() - self._dbg(1, 'Authenticating with %s' % method.__name__) try: - method(username, password) - return - except BadHostKeyException as e: - msg = '%s: Bad host key: %s' % (method.__name__, str(e)) - self._dbg(1, msg) - errors.append(msg) - except AuthenticationException as e: - msg = 'Authentication with %s failed' % method.__name__ - msg += ': ' + str(e) - self._dbg(1, msg) - errors.append(msg) - except SSHException as e: - msg = '%s: SSHException: %s' % (method.__name__, str(e)) - self._dbg(1, msg) - errors.append(msg) - raise LoginFailure('Login failed: ' + '; '.join(errors)) + self._dbg(1, 'Authenticating with %s' % method_name) + next_methods = getattr(self, method['function'])(username, password) + if next_methods == []: + self.os_guesser.data_received(self.get_banner(), True) # Auth done, get banner ! + self._dbg(1, 'Authentication successful') + return + elif partial_methods == []: # Only attempt partial methods once (1 recursion) + self.os_guesser.data_received(self.get_banner(), False) # Try to get banner before trying next method + self._dbg(1, 'Partial authentication done, attempting next part') + self._paramiko_auth(username, password, next_methods) + except (BadAuthenticationType, AuthenticationException, BadHostKeyException, SSHException) as e: + if type(e) is BadAuthenticationType: # Should happen only once + self._add_auth_error('{} reported as bad method, supported: {}'.format(method['ssh_method'], e.allowed_types)) + elif type(e) is AuthenticationException: + self._add_auth_error('Authentication with {} failed: {}'.format(method_name, e)) + elif type(e) is BadHostKeyException: + self._add_auth_error('{}: Bad host key: {}'.format(method_name, e)) + elif type(e) is SSHException: + self._add_auth_error('{}: SSHException: {}'.format(method_name, e)) + if partial_methods == [] and hasattr(e, 'allowed_types'): + allowed_methods = e.allowed_types + finally: + self.os_guesser.data_received(self.get_banner(), False) # Log banner sent DURING auth + raise LoginFailure('Login failed: ' + '; '.join(self.auth_errors)) def _paramiko_shell(self): rows, cols = get_terminal_size() @@ -366,7 +369,12 @@ def _protocol_authenticate_by_key(self, user, key): def get_banner(self): if not self.client: return None - return self.client.get_banner() + banner = self.client.get_banner() + if banner is None: + banner = '' + elif not isinstance(banner, str): + banner = banner.decode('utf-8') + return banner def get_remote_version(self): if not self.client: From 6a5e5d2acbe730056390237564d5157ed375d5b1 Mon Sep 17 00:00:00 2001 From: Alexandre L Date: Thu, 22 Aug 2019 22:20:22 +0200 Subject: [PATCH 2/3] Put banner changes in `guess_os_from_banner()` --- Exscript/protocols/ssh2.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/Exscript/protocols/ssh2.py b/Exscript/protocols/ssh2.py index 330e5083..21f9f3dc 100644 --- a/Exscript/protocols/ssh2.py +++ b/Exscript/protocols/ssh2.py @@ -298,16 +298,19 @@ def _paramiko_auth(self, username, password, partial_methods=[]): self._dbg(1, 'Authenticating with %s' % method_name) next_methods = getattr(self, method['function'])(username, password) if next_methods == []: - self.os_guesser.data_received(self.get_banner(), True) # Auth done, get banner ! + self.guess_os_from_banner(authenticated=True) # Auth done, get banner ! self._dbg(1, 'Authentication successful') return elif partial_methods == []: # Only attempt partial methods once (1 recursion) - self.os_guesser.data_received(self.get_banner(), False) # Try to get banner before trying next method + self.guess_os_from_banner() # Try to get banner before trying next method self._dbg(1, 'Partial authentication done, attempting next part') self._paramiko_auth(username, password, next_methods) except (BadAuthenticationType, AuthenticationException, BadHostKeyException, SSHException) as e: if type(e) is BadAuthenticationType: # Should happen only once - self._add_auth_error('{} reported as bad method, supported: {}'.format(method['ssh_method'], e.allowed_types)) + self._add_auth_error('{} reported as bad method, supported: {}'.format( + method['ssh_method'], + e.allowed_types + )) elif type(e) is AuthenticationException: self._add_auth_error('Authentication with {} failed: {}'.format(method_name, e)) elif type(e) is BadHostKeyException: @@ -316,8 +319,7 @@ def _paramiko_auth(self, username, password, partial_methods=[]): self._add_auth_error('{}: SSHException: {}'.format(method_name, e)) if partial_methods == [] and hasattr(e, 'allowed_types'): allowed_methods = e.allowed_types - finally: - self.os_guesser.data_received(self.get_banner(), False) # Log banner sent DURING auth + self.guess_os_from_banner() # Log banner sent DURING auth raise LoginFailure('Login failed: ' + '; '.join(self.auth_errors)) def _paramiko_shell(self): @@ -369,12 +371,14 @@ def _protocol_authenticate_by_key(self, user, key): def get_banner(self): if not self.client: return None - banner = self.client.get_banner() - if banner is None: - banner = '' - elif not isinstance(banner, str): - banner = banner.decode('utf-8') - return banner + return self.client.get_banner() + + def guess_os_from_banner(self, authenticated=False): + banner = self.get_banner() + if banner is not None: + if not isinstance(banner, str): + banner = banner.decode('utf-8') + self.os_guesser.data_received(banner, authenticated) def get_remote_version(self): if not self.client: From ef2ae013b182b4b74a1aefca4ef449fff2abec04 Mon Sep 17 00:00:00 2001 From: Alexandre L Date: Fri, 23 Aug 2019 14:51:59 +0200 Subject: [PATCH 3/3] Use protocol encoding for banner --- Exscript/protocols/ssh2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Exscript/protocols/ssh2.py b/Exscript/protocols/ssh2.py index 21f9f3dc..da926a7a 100644 --- a/Exscript/protocols/ssh2.py +++ b/Exscript/protocols/ssh2.py @@ -377,7 +377,7 @@ def guess_os_from_banner(self, authenticated=False): banner = self.get_banner() if banner is not None: if not isinstance(banner, str): - banner = banner.decode('utf-8') + banner = banner.decode(self.encoding) self.os_guesser.data_received(banner, authenticated) def get_remote_version(self):