上个月在机房蹲到凌晨两点,就为给一批内网服务器换掉系统自带的 OpenSSH。原因很直接:安全扫描报告上那几条高危项一直挂着,而机器所在的网段根本不通外网,连个临时软件源都搭不起来。这类活干过几回之后,我索性把整套流程做成了可带U盘进场的Kylin Server V10 下 OpenSSH 安全漏洞离线修复脚本:事先准备好升级包,进场后一条命令完成备份、升级、验证、回滚兜底。这套东西解决的问题不是“怎么装一个包”,而是在无外网、不能断业务、必须可回滚三个约束同时成立时,怎么把 OpenSSH 的漏洞面安全地收掉。适合手上管着几十上百台内网服务器、被安全通告追着跑、又不敢在生产机上直接敲yum update的运维和系统管理员看;刚入行但需要独立完成一次变更的朋友,照着走也能跑通。
1. 为什么这类离线修复值得单独做一套脚本
1.1 内网环境的现实约束比想象中更硬
先说场景。真正需要离线修复的机器,通常长这样:部署在隔离网段,只有堡垒机单向跳板,机器自己没有任何出口;变更要走审批,窗口通常给两小时,超时就得改期;业务方对“重启”两个字极度敏感,SSH 服务一断,运维通道就没了,后面的操作全部抓瞎。这跟实验室里随手yum update openssh完全不是一回事——实验室里失败了重装系统就行,生产机上一次失联可能就是要派人去机房按电源键。
所以离线修复的核心矛盾不是“包从哪来”,而是你只有一个远程通道,而这个通道恰好就是你即将改动的那个服务。这个死结决定了后面所有的设计:为什么要有带外控制台、为什么要用at挂一个延时回滚、为什么升级完不能立刻systemctl restart sshd。我见过太多人第一步就把 sshd 停了准备换包,然后就没有然后了。
还有一个容易被忽略的约束是审计与可追溯。这类变更往往要留证据:升级前的版本、升级后的版本、升级包的来源与校验值、每一步的执行日志。手工敲命令很难留全,脚本天生适合干这个。
1.2 真正难的地方不在装包,在“不断线”
装包本身三十秒的事。难的是一整套配套动作:确认现有配置里有哪些非默认项会被新版本改掉、确认依赖闭包在离线环境下能解开、确认新版本的算法策略不会把老客户端挡在门外、确认出问题能在十分钟内退回去。这些事单看每一件都不算难,凑在一起就是一次完整的变更工程。
我踩得最深的一个坑是算法策略。某次升级后 sshd 起来了,systemctl status一片绿,密码登录也正常,结果第二天业务方报障:他们那套老旧的网管软件连不上来了,报no matching key exchange method found。查了半天才发现新版本默认禁掉了几个老 KEX 算法,而客户端的版本停在很多年前。这种问题在升级当场是发现不了的,只有真正用的人才会撞上。所以脚本里我后来加了一条硬性规定:升级后必须用至少两种客户端实测,一种是新版本 OpenSSH 命令行的ssh,另一种是业务方实际在用的那套工具。
注意:升级窗口内不要只验证“我自己能连上”。你自己用的是最新的客户端,天然兼容新算法,验证不出兼容性问题。
1.3 三条技术路线的取舍,我为什么倾向 RPM
离线修复无非三条路,实际选型时我把它们摆在一起对比过很多次,结论一直没变。
| 路线 | 具体做法 | 优势 | 主要风险 | 我的取舍 |
|---|---|---|---|---|
| 官方离线 RPM 升级 | 从同源介质取新版 openssh 系列包,做本地源后yum update | 包管理可追溯、依赖自动解、systemd 单元与 PAM 配置沿用原厂、可yum downgrade回滚 | 需要拿到与系统版本匹配的包 | 首选,除非拿不到包 |
| 源码编译安装 | ./configure && make && make install到独立前缀 | 版本可控、能选最新 | 配置目录、PAM、SELinux 上下文、systemd 单元全要自己接;回滚靠人肉 | 拿不到匹配 RPM 时的备选 |
| 仅打补丁不升版本 | 应用发行方提供的小补丁 | 变更面最小 | 补丁可得性差,版本号不变容易被扫描器继续报 | 看安全通告怎么认定 |
选 RPM 的根本理由不是“省事”,而是一致性。Kylin Server V10 的 openssh 包和系统里的 openssl、pam、krb5、crypto-policies 是一起编译、一起测试、一起发布的,它们之间的 ABI 和默认策略是配套的。你从源码编一个 9.x 进去,表面上版本号漂亮了,实际上把一整套隐式契约打破了——最典型的就是 crypto-policies 对算法列表的干预,源码编译的 sshd 未必认这套策略,最后出现“配置文件里写着允许,实际握手却被拒”的怪现象。
2. 动手前的底账:把现状摸清楚
2.1 版本、配置与漏洞面的一次性确认
进场第一件事不是拷包,是摸清这台机器到底什么样。我固定跑这几条:
# 已安装的 ssh 相关包与版本 rpm -qa | grep -i openssh | sort # 二进制实际版本(注意看括号里的 OpenSSL 版本) ssh -V # 当前生效的完整配置(含所有默认值展开) sshd -T > /tmp/sshd_T_before.txt # 配置文件里被显式改过的行(过滤掉注释和空行) grep -vE '^\s*#|^\s*$' /etc/ssh/sshd_configsshd -T这一步价值极高。sshd_config里往往只写了十几行,剩下几百项都是编译期默认值,升级后默认值可能变。把升级前后的sshd -T输出做一次diff,就能精确知道新版本偷偷改了哪些默认行为,比逐条读 release notes 靠谱得多。
再看一眼包自带的脚本,有些版本的 post 脚本会动配置文件:
rpm -q --scripts openssh-server如果脚本里有%post段做sshd_config的迁移或备份,你就得知道它会生成.rpmnew还是.rpmsave,否则升级完发现配置被换掉了还以为是自己的锅。
2.2 必须备份的六类东西,少一样都可能回不去
备份这件事,我的原则是“按回滚时需要什么来备”,不是“按习惯备”。清单如下:
/etc/ssh整个目录:包括sshd_config、ssh_config、所有 host key、moduli。host key 一定要备,换掉会导致所有客户端的known_hosts报指纹变更,批量环境里这是个灾难。- 原版本 RPM 包本体:这是回滚的物理基础。离线环境拿不到旧包就永远回不去,必须从安装 ISO 或本地源里把
openssh、openssh-server、openssh-clients三个包抠出来存好。 /etc/pam.d/sshd与/etc/pam.d/password-auth:新版本的 PAM 交互可能有变化,出问题时对比这两份文件能省几小时。- systemd 单元文件:
/usr/lib/systemd/system/sshd.service以及sshd@.service、sshd.socket(如果启用了 socket 激活)。 - 用户密钥目录:
/root/.ssh和/home/*/.ssh。升级本身不该动它们,但有人误操作chmod或者替换 home 目录时,这是最后一道保险。 sshd -T的完整输出:前面说了,用于前后对比。
我习惯把备份统一打到一个带时间戳的目录里,路径固定,脚本回滚时直接引用:
BAK=/var/backups/sshfix/$(date +%Y%m%d%H%M%S) mkdir -p "$BAK" cp -a /etc/ssh "$BAK/etc-ssh" cp -a /etc/pam.d/sshd "$BAK/pam-sshd" cp -a /usr/lib/systemd/system/sshd.service "$BAK/" 2>/dev/null rpm -qa | grep -i openssh > "$BAK/rpm-list-before.txt" sshd -T > "$BAK/sshd_T_before.txt" 2>/dev/null2.3 依赖闭包怎么算,离线源怎么搭
离线环境最怕的不是缺主包,是缺一个不起眼的依赖。判断方法有两个,我一般都用:
# 方法一:直接问 RPM 这个包需要什么 rpm -qpR /path/to/openssh-server-*.rpm # 方法二:用旧包做参照,缺什么补什么 rpm -qR openssh-server | sort -uKylin Server V10 这一系的 openssh 包,常见依赖集中在openssl-libs、krb5-libs、libselinux、pam、zlib、audit-libs、ncurses-libs、libedit、crypto-policies。其中最容易出事的是openssl-libs的版本下限和crypto-policies:前者版本不够会直接装不上,后者决定了算法白名单,升级后行为差异大多来自它。
搭本地源的完整动作:
mkdir -p /opt/localrepo/Packages cp /media/cdrom/Packages/openssh*.rpm /opt/localrepo/Packages/ cp /media/cdrom/Packages/openssl-libs*.rpm /opt/localrepo/Packages/ # 视需要 createrepo_c /opt/localrepo # 老系统上是 createrepo cat > /etc/yum.repos.d/local-offline.repo <<'EOF' [local-offline] name=Local Offline Repo baseurl=file:///opt/localrepo enabled=1 gpgcheck=0 EOF yum clean all && yum makecache yum --disablerepo='*' --enablerepo=local-offline list available | grep -i openssh提示:
--disablerepo='*'这个参数很重要。内网机器上可能残留着指向不可达地址的 repo 配置,不显式禁用会让 yum 卡在超时上,一个makecache能等五分钟。
gpgcheck=0在内网自建源里是常规做法,但前提是包的来源可信——从官方安装介质里取的包,校验值要单独记一份,脚本里做一次sha256sum比对,这也算给审计留个交代。
3. RPM 离线升级的完整实操
3.1 用三条会话把“失联风险”摁死
前面说过,改 sshd 的过程中你唯一的通道就是 sshd 本身。解决办法是永远保留一条不参与操作的会话。我的标准配置是三条:
第一条,普通 SSH 会话,登录后什么都不做,就挂着。这条会话的价值在于:sshd 重启时,已经建立的连接不会被断开(sshd 是 fork 模型,老进程继续服务已连接的会话),所以只要你不主动退出,这条通道就是活的,可以用来救场。
第二条,执行操作的会话,所有命令在这里跑。
第三条,带外通道:虚拟化管理平台的串口控制台、IPMI 的 SOL、或者干脆是安排在现场的同事。这条是最后保险,前两条都凉了的时候用。
三条会话到位之后,还有一个动作是必做的——挂一个延时回滚:
# 15 分钟后自动恢复配置并重启 sshd,操作成功后再手动取消 echo 'cp -a /var/backups/sshfix/20240101000000/etc-ssh/sshd_config /etc/ssh/sshd_config && systemctl restart sshd' | at now + 15 minutes atq # 确认任务已排入 atrm <job> # 一切正常后取消这个动作看着土,但救过我至少两次。一次是新版本 sshd 因为 PAM 配置不兼容起不来,我在第一条会话里干等,到了时间点at自动把配置换回去、服务拉起来,通道恢复。没有这个兜底,那就得走带外或者派人。
3.2 升级执行:先试运行,再动真格
真正执行的顺序我固定为:试运行 → 正式升级 → 配置比对 → 语法检查 → 临时端口验证 → 正式重启。中间任何一步不过,立刻停。
# 1. 试运行,只看依赖能不能解,不动文件 yum --disablerepo='*' --enablerepo=local-offline update openssh openssh-server openssh-clients --assumeno # 2. 正式升级(-y 之前先确认上一步输出里没有额外的包被牵连升级) yum --disablerepo='*' --enablerepo=local-offline update openssh openssh-server openssh-clients -y # 3. 版本确认 ssh -V rpm -q openssh openssh-server openssh-clients # 4. 配置文件是否被替换(重点看有没有 .rpmnew / .rpmsave) ls -l /etc/ssh/ | grep -E 'rpmnew|rpmsave' # 5. 语法检查,必须在重启之前 /usr/sbin/sshd -t第 2 步的细节值得多说一句:yum update openssh有可能顺带把openssl-libs一起升了,而openssl-libs是全系统级的库,牵一发动全身。所以试运行时一定要看输出里的“将要安装/更新”清单,如果出现意料之外的包,停下来评估,别图快一把梭。我一般会在 repo 里把 openssl 相关包单独放一个目录,需要时才启用,避免误伤。
/usr/sbin/sshd -t通过只说明语法没问题,不说明新版本能正常起。所以中间加一步临时端口验证,这是我认为整个流程里性价比最高的一个动作:
# SELinux 环境下必须给 2222 端口打标签,否则 sshd 绑不上 semanage port -a -t ssh_port_t -p tcp 2222 2>/dev/null || \ semanage port -m -t ssh_port_t -p tcp 2222 # 用新二进制在 2222 端口起一个临时实例,不改动 22 端口 /usr/sbin/sshd -p 2222 -o PidFile=/tmp/sshd_test.pid # 另一条会话里验证(注意用新会话,别复用老连接) ssh -p 2222 -o PreferredAuthentications=password root@127.0.0.1 # 验证通过后关掉临时实例 kill $(cat /tmp/sshd_test.pid)这一步的意义在于:它验证的是新二进制 + 新配置 + 新 PAM 栈的完整启动链路,而且完全不影响 22 端口上跑着的旧服务。如果这里起不来,你能立刻看到真实的报错,而不必冒着断线的风险去重启生产服务。
临时实例验证通过后,才执行systemctl restart sshd。重启完立刻在第二条会话里新开一个连接测试,成功了再去atrm取消那个定时回滚。
3.3 升级后的验证清单,比“能连上”多得多
我见过太多人验证就是ssh localhost一下,能通就算完事。实际要验的东西至少有这些:
| 验证项 | 命令 | 期望结果 |
|---|---|---|
| 版本号 | ssh -V | 达到目标版本 |
| 服务状态 | systemctl status sshd | active (running),无报错 |
| 生效配置 | sshd -T | diff - /tmp/sshd_T_before.txt | 差异项可解释 |
| 密码登录 | 新会话强行走密码认证 | 成功 |
| 密钥登录 | 新会话-i指定私钥 | 成功 |
| SFTP 子系统 | sftp user@127.0.0.1 | 能进交互 |
| 端口转发 | ssh -L建一条隧道 | 隧道可通 |
| 算法兼容 | 老客户端/业务工具实测 | 能握手 |
| 审计日志 | journalctl -u sshd -n 50 | 无异常拒绝记录 |
特别说一下最后一项“算法兼容”。如果环境里确实有老客户端必须保留,就得在sshd_config里显式放行,而不是靠降级整个服务:
# 仅为兼容老客户端放开,密钥交换限定回部分算法 KexAlgorithms curve25519-sha256,diffie-hellman-group14-sha256,diffie-hellman-group-exchange-sha256 HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa加ssh-rsa是为了兼容那些还在用 SHA-1 签名的老客户端,这是明确的安全折损,加之前要和安全侧对过口径,并且最好配合来源 IP 限制——只对特定的管理网段放开,对业务网段保持严格。
3.4 回滚路径,得在升级前就演练一遍
回滚方案如果只在纸面上,那就是没有。我的做法是每次升级前都确认三件事:旧包在不在、回滚命令对不对、回滚后配置能不能恢复。
# 回滚:降级到旧版本(需本地源里有旧包) yum --disablerepo='*' --enablerepo=local-offline downgrade openssh openssh-server openssh-clients -y # 或者直接指定包文件 rpm -Uvh --oldpackage --replacepkgs /opt/localrepo/Packages/openssh-server-旧版本.rpm ... # 恢复配置并重启 cp -a /var/backups/sshfix/20240101000000/etc-ssh/sshd_config /etc/ssh/sshd_config sshd -t && systemctl restart sshd这里有个坑要提:downgrade依赖本地源里存在旧版本包,而很多人的本地源只放了一个版本。所以要么把旧版包也放进同一个 repo 目录(createrepo_c支持多版本共存),要么直接用rpm -Uvh --oldpackage指定文件。后者更直接,但要注意--oldpackage不会自动处理依赖降级,如果新版把某个依赖的版本下限抬高过,降级会因为依赖不满足失败,这时候得先把被牵连的包也降下来。
4. 源码编译路线:看似自由,坑在后半段
4.1 configure 参数怎么定,决定后面顺不顺
拿不到匹配 RPM 的场景不算少见——比如系统版本老、官方源里没有针对该漏洞的新包,或者安全侧明确要求升到某个更高版本。这时候只能源码编译。参数我一般这么给:
tar -xf openssh-9.x.tar.gz && cd openssh-9.x ./configure \ --prefix=/usr/local/openssh \ --sysconfdir=/etc/ssh \ --with-pam \ --with-selinux \ --with-audit \ --with-ssl-dir=/usr \ --with-privsep-path=/var/empty/sshd \ --with-ipaddr-display \ --with-default-path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin make -j"$(nproc)" make install几个参数值得解释。--sysconfdir=/etc/ssh是关键中的关键:它让新编译的 sshd 直接读取系统原有的配置目录,你的sshd_config、host key、moduli全都原封不动,省掉大量迁移工作。如果按默认装到/usr/local/etc,你就得手工搬配置,还得处理 host key 的权限(sshd 对 host key 的权限很挑,0640 以下、属主 root,权限不对直接拒绝启动)。
--with-pam和--with-selinux必须开。前者不开会导致 PAM 相关功能全部失效,某些环境的登录认证链路直接断掉;后者不开在 enforcing 模式下会出现莫名其妙的上下文拒绝。--with-audit看审计要求,开了会往 audit 里写登录事件,合规环境一般要。
--with-ssl-dir=/usr指向系统自带的 OpenSSL,而不是自己再编一份。原因还是那个词:一致性。自己编的 OpenSSL 版本可能和系统里其他依赖它的程序冲突,动这个等于给自己挖坑。
4.2 二进制放哪、服务怎么接管
源码装完,最容易犯的错是直接cp sbin/sshd /usr/sbin/sshd。这个动作做完,原厂的包管理记录就对不上了,rpm -V openssh-server一片红,回滚也没法靠yum downgrade了。
我的做法是双二进制共存 + systemd 单元指向新路径:
# 保留原二进制做应急(原厂包不受影响) ls -l /usr/sbin/sshd # 原厂 ls -l /usr/local/openssh/sbin/sshd # 新编译 # 修改单元文件,只改 ExecStart cp -a /usr/lib/systemd/system/sshd.service /var/backups/sshfix/ sed -i 's#^ExecStart=.*#ExecStart=/usr/local/openssh/sbin/sshd -D $OPTIONS#' \ /usr/lib/systemd/system/sshd.service systemctl daemon-reload systemctl restart sshd这么做的好处是回滚极其简单:把单元文件改回ExecStart=/usr/sbin/sshd,daemon-reload再重启,十秒钟退回原状,而且包管理记录干净。
SELinux 那边还有一步:新路径/usr/local/openssh/sbin/sshd的上下文大概率是usr_t而不是sshd_exec_t,enforcing 模式下会被拒。处理方式:
semanage fcontext -a -t sshd_exec_t '/usr/local/openssh/sbin/sshd' restorecon -v /usr/local/openssh/sbin/sshd # 确认 ls -Z /usr/local/openssh/sbin/sshd注意:
/var/empty/sshd这个权限分离目录的属主和权限必须是root:root 0755,且不能有写权限给其他人。源码编译有时不会自动创建,需要手工mkdir -p /var/empty/sshd && chmod 755 /var/empty/sshd。这个目录权限不对时,sshd 的报错信息非常隐晦,容易让人查错方向。
4.3 配置与版本的兼容性,比想象中脆弱
高版本 sshd 读低版本配置文件,大部分情况下能读,但有些指令被移除了或语义变了。比如早期版本里的RSAAuthentication、RhostsRSAAuthentication这些早就不认了,多余指令在-t阶段会直接报错,反而好排查。麻烦的是那些默认值悄悄变了的指令,配置能读、服务能起,行为却不一样了。
所以源码路线我必做的一步是:升级后重新跑一次sshd -T,跟升级前的快照diff,对每一个差异项明确判断“接受”还是“覆盖掉”。经验上,差异集中在算法列表(KexAlgorithms、Ciphers、MACs)、PermitRootLogin的默认值、以及Subsystem sftp的路径这几处。Subsystem这一条尤其容易被忽略:老配置文件里写的可能是/usr/libexec/openssh/sftp-server,这个路径在原厂包里存在,但如果新版本装到/usr/local,内部 sftp-server 的路径变了,SFTP 就会报subsystem request failed。
5. 把流程固化成脚本:设计上的几个取舍
5.1 阶段划分与幂等性
脚本我拆成五个阶段,每个阶段都能单独执行,用参数控制:preflight、backup、upgrade、verify、rollback。这样做的理由很实际——真出问题时,你不需要重跑全流程,直接./sshfix.sh rollback就行;中途某步失败,也能从失败的那一步单独重试。
#!/bin/bash set -euo pipefail ACTION="${1:-}" LOG=/var/log/sshfix/$(date +%Y%m%d%H%M%S).log BAK_ROOT=/var/backups/sshfix mkdir -p "$(dirname "$LOG")" "$BAK_ROOT" exec > >(tee -a "$LOG") 2>&1 log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; } die() { log "ERROR: $*"; exit 1; } preflight() { [ "$(id -u)" -eq 0 ] || die "需要 root 权限" log "当前版本: $(ssh -V 2>&1)" log "已装包: $(rpm -qa | grep -i '^openssh' | tr '\n' ' ')" [ -d /opt/localrepo/repodata ] || die "本地源未就绪" yum --disablerepo='*' --enablerepo=local-offline \ update openssh-server --assumeno >/dev/null 2>&1 \ && log "依赖可解" || die "依赖无法解析,检查本地源" } backup() { BAK="$BAK_ROOT/$(date +%Y%m%d%H%M%S)" mkdir -p "$BAK" cp -a /etc/ssh "$BAK/etc-ssh" cp -a /etc/pam.d/sshd "$BAK/pam-sshd" cp -a /usr/lib/systemd/system/sshd.service "$BAK/" sshd -T > "$BAK/sshd_T_before.txt" 2>/dev/null || true rpm -qa | grep -i '^openssh' > "$BAK/rpm-list-before.txt" log "$BAK" > "$BAK_ROOT/LATEST" log "备份完成: $BAK" }幂等性靠两点保证:升级本身是幂等的(版本已达标就跳过),备份目录带时间戳因此不会互相覆盖。写这类脚本最忌讳的是“跑第二遍就乱”,尤其是在多台机器上批量执行时,一台失败重试、一台成功,脚本必须能识别当前状态而不是无脑往下走。
5.2 防失联的兜底设计
脚本里我把at的回滚任务做成了默认开启,只有verify阶段全部通过才自动atrm。这样即使脚本在中间环节被意外终止(网络断了、终端关了),机器也会在设定时间自动回到可用状态。这是我认为整个脚本里最有价值的一段代码,虽然它只有四行。
arm_rollback() { local conf="$BAK/etc-ssh/sshd_config" echo "cp -a $conf /etc/ssh/sshd_config && systemctl restart sshd" \ | at now + 15 minutes >/dev/null 2>&1 atq | tail -n1 > "$BAK/at-job.txt" log "已挂载延时回滚,15 分钟后自动执行" } disarm_rollback() { local job job="$(awk '{print $1}' "$BAK/at-job.txt" 2>/dev/null || true)" [ -n "$job" ] && atrm "$job" 2>/dev/null && log "已取消延时回滚" }还有一个细节:脚本执行过程中如果检测到当前会话的父进程是 sshd,并且$SSH_CONNECTION为空或异常,就直接拒绝执行。这是防止有人在串口控制台上误跑、或者在 cron 里被意外拉起。
5.3 日志、退出码与批量执行
日志全部落到/var/log/sshfix/下,每条带时间戳。退出码我定了一套约定:0 成功,10 前置检查失败,20 备份失败,30 升级失败,40 验证失败,50 已回滚。批量分发时外层脚本只认退出码,非 0 就停下来人工介入,绝不自动重试——SSH 服务的变更自动重试是很危险的。
批量执行我用的是简单的“读清单 + 串行执行 + 失败即停”:
while read -r host; do scp -q sshfix.sh "root@$host:/tmp/" || { echo "$host 分发失败"; break; } ssh "root@$host" 'bash /tmp/sshfix.sh upgrade' rc=$? [ $rc -eq 0 ] || { echo "$host 失败,退出码 $rc,已停止批量"; break; } done < hosts.txt串行而不是并行,是我刻意的选择。并行看着快,但一旦出问题,多台机器同时失联,你连一台完整的日志都抓不全。串行每台三十秒,二十台也就十分钟,换来的是可控。
6. 踩坑实录与排查速查表
6.1 几个印象最深的故障
故障一:sshd -t通过,但服务起不来,报Unable to load host key。原因是备份恢复时用了cp而不是cp -a,host key 权限变成了 0644,属于“对其他用户可读”,sshd 出于安全考虑直接拒绝加载私钥。解决:chmod 600 /etc/ssh/ssh_host_*_key && chmod 644 /etc/ssh/ssh_host_*_key.pub。这个坑第一次遇到时查了快一小时,因为错误信息完全没提权限。
故障二:升级后所有用户登录都报Permission denied,日志里是PAM: Authentication failure。排查发现是新版本对 PAM 模块的调用路径变了,而/etc/pam.d/sshd里引用了一个在系统里已不存在的模块。因为用的是--sysconfdir=/etc/ssh但 PAM 配置是系统级的/etc/pam.d/,属于“配置目录”和“认证目录”分开处理时容易漏掉的地方。教训:PAM 目录必须单独备份、单独核对。
故障三:临时端口实例起来了,但ssh -p 2222报Connection refused。这跟 sshd 无关,是 firewalld 拦了。临时排查阶段我一般直接systemctl stop firewalld几秒钟,验证完立刻起回来,比临时加规则快。当然长期方案还是加规则。
故障四:批量执行时第三台失败,日志显示Transaction check error,说是openssl-libs版本不足。原因是那台机器的openssl-libs被人为降过版本,低于新 openssh 的依赖下限。解决只能是把openssl-libs也升上去,但这属于扩大变更面,得重新走审批。这件事之后,我在preflight阶段加了一条硬性检查:rpm -q --whatrequires openssl-libs,看有多少包依赖它,评估升级影响的半径。
6.2 排查速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
sshd -t报语法错误 | 配置里有新版本已移除的指令 | 按报错行号注释掉,重跑-t |
| 服务起不来,无明确报错 | host key 或/var/empty/sshd权限不对 | 检查属主与权限位,restorecon上下文 |
| 新会话连接被立即断开 | 算法不匹配 | 服务端sshd -T与客户端ssh -vvv双向对照 |
| 密码正确但登录失败 | PAM 模块缺失或路径变更 | 查/var/log/secure,比对备份的pam.d/sshd |
| SFTP 可用但子系统路径报错 | Subsystem sftp路径在新版本不存在 | 改为新二进制内置的 sftp-server 路径 |
| 升级后扫描器仍报高危 | 只升了部分包,或旧二进制仍在 PATH 中 | ssh -V与实际监听进程ls -l /proc/<pid>/exe对照 |
| 批量升级后个别机器失联 | 该机器配置文件有历史遗留非默认项 | 用sshd -T快照 diff,逐项核对 |
| 回滚后配置没生效 | 只降了包,没恢复sshd_config | 回滚必须同时处理包与配置两件事 |
提示:
ls -l /proc/$(pidof sshd)/exe这条命令能直接看到当前运行中的 sshd 到底是哪个二进制。升级后版本号对但行为没变,八成是服务没真正重启、或者 systemd 起来的是另一个路径下的程序,这一条一查就清楚。
7. 修完之后:加固、复测与长期维护
修复完不等于收工。安全通告那一关过了,扫描器复测还得过,而且复测的口径往往和你的理解不一样——有的扫描器只认版本号,有的会真的去试探算法和认证方式。前者的话,只要ssh -V达标就行;后者的话,得确保你放开的兼容算法没有把风险重新带回来。
我的习惯是修复完立刻做一轮配置收紧,把因为兼容性放开的东西限定到最小范围:
# 只对管理网段放行老算法,业务网段保持严格 Match Address 10.0.0.0/8 KexAlgorithms diffie-hellman-group14-sha256,diffie-hellman-group-exchange-sha256 PubkeyAcceptedAlgorithms ssh-rsa,rsa-sha2-256,rsa-sha2-512 Match all KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.orgMatch块的顺序有讲究,Match all必须放最后,否则后面的规则会被前面的覆盖。这个特性用来做“默认严格 + 例外放宽”非常合适,比全局放开安全得多。
长期维护上,我把整套东西做成了一个自包含的离线包:一个压缩包,里面是sshfix.sh、localrepo/目录(含新旧两版 RPM 和 repodata)、一份README写明适用版本和回滚方法。带到任何一个隔离环境,解压、跑脚本、完事。下次再来新漏洞,只要把新包丢进localrepo/、重新createrepo_c一下,脚本逻辑一行都不用改。这种“包与逻辑分离”的做法,比每次改脚本要可靠得多,也便于多人协作——毕竟脚本是资产,漏洞是一直在变的。
我个人在实际操作中的体会是,这类离线修复最该投入精力的是准备阶段,而不是执行阶段。执行阶段真正在敲命令的时间可能不到三分钟,剩下的全是确认、验证和等待。把能提前做的事(拿包、算依赖、备配置、写回滚)都在进场前做完,现场就只剩下“跑脚本、看结果”两件事,压力和风险都会小一个量级。