SSH 加固的难点不在于写出三行配置,而在于避免把自己锁在服务器外。可靠做法是保留现有会话,先确认第二条管理通道和密钥有效,再分层修改 sshd、主机防火墙与上游访问控制,最后从新的独立终端验证。本文以使用 OpenSSH、systemd 和 firewalld 的 RHEL 8/9 系列为基线;Ubuntu/Debian 的包名、防火墙工具和服务名不同,不应直接照搬。文中的 <管理网CIDR>、<新端口>、<管理员用户>、<服务器IP> 首次使用前必须替换,例如管理网 CIDR 可为 192.0.2.0/24,新端口应选择 1024~65535 中未占用的 TCP 端口。
先定义边界,而不是立即改配置
禁用密码能消除弱口令和撞库入口;改端口只能降低互联网扫描噪声,不能替代认证加固;限制 IP 的安全收益最大,但依赖稳定的办公出口、堡垒机或 VPN 网段。三项措施属于不同控制层,必须分别验证。实施前应确认云控制台、虚拟机控制台、带外管理或同机房救援终端可用,并记录当前允许登录的账号、来源地址、端口和跳板链路。
先固定审计上下文,避免在错误主机上操作:
hostnamectl
who -u
ip -brief address
ip route
who -u 可确认当前登录会话和来源;路由信息用于识别默认出口。不要仅凭 shell 提示符判断主机身份。
确认 OpenSSH 与服务单元。RHEL 8/9 通常使用 sshd.service:
rpm -q openssh-server
sshd -V 2>&1 | head -n 1
systemctl status sshd --no-pager
systemctl cat sshd
部分 OpenSSH 版本的 sshd -V 输出到标准错误,因此需要 2>&1。systemctl cat 能发现单元覆盖文件,防止遗漏通过 EnvironmentFile 或启动参数指定的其他配置。
检查监听端口和占用进程:
ss -lntp | awk 'NR==1 || /sshd/'
sudo lsof -nP -iTCP -sTCP:LISTEN | grep -E 'sshd|:<新端口>'
如果新端口已被其他进程占用,必须先重新选端口;不要杀掉未知进程来“腾端口”。
证明密钥登录已经可用
禁用密码前,管理员账户必须存在、未锁定、具有可用 shell,且公钥文件权限正确:
getent passwd <管理员用户>
sudo passwd -S <管理员用户>
sudo namei -l /home/<管理员用户>/.ssh/authorized_keys
sudo stat -c '%U:%G %a %n' \
/home/<管理员用户> \
/home/<管理员用户>/.ssh \
/home/<管理员用户>/.ssh/authorized_keys
authorized_keys 一般应由目标用户拥有,目录权限建议 700、文件权限 600;家目录不能由其他用户可写。StrictModes yes 时权限异常会导致公钥被拒绝。
在客户端创建 Ed25519 密钥。若组织要求 FIPS 模式,Ed25519 可能不可用,应依合规基线选择 RSA 3072/4096 并确认客户端与服务端算法支持:
ssh-keygen -t ed25519 -a 100 \
-f ~/.ssh/<服务器别名>_ed25519 \
-C '<管理员用户>@<服务器别名>'
ssh-keygen -lf ~/.ssh/<服务器别名>_ed25519.pub
私钥应设置口令并存放在受控终端或密钥代理中。-a 100 增加私钥口令派生轮次,不影响服务器认证速度。
在仍可用的既有登录方式下安装公钥,并显式修复权限:
#!/usr/bin/env bash
set -euo pipefail
ADMIN_USER="<管理员用户>"
PUBKEY_FILE="<本地公钥文件>"
SERVER="<服务器IP>"
ssh-copy-id -i "${PUBKEY_FILE}" "${ADMIN_USER}@${SERVER}"
ssh "${ADMIN_USER}@${SERVER}" \
'umask 077; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys'
<本地公钥文件> 替换为 .pub 文件路径。生产环境若禁止 ssh-copy-id,可通过配置管理系统分发,避免聊天或工单传递私钥。
强制客户端只尝试指定密钥,证明成功不是密码回退:
ssh -vv \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-o IdentitiesOnly=yes \
-i ~/.ssh/<服务器别名>_ed25519 \
<管理员用户>@<服务器IP>
调试信息中应看到服务端接受所选公钥并进入会话。若仍要求密码,先查服务端日志,不得继续禁用密码。
识别真正生效的 sshd 配置
OpenSSH 配置可能来自主文件、Include 目录以及 Match 条件块。文本搜索只能发现声明,sshd -T 才能展示解析后的全局有效值:
sudo grep -RInE \
'^[[:space:]]*(Include|Port|PasswordAuthentication|KbdInteractiveAuthentication|PubkeyAuthentication|PermitRootLogin|AllowUsers|AllowGroups|Match)\b' \
/etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null
sudo sshd -T | grep -E \
'^(port|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin|usepam) '
Match 会按用户、来源和地址改变结果,应使用 -C 模拟真实连接:
sudo sshd -T \
-C user=<管理员用户>,addr=<管理端IP>,host=<服务器主机名> \
| grep -E '^(port|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin|allowusers|allowgroups) '
<管理端IP> 是客户端经过 NAT 后被服务器看到的源地址,可从当前 who、ss 或日志确认,不能直接填笔记本内网地址。
备份并使用 drop-in 加固
先创建带时间戳、权限受控的备份,并保存校验和:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/root/sshd-backup-$(date +%Y%m%d-%H%M%S)"
install -d -m 0700 "${BACKUP_DIR}"
cp -a /etc/ssh/sshd_config "${BACKUP_DIR}/"
if [[ -d /etc/ssh/sshd_config.d ]]; then
cp -a /etc/ssh/sshd_config.d "${BACKUP_DIR}/"
fi
sha256sum "${BACKUP_DIR}/sshd_config" >"${BACKUP_DIR}/SHA256SUMS"
printf 'backup=%s\n' "${BACKUP_DIR}"
记录输出路径到变更单。RHEL 的 OpenSSH 配置通常支持 /etc/ssh/sshd_config.d/*.conf,但必须先确认主文件存在对应 Include;否则 drop-in 不会加载。
创建一个职责单一的加固文件。以下配置保留 PAM 的账户和会话处理,但关闭密码与键盘交互认证:
# /etc/ssh/sshd_config.d/60-login-hardening.conf
Port <新端口>
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
UsePAM yes
MaxAuthTries 3
LoginGraceTime 30
AllowGroups ssh-admins
AllowGroups ssh-admins 要求目标账号确实属于该组。启用前检查并建立授权关系:
getent group ssh-admins
id <管理员用户>
sudo usermod -aG ssh-admins <管理员用户>
getent group ssh-admins
新组成员资格对新会话生效;当前 shell 的 groups 结果不会自动刷新。至少保留一个已验证的管理会话。
不要直接用重定向覆盖配置。可以安全地暂存、设置所有者与权限后原子安装:
#!/usr/bin/env bash
set -euo pipefail
NEW_PORT="<新端口>"
TMP_FILE="$(mktemp)"
trap 'rm -f "${TMP_FILE}"' EXIT
sed "s/<新端口>/${NEW_PORT}/g" \
< /root/60-login-hardening.conf.template > "${TMP_FILE}"
sudo install -o root -g root -m 0600 "${TMP_FILE}" \
/etc/ssh/sshd_config.d/60-login-hardening.conf
sudo sshd -t
这里模板文件应由变更系统预先下发。sshd -t 无输出通常表示语法通过;它不能证明防火墙、SELinux、授权组或密钥均正确。
端口变更涉及 SELinux 与防火墙
RHEL 启用 SELinux enforcing 时,sshd 监听非标准端口需要为 ssh_port_t 增加端口标签。先读后改:
getenforce
sudo semanage port -l | grep '^ssh_port_t'
sudo semanage port -l | awk -v p='<新端口>' '$1=="ssh_port_t" && $0 ~ p {print}'
若 semanage 不存在,RHEL 8/9 可安装 policycoreutils-python-utils。新增端口用 -a,已经存在但属于其他类型时不能盲目 -m,应先评估该服务影响:
sudo dnf install -y policycoreutils-python-utils
sudo semanage port -a -t ssh_port_t -p tcp <新端口>
sudo semanage port -l | grep '^ssh_port_t'
这是安全策略修改。回滚时只有确认该端口标签是本次新增且没有其他 sshd 实例依赖,才执行 semanage port -d -t ssh_port_t -p tcp <新端口>。
确认 firewalld 当前区域与网卡绑定,避免把规则加到未生效区域:
sudo firewall-cmd --state
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --get-zone-of-interface=<网卡名>
sudo firewall-cmd --zone=<区域名> --list-all
<网卡名> 可从 ip -brief link 获取,<区域名> 替换为实际活动区域。远程修改防火墙属于高风险操作:先确认控制台可用、当前来源 CIDR 正确,并暂时保留旧 SSH 端口。
使用 rich rule 只允许管理网访问新端口。先加运行时规则并验证,不要一开始就写永久配置:
sudo firewall-cmd --zone=<区域名> \
--add-rich-rule='rule family="ipv4" source address="<管理网CIDR>" port port="<新端口>" protocol="tcp" accept'
sudo firewall-cmd --zone=<区域名> --list-rich-rules
若还有 IPv6 管理网络,应另建 family="ipv6" 规则并填写 IPv6 CIDR;不能以 IPv4 规则推断 IPv6 已受限。云安全组、边界防火墙和主机规则都要同步评估。
确认内核和防火墙看到新端口前,不要关闭 22:
sudo systemctl reload sshd
sudo systemctl status sshd --no-pager
sudo ss -lntp '( sport = :<新端口> )'
sudo journalctl -u sshd --since '-5 minutes' --no-pager
优先 reload,因为它通常不终止已建立会话;若服务单元不支持 reload,再评估 restart。配置语法错误时 systemd 日志和 sshd -t 是第一证据。
从另一终端完成闭环验证
保持旧会话不动,从管理网内另一终端连接新端口:
ssh -p <新端口> \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-o IdentitiesOnly=yes \
-i ~/.ssh/<服务器别名>_ed25519 \
<管理员用户>@<服务器IP>
登录后运行 id、sudo -n true 或按组织流程验证提权。若 sudo 需要密码,禁用 SSH 密码并不影响 sudo 的本地认证策略。
验证密码确实被拒绝。该测试应从授权来源执行,且不要连续大量尝试触发封禁:
ssh -p <新端口> \
-o PubkeyAuthentication=no \
-o PreferredAuthentications=password,keyboard-interactive \
-o NumberOfPasswordPrompts=1 \
<管理员用户>@<服务器IP>
预期结果是服务端不提供可用的密码或键盘交互方法。任何成功登录都说明 Match、配置覆盖或其他认证方式仍在生效,应立即检查 sshd -T -C。
在服务端用日志把客户端现象转换为证据:
sudo journalctl -u sshd --since '-15 minutes' --no-pager \
| grep -E 'Accepted|Failed|Invalid user|Connection|error|refused'
sudo ausearch -m AVC,USER_LOGIN -ts recent -i 2>/dev/null
公钥被拒绝常见于权限、SELinux 上下文、算法策略或 AllowGroups。若文件从其他目录复制,修复上下文:
sudo restorecon -RFv /home/<管理员用户>/.ssh
sudo ls -lZ /home/<管理员用户>/.ssh
sudo sshd -T -C user=<管理员用户>,addr=<管理端IP>,host=<服务器主机名> \
| grep -E 'authorizedkeysfile|pubkeyauthentication|allowgroups'
固化规则并撤销旧入口
只有在新端口、公钥、sudo、日志和至少一个独立会话均验证后,才把新规则写入永久配置:
sudo firewall-cmd --permanent --zone=<区域名> \
--add-rich-rule='rule family="ipv4" source address="<管理网CIDR>" port port="<新端口>" protocol="tcp" accept'
sudo firewall-cmd --reload
sudo firewall-cmd --zone=<区域名> --list-rich-rules
--reload 会用永久配置重建运行时规则,应先检查永久区是否存在其他未同步的重要临时规则。随后从授权网再次连接。
删除旧端口前确认它来自 service、port 还是 rich rule:
sudo firewall-cmd --zone=<区域名> --query-service=ssh
sudo firewall-cmd --permanent --zone=<区域名> --query-service=ssh
sudo firewall-cmd --zone=<区域名> --list-ports
sudo firewall-cmd --zone=<区域名> --list-rich-rules
若旧入口由 ssh service 开放,在新通道验证完成后再撤销。该操作会阻断仍使用 22 端口的新连接,执行前需通知使用者并确认自动化、监控和备份任务已改端口:
sudo firewall-cmd --permanent --zone=<区域名> --remove-service=ssh
sudo firewall-cmd --reload
sudo firewall-cmd --zone=<区域名> --query-service=ssh
同时检查 sshd 配置中不再监听 22。多个 Port 指令会让 sshd 同时监听多个端口;以 sshd -T 和 ss 为准。
上游访问控制与自动化影响
主机防火墙不是唯一入口。云安全组或硬件防火墙也应只允许堡垒机/VPN CIDR 到新端口,并在灰度验证后移除 22。变更前导出规则或截图留档;通过云平台变更时优先使用预览、策略差异和审批功能。切勿先删除旧规则再创建新规则。
扫描依赖和硬编码端口:
sudo grep -RInE '(^|[^0-9])22([^0-9]|$)|ssh .*-[pP]' \
/etc/cron.d /etc/cron.daily /etc/systemd/system /opt 2>/dev/null
systemctl list-timers --all
sudo crontab -l
还需检查 Ansible inventory、Git 部署密钥、SFTP、rsync、监控探针、备份软件和堡垒机资产配置。自动化使用的客户端配置可明确主机、端口和身份文件:
# ~/.ssh/config
Host <服务器别名>
HostName <服务器IP>
User <管理员用户>
Port <新端口>
IdentityFile ~/.ssh/<服务器别名>_ed25519
IdentitiesOnly yes
PreferredAuthentications publickey
客户端执行 ssh -G <服务器别名> 可查看最终解析值,避免通配 Host * 覆盖预期。
典型故障的证据链
“连接超时”通常指网络路径丢弃:依次检查服务端监听、主机防火墙计数、云安全组、路由与 NAT 出口。“Connection refused”通常表示目标可达但没有进程监听或被主动拒绝。“Permission denied (publickey)”说明网络与 SSH 握手已经完成,重点转向账号、公钥、权限和策略。
抓取有限的数据包确认请求是否抵达。抓包可能包含地址等敏感元数据,应限定接口、端口和时长:
sudo timeout 30 tcpdump -ni <网卡名> \
'tcp port <新端口> and host <管理端IP>'
若看不到 SYN,问题在主机之前或客户端路由;看到 SYN 但无 SYN-ACK,应查监听和防火墙;三次握手完成后断开,则查 sshd 日志。这个判断必须与实际抓包和日志互证。
配置变更后做一次可重复巡检:
#!/usr/bin/env bash
set -euo pipefail
PORT="<新端口>"
ADMIN_USER="<管理员用户>"
CLIENT_IP="<管理端IP>"
HOST_NAME="$(hostname -f 2>/dev/null || hostname)"
sshd -t
sshd -T -C "user=${ADMIN_USER},addr=${CLIENT_IP},host=${HOST_NAME}" \
| grep -E '^(port|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin) '
ss -lnt "sport = :${PORT}"
systemctl is-active --quiet sshd
journalctl -u sshd --since '-10 minutes' --no-pager | tail -n 50
该脚本只做本机检查,不能替代外部网络验证。输出应作为变更证据保存,而不是只记录“测试成功”。
回滚设计
回滚顺序应保证始终存在一条入口:在控制台或既有会话中恢复旧端口的 SELinux 与防火墙许可,恢复 sshd 配置并通过语法检查,reload 后确认 22 监听,再从外部验证。不要先删除新规则。
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="<备份目录>"
ZONE="<区域名>"
firewall-cmd --zone="${ZONE}" --add-service=ssh
firewall-cmd --permanent --zone="${ZONE}" --add-service=ssh
cp -a "${BACKUP_DIR}/sshd_config" /etc/ssh/sshd_config
if [[ -d "${BACKUP_DIR}/sshd_config.d" ]]; then
rm -f /etc/ssh/sshd_config.d/60-login-hardening.conf
cp -a "${BACKUP_DIR}/sshd_config.d/." /etc/ssh/sshd_config.d/
fi
sshd -t
systemctl reload sshd
ss -lntp '( sport = :22 )'
脚本中的 rm -f 只针对本次明确创建的文件,执行前核对备份目录和文件差异。若原环境没有 drop-in 目录,不应复制不存在的内容。恢复后,从外部使用原端口验证,再删除本次新端口规则和 SELinux 标签。
最终验收至少包括:授权来源可用密钥登录;非授权来源在网络层不可达;密码与键盘交互认证被拒;root 不能直接登录;新端口在 sshd、SELinux、防火墙和上游策略四处一致;自动化任务已更新;旧端口没有监听或开放;回滚路径经过只读核对。安全配置的结论不能来自单一配置文件,必须由有效配置、监听状态、网络策略、登录测试和日志共同证明。更多系统安全加固实践,欢迎到 云栈社区 深入探讨。
进一步收紧密钥与账号边界
禁用密码后,密钥本身就成为主要凭据。应建立发放人、使用人、用途、指纹、创建时间和撤销时间台账。多人共用一个 Unix 账号会削弱审计能力;更合理的是一人一账号,通过统一的 ssh-admins 组授予登录权限,再使用 sudo 完成特权操作。离职或终端遗失时,只撤销对应公钥,不影响其他管理员。
检查授权文件中的密钥类型、注释与重复项,但不要把完整公钥复制到普通工单:
sudo awk 'NF >= 2 && $1 !~ /^#/ {print $1, $2}' \
/home/<管理员用户>/.ssh/authorized_keys \
| while read -r type key; do
printf '%s ' "${type}"
printf '%s %s\n' "${type}" "${key}" | ssh-keygen -lf -
done
若行首含 from=、command= 等选项,上述简化解析不适用,应逐行审查。自动化密钥宜增加来源限制和最小命令权限,例如只允许固定网段并禁止转发:
from="<自动化网CIDR>",restrict ssh-ed25519 <公钥内容> <用途说明>
restrict 需要较新的 OpenSSH,等价于关闭多类转发与代理能力;具体支持情况用 man authorized_keys 和当前版本确认。若任务需要端口转发或分配 PTY,不应盲目使用。<公钥内容> 仅放公钥的 Base64 字段,绝不能放私钥。
OpenSSH 还可使用证书认证,由内部 CA 为用户公钥签发短期证书,从而避免逐台维护永久密钥。引入 CA 会扩大信任域,必须保护 CA 私钥、限制签发主体并测试撤销流程。服务端信任配置示意:
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
AuthorizedPrincipalsFile 必须由 root 管理且权限正确。证书 principals 应映射实际角色,不能让所有证书默认登录所有账号。变更仍需经过 sshd -t、sshd -T -C 和独立会话验证。
IPv4、IPv6 与多网卡陷阱
只限制 IPv4 时,sshd 仍可能通过 IPv6 全局地址开放。检查所有监听族和地址:
sudo ss -lntp | grep sshd
ip -6 address show scope global
sudo firewall-cmd --zone=<区域名> --list-all
若环境明确不使用 IPv6,不应仅为 SSH 临时在内核全局禁用 IPv6,因为这会影响其他服务。更稳妥的是在网络策略中建立对应 IPv6 规则,或经架构评审后用 sshd 的 AddressFamily/ListenAddress 限定监听。多网卡服务器还需确认管理流量进入的接口属于哪个 firewalld zone;错误的 zone 绑定会造成规则看似存在但不生效。
通过指定监听地址缩小暴露面时,必须确认该地址稳定存在:
# 仅示意,地址必须属于本机稳定的管理接口
ListenAddress <管理接口IP>:<新端口>
DHCP 地址、漂移 IP 或集群 VIP 不应未经验证直接绑定。地址不存在时 sshd 可能无法启动或无法监听预期入口,语法检查也未必能覆盖启动时的地址状态。
审计与持续检测
加固不是一次性操作。应监控失败登录、未知来源、配置漂移和监听端口变化。查询近一天失败与成功事件:
sudo journalctl -u sshd --since '-24 hours' --no-pager \
| grep -E 'Accepted publickey|Failed publickey|Failed password|Invalid user' \
| tail -n 500
日志保留周期和转发策略应满足审计要求。对集中日志告警时,需要按源地址、用户、主机和时间窗口聚合,避免一次拼写错误触发高优先级事件,也不能用改端口后扫描减少来宣称认证风险消失。
保存一份已批准的有效配置基线,并定期比较:
sudo sshd -T | sort > /var/lib/<巡检目录>/sshd-effective.current
sudo diff -u \
/var/lib/<巡检目录>/sshd-effective.approved \
/var/lib/<巡检目录>/sshd-effective.current
<巡检目录> 应是 root 管理的专用目录。有效配置可能因软件升级新增默认项,差异需要人工判断,不能自动把当前值覆盖成批准基线。
软件更新也可能改变默认算法或废弃选项。补丁前后分别运行:
rpm -q openssh openssh-server openssl
sudo sshd -t
sudo sshd -T | grep -E '^(ciphers|macs|kexalgorithms|hostkeyalgorithms) '
ssh -Q cipher
ssh -Q kex
不要从网上复制一份静态算法列表长期冻结;过度限制会造成旧客户端中断,也可能阻止未来安全默认值生效。算法策略应来自组织合规基线,并在客户端矩阵中验证。
堡垒机与紧急账号
限制 IP 最适合与堡垒机或 VPN 配合。服务器日志看到的来源可能只有堡垒机地址,因此用户身份审计必须在堡垒机和目标机之间保持关联。不要共享堡垒机账号,也不要让普通用户任意使用 ProxyCommand 绕开记录策略。客户端跳转配置示意:
Host <目标别名>
HostName <服务器IP>
Port <新端口>
User <管理员用户>
ProxyJump <堡垒机别名>
IdentityFile ~/.ssh/<服务器别名>_ed25519
紧急账号(break-glass)应有独立、受控、可审计的凭据和启用流程,而不是为了“保险”永久保留互联网密码登录。定期演练控制台、恢复旧端口、恢复密钥和撤销临时规则,才能证明回滚真实可用。
变更窗口的最终清单
执行前核对资产、窗口、影响方、控制台、备份、管理网 CIDR、云策略权限和至少两名验证人员;执行中按“密钥验证—配置语法—SELinux—临时防火墙—reload—外部验证—永久规则—关闭旧入口”的顺序推进;执行后观察认证日志、自动化任务与集中监控至少一个业务周期。任何一步证据不完整,都应停在仍保留旧通道的状态,而不是继续叠加变化。