Added support for scanning servers over UNIX sockets. (#351)

This commit is contained in:
Joe Testa
2026-06-14 17:21:13 -04:00
parent abf5b8326a
commit 0ccb915f37
6 changed files with 70 additions and 20 deletions
+6 -3
View File
@@ -93,7 +93,8 @@ optional arguments:
used) used)
-T targets.txt, --targets targets.txt -T targets.txt, --targets targets.txt
a file containing a list of target hosts (one per a file containing a list of target hosts (one per
line, format HOST[:PORT]). Use -p/--port to set the line, format 'HOST[:PORT]'; for UNIX socket servers,
use 'unix:///path/socket'). Use -p/--port to set the
default port for all hosts. Use --threads to control default port for all hosts. Use --threads to control
concurrent scans concurrent scans
-t N, --timeout N timeout (in seconds) for connection and reading -t N, --timeout N timeout (in seconds) for connection and reading
@@ -142,6 +143,7 @@ ssh-audit 127.0.0.1
ssh-audit 127.0.0.1:222 ssh-audit 127.0.0.1:222
ssh-audit ::1 ssh-audit ::1
ssh-audit [::1]:222 ssh-audit [::1]:222
ssh-audit unix:///run/ssh-unix-local/socket
``` ```
To run a standard audit against many servers (place targets into servers.txt, one on each line in the format of `HOST[:PORT]`): To run a standard audit against many servers (place targets into servers.txt, one on each line in the format of `HOST[:PORT]`):
@@ -150,13 +152,13 @@ To run a standard audit against many servers (place targets into servers.txt, on
ssh-audit -T servers.txt ssh-audit -T servers.txt
``` ```
To audit a client configuration (listens on port 2222 by default; connect using `ssh -p 2222 anything@localhost`): To audit a client configuration (listens on port 2222/tcp by default; connect using `ssh -p 2222 anything@localhost`):
``` ```
ssh-audit -c ssh-audit -c
``` ```
To audit a client configuration, with a listener on port 4567: To audit a client configuration, with a listener on port 4567/tcp:
``` ```
ssh-audit -c -p 4567 ssh-audit -c -p 4567
``` ```
@@ -260,6 +262,7 @@ For convenience, a web front-end on top of the command-line tool is available at
- Migrated from deprecated `getopt` module to `argparse`; partial credit [oam7575](https://github.com/oam7575). - Migrated from deprecated `getopt` module to `argparse`; partial credit [oam7575](https://github.com/oam7575).
- When running against multiple hosts, now prints each target host regardless of output level. - When running against multiple hosts, now prints each target host regardless of output level.
- Batch mode (`-b`) no longer automatically enables verbose mode, due to sometimes confusing results; users can still explicitly enable verbose mode using the `-v` flag. - Batch mode (`-b`) no longer automatically enables verbose mode, due to sometimes confusing results; users can still explicitly enable verbose mode using the `-v` flag.
- Added UNIX server socket scanning (specify the target with `unix:///path/to/socket`).
- Added built-in policy for OpenSSH 10.0. - Added built-in policy for OpenSSH 10.0.
- Added hardening guides and policies for Debian 13. - Added hardening guides and policies for Debian 13.
- Added 2 new key exchanges: `mlkem768nistp256-sha256`, `mlkem1024nistp384-sha384`. - Added 2 new key exchanges: `mlkem768nistp256-sha256`, `mlkem1024nistp384-sha384`.
+9 -1
View File
@@ -1,7 +1,7 @@
""" """
The MIT License (MIT) The MIT License (MIT)
Copyright (C) 2023-2024 Joe Testa (jtesta@positronsecurity.com) Copyright (C) 2023-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
@@ -435,7 +435,11 @@ 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:
ret = s.connect_ex(target_ip_address)
else:
ret = s.connect_ex((target_ip_address, aconf.port)) ret = s.connect_ex((target_ip_address, aconf.port))
num_attempted_connections += 1 num_attempted_connections += 1
if ret in [0, errno.EINPROGRESS, errno.EWOULDBLOCK]: if ret in [0, errno.EINPROGRESS, errno.EWOULDBLOCK]:
socket_dict[s] = now socket_dict[s] = now
@@ -752,6 +756,10 @@ class DHEat:
def _resolve_hostname(host: str, ip_version_preference: List[int]) -> Tuple[int, str]: def _resolve_hostname(host: str, ip_version_preference: List[int]) -> Tuple[int, str]:
'''Resolves a hostname to its IPv4 or IPv6 address, depending on user preference.''' '''Resolves a hostname to its IPv4 or IPv6 address, depending on user preference.'''
# First check if this is a UNIX socket.
if host.startswith("unix://"):
return int(socket.AF_UNIX), host[7:]
family = socket.AF_UNSPEC family = socket.AF_UNSPEC
if len(ip_version_preference) == 1: if len(ip_version_preference) == 1:
family = socket.AF_INET if ip_version_preference[0] == 4 else socket.AF_INET6 family = socket.AF_INET if ip_version_preference[0] == 4 else socket.AF_INET6
+6 -4
View File
@@ -2,7 +2,7 @@
""" """
The MIT License (MIT) The MIT License (MIT)
Copyright (C) 2017-2025 Joe Testa (jtesta@positronsecurity.com) Copyright (C) 2017-2026 Joe Testa (jtesta@positronsecurity.com)
Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu)
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
@@ -915,19 +915,21 @@ def process_commandline(out: OutputBuffer, args: List[str]) -> 'AuditConf': # p
Hardening_Guides.print_hardening_guide(out, argument.get_hardening_guide) Hardening_Guides.print_hardening_guide(out, argument.get_hardening_guide)
sys.exit(exitcodes.GOOD) sys.exit(exitcodes.GOOD)
# If we're doing a server audit against a single target.
if aconf.client_audit is False and aconf.target_file is None: if aconf.client_audit is False and aconf.target_file is None:
if oport is not None:
host = argument.host host = argument.host
else: if oport is None:
host, port = Utils.parse_host_and_port(argument.host) host, port = Utils.parse_host_and_port(argument.host)
if not host and aconf.target_file is None: if not host and aconf.target_file is None:
out.fail("target host is not specified", write_now=True) out.fail("target host is not specified", write_now=True)
sys.exit(exitcodes.UNKNOWN_ERROR) sys.exit(exitcodes.UNKNOWN_ERROR)
if oport is None and aconf.client_audit: # The default port to listen on during a client audit is 2222. # For client audits, if an explicit port isn't set, default to 2222/tcp.
if aconf.client_audit and oport is None:
port = 2222 port = 2222
# If an explicit port was set by the user, parse it.
if oport is not None: if oport is not None:
port = Utils.parse_int(oport) port = Utils.parse_int(oport)
if port < 1 or port > 65535: if port < 1 or port > 65535:
+35 -3
View File
@@ -1,7 +1,7 @@
""" """
The MIT License (MIT) The MIT License (MIT)
Copyright (C) 2017-2025 Joe Testa (jtesta@positronsecurity.com) Copyright (C) 2017-2026 Joe Testa (jtesta@positronsecurity.com)
Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu)
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
@@ -51,6 +51,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
SM_BANNER_SENT = 1 SM_BANNER_SENT = 1
def __init__(self, outputbuffer: 'OutputBuffer', host: Optional[str], port: int, ip_version_preference: List[int] = [], timeout: Union[int, float] = 5, timeout_set: bool = False) -> None: # pylint: disable=dangerous-default-value def __init__(self, outputbuffer: 'OutputBuffer', host: Optional[str], port: int, ip_version_preference: List[int] = [], timeout: Union[int, float] = 5, timeout_set: bool = False) -> None: # pylint: disable=dangerous-default-value
super(SSH_Socket, self).__init__() super(SSH_Socket, self).__init__()
self.__outputbuffer = outputbuffer self.__outputbuffer = outputbuffer
@@ -73,6 +74,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
self.client_host: Optional[str] = None self.client_host: Optional[str] = None
self.client_port = None self.client_port = None
def _resolve(self) -> Iterable[Tuple[int, Tuple[Any, ...]]]: def _resolve(self) -> Iterable[Tuple[int, Tuple[Any, ...]]]:
"""Resolves a hostname into a list of IPs """Resolves a hostname into a list of IPs
Raises Raises
@@ -95,11 +97,13 @@ class SSH_Socket(ReadBuf, WriteBuf):
if socktype == socket.SOCK_STREAM: if socktype == socket.SOCK_STREAM:
yield af, addr yield af, addr
# Listens on a server socket and accepts one connection (used for
# auditing client connections).
def listen_and_accept(self) -> None: def listen_and_accept(self) -> None:
'''Listens on a server socket and accepts one connection (used for auditing client connections).'''
try: try:
self.__outputbuffer.d(f"Listening on 0.0.0.0:{self.__port}...", write_now=True)
# Socket to listen on all IPv4 addresses. # Socket to listen on all IPv4 addresses.
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -110,6 +114,8 @@ class SSH_Socket(ReadBuf, WriteBuf):
print("Warning: failed to listen on any IPv4 interfaces: %s" % str(e), file=sys.stderr) print("Warning: failed to listen on any IPv4 interfaces: %s" % str(e), file=sys.stderr)
try: try:
self.__outputbuffer.d(f"Listening on [::]:{self.__port}...", write_now=True)
# Socket to listen on all IPv6 addresses. # Socket to listen on all IPv6 addresses.
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -150,11 +156,23 @@ class SSH_Socket(ReadBuf, WriteBuf):
c.settimeout(self.__timeout) c.settimeout(self.__timeout)
self.__sock = c self.__sock = c
def connect(self) -> Optional[str]: def connect(self) -> Optional[str]:
'''Returns None on success, or an error string.''' '''Returns None on success, or an error string.'''
err = None err = None
s = None s = None
try: try:
# If we're connecting to a UNIX socket.
if self.__host.startswith("unix://"):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(self.__timeout)
s.connect(self.__host[7:])
self.__sock = s
return None
# We're connecting to an Internet host.
else:
for af, addr in self._resolve(): for af, addr in self._resolve():
s = socket.socket(af, socket.SOCK_STREAM) s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(self.__timeout) s.settimeout(self.__timeout)
@@ -162,6 +180,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
s.connect(addr) s.connect(addr)
self.__sock = s self.__sock = s
return None return None
except socket.error as e: except socket.error as e:
err = e err = e
self._close_socket(s) self._close_socket(s)
@@ -170,8 +189,10 @@ class SSH_Socket(ReadBuf, WriteBuf):
else: else:
errt = (self.__host, self.__port, err) errt = (self.__host, self.__port, err)
errm = 'cannot connect to {} port {}: {}'.format(*errt) errm = 'cannot connect to {} port {}: {}'.format(*errt)
return '[exception] {}'.format(errm) return '[exception] {}'.format(errm)
def get_banner(self) -> Tuple[Optional['Banner'], List[str], Optional[str]]: def get_banner(self) -> Tuple[Optional['Banner'], List[str], Optional[str]]:
self.__outputbuffer.d('Getting banner...', write_now=True) self.__outputbuffer.d('Getting banner...', write_now=True)
@@ -201,6 +222,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
return self.__banner, self.__header, e return self.__banner, self.__header, e
def recv(self, size: int = 2048) -> Tuple[int, Optional[str]]: def recv(self, size: int = 2048) -> Tuple[int, Optional[str]]:
if self.__sock is None: if self.__sock is None:
return -1, 'not connected' return -1, 'not connected'
@@ -221,6 +243,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
self._buf.seek(pos, 0) self._buf.seek(pos, 0)
return len(data), None return len(data), None
def send(self, data: bytes) -> Tuple[int, Optional[str]]: def send(self, data: bytes) -> Tuple[int, Optional[str]]:
if self.__sock is None: if self.__sock is None:
return -1, 'not connected' return -1, 'not connected'
@@ -243,16 +266,19 @@ class SSH_Socket(ReadBuf, WriteBuf):
kex.write(self) kex.write(self)
self.send_packet() self.send_packet()
def send_banner(self, banner: str) -> None: def send_banner(self, banner: str) -> None:
self.send(banner.encode() + b'\r\n') self.send(banner.encode() + b'\r\n')
self.__state = max(self.__state, self.SM_BANNER_SENT) self.__state = max(self.__state, self.SM_BANNER_SENT)
def ensure_read(self, size: int) -> None: def ensure_read(self, size: int) -> None:
while self.unread_len < size: while self.unread_len < size:
s, e = self.recv() s, e = self.recv()
if s < 0: if s < 0:
raise SSH_Socket.InsufficientReadException(e) raise SSH_Socket.InsufficientReadException(e)
def read_packet(self) -> Tuple[int, bytes]: def read_packet(self) -> Tuple[int, bytes]:
try: try:
header = WriteBuf() header = WriteBuf()
@@ -284,6 +310,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
e = ex.args[0].encode('utf-8') e = ex.args[0].encode('utf-8')
return -1, e return -1, e
def send_packet(self) -> Tuple[int, Optional[str]]: def send_packet(self) -> Tuple[int, Optional[str]]:
payload = self.write_flush() payload = self.write_flush()
padding = -(len(payload) + 5) % 8 padding = -(len(payload) + 5) % 8
@@ -294,10 +321,12 @@ class SSH_Socket(ReadBuf, WriteBuf):
data = struct.pack('>Ib', plen, padding) + payload + pad_bytes data = struct.pack('>Ib', plen, padding) + payload + pad_bytes
return self.send(data) return self.send(data)
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Returns true if this Socket is connected, False otherwise.""" """Returns true if this Socket is connected, False otherwise."""
return self.__sock is not None return self.__sock is not None
def close(self) -> None: def close(self) -> None:
self.__cleanup() self.__cleanup()
self.reset() self.reset()
@@ -305,6 +334,7 @@ class SSH_Socket(ReadBuf, WriteBuf):
self.__header = [] self.__header = []
self.__banner = None self.__banner = None
def _close_socket(self, s: Optional[socket.socket]) -> None: def _close_socket(self, s: Optional[socket.socket]) -> None:
try: try:
if s is not None: if s is not None:
@@ -313,9 +343,11 @@ class SSH_Socket(ReadBuf, WriteBuf):
except Exception: except Exception:
pass pass
def __del__(self) -> None: def __del__(self) -> None:
self.__cleanup() self.__cleanup()
def __cleanup(self) -> None: def __cleanup(self) -> None:
self._close_socket(self.__sock) self._close_socket(self.__sock)
for sock in self.__sock_map.values(): for sock in self.__sock_map.values():
+5 -1
View File
@@ -1,7 +1,7 @@
""" """
The MIT License (MIT) The MIT License (MIT)
Copyright (C) 2017-2020 Joe Testa (jtesta@positronsecurity.com) Copyright (C) 2017-2026 Joe Testa (jtesta@positronsecurity.com)
Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu) Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu)
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
@@ -134,6 +134,10 @@ class Utils:
host = host_and_port host = host_and_port
port = default_port port = default_port
# If we have a UNIX socket path, do no further processing.
if host.startswith("unix://"):
return host, 1
mx = re.match(r'^\[([^\]]+)\](?::(\d+))?$', host_and_port) mx = re.match(r'^\[([^\]]+)\](?::(\d+))?$', host_and_port)
if mx is not None: if mx is not None:
host = mx.group(1) host = mx.group(1)
+3 -2
View File
@@ -1,4 +1,4 @@
.TH SSH-AUDIT 1 "August 17, 2025" .TH SSH-AUDIT 1 "June 14, 2026"
.SH NAME .SH NAME
\fBssh-audit\fP \- SSH server & client configuration auditor \fBssh-audit\fP \- SSH server & client configuration auditor
.SH SYNOPSIS .SH SYNOPSIS
@@ -149,7 +149,7 @@ The timeout, in seconds, for creating connections and reading data from the sock
.TP .TP
.B -T, \-\-targets=<hosts.txt> .B -T, \-\-targets=<hosts.txt>
.br .br
A file containing a list of target hosts. Each line must have one host, in the format of HOST[:PORT]. Use -p/--port to set the default port for all hosts. Use --threads to control concurrent scans. A file containing a list of target hosts. Each line must have one host, in the format of HOST[:PORT] (or for UNIX sockets, use "unix://path/to/socket"). Use -p/--port to set the default port for all hosts. Use --threads to control concurrent scans.
.TP .TP
.B \-\-threads=<threads> .B \-\-threads=<threads>
@@ -188,6 +188,7 @@ ssh-audit 127.0.0.1
ssh-audit 127.0.0.1:222 ssh-audit 127.0.0.1:222
ssh-audit ::1 ssh-audit ::1
ssh-audit [::1]:222 ssh-audit [::1]:222
ssh-audit unix:///run/ssh-unix-local/socket
.fi .fi
.RE .RE