发现网络上的每一个 SSH 服务,判断其是否符合你的标准,并在发生变化时收到通知。
sshfinder 是一个无第三方依赖的单一 Python 文件。将其指向一个 CIDR 网段,它就能发现 SSH 实际监听的任何位置——不仅仅是 22 端口——确认每个服务确实在运行 SSH 协议,评估其加密安全态势,并在有服务不符合你的策略时返回非零退出码。
大多数团队无法回答关于自身 SSH 资产状况的三个问题:
现有工具各自只解决了部分问题便止步于此:
| 工具 | 发现 SSH | 评估 | 覆盖整个资产 |
|---|---|---|---|
nmap | 是 | 浅层,通过 NSE 脚本 | 是 |
ssh-audit | 否——你需要提供单个主机 | 深入 | 否 |
masscan / zmap | 互联网规模 | 否 | 是 |
sshfinder | 是 | 是 | 是 |
这一空白——在一个工具中同时实现发现、评估和判定——正是本工具存在的意义。如果你只需要审计一个已知的主机,请使用 ssh-audit;它在单个服务上的分析深度超过本工具。
渗透测试人员会发现审计和 SOCKS 代理功能很有用,但该工具的设计初衷是针对你拥有的资产反复运行相同的扫描,而非用于一次性测试项目。
git clone https://github.com/kabiri-labs/sshfinder.git
cd sshfinder
python sshfinder.py 10.0.0.0/24 -p 22,2222
无需安装,无依赖。需要 Python 3.9+。
它做的三件事,用三条命令展示:
# 1. 资产盘点——网络上存在哪些 SSH 服务?
python sshfinder.py 10.0.0.0/24 --audit --format csv -o ssh-inventory.csv
# 2. 合规判定——是否符合我们的标准?(不符合则退出码为 3)
python sshfinder.py 10.0.0.0/24 -p 22,2222 --policy baseline
# 3. 漂移检测——自昨晚以来发生了什么变化?
python sshfinder.py 10.0.0.0/24 -p 22,2222 --baseline yesterday.json \
--fail-on-drift
默认扫描全部 65535 个端口,因为位于非标准端口上的 SSH 服务恰恰是没有人记录在案的那一个。每个开放端口都会被标记,因此开放端口永远不会被静默地计为 SSH 端口:
=== 10.0.0.5 ===
open: 10.0.0.5:22 [SSH], 10.0.0.5:8080 [not ssh]
SSH 10.0.0.5:22 (SSH-2.0-OpenSSH_7.4)
确认过程是真实的 RFC 4253 标识交换,而非仅查看线路上的前几个字节。先打印合法横幅的服务器、等待客户端自我标识的服务器,以及横幅跨 TCP 分段到达的服务器,都能被正确识别——在朴素的实现中,这些情况每一种都会导致漏报。
添加 --audit 参数可查看每个服务的完整信息:
SSH 10.0.0.5:22 (SSH-2.0-OpenSSH_7.4)
host key: ssh-ed25519 SHA256:T/ZM4jOL4amTsO5K3AaCdg2...
auth: publickey, password [!] password auth enabled
[!] Terrapin (CVE-2023-48795): VULNERABLE
[!] weak ciphers: aes128-cbc
aes128-cbc [weak]: CBC mode is vulnerable to the SSH plaintext-recovery attack (CVE-2008-5161) and, …
共享 SSH 主机密钥(可能存在共享/克隆主机):
SHA256:T/ZM4jOL4amTsO5K3AaCdg2...
-> 10.0.0.5:22, 10.0.0.9:22
最后一块值得关注:跨机器复用的主机密钥通常意味着克隆的虚拟机或共享镜像,而且这意味着攻陷一台主机就等于攻陷了所有主机的身份。
OpenSSH 10.0 将 mlkem768x25519-sha256 设为默认密钥交换算法,10.1 版本警告经典会话面临现在存储、以后解密的捕获风险。--pq-report 直接回答资产层面的问题,仅使用 KEXINIT——因此无需第三方库:
python sshfinder.py 10.0.0.0/24 -p 22,2222 --pq-report
Post-quantum readiness:
1/3 service(s) negotiate post-quantum key exchange with a current client
[!] no PQ key exchange offered (1):
10.0.0.2:22
[!] pre-standard PQ only (1) - looks post-quantum but is not:
10.0.0.3:22
2 service(s) exposed to store-now-decrypt-later capture; upgrade to OpenSSH 9.0+
pre-standard(前标准)类别是最容易让人上当的。一个通告 [email protected] 或 Kyber 草案的服务器在算法转储中看起来是后量子的,但 OpenSSH 在 2020 年就放弃了这套已撤回的参数集——因此当前客户端找不到共同方法,会回退到经典加密。如果将其计为就绪,那比完全不检查更糟糕。
报告描述问题。策略断言问题,并可使构建失败:
python sshfinder.py 10.0.0.0/24 -p 22,2222 --policy baseline; echo "exit $?"
Policy 'baseline':
No password login, no Terrapin exposure, no weak algorithms.
1/3 service(s) pass
[FAIL] 1 service(s):
10.0.0.3:22
- password_auth: password login accepted: publickey, password
- terrapin: vulnerable to Terrapin (CVE-2023-48795)
- post_quantum (warn): post-quantum readiness is absent, ready required
[warn] 1 service(s):
10.0.0.2:22
- post_quantum (warn): post-quantum readiness is absent, ready required
exit 3
内置三个策略——baseline、strict 和 pq——以其强制执行的成果命名,而非以发行版命名。规则带有 fail 或 warn 严重级别,--fail-on 决定哪个级别触发退出码,因此团队可以先以警告级别采用更严格的标准,之后无需编辑任何内容即可升级。
以 JSON 编写你自己的策略:
{
"name": "house-rules",
"description": "What we expect of every SSH service.",
"rules": [
{"check": "password_auth", "severity": "fail"},
{"check": "terrapin", "severity": "fail"},
{"check": "post_quantum", "require": "ready", "severity": "warn"},
{"check": "forbid", "field": "ciphers",
"algorithms": ["3des-cbc", "arcfour"], "severity": "fail"},
{"check": "require", "field": "kex_algorithms",
"algorithms": ["curve25519-sha256"], "severity": "fail"}
]
}
检查项:password_auth、terrapin、weak_algorithms、post_quantum(带 require:ready、legacy 或 absent),以及针对 kex_algorithms、host_key_algorithms、ciphers 或 macs 字段的 forbid / require。
任何其他内容都会在策略加载时、扫描开始前产生硬错误。 一个静默跳过其无法理解的规则的检查门比没有检查门更糟糕:运行显示绿色通过,而没有人知道该检查从未执行。
$ sshfinder 10.0.0.0/24 --policy house.json
sshfinder: error: rule 1: unknown check 'pasword_auth'
(known: forbid, password_auth, post_quantum, require, terrapin, weak_algorithms)
每晚针对昨天的报告运行,只查看变化的内容:
# 每晚,在 cron 中:
python sshfinder.py 10.0.0.0/24 -p 22,2222 --audit --json -o today.json
python sshfinder.py 10.0.0.0/24 -p 22,2222 --baseline yesterday.json \
--fail-on-drift
Baseline drift (vs yesterday.json):
[alert] 2 change(s):
10.0.0.5:22 SHA256:T/ZM4jO... -> SHA256:9aKm2Qx...; expected only after a rebuild or key rotation
10.0.0.3:22 password login is now accepted
[added] 1 change(s):
10.0.0.9:2222 new SSH service (SSH-2.0-OpenSSH_9.6)
[improved] 1 change(s):
10.0.0.7:22 post-quantum readiness rose from absent to ready
主机密钥发生变化是这里最需要关注的信号——仅在重建或密钥轮换后才会预期发生,其他任何时候都值得关注。
只有 alert 级别会触发 --fail-on-drift。退役主机属于正常变动,如果每晚任务因此失败,会让所有人习惯性地忽略结果。
比较过程谨慎地避免凭空捏造变化。两次扫描都未测量的字段永远不会被报告为已变化,仅比较两次扫描中都存在的主机,且基线中包含主机密钥指纹会使本次扫描也运行深度探测——因此浅层重新扫描永远不会被误读为所有密钥都已消失。
--format text|json|sarif|csv,可选通过 -o 写入文件。
csv — 每个已确认的 SSH 服务一行。这是资产清单实际进行排序和筛选的形态。json — 原生报告格式,也是 --baseline 的输入格式。sarif — SARIF 2.1.0,已针对 OASIS 模式验证。发现项锚定到 host:port 逻辑位置并携带稳定指纹,因此消费者可以跨每晚运行跟踪同一发现项,而非每次都打开新警报。--stream — 换行分隔的 JSON 事件,在每个端口打开和每个服务确认时即时刷新,使管道可以在扫描仍在运行时对第一个结果采取行动:python sshfinder.py 10.0.0.0/24 --stream -q | jq -c 'select(.event=="ssh")'
{"event":"ssh","elapsed":0.164,"host":"10.0.0.5","port":22,"banner":"SSH-2.0-OpenSSH_9.6"}
{"event":"ssh","elapsed":0.881,"host":"10.0.0.9","port":2222,"banner":"SSH-2.0-dropbear"}
关于 SARIF 和 GitHub 代码扫描。 SARIF 结果必须携带非空的工件位置,否则
upload-sarif会拒绝该文件,因此会随逻辑位置一起输出一个合成的ssh://host:portURI。它不会解析为你仓库中的文件,因此警报不会带有代码锚点。请将其视为适用于安全工具的一般性 SARIF——VS Code SARIF 查看器、Azure DevOps、归档——而非用于注释差异的方式。
这是策略和漂移功能的核心意义所在,因此值得精确说明:
硬错误的优先级高于策略判定,策略判定的优先级高于漂移。如果没有任何目标可达,则扫描也无法证明任何合规性结论,因此你会得到 1 而非误导性的通过或失败;而未能达到既定标准比"有变化发生"是更具体的发现。
核心扫描、横幅验证以及 --audit 中无依赖的部分(算法清单、弱加密标志、Terrapin、后量子就绪状态)仅需 Python 3.9+。
# 推荐:解锁 --audit 中的主机密钥指纹、认证方法枚举和共享密钥关联,以及 --validate paramiko。
pip install -r requirements.txt
# 可选,仅用于半开 SYN 扫描(需要 root 权限):
pip install scapy>=2.5
--scan-method auto 在以 root 身份运行且安装了 Scapy 时选择 SYN 扫描,否则回退到无需特权的连接扫描。SOCKS 不能与 SYN 扫描组合使用——SOCKS5 承载 TCP 流,而非原始数据包。
python sshfinder.py 10.0.0.0/24 -p 22 --socks user:[email protected]:1080
发现、横幅交换和审计都通过代理进行,因此结果永远不会是半隧道的。无法访问的代理会被报告为扫描错误,绝不会报告为"未发现 SSH"。
--max-rate 限制整个扫描的每秒探测数。并发度限制同时打开的连接数;此参数限制新连接启动的速度,这是在交战规则下扫描任何内容之前需要能够承诺的上限。
实现说明,用于解释上述行为。
扫描引擎。 所有在途连接均由一个线程通过操作系统事件循环(epoll/kqueue/select)驱动,因此并发消耗的是文件描述符而非操作系统线程,且每台主机只解析一次而非每个端口解析一次。完整的 1–65535 全端口扫描比线程池设计快约 6 倍。
SSH 端口优先。 SSH 实际驻留的少数端口(22、2222、22222 等)会在每次扫描开始时优先探测。在全端口扫描中,第一个确认的 SSH 服务约在 0.2 秒内出现,而非 16 秒。
每个服务一次握手。 发现开放端口的套接字直接交给横幅交换,因此确认一个 SSH 服务只需一次 TCP 握手而非两次。
自适应超时。 探测等待的时间与路径所需相匹配,使用 RFC 6298 中的平滑往返时间估计器——即 TCP 本身使用的算法——由每个已应答的探测提供输入并在整个扫描中共享。--timeout 成为上限而非固定成本:在活跃但大部分被过滤的主机上,这大约带来 5 倍的性能提升,且发现结果完全相同。只有明确的应答才能教会它什么;超时对路径不提供任何信息,永远不会被反馈。
两个位置刻意保留完整的上限。横幅交换和审计从不自适应,因为主机完成 TCP 握手的速度与其 SSH 守护进程编写问候语的速度无关。SSH 常用端口的最终重新探测也不自适应,因为那里的 SYN 丢失是唯一真正会让本工具丢失发现的丢包。
提前退出。 在前几百次探测中完全无响应的主机被报告为无响应,而非为每个剩余端口消耗一次超时。由于 SSH 端口最先扫描,活跃服务总能在此触发前被发现;--no-early-exit 强制扫描完整范围。
设计上有界。 从文件描述符限制推导出的进程级套接字预算可防止大规模扫描耗尽描述符并将活跃服务误报为已过滤。目标扩展在物化网络之前检查其大小,因此杂散的 /8 会在毫秒内被拒绝,而非消耗 1 GB 内存。
精心整理的算法判定。 每个被标记的算法都来自一个带有严重级别和明确理由的显式表,而非一连串子串测试。名称首先被规范化,因此供应商后缀无法绕过检查——[email protected] 无论由谁提供都是 CBC——且 [email protected] 等协商标记永远不会被当作算法评估。策略门和漂移比较最终都建立在此表之上。
稳健的 Ctrl+C。 即使在 Windows 上也能正常响应,而 Windows 上无界线程等待通常会吞掉它:第一次按下会优雅停止并返回部分结果,第二次按下强制立即退出。
OpenSSH_9.6p1 已针对公共数据库归因于 9.6p1 的大多数漏洞打了补丁。那是一个误报机器——这就是 ssh-audit 移除其基于版本的 CVE 检测的原因,也是 Tenable 发布一个专门检测破坏该机制的回移补丁的插件的原因。仅评估服务器实际通告的内容。nmap 竞争,也不在互联网规模上与 masscan 和 zmap 竞争。那些问题已经解决了。测试套件仅使用标准库,因此可在裸解释器上运行:
python -m unittest discover -s tests
安装运行时依赖以同时运行基于 Paramiko 的审计测试,这些测试在缺少 Paramiko 时会自行跳过:
pip install -r requirements-dev.txt
python -m unittest discover -s tests
仅扫描你拥有或明确授权测试的系统。未经授权的扫描在您的司法管辖区可能属于违法行为。
| 代码 | 含义 |
|---|
0 | 成功。未发现任何内容仍算成功——空资产不是错误。 |
1 | 硬错误:所有目标均扫描失败,或无法写入输出文件。 |
2 | 调用错误(未知标志、无效端口规范、格式错误的策略或代理)。 |
3 | 达到或超过 --fail-on 级别的策略违规。仅在 --policy 下出现。 |
4 | 基线漂移警报。仅在 --baseline --fail-on-drift 下出现。 |
130 | 通过 Ctrl+C 中断。 |
| 选项 | 描述 |
|---|
targets | 一个或多个 IP、主机名或 CIDR 网络。 |
-iL, --target-file FILE | 从文件读取目标(每行一个,允许 # 注释)。 |
-p, --ports SPEC | 要扫描的端口,例如 22,80,1000-2000(默认:1-65535)。 |
--audit | 审计每个 SSH 服务:算法、主机密钥、认证方法、Terrapin、后量子就绪状态、共享密钥关联。 |
--pq-report | 报告整个资产的后量子就绪状态。无需第三方库。 |
--policy NAME_OR_PATH | 针对 baseline、strict、pq 或 JSON 策略文件检查每个服务。违规时退出码为 3。 |
--fail-on {fail,warn,never} | 哪个策略严重级别触发退出码(默认:fail)。 |
--baseline FILE | 与之前的 --json 报告比较并列出变化。 |
--fail-on-drift | 当比较产生警报时退出码为 4。 |
--format {text,json,sarif,csv} | 输出格式(默认:text)。 |
--json | --format json 的简写。 |
--stream | 在发现结果时输出换行分隔的 JSON 事件。 |
-o, --output FILE | 将结果写入文件而非 stdout。 |
--validate {banner,paramiko,none} | SSH 验证策略(默认:banner)。 |
--scan-method {auto,connect,syn} | 扫描后端(默认:auto)。 |
--socks [user:pass@]host:port | 通过 SOCKS5 代理访问所有目标。 |
--max-rate N | 限制整个扫描的每秒探测数(默认:无限制)。 |
-t, --timeout SECONDS | 单个探测最长等待时间(默认:2.0)。 |
--min-timeout SECONDS | 自适应探测超时的下限(默认:0.1)。 |
--no-adaptive-timeout | 每次探测都等待完整的 --timeout。 |
-w, --workers N | 每台主机的并发连接数(默认:512)。 |
--max-sockets N | 同时打开的探测套接字上限(默认:取决于文件描述符限制)。 |
--host-concurrency N | 并行扫描的主机数(默认:16)。 |
-r, --retries N | 超时探测的重试次数(默认:0)。 |
--max-targets N | 拒绝大于此值的目标列表(默认:65536)。 |
--no-early-exit | 即使主机完全无响应也扫描每个端口。 |
--no-progress | 禁用实时进度指示器。 |
-v, --verbose | 详细日志(-vv 还会取消 Paramiko 的静默)。 |
-q, --quiet | 抑制进度和信息日志。 |
--version | 打印版本并退出。 |