mirror of
https://github.com/jtesta/ssh-audit.git
synced 2026-05-25 23:41:22 +02:00
Compare commits
6 Commits
8124c8e443
...
v3.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 68cf05d0ff | |||
| 2d9ddabcad | |||
| 986f83653d | |||
| 3c459f1428 | |||
| 46b89fff2e | |||
| 81718d1948 |
@@ -213,19 +213,19 @@ For convenience, a web front-end on top of the command-line tool is available at
|
|||||||
|
|
||||||
## ChangeLog
|
## ChangeLog
|
||||||
|
|
||||||
### v3.2.0-dev (???)
|
### v3.2.0 (2024-04-22)
|
||||||
- Added implementation of the DHEat denial-of-service attack (see `--dheat` option; [CVE-2002-20001](https://nvd.nist.gov/vuln/detail/CVE-2002-20001)).
|
- Added implementation of the DHEat denial-of-service attack (see `--dheat` option; [CVE-2002-20001](https://nvd.nist.gov/vuln/detail/CVE-2002-20001)).
|
||||||
- Expanded filter of CBC ciphers to flag for the Terrapin vulnerability. It now includes more rarely found ciphers.
|
- Expanded filter of CBC ciphers to flag for the Terrapin vulnerability. It now includes more rarely found ciphers.
|
||||||
- Color output is disabled if the `NO_COLOR` environment variable is set (see https://no-color.org/).
|
|
||||||
- Fixed parsing of `ecdsa-sha2-nistp*` CA signatures on host keys. Additionally, they are now flagged as potentially back-doored, just as standard host keys are.
|
- Fixed parsing of `ecdsa-sha2-nistp*` CA signatures on host keys. Additionally, they are now flagged as potentially back-doored, just as standard host keys are.
|
||||||
- Gracefully handle rare exceptions (i.e.: crashes) while performing GEX tests.
|
- Gracefully handle rare exceptions (i.e.: crashes) while performing GEX tests.
|
||||||
- Built-in policies now include a change log (use `-L -v` to view them).
|
|
||||||
- Added built-in policies for Amazon Linux 2023, Debian 12, OpenSSH 9.7, and Rocky Linux 9.
|
|
||||||
- The built-in man page (`-m`, `--manual`) is now available on Docker, PyPI, and Snap builds, in addition to the Windows build.
|
- The built-in man page (`-m`, `--manual`) is now available on Docker, PyPI, and Snap builds, in addition to the Windows build.
|
||||||
- Snap builds are now architecture-independent.
|
- Snap builds are now architecture-independent.
|
||||||
- Changed Docker base image from `python:3-slim` to `python:3-alpine`, resulting in a 59% reduction in image size; credit [Daniel Thamdrup](https://github.com/dallemon).
|
- Changed Docker base image from `python:3-slim` to `python:3-alpine`, resulting in a 59% reduction in image size; credit [Daniel Thamdrup](https://github.com/dallemon).
|
||||||
|
- Added built-in policies for Amazon Linux 2023, Debian 12, OpenSSH 9.7, and Rocky Linux 9.
|
||||||
|
- Built-in policies now include a change log (use `-L -v` to view them).
|
||||||
- Custom policies now support the `allow_algorithm_subset_and_reordering` directive to allow targets to pass with a subset and/or re-ordered list of host keys, kex, ciphers, and MACs. This allows for the creation of a baseline policy where targets can optionally implement stricter controls; partial credit [yannik1015](https://github.com/yannik1015).
|
- Custom policies now support the `allow_algorithm_subset_and_reordering` directive to allow targets to pass with a subset and/or re-ordered list of host keys, kex, ciphers, and MACs. This allows for the creation of a baseline policy where targets can optionally implement stricter controls; partial credit [yannik1015](https://github.com/yannik1015).
|
||||||
- Custom policies now support the `allow_larger_keys` directive to allow targets to pass with larger host keys, CA keys, and Diffie-Hellman keys. This allows for the creation of a baseline policy where targets can optionally implement stricter controls; partial credit [Damian Szuberski](https://github.com/szubersk).
|
- Custom policies now support the `allow_larger_keys` directive to allow targets to pass with larger host keys, CA keys, and Diffie-Hellman keys. This allows for the creation of a baseline policy where targets can optionally implement stricter controls; partial credit [Damian Szuberski](https://github.com/szubersk).
|
||||||
|
- Color output is disabled if the `NO_COLOR` environment variable is set (see https://no-color.org/).
|
||||||
- Added 1 new key exchange algorithm: `gss-nistp384-sha384-*`.
|
- Added 1 new key exchange algorithm: `gss-nistp384-sha384-*`.
|
||||||
- Added 1 new cipher: `aes128-ocb@libassh.org`.
|
- Added 1 new cipher: `aes128-ocb@libassh.org`.
|
||||||
|
|
||||||
|
|||||||
+70
-19
@@ -51,7 +51,7 @@ class DHEat:
|
|||||||
MAX_SAFE_RATE = 20.0
|
MAX_SAFE_RATE = 20.0
|
||||||
|
|
||||||
# The warning added to DH algorithms in the UI when dh_rate_test determines that no throttling is being done.
|
# The warning added to DH algorithms in the UI when dh_rate_test determines that no throttling is being done.
|
||||||
DHEAT_WARNING = "Potentially insufficient connection throttling detected, resulting in possible vulnerability to the DHEat DoS attack (CVE-2002-20001). Either connection throttling or removal of Diffie-Hellman key exchanges is necessary to remediate this issue. Suppress this test/message with --skip-rate-test. Additional info: {connections:d} connections were created in {time_elapsed:.3f} seconds, or {rate:.1f} conns/sec; server must respond with a rate less than {max_safe_rate:.1f} conns/sec to be considered safe."
|
DHEAT_WARNING = "Potentially insufficient connection throttling detected, resulting in possible vulnerability to the DHEat DoS attack (CVE-2002-20001). {connections:d} connections were created in {time_elapsed:.3f} seconds, or {rate:.1f} conns/sec; server must respond with a rate less than {max_safe_rate:.1f} conns/sec per IPv4/IPv6 source address to be considered safe. For rate-throttling options, please see <https://www.ssh-audit.com/hardening_guides.html>. Be aware that using 'PerSourceMaxStartups 1' properly protects the server from this attack, but will cause this test to yield a false positive. Suppress this test and message with the --skip-rate-test option."
|
||||||
|
|
||||||
# List of the Diffie-Hellman group exchange algorithms this test supports.
|
# List of the Diffie-Hellman group exchange algorithms this test supports.
|
||||||
gex_algs = [
|
gex_algs = [
|
||||||
@@ -309,14 +309,14 @@ class DHEat:
|
|||||||
def _dh_rate_test(out: 'OutputBuffer', aconf: 'AuditConf', kex: 'SSH2_Kex', max_time: float, max_connections: int, concurrent_sockets: int) -> str:
|
def _dh_rate_test(out: 'OutputBuffer', aconf: 'AuditConf', kex: 'SSH2_Kex', max_time: float, max_connections: int, concurrent_sockets: int) -> str:
|
||||||
'''Attempts to quickly create many sockets to the target server. This simulates the DHEat attack without causing an actual DoS condition. If a rate greater than MAX_SAFE_RATE is allowed, then a warning string is returned.'''
|
'''Attempts to quickly create many sockets to the target server. This simulates the DHEat attack without causing an actual DoS condition. If a rate greater than MAX_SAFE_RATE is allowed, then a warning string is returned.'''
|
||||||
|
|
||||||
def _close_socket(socket_list: List[socket.socket], s: socket.socket) -> None:
|
def _close_socket(socket_dict: Dict[socket.socket, float], s: socket.socket) -> None:
|
||||||
try:
|
try:
|
||||||
s.shutdown(socket.SHUT_RDWR)
|
s.shutdown(socket.SHUT_RDWR)
|
||||||
s.close()
|
s.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
socket_list.remove(s)
|
del socket_dict[s]
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
DHEat.YELLOWB = "\033[1;93m"
|
DHEat.YELLOWB = "\033[1;93m"
|
||||||
@@ -329,6 +329,7 @@ class DHEat:
|
|||||||
|
|
||||||
# If the user passed --conn-rate-test, then we'll perform an interactive rate test against the target.
|
# If the user passed --conn-rate-test, then we'll perform an interactive rate test against the target.
|
||||||
interactive = False
|
interactive = False
|
||||||
|
multiline_output = False
|
||||||
if aconf.conn_rate_test_enabled:
|
if aconf.conn_rate_test_enabled:
|
||||||
interactive = True
|
interactive = True
|
||||||
max_connections = 999999999999999999
|
max_connections = 999999999999999999
|
||||||
@@ -338,6 +339,11 @@ class DHEat:
|
|||||||
DHEat.WHITEB = "\033[1;97m"
|
DHEat.WHITEB = "\033[1;97m"
|
||||||
DHEat.BLUEB = "\033[1;94m"
|
DHEat.BLUEB = "\033[1;94m"
|
||||||
|
|
||||||
|
# Enable multi-line output only if we're running in the Bash shell. This might work in other shells, too, but they are untested.
|
||||||
|
shell = os.getenv("SHELL", default="")
|
||||||
|
if shell.endswith("/bash") or shell == "bash":
|
||||||
|
multiline_output = True
|
||||||
|
|
||||||
rate_str = ""
|
rate_str = ""
|
||||||
if aconf.conn_rate_test_target_rate > 0:
|
if aconf.conn_rate_test_target_rate > 0:
|
||||||
rate_str = " at a max rate of %s%u%s connections per second" % (DHEat.WHITEB, aconf.conn_rate_test_target_rate, DHEat.CLEAR)
|
rate_str = " at a max rate of %s%u%s connections per second" % (DHEat.WHITEB, aconf.conn_rate_test_target_rate, DHEat.CLEAR)
|
||||||
@@ -346,6 +352,10 @@ class DHEat:
|
|||||||
print("Performing non-disruptive rate test against %s[%s]:%u%s with %s%u%s concurrent sockets%s. No Diffie-Hellman requests will be sent." % (DHEat.WHITEB, aconf.host, aconf.port, DHEat.CLEAR, DHEat.WHITEB, concurrent_sockets, DHEat.CLEAR, rate_str))
|
print("Performing non-disruptive rate test against %s[%s]:%u%s with %s%u%s concurrent sockets%s. No Diffie-Hellman requests will be sent." % (DHEat.WHITEB, aconf.host, aconf.port, DHEat.CLEAR, DHEat.WHITEB, concurrent_sockets, DHEat.CLEAR, rate_str))
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
# Make room for the multi-line output.
|
||||||
|
if multiline_output:
|
||||||
|
print("\n\n\n\n")
|
||||||
|
|
||||||
else: # We'll do a non-interactive test as part of a standard audit.
|
else: # We'll do a non-interactive test as part of a standard audit.
|
||||||
# Ensure that the server supports at least one DH algorithm. Otherwise, this test is pointless.
|
# Ensure that the server supports at least one DH algorithm. Otherwise, this test is pointless.
|
||||||
server_dh_kex = []
|
server_dh_kex = []
|
||||||
@@ -357,24 +367,35 @@ class DHEat:
|
|||||||
out.d("Skipping DHEat.dh_rate_test() since server does not support any DH algorithms: [%s]" % ", ".join(kex.kex_algorithms))
|
out.d("Skipping DHEat.dh_rate_test() since server does not support any DH algorithms: [%s]" % ", ".join(kex.kex_algorithms))
|
||||||
return ""
|
return ""
|
||||||
else:
|
else:
|
||||||
out.d("DHEat.dh_rate_test(): starting test; parameters: %f seconds, %u max connections, %u concurrent sockets." % (max_time, max_connections, concurrent_sockets))
|
out.d("DHEat.dh_rate_test(): starting test; parameters: %f seconds, %u max connections, %u concurrent sockets." % (max_time, max_connections, concurrent_sockets), write_now=True)
|
||||||
|
|
||||||
num_attempted_connections = 0
|
num_attempted_connections = 0
|
||||||
num_opened_connections = 0
|
num_opened_connections = 0
|
||||||
socket_list: List[socket.socket] = []
|
num_exceeded_maxstartups = 0
|
||||||
|
socket_dict: Dict[socket.socket, float] = {}
|
||||||
start_timer = time.time()
|
start_timer = time.time()
|
||||||
|
now = start_timer
|
||||||
last_update = start_timer
|
last_update = start_timer
|
||||||
while True:
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
# During non-interactive tests, limit based on time and number of connections. Otherwise, we loop indefinitely until the user presses CTRL-C.
|
# During non-interactive tests, limit based on time and number of connections. Otherwise, we loop indefinitely until the user presses CTRL-C.
|
||||||
if (interactive is False) and ((time.time() - start_timer) >= max_time) and (num_opened_connections >= max_connections):
|
if (interactive is False) and ((now - start_timer) >= max_time) or (num_opened_connections >= max_connections):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# out.d("interactive: %r; time.time() - start_timer: %f; max_time: %f; num_opened_connections: %u; max_connections: %u" % (interactive, time.time() - start_timer, max_time, num_opened_connections, max_connections), write_now=True)
|
||||||
|
|
||||||
# Give the user some interactive feedback.
|
# Give the user some interactive feedback.
|
||||||
if interactive:
|
if interactive:
|
||||||
now = time.time()
|
|
||||||
if (now - last_update) >= 1.0:
|
if (now - last_update) >= 1.0:
|
||||||
seconds_running = now - start_timer
|
seconds_running = now - start_timer
|
||||||
|
if multiline_output:
|
||||||
|
print("\033[5ARun time: %s%.1f%s seconds" % (DHEat.WHITEB, seconds_running, DHEat.CLEAR))
|
||||||
|
print("TCP SYNs: %s%u%s (total); %s%.1f%s (per second)" % (DHEat.WHITEB, num_attempted_connections, DHEat.CLEAR, DHEat.BLUEB, num_attempted_connections / seconds_running, DHEat.CLEAR))
|
||||||
|
print("Completed connections: %s%u%s (total); %s%.1f%s (per second)" % (DHEat.WHITEB, num_opened_connections, DHEat.CLEAR, DHEat.BLUEB, num_opened_connections / seconds_running, DHEat.CLEAR))
|
||||||
|
print("\"Exceeded MaxStartups\" responses: %s%u%s (total); %s%.1f%s (per second)" % (DHEat.WHITEB, num_exceeded_maxstartups, DHEat.CLEAR, DHEat.BLUEB, num_exceeded_maxstartups / seconds_running, DHEat.CLEAR))
|
||||||
|
print("%s%s%s" % (DHEat.WHITEB, spinner[spinner_index], DHEat.CLEAR))
|
||||||
|
else:
|
||||||
print("%s%s%s Run time: %s%.1f%s; TCP SYNs: %s%u%s; Compl. conns: %s%u%s; TCP SYNs/sec: %s%.1f%s; Compl. conns/sec: %s%.1f%s \r" % (DHEat.WHITEB, spinner[spinner_index], DHEat.CLEAR, DHEat.WHITEB, seconds_running, DHEat.CLEAR, DHEat.WHITEB, num_attempted_connections, DHEat.CLEAR, DHEat.WHITEB, num_opened_connections, DHEat.CLEAR, DHEat.BLUEB, num_attempted_connections / seconds_running, DHEat.CLEAR, DHEat.BLUEB, num_opened_connections / seconds_running, DHEat.CLEAR), end="")
|
print("%s%s%s Run time: %s%.1f%s; TCP SYNs: %s%u%s; Compl. conns: %s%u%s; TCP SYNs/sec: %s%.1f%s; Compl. conns/sec: %s%.1f%s \r" % (DHEat.WHITEB, spinner[spinner_index], DHEat.CLEAR, DHEat.WHITEB, seconds_running, DHEat.CLEAR, DHEat.WHITEB, num_attempted_connections, DHEat.CLEAR, DHEat.WHITEB, num_opened_connections, DHEat.CLEAR, DHEat.BLUEB, num_attempted_connections / seconds_running, DHEat.CLEAR, DHEat.BLUEB, num_opened_connections / seconds_running, DHEat.CLEAR), end="")
|
||||||
last_update = now
|
last_update = now
|
||||||
spinner_index = (spinner_index + 1) % 4
|
spinner_index = (spinner_index + 1) % 4
|
||||||
@@ -388,33 +409,59 @@ class DHEat:
|
|||||||
if sleep_time > 0.0:
|
if sleep_time > 0.0:
|
||||||
time.sleep(sleep_time)
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
while (len(socket_list) < concurrent_sockets) and (len(socket_list) + num_opened_connections < max_connections):
|
# Check our sockets to see if they've existed for more than 30 seconds. If so, close them so new ones can be re-opened in their place.
|
||||||
|
timedout_sockets = []
|
||||||
|
for s, create_time in socket_dict.items():
|
||||||
|
if (now - create_time) > 30:
|
||||||
|
timedout_sockets.append(s) # We shouldn't modify the dictionary while iterating over it, so add it to a separate list.
|
||||||
|
|
||||||
|
# Now we can safely close the timed-out sockets.
|
||||||
|
while True:
|
||||||
|
if len(timedout_sockets) == 0: # Ensure that len() is called in every iteration by putting it here instead of the while clause.
|
||||||
|
break
|
||||||
|
|
||||||
|
out.d("Closing timed-out socket.", write_now=True)
|
||||||
|
_close_socket(socket_dict, timedout_sockets[0])
|
||||||
|
del timedout_sockets[0]
|
||||||
|
|
||||||
|
# Open new sockets until we've hit the number of concurrent sockets, or if we exceeded the number of maximum connections.
|
||||||
|
while (len(socket_dict) < concurrent_sockets) and (len(socket_dict) + num_opened_connections < max_connections):
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s.setblocking(False)
|
s.setblocking(False)
|
||||||
|
|
||||||
out.d("Creating socket (%u of %u already exist)..." % (len(socket_list), concurrent_sockets))
|
# out.d("Creating socket (%u of %u already exist)..." % (len(socket_dict), concurrent_sockets), write_now=True)
|
||||||
ret = s.connect_ex((aconf.host, aconf.port))
|
ret = s.connect_ex((aconf.host, aconf.port))
|
||||||
num_attempted_connections += 1
|
num_attempted_connections += 1
|
||||||
if ret in [0, 115]: # Check if connection is successful or EINPROGRESS.
|
if ret in [0, 115]: # Check if connection is successful or EINPROGRESS.
|
||||||
socket_list.append(s)
|
socket_dict[s] = now
|
||||||
|
else:
|
||||||
|
out.d("connect_ex() returned: %d" % ret, write_now=True)
|
||||||
|
|
||||||
|
# out.d("Calling select() on %u sockets..." % len(socket_dict), write_now=True)
|
||||||
|
socket_list: List[socket.socket] = [*socket_dict] # Get a list of sockets from the dictionary.
|
||||||
rlist, _, elist = select.select(socket_list, [], socket_list, 0.1)
|
rlist, _, elist = select.select(socket_list, [], socket_list, 0.1)
|
||||||
|
|
||||||
# For each socket that has something for us to read...
|
# For each socket that has something for us to read...
|
||||||
for s in rlist:
|
for s in rlist:
|
||||||
|
# out.d("Socket in read list.", write_now=True)
|
||||||
buf = b''
|
buf = b''
|
||||||
try:
|
try:
|
||||||
buf = s.recv(8)
|
buf = s.recv(8)
|
||||||
except (ConnectionResetError, BrokenPipeError):
|
# out.d("Banner: %r" % buf, write_now=True)
|
||||||
_close_socket(socket_list, s)
|
except (ConnectionRefusedError, ConnectionResetError, BrokenPipeError, TimeoutError):
|
||||||
|
out.d("Socket error.", write_now=True)
|
||||||
|
_close_socket(socket_dict, s)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If we received the SSH header, we'll count this as an opened connection.
|
# If we received the SSH header, we'll count this as an opened connection.
|
||||||
if buf.startswith(b"SSH-"):
|
if buf.startswith(b"SSH-"):
|
||||||
num_opened_connections += 1
|
num_opened_connections += 1
|
||||||
out.d("Number of opened connections: %u (max: %u)." % (num_opened_connections, max_connections))
|
# out.d("Number of opened connections: %u (max: %u)." % (num_opened_connections, max_connections))
|
||||||
|
elif buf == b"Exceeded":
|
||||||
|
num_exceeded_maxstartups += 1
|
||||||
|
# out.d("Number of \"Exceeded MaxStartups\": %u" % num_exceeded_maxstartups)
|
||||||
|
|
||||||
_close_socket(socket_list, s)
|
_close_socket(socket_dict, s)
|
||||||
|
|
||||||
# Since we just closed the socket, ensure its not in the exception list.
|
# Since we just closed the socket, ensure its not in the exception list.
|
||||||
if s in elist:
|
if s in elist:
|
||||||
@@ -422,20 +469,24 @@ class DHEat:
|
|||||||
|
|
||||||
# Close all sockets that are in the exception state.
|
# Close all sockets that are in the exception state.
|
||||||
for s in elist:
|
for s in elist:
|
||||||
_close_socket(socket_list, s)
|
# out.d("Socket in exception list.", write_now=True)
|
||||||
|
_close_socket(socket_dict, s)
|
||||||
|
|
||||||
# Close any remaining sockets.
|
# Close any remaining sockets.
|
||||||
while len(socket_list) > 0:
|
while True:
|
||||||
_close_socket(socket_list, socket_list[0])
|
if len(socket_dict) == 0: # Ensure that len() is called in every iteration by putting it here instead of the while clause.
|
||||||
|
break
|
||||||
|
|
||||||
|
_close_socket(socket_dict, [*socket_dict][0]) # Close & remove the first socket we find.
|
||||||
|
|
||||||
time_elapsed = time.time() - start_timer
|
time_elapsed = time.time() - start_timer
|
||||||
out.d("DHEat.dh_rate_test() results: time elapsed: %f; connections created: %u" % (time_elapsed, num_opened_connections))
|
out.d("DHEat.dh_rate_test() results: time elapsed: %f; connections created: %u" % (time_elapsed, num_opened_connections), write_now=True)
|
||||||
|
|
||||||
note = ""
|
note = ""
|
||||||
rate = 0.0
|
rate = 0.0
|
||||||
if time_elapsed > 0.0 and num_opened_connections > 0:
|
if time_elapsed > 0.0 and num_opened_connections > 0:
|
||||||
rate = num_opened_connections / time_elapsed
|
rate = num_opened_connections / time_elapsed
|
||||||
out.d("DHEat.dh_rate_test() results: %.1f connections opened per second." % rate)
|
out.d("DHEat.dh_rate_test() results: %.1f connections opened per second." % rate, write_now=True)
|
||||||
|
|
||||||
# If we were able to open connections at a rate greater than 25 per second, then we need to warn the user.
|
# If we were able to open connections at a rate greater than 25 per second, then we need to warn the user.
|
||||||
if rate > DHEat.MAX_SAFE_RATE:
|
if rate > DHEat.MAX_SAFE_RATE:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (C) 2017-2023 Joe Testa (jtesta@positronsecurity.com)
|
Copyright (C) 2017-2024 Joe Testa (jtesta@positronsecurity.com)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
"""
|
"""
|
||||||
# The version to display.
|
# The version to display.
|
||||||
VERSION = 'v3.2.0-dev'
|
VERSION = 'v3.2.0'
|
||||||
|
|
||||||
# SSH software to impersonate
|
# SSH software to impersonate
|
||||||
SSH_HEADER = 'SSH-{0}-OpenSSH_8.2'
|
SSH_HEADER = 'SSH-{0}-OpenSSH_8.2'
|
||||||
|
|||||||
Reference in New Issue
Block a user