├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── ssh-audit.py └── test ├── conftest.py ├── coverage.sh ├── mypy-py2.sh ├── mypy-py3.sh ├── mypy.ini ├── prospector.sh ├── prospector.yml ├── test_auditconf.py ├── test_banner.py ├── test_buffer.py ├── test_errors.py ├── test_output.py ├── test_software.py ├── test_ssh1.py ├── test_ssh2.py └── test_version_compare.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | html/ 4 | venv/ 5 | .cache/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.6 4 | - 2.7 5 | - 3.3 6 | - 3.4 7 | - 3.5 8 | - pypy 9 | - pypy3 10 | install: 11 | - pip install --upgrade pytest 12 | - pip install --upgrade pytest-cov 13 | - pip install --upgrade coveralls 14 | script: 15 | - py.test --cov-report= --cov=ssh-audit -v test 16 | after_success: 17 | - coveralls 18 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-audit 2 | [![build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg)](https://travis-ci.org/arthepsy/ssh-audit) 3 | [![coverage status](https://coveralls.io/repos/github/arthepsy/ssh-audit/badge.svg)](https://coveralls.io/github/arthepsy/ssh-audit) 4 | **ssh-audit** is a tool for ssh server auditing. 5 | 6 | ## Features 7 | - SSH1 and SSH2 protocol server support; 8 | - grab banner, recognize device or software and operating system, detect compression; 9 | - gather key-exchange, host-key, encryption and message authentication code algorithms; 10 | - output algorithm information (available since, removed/disabled, unsafe/weak/legacy, etc); 11 | - output algorithm recommendations (append or remove based on recognized software version); 12 | - output security information (related issues, assigned CVE list, etc); 13 | - analyze SSH version compatibility based on algorithm information; 14 | - historical information from OpenSSH, Dropbear SSH and libssh; 15 | - no dependencies, compatible with Python 2.6+, Python 3.x and PyPy; 16 | 17 | ## Usage 18 | ``` 19 | usage: ssh-audit.py [-1246pbnvl] 20 | 21 | -1, --ssh1 force ssh version 1 only 22 | -2, --ssh2 force ssh version 2 only 23 | -4, --ipv4 enable IPv4 (order of precedence) 24 | -6, --ipv6 enable IPv6 (order of precedence) 25 | -p, --port= port to connect 26 | -b, --batch batch output 27 | -n, --no-colors disable colors 28 | -v, --verbose verbose output 29 | -l, --level= minimum output level (info|warn|fail) 30 | 31 | ``` 32 | * if both IPv4 and IPv6 are used, order of precedence can be set by using either `-46` or `-64`. 33 | * batch flag `-b` will output sections without header and without empty lines (implies verbose flag). 34 | * verbose flag `-v` will prefix each line with section type and algorithm name. 35 | 36 | ### example 37 | ![screenshot](https://cloud.githubusercontent.com/assets/7356025/19233757/3e09b168-8ef0-11e6-91b4-e880bacd0b8a.png) 38 | 39 | ## ChangeLog 40 | ### v1.7.0 (2016-10-26) 41 | - implement options to allow specify IPv4/IPv6 usage and order of precedence 42 | - implement option to specify remote port (old behavior kept for compatibility) 43 | - add colors support for Microsoft Windows via optional colorama dependency 44 | - fix encoding and decoding issues, add tests, do not crash on encoding errors 45 | - use mypy-lang for static type checking and verify all code 46 | 47 | ### v1.6.0 (2016-10-14) 48 | - implement algorithm recommendations section (based on recognized software) 49 | - implement full libssh support (version history, algorithms, security, etc) 50 | - fix SSH-1.99 banner recognition and version comparison functionality 51 | - do not output empty algorithms (happens for misconfigured servers) 52 | - make consistent output for Python 3.x versions 53 | - add a lot more tests (conf, banner, software, SSH1/SSH2, output, etc) 54 | - use Travis CI to test for multiple Python versions (2.6-3.5, pypy, pypy3) 55 | 56 | ### v1.5.0 (2016-09-20) 57 | - create security section for related security information 58 | - match and output assigned CVE list and security issues for Dropbear SSH 59 | - implement full SSH1 support with fingerprint information 60 | - automatically fallback to SSH1 on protocol mismatch 61 | - add new options to force SSH1 or SSH2 (both allowed by default) 62 | - parse banner information and convert it to specific software and OS version 63 | - do not use padding in batch mode 64 | - several fixes (Cisco sshd, rare hangs, error handling, etc) 65 | 66 | ### v1.0.20160902 67 | - implement batch output option 68 | - implement minimum output level option 69 | - fix compatibility with Python 2.6 70 | 71 | ### v1.0.20160812 72 | - implement SSH version compatibility feature 73 | - fix wrong mac algorithm warning 74 | - fix Dropbear SSH version typo 75 | - parse pre-banner header 76 | - better errors handling 77 | 78 | ### v1.0.20160803 79 | - use OpenSSH 7.3 banner 80 | - add new key-exchange algorithms 81 | 82 | ### v1.0.20160207 83 | - use OpenSSH 7.2 banner 84 | - additional warnings for OpenSSH 7.2 85 | - fix OpenSSH 7.0 failure messages 86 | - add rijndael-cbc failure message from OpenSSH 6.7 87 | 88 | ### v1.0.20160105 89 | - multiple additional warnings 90 | - support for none algorithm 91 | - better compression handling 92 | - ensure reading enough data (fixes few Linux SSH) 93 | 94 | ### v1.0.20151230 95 | - Dropbear SSH support 96 | 97 | ### v1.0.20151223 98 | - initial version 99 | -------------------------------------------------------------------------------- /ssh-audit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | The MIT License (MIT) 5 | 6 | Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu) 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | """ 26 | from __future__ import print_function 27 | import os, io, sys, socket, struct, random, errno, getopt, re, hashlib, base64 28 | 29 | VERSION = 'v1.7.0' 30 | 31 | if sys.version_info >= (3,): # pragma: nocover 32 | StringIO, BytesIO = io.StringIO, io.BytesIO 33 | text_type = str 34 | binary_type = bytes 35 | else: # pragma: nocover 36 | import StringIO as _StringIO # pylint: disable=import-error 37 | StringIO = BytesIO = _StringIO.StringIO 38 | text_type = unicode # pylint: disable=undefined-variable 39 | binary_type = str 40 | try: # pragma: nocover 41 | # pylint: disable=unused-import 42 | from typing import List, Set, Sequence, Tuple, Iterable 43 | from typing import Callable, Optional, Union, Any 44 | except ImportError: # pragma: nocover 45 | pass 46 | try: # pragma: nocover 47 | from colorama import init as colorama_init 48 | colorama_init() # pragma: nocover 49 | except ImportError: # pragma: nocover 50 | pass 51 | 52 | 53 | def usage(err=None): 54 | # type: (Optional[str]) -> None 55 | uout = Output() 56 | p = os.path.basename(sys.argv[0]) 57 | uout.head('# {0} {1}, moo@arthepsy.eu\n'.format(p, VERSION)) 58 | if err is not None: 59 | uout.fail('\n' + err) 60 | uout.info('usage: {0} [-1246pbnvl] \n'.format(p)) 61 | uout.info(' -h, --help print this help') 62 | uout.info(' -1, --ssh1 force ssh version 1 only') 63 | uout.info(' -2, --ssh2 force ssh version 2 only') 64 | uout.info(' -4, --ipv4 enable IPv4 (order of precedence)') 65 | uout.info(' -6, --ipv6 enable IPv6 (order of precedence)') 66 | uout.info(' -p, --port= port to connect') 67 | uout.info(' -b, --batch batch output') 68 | uout.info(' -n, --no-colors disable colors') 69 | uout.info(' -v, --verbose verbose output') 70 | uout.info(' -l, --level= minimum output level (info|warn|fail)') 71 | uout.sep() 72 | sys.exit(1) 73 | 74 | 75 | class AuditConf(object): 76 | # pylint: disable=too-many-instance-attributes 77 | def __init__(self, host=None, port=22): 78 | # type: (Optional[str], int) -> None 79 | self.host = host 80 | self.port = port 81 | self.ssh1 = True 82 | self.ssh2 = True 83 | self.batch = False 84 | self.colors = True 85 | self.verbose = False 86 | self.minlevel = 'info' 87 | self.ipvo = () # type: Sequence[int] 88 | self.ipv4 = False 89 | self.ipv6 = False 90 | 91 | def __setattr__(self, name, value): 92 | # type: (str, Union[str, int, bool, Sequence[int]]) -> None 93 | valid = False 94 | if name in ['ssh1', 'ssh2', 'batch', 'colors', 'verbose']: 95 | valid, value = True, True if value else False 96 | elif name in ['ipv4', 'ipv6']: 97 | valid = False 98 | value = True if value else False 99 | ipv = 4 if name == 'ipv4' else 6 100 | if value: 101 | value = tuple(list(self.ipvo) + [ipv]) 102 | else: 103 | if len(self.ipvo) == 0: 104 | value = (6,) if ipv == 4 else (4,) 105 | else: 106 | value = tuple(filter(lambda x: x != ipv, self.ipvo)) 107 | self.__setattr__('ipvo', value) 108 | elif name == 'ipvo': 109 | if isinstance(value, (tuple, list)): 110 | uniq_value = utils.unique_seq(value) 111 | value = tuple(filter(lambda x: x in (4, 6), uniq_value)) 112 | valid = True 113 | ipv_both = len(value) == 0 114 | object.__setattr__(self, 'ipv4', ipv_both or 4 in value) 115 | object.__setattr__(self, 'ipv6', ipv_both or 6 in value) 116 | elif name == 'port': 117 | valid, port = True, utils.parse_int(value) 118 | if port < 1 or port > 65535: 119 | raise ValueError('invalid port: {0}'.format(value)) 120 | value = port 121 | elif name in ['minlevel']: 122 | if value not in ('info', 'warn', 'fail'): 123 | raise ValueError('invalid level: {0}'.format(value)) 124 | valid = True 125 | elif name == 'host': 126 | valid = True 127 | if valid: 128 | object.__setattr__(self, name, value) 129 | 130 | @classmethod 131 | def from_cmdline(cls, args, usage_cb): 132 | # type: (List[str], Callable[..., None]) -> AuditConf 133 | # pylint: disable=too-many-branches 134 | aconf = cls() 135 | try: 136 | sopts = 'h1246p:bnvl:' 137 | lopts = ['help', 'ssh1', 'ssh2', 'ipv4', 'ipv6', 'port', 138 | 'batch', 'no-colors', 'verbose', 'level='] 139 | opts, args = getopt.getopt(args, sopts, lopts) 140 | except getopt.GetoptError as err: 141 | usage_cb(str(err)) 142 | aconf.ssh1, aconf.ssh2 = False, False 143 | oport = None 144 | for o, a in opts: 145 | if o in ('-h', '--help'): 146 | usage_cb() 147 | elif o in ('-1', '--ssh1'): 148 | aconf.ssh1 = True 149 | elif o in ('-2', '--ssh2'): 150 | aconf.ssh2 = True 151 | elif o in ('-4', '--ipv4'): 152 | aconf.ipv4 = True 153 | elif o in ('-6', '--ipv6'): 154 | aconf.ipv6 = True 155 | elif o in ('-p', '--port'): 156 | oport = a 157 | elif o in ('-b', '--batch'): 158 | aconf.batch = True 159 | aconf.verbose = True 160 | elif o in ('-n', '--no-colors'): 161 | aconf.colors = False 162 | elif o in ('-v', '--verbose'): 163 | aconf.verbose = True 164 | elif o in ('-l', '--level'): 165 | if a not in ('info', 'warn', 'fail'): 166 | usage_cb('level {0} is not valid'.format(a)) 167 | aconf.minlevel = a 168 | if len(args) == 0: 169 | usage_cb() 170 | if oport is not None: 171 | host = args[0] 172 | port = utils.parse_int(oport) 173 | else: 174 | s = args[0].split(':') 175 | host = s[0].strip() 176 | if len(s) == 2: 177 | oport, port = s[1], utils.parse_int(s[1]) 178 | else: 179 | oport, port = '22', 22 180 | if not host: 181 | usage_cb('host is empty') 182 | if port <= 0 or port > 65535: 183 | usage_cb('port {0} is not valid'.format(oport)) 184 | aconf.host = host 185 | aconf.port = port 186 | if not (aconf.ssh1 or aconf.ssh2): 187 | aconf.ssh1, aconf.ssh2 = True, True 188 | return aconf 189 | 190 | 191 | class Output(object): 192 | LEVELS = ['info', 'warn', 'fail'] 193 | COLORS = {'head': 36, 'good': 32, 'warn': 33, 'fail': 31} 194 | 195 | def __init__(self): 196 | # type: () -> None 197 | self.batch = False 198 | self.colors = True 199 | self.verbose = False 200 | self.__minlevel = 0 201 | 202 | @property 203 | def minlevel(self): 204 | # type: () -> str 205 | if self.__minlevel < len(self.LEVELS): 206 | return self.LEVELS[self.__minlevel] 207 | return 'unknown' 208 | 209 | @minlevel.setter 210 | def minlevel(self, name): 211 | # type: (str) -> None 212 | self.__minlevel = self.getlevel(name) 213 | 214 | def getlevel(self, name): 215 | # type: (str) -> int 216 | cname = 'info' if name == 'good' else name 217 | if cname not in self.LEVELS: 218 | return sys.maxsize 219 | return self.LEVELS.index(cname) 220 | 221 | def sep(self): 222 | # type: () -> None 223 | if not self.batch: 224 | print() 225 | 226 | @property 227 | def colors_supported(self): 228 | # type: () -> bool 229 | return 'colorama' in sys.modules or os.name == 'posix' 230 | 231 | @staticmethod 232 | def _colorized(color): 233 | # type: (str) -> Callable[[text_type], None] 234 | return lambda x: print(u'{0}{1}\033[0m'.format(color, x)) 235 | 236 | def __getattr__(self, name): 237 | # type: (str) -> Callable[[text_type], None] 238 | if name == 'head' and self.batch: 239 | return lambda x: None 240 | if not self.getlevel(name) >= self.__minlevel: 241 | return lambda x: None 242 | if self.colors and self.colors_supported and name in self.COLORS: 243 | color = '\033[0;{0}m'.format(self.COLORS[name]) 244 | return self._colorized(color) 245 | else: 246 | return lambda x: print(u'{0}'.format(x)) 247 | 248 | 249 | class OutputBuffer(list): 250 | def __enter__(self): 251 | # type: () -> OutputBuffer 252 | # pylint: disable=attribute-defined-outside-init 253 | self.__buf = StringIO() 254 | self.__stdout = sys.stdout 255 | sys.stdout = self.__buf 256 | return self 257 | 258 | def flush(self): 259 | # type: () -> None 260 | for line in self: 261 | print(line) 262 | 263 | def __exit__(self, *args): 264 | # type: (*Any) -> None 265 | self.extend(self.__buf.getvalue().splitlines()) 266 | sys.stdout = self.__stdout 267 | 268 | 269 | class SSH2(object): # pylint: disable=too-few-public-methods 270 | class KexParty(object): 271 | def __init__(self, enc, mac, compression, languages): 272 | # type: (List[text_type], List[text_type], List[text_type], List[text_type]) -> None 273 | self.__enc = enc 274 | self.__mac = mac 275 | self.__compression = compression 276 | self.__languages = languages 277 | 278 | @property 279 | def encryption(self): 280 | # type: () -> List[text_type] 281 | return self.__enc 282 | 283 | @property 284 | def mac(self): 285 | # type: () -> List[text_type] 286 | return self.__mac 287 | 288 | @property 289 | def compression(self): 290 | # type: () -> List[text_type] 291 | return self.__compression 292 | 293 | @property 294 | def languages(self): 295 | # type: () -> List[text_type] 296 | return self.__languages 297 | 298 | class Kex(object): 299 | def __init__(self, cookie, kex_algs, key_algs, cli, srv, follows, unused=0): 300 | # type: (binary_type, List[text_type], List[text_type], SSH2.KexParty, SSH2.KexParty, bool, int) -> None 301 | self.__cookie = cookie 302 | self.__kex_algs = kex_algs 303 | self.__key_algs = key_algs 304 | self.__client = cli 305 | self.__server = srv 306 | self.__follows = follows 307 | self.__unused = unused 308 | 309 | @property 310 | def cookie(self): 311 | # type: () -> binary_type 312 | return self.__cookie 313 | 314 | @property 315 | def kex_algorithms(self): 316 | # type: () -> List[text_type] 317 | return self.__kex_algs 318 | 319 | @property 320 | def key_algorithms(self): 321 | # type: () -> List[text_type] 322 | return self.__key_algs 323 | 324 | # client_to_server 325 | @property 326 | def client(self): 327 | # type: () -> SSH2.KexParty 328 | return self.__client 329 | 330 | # server_to_client 331 | @property 332 | def server(self): 333 | # type: () -> SSH2.KexParty 334 | return self.__server 335 | 336 | @property 337 | def follows(self): 338 | # type: () -> bool 339 | return self.__follows 340 | 341 | @property 342 | def unused(self): 343 | # type: () -> int 344 | return self.__unused 345 | 346 | def write(self, wbuf): 347 | # type: (WriteBuf) -> None 348 | wbuf.write(self.cookie) 349 | wbuf.write_list(self.kex_algorithms) 350 | wbuf.write_list(self.key_algorithms) 351 | wbuf.write_list(self.client.encryption) 352 | wbuf.write_list(self.server.encryption) 353 | wbuf.write_list(self.client.mac) 354 | wbuf.write_list(self.server.mac) 355 | wbuf.write_list(self.client.compression) 356 | wbuf.write_list(self.server.compression) 357 | wbuf.write_list(self.client.languages) 358 | wbuf.write_list(self.server.languages) 359 | wbuf.write_bool(self.follows) 360 | wbuf.write_int(self.__unused) 361 | 362 | @property 363 | def payload(self): 364 | # type: () -> binary_type 365 | wbuf = WriteBuf() 366 | self.write(wbuf) 367 | return wbuf.write_flush() 368 | 369 | @classmethod 370 | def parse(cls, payload): 371 | # type: (binary_type) -> SSH2.Kex 372 | buf = ReadBuf(payload) 373 | cookie = buf.read(16) 374 | kex_algs = buf.read_list() 375 | key_algs = buf.read_list() 376 | cli_enc = buf.read_list() 377 | srv_enc = buf.read_list() 378 | cli_mac = buf.read_list() 379 | srv_mac = buf.read_list() 380 | cli_compression = buf.read_list() 381 | srv_compression = buf.read_list() 382 | cli_languages = buf.read_list() 383 | srv_languages = buf.read_list() 384 | follows = buf.read_bool() 385 | unused = buf.read_int() 386 | cli = SSH2.KexParty(cli_enc, cli_mac, cli_compression, cli_languages) 387 | srv = SSH2.KexParty(srv_enc, srv_mac, srv_compression, srv_languages) 388 | kex = cls(cookie, kex_algs, key_algs, cli, srv, follows, unused) 389 | return kex 390 | 391 | 392 | class SSH1(object): 393 | class CRC32(object): 394 | def __init__(self): 395 | # type: () -> None 396 | self._table = [0] * 256 397 | for i in range(256): 398 | crc = 0 399 | n = i 400 | for _ in range(8): 401 | x = (crc ^ n) & 1 402 | crc = (crc >> 1) ^ (x * 0xedb88320) 403 | n = n >> 1 404 | self._table[i] = crc 405 | 406 | def calc(self, v): 407 | # type: (binary_type) -> int 408 | crc, l = 0, len(v) 409 | for i in range(l): 410 | n = ord(v[i:i + 1]) 411 | n = n ^ (crc & 0xff) 412 | crc = (crc >> 8) ^ self._table[n] 413 | return crc 414 | 415 | _crc32 = None # type: Optional[SSH1.CRC32] 416 | CIPHERS = ['none', 'idea', 'des', '3des', 'tss', 'rc4', 'blowfish'] 417 | AUTHS = [None, 'rhosts', 'rsa', 'password', 'rhosts_rsa', 'tis', 'kerberos'] 418 | 419 | @classmethod 420 | def crc32(cls, v): 421 | # type: (binary_type) -> int 422 | if cls._crc32 is None: 423 | cls._crc32 = cls.CRC32() 424 | return cls._crc32.calc(v) 425 | 426 | class KexDB(object): # pylint: disable=too-few-public-methods 427 | # pylint: disable=bad-whitespace 428 | FAIL_PLAINTEXT = 'no encryption/integrity' 429 | FAIL_OPENSSH37_REMOVE = 'removed since OpenSSH 3.7' 430 | FAIL_NA_BROKEN = 'not implemented in OpenSSH, broken algorithm' 431 | FAIL_NA_UNSAFE = 'not implemented in OpenSSH (server), unsafe algorithm' 432 | TEXT_CIPHER_IDEA = 'cipher used by commercial SSH' 433 | 434 | ALGORITHMS = { 435 | 'key': { 436 | 'ssh-rsa1': [['1.2.2']], 437 | }, 438 | 'enc': { 439 | 'none': [['1.2.2'], [FAIL_PLAINTEXT]], 440 | 'idea': [[None], [], [], [TEXT_CIPHER_IDEA]], 441 | 'des': [['2.3.0C'], [FAIL_NA_UNSAFE]], 442 | '3des': [['1.2.2']], 443 | 'tss': [[''], [FAIL_NA_BROKEN]], 444 | 'rc4': [[], [FAIL_NA_BROKEN]], 445 | 'blowfish': [['1.2.2']], 446 | }, 447 | 'aut': { 448 | 'rhosts': [['1.2.2', '3.6'], [FAIL_OPENSSH37_REMOVE]], 449 | 'rsa': [['1.2.2']], 450 | 'password': [['1.2.2']], 451 | 'rhosts_rsa': [['1.2.2']], 452 | 'tis': [['1.2.2']], 453 | 'kerberos': [['1.2.2', '3.6'], [FAIL_OPENSSH37_REMOVE]], 454 | } 455 | } # type: Dict[str, Dict[str, List[List[str]]]] 456 | 457 | class PublicKeyMessage(object): 458 | def __init__(self, cookie, skey, hkey, pflags, cmask, amask): 459 | # type: (binary_type, Tuple[int, int, int], Tuple[int, int, int], int, int, int) -> None 460 | assert len(skey) == 3 461 | assert len(hkey) == 3 462 | self.__cookie = cookie 463 | self.__server_key = skey 464 | self.__host_key = hkey 465 | self.__protocol_flags = pflags 466 | self.__supported_ciphers_mask = cmask 467 | self.__supported_authentications_mask = amask 468 | 469 | @property 470 | def cookie(self): 471 | # type: () -> binary_type 472 | return self.__cookie 473 | 474 | @property 475 | def server_key_bits(self): 476 | # type: () -> int 477 | return self.__server_key[0] 478 | 479 | @property 480 | def server_key_public_exponent(self): 481 | # type: () -> int 482 | return self.__server_key[1] 483 | 484 | @property 485 | def server_key_public_modulus(self): 486 | # type: () -> int 487 | return self.__server_key[2] 488 | 489 | @property 490 | def host_key_bits(self): 491 | # type: () -> int 492 | return self.__host_key[0] 493 | 494 | @property 495 | def host_key_public_exponent(self): 496 | # type: () -> int 497 | return self.__host_key[1] 498 | 499 | @property 500 | def host_key_public_modulus(self): 501 | # type: () -> int 502 | return self.__host_key[2] 503 | 504 | @property 505 | def host_key_fingerprint_data(self): 506 | # type: () -> binary_type 507 | # pylint: disable=protected-access 508 | mod = WriteBuf._create_mpint(self.host_key_public_modulus, False) 509 | e = WriteBuf._create_mpint(self.host_key_public_exponent, False) 510 | return mod + e 511 | 512 | @property 513 | def protocol_flags(self): 514 | # type: () -> int 515 | return self.__protocol_flags 516 | 517 | @property 518 | def supported_ciphers_mask(self): 519 | # type: () -> int 520 | return self.__supported_ciphers_mask 521 | 522 | @property 523 | def supported_ciphers(self): 524 | # type: () -> List[text_type] 525 | ciphers = [] 526 | for i in range(len(SSH1.CIPHERS)): 527 | if self.__supported_ciphers_mask & (1 << i) != 0: 528 | ciphers.append(utils.to_utext(SSH1.CIPHERS[i])) 529 | return ciphers 530 | 531 | @property 532 | def supported_authentications_mask(self): 533 | # type: () -> int 534 | return self.__supported_authentications_mask 535 | 536 | @property 537 | def supported_authentications(self): 538 | # type: () -> List[text_type] 539 | auths = [] 540 | for i in range(1, len(SSH1.AUTHS)): 541 | if self.__supported_authentications_mask & (1 << i) != 0: 542 | auths.append(utils.to_utext(SSH1.AUTHS[i])) 543 | return auths 544 | 545 | def write(self, wbuf): 546 | # type: (WriteBuf) -> None 547 | wbuf.write(self.cookie) 548 | wbuf.write_int(self.server_key_bits) 549 | wbuf.write_mpint1(self.server_key_public_exponent) 550 | wbuf.write_mpint1(self.server_key_public_modulus) 551 | wbuf.write_int(self.host_key_bits) 552 | wbuf.write_mpint1(self.host_key_public_exponent) 553 | wbuf.write_mpint1(self.host_key_public_modulus) 554 | wbuf.write_int(self.protocol_flags) 555 | wbuf.write_int(self.supported_ciphers_mask) 556 | wbuf.write_int(self.supported_authentications_mask) 557 | 558 | @property 559 | def payload(self): 560 | # type: () -> binary_type 561 | wbuf = WriteBuf() 562 | self.write(wbuf) 563 | return wbuf.write_flush() 564 | 565 | @classmethod 566 | def parse(cls, payload): 567 | # type: (binary_type) -> SSH1.PublicKeyMessage 568 | buf = ReadBuf(payload) 569 | cookie = buf.read(8) 570 | server_key_bits = buf.read_int() 571 | server_key_exponent = buf.read_mpint1() 572 | server_key_modulus = buf.read_mpint1() 573 | skey = (server_key_bits, server_key_exponent, server_key_modulus) 574 | host_key_bits = buf.read_int() 575 | host_key_exponent = buf.read_mpint1() 576 | host_key_modulus = buf.read_mpint1() 577 | hkey = (host_key_bits, host_key_exponent, host_key_modulus) 578 | pflags = buf.read_int() 579 | cmask = buf.read_int() 580 | amask = buf.read_int() 581 | pkm = cls(cookie, skey, hkey, pflags, cmask, amask) 582 | return pkm 583 | 584 | 585 | class ReadBuf(object): 586 | def __init__(self, data=None): 587 | # type: (Optional[binary_type]) -> None 588 | super(ReadBuf, self).__init__() 589 | self._buf = BytesIO(data) if data else BytesIO() 590 | self._len = len(data) if data else 0 591 | 592 | @property 593 | def unread_len(self): 594 | # type: () -> int 595 | return self._len - self._buf.tell() 596 | 597 | def read(self, size): 598 | # type: (int) -> binary_type 599 | return self._buf.read(size) 600 | 601 | def read_byte(self): 602 | # type: () -> int 603 | return struct.unpack('B', self.read(1))[0] 604 | 605 | def read_bool(self): 606 | # type: () -> bool 607 | return self.read_byte() != 0 608 | 609 | def read_int(self): 610 | # type: () -> int 611 | return struct.unpack('>I', self.read(4))[0] 612 | 613 | def read_list(self): 614 | # type: () -> List[text_type] 615 | list_size = self.read_int() 616 | return self.read(list_size).decode('utf-8', 'replace').split(',') 617 | 618 | def read_string(self): 619 | # type: () -> binary_type 620 | n = self.read_int() 621 | return self.read(n) 622 | 623 | @classmethod 624 | def _parse_mpint(cls, v, pad, sf): 625 | # type: (binary_type, binary_type, str) -> int 626 | r = 0 627 | if len(v) % 4: 628 | v = pad * (4 - (len(v) % 4)) + v 629 | for i in range(0, len(v), 4): 630 | r = (r << 32) | struct.unpack(sf, v[i:i + 4])[0] 631 | return r 632 | 633 | def read_mpint1(self): 634 | # type: () -> int 635 | # NOTE: Data Type Enc @ http://www.snailbook.com/docs/protocol-1.5.txt 636 | bits = struct.unpack('>H', self.read(2))[0] 637 | n = (bits + 7) // 8 638 | return self._parse_mpint(self.read(n), b'\x00', '>I') 639 | 640 | def read_mpint2(self): 641 | # type: () -> int 642 | # NOTE: Section 5 @ https://www.ietf.org/rfc/rfc4251.txt 643 | v = self.read_string() 644 | if len(v) == 0: 645 | return 0 646 | pad, sf = (b'\xff', '>i') if ord(v[0:1]) & 0x80 else (b'\x00', '>I') 647 | return self._parse_mpint(v, pad, sf) 648 | 649 | def read_line(self): 650 | # type: () -> text_type 651 | return self._buf.readline().rstrip().decode('utf-8', 'replace') 652 | 653 | 654 | class WriteBuf(object): 655 | def __init__(self, data=None): 656 | # type: (Optional[binary_type]) -> None 657 | super(WriteBuf, self).__init__() 658 | self._wbuf = BytesIO(data) if data else BytesIO() 659 | 660 | def write(self, data): 661 | # type: (binary_type) -> WriteBuf 662 | self._wbuf.write(data) 663 | return self 664 | 665 | def write_byte(self, v): 666 | # type: (int) -> WriteBuf 667 | return self.write(struct.pack('B', v)) 668 | 669 | def write_bool(self, v): 670 | # type: (bool) -> WriteBuf 671 | return self.write_byte(1 if v else 0) 672 | 673 | def write_int(self, v): 674 | # type: (int) -> WriteBuf 675 | return self.write(struct.pack('>I', v)) 676 | 677 | def write_string(self, v): 678 | # type: (Union[binary_type, text_type]) -> WriteBuf 679 | if not isinstance(v, bytes): 680 | v = bytes(bytearray(v, 'utf-8')) 681 | self.write_int(len(v)) 682 | return self.write(v) 683 | 684 | def write_list(self, v): 685 | # type: (List[text_type]) -> WriteBuf 686 | return self.write_string(u','.join(v)) 687 | 688 | @classmethod 689 | def _bitlength(cls, n): 690 | # type: (int) -> int 691 | try: 692 | return n.bit_length() 693 | except AttributeError: 694 | return len(bin(n)) - (2 if n > 0 else 3) 695 | 696 | @classmethod 697 | def _create_mpint(cls, n, signed=True, bits=None): 698 | # type: (int, bool, Optional[int]) -> binary_type 699 | if bits is None: 700 | bits = cls._bitlength(n) 701 | length = bits // 8 + (1 if n != 0 else 0) 702 | ql = (length + 7) // 8 703 | fmt, v2 = '>{0}Q'.format(ql), [0] * ql 704 | for i in range(ql): 705 | v2[ql - i - 1] = (n & 0xffffffffffffffff) 706 | n >>= 64 707 | data = bytes(struct.pack(fmt, *v2)[-length:]) 708 | if not signed: 709 | data = data.lstrip(b'\x00') 710 | elif data.startswith(b'\xff\x80'): 711 | data = data[1:] 712 | return data 713 | 714 | def write_mpint1(self, n): 715 | # type: (int) -> WriteBuf 716 | # NOTE: Data Type Enc @ http://www.snailbook.com/docs/protocol-1.5.txt 717 | bits = self._bitlength(n) 718 | data = self._create_mpint(n, False, bits) 719 | self.write(struct.pack('>H', bits)) 720 | return self.write(data) 721 | 722 | def write_mpint2(self, n): 723 | # type: (int) -> WriteBuf 724 | # NOTE: Section 5 @ https://www.ietf.org/rfc/rfc4251.txt 725 | data = self._create_mpint(n) 726 | return self.write_string(data) 727 | 728 | def write_line(self, v): 729 | # type: (Union[binary_type, str]) -> WriteBuf 730 | if not isinstance(v, bytes): 731 | v = bytes(bytearray(v, 'utf-8')) 732 | v += b'\r\n' 733 | return self.write(v) 734 | 735 | def write_flush(self): 736 | # type: () -> binary_type 737 | payload = self._wbuf.getvalue() 738 | self._wbuf.truncate(0) 739 | self._wbuf.seek(0) 740 | return payload 741 | 742 | 743 | class SSH(object): # pylint: disable=too-few-public-methods 744 | class Protocol(object): # pylint: disable=too-few-public-methods 745 | # pylint: disable=bad-whitespace 746 | SMSG_PUBLIC_KEY = 2 747 | MSG_KEXINIT = 20 748 | MSG_NEWKEYS = 21 749 | MSG_KEXDH_INIT = 30 750 | MSG_KEXDH_REPLY = 32 751 | 752 | class Product(object): # pylint: disable=too-few-public-methods 753 | OpenSSH = 'OpenSSH' 754 | DropbearSSH = 'Dropbear SSH' 755 | LibSSH = 'libssh' 756 | 757 | class Software(object): 758 | def __init__(self, vendor, product, version, patch, os_version): 759 | # type: (Optional[str], str, str, Optional[str], Optional[str]) -> None 760 | self.__vendor = vendor 761 | self.__product = product 762 | self.__version = version 763 | self.__patch = patch 764 | self.__os = os_version 765 | 766 | @property 767 | def vendor(self): 768 | # type: () -> Optional[str] 769 | return self.__vendor 770 | 771 | @property 772 | def product(self): 773 | # type: () -> str 774 | return self.__product 775 | 776 | @property 777 | def version(self): 778 | # type: () -> str 779 | return self.__version 780 | 781 | @property 782 | def patch(self): 783 | # type: () -> Optional[str] 784 | return self.__patch 785 | 786 | @property 787 | def os(self): 788 | # type: () -> Optional[str] 789 | return self.__os 790 | 791 | def compare_version(self, other): 792 | # type: (Union[None, SSH.Software, text_type]) -> int 793 | # pylint: disable=too-many-branches 794 | if other is None: 795 | return 1 796 | if isinstance(other, SSH.Software): 797 | other = '{0}{1}'.format(other.version, other.patch or '') 798 | else: 799 | other = str(other) 800 | mx = re.match(r'^([\d\.]+\d+)(.*)$', other) 801 | if mx: 802 | oversion, opatch = mx.group(1), mx.group(2).strip() 803 | else: 804 | oversion, opatch = other, '' 805 | if self.version < oversion: 806 | return -1 807 | elif self.version > oversion: 808 | return 1 809 | spatch = self.patch or '' 810 | if self.product == SSH.Product.DropbearSSH: 811 | if not re.match(r'^test\d.*$', opatch): 812 | opatch = 'z{0}'.format(opatch) 813 | if not re.match(r'^test\d.*$', spatch): 814 | spatch = 'z{0}'.format(spatch) 815 | elif self.product == SSH.Product.OpenSSH: 816 | mx1 = re.match(r'^p\d(.*)', opatch) 817 | mx2 = re.match(r'^p\d(.*)', spatch) 818 | if not (mx1 and mx2): 819 | if mx1: 820 | opatch = mx1.group(1) 821 | if mx2: 822 | spatch = mx2.group(1) 823 | if spatch < opatch: 824 | return -1 825 | elif spatch > opatch: 826 | return 1 827 | return 0 828 | 829 | def between_versions(self, vfrom, vtill): 830 | # type: (str, str) -> bool 831 | if vfrom and self.compare_version(vfrom) < 0: 832 | return False 833 | if vtill and self.compare_version(vtill) > 0: 834 | return False 835 | return True 836 | 837 | def display(self, full=True): 838 | # type: (bool) -> str 839 | r = '{0} '.format(self.vendor) if self.vendor else '' 840 | r += self.product 841 | if self.version: 842 | r += ' {0}'.format(self.version) 843 | if full: 844 | patch = self.patch or '' 845 | if self.product == SSH.Product.OpenSSH: 846 | mx = re.match(r'^(p\d)(.*)$', patch) 847 | if mx is not None: 848 | r += mx.group(1) 849 | patch = mx.group(2).strip() 850 | if patch: 851 | r += ' ({0})'.format(patch) 852 | if self.os: 853 | r += ' running on {0}'.format(self.os) 854 | return r 855 | 856 | def __str__(self): 857 | # type: () -> str 858 | return self.display() 859 | 860 | def __repr__(self): 861 | # type: () -> str 862 | r = 'vendor={0}'.format(self.vendor) if self.vendor else '' 863 | if self.product: 864 | if self.vendor: 865 | r += ', ' 866 | r += 'product={0}'.format(self.product) 867 | if self.version: 868 | r += ', version={0}'.format(self.version) 869 | if self.patch: 870 | r += ', patch={0}'.format(self.patch) 871 | if self.os: 872 | r += ', os={0}'.format(self.os) 873 | return '<{0}({1})>'.format(self.__class__.__name__, r) 874 | 875 | @staticmethod 876 | def _fix_patch(patch): 877 | # type: (str) -> Optional[str] 878 | return re.sub(r'^[-_\.]+', '', patch) or None 879 | 880 | @staticmethod 881 | def _fix_date(d): 882 | # type: (str) -> Optional[str] 883 | if d is not None and len(d) == 8: 884 | return '{0}-{1}-{2}'.format(d[:4], d[4:6], d[6:8]) 885 | else: 886 | return None 887 | 888 | @classmethod 889 | def _extract_os_version(cls, c): 890 | # type: (Optional[str]) -> str 891 | if c is None: 892 | return None 893 | mx = re.match(r'^NetBSD(?:_Secure_Shell)?(?:[\s-]+(\d{8})(.*))?$', c) 894 | if mx: 895 | d = cls._fix_date(mx.group(1)) 896 | return 'NetBSD' if d is None else 'NetBSD ({0})'.format(d) 897 | mx = re.match(r'^FreeBSD(?:\slocalisations)?[\s-]+(\d{8})(.*)$', c) 898 | if not mx: 899 | mx = re.match(r'^[^@]+@FreeBSD\.org[\s-]+(\d{8})(.*)$', c) 900 | if mx: 901 | d = cls._fix_date(mx.group(1)) 902 | return 'FreeBSD' if d is None else 'FreeBSD ({0})'.format(d) 903 | w = ['RemotelyAnywhere', 'DesktopAuthority', 'RemoteSupportManager'] 904 | for win_soft in w: 905 | mx = re.match(r'^in ' + win_soft + r' ([\d\.]+\d)$', c) 906 | if mx: 907 | ver = mx.group(1) 908 | return 'Microsoft Windows ({0} {1})'.format(win_soft, ver) 909 | generic = ['NetBSD', 'FreeBSD'] 910 | for g in generic: 911 | if c.startswith(g) or c.endswith(g): 912 | return g 913 | return None 914 | 915 | @classmethod 916 | def parse(cls, banner): 917 | # type: (SSH.Banner) -> SSH.Software 918 | # pylint: disable=too-many-return-statements 919 | software = str(banner.software) 920 | mx = re.match(r'^dropbear_([\d\.]+\d+)(.*)', software) 921 | if mx: 922 | patch = cls._fix_patch(mx.group(2)) 923 | v, p = 'Matt Johnston', SSH.Product.DropbearSSH 924 | v = None 925 | return cls(v, p, mx.group(1), patch, None) 926 | mx = re.match(r'^OpenSSH[_\.-]+([\d\.]+\d+)(.*)', software) 927 | if mx: 928 | patch = cls._fix_patch(mx.group(2)) 929 | v, p = 'OpenBSD', SSH.Product.OpenSSH 930 | v = None 931 | os_version = cls._extract_os_version(banner.comments) 932 | return cls(v, p, mx.group(1), patch, os_version) 933 | mx = re.match(r'^libssh-([\d\.]+\d+)(.*)', software) 934 | if mx: 935 | patch = cls._fix_patch(mx.group(2)) 936 | v, p = None, SSH.Product.LibSSH 937 | os_version = cls._extract_os_version(banner.comments) 938 | return cls(v, p, mx.group(1), patch, os_version) 939 | mx = re.match(r'^RomSShell_([\d\.]+\d+)(.*)', software) 940 | if mx: 941 | patch = cls._fix_patch(mx.group(2)) 942 | v, p = 'Allegro Software', 'RomSShell' 943 | return cls(v, p, mx.group(1), patch, None) 944 | mx = re.match(r'^mpSSH_([\d\.]+\d+)', software) 945 | if mx: 946 | v, p = 'HP', 'iLO (Integrated Lights-Out) sshd' 947 | return cls(v, p, mx.group(1), None, None) 948 | mx = re.match(r'^Cisco-([\d\.]+\d+)', software) 949 | if mx: 950 | v, p = 'Cisco', 'IOS/PIX sshd' 951 | return cls(v, p, mx.group(1), None, None) 952 | return None 953 | 954 | class Banner(object): 955 | _RXP, _RXR = r'SSH-\d\.\s*?\d+', r'(-\s*([^\s]*)(?:\s+(.*))?)?' 956 | RX_PROTOCOL = re.compile(re.sub(r'\\d(\+?)', r'(\\d\g<1>)', _RXP)) 957 | RX_BANNER = re.compile(r'^({0}(?:(?:-{0})*)){1}$'.format(_RXP, _RXR)) 958 | 959 | def __init__(self, protocol, software, comments, valid_ascii): 960 | # type: (Tuple[int, int], str, str, bool) -> None 961 | self.__protocol = protocol 962 | self.__software = software 963 | self.__comments = comments 964 | self.__valid_ascii = valid_ascii 965 | 966 | @property 967 | def protocol(self): 968 | # type: () -> Tuple[int, int] 969 | return self.__protocol 970 | 971 | @property 972 | def software(self): 973 | # type: () -> str 974 | return self.__software 975 | 976 | @property 977 | def comments(self): 978 | # type: () -> str 979 | return self.__comments 980 | 981 | @property 982 | def valid_ascii(self): 983 | # type: () -> bool 984 | return self.__valid_ascii 985 | 986 | def __str__(self): 987 | # type: () -> str 988 | r = 'SSH-{0}.{1}'.format(self.protocol[0], self.protocol[1]) 989 | if self.software is not None: 990 | r += '-{0}'.format(self.software) 991 | if self.comments: 992 | r += ' {0}'.format(self.comments) 993 | return r 994 | 995 | def __repr__(self): 996 | # type: () -> str 997 | p = '{0}.{1}'.format(self.protocol[0], self.protocol[1]) 998 | r = 'protocol={0}'.format(p) 999 | if self.software: 1000 | r += ', software={0}'.format(self.software) 1001 | if self.comments: 1002 | r += ', comments={0}'.format(self.comments) 1003 | return '<{0}({1})>'.format(self.__class__.__name__, r) 1004 | 1005 | @classmethod 1006 | def parse(cls, banner): 1007 | # type: (text_type) -> SSH.Banner 1008 | valid_ascii = utils.is_ascii(banner) 1009 | ascii_banner = utils.to_ascii(banner) 1010 | mx = cls.RX_BANNER.match(ascii_banner) 1011 | if mx is None: 1012 | return None 1013 | protocol = min(re.findall(cls.RX_PROTOCOL, mx.group(1))) 1014 | protocol = (int(protocol[0]), int(protocol[1])) 1015 | software = (mx.group(3) or '').strip() or None 1016 | if software is None and (mx.group(2) or '').startswith('-'): 1017 | software = '' 1018 | comments = (mx.group(4) or '').strip() or None 1019 | if comments is not None: 1020 | comments = re.sub(r'\s+', ' ', comments) 1021 | return cls(protocol, software, comments, valid_ascii) 1022 | 1023 | class Fingerprint(object): 1024 | def __init__(self, fpd): 1025 | # type: (binary_type) -> None 1026 | self.__fpd = fpd 1027 | 1028 | @property 1029 | def md5(self): 1030 | # type: () -> text_type 1031 | h = hashlib.md5(self.__fpd).hexdigest() 1032 | r = u':'.join(h[i:i + 2] for i in range(0, len(h), 2)) 1033 | return u'MD5:{0}'.format(r) 1034 | 1035 | @property 1036 | def sha256(self): 1037 | # type: () -> text_type 1038 | h = base64.b64encode(hashlib.sha256(self.__fpd).digest()) 1039 | r = h.decode('ascii').rstrip('=') 1040 | return u'SHA256:{0}'.format(r) 1041 | 1042 | class Security(object): # pylint: disable=too-few-public-methods 1043 | # pylint: disable=bad-whitespace 1044 | CVE = { 1045 | 'Dropbear SSH': [ 1046 | ['0.44', '2015.71', 1, 'CVE-2016-3116', 5.5, 'bypass command restrictions via xauth command injection'], 1047 | ['0.28', '2013.58', 1, 'CVE-2013-4434', 5.0, 'discover valid usernames through different time delays'], 1048 | ['0.28', '2013.58', 1, 'CVE-2013-4421', 5.0, 'cause DoS (memory consumption) via a compressed packet'], 1049 | ['0.52', '2011.54', 1, 'CVE-2012-0920', 7.1, 'execute arbitrary code or bypass command restrictions'], 1050 | ['0.40', '0.48.1', 1, 'CVE-2007-1099', 7.5, 'conduct a MitM attack (no warning for hostkey mismatch)'], 1051 | ['0.28', '0.47', 1, 'CVE-2006-1206', 7.5, 'cause DoS (slot exhaustion) via large number of connections'], 1052 | ['0.39', '0.47', 1, 'CVE-2006-0225', 4.6, 'execute arbitrary commands via scp with crafted filenames'], 1053 | ['0.28', '0.46', 1, 'CVE-2005-4178', 6.5, 'execute arbitrary code via buffer overflow vulnerability'], 1054 | ['0.28', '0.42', 1, 'CVE-2004-2486', 7.5, 'execute arbitrary code via DSS verification code']], 1055 | 'libssh': [ 1056 | ['0.1', '0.7.2', 1, 'CVE-2016-0739', 4.3, 'conduct a MitM attack (weakness in DH key generation)'], 1057 | ['0.5.1', '0.6.4', 1, 'CVE-2015-3146', 5.0, 'cause DoS via kex packets (null pointer dereference)'], 1058 | ['0.5.1', '0.6.3', 1, 'CVE-2014-8132', 5.0, 'cause DoS via kex init packet (dangling pointer)'], 1059 | ['0.4.7', '0.6.2', 1, 'CVE-2014-0017', 1.9, 'leak data via PRNG state reuse on forking servers'], 1060 | ['0.4.7', '0.5.3', 1, 'CVE-2013-0176', 4.3, 'cause DoS via kex packet (null pointer dereference)'], 1061 | ['0.4.7', '0.5.2', 1, 'CVE-2012-6063', 7.5, 'cause DoS or execute arbitrary code via sftp (double free)'], 1062 | ['0.4.7', '0.5.2', 1, 'CVE-2012-4562', 7.5, 'cause DoS or execute arbitrary code (overflow check)'], 1063 | ['0.4.7', '0.5.2', 1, 'CVE-2012-4561', 5.0, 'cause DoS via unspecified vectors (invalid pointer)'], 1064 | ['0.4.7', '0.5.2', 1, 'CVE-2012-4560', 7.5, 'cause DoS or execute arbitrary code (buffer overflow)'], 1065 | ['0.4.7', '0.5.2', 1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']] 1066 | } # type: Dict[str, List[List[Any]]] 1067 | TXT = { 1068 | 'Dropbear SSH': [ 1069 | ['0.28', '0.34', 1, 'remote root exploit', 'remote format string buffer overflow exploit (exploit-db#387)']], 1070 | 'libssh': [ 1071 | ['0.3.3', '0.3.3', 1, 'null pointer check', 'missing null pointer check in "crypt_set_algorithms_server"'], 1072 | ['0.3.3', '0.3.3', 1, 'integer overflow', 'integer overflow in "buffer_get_data"'], 1073 | ['0.3.3', '0.3.3', 3, 'heap overflow', 'heap overflow in "packet_decrypt"']] 1074 | } # type: Dict[str, List[List[Any]]] 1075 | 1076 | class Socket(ReadBuf, WriteBuf): 1077 | class InsufficientReadException(Exception): 1078 | pass 1079 | 1080 | SM_BANNER_SENT = 1 1081 | 1082 | def __init__(self, host, port): 1083 | # type: (str, int) -> None 1084 | super(SSH.Socket, self).__init__() 1085 | self.__block_size = 8 1086 | self.__state = 0 1087 | self.__header = [] # type: List[text_type] 1088 | self.__banner = None # type: Optional[SSH.Banner] 1089 | self.__host = host 1090 | self.__port = port 1091 | self.__sock = None # type: socket.socket 1092 | 1093 | def __enter__(self): 1094 | # type: () -> SSH.Socket 1095 | return self 1096 | 1097 | def _resolve(self, ipvo): 1098 | # type: (Sequence[int]) -> Iterable[Tuple[int, Tuple[Any, ...]]] 1099 | ipvo = tuple(filter(lambda x: x in (4, 6), utils.unique_seq(ipvo))) 1100 | ipvo_len = len(ipvo) 1101 | prefer_ipvo = ipvo_len > 0 1102 | prefer_ipv4 = prefer_ipvo and ipvo[0] == 4 1103 | if len(ipvo) == 1: 1104 | family = {4: socket.AF_INET, 6: socket.AF_INET6}.get(ipvo[0]) 1105 | else: 1106 | family = socket.AF_UNSPEC 1107 | try: 1108 | stype = socket.SOCK_STREAM 1109 | r = socket.getaddrinfo(self.__host, self.__port, family, stype) 1110 | if prefer_ipvo: 1111 | r = sorted(r, key=lambda x: x[0], reverse=not prefer_ipv4) 1112 | check = any(stype == rline[2] for rline in r) 1113 | for (af, socktype, proto, canonname, addr) in r: 1114 | if not check or socktype == socket.SOCK_STREAM: 1115 | yield (af, addr) 1116 | except socket.error as e: 1117 | out.fail('[exception] {0}'.format(e)) 1118 | sys.exit(1) 1119 | 1120 | def connect(self, ipvo=(), cto=3.0, rto=5.0): 1121 | # type: (Sequence[int], float, float) -> None 1122 | err = None 1123 | for (af, addr) in self._resolve(ipvo): 1124 | s = None 1125 | try: 1126 | s = socket.socket(af, socket.SOCK_STREAM) 1127 | s.settimeout(cto) 1128 | s.connect(addr) 1129 | s.settimeout(rto) 1130 | self.__sock = s 1131 | return 1132 | except socket.error as e: 1133 | err = e 1134 | self._close_socket(s) 1135 | if err is None: 1136 | errm = 'host {0} has no DNS records'.format(self.__host) 1137 | else: 1138 | errt = (self.__host, self.__port, err) 1139 | errm = 'cannot connect to {0} port {1}: {2}'.format(*errt) 1140 | out.fail('[exception] {0}'.format(errm)) 1141 | sys.exit(1) 1142 | 1143 | def get_banner(self, sshv=2): 1144 | # type: (int) -> Tuple[Optional[SSH.Banner], List[text_type]] 1145 | banner = 'SSH-{0}-OpenSSH_7.3'.format('1.5' if sshv == 1 else '2.0') 1146 | rto = self.__sock.gettimeout() 1147 | self.__sock.settimeout(0.7) 1148 | s, e = self.recv() 1149 | self.__sock.settimeout(rto) 1150 | if s < 0: 1151 | return self.__banner, self.__header 1152 | if self.__state < self.SM_BANNER_SENT: 1153 | self.send_banner(banner) 1154 | while self.__banner is None: 1155 | if not s > 0: 1156 | s, e = self.recv() 1157 | if s < 0: 1158 | break 1159 | while self.__banner is None and self.unread_len > 0: 1160 | line = self.read_line() 1161 | if len(line.strip()) == 0: 1162 | continue 1163 | if self.__banner is None: 1164 | self.__banner = SSH.Banner.parse(line) 1165 | if self.__banner is not None: 1166 | continue 1167 | self.__header.append(line) 1168 | s = 0 1169 | return self.__banner, self.__header 1170 | 1171 | def recv(self, size=2048): 1172 | # type: (int) -> Tuple[int, Optional[str]] 1173 | try: 1174 | data = self.__sock.recv(size) 1175 | except socket.timeout: 1176 | return (-1, 'timeout') 1177 | except socket.error as e: 1178 | if e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK): 1179 | return (0, 'retry') 1180 | return (-1, str(e.args[-1])) 1181 | if len(data) == 0: 1182 | return (-1, None) 1183 | pos = self._buf.tell() 1184 | self._buf.seek(0, 2) 1185 | self._buf.write(data) 1186 | self._len += len(data) 1187 | self._buf.seek(pos, 0) 1188 | return (len(data), None) 1189 | 1190 | def send(self, data): 1191 | # type: (binary_type) -> Tuple[int, Optional[str]] 1192 | try: 1193 | self.__sock.send(data) 1194 | return (0, None) 1195 | except socket.error as e: 1196 | return (-1, str(e.args[-1])) 1197 | self.__sock.send(data) 1198 | 1199 | def send_banner(self, banner): 1200 | # type: (str) -> None 1201 | self.send(banner.encode() + b'\r\n') 1202 | if self.__state < self.SM_BANNER_SENT: 1203 | self.__state = self.SM_BANNER_SENT 1204 | 1205 | def ensure_read(self, size): 1206 | # type: (int) -> None 1207 | while self.unread_len < size: 1208 | s, e = self.recv() 1209 | if s < 0: 1210 | raise SSH.Socket.InsufficientReadException(e) 1211 | 1212 | def read_packet(self, sshv=2): 1213 | # type: (int) -> Tuple[int, binary_type] 1214 | try: 1215 | header = WriteBuf() 1216 | self.ensure_read(4) 1217 | packet_length = self.read_int() 1218 | header.write_int(packet_length) 1219 | # XXX: validate length 1220 | if sshv == 1: 1221 | padding_length = (8 - packet_length % 8) 1222 | self.ensure_read(padding_length) 1223 | padding = self.read(padding_length) 1224 | header.write(padding) 1225 | payload_length = packet_length 1226 | check_size = padding_length + payload_length 1227 | else: 1228 | self.ensure_read(1) 1229 | padding_length = self.read_byte() 1230 | header.write_byte(padding_length) 1231 | payload_length = packet_length - padding_length - 1 1232 | check_size = 4 + 1 + payload_length + padding_length 1233 | if check_size % self.__block_size != 0: 1234 | out.fail('[exception] invalid ssh packet (block size)') 1235 | sys.exit(1) 1236 | self.ensure_read(payload_length) 1237 | if sshv == 1: 1238 | payload = self.read(payload_length - 4) 1239 | header.write(payload) 1240 | crc = self.read_int() 1241 | header.write_int(crc) 1242 | else: 1243 | payload = self.read(payload_length) 1244 | header.write(payload) 1245 | packet_type = ord(payload[0:1]) 1246 | if sshv == 1: 1247 | rcrc = SSH1.crc32(padding + payload) 1248 | if crc != rcrc: 1249 | out.fail('[exception] packet checksum CRC32 mismatch.') 1250 | sys.exit(1) 1251 | else: 1252 | self.ensure_read(padding_length) 1253 | padding = self.read(padding_length) 1254 | payload = payload[1:] 1255 | return packet_type, payload 1256 | except SSH.Socket.InsufficientReadException as ex: 1257 | if ex.args[0] is None: 1258 | header.write(self.read(self.unread_len)) 1259 | e = header.write_flush().strip() 1260 | else: 1261 | e = ex.args[0].encode('utf-8') 1262 | return (-1, e) 1263 | 1264 | def send_packet(self): 1265 | # type: () -> Tuple[int, Optional[str]] 1266 | payload = self.write_flush() 1267 | padding = -(len(payload) + 5) % 8 1268 | if padding < 4: 1269 | padding += 8 1270 | plen = len(payload) + padding + 1 1271 | pad_bytes = b'\x00' * padding 1272 | data = struct.pack('>Ib', plen, padding) + payload + pad_bytes 1273 | return self.send(data) 1274 | 1275 | def _close_socket(self, s): 1276 | # type: (Optional[socket.socket]) -> None 1277 | try: 1278 | if s is not None: 1279 | s.shutdown(socket.SHUT_RDWR) 1280 | s.close() 1281 | except: # pylint: disable=bare-except 1282 | pass 1283 | 1284 | def __del__(self): 1285 | # type: () -> None 1286 | self.__cleanup() 1287 | 1288 | def __exit__(self, *args): 1289 | # type: (*Any) -> None 1290 | self.__cleanup() 1291 | 1292 | def __cleanup(self): 1293 | # type: () -> None 1294 | self._close_socket(self.__sock) 1295 | 1296 | 1297 | class KexDH(object): 1298 | def __init__(self, alg, g, p): 1299 | # type: (str, int, int) -> None 1300 | self.__alg = alg 1301 | self.__g = g 1302 | self.__p = p 1303 | self.__q = (self.__p - 1) // 2 1304 | self.__x = None # type: Optional[int] 1305 | self.__e = None # type: Optional[int] 1306 | 1307 | def send_init(self, s): 1308 | # type: (SSH.Socket) -> None 1309 | r = random.SystemRandom() 1310 | self.__x = r.randrange(2, self.__q) 1311 | self.__e = pow(self.__g, self.__x, self.__p) 1312 | s.write_byte(SSH.Protocol.MSG_KEXDH_INIT) 1313 | s.write_mpint2(self.__e) 1314 | s.send_packet() 1315 | 1316 | 1317 | class KexGroup1(KexDH): 1318 | def __init__(self): 1319 | # type: () -> None 1320 | # rfc2409: second oakley group 1321 | p = int('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67' 1322 | 'cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6d' 1323 | 'f25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff' 1324 | '5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381' 1325 | 'ffffffffffffffff', 16) 1326 | super(KexGroup1, self).__init__('sha1', 2, p) 1327 | 1328 | 1329 | class KexGroup14(KexDH): 1330 | def __init__(self): 1331 | # type: () -> None 1332 | # rfc3526: 2048-bit modp group 1333 | p = int('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67' 1334 | 'cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6d' 1335 | 'f25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff' 1336 | '5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3d' 1337 | 'c2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3' 1338 | 'ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08' 1339 | 'ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c5' 1340 | '5df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa0510' 1341 | '15728e5a8aacaa68ffffffffffffffff', 16) 1342 | super(KexGroup14, self).__init__('sha1', 2, p) 1343 | 1344 | 1345 | class KexDB(object): # pylint: disable=too-few-public-methods 1346 | # pylint: disable=bad-whitespace 1347 | WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm' 1348 | FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm' 1349 | FAIL_OPENSSH70_WEAK = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm' 1350 | FAIL_OPENSSH70_LOGJAM = 'disabled (in client) since OpenSSH 7.0, logjam attack' 1351 | INFO_OPENSSH69_CHACHA = 'default cipher since OpenSSH 6.9.' 1352 | FAIL_OPENSSH67_UNSAFE = 'removed (in server) since OpenSSH 6.7, unsafe algorithm' 1353 | FAIL_OPENSSH61_REMOVE = 'removed since OpenSSH 6.1, removed from specification' 1354 | FAIL_OPENSSH31_REMOVE = 'removed since OpenSSH 3.1' 1355 | FAIL_DBEAR67_DISABLED = 'disabled since Dropbear SSH 2015.67' 1356 | FAIL_DBEAR53_DISABLED = 'disabled since Dropbear SSH 0.53' 1357 | FAIL_PLAINTEXT = 'no encryption/integrity' 1358 | WARN_CURVES_WEAK = 'using weak elliptic curves' 1359 | WARN_RNDSIG_KEY = 'using weak random number generator could reveal the key' 1360 | WARN_MODULUS_SIZE = 'using small 1024-bit modulus' 1361 | WARN_MODULUS_CUSTOM = 'using custom size modulus (possibly weak)' 1362 | WARN_HASH_WEAK = 'using weak hashing algorithm' 1363 | WARN_CIPHER_MODE = 'using weak cipher mode' 1364 | WARN_BLOCK_SIZE = 'using small 64-bit block size' 1365 | WARN_CIPHER_WEAK = 'using weak cipher' 1366 | WARN_ENCRYPT_AND_MAC = 'using encrypt-and-MAC mode' 1367 | WARN_TAG_SIZE = 'using small 64-bit tag size' 1368 | 1369 | ALGORITHMS = { 1370 | 'kex': { 1371 | 'diffie-hellman-group1-sha1': [['2.3.0,d0.28,l10.2', '6.6', '6.9'], [FAIL_OPENSSH67_UNSAFE, FAIL_OPENSSH70_LOGJAM], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]], 1372 | 'diffie-hellman-group14-sha1': [['3.9,d0.53,l10.6.0'], [], [WARN_HASH_WEAK]], 1373 | 'diffie-hellman-group14-sha256': [['7.3,d2016.73']], 1374 | 'diffie-hellman-group16-sha512': [['7.3,d2016.73']], 1375 | 'diffie-hellman-group18-sha512': [['7.3']], 1376 | 'diffie-hellman-group-exchange-sha1': [['2.3.0', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], 1377 | 'diffie-hellman-group-exchange-sha256': [['4.4'], [], [WARN_MODULUS_CUSTOM]], 1378 | 'ecdh-sha2-nistp256': [['5.7,d2013.62,l10.6.0'], [WARN_CURVES_WEAK]], 1379 | 'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], 1380 | 'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]], 1381 | 'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']], 1382 | 'kexguess2@matt.ucc.asn.au': [['d2013.57']], 1383 | }, 1384 | 'key': { 1385 | 'rsa-sha2-256': [['7.2']], 1386 | 'rsa-sha2-512': [['7.2']], 1387 | 'ssh-ed25519': [['6.5,l10.7.0']], 1388 | 'ssh-ed25519-cert-v01@openssh.com': [['6.5']], 1389 | 'ssh-rsa': [['2.5.0,d0.28,l10.2']], 1390 | 'ssh-dss': [['2.1.0,d0.28,l10.2', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], 1391 | 'ecdsa-sha2-nistp256': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1392 | 'ecdsa-sha2-nistp384': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1393 | 'ecdsa-sha2-nistp521': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1394 | 'ssh-rsa-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], []], 1395 | 'ssh-dss-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], 1396 | 'ssh-rsa-cert-v01@openssh.com': [['5.6']], 1397 | 'ssh-dss-cert-v01@openssh.com': [['5.6', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]], 1398 | 'ecdsa-sha2-nistp256-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1399 | 'ecdsa-sha2-nistp384-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1400 | 'ecdsa-sha2-nistp521-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]], 1401 | }, 1402 | 'enc': { 1403 | 'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]], 1404 | '3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], 1405 | '3des-ctr': [['d0.52']], 1406 | 'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], 1407 | 'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], 1408 | 'twofish128-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], 1409 | 'twofish256-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]], 1410 | 'twofish128-ctr': [['d2015.68']], 1411 | 'twofish256-ctr': [['d2015.68']], 1412 | 'cast128-cbc': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]], 1413 | 'arcfour': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], 1414 | 'arcfour128': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], 1415 | 'arcfour256': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]], 1416 | 'aes128-cbc': [['2.3.0,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], 1417 | 'aes192-cbc': [['2.3.0,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], 1418 | 'aes256-cbc': [['2.3.0,d0.47,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]], 1419 | 'rijndael128-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], 1420 | 'rijndael192-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], 1421 | 'rijndael256-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]], 1422 | 'rijndael-cbc@lysator.liu.se': [['2.3.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE]], 1423 | 'aes128-ctr': [['3.7,d0.52,l10.4.1']], 1424 | 'aes192-ctr': [['3.7,l10.4.1']], 1425 | 'aes256-ctr': [['3.7,d0.52,l10.4.1']], 1426 | 'aes128-gcm@openssh.com': [['6.2']], 1427 | 'aes256-gcm@openssh.com': [['6.2']], 1428 | 'chacha20-poly1305@openssh.com': [['6.5'], [], [], [INFO_OPENSSH69_CHACHA]], 1429 | }, 1430 | 'mac': { 1431 | 'none': [['d2013.56'], [FAIL_PLAINTEXT]], 1432 | 'hmac-sha1': [['2.1.0,d0.28,l10.2'], [], [WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], 1433 | 'hmac-sha1-96': [['2.5.0,d0.47', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], 1434 | 'hmac-sha2-256': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], 1435 | 'hmac-sha2-256-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], 1436 | 'hmac-sha2-512': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]], 1437 | 'hmac-sha2-512-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]], 1438 | 'hmac-md5': [['2.1.0,d0.28', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], 1439 | 'hmac-md5-96': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]], 1440 | 'hmac-ripemd160': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], 1441 | 'hmac-ripemd160@openssh.com': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]], 1442 | 'umac-64@openssh.com': [['4.7'], [], [WARN_ENCRYPT_AND_MAC, WARN_TAG_SIZE]], 1443 | 'umac-128@openssh.com': [['6.2'], [], [WARN_ENCRYPT_AND_MAC]], 1444 | 'hmac-sha1-etm@openssh.com': [['6.2'], [], [WARN_HASH_WEAK]], 1445 | 'hmac-sha1-96-etm@openssh.com': [['6.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]], 1446 | 'hmac-sha2-256-etm@openssh.com': [['6.2']], 1447 | 'hmac-sha2-512-etm@openssh.com': [['6.2']], 1448 | 'hmac-md5-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], 1449 | 'hmac-md5-96-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]], 1450 | 'hmac-ripemd160-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY]], 1451 | 'umac-64-etm@openssh.com': [['6.2'], [], [WARN_TAG_SIZE]], 1452 | 'umac-128-etm@openssh.com': [['6.2']], 1453 | } 1454 | } # type: Dict[str, Dict[str, List[List[str]]]] 1455 | 1456 | 1457 | def get_ssh_version(version_desc): 1458 | # type: (str) -> Tuple[str, str] 1459 | if version_desc.startswith('d'): 1460 | return (SSH.Product.DropbearSSH, version_desc[1:]) 1461 | elif version_desc.startswith('l1'): 1462 | return (SSH.Product.LibSSH, version_desc[2:]) 1463 | else: 1464 | return (SSH.Product.OpenSSH, version_desc) 1465 | 1466 | 1467 | def get_alg_timeframe(versions, for_server=True, result=None): 1468 | # type: (List[str], bool, Optional[Dict[str, List[Optional[str]]]]) -> Dict[str, List[Optional[str]]] 1469 | result = result or {} 1470 | vlen = len(versions) 1471 | for i in range(3): 1472 | if i > vlen - 1: 1473 | if i == 2 and vlen > 1: 1474 | cversions = versions[1] 1475 | else: 1476 | continue 1477 | else: 1478 | cversions = versions[i] 1479 | if cversions is None: 1480 | continue 1481 | for v in cversions.split(','): 1482 | ssh_prefix, ssh_version = get_ssh_version(v) 1483 | if not ssh_version: 1484 | continue 1485 | if ssh_version.endswith('C'): 1486 | if for_server: 1487 | continue 1488 | ssh_version = ssh_version[:-1] 1489 | if ssh_prefix not in result: 1490 | result[ssh_prefix] = [None, None, None] 1491 | prev, push = result[ssh_prefix][i], False 1492 | if prev is None: 1493 | push = True 1494 | elif i == 0 and prev < ssh_version: 1495 | push = True 1496 | elif i > 0 and prev > ssh_version: 1497 | push = True 1498 | if push: 1499 | result[ssh_prefix][i] = ssh_version 1500 | return result 1501 | 1502 | 1503 | def get_ssh_timeframe(alg_pairs, for_server=True): 1504 | # type: (List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]], bool) -> Dict[str, List[Optional[str]]] 1505 | timeframe = {} # type: Dict[str, List[Optional[str]]] 1506 | for alg_pair in alg_pairs: 1507 | alg_db = alg_pair[1] 1508 | for alg_set in alg_pair[2]: 1509 | alg_type, alg_list = alg_set 1510 | for alg_name in alg_list: 1511 | alg_name_native = utils.to_ntext(alg_name) 1512 | alg_desc = alg_db[alg_type].get(alg_name_native) 1513 | if alg_desc is None: 1514 | continue 1515 | versions = alg_desc[0] 1516 | timeframe = get_alg_timeframe(versions, for_server, timeframe) 1517 | return timeframe 1518 | 1519 | 1520 | def get_alg_since_text(versions): 1521 | # type: (List[str]) -> text_type 1522 | tv = [] 1523 | if len(versions) == 0 or versions[0] is None: 1524 | return None 1525 | for v in versions[0].split(','): 1526 | ssh_prefix, ssh_version = get_ssh_version(v) 1527 | if not ssh_version: 1528 | continue 1529 | if ssh_prefix in [SSH.Product.LibSSH]: 1530 | continue 1531 | if ssh_version.endswith('C'): 1532 | ssh_version = '{0} (client only)'.format(ssh_version[:-1]) 1533 | tv.append('{0} {1}'.format(ssh_prefix, ssh_version)) 1534 | if len(tv) == 0: 1535 | return None 1536 | return 'available since ' + ', '.join(tv).rstrip(', ') 1537 | 1538 | 1539 | def get_alg_pairs(kex, pkm): 1540 | # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]] 1541 | alg_pairs = [] 1542 | if pkm is not None: 1543 | alg_pairs.append((1, SSH1.KexDB.ALGORITHMS, 1544 | [('key', [u'ssh-rsa1']), 1545 | ('enc', pkm.supported_ciphers), 1546 | ('aut', pkm.supported_authentications)])) 1547 | if kex is not None: 1548 | alg_pairs.append((2, KexDB.ALGORITHMS, 1549 | [('kex', kex.kex_algorithms), 1550 | ('key', kex.key_algorithms), 1551 | ('enc', kex.server.encryption), 1552 | ('mac', kex.server.mac)])) 1553 | return alg_pairs 1554 | 1555 | 1556 | def get_alg_recommendations(software, kex, pkm, for_server=True): 1557 | # type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, bool) -> Tuple[SSH.Software, Dict[int, Dict[str, Dict[str, Dict[str, int]]]]] 1558 | # pylint: disable=too-many-locals,too-many-statements 1559 | alg_pairs = get_alg_pairs(kex, pkm) 1560 | vproducts = [SSH.Product.OpenSSH, 1561 | SSH.Product.DropbearSSH, 1562 | SSH.Product.LibSSH] 1563 | if software is not None: 1564 | if software.product not in vproducts: 1565 | software = None 1566 | if software is None: 1567 | ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server) 1568 | for product in vproducts: 1569 | if product not in ssh_timeframe: 1570 | continue 1571 | version = ssh_timeframe[product][0] 1572 | if version is not None: 1573 | software = SSH.Software(None, product, version, None, None) 1574 | break 1575 | rec = {} # type: Dict[int, Dict[str, Dict[str, Dict[str, int]]]] 1576 | if software is None: 1577 | return software, rec 1578 | for alg_pair in alg_pairs: 1579 | sshv, alg_db = alg_pair[0], alg_pair[1] 1580 | rec[sshv] = {} 1581 | for alg_set in alg_pair[2]: 1582 | alg_type, alg_list = alg_set 1583 | if alg_type == 'aut': 1584 | continue 1585 | rec[sshv][alg_type] = {'add': {}, 'del': {}} 1586 | for n, alg_desc in alg_db[alg_type].items(): 1587 | if alg_type == 'key' and '-cert-' in n: 1588 | continue 1589 | versions = alg_desc[0] 1590 | if len(versions) == 0 or versions[0] is None: 1591 | continue 1592 | matches = False 1593 | for v in versions[0].split(','): 1594 | ssh_prefix, ssh_version = get_ssh_version(v) 1595 | if not ssh_version: 1596 | continue 1597 | if ssh_prefix != software.product: 1598 | continue 1599 | if ssh_version.endswith('C'): 1600 | if for_server: 1601 | continue 1602 | ssh_version = ssh_version[:-1] 1603 | if software.compare_version(ssh_version) < 0: 1604 | continue 1605 | matches = True 1606 | break 1607 | if not matches: 1608 | continue 1609 | adl, faults = len(alg_desc), 0 1610 | for i in range(1, 3): 1611 | if not adl > i: 1612 | continue 1613 | fc = len(alg_desc[i]) 1614 | if fc > 0: 1615 | faults += pow(10, 2 - i) * fc 1616 | if n not in alg_list: 1617 | if faults > 0: 1618 | continue 1619 | rec[sshv][alg_type]['add'][n] = 0 1620 | else: 1621 | if faults == 0: 1622 | continue 1623 | if n == 'diffie-hellman-group-exchange-sha256': 1624 | if software.compare_version('7.3') < 0: 1625 | continue 1626 | rec[sshv][alg_type]['del'][n] = faults 1627 | add_count = len(rec[sshv][alg_type]['add']) 1628 | del_count = len(rec[sshv][alg_type]['del']) 1629 | new_alg_count = len(alg_list) + add_count - del_count 1630 | if new_alg_count < 1 and del_count > 0: 1631 | mf = min(rec[sshv][alg_type]['del'].values()) 1632 | new_del = {} 1633 | for k, cf in rec[sshv][alg_type]['del'].items(): 1634 | if cf != mf: 1635 | new_del[k] = cf 1636 | if del_count != len(new_del): 1637 | rec[sshv][alg_type]['del'] = new_del 1638 | new_alg_count += del_count - len(new_del) 1639 | if new_alg_count < 1: 1640 | del rec[sshv][alg_type] 1641 | else: 1642 | if add_count == 0: 1643 | del rec[sshv][alg_type]['add'] 1644 | if del_count == 0: 1645 | del rec[sshv][alg_type]['del'] 1646 | if len(rec[sshv][alg_type]) == 0: 1647 | del rec[sshv][alg_type] 1648 | if len(rec[sshv]) == 0: 1649 | del rec[sshv] 1650 | return software, rec 1651 | 1652 | 1653 | def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0): 1654 | # type: (str, Dict[str, Dict[str, List[List[str]]]], str, List[text_type], int) -> None 1655 | with OutputBuffer() as obuf: 1656 | for algorithm in algorithms: 1657 | output_algorithm(alg_db, alg_type, algorithm, maxlen) 1658 | if len(obuf) > 0: 1659 | out.head('# ' + title) 1660 | obuf.flush() 1661 | out.sep() 1662 | 1663 | 1664 | def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0): 1665 | # type: (Dict[str, Dict[str, List[List[str]]]], str, text_type, int) -> None 1666 | prefix = '(' + alg_type + ') ' 1667 | if alg_max_len == 0: 1668 | alg_max_len = len(alg_name) 1669 | padding = '' if out.batch else ' ' * (alg_max_len - len(alg_name)) 1670 | texts = [] 1671 | if len(alg_name.strip()) == 0: 1672 | return 1673 | alg_name_native = utils.to_ntext(alg_name) 1674 | if alg_name_native in alg_db[alg_type]: 1675 | alg_desc = alg_db[alg_type][alg_name_native] 1676 | ldesc = len(alg_desc) 1677 | for idx, level in enumerate(['fail', 'warn', 'info']): 1678 | if level == 'info': 1679 | versions = alg_desc[0] 1680 | since_text = get_alg_since_text(versions) 1681 | if since_text: 1682 | texts.append((level, since_text)) 1683 | idx = idx + 1 1684 | if ldesc > idx: 1685 | for t in alg_desc[idx]: 1686 | texts.append((level, t)) 1687 | if len(texts) == 0: 1688 | texts.append(('info', '')) 1689 | else: 1690 | texts.append(('warn', 'unknown algorithm')) 1691 | first = True 1692 | for (level, text) in texts: 1693 | f = getattr(out, level) 1694 | text = '[' + level + '] ' + text 1695 | if first: 1696 | if first and level == 'info': 1697 | f = out.good 1698 | f(prefix + alg_name + padding + ' -- ' + text) 1699 | first = False 1700 | else: 1701 | if out.verbose: 1702 | f(prefix + alg_name + padding + ' -- ' + text) 1703 | else: 1704 | f(' ' * len(prefix + alg_name) + padding + ' `- ' + text) 1705 | 1706 | 1707 | def output_compatibility(kex, pkm, for_server=True): 1708 | # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool) -> None 1709 | alg_pairs = get_alg_pairs(kex, pkm) 1710 | ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server) 1711 | vp = 1 if for_server else 2 1712 | comp_text = [] 1713 | for sshd_name in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]: 1714 | if sshd_name not in ssh_timeframe: 1715 | continue 1716 | v = ssh_timeframe[sshd_name] 1717 | if v[vp] is None: 1718 | comp_text.append('{0} {1}+'.format(sshd_name, v[0])) 1719 | elif v[0] == v[vp]: 1720 | comp_text.append('{0} {1}'.format(sshd_name, v[0])) 1721 | else: 1722 | if v[vp] < v[0]: 1723 | tfmt = '{0} {1}+ (some functionality from {2})' 1724 | else: 1725 | tfmt = '{0} {1}-{2}' 1726 | comp_text.append(tfmt.format(sshd_name, v[0], v[vp])) 1727 | if len(comp_text) > 0: 1728 | out.good('(gen) compatibility: ' + ', '.join(comp_text)) 1729 | 1730 | 1731 | def output_security_sub(sub, software, padlen): 1732 | # type: (str, SSH.Software, int) -> None 1733 | secdb = SSH.Security.CVE if sub == 'cve' else SSH.Security.TXT 1734 | if software is None or software.product not in secdb: 1735 | return 1736 | for line in secdb[software.product]: 1737 | vfrom, vtill = line[0:2] # type: str, str 1738 | if not software.between_versions(vfrom, vtill): 1739 | continue 1740 | target, name = line[2:4] # type: int, str 1741 | is_server, is_client = target & 1 == 1, target & 2 == 2 1742 | is_local = target & 4 == 4 1743 | if not is_server: 1744 | continue 1745 | p = '' if out.batch else ' ' * (padlen - len(name)) 1746 | if sub == 'cve': 1747 | cvss, descr = line[4:6] # type: float, str 1748 | out.fail('(cve) {0}{1} -- ({2}) {3}'.format(name, p, cvss, descr)) 1749 | else: 1750 | descr = line[4] 1751 | out.fail('(sec) {0}{1} -- {2}'.format(name, p, descr)) 1752 | 1753 | 1754 | def output_security(banner, padlen): 1755 | # type: (SSH.Banner, int) -> None 1756 | with OutputBuffer() as obuf: 1757 | if banner: 1758 | software = SSH.Software.parse(banner) 1759 | output_security_sub('cve', software, padlen) 1760 | output_security_sub('txt', software, padlen) 1761 | if len(obuf) > 0: 1762 | out.head('# security') 1763 | obuf.flush() 1764 | out.sep() 1765 | 1766 | 1767 | def output_fingerprint(kex, pkm, sha256=True, padlen=0): 1768 | # type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool, int) -> None 1769 | with OutputBuffer() as obuf: 1770 | fps = [] 1771 | if pkm is not None: 1772 | name = 'ssh-rsa1' 1773 | fp = SSH.Fingerprint(pkm.host_key_fingerprint_data) 1774 | bits = pkm.host_key_bits 1775 | fps.append((name, fp, bits)) 1776 | for fpp in fps: 1777 | name, fp, bits = fpp 1778 | fpo = fp.sha256 if sha256 else fp.md5 1779 | p = '' if out.batch else ' ' * (padlen - len(name)) 1780 | out.good('(fin) {0}{1} -- {2} {3}'.format(name, p, bits, fpo)) 1781 | if len(obuf) > 0: 1782 | out.head('# fingerprints') 1783 | obuf.flush() 1784 | out.sep() 1785 | 1786 | 1787 | def output_recommendations(software, kex, pkm, padlen=0): 1788 | # type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, int) -> None 1789 | for_server = True 1790 | with OutputBuffer() as obuf: 1791 | software, alg_rec = get_alg_recommendations(software, kex, pkm, for_server) 1792 | for sshv in range(2, 0, -1): 1793 | if sshv not in alg_rec: 1794 | continue 1795 | for alg_type in ['kex', 'key', 'enc', 'mac']: 1796 | if alg_type not in alg_rec[sshv]: 1797 | continue 1798 | for action in ['del', 'add']: 1799 | if action not in alg_rec[sshv][alg_type]: 1800 | continue 1801 | for name in alg_rec[sshv][alg_type][action]: 1802 | p = '' if out.batch else ' ' * (padlen - len(name)) 1803 | if action == 'del': 1804 | an, sg, fn = 'remove', '-', out.warn 1805 | if alg_rec[sshv][alg_type][action][name] >= 10: 1806 | fn = out.fail 1807 | else: 1808 | an, sg, fn = 'append', '+', out.good 1809 | b = '(SSH{0})'.format(sshv) if sshv == 1 else '' 1810 | fm = '(rec) {0}{1}{2}-- {3} algorithm to {4} {5}' 1811 | fn(fm.format(sg, name, p, alg_type, an, b)) 1812 | if len(obuf) > 0: 1813 | title = '(for {0})'.format(software.display(False)) if software else '' 1814 | out.head('# algorithm recommendations {0}'.format(title)) 1815 | obuf.flush() 1816 | out.sep() 1817 | 1818 | 1819 | def output(banner, header, kex=None, pkm=None): 1820 | # type: (Optional[SSH.Banner], List[text_type], Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> None 1821 | sshv = 1 if pkm else 2 1822 | with OutputBuffer() as obuf: 1823 | if len(header) > 0: 1824 | out.info('(gen) header: ' + '\n'.join(header)) 1825 | if banner is not None: 1826 | out.good('(gen) banner: {0}'.format(banner)) 1827 | if not banner.valid_ascii: 1828 | # NOTE: RFC 4253, Section 4.2 1829 | out.warn('(gen) banner contains non-printable ASCII') 1830 | if sshv == 1 or banner.protocol[0] == 1: 1831 | out.fail('(gen) protocol SSH1 enabled') 1832 | software = SSH.Software.parse(banner) 1833 | if software is not None: 1834 | out.good('(gen) software: {0}'.format(software)) 1835 | else: 1836 | software = None 1837 | output_compatibility(kex, pkm) 1838 | if kex is not None: 1839 | compressions = [x for x in kex.server.compression if x != 'none'] 1840 | if len(compressions) > 0: 1841 | cmptxt = 'enabled ({0})'.format(', '.join(compressions)) 1842 | else: 1843 | cmptxt = 'disabled' 1844 | out.good('(gen) compression: {0}'.format(cmptxt)) 1845 | if len(obuf) > 0: 1846 | out.head('# general') 1847 | obuf.flush() 1848 | out.sep() 1849 | ml, maxlen = lambda l: max(len(i) for i in l), 0 1850 | if pkm is not None: 1851 | maxlen = max(ml(pkm.supported_ciphers), 1852 | ml(pkm.supported_authentications), 1853 | maxlen) 1854 | if kex is not None: 1855 | maxlen = max(ml(kex.kex_algorithms), 1856 | ml(kex.key_algorithms), 1857 | ml(kex.server.encryption), 1858 | ml(kex.server.mac), 1859 | maxlen) 1860 | maxlen += 1 1861 | output_security(banner, maxlen) 1862 | if pkm is not None: 1863 | adb = SSH1.KexDB.ALGORITHMS 1864 | ciphers = pkm.supported_ciphers 1865 | auths = pkm.supported_authentications 1866 | title, atype = 'SSH1 host-key algorithms', 'key' 1867 | output_algorithms(title, adb, atype, ['ssh-rsa1'], maxlen) 1868 | title, atype = 'SSH1 encryption algorithms (ciphers)', 'enc' 1869 | output_algorithms(title, adb, atype, ciphers, maxlen) 1870 | title, atype = 'SSH1 authentication types', 'aut' 1871 | output_algorithms(title, adb, atype, auths, maxlen) 1872 | if kex is not None: 1873 | adb = KexDB.ALGORITHMS 1874 | title, atype = 'key exchange algorithms', 'kex' 1875 | output_algorithms(title, adb, atype, kex.kex_algorithms, maxlen) 1876 | title, atype = 'host-key algorithms', 'key' 1877 | output_algorithms(title, adb, atype, kex.key_algorithms, maxlen) 1878 | title, atype = 'encryption algorithms (ciphers)', 'enc' 1879 | output_algorithms(title, adb, atype, kex.server.encryption, maxlen) 1880 | title, atype = 'message authentication code algorithms', 'mac' 1881 | output_algorithms(title, adb, atype, kex.server.mac, maxlen) 1882 | output_recommendations(software, kex, pkm, maxlen) 1883 | output_fingerprint(kex, pkm, True, maxlen) 1884 | 1885 | 1886 | class Utils(object): 1887 | @classmethod 1888 | def _type_err(cls, v, target): 1889 | # type: (Any, text_type) -> TypeError 1890 | return TypeError('cannot convert {0} to {1}'.format(type(v), target)) 1891 | 1892 | @classmethod 1893 | def to_bytes(cls, v, enc='utf-8'): 1894 | # type: (Union[binary_type, text_type], str) -> binary_type 1895 | if isinstance(v, binary_type): 1896 | return v 1897 | elif isinstance(v, text_type): 1898 | return v.encode(enc) 1899 | raise cls._type_err(v, 'bytes') 1900 | 1901 | @classmethod 1902 | def to_utext(cls, v, enc='utf-8'): 1903 | # type: (Union[text_type, binary_type], str) -> text_type 1904 | if isinstance(v, text_type): 1905 | return v 1906 | elif isinstance(v, binary_type): 1907 | return v.decode(enc) 1908 | raise cls._type_err(v, 'unicode text') 1909 | 1910 | @classmethod 1911 | def to_ntext(cls, v, enc='utf-8'): 1912 | # type: (Union[text_type, binary_type], str) -> str 1913 | if isinstance(v, str): 1914 | return v 1915 | elif isinstance(v, text_type): 1916 | return v.encode(enc) 1917 | elif isinstance(v, binary_type): 1918 | return v.decode(enc) 1919 | raise cls._type_err(v, 'native text') 1920 | 1921 | @classmethod 1922 | def is_ascii(cls, v): 1923 | # type: (Union[text_type, str]) -> bool 1924 | try: 1925 | if isinstance(v, (text_type, str)): 1926 | v.encode('ascii') 1927 | return True 1928 | except UnicodeEncodeError: 1929 | pass 1930 | return False 1931 | 1932 | @classmethod 1933 | def to_ascii(cls, v, errors='replace'): 1934 | # type: (Union[text_type, str], str) -> str 1935 | if isinstance(v, (text_type, str)): 1936 | return cls.to_ntext(v.encode('ascii', errors)) 1937 | raise cls._type_err(v, 'ascii') 1938 | 1939 | @classmethod 1940 | def unique_seq(cls, seq): 1941 | # type: (Sequence[Any]) -> Sequence[Any] 1942 | seen = set() # type: Set[Any] 1943 | 1944 | def _seen_add(x): 1945 | # type: (Any) -> bool 1946 | seen.add(x) 1947 | return False 1948 | 1949 | if isinstance(seq, tuple): 1950 | return tuple(x for x in seq if x not in seen and not _seen_add(x)) 1951 | else: 1952 | return [x for x in seq if x not in seen and not _seen_add(x)] 1953 | 1954 | @staticmethod 1955 | def parse_int(v): 1956 | # type: (Any) -> int 1957 | try: 1958 | return int(v) 1959 | except: # pylint: disable=bare-except 1960 | return 0 1961 | 1962 | 1963 | def audit(aconf, sshv=None): 1964 | # type: (AuditConf, Optional[int]) -> None 1965 | out.batch = aconf.batch 1966 | out.colors = aconf.colors 1967 | out.verbose = aconf.verbose 1968 | out.minlevel = aconf.minlevel 1969 | s = SSH.Socket(aconf.host, aconf.port) 1970 | s.connect(aconf.ipvo) 1971 | if sshv is None: 1972 | sshv = 2 if aconf.ssh2 else 1 1973 | err = None 1974 | banner, header = s.get_banner(sshv) 1975 | if banner is None: 1976 | err = '[exception] did not receive banner.' 1977 | if err is None: 1978 | packet_type, payload = s.read_packet(sshv) 1979 | if packet_type < 0: 1980 | try: 1981 | payload_txt = payload.decode('utf-8') if payload else u'empty' 1982 | except UnicodeDecodeError: 1983 | payload_txt = u'"{0}"'.format(repr(payload).lstrip('b')[1:-1]) 1984 | if payload_txt == u'Protocol major versions differ.': 1985 | if sshv == 2 and aconf.ssh1: 1986 | audit(aconf, 1) 1987 | return 1988 | err = '[exception] error reading packet ({0})'.format(payload_txt) 1989 | else: 1990 | err_pair = None 1991 | if sshv == 1 and packet_type != SSH.Protocol.SMSG_PUBLIC_KEY: 1992 | err_pair = ('SMSG_PUBLIC_KEY', SSH.Protocol.SMSG_PUBLIC_KEY) 1993 | elif sshv == 2 and packet_type != SSH.Protocol.MSG_KEXINIT: 1994 | err_pair = ('MSG_KEXINIT', SSH.Protocol.MSG_KEXINIT) 1995 | if err_pair is not None: 1996 | fmt = '[exception] did not receive {0} ({1}), ' + \ 1997 | 'instead received unknown message ({2})' 1998 | err = fmt.format(err_pair[0], err_pair[1], packet_type) 1999 | if err: 2000 | output(banner, header) 2001 | out.fail(err) 2002 | sys.exit(1) 2003 | if sshv == 1: 2004 | pkm = SSH1.PublicKeyMessage.parse(payload) 2005 | output(banner, header, pkm=pkm) 2006 | elif sshv == 2: 2007 | kex = SSH2.Kex.parse(payload) 2008 | output(banner, header, kex=kex) 2009 | 2010 | 2011 | utils = Utils() 2012 | out = Output() 2013 | if __name__ == '__main__': # pragma: nocover 2014 | conf = AuditConf.from_cmdline(sys.argv[1:], usage) 2015 | audit(conf) 2016 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import io 5 | import sys 6 | import socket 7 | import pytest 8 | 9 | 10 | if sys.version_info[0] == 2: 11 | import StringIO # pylint: disable=import-error 12 | StringIO = StringIO.StringIO 13 | else: 14 | StringIO = io.StringIO 15 | 16 | 17 | @pytest.fixture(scope='module') 18 | def ssh_audit(): 19 | __rdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') 20 | sys.path.append(os.path.abspath(__rdir)) 21 | return __import__('ssh-audit') 22 | 23 | 24 | # pylint: disable=attribute-defined-outside-init 25 | class _OutputSpy(list): 26 | def begin(self): 27 | self.__out = StringIO() 28 | self.__old_stdout = sys.stdout 29 | sys.stdout = self.__out 30 | 31 | def flush(self): 32 | lines = self.__out.getvalue().splitlines() 33 | sys.stdout = self.__old_stdout 34 | self.__out = None 35 | return lines 36 | 37 | 38 | @pytest.fixture(scope='module') 39 | def output_spy(): 40 | return _OutputSpy() 41 | 42 | 43 | class _VirtualSocket(object): 44 | def __init__(self): 45 | self.sock_address = ('127.0.0.1', 0) 46 | self.peer_address = None 47 | self._connected = False 48 | self.timeout = -1.0 49 | self.rdata = [] 50 | self.sdata = [] 51 | self.errors = {} 52 | 53 | def _check_err(self, method): 54 | method_error = self.errors.get(method) 55 | if method_error: 56 | raise method_error 57 | 58 | def connect(self, address): 59 | return self._connect(address, False) 60 | 61 | def _connect(self, address, ret=True): 62 | self.peer_address = address 63 | self._connected = True 64 | self._check_err('connect') 65 | return self if ret else None 66 | 67 | def settimeout(self, timeout): 68 | self.timeout = timeout 69 | 70 | def gettimeout(self): 71 | return self.timeout 72 | 73 | def getpeername(self): 74 | if self.peer_address is None or not self._connected: 75 | raise socket.error(57, 'Socket is not connected') 76 | return self.peer_address 77 | 78 | def getsockname(self): 79 | return self.sock_address 80 | 81 | def bind(self, address): 82 | self.sock_address = address 83 | 84 | def listen(self, backlog): 85 | pass 86 | 87 | def accept(self): 88 | # pylint: disable=protected-access 89 | conn = _VirtualSocket() 90 | conn.sock_address = self.sock_address 91 | conn.peer_address = ('127.0.0.1', 0) 92 | conn._connected = True 93 | return conn, conn.peer_address 94 | 95 | def recv(self, bufsize, flags=0): 96 | # pylint: disable=unused-argument 97 | if not self._connected: 98 | raise socket.error(54, 'Connection reset by peer') 99 | if not len(self.rdata) > 0: 100 | return b'' 101 | data = self.rdata.pop(0) 102 | if isinstance(data, Exception): 103 | raise data 104 | return data 105 | 106 | def send(self, data): 107 | if self.peer_address is None or not self._connected: 108 | raise socket.error(32, 'Broken pipe') 109 | self._check_err('send') 110 | self.sdata.append(data) 111 | 112 | 113 | @pytest.fixture() 114 | def virtual_socket(monkeypatch): 115 | vsocket = _VirtualSocket() 116 | 117 | # pylint: disable=unused-argument 118 | def _socket(family=socket.AF_INET, 119 | socktype=socket.SOCK_STREAM, 120 | proto=0, 121 | fileno=None): 122 | return vsocket 123 | 124 | def _cc(address, timeout=0, source_address=None): 125 | # pylint: disable=protected-access 126 | return vsocket._connect(address, True) 127 | 128 | monkeypatch.setattr(socket, 'create_connection', _cc) 129 | monkeypatch.setattr(socket, 'socket', _socket) 130 | return vsocket 131 | -------------------------------------------------------------------------------- /test/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | _cdir=$(cd -- "$(dirname "$0")" && pwd) 3 | type py.test > /dev/null 2>&1 4 | if [ $? -ne 0 ]; then 5 | echo "err: py.test (Python testing framework) not found." 6 | exit 1 7 | fi 8 | cd -- "${_cdir}/.." 9 | mkdir -p html 10 | py.test -v --cov-report=html:html/coverage --cov=ssh-audit test 11 | -------------------------------------------------------------------------------- /test/mypy-py2.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | _cdir=$(cd -- "$(dirname "$0")" && pwd) 3 | type mypy > /dev/null 2>&1 4 | if [ $? -ne 0 ]; then 5 | echo "err: mypy (Optional Static Typing for Python) not found." 6 | exit 1 7 | fi 8 | _htmldir="${_cdir}/../html/mypy-py2" 9 | mkdir -p "${_htmldir}" 10 | mypy --python-version 2.7 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" 11 | -------------------------------------------------------------------------------- /test/mypy-py3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | _cdir=$(cd -- "$(dirname "$0")" && pwd) 3 | type mypy > /dev/null 2>&1 4 | if [ $? -ne 0 ]; then 5 | echo "err: mypy (Optional Static Typing for Python) not found." 6 | exit 1 7 | fi 8 | _htmldir="${_cdir}/../html/mypy-py3" 9 | mkdir -p "${_htmldir}" 10 | mypy --python-version 3.5 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py" 11 | -------------------------------------------------------------------------------- /test/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | silent_imports = True 3 | disallow_untyped_calls = True 4 | disallow_untyped_defs = True 5 | check_untyped_defs = True 6 | disallow-subclassing-any = True 7 | warn-incomplete-stub = True 8 | warn-redundant-casts = True 9 | 10 | -------------------------------------------------------------------------------- /test/prospector.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | _cdir=$(cd -- "$(dirname "$0")" && pwd) 3 | type prospector > /dev/null 2>&1 4 | if [ $? -ne 0 ]; then 5 | echo "err: prospector (Python Static Analysis) not found." 6 | exit 1 7 | fi 8 | if [ X"$1" == X"" ]; then 9 | _file="${_cdir}/../ssh-audit.py" 10 | else 11 | _file="$1" 12 | fi 13 | prospector -E --profile-path "${_cdir}" -P prospector "${_file}" 14 | -------------------------------------------------------------------------------- /test/prospector.yml: -------------------------------------------------------------------------------- 1 | strictness: veryhigh 2 | doc-warnings: false 3 | 4 | pylint: 5 | disable: 6 | - multiple-imports 7 | - invalid-name 8 | - trailing-whitespace 9 | 10 | options: 11 | max-args: 8 # default: 5 12 | max-locals: 20 # default: 15 13 | max-returns: 6 14 | max-branches: 15 # default: 12 15 | max-statements: 60 # default: 50 16 | max-parents: 7 17 | max-attributes: 8 # default: 7 18 | min-public-methods: 1 # default: 2 19 | max-public-methods: 20 20 | max-bool-expr: 5 21 | max-nested-blocks: 6 # default: 5 22 | max-line-length: 80 # default: 100 23 | ignore-long-lines: ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?)$ 24 | max-module-lines: 2500 # default: 10000 25 | 26 | pep8: 27 | disable: 28 | - W191 # indentation contains tabs 29 | - W293 # blank line contains whitespace 30 | - E101 # indentation contains mixed spaces and tabs 31 | - E401 # multiple imports on one line 32 | - E501 # line too long 33 | - E221 # multiple spaces before operator 34 | 35 | pyflakes: 36 | disable: 37 | - F401 # module imported but unused 38 | - F821 # undefined name 39 | 40 | mccabe: 41 | options: 42 | max-complexity: 15 43 | -------------------------------------------------------------------------------- /test/test_auditconf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import pytest 4 | 5 | 6 | # pylint: disable=attribute-defined-outside-init 7 | class TestAuditConf(object): 8 | @pytest.fixture(autouse=True) 9 | def init(self, ssh_audit): 10 | self.AuditConf = ssh_audit.AuditConf 11 | self.usage = ssh_audit.usage 12 | 13 | @classmethod 14 | def _test_conf(cls, conf, **kwargs): 15 | options = { 16 | 'host': None, 17 | 'port': 22, 18 | 'ssh1': True, 19 | 'ssh2': True, 20 | 'batch': False, 21 | 'colors': True, 22 | 'verbose': False, 23 | 'minlevel': 'info', 24 | 'ipv4': True, 25 | 'ipv6': True, 26 | 'ipvo': () 27 | } 28 | for k, v in kwargs.items(): 29 | options[k] = v 30 | assert conf.host == options['host'] 31 | assert conf.port == options['port'] 32 | assert conf.ssh1 is options['ssh1'] 33 | assert conf.ssh2 is options['ssh2'] 34 | assert conf.batch is options['batch'] 35 | assert conf.colors is options['colors'] 36 | assert conf.verbose is options['verbose'] 37 | assert conf.minlevel == options['minlevel'] 38 | assert conf.ipv4 == options['ipv4'] 39 | assert conf.ipv6 == options['ipv6'] 40 | assert conf.ipvo == options['ipvo'] 41 | 42 | def test_audit_conf_defaults(self): 43 | conf = self.AuditConf() 44 | self._test_conf(conf) 45 | 46 | def test_audit_conf_booleans(self): 47 | conf = self.AuditConf() 48 | for p in ['ssh1', 'ssh2', 'batch', 'colors', 'verbose']: 49 | for v in [True, 1]: 50 | setattr(conf, p, v) 51 | assert getattr(conf, p) is True 52 | for v in [False, 0]: 53 | setattr(conf, p, v) 54 | assert getattr(conf, p) is False 55 | 56 | def test_audit_conf_port(self): 57 | conf = self.AuditConf() 58 | for port in [22, 2222]: 59 | conf.port = port 60 | assert conf.port == port 61 | for port in [-1, 0, 65536, 99999]: 62 | with pytest.raises(ValueError) as excinfo: 63 | conf.port = port 64 | excinfo.match(r'.*invalid port.*') 65 | 66 | def test_audit_conf_ipvo(self): 67 | # ipv4-only 68 | conf = self.AuditConf() 69 | conf.ipv4 = True 70 | assert conf.ipv4 is True 71 | assert conf.ipv6 is False 72 | assert conf.ipvo == (4,) 73 | # ipv6-only 74 | conf = self.AuditConf() 75 | conf.ipv6 = True 76 | assert conf.ipv4 is False 77 | assert conf.ipv6 is True 78 | assert conf.ipvo == (6,) 79 | # ipv4-only (by removing ipv6) 80 | conf = self.AuditConf() 81 | conf.ipv6 = False 82 | assert conf.ipv4 is True 83 | assert conf.ipv6 is False 84 | assert conf.ipvo == (4, ) 85 | # ipv6-only (by removing ipv4) 86 | conf = self.AuditConf() 87 | conf.ipv4 = False 88 | assert conf.ipv4 is False 89 | assert conf.ipv6 is True 90 | assert conf.ipvo == (6, ) 91 | # ipv4-preferred 92 | conf = self.AuditConf() 93 | conf.ipv4 = True 94 | conf.ipv6 = True 95 | assert conf.ipv4 is True 96 | assert conf.ipv6 is True 97 | assert conf.ipvo == (4, 6) 98 | # ipv6-preferred 99 | conf = self.AuditConf() 100 | conf.ipv6 = True 101 | conf.ipv4 = True 102 | assert conf.ipv4 is True 103 | assert conf.ipv6 is True 104 | assert conf.ipvo == (6, 4) 105 | # ipvo empty 106 | conf = self.AuditConf() 107 | conf.ipvo = () 108 | assert conf.ipv4 is True 109 | assert conf.ipv6 is True 110 | assert conf.ipvo == () 111 | # ipvo validation 112 | conf = self.AuditConf() 113 | conf.ipvo = (1, 2, 3, 4, 5, 6) 114 | assert conf.ipvo == (4, 6) 115 | conf.ipvo = (4, 4, 4, 6, 6) 116 | assert conf.ipvo == (4, 6) 117 | 118 | def test_audit_conf_minlevel(self): 119 | conf = self.AuditConf() 120 | for level in ['info', 'warn', 'fail']: 121 | conf.minlevel = level 122 | assert conf.minlevel == level 123 | for level in ['head', 'good', 'unknown', None]: 124 | with pytest.raises(ValueError) as excinfo: 125 | conf.minlevel = level 126 | excinfo.match(r'.*invalid level.*') 127 | 128 | def test_audit_conf_cmdline(self): 129 | # pylint: disable=too-many-statements 130 | c = lambda x: self.AuditConf.from_cmdline(x.split(), self.usage) # noqa 131 | with pytest.raises(SystemExit): 132 | conf = c('') 133 | with pytest.raises(SystemExit): 134 | conf = c('-x') 135 | with pytest.raises(SystemExit): 136 | conf = c('-h') 137 | with pytest.raises(SystemExit): 138 | conf = c('--help') 139 | with pytest.raises(SystemExit): 140 | conf = c(':') 141 | with pytest.raises(SystemExit): 142 | conf = c(':22') 143 | conf = c('localhost') 144 | self._test_conf(conf, host='localhost') 145 | conf = c('github.com') 146 | self._test_conf(conf, host='github.com') 147 | conf = c('localhost:2222') 148 | self._test_conf(conf, host='localhost', port=2222) 149 | conf = c('-p 2222 localhost') 150 | self._test_conf(conf, host='localhost', port=2222) 151 | with pytest.raises(SystemExit): 152 | conf = c('localhost:') 153 | with pytest.raises(SystemExit): 154 | conf = c('localhost:abc') 155 | with pytest.raises(SystemExit): 156 | conf = c('-p abc localhost') 157 | with pytest.raises(SystemExit): 158 | conf = c('localhost:-22') 159 | with pytest.raises(SystemExit): 160 | conf = c('-p -22 localhost') 161 | with pytest.raises(SystemExit): 162 | conf = c('localhost:99999') 163 | with pytest.raises(SystemExit): 164 | conf = c('-p 99999 localhost') 165 | conf = c('-1 localhost') 166 | self._test_conf(conf, host='localhost', ssh1=True, ssh2=False) 167 | conf = c('-2 localhost') 168 | self._test_conf(conf, host='localhost', ssh1=False, ssh2=True) 169 | conf = c('-12 localhost') 170 | self._test_conf(conf, host='localhost', ssh1=True, ssh2=True) 171 | conf = c('-4 localhost') 172 | self._test_conf(conf, host='localhost', ipv4=True, ipv6=False, ipvo=(4,)) 173 | conf = c('-6 localhost') 174 | self._test_conf(conf, host='localhost', ipv4=False, ipv6=True, ipvo=(6,)) 175 | conf = c('-46 localhost') 176 | self._test_conf(conf, host='localhost', ipv4=True, ipv6=True, ipvo=(4, 6)) 177 | conf = c('-64 localhost') 178 | self._test_conf(conf, host='localhost', ipv4=True, ipv6=True, ipvo=(6, 4)) 179 | conf = c('-b localhost') 180 | self._test_conf(conf, host='localhost', batch=True, verbose=True) 181 | conf = c('-n localhost') 182 | self._test_conf(conf, host='localhost', colors=False) 183 | conf = c('-v localhost') 184 | self._test_conf(conf, host='localhost', verbose=True) 185 | conf = c('-l info localhost') 186 | self._test_conf(conf, host='localhost', minlevel='info') 187 | conf = c('-l warn localhost') 188 | self._test_conf(conf, host='localhost', minlevel='warn') 189 | conf = c('-l fail localhost') 190 | self._test_conf(conf, host='localhost', minlevel='fail') 191 | with pytest.raises(SystemExit): 192 | conf = c('-l something localhost') 193 | -------------------------------------------------------------------------------- /test/test_banner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import pytest 4 | 5 | 6 | # pylint: disable=line-too-long,attribute-defined-outside-init 7 | class TestBanner(object): 8 | @pytest.fixture(autouse=True) 9 | def init(self, ssh_audit): 10 | self.ssh = ssh_audit.SSH 11 | 12 | def test_simple_banners(self): 13 | banner = lambda x: self.ssh.Banner.parse(x) # noqa 14 | b = banner('SSH-2.0-OpenSSH_7.3') 15 | assert b.protocol == (2, 0) 16 | assert b.software == 'OpenSSH_7.3' 17 | assert b.comments is None 18 | assert str(b) == 'SSH-2.0-OpenSSH_7.3' 19 | b = banner('SSH-1.99-Sun_SSH_1.1.3') 20 | assert b.protocol == (1, 99) 21 | assert b.software == 'Sun_SSH_1.1.3' 22 | assert b.comments is None 23 | assert str(b) == 'SSH-1.99-Sun_SSH_1.1.3' 24 | b = banner('SSH-1.5-Cisco-1.25') 25 | assert b.protocol == (1, 5) 26 | assert b.software == 'Cisco-1.25' 27 | assert b.comments is None 28 | assert str(b) == 'SSH-1.5-Cisco-1.25' 29 | 30 | def test_invalid_banners(self): 31 | b = lambda x: self.ssh.Banner.parse(x) # noqa 32 | assert b('Something') is None 33 | assert b('SSH-XXX-OpenSSH_7.3') is None 34 | 35 | def test_banners_with_spaces(self): 36 | b = lambda x: self.ssh.Banner.parse(x) # noqa 37 | s = 'SSH-2.0-OpenSSH_4.3p2' 38 | assert str(b('SSH-2.0-OpenSSH_4.3p2 ')) == s 39 | assert str(b('SSH-2.0- OpenSSH_4.3p2')) == s 40 | assert str(b('SSH-2.0- OpenSSH_4.3p2 ')) == s 41 | s = 'SSH-2.0-OpenSSH_4.3p2 Debian-9etch3 on i686-pc-linux-gnu' 42 | assert str(b('SSH-2.0- OpenSSH_4.3p2 Debian-9etch3 on i686-pc-linux-gnu')) == s 43 | assert str(b('SSH-2.0-OpenSSH_4.3p2 Debian-9etch3 on i686-pc-linux-gnu ')) == s 44 | assert str(b('SSH-2.0- OpenSSH_4.3p2 Debian-9etch3 on i686-pc-linux-gnu ')) == s 45 | 46 | def test_banners_without_software(self): 47 | b = lambda x: self.ssh.Banner.parse(x) # noqa 48 | assert b('SSH-2.0').protocol == (2, 0) 49 | assert b('SSH-2.0').software is None 50 | assert b('SSH-2.0').comments is None 51 | assert str(b('SSH-2.0')) == 'SSH-2.0' 52 | assert b('SSH-2.0-').protocol == (2, 0) 53 | assert b('SSH-2.0-').software == '' 54 | assert b('SSH-2.0-').comments is None 55 | assert str(b('SSH-2.0-')) == 'SSH-2.0-' 56 | 57 | def test_banners_with_comments(self): 58 | b = lambda x: self.ssh.Banner.parse(x) # noqa 59 | assert repr(b('SSH-2.0-OpenSSH_7.2p2 Ubuntu-1')) == '' 60 | assert repr(b('SSH-1.99-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3')) == '' 61 | assert repr(b('SSH-1.5-1.3.7 F-SECURE SSH')) == '' 62 | 63 | def test_banners_with_multiple_protocols(self): 64 | b = lambda x: self.ssh.Banner.parse(x) # noqa 65 | assert str(b('SSH-1.99-SSH-1.99-OpenSSH_3.6.1p2')) == 'SSH-1.99-OpenSSH_3.6.1p2' 66 | assert str(b('SSH-2.0-SSH-2.0-OpenSSH_4.3p2 Debian-9')) == 'SSH-2.0-OpenSSH_4.3p2 Debian-9' 67 | assert str(b('SSH-1.99-SSH-2.0-dropbear_0.5')) == 'SSH-1.99-dropbear_0.5' 68 | assert str(b('SSH-2.0-SSH-1.99-OpenSSH_4.2p1 SSH Secure Shell (non-commercial)')) == 'SSH-1.99-OpenSSH_4.2p1 SSH Secure Shell (non-commercial)' 69 | assert str(b('SSH-1.99-SSH-1.99-SSH-1.99-OpenSSH_3.9p1')) == 'SSH-1.99-OpenSSH_3.9p1' 70 | -------------------------------------------------------------------------------- /test/test_buffer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import pytest 5 | 6 | 7 | # pylint: disable=attribute-defined-outside-init,bad-whitespace 8 | class TestBuffer(object): 9 | @pytest.fixture(autouse=True) 10 | def init(self, ssh_audit): 11 | self.rbuf = ssh_audit.ReadBuf 12 | self.wbuf = ssh_audit.WriteBuf 13 | self.utf8rchar = b'\xef\xbf\xbd' 14 | 15 | @classmethod 16 | def _b(cls, v): 17 | v = re.sub(r'\s', '', v) 18 | data = [int(v[i * 2:i * 2 + 2], 16) for i in range(len(v) // 2)] 19 | return bytes(bytearray(data)) 20 | 21 | def test_unread(self): 22 | w = self.wbuf().write_byte(1).write_int(2).write_flush() 23 | r = self.rbuf(w) 24 | assert r.unread_len == 5 25 | r.read_byte() 26 | assert r.unread_len == 4 27 | r.read_int() 28 | assert r.unread_len == 0 29 | 30 | def test_byte(self): 31 | w = lambda x: self.wbuf().write_byte(x).write_flush() # noqa 32 | r = lambda x: self.rbuf(x).read_byte() # noqa 33 | tc = [(0x00, '00'), 34 | (0x01, '01'), 35 | (0x10, '10'), 36 | (0xff, 'ff')] 37 | for p in tc: 38 | assert w(p[0]) == self._b(p[1]) 39 | assert r(self._b(p[1])) == p[0] 40 | 41 | def test_bool(self): 42 | w = lambda x: self.wbuf().write_bool(x).write_flush() # noqa 43 | r = lambda x: self.rbuf(x).read_bool() # noqa 44 | tc = [(True, '01'), 45 | (False, '00')] 46 | for p in tc: 47 | assert w(p[0]) == self._b(p[1]) 48 | assert r(self._b(p[1])) == p[0] 49 | 50 | def test_int(self): 51 | w = lambda x: self.wbuf().write_int(x).write_flush() # noqa 52 | r = lambda x: self.rbuf(x).read_int() # noqa 53 | tc = [(0x00, '00 00 00 00'), 54 | (0x01, '00 00 00 01'), 55 | (0xabcd, '00 00 ab cd'), 56 | (0xffffffff, 'ff ff ff ff')] 57 | for p in tc: 58 | assert w(p[0]) == self._b(p[1]) 59 | assert r(self._b(p[1])) == p[0] 60 | 61 | def test_string(self): 62 | w = lambda x: self.wbuf().write_string(x).write_flush() # noqa 63 | r = lambda x: self.rbuf(x).read_string() # noqa 64 | tc = [(u'abc1', '00 00 00 04 61 62 63 31'), 65 | (b'abc2', '00 00 00 04 61 62 63 32')] 66 | for p in tc: 67 | v = p[0] 68 | assert w(v) == self._b(p[1]) 69 | if not isinstance(v, bytes): 70 | v = bytes(bytearray(v, 'utf-8')) 71 | assert r(self._b(p[1])) == v 72 | 73 | def test_list(self): 74 | w = lambda x: self.wbuf().write_list(x).write_flush() # noqa 75 | r = lambda x: self.rbuf(x).read_list() # noqa 76 | tc = [(['d', 'ef', 'ault'], '00 00 00 09 64 2c 65 66 2c 61 75 6c 74')] 77 | for p in tc: 78 | assert w(p[0]) == self._b(p[1]) 79 | assert r(self._b(p[1])) == p[0] 80 | 81 | def test_list_nonutf8(self): 82 | r = lambda x: self.rbuf(x).read_list() # noqa 83 | src = self._b('00 00 00 04 de ad be ef') 84 | dst = [(b'\xde\xad' + self.utf8rchar + self.utf8rchar).decode('utf-8')] 85 | assert r(src) == dst 86 | 87 | def test_line(self): 88 | w = lambda x: self.wbuf().write_line(x).write_flush() # noqa 89 | r = lambda x: self.rbuf(x).read_line() # noqa 90 | tc = [(u'example line', '65 78 61 6d 70 6c 65 20 6c 69 6e 65 0d 0a')] 91 | for p in tc: 92 | assert w(p[0]) == self._b(p[1]) 93 | assert r(self._b(p[1])) == p[0] 94 | 95 | def test_line_nonutf8(self): 96 | r = lambda x: self.rbuf(x).read_line() # noqa 97 | src = self._b('de ad be af') 98 | dst = (b'\xde\xad' + self.utf8rchar + self.utf8rchar).decode('utf-8') 99 | assert r(src) == dst 100 | 101 | def test_bitlen(self): 102 | # pylint: disable=protected-access 103 | class Py26Int(int): 104 | def bit_length(self): 105 | raise AttributeError 106 | assert self.wbuf._bitlength(42) == 6 107 | assert self.wbuf._bitlength(Py26Int(42)) == 6 108 | 109 | def test_mpint1(self): 110 | mpint1w = lambda x: self.wbuf().write_mpint1(x).write_flush() # noqa 111 | mpint1r = lambda x: self.rbuf(x).read_mpint1() # noqa 112 | tc = [(0x0, '00 00'), 113 | (0x1234, '00 0d 12 34'), 114 | (0x12345, '00 11 01 23 45'), 115 | (0xdeadbeef, '00 20 de ad be ef')] 116 | for p in tc: 117 | assert mpint1w(p[0]) == self._b(p[1]) 118 | assert mpint1r(self._b(p[1])) == p[0] 119 | 120 | def test_mpint2(self): 121 | mpint2w = lambda x: self.wbuf().write_mpint2(x).write_flush() # noqa 122 | mpint2r = lambda x: self.rbuf(x).read_mpint2() # noqa 123 | tc = [(0x0, '00 00 00 00'), 124 | (0x80, '00 00 00 02 00 80'), 125 | (0x9a378f9b2e332a7, '00 00 00 08 09 a3 78 f9 b2 e3 32 a7'), 126 | (-0x1234, '00 00 00 02 ed cc'), 127 | (-0xdeadbeef, '00 00 00 05 ff 21 52 41 11'), 128 | (-0x8000, '00 00 00 02 80 00'), 129 | (-0x80, '00 00 00 01 80')] 130 | for p in tc: 131 | assert mpint2w(p[0]) == self._b(p[1]) 132 | assert mpint2r(self._b(p[1])) == p[0] 133 | assert mpint2r(self._b('00 00 00 02 ff 80')) == -0x80 134 | -------------------------------------------------------------------------------- /test/test_errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import socket 4 | import pytest 5 | 6 | 7 | # pylint: disable=attribute-defined-outside-init 8 | class TestErrors(object): 9 | @pytest.fixture(autouse=True) 10 | def init(self, ssh_audit): 11 | self.AuditConf = ssh_audit.AuditConf 12 | self.audit = ssh_audit.audit 13 | 14 | def _conf(self): 15 | conf = self.AuditConf('localhost', 22) 16 | conf.colors = False 17 | conf.batch = True 18 | return conf 19 | 20 | def test_connection_refused(self, output_spy, virtual_socket): 21 | vsocket = virtual_socket 22 | vsocket.errors['connect'] = socket.error(61, 'Connection refused') 23 | output_spy.begin() 24 | with pytest.raises(SystemExit): 25 | self.audit(self._conf()) 26 | lines = output_spy.flush() 27 | assert len(lines) == 1 28 | assert 'Connection refused' in lines[-1] 29 | 30 | def test_connection_closed_before_banner(self, output_spy, virtual_socket): 31 | vsocket = virtual_socket 32 | vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) 33 | output_spy.begin() 34 | with pytest.raises(SystemExit): 35 | self.audit(self._conf()) 36 | lines = output_spy.flush() 37 | assert len(lines) == 1 38 | assert 'did not receive banner' in lines[-1] 39 | 40 | def test_connection_closed_after_header(self, output_spy, virtual_socket): 41 | vsocket = virtual_socket 42 | vsocket.rdata.append(b'header line 1\n') 43 | vsocket.rdata.append(b'header line 2\n') 44 | vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) 45 | output_spy.begin() 46 | with pytest.raises(SystemExit): 47 | self.audit(self._conf()) 48 | lines = output_spy.flush() 49 | assert len(lines) == 3 50 | assert 'did not receive banner' in lines[-1] 51 | 52 | def test_connection_closed_after_banner(self, output_spy, virtual_socket): 53 | vsocket = virtual_socket 54 | vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') 55 | vsocket.rdata.append(socket.error(54, 'Connection reset by peer')) 56 | output_spy.begin() 57 | with pytest.raises(SystemExit): 58 | self.audit(self._conf()) 59 | lines = output_spy.flush() 60 | assert len(lines) == 2 61 | assert 'error reading packet' in lines[-1] 62 | assert 'reset by peer' in lines[-1] 63 | 64 | def test_empty_data_after_banner(self, output_spy, virtual_socket): 65 | vsocket = virtual_socket 66 | vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') 67 | output_spy.begin() 68 | with pytest.raises(SystemExit): 69 | self.audit(self._conf()) 70 | lines = output_spy.flush() 71 | assert len(lines) == 2 72 | assert 'error reading packet' in lines[-1] 73 | assert 'empty' in lines[-1] 74 | 75 | def test_wrong_data_after_banner(self, output_spy, virtual_socket): 76 | vsocket = virtual_socket 77 | vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') 78 | vsocket.rdata.append(b'xxx\n') 79 | output_spy.begin() 80 | with pytest.raises(SystemExit): 81 | self.audit(self._conf()) 82 | lines = output_spy.flush() 83 | assert len(lines) == 2 84 | assert 'error reading packet' in lines[-1] 85 | assert 'xxx' in lines[-1] 86 | 87 | def test_non_ascii_banner(self, output_spy, virtual_socket): 88 | vsocket = virtual_socket 89 | vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\xc3\xbc\r\n') 90 | output_spy.begin() 91 | with pytest.raises(SystemExit): 92 | self.audit(self._conf()) 93 | lines = output_spy.flush() 94 | assert len(lines) == 3 95 | assert 'error reading packet' in lines[-1] 96 | assert 'ASCII' in lines[-2] 97 | assert lines[-3].endswith('SSH-2.0-ssh-audit-test?') 98 | 99 | def test_nonutf8_data_after_banner(self, output_spy, virtual_socket): 100 | vsocket = virtual_socket 101 | vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n') 102 | vsocket.rdata.append(b'\x81\xff\n') 103 | output_spy.begin() 104 | with pytest.raises(SystemExit): 105 | self.audit(self._conf()) 106 | lines = output_spy.flush() 107 | assert len(lines) == 2 108 | assert 'error reading packet' in lines[-1] 109 | assert '\\x81\\xff' in lines[-1] 110 | 111 | def test_protocol_mismatch_by_conf(self, output_spy, virtual_socket): 112 | vsocket = virtual_socket 113 | vsocket.rdata.append(b'SSH-1.3-ssh-audit-test\r\n') 114 | vsocket.rdata.append(b'Protocol major versions differ.\n') 115 | output_spy.begin() 116 | with pytest.raises(SystemExit): 117 | conf = self._conf() 118 | conf.ssh1, conf.ssh2 = True, False 119 | self.audit(conf) 120 | lines = output_spy.flush() 121 | assert len(lines) == 3 122 | assert 'error reading packet' in lines[-1] 123 | assert 'major versions differ' in lines[-1] 124 | -------------------------------------------------------------------------------- /test/test_output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | import pytest 5 | 6 | 7 | # pylint: disable=attribute-defined-outside-init 8 | class TestOutput(object): 9 | @pytest.fixture(autouse=True) 10 | def init(self, ssh_audit): 11 | self.Output = ssh_audit.Output 12 | self.OutputBuffer = ssh_audit.OutputBuffer 13 | 14 | def test_output_buffer_no_lines(self, output_spy): 15 | output_spy.begin() 16 | with self.OutputBuffer() as obuf: 17 | pass 18 | assert output_spy.flush() == [] 19 | output_spy.begin() 20 | with self.OutputBuffer() as obuf: 21 | pass 22 | obuf.flush() 23 | assert output_spy.flush() == [] 24 | 25 | def test_output_buffer_no_flush(self, output_spy): 26 | output_spy.begin() 27 | with self.OutputBuffer(): 28 | print(u'abc') 29 | assert output_spy.flush() == [] 30 | 31 | def test_output_buffer_flush(self, output_spy): 32 | output_spy.begin() 33 | with self.OutputBuffer() as obuf: 34 | print(u'abc') 35 | print() 36 | print(u'def') 37 | obuf.flush() 38 | assert output_spy.flush() == [u'abc', u'', u'def'] 39 | 40 | def test_output_defaults(self): 41 | out = self.Output() 42 | # default: on 43 | assert out.batch is False 44 | assert out.colors is True 45 | assert out.minlevel == 'info' 46 | 47 | def test_output_colors(self, output_spy): 48 | out = self.Output() 49 | # test without colors 50 | out.colors = False 51 | output_spy.begin() 52 | out.info('info color') 53 | assert output_spy.flush() == [u'info color'] 54 | output_spy.begin() 55 | out.head('head color') 56 | assert output_spy.flush() == [u'head color'] 57 | output_spy.begin() 58 | out.good('good color') 59 | assert output_spy.flush() == [u'good color'] 60 | output_spy.begin() 61 | out.warn('warn color') 62 | assert output_spy.flush() == [u'warn color'] 63 | output_spy.begin() 64 | out.fail('fail color') 65 | assert output_spy.flush() == [u'fail color'] 66 | if not out.colors_supported: 67 | return 68 | # test with colors 69 | out.colors = True 70 | output_spy.begin() 71 | out.info('info color') 72 | assert output_spy.flush() == [u'info color'] 73 | output_spy.begin() 74 | out.head('head color') 75 | assert output_spy.flush() == [u'\x1b[0;36mhead color\x1b[0m'] 76 | output_spy.begin() 77 | out.good('good color') 78 | assert output_spy.flush() == [u'\x1b[0;32mgood color\x1b[0m'] 79 | output_spy.begin() 80 | out.warn('warn color') 81 | assert output_spy.flush() == [u'\x1b[0;33mwarn color\x1b[0m'] 82 | output_spy.begin() 83 | out.fail('fail color') 84 | assert output_spy.flush() == [u'\x1b[0;31mfail color\x1b[0m'] 85 | 86 | def test_output_sep(self, output_spy): 87 | out = self.Output() 88 | output_spy.begin() 89 | out.sep() 90 | out.sep() 91 | out.sep() 92 | assert output_spy.flush() == [u'', u'', u''] 93 | 94 | def test_output_levels(self): 95 | out = self.Output() 96 | assert out.getlevel('info') == 0 97 | assert out.getlevel('good') == 0 98 | assert out.getlevel('warn') == 1 99 | assert out.getlevel('fail') == 2 100 | assert out.getlevel('unknown') > 2 101 | 102 | def test_output_minlevel_property(self): 103 | out = self.Output() 104 | out.minlevel = 'info' 105 | assert out.minlevel == 'info' 106 | out.minlevel = 'good' 107 | assert out.minlevel == 'info' 108 | out.minlevel = 'warn' 109 | assert out.minlevel == 'warn' 110 | out.minlevel = 'fail' 111 | assert out.minlevel == 'fail' 112 | out.minlevel = 'invalid level' 113 | assert out.minlevel == 'unknown' 114 | 115 | def test_output_minlevel(self, output_spy): 116 | out = self.Output() 117 | # visible: all 118 | out.minlevel = 'info' 119 | output_spy.begin() 120 | out.info('info color') 121 | out.head('head color') 122 | out.good('good color') 123 | out.warn('warn color') 124 | out.fail('fail color') 125 | assert len(output_spy.flush()) == 5 126 | # visible: head, warn, fail 127 | out.minlevel = 'warn' 128 | output_spy.begin() 129 | out.info('info color') 130 | out.head('head color') 131 | out.good('good color') 132 | out.warn('warn color') 133 | out.fail('fail color') 134 | assert len(output_spy.flush()) == 3 135 | # visible: head, fail 136 | out.minlevel = 'fail' 137 | output_spy.begin() 138 | out.info('info color') 139 | out.head('head color') 140 | out.good('good color') 141 | out.warn('warn color') 142 | out.fail('fail color') 143 | assert len(output_spy.flush()) == 2 144 | # visible: head 145 | out.minlevel = 'invalid level' 146 | output_spy.begin() 147 | out.info('info color') 148 | out.head('head color') 149 | out.good('good color') 150 | out.warn('warn color') 151 | out.fail('fail color') 152 | assert len(output_spy.flush()) == 1 153 | 154 | def test_output_batch(self, output_spy): 155 | out = self.Output() 156 | # visible: all 157 | output_spy.begin() 158 | out.minlevel = 'info' 159 | out.batch = False 160 | out.info('info color') 161 | out.head('head color') 162 | out.good('good color') 163 | out.warn('warn color') 164 | out.fail('fail color') 165 | assert len(output_spy.flush()) == 5 166 | # visible: all except head 167 | output_spy.begin() 168 | out.minlevel = 'info' 169 | out.batch = True 170 | out.info('info color') 171 | out.head('head color') 172 | out.good('good color') 173 | out.warn('warn color') 174 | out.fail('fail color') 175 | assert len(output_spy.flush()) == 4 176 | -------------------------------------------------------------------------------- /test/test_software.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import pytest 4 | 5 | 6 | # pylint: disable=line-too-long,attribute-defined-outside-init 7 | class TestSoftware(object): 8 | @pytest.fixture(autouse=True) 9 | def init(self, ssh_audit): 10 | self.ssh = ssh_audit.SSH 11 | 12 | def test_unknown_software(self): 13 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 14 | assert ps('SSH-1.5') is None 15 | assert ps('SSH-1.99-AlfaMegaServer') is None 16 | assert ps('SSH-2.0-BetaMegaServer 0.0.1') is None 17 | 18 | def test_openssh_software(self): 19 | # pylint: disable=too-many-statements 20 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 21 | # common 22 | s = ps('SSH-2.0-OpenSSH_7.3') 23 | assert s.vendor is None 24 | assert s.product == 'OpenSSH' 25 | assert s.version == '7.3' 26 | assert s.patch is None 27 | assert s.os is None 28 | assert str(s) == 'OpenSSH 7.3' 29 | assert str(s) == s.display() 30 | assert s.display(True) == str(s) 31 | assert s.display(False) == str(s) 32 | assert repr(s) == '' 33 | # common, portable 34 | s = ps('SSH-2.0-OpenSSH_7.2p1') 35 | assert s.vendor is None 36 | assert s.product == 'OpenSSH' 37 | assert s.version == '7.2' 38 | assert s.patch == 'p1' 39 | assert s.os is None 40 | assert str(s) == 'OpenSSH 7.2p1' 41 | assert str(s) == s.display() 42 | assert s.display(True) == str(s) 43 | assert s.display(False) == 'OpenSSH 7.2' 44 | assert repr(s) == '' 45 | # dot instead of underline 46 | s = ps('SSH-2.0-OpenSSH.6.6') 47 | assert s.vendor is None 48 | assert s.product == 'OpenSSH' 49 | assert s.version == '6.6' 50 | assert s.patch is None 51 | assert s.os is None 52 | assert str(s) == 'OpenSSH 6.6' 53 | assert str(s) == s.display() 54 | assert s.display(True) == str(s) 55 | assert s.display(False) == str(s) 56 | assert repr(s) == '' 57 | # dash instead of underline 58 | s = ps('SSH-2.0-OpenSSH-3.9p1') 59 | assert s.vendor is None 60 | assert s.product == 'OpenSSH' 61 | assert s.version == '3.9' 62 | assert s.patch == 'p1' 63 | assert s.os is None 64 | assert str(s) == 'OpenSSH 3.9p1' 65 | assert str(s) == s.display() 66 | assert s.display(True) == str(s) 67 | assert s.display(False) == 'OpenSSH 3.9' 68 | assert repr(s) == '' 69 | # patch prefix with dash 70 | s = ps('SSH-2.0-OpenSSH_7.2-hpn14v5') 71 | assert s.vendor is None 72 | assert s.product == 'OpenSSH' 73 | assert s.version == '7.2' 74 | assert s.patch == 'hpn14v5' 75 | assert s.os is None 76 | assert str(s) == 'OpenSSH 7.2 (hpn14v5)' 77 | assert str(s) == s.display() 78 | assert s.display(True) == str(s) 79 | assert s.display(False) == 'OpenSSH 7.2' 80 | assert repr(s) == '' 81 | # patch prefix with underline 82 | s = ps('SSH-1.5-OpenSSH_6.6.1_hpn13v11') 83 | assert s.vendor is None 84 | assert s.product == 'OpenSSH' 85 | assert s.version == '6.6.1' 86 | assert s.patch == 'hpn13v11' 87 | assert s.os is None 88 | assert str(s) == 'OpenSSH 6.6.1 (hpn13v11)' 89 | assert str(s) == s.display() 90 | assert s.display(True) == str(s) 91 | assert s.display(False) == 'OpenSSH 6.6.1' 92 | assert repr(s) == '' 93 | # patch prefix with dot 94 | s = ps('SSH-2.0-OpenSSH_5.9.CASPUR') 95 | assert s.vendor is None 96 | assert s.product == 'OpenSSH' 97 | assert s.version == '5.9' 98 | assert s.patch == 'CASPUR' 99 | assert s.os is None 100 | assert str(s) == 'OpenSSH 5.9 (CASPUR)' 101 | assert str(s) == s.display() 102 | assert s.display(True) == str(s) 103 | assert s.display(False) == 'OpenSSH 5.9' 104 | assert repr(s) == '' 105 | 106 | def test_dropbear_software(self): 107 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 108 | # common 109 | s = ps('SSH-2.0-dropbear_2016.74') 110 | assert s.vendor is None 111 | assert s.product == 'Dropbear SSH' 112 | assert s.version == '2016.74' 113 | assert s.patch is None 114 | assert s.os is None 115 | assert str(s) == 'Dropbear SSH 2016.74' 116 | assert str(s) == s.display() 117 | assert s.display(True) == str(s) 118 | assert s.display(False) == str(s) 119 | assert repr(s) == '' 120 | # common, patch 121 | s = ps('SSH-2.0-dropbear_0.44test4') 122 | assert s.vendor is None 123 | assert s.product == 'Dropbear SSH' 124 | assert s.version == '0.44' 125 | assert s.patch == 'test4' 126 | assert s.os is None 127 | assert str(s) == 'Dropbear SSH 0.44 (test4)' 128 | assert str(s) == s.display() 129 | assert s.display(True) == str(s) 130 | assert s.display(False) == 'Dropbear SSH 0.44' 131 | assert repr(s) == '' 132 | # patch prefix with dash 133 | s = ps('SSH-2.0-dropbear_0.44-Freesco-p49') 134 | assert s.vendor is None 135 | assert s.product == 'Dropbear SSH' 136 | assert s.version == '0.44' 137 | assert s.patch == 'Freesco-p49' 138 | assert s.os is None 139 | assert str(s) == 'Dropbear SSH 0.44 (Freesco-p49)' 140 | assert str(s) == s.display() 141 | assert s.display(True) == str(s) 142 | assert s.display(False) == 'Dropbear SSH 0.44' 143 | assert repr(s) == '' 144 | # patch prefix with underline 145 | s = ps('SSH-2.0-dropbear_2014.66_agbn_1') 146 | assert s.vendor is None 147 | assert s.product == 'Dropbear SSH' 148 | assert s.version == '2014.66' 149 | assert s.patch == 'agbn_1' 150 | assert s.os is None 151 | assert str(s) == 'Dropbear SSH 2014.66 (agbn_1)' 152 | assert str(s) == s.display() 153 | assert s.display(True) == str(s) 154 | assert s.display(False) == 'Dropbear SSH 2014.66' 155 | assert repr(s) == '' 156 | 157 | def test_libssh_software(self): 158 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 159 | # common 160 | s = ps('SSH-2.0-libssh-0.2') 161 | assert s.vendor is None 162 | assert s.product == 'libssh' 163 | assert s.version == '0.2' 164 | assert s.patch is None 165 | assert s.os is None 166 | assert str(s) == 'libssh 0.2' 167 | assert str(s) == s.display() 168 | assert s.display(True) == str(s) 169 | assert s.display(False) == str(s) 170 | assert repr(s) == '' 171 | s = ps('SSH-2.0-libssh-0.7.3') 172 | assert s.vendor is None 173 | assert s.product == 'libssh' 174 | assert s.version == '0.7.3' 175 | assert s.patch is None 176 | assert s.os is None 177 | assert str(s) == 'libssh 0.7.3' 178 | assert str(s) == s.display() 179 | assert s.display(True) == str(s) 180 | assert s.display(False) == str(s) 181 | assert repr(s) == '' 182 | 183 | def test_romsshell_software(self): 184 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 185 | # common 186 | s = ps('SSH-2.0-RomSShell_5.40') 187 | assert s.vendor == 'Allegro Software' 188 | assert s.product == 'RomSShell' 189 | assert s.version == '5.40' 190 | assert s.patch is None 191 | assert s.os is None 192 | assert str(s) == 'Allegro Software RomSShell 5.40' 193 | assert str(s) == s.display() 194 | assert s.display(True) == str(s) 195 | assert s.display(False) == str(s) 196 | assert repr(s) == '' 197 | 198 | def test_hp_ilo_software(self): 199 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 200 | # common 201 | s = ps('SSH-2.0-mpSSH_0.2.1') 202 | assert s.vendor == 'HP' 203 | assert s.product == 'iLO (Integrated Lights-Out) sshd' 204 | assert s.version == '0.2.1' 205 | assert s.patch is None 206 | assert s.os is None 207 | assert str(s) == 'HP iLO (Integrated Lights-Out) sshd 0.2.1' 208 | assert str(s) == s.display() 209 | assert s.display(True) == str(s) 210 | assert s.display(False) == str(s) 211 | assert repr(s) == '' 212 | 213 | def test_cisco_software(self): 214 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 215 | # common 216 | s = ps('SSH-1.5-Cisco-1.25') 217 | assert s.vendor == 'Cisco' 218 | assert s.product == 'IOS/PIX sshd' 219 | assert s.version == '1.25' 220 | assert s.patch is None 221 | assert s.os is None 222 | assert str(s) == 'Cisco IOS/PIX sshd 1.25' 223 | assert str(s) == s.display() 224 | assert s.display(True) == str(s) 225 | assert s.display(False) == str(s) 226 | assert repr(s) == '' 227 | 228 | def test_software_os(self): 229 | ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x)) # noqa 230 | # unknown 231 | s = ps('SSH-2.0-OpenSSH_3.7.1 MegaOperatingSystem 123') 232 | assert s.os is None 233 | # NetBSD 234 | s = ps('SSH-1.99-OpenSSH_2.5.1 NetBSD_Secure_Shell-20010614') 235 | assert s.os == 'NetBSD (2001-06-14)' 236 | assert str(s) == 'OpenSSH 2.5.1 running on NetBSD (2001-06-14)' 237 | assert repr(s) == '' 238 | s = ps('SSH-1.99-OpenSSH_5.0 NetBSD_Secure_Shell-20080403+-hpn13v1') 239 | assert s.os == 'NetBSD (2008-04-03)' 240 | assert str(s) == 'OpenSSH 5.0 running on NetBSD (2008-04-03)' 241 | assert repr(s) == '' 242 | s = ps('SSH-2.0-OpenSSH_6.6.1_hpn13v11 NetBSD-20100308') 243 | assert s.os == 'NetBSD (2010-03-08)' 244 | assert str(s) == 'OpenSSH 6.6.1 (hpn13v11) running on NetBSD (2010-03-08)' 245 | assert repr(s) == '' 246 | s = ps('SSH-2.0-OpenSSH_4.4 NetBSD') 247 | assert s.os == 'NetBSD' 248 | assert str(s) == 'OpenSSH 4.4 running on NetBSD' 249 | assert repr(s) == '' 250 | s = ps('SSH-2.0-OpenSSH_3.0.2 NetBSD Secure Shell') 251 | assert s.os == 'NetBSD' 252 | assert str(s) == 'OpenSSH 3.0.2 running on NetBSD' 253 | assert repr(s) == '' 254 | # FreeBSD 255 | s = ps('SSH-2.0-OpenSSH_7.2 FreeBSD-20160310') 256 | assert s.os == 'FreeBSD (2016-03-10)' 257 | assert str(s) == 'OpenSSH 7.2 running on FreeBSD (2016-03-10)' 258 | assert repr(s) == '' 259 | s = ps('SSH-1.99-OpenSSH_2.9 FreeBSD localisations 20020307') 260 | assert s.os == 'FreeBSD (2002-03-07)' 261 | assert str(s) == 'OpenSSH 2.9 running on FreeBSD (2002-03-07)' 262 | assert repr(s) == '' 263 | s = ps('SSH-2.0-OpenSSH_2.3.0 green@FreeBSD.org 20010321') 264 | assert s.os == 'FreeBSD (2001-03-21)' 265 | assert str(s) == 'OpenSSH 2.3.0 running on FreeBSD (2001-03-21)' 266 | assert repr(s) == '' 267 | s = ps('SSH-1.99-OpenSSH_4.4p1 FreeBSD-openssh-portable-overwrite-base-4.4.p1_1,1') 268 | assert s.os == 'FreeBSD' 269 | assert str(s) == 'OpenSSH 4.4p1 running on FreeBSD' 270 | assert repr(s) == '' 271 | s = ps('SSH-2.0-OpenSSH_7.2-OVH-rescue FreeBSD') 272 | assert s.os == 'FreeBSD' 273 | assert str(s) == 'OpenSSH 7.2 (OVH-rescue) running on FreeBSD' 274 | assert repr(s) == '' 275 | # Windows 276 | s = ps('SSH-2.0-OpenSSH_3.7.1 in RemotelyAnywhere 5.21.422') 277 | assert s.os == 'Microsoft Windows (RemotelyAnywhere 5.21.422)' 278 | assert str(s) == 'OpenSSH 3.7.1 running on Microsoft Windows (RemotelyAnywhere 5.21.422)' 279 | assert repr(s) == '' 280 | s = ps('SSH-2.0-OpenSSH_3.8 in DesktopAuthority 7.1.091') 281 | assert s.os == 'Microsoft Windows (DesktopAuthority 7.1.091)' 282 | assert str(s) == 'OpenSSH 3.8 running on Microsoft Windows (DesktopAuthority 7.1.091)' 283 | assert repr(s) == '' 284 | s = ps('SSH-2.0-OpenSSH_3.8 in RemoteSupportManager 1.0.023') 285 | assert s.os == 'Microsoft Windows (RemoteSupportManager 1.0.023)' 286 | assert str(s) == 'OpenSSH 3.8 running on Microsoft Windows (RemoteSupportManager 1.0.023)' 287 | assert repr(s) == '' 288 | -------------------------------------------------------------------------------- /test/test_ssh1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import struct 4 | import pytest 5 | 6 | 7 | # pylint: disable=line-too-long,attribute-defined-outside-init 8 | class TestSSH1(object): 9 | @pytest.fixture(autouse=True) 10 | def init(self, ssh_audit): 11 | self.ssh = ssh_audit.SSH 12 | self.ssh1 = ssh_audit.SSH1 13 | self.rbuf = ssh_audit.ReadBuf 14 | self.wbuf = ssh_audit.WriteBuf 15 | self.audit = ssh_audit.audit 16 | self.AuditConf = ssh_audit.AuditConf 17 | 18 | def _conf(self): 19 | conf = self.AuditConf('localhost', 22) 20 | conf.colors = False 21 | conf.batch = True 22 | conf.verbose = True 23 | conf.ssh1 = True 24 | conf.ssh2 = False 25 | return conf 26 | 27 | def _create_ssh1_packet(self, payload, valid_crc=True): 28 | padding = -(len(payload) + 4) % 8 29 | plen = len(payload) + 4 30 | pad_bytes = b'\x00' * padding 31 | cksum = self.ssh1.crc32(pad_bytes + payload) if valid_crc else 0 32 | data = struct.pack('>I', plen) + pad_bytes + payload + struct.pack('>I', cksum) 33 | return data 34 | 35 | @classmethod 36 | def _server_key(cls): 37 | return (1024, 0x10001, 0xee6552da432e0ac2c422df1a51287507748bfe3b5e3e4fa989a8f49fdc163a17754939ef18ef8a667ea3b71036a151fcd7f5e01ceef1e4439864baf3ac569047582c69d6c128212e0980dcb3168f00d371004039983f6033cd785b8b8f85096c7d9405cbfdc664e27c966356a6b4eb6ee20ad43414b50de18b22829c1880b551) 38 | 39 | @classmethod 40 | def _host_key(cls): 41 | return (2048, 0x10001, 0xdfa20cd2a530ccc8c870aa60d9feb3b35deeab81c3215a96557abbd683d21f4600f38e475d87100da9a4404220eeb3bb5584e5a2b5b48ffda58530ea19104a32577d7459d91e76aa711b241050f4cc6d5327ccce254f371acad3be56d46eb5919b73f20dbdb1177b700f00891c5bf4ed128bb90ed541b778288285bcfa28432ab5cbcb8321b6e24760e998e0daa519f093a631e44276d7dd252ce0c08c75e2ab28a7349ead779f97d0f20a6d413bf3623cd216dc35375f6366690bcc41e3b2d5465840ec7ee0dc7e3f1c101d674a0c7dbccbc3942788b111396add2f8153b46a0e4b50d66e57ee92958f1c860dd97cc0e40e32febff915343ed53573142bdf4b) 42 | 43 | def _pkm_payload(self): 44 | w = self.wbuf() 45 | w.write(b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff') 46 | b, e, m = self._server_key() 47 | w.write_int(b).write_mpint1(e).write_mpint1(m) 48 | b, e, m = self._host_key() 49 | w.write_int(b).write_mpint1(e).write_mpint1(m) 50 | w.write_int(2) 51 | w.write_int(72) 52 | w.write_int(36) 53 | return w.write_flush() 54 | 55 | def test_crc32(self): 56 | assert self.ssh1.crc32(b'') == 0x00 57 | assert self.ssh1.crc32(b'The quick brown fox jumps over the lazy dog') == 0xb9c60808 58 | 59 | def test_fingerprint(self): 60 | # pylint: disable=protected-access 61 | b, e, m = self._host_key() 62 | fpd = self.wbuf._create_mpint(m, False) 63 | fpd += self.wbuf._create_mpint(e, False) 64 | fp = self.ssh.Fingerprint(fpd) 65 | assert b == 2048 66 | assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96' 67 | assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs' 68 | 69 | def test_pkm_read(self): 70 | pkm = self.ssh1.PublicKeyMessage.parse(self._pkm_payload()) 71 | assert pkm is not None 72 | assert pkm.cookie == b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' 73 | b, e, m = self._server_key() 74 | assert pkm.server_key_bits == b 75 | assert pkm.server_key_public_exponent == e 76 | assert pkm.server_key_public_modulus == m 77 | b, e, m = self._host_key() 78 | assert pkm.host_key_bits == b 79 | assert pkm.host_key_public_exponent == e 80 | assert pkm.host_key_public_modulus == m 81 | fp = self.ssh.Fingerprint(pkm.host_key_fingerprint_data) 82 | assert pkm.protocol_flags == 2 83 | assert pkm.supported_ciphers_mask == 72 84 | assert pkm.supported_ciphers == ['3des', 'blowfish'] 85 | assert pkm.supported_authentications_mask == 36 86 | assert pkm.supported_authentications == ['rsa', 'tis'] 87 | assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96' 88 | assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs' 89 | 90 | def test_pkm_payload(self): 91 | cookie = b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff' 92 | skey = self._server_key() 93 | hkey = self._host_key() 94 | pflags = 2 95 | cmask = 72 96 | amask = 36 97 | pkm1 = self.ssh1.PublicKeyMessage(cookie, skey, hkey, pflags, cmask, amask) 98 | pkm2 = self.ssh1.PublicKeyMessage.parse(self._pkm_payload()) 99 | assert pkm1.payload == pkm2.payload 100 | 101 | def test_ssh1_server_simple(self, output_spy, virtual_socket): 102 | vsocket = virtual_socket 103 | w = self.wbuf() 104 | w.write_byte(self.ssh.Protocol.SMSG_PUBLIC_KEY) 105 | w.write(self._pkm_payload()) 106 | vsocket.rdata.append(b'SSH-1.5-OpenSSH_7.2 ssh-audit-test\r\n') 107 | vsocket.rdata.append(self._create_ssh1_packet(w.write_flush())) 108 | output_spy.begin() 109 | self.audit(self._conf()) 110 | lines = output_spy.flush() 111 | assert len(lines) == 10 112 | 113 | def test_ssh1_server_invalid_first_packet(self, output_spy, virtual_socket): 114 | vsocket = virtual_socket 115 | w = self.wbuf() 116 | w.write_byte(self.ssh.Protocol.SMSG_PUBLIC_KEY + 1) 117 | w.write(self._pkm_payload()) 118 | vsocket.rdata.append(b'SSH-1.5-OpenSSH_7.2 ssh-audit-test\r\n') 119 | vsocket.rdata.append(self._create_ssh1_packet(w.write_flush())) 120 | output_spy.begin() 121 | with pytest.raises(SystemExit): 122 | self.audit(self._conf()) 123 | lines = output_spy.flush() 124 | assert len(lines) == 4 125 | assert 'unknown message' in lines[-1] 126 | 127 | def test_ssh1_server_invalid_checksum(self, output_spy, virtual_socket): 128 | vsocket = virtual_socket 129 | w = self.wbuf() 130 | w.write_byte(self.ssh.Protocol.SMSG_PUBLIC_KEY + 1) 131 | w.write(self._pkm_payload()) 132 | vsocket.rdata.append(b'SSH-1.5-OpenSSH_7.2 ssh-audit-test\r\n') 133 | vsocket.rdata.append(self._create_ssh1_packet(w.write_flush(), False)) 134 | output_spy.begin() 135 | with pytest.raises(SystemExit): 136 | self.audit(self._conf()) 137 | lines = output_spy.flush() 138 | assert len(lines) == 1 139 | assert 'checksum' in lines[-1] 140 | -------------------------------------------------------------------------------- /test/test_ssh2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import struct, os 4 | import pytest 5 | 6 | 7 | # pylint: disable=line-too-long,attribute-defined-outside-init 8 | class TestSSH2(object): 9 | @pytest.fixture(autouse=True) 10 | def init(self, ssh_audit): 11 | self.ssh = ssh_audit.SSH 12 | self.ssh2 = ssh_audit.SSH2 13 | self.rbuf = ssh_audit.ReadBuf 14 | self.wbuf = ssh_audit.WriteBuf 15 | self.audit = ssh_audit.audit 16 | self.AuditConf = ssh_audit.AuditConf 17 | 18 | def _conf(self): 19 | conf = self.AuditConf('localhost', 22) 20 | conf.colors = False 21 | conf.batch = True 22 | conf.verbose = True 23 | conf.ssh1 = False 24 | conf.ssh2 = True 25 | return conf 26 | 27 | @classmethod 28 | def _create_ssh2_packet(cls, payload): 29 | padding = -(len(payload) + 5) % 8 30 | if padding < 4: 31 | padding += 8 32 | plen = len(payload) + padding + 1 33 | pad_bytes = b'\x00' * padding 34 | data = struct.pack('>Ib', plen, padding) + payload + pad_bytes 35 | return data 36 | 37 | def _kex_payload(self): 38 | w = self.wbuf() 39 | w.write(b'\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff') 40 | w.write_list([u'curve25519-sha256@libssh.org', u'ecdh-sha2-nistp256', u'ecdh-sha2-nistp384', u'ecdh-sha2-nistp521', u'diffie-hellman-group-exchange-sha256', u'diffie-hellman-group14-sha1']) 41 | w.write_list([u'ssh-rsa', u'rsa-sha2-512', u'rsa-sha2-256', u'ssh-ed25519']) 42 | w.write_list([u'chacha20-poly1305@openssh.com', u'aes128-ctr', u'aes192-ctr', u'aes256-ctr', u'aes128-gcm@openssh.com', u'aes256-gcm@openssh.com', u'aes128-cbc', u'aes192-cbc', u'aes256-cbc']) 43 | w.write_list([u'chacha20-poly1305@openssh.com', u'aes128-ctr', u'aes192-ctr', u'aes256-ctr', u'aes128-gcm@openssh.com', u'aes256-gcm@openssh.com', u'aes128-cbc', u'aes192-cbc', u'aes256-cbc']) 44 | w.write_list([u'umac-64-etm@openssh.com', u'umac-128-etm@openssh.com', u'hmac-sha2-256-etm@openssh.com', u'hmac-sha2-512-etm@openssh.com', u'hmac-sha1-etm@openssh.com', u'umac-64@openssh.com', u'umac-128@openssh.com', u'hmac-sha2-256', u'hmac-sha2-512', u'hmac-sha1']) 45 | w.write_list([u'umac-64-etm@openssh.com', u'umac-128-etm@openssh.com', u'hmac-sha2-256-etm@openssh.com', u'hmac-sha2-512-etm@openssh.com', u'hmac-sha1-etm@openssh.com', u'umac-64@openssh.com', u'umac-128@openssh.com', u'hmac-sha2-256', u'hmac-sha2-512', u'hmac-sha1']) 46 | w.write_list([u'none', u'zlib@openssh.com']) 47 | w.write_list([u'none', u'zlib@openssh.com']) 48 | w.write_list([u'']) 49 | w.write_list([u'']) 50 | w.write_byte(False) 51 | w.write_int(0) 52 | return w.write_flush() 53 | 54 | def test_kex_read(self): 55 | kex = self.ssh2.Kex.parse(self._kex_payload()) 56 | assert kex is not None 57 | assert kex.cookie == b'\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff' 58 | assert kex.kex_algorithms == [u'curve25519-sha256@libssh.org', u'ecdh-sha2-nistp256', u'ecdh-sha2-nistp384', u'ecdh-sha2-nistp521', u'diffie-hellman-group-exchange-sha256', u'diffie-hellman-group14-sha1'] 59 | assert kex.key_algorithms == [u'ssh-rsa', u'rsa-sha2-512', u'rsa-sha2-256', u'ssh-ed25519'] 60 | assert kex.client is not None 61 | assert kex.server is not None 62 | assert kex.client.encryption == [u'chacha20-poly1305@openssh.com', u'aes128-ctr', u'aes192-ctr', u'aes256-ctr', u'aes128-gcm@openssh.com', u'aes256-gcm@openssh.com', u'aes128-cbc', u'aes192-cbc', u'aes256-cbc'] 63 | assert kex.server.encryption == [u'chacha20-poly1305@openssh.com', u'aes128-ctr', u'aes192-ctr', u'aes256-ctr', u'aes128-gcm@openssh.com', u'aes256-gcm@openssh.com', u'aes128-cbc', u'aes192-cbc', u'aes256-cbc'] 64 | assert kex.client.mac == [u'umac-64-etm@openssh.com', u'umac-128-etm@openssh.com', u'hmac-sha2-256-etm@openssh.com', u'hmac-sha2-512-etm@openssh.com', u'hmac-sha1-etm@openssh.com', u'umac-64@openssh.com', u'umac-128@openssh.com', u'hmac-sha2-256', u'hmac-sha2-512', u'hmac-sha1'] 65 | assert kex.server.mac == [u'umac-64-etm@openssh.com', u'umac-128-etm@openssh.com', u'hmac-sha2-256-etm@openssh.com', u'hmac-sha2-512-etm@openssh.com', u'hmac-sha1-etm@openssh.com', u'umac-64@openssh.com', u'umac-128@openssh.com', u'hmac-sha2-256', u'hmac-sha2-512', u'hmac-sha1'] 66 | assert kex.client.compression == [u'none', u'zlib@openssh.com'] 67 | assert kex.server.compression == [u'none', u'zlib@openssh.com'] 68 | assert kex.client.languages == [u''] 69 | assert kex.server.languages == [u''] 70 | assert kex.follows is False 71 | assert kex.unused == 0 72 | 73 | def _get_empty_kex(self, cookie=None): 74 | kex_algs, key_algs = [], [] 75 | enc, mac, compression, languages = [], [], ['none'], [] 76 | cli = self.ssh2.KexParty(enc, mac, compression, languages) 77 | enc, mac, compression, languages = [], [], ['none'], [] 78 | srv = self.ssh2.KexParty(enc, mac, compression, languages) 79 | if cookie is None: 80 | cookie = os.urandom(16) 81 | kex = self.ssh2.Kex(cookie, kex_algs, key_algs, cli, srv, 0) 82 | return kex 83 | 84 | def _get_kex_variat1(self): 85 | cookie = b'\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff' 86 | kex = self._get_empty_kex(cookie) 87 | kex.kex_algorithms.append('curve25519-sha256@libssh.org') 88 | kex.kex_algorithms.append('ecdh-sha2-nistp256') 89 | kex.kex_algorithms.append('ecdh-sha2-nistp384') 90 | kex.kex_algorithms.append('ecdh-sha2-nistp521') 91 | kex.kex_algorithms.append('diffie-hellman-group-exchange-sha256') 92 | kex.kex_algorithms.append('diffie-hellman-group14-sha1') 93 | kex.key_algorithms.append('ssh-rsa') 94 | kex.key_algorithms.append('rsa-sha2-512') 95 | kex.key_algorithms.append('rsa-sha2-256') 96 | kex.key_algorithms.append('ssh-ed25519') 97 | kex.server.encryption.append('chacha20-poly1305@openssh.com') 98 | kex.server.encryption.append('aes128-ctr') 99 | kex.server.encryption.append('aes192-ctr') 100 | kex.server.encryption.append('aes256-ctr') 101 | kex.server.encryption.append('aes128-gcm@openssh.com') 102 | kex.server.encryption.append('aes256-gcm@openssh.com') 103 | kex.server.encryption.append('aes128-cbc') 104 | kex.server.encryption.append('aes192-cbc') 105 | kex.server.encryption.append('aes256-cbc') 106 | kex.server.mac.append('umac-64-etm@openssh.com') 107 | kex.server.mac.append('umac-128-etm@openssh.com') 108 | kex.server.mac.append('hmac-sha2-256-etm@openssh.com') 109 | kex.server.mac.append('hmac-sha2-512-etm@openssh.com') 110 | kex.server.mac.append('hmac-sha1-etm@openssh.com') 111 | kex.server.mac.append('umac-64@openssh.com') 112 | kex.server.mac.append('umac-128@openssh.com') 113 | kex.server.mac.append('hmac-sha2-256') 114 | kex.server.mac.append('hmac-sha2-512') 115 | kex.server.mac.append('hmac-sha1') 116 | kex.server.compression.append('zlib@openssh.com') 117 | for a in kex.server.encryption: 118 | kex.client.encryption.append(a) 119 | for a in kex.server.mac: 120 | kex.client.mac.append(a) 121 | for a in kex.server.compression: 122 | if a == 'none': 123 | continue 124 | kex.client.compression.append(a) 125 | return kex 126 | 127 | def test_key_payload(self): 128 | kex1 = self._get_kex_variat1() 129 | kex2 = self.ssh2.Kex.parse(self._kex_payload()) 130 | assert kex1.payload == kex2.payload 131 | 132 | def test_ssh2_server_simple(self, output_spy, virtual_socket): 133 | vsocket = virtual_socket 134 | w = self.wbuf() 135 | w.write_byte(self.ssh.Protocol.MSG_KEXINIT) 136 | w.write(self._kex_payload()) 137 | vsocket.rdata.append(b'SSH-2.0-OpenSSH_7.3 ssh-audit-test\r\n') 138 | vsocket.rdata.append(self._create_ssh2_packet(w.write_flush())) 139 | output_spy.begin() 140 | self.audit(self._conf()) 141 | lines = output_spy.flush() 142 | assert len(lines) == 72 143 | 144 | def test_ssh2_server_invalid_first_packet(self, output_spy, virtual_socket): 145 | vsocket = virtual_socket 146 | w = self.wbuf() 147 | w.write_byte(self.ssh.Protocol.MSG_KEXINIT + 1) 148 | vsocket.rdata.append(b'SSH-2.0-OpenSSH_7.3 ssh-audit-test\r\n') 149 | vsocket.rdata.append(self._create_ssh2_packet(w.write_flush())) 150 | output_spy.begin() 151 | with pytest.raises(SystemExit): 152 | self.audit(self._conf()) 153 | lines = output_spy.flush() 154 | assert len(lines) == 3 155 | assert 'unknown message' in lines[-1] 156 | -------------------------------------------------------------------------------- /test/test_version_compare.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import pytest 4 | 5 | 6 | # pylint: disable=attribute-defined-outside-init 7 | class TestVersionCompare(object): 8 | @pytest.fixture(autouse=True) 9 | def init(self, ssh_audit): 10 | self.ssh = ssh_audit.SSH 11 | 12 | def get_dropbear_software(self, v): 13 | b = self.ssh.Banner.parse('SSH-2.0-dropbear_{0}'.format(v)) 14 | return self.ssh.Software.parse(b) 15 | 16 | def get_openssh_software(self, v): 17 | b = self.ssh.Banner.parse('SSH-2.0-OpenSSH_{0}'.format(v)) 18 | return self.ssh.Software.parse(b) 19 | 20 | def get_libssh_software(self, v): 21 | b = self.ssh.Banner.parse('SSH-2.0-libssh-{0}'.format(v)) 22 | return self.ssh.Software.parse(b) 23 | 24 | def test_dropbear_compare_version_pre_years(self): 25 | s = self.get_dropbear_software('0.44') 26 | assert s.compare_version(None) == 1 27 | assert s.compare_version('') == 1 28 | assert s.compare_version('0.43') > 0 29 | assert s.compare_version('0.44') == 0 30 | assert s.compare_version(s) == 0 31 | assert s.compare_version('0.45') < 0 32 | assert s.between_versions('0.43', '0.45') 33 | assert s.between_versions('0.43', '0.43') is False 34 | assert s.between_versions('0.45', '0.43') is False 35 | 36 | def test_dropbear_compare_version_with_years(self): 37 | s = self.get_dropbear_software('2015.71') 38 | assert s.compare_version(None) == 1 39 | assert s.compare_version('') == 1 40 | assert s.compare_version('2014.66') > 0 41 | assert s.compare_version('2015.71') == 0 42 | assert s.compare_version(s) == 0 43 | assert s.compare_version('2016.74') < 0 44 | assert s.between_versions('2014.66', '2016.74') 45 | assert s.between_versions('2014.66', '2015.69') is False 46 | assert s.between_versions('2016.74', '2014.66') is False 47 | 48 | def test_dropbear_compare_version_mixed(self): 49 | s = self.get_dropbear_software('0.53.1') 50 | assert s.compare_version(None) == 1 51 | assert s.compare_version('') == 1 52 | assert s.compare_version('0.53') > 0 53 | assert s.compare_version('0.53.1') == 0 54 | assert s.compare_version(s) == 0 55 | assert s.compare_version('2011.54') < 0 56 | assert s.between_versions('0.53', '2011.54') 57 | assert s.between_versions('0.53', '0.53') is False 58 | assert s.between_versions('2011.54', '0.53') is False 59 | 60 | def test_dropbear_compare_version_patchlevel(self): 61 | s1 = self.get_dropbear_software('0.44') 62 | s2 = self.get_dropbear_software('0.44test3') 63 | assert s1.compare_version(None) == 1 64 | assert s1.compare_version('') == 1 65 | assert s1.compare_version('0.44') == 0 66 | assert s1.compare_version(s1) == 0 67 | assert s1.compare_version('0.43') > 0 68 | assert s1.compare_version('0.44test4') > 0 69 | assert s1.between_versions('0.44test4', '0.45') 70 | assert s1.between_versions('0.43', '0.44test4') is False 71 | assert s1.between_versions('0.45', '0.44test4') is False 72 | assert s2.compare_version(None) == 1 73 | assert s2.compare_version('') == 1 74 | assert s2.compare_version('0.44test3') == 0 75 | assert s2.compare_version(s2) == 0 76 | assert s2.compare_version('0.44') < 0 77 | assert s2.compare_version('0.44test4') < 0 78 | assert s2.between_versions('0.43', '0.44') 79 | assert s2.between_versions('0.43', '0.44test2') is False 80 | assert s2.between_versions('0.44', '0.43') is False 81 | assert s1.compare_version(s2) > 0 82 | assert s2.compare_version(s1) < 0 83 | 84 | def test_dropbear_compare_version_sequential(self): 85 | versions = [] 86 | for i in range(28, 44): 87 | versions.append('0.{0}'.format(i)) 88 | for i in range(1, 5): 89 | versions.append('0.44test{0}'.format(i)) 90 | for i in range(44, 49): 91 | versions.append('0.{0}'.format(i)) 92 | versions.append('0.48.1') 93 | for i in range(49, 54): 94 | versions.append('0.{0}'.format(i)) 95 | versions.append('0.53.1') 96 | for v in ['2011.54', '2012.55']: 97 | versions.append(v) 98 | for i in range(56, 61): 99 | versions.append('2013.{0}'.format(i)) 100 | for v in ['2013.61test', '2013.62']: 101 | versions.append(v) 102 | for i in range(63, 67): 103 | versions.append('2014.{0}'.format(i)) 104 | for i in range(67, 72): 105 | versions.append('2015.{0}'.format(i)) 106 | for i in range(72, 75): 107 | versions.append('2016.{0}'.format(i)) 108 | l = len(versions) 109 | for i in range(l): 110 | v = versions[i] 111 | s = self.get_dropbear_software(v) 112 | assert s.compare_version(v) == 0 113 | if i - 1 >= 0: 114 | vbefore = versions[i - 1] 115 | assert s.compare_version(vbefore) > 0 116 | if i + 1 < l: 117 | vnext = versions[i + 1] 118 | assert s.compare_version(vnext) < 0 119 | 120 | def test_openssh_compare_version_simple(self): 121 | s = self.get_openssh_software('3.7.1') 122 | assert s.compare_version(None) == 1 123 | assert s.compare_version('') == 1 124 | assert s.compare_version('3.7') > 0 125 | assert s.compare_version('3.7.1') == 0 126 | assert s.compare_version(s) == 0 127 | assert s.compare_version('3.8') < 0 128 | assert s.between_versions('3.7', '3.8') 129 | assert s.between_versions('3.6', '3.7') is False 130 | assert s.between_versions('3.8', '3.7') is False 131 | 132 | def test_openssh_compare_version_patchlevel(self): 133 | s1 = self.get_openssh_software('2.1.1') 134 | s2 = self.get_openssh_software('2.1.1p2') 135 | assert s1.compare_version(s1) == 0 136 | assert s2.compare_version(s2) == 0 137 | assert s1.compare_version('2.1.1p1') == 0 138 | assert s1.compare_version('2.1.1p2') == 0 139 | assert s2.compare_version('2.1.1') == 0 140 | assert s2.compare_version('2.1.1p1') > 0 141 | assert s2.compare_version('2.1.1p3') < 0 142 | assert s1.compare_version(s2) == 0 143 | assert s2.compare_version(s1) == 0 144 | 145 | def test_openbsd_compare_version_sequential(self): 146 | versions = [] 147 | for v in ['1.2.3', '2.1.0', '2.1.1', '2.2.0', '2.3.0']: 148 | versions.append(v) 149 | for v in ['2.5.0', '2.5.1', '2.5.2', '2.9', '2.9.9']: 150 | versions.append(v) 151 | for v in ['3.0', '3.0.1', '3.0.2', '3.1', '3.2.2', '3.2.3']: 152 | versions.append(v) 153 | for i in range(3, 7): 154 | versions.append('3.{0}'.format(i)) 155 | for v in ['3.6.1', '3.7.0', '3.7.1']: 156 | versions.append(v) 157 | for i in range(8, 10): 158 | versions.append('3.{0}'.format(i)) 159 | for i in range(0, 10): 160 | versions.append('4.{0}'.format(i)) 161 | for i in range(0, 10): 162 | versions.append('5.{0}'.format(i)) 163 | for i in range(0, 10): 164 | versions.append('6.{0}'.format(i)) 165 | for i in range(0, 4): 166 | versions.append('7.{0}'.format(i)) 167 | l = len(versions) 168 | for i in range(l): 169 | v = versions[i] 170 | s = self.get_openssh_software(v) 171 | assert s.compare_version(v) == 0 172 | if i - 1 >= 0: 173 | vbefore = versions[i - 1] 174 | assert s.compare_version(vbefore) > 0 175 | if i + 1 < l: 176 | vnext = versions[i + 1] 177 | assert s.compare_version(vnext) < 0 178 | 179 | def test_libssh_compare_version_simple(self): 180 | s = self.get_libssh_software('0.3') 181 | assert s.compare_version(None) == 1 182 | assert s.compare_version('') == 1 183 | assert s.compare_version('0.2') > 0 184 | assert s.compare_version('0.3') == 0 185 | assert s.compare_version(s) == 0 186 | assert s.compare_version('0.3.1') < 0 187 | assert s.between_versions('0.2', '0.3.1') 188 | assert s.between_versions('0.1', '0.2') is False 189 | assert s.between_versions('0.3.1', '0.2') is False 190 | 191 | def test_libssh_compare_version_sequential(self): 192 | versions = [] 193 | for v in ['0.2', '0.3']: 194 | versions.append(v) 195 | for i in range(1, 5): 196 | versions.append('0.3.{0}'.format(i)) 197 | for i in range(0, 9): 198 | versions.append('0.4.{0}'.format(i)) 199 | for i in range(0, 6): 200 | versions.append('0.5.{0}'.format(i)) 201 | for i in range(0, 6): 202 | versions.append('0.6.{0}'.format(i)) 203 | for i in range(0, 4): 204 | versions.append('0.7.{0}'.format(i)) 205 | l = len(versions) 206 | for i in range(l): 207 | v = versions[i] 208 | s = self.get_libssh_software(v) 209 | assert s.compare_version(v) == 0 210 | if i - 1 >= 0: 211 | vbefore = versions[i - 1] 212 | assert s.compare_version(vbefore) > 0 213 | if i + 1 < l: 214 | vnext = versions[i + 1] 215 | assert s.compare_version(vnext) < 0 216 | --------------------------------------------------------------------------------