6 Commits
6 changed files with 154 additions and 63 deletions
+12 -4
View File
@@ -18,7 +18,7 @@ An executable can only be made on a Windows host because the PyInstaller tool (h
To create package and upload to test server (hint: use API token for test.pypi.org): To create package and upload to test server (hint: use API token for test.pypi.org):
``` ```
$ sudo apt install python3-virtualenv python3.12-venv $ sudo apt install python3-virtualenv python3.14-venv
$ make -f Makefile.pypi $ make -f Makefile.pypi
$ make -f Makefile.pypi uploadtest $ make -f Makefile.pypi uploadtest
``` ```
@@ -48,7 +48,7 @@ To download from production server and verify:
# Snap # Snap
To create the Snap package, run a fully-updated Ubuntu Server 24.04 VM. To create the Snap package, run a fully-updated Ubuntu Server 26.04 VM.
Create the Snap package with: Create the Snap package with:
``` ```
@@ -68,9 +68,17 @@ Upload the Snap with:
# Docker # Docker
Ensure that the `buildx` plugin is available by following the installation instructions available at: https://docs.docker.com/engine/install/ubuntu/ On an Ubuntu Server 26.06 VM, install dependencies with:
```
# apt install docker.io docker-buildx
```
Build a local image with: Ensure that your non-root build user can access the docker daemon with by adding it to the `docker` group:
```
# usermod -a -G docker build_user
```
As a non-root user, build a local image with:
``` ```
$ make -f Makefile.docker $ make -f Makefile.docker
+7 -4
View File
@@ -51,7 +51,7 @@ usage: ssh-audit.py [-h] [-4] [-6] [-b] [-c] [-d]
[--socks5 host:port] [--threads N] [--socks5 host:port] [--threads N]
[host] [host]
# ssh-audit.py v3.4.0-dev, https://github.com/jtesta/ssh-audit # ssh-audit.py v3.4.1-dev, https://github.com/jtesta/ssh-audit
positional arguments: positional arguments:
host target hostname or IPv4/IPv6 address host target hostname or IPv4/IPv6 address
@@ -239,9 +239,9 @@ $ snap install ssh-audit
To install from Dockerhub: To install from Dockerhub:
``` ```
$ docker pull positronsecurity/ssh-audit $ docker pull docker.io/positronsecurity/ssh-audit
``` ```
(Then run with: `docker run -it --rm -p 2222:2222 positronsecurity/ssh-audit 10.1.1.1`) (Then run with: `docker run -it --rm -p 2222:2222 docker.io/positronsecurity/ssh-audit 10.1.1.1`)
The status of various other platform packages can be found below (via Repology): The status of various other platform packages can be found below (via Repology):
@@ -252,7 +252,10 @@ For convenience, a web front-end on top of the command-line tool is available at
## ChangeLog ## ChangeLog
### v3.4.0-dev ### v3.9.1-dev
- Fixed a perhaps rare crash when performing connection rate tests during standard audits.
### v3.9.0 (2026-07-04)
- BIG THANKS to [realmiwi](https://github.com/realmiwi) for being the project's *very first sponsor!!* - BIG THANKS to [realmiwi](https://github.com/realmiwi) for being the project's *very first sponsor!!*
- Added support for Python 3.14. - Added support for Python 3.14.
- Dropped support for Python 3.8, as it reached end-of-life in October 2024. - Dropped support for Python 3.8, as it reached end-of-life in October 2024.
+41 -6
View File
@@ -3,7 +3,7 @@
# #
# The MIT License (MIT) # The MIT License (MIT)
# #
# Copyright (C) 2021-2024 Joe Testa (jtesta@positronsecurity.com) # Copyright (C) 2021-2026 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
@@ -32,19 +32,23 @@
# Pre-requisites # Pre-requisites
echo "Installing pre-requisites..."
sudo apt install -y make sudo apt install -y make
sudo snap install snapcraft --classic sudo snap install snapcraft --classic
sudo snap install review-tools lxd sudo snap install review-tools lxd
# Initialize LXD. # Initialize LXD.
echo -e "\nInitializing LXD..."
sudo lxd init --auto sudo lxd init --auto
# Reset the filesystem from any previous runs. # Reset local files that may have been changed through a previous run.
rm -rf parts/ prime/ snap/ stage/ build/ dist/ src/*.egg-info/ ssh-audit*.snap echo "Resetting local files..."
rm -f ssh-audit*.snap
git checkout snapcraft.yaml 2> /dev/null git checkout snapcraft.yaml 2> /dev/null
git checkout src/ssh_audit/globals.py 2> /dev/null git checkout src/ssh_audit/globals.py 2> /dev/null
# Add the built-in manual page. # Add the built-in manual page.
echo "Processing man page..."
./add_builtin_man_page.sh ./add_builtin_man_page.sh
# Get the version from the globals.py file. # Get the version from the globals.py file.
@@ -67,10 +71,41 @@ if [[ ! $snap_version =~ ^[0-9]\.[0-9]\.[0-9]\-[0-9]$ ]]; then
exit 1 exit 1
fi fi
# Append the version field to the end of the file. Not pretty, but it works. # Set the version field.
echo -e "\nversion: '${snap_version}'" >> snapcraft.yaml sed -i "s/version: \"\"/version: \"${snap_version}\"/" snapcraft.yaml
# Ensure we set the version field correctly.
grep "version: \"${snap_version}\"" snapcraft.yaml > /dev/null 2> /dev/null
if [[ $? != 0 ]]; then
echo "Failed to set version field in snapcraft.yaml."
exit 1
fi
# Set the SNAP_PACKAGE variable to True so that file permission errors give more user-friendly # Set the SNAP_PACKAGE variable to True so that file permission errors give more user-friendly
sed -i 's/SNAP_PACKAGE = False/SNAP_PACKAGE = True/' src/ssh_audit/globals.py sed -i 's/SNAP_PACKAGE = False/SNAP_PACKAGE = True/' src/ssh_audit/globals.py
snapcraft --use-lxd && echo -e "\nDone.\n" # Clean any previous builds.
echo "Running 'snapcraft clean'..."
snapcraft clean
# Build the snap file.
echo "Running 'snapcraft pack --use-lxd'..."
snapcraft pack --use-lxd
if [[ $? != 0 ]]; then
echo -e "\nPack process failed: $?"
exit 1
else
echo -e "Pack process succeeded."
fi
# Run the linter on it.
echo "Running 'snapcraft lint' on *.snap..."
snapcraft lint --quiet *.snap
if [[ $? != 0 ]]; then
echo -e "\nLint process failed: $?"
exit 1
else
echo -e "Lint process succeeded."
fi
echo -e "\nDone!\n"
+18 -7
View File
@@ -1,23 +1,34 @@
name: ssh-audit name: ssh-audit
# 'version' field will be automatically added by build_snap.sh. title: ssh-audit
license: 'MIT'
summary: ssh-audit summary: ssh-audit
version: "" # Set at run-time by build_snap.sh.
license: 'MIT'
description: | description: |
SSH server and client security configuration auditor. Official repository: <https://github.com/jtesta/ssh-audit> SSH server and client security configuration auditor. Official repository: <https://github.com/jtesta/ssh-audit>
contact:
base: core22 - https://github.com/jtesta/ssh-audit/issues
issues:
- https://github.com/jtesta/ssh-audit/issues
source-code:
- https://github.com/jtesta/ssh-audit
website:
- https://github.com/jtesta/ssh-audit
#base: core26
base: core24
grade: stable grade: stable
confinement: strict confinement: strict
architectures: platforms:
- build-on: [amd64] multi:
build-on: [amd64]
build-for: [all] build-for: [all]
apps: apps:
ssh-audit: ssh-audit:
command: bin/ssh-audit command: bin/ssh-audit
plugs: [network,network-bind,home] plugs: [network, network-bind, home]
parts: parts:
ssh-audit: ssh-audit:
plugin: python plugin: python
#stage-packages: [python3.14, python3.14-venv]
source: . source: .
+72 -38
View File
@@ -194,7 +194,9 @@ class DHEat:
self.num_connect_timeouts = 0 self.num_connect_timeouts = 0
self.num_read_timeouts = 0 self.num_read_timeouts = 0
self.num_socket_exceptions = 0 self.num_socket_exceptions = 0
self.num_openssh_throttled_connections = 0 self.num_em = 0 # Number of "Exceeded MaxStartups" messages.
self.num_naatt = 0 # Number of "No allowed at this time" messages.
self.num_invalid_banners = 0
# The time we started the attack. # The time we started the attack.
self.start_timer = 0.0 self.start_timer = 0.0
@@ -322,8 +324,12 @@ class DHEat:
except OSError: except OSError:
pass pass
if s in socket_dict:
del socket_dict[s] del socket_dict[s]
# Check if this platform has socket.AF_UNIX. Windows does not.
has_af_unix = hasattr(socket, 'AF_UNIX')
# Resolve the target into an IP address # Resolve the target into an IP address
out.d("Resolving target %s..." % aconf.host) out.d("Resolving target %s..." % aconf.host)
target_address_family, target_ip_address = DHEat._resolve_hostname(aconf.host, aconf.ip_version_preference) target_address_family, target_ip_address = DHEat._resolve_hostname(aconf.host, aconf.ip_version_preference)
@@ -351,10 +357,10 @@ class DHEat:
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 = f" at a max rate of {DHEat.WHITEB}{aconf.conn_rate_test_target_rate:d}{DHEat.CLEAR} connections per second"
print() print()
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, target_ip_address, aconf.port, DHEat.CLEAR, DHEat.WHITEB, concurrent_sockets, DHEat.CLEAR, rate_str)) print(f"Performing non-disruptive rate test against {DHEat.WHITEB}[{target_ip_address}]:{aconf.port:d}{DHEat.CLEAR} with {DHEat.WHITEB}{concurrent_sockets:d}{DHEat.CLEAR} concurrent sockets{rate_str}. No Diffie-Hellman requests will be sent.\n")
print() print()
# Make room for the multi-line output. # Make room for the multi-line output.
@@ -369,14 +375,17 @@ class DHEat:
server_dh_kex.append(server_kex) server_dh_kex.append(server_kex)
if len(server_dh_kex) == 0: if len(server_dh_kex) == 0:
out.d("Skipping DHEat.dh_rate_test() since server does not support any DH algorithms: [%s]" % ", ".join(kex.kex_algorithms)) kex_list = ", ".join(kex.kex_algorithms)
out.d(f"Skipping DHEat.dh_rate_test() since server does not support any DH algorithms: [{kex_list}]")
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), write_now=True) out.d(f"DHEat.dh_rate_test(): starting test; parameters: {max_time:,.1f} seconds, {max_connections:,d} max connections, {concurrent_sockets:,d} concurrent sockets.", write_now=True)
num_attempted_connections = 0 num_attempted_connections = 0
num_opened_connections = 0 num_opened_connections = 0
num_exceeded_maxstartups = 0 num_em = 0 # Number of "Exceeded MaxStartups" messages.
num_naatt = 0 # Number of "Not available at this time" messages.
num_invalid_banners = 0
socket_dict: Dict[socket.socket, float] = {} socket_dict: Dict[socket.socket, float] = {}
start_timer = time.time() start_timer = time.time()
now = start_timer now = start_timer
@@ -395,13 +404,15 @@ class DHEat:
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: if multiline_output:
print("\033[5ARun time: %s%.1f%s seconds" % (DHEat.WHITEB, seconds_running, DHEat.CLEAR)) print(f"\033[7A Run time: {DHEat.WHITEB}{seconds_running:,.1f}{DHEat.CLEAR} seconds")
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(f" TCP SYNs: {DHEat.WHITEB}{num_attempted_connections / seconds_running:,.1f}/sec{DHEat.CLEAR}; {DHEat.WHITEB}{num_attempted_connections:,d}{DHEat.CLEAR} total ")
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(f" Completed connections: {DHEat.WHITEB}{num_opened_connections / seconds_running:,.1f}/sec{DHEat.CLEAR}; {DHEat.WHITEB}{num_opened_connections:,d}{DHEat.CLEAR} total ")
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(f" \"Exceeded MaxStartups\" responses: {DHEat.WHITEB}{num_em / seconds_running:,.1f}/sec{DHEat.CLEAR}; {DHEat.WHITEB}{num_em:,d}{DHEat.CLEAR} total ")
print("%s%s%s" % (DHEat.WHITEB, spinner[spinner_index], DHEat.CLEAR)) print(f"\"Not allowed at this time\" responses: {DHEat.WHITEB}{num_naatt / seconds_running:,.1f}/sec{DHEat.CLEAR}; {DHEat.WHITEB}{num_naatt:,d}{DHEat.CLEAR} total ")
print(f" Invalid banners: {DHEat.WHITEB}{num_invalid_banners / seconds_running:,.1f}/sec{DHEat.CLEAR}; {DHEat.WHITEB}{num_invalid_banners:,d}{DHEat.CLEAR} total ")
print(DHEat.WHITEB + spinner[spinner_index] + DHEat.CLEAR)
else: 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(f"{DHEat.WHITEB}{spinner[spinner_index]}{DHEat.CLEAR} Run time: {DHEat.WHITEB}{seconds_running:,.1f}{DHEat.CLEAR}; TCP SYNs: {DHEat.WHITEB}{num_attempted_connections:,d}{DHEat.CLEAR}; Compl. conns: {DHEat.WHITEB}{num_opened_connections:,d}{DHEat.CLEAR}; TCP SYNs/sec: {DHEat.BLUEB}{num_attempted_connections / seconds_running:,.1f}{DHEat.CLEAR}; Compl. conns/sec: {DHEat.BLUEB}{num_opened_connections / seconds_running:,.1f}{DHEat.CLEAR} \r", end="")
last_update = now last_update = now
spinner_index = (spinner_index + 1) % 4 spinner_index = (spinner_index + 1) % 4
@@ -435,7 +446,7 @@ class DHEat:
s.setblocking(False) s.setblocking(False)
# out.d("Creating socket (%u of %u already exist)..." % (len(socket_dict), concurrent_sockets), write_now=True) # out.d("Creating socket (%u of %u already exist)..." % (len(socket_dict), concurrent_sockets), write_now=True)
if target_address_family == socket.AF_UNIX: if has_af_unix and (target_address_family == socket.AF_UNIX):
ret = s.connect_ex(target_ip_address) ret = s.connect_ex(target_ip_address)
else: else:
ret = s.connect_ex((target_ip_address, aconf.port)) ret = s.connect_ex((target_ip_address, aconf.port))
@@ -467,8 +478,14 @@ class DHEat:
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": elif buf == b"Exceeded":
num_exceeded_maxstartups += 1 num_em += 1
# out.d("Number of \"Exceeded MaxStartups\": %u" % num_exceeded_maxstartups) # out.d("Number of \"Exceeded MaxStartups\": %u" % num_exceeded_maxstartups)
elif buf == b"Not allo":
num_naatt += 1
# out.d("Number of \"Not allowed at this time\": {num_naatt:,d}")
else:
num_invalid_banners += 1
# out.d("Number of invalid banners: {num_invalid_banners:,d}")
_close_socket(socket_dict, s) _close_socket(socket_dict, s)
@@ -720,35 +737,39 @@ class DHEat:
# Print extensive statistics on what just happened. # Print extensive statistics on what just happened.
seconds_running = time.time() - self.start_timer seconds_running = time.time() - self.start_timer
print("\n\n") print("\n\n")
print(" %s %sSTATISTICS%s %s" % (self.BAR_CHART, self.WHITEB, self.CLEAR, self.CHART_UPWARDS)) print(f" {self.BAR_CHART} {self.WHITEB}STATISTICS{self.CLEAR} {self.CHART_UPWARDS}")
print(" %s----------%s" % (self.WHITEB, self.CLEAR)) print(f" {self.WHITEB}----------{self.CLEAR}")
print() print()
print(" Run time: %s%.1f seconds%s" % (self.WHITEB, seconds_running, self.CLEAR)) print(f" Run time: {self.WHITEB}{seconds_running:,.1f} seconds{self.CLEAR}")
print() print()
print(" Attempted TCP connections: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_attempted_tcp_connections / seconds_running, self.num_attempted_tcp_connections, self.CLEAR)) print(f" Attempted TCP connections: {self.WHITEB}{self.num_attempted_tcp_connections / seconds_running:,.1f}/sec, {self.num_attempted_tcp_connections:,d} total{self.CLEAR}")
print(" Successful TCP connections: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_successful_tcp_connections / seconds_running, self.num_successful_tcp_connections, self.CLEAR)) print(f" Successful TCP connections: {self.WHITEB}{self.num_successful_tcp_connections / seconds_running:,.1f}/sec, {self.num_successful_tcp_connections:,d} total{self.CLEAR}")
print() print()
print(" Bytes written: %s%s/sec, %s total%s" % (self.WHITEB, DHEat.add_byte_units(self.num_bytes_written / seconds_running), DHEat.add_byte_units(self.num_bytes_written), self.CLEAR)) print(f" Bytes written: {self.WHITEB}{DHEat.add_byte_units(self.num_bytes_written / seconds_running)}/sec, {DHEat.add_byte_units(self.num_bytes_written)} total{self.CLEAR}")
print() print()
print(" Successful DH KEX replies: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_successful_dh_kex / seconds_running, self.num_successful_dh_kex, self.CLEAR)) print(f" Successful DH KEX replies: {self.WHITEB}{self.num_successful_dh_kex / seconds_running:,.1f}/sec, {self.num_successful_dh_kex:,d} total{self.CLEAR}")
print(" Unexpected DH KEX replies: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_failed_dh_kex / seconds_running, self.num_failed_dh_kex, self.CLEAR)) print(f" Unexpected DH KEX replies: {self.WHITEB}{self.num_failed_dh_kex / seconds_running:,.1f}/sec, {self.num_failed_dh_kex:,d} total{self.CLEAR}")
print("\"Exceeded MaxStartups\" replies*: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_openssh_throttled_connections / seconds_running, self.num_openssh_throttled_connections, self.CLEAR)) print(f" \"Exceeded MaxStartups\" replies*: {self.WHITEB}{self.num_em / seconds_running:,.1f}/sec, {self.num_em:,d} total{self.CLEAR}")
print(f"\"Not allowed at this time\" replies+: {self.WHITEB}{self.num_naatt / seconds_running:,.1f}/sec, {self.num_naatt:,d} total{self.CLEAR}")
print(f" Invalid banners: {self.WHITEB}{self.num_invalid_banners / seconds_running:,.1f}/sec, {self.num_invalid_banners:,.1f} total{self.CLEAR}")
print() print()
print(" Connection timeouts: %s%.1f/sec, %u total%s (timeout setting: %.1f sec)" % (self.WHITEB, self.num_connect_timeouts / seconds_running, self.num_connect_timeouts, self.CLEAR, self.connect_timeout)) print(f" Connection timeouts: {self.WHITEB}{self.num_connect_timeouts / seconds_running:,.1f}/sec, {self.num_connect_timeouts:,d} total{self.CLEAR} (timeout setting: {self.connect_timeout:,.1f} sec)")
print(" Read timeouts: %s%.1f/sec, %u total%s (timeout setting: %.1f sec)" % (self.WHITEB, self.num_read_timeouts / seconds_running, self.num_read_timeouts, self.CLEAR, self.read_timeout)) print(f" Read timeouts: {self.WHITEB}{self.num_read_timeouts / seconds_running:,.1f}/sec, {self.num_read_timeouts:,d} total{self.CLEAR} (timeout setting: {self.read_timeout:,.1f} sec)")
print(" Socket exceptions: %s%.1f/sec, %u total%s" % (self.WHITEB, self.num_socket_exceptions / seconds_running, self.num_socket_exceptions, self.CLEAR)) print(f" Socket exceptions: {self.WHITEB}{self.num_socket_exceptions / seconds_running:,.1f}/sec, {self.num_socket_exceptions:,d} total{self.CLEAR}")
print() print()
if seconds_running < 5.0: if seconds_running < 5.0:
print("%sTotal run time was under 5 seconds; try running it for longer to get more accurate analysis.%s" % (DHEat.YELLOWB, DHEat.CLEAR)) print(f"{DHEat.YELLOWB}Total run time was under 5 seconds; try running it for longer to get more accurate analysis.{DHEat.CLEAR}")
elif self.num_successful_tcp_connections / seconds_running < DHEat.MAX_SAFE_RATE: elif self.num_successful_tcp_connections / seconds_running < DHEat.MAX_SAFE_RATE:
print("Because the number of successful TCP connections per second (%.1f) is less than %.1f, it appears that the target %sis using rate limiting%s to prevent CPU exaustion." % (self.num_successful_tcp_connections / seconds_running, DHEat.MAX_SAFE_RATE, DHEat.GREENB, DHEat.CLEAR)) print(f"Because the number of successful TCP connections per second ({self.num_successful_tcp_connections / seconds_running:,.1f}) is less than {DHEat.MAX_SAFE_RATE:,.1f}, it appears that the target {DHEat.GREENB}is using rate limiting{DHEat.CLEAR} to prevent CPU exaustion.")
else: else:
print("Because the number of successful TCP connections per second (%.1f) is greater than %.1f, it appears that the target %sis NOT using rate limiting%s to prevent CPU exaustion." % (self.num_successful_tcp_connections / seconds_running, DHEat.MAX_SAFE_RATE, DHEat.REDB, DHEat.CLEAR)) print(f"Because the number of successful TCP connections per second ({self.num_successful_tcp_connections / seconds_running:,.1f}) is greater than {DHEat.MAX_SAFE_RATE:,.1f}, it appears that the target {DHEat.REDB}is NOT using rate limiting{DHEat.CLEAR} to prevent CPU exaustion.")
print() print()
print() print()
print(" * OpenSSH has a throttling mechanism (controlled by the MaxStartups directive) to prevent too many pre-authentication connections from overwhelming the server. When triggered, the server will probabilistically return \"Exceeded MaxStartups\" instead of the usual SSH banner, then terminate the connection. In order to maximize the DoS effectiveness, this metric should be greater than zero, though the ideal rate of rejections depends on the target server's CPU resources.") print(" [*] Prior to OpenSSH 9.8, the sole connection throttling mechanism is controlled by the MaxStartups directive. This prevents too many pre-authentication connections from overwhelming the server. When triggered, the server will probabilistically return \"Exceeded MaxStartups\" instead of the usual SSH banner, then terminate the connection. In order to maximize the DoS effectiveness, this metric should be greater than zero, though the ideal rate of rejections depends on the target server's CPU resources.")
print()
print(" [+] Starting in OpenSSH 9.8, a more fine-gained connection throttling mechanism was added via the PerSourcePenalties directive. When triggered, the server returns \"Not allowed at this time\" instead of the usual SSH banner. This countermeasure is more effective than simply relying on the MaxStartups directive, and seems to block this attack under its default settings.")
print() print()
@@ -775,7 +796,6 @@ class DHEat:
def _run(self) -> bool: def _run(self) -> bool:
'''Where all the magic happens.''' '''Where all the magic happens.'''
self.output() self.output()
if sys.platform == "win32": if sys.platform == "win32":
self.output("%sWARNING:%s this feature has not been thoroughly tested on Windows. It may perform worse than on UNIX OSes." % (self.YELLOWB, self.CLEAR)) self.output("%sWARNING:%s this feature has not been thoroughly tested on Windows. It may perform worse than on UNIX OSes." % (self.YELLOWB, self.CLEAR))
@@ -887,14 +907,16 @@ class DHEat:
self.num_connect_timeouts += thread_statistics['num_connect_timeouts'] self.num_connect_timeouts += thread_statistics['num_connect_timeouts']
self.num_read_timeouts += thread_statistics['num_read_timeouts'] self.num_read_timeouts += thread_statistics['num_read_timeouts']
self.num_socket_exceptions += thread_statistics['num_socket_exceptions'] self.num_socket_exceptions += thread_statistics['num_socket_exceptions']
self.num_openssh_throttled_connections += thread_statistics['num_openssh_throttled_connections'] self.num_em += thread_statistics['num_em']
self.num_naatt += thread_statistics['num_naatt']
self.num_invalid_banners += thread_statistics['num_invalid_banners']
except queue.Empty: # If Queue.get() timeout exceeded. except queue.Empty: # If Queue.get() timeout exceeded.
pass pass
now = time.time() now = time.time()
if (now - last_update) >= 1.0: if (now - last_update) >= 1.0:
seconds_running = now - self.start_timer seconds_running = now - self.start_timer
print("%s%s%s TCP SYNs/sec: %s%u%s; Compl. conns/sec: %s%u%s; Bytes sent/sec: %s%s%s; DH kex/sec: %s%u%s \r" % (self.WHITEB, spinner[spinner_index], self.CLEAR, self.BLUEB, self.num_attempted_tcp_connections / seconds_running, self.CLEAR, self.BLUEB, self.num_successful_tcp_connections / seconds_running, self.CLEAR, self.BLUEB, DHEat.add_byte_units(self.num_bytes_written / seconds_running), self.CLEAR, self.PURPLEB, self.num_successful_dh_kex / seconds_running, self.CLEAR), end="") print(f"{self.WHITEB}{spinner[spinner_index]}{self.CLEAR} TCP SYNs/sec: {self.BLUEB}{int(self.num_attempted_tcp_connections / seconds_running):,d}{self.CLEAR}; Compl. conns/sec: {self.BLUEB}{self.num_successful_tcp_connections / seconds_running:,.1f}{self.CLEAR}; Bytes sent/sec: {self.BLUEB}{DHEat.add_byte_units(self.num_bytes_written / seconds_running)}{self.CLEAR}; DH kex/sec: {self.PURPLEB}{self.num_successful_dh_kex / seconds_running:,.1f}{self.CLEAR} \r", end="")
last_update = now last_update = now
spinner_index = (spinner_index + 1) % 4 spinner_index = (spinner_index + 1) % 4
@@ -942,13 +964,16 @@ class DHEat:
num_connect_timeouts = 0 num_connect_timeouts = 0
num_read_timeouts = 0 num_read_timeouts = 0
num_socket_exceptions = 0 num_socket_exceptions = 0
num_openssh_throttled_connections = 0 num_em = 0 # Number of "Exceeded MaxStartups" messages received.
num_naatt = 0 # Number of "Not allowed at this time" messages received.
num_invalid_banners = 0
num_loops_since_last_statistics_sync = 0 num_loops_since_last_statistics_sync = 0
while True: while True:
num_loops_since_last_statistics_sync += 1 num_loops_since_last_statistics_sync += 1
# Instead of flooding the parent process with statistics, report our stats only every 5 connections. # Instead of flooding the parent process with statistics, report our stats only every 5 connections.
# TODO: this sends too quickly in the case of application-level rate limiting, resulting in client-side CPU exhaustion instead! :O Perhaps a dynamic number of loops should be skipped, based on what would result in 1 message per 2-3 seconds.
if num_loops_since_last_statistics_sync > 5: if num_loops_since_last_statistics_sync > 5:
num_loops_since_last_statistics_sync = 0 num_loops_since_last_statistics_sync = 0
@@ -961,7 +986,9 @@ class DHEat:
'num_connect_timeouts': num_connect_timeouts, 'num_connect_timeouts': num_connect_timeouts,
'num_read_timeouts': num_read_timeouts, 'num_read_timeouts': num_read_timeouts,
'num_socket_exceptions': num_socket_exceptions, 'num_socket_exceptions': num_socket_exceptions,
'num_openssh_throttled_connections': num_openssh_throttled_connections, 'num_em': num_em,
'num_naatt': num_naatt,
'num_invalid_banners': num_invalid_banners
}) })
# Since we sent our statistics, reset them all back to zero. # Since we sent our statistics, reset them all back to zero.
@@ -973,7 +1000,9 @@ class DHEat:
num_connect_timeouts = 0 num_connect_timeouts = 0
num_read_timeouts = 0 num_read_timeouts = 0
num_socket_exceptions = 0 num_socket_exceptions = 0
num_openssh_throttled_connections = 0 num_em = 0
num_naatt = 0
num_invalid_banners = 0
s = socket.socket(target_address_family, socket.SOCK_STREAM) s = socket.socket(target_address_family, socket.SOCK_STREAM)
s.settimeout(connect_timeout) s.settimeout(connect_timeout)
@@ -1016,11 +1045,16 @@ class DHEat:
if banner.startswith(b"SSH-2.0-") or banner.startswith(b"SSH-1"): if banner.startswith(b"SSH-2.0-") or banner.startswith(b"SSH-1"):
num_successful_tcp_connections += 1 num_successful_tcp_connections += 1
elif banner == b'Exceeded MaxStartups': elif banner == b'Exceeded MaxStartups':
num_openssh_throttled_connections += 1 num_em += 1
_close_socket(s)
continue
elif banner == b'Not allowed at this time':
num_naatt += 1
_close_socket(s) _close_socket(s)
continue continue
else: else:
self.debug("Invalid banner received: %r" % banner) num_invalid_banners += 1
# self.debug("Invalid banner received: %r" % banner)
_close_socket(s) _close_socket(s)
continue continue
+3 -3
View File
@@ -1,7 +1,7 @@
""" """
The MIT License (MIT) The MIT License (MIT)
Copyright (C) 2017-2024 Joe Testa (jtesta@positronsecurity.com) Copyright (C) 2017-2026 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,10 +22,10 @@
THE SOFTWARE. THE SOFTWARE.
""" """
# The version to display. # The version to display.
VERSION = 'v3.4.0-dev' VERSION = 'v3.9.1-dev'
# SSH software to impersonate # SSH software to impersonate
SSH_HEADER = 'SSH-{0}-OpenSSH_8.2' SSH_HEADER = 'SSH-{0}-OpenSSH_10.3'
# The URL to the Github issues tracker. # The URL to the Github issues tracker.
GITHUB_ISSUES_URL = 'https://github.com/jtesta/ssh-audit/issues' GITHUB_ISSUES_URL = 'https://github.com/jtesta/ssh-audit/issues'