一、事件概览

2026 年 11 月 5 日,OpenSSH 项目发布 10.6p2,紧急修复一个可绕过 SSH 密码认证的严重漏洞。此时距离 10.6 正式版(10 月 6 日)发布仅一个月,属于典型的"计划外安全版本"。

漏洞编号 CVE-2026-123158,代号 "sshole",由 Wakefield 的 Özgün Kültekin 报告。MITRE 给出的 CVSS 3.1 评分为 8.1(HIGH),向量 AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H,CWE-394(对返回值/状态码的误判)。

一句话概括:在 musl libc + 未启用 PAM 的 Linux 上,只要系统里存在一个密码字段为 * 的账户,且同时存在另一个持有 $5$/$6$ 哈希的账户,攻击者提交一段 257~1024 字节的任意密码,就能以那个"本应禁止密码登录"的账户身份登录。一次连接即可,无需重试,命中率 100%。

二、漏洞现状

  • 补丁已发布:上游 OpenSSH 10.6p2 于 2026-11-05 发布;Alpine 率先完成回补,修复版本一路回到已停止维护的 3.16 分支。
  • 细节与 PoC 公开:研究者放出原理站点 sshhole.com 与两个复现环境(leonjza/sshhole-lab、predkambrij/sshhole-repro),漏洞可被低成本验证,不存在"仅理论可利用"的缓冲期。
  • 影响面被显著放大:Alpine 官方基础镜像在 Docker Hub 的累计下载量以十亿计,CI Runner、云主机、边缘设备、路由器固件大量基于 musl。腾讯云安全中心给出的风险等级为高风险,并确认"漏洞细节、POC 已公开"。
  • 不是一个新 bug:触发链的两半分别成形于 2012 年和 2016 年,之后十年无人察觉,2026 年 11 月才被系统性测试发现。
  • OpenSSH 官方定性:认为"根因在 sshd 之外"(即 musl 的 crypt() 返回值语义),但既然能经由 sshd 利用,就在 sshd 侧加了规避补丁。

三、原理:一个 "*" 引发的血案

1. /etc/shadow 里的 * 是什么

现代 Linux 把密码哈希存放在 /etc/shadow,形如 $6$abc$k2P9...:$6$ 表示 SHA-512,abc 是盐(salt),后面是哈希值。而 * 不是哈希,它是"此账户永不允许用密码登录"的标记——1980 年 Bill Joy 在 BSD 的 /etc/passwd 里为 daemon 账户写下这个符号,沿用至今。

与之容易混淆的是 !:! 表示账户被锁定,在未启用 PAM 时连公钥登录也会被拒绝;* 则允许公钥登录、只禁止密码。Alpine 从 3.16 起把 root 的密码字段从 ! 改成 *,正是为了让云镜像能用密钥登录 root。

2. 无 PAM 时 sshd 如何校验密码

当 sshd 编译时未启用 PAM(Alpine 的 openssh-server 就是如此),密码校验走传统路径:调用 C 库的 crypt(用户提交的密码, 盐) 算出哈希,再与 /etc/shadow 中存储的字段逐字符比较,相等即通过。字段是 * 时本没有盐,理论上任何密码都无法匹对。

3. 没有盐,就借别人的

2016 年修复 CVE-2016-6210(用户枚举时间侧信道)时,OpenSSH 引入了一个"填充耗时"的逻辑:如果目标账户没有可用的盐,就借用系统中另一个账户的盐,照样做一次哈希运算,让"用户不存在"与"用户存在但密码错"的响应时间一致。这个改动本意是防御计时攻击,却让 * 账户第一次"拿到了别人的盐"。

4. musl 的两次关键改动

  • 2012 年 8 月(commit b3c4cc12):musl 认为 crypt() 失败返回 NULL 会让调用方崩溃,于是把失败返回值改成字符串 "*"。
  • 20 天后(commit 88bf5a8a):musl 加入 SHA-512 crypt 实现,为防止"超长密码拖慢服务器"的 DoS,把密码长度上限设为 256 字节,超过即视为失败。

两条改动单独看都合理,叠加后产生了一个致命的等价关系:超长密码 → crypt 失败 → 返回 "*" → 与 shadow 里的 "*" 完全相等。

5. 两条线交汇

alice 的密码字段          :  *
crypt(257~1024 字节密码, bob 的盐)  ->  *      (musl 长度超限,返回失败标记)
比较结果                  :  * == *  →  认证通过

攻击者不需要知道任何密码,也不需要知道 alice 的任何信息之外的东西;只要能把 257 字节以上的字符串送进密码框,就能以 alice 身份登录。字数越长越稳,1024 字节以内都能成功。

四、受影响范围

漏洞需要以下四个条件同时成立,缺一不可:

  • OpenSSH 版本 < 10.6p2;
  • 运行在 musl libc 环境(Alpine、OpenWrt、Buildroot、Yocto,以及大量基于 Alpine 的容器镜像);
  • 未使用 PAM(Alpine 的 openssh-server 默认如此;openssh-server-pam 配 UsePAM no 也算);
  • PasswordAuthentication yes(OpenSSH 默认值,除非你手动关过),并且系统中至少有一个 * 账户 + 至少一个带 $5$/$6$ 哈希的账户。

常见的 * 账户来源:Alpine 3.16+ 的 root(默认受 PermitRootLogin prohibit-password 保护,但可通过 doas 提权)、用 adduser 创建但未设密码的"仅密钥"用户、usermod -p '*' 的用户、通过 musl-nscd 接入的 LDAP/AD 用户(在本地没有 shadow 条目)、以及官方 tiny-cloud 镜像里的 alpine 用户。

自查三步

# 1. 确认 sshd 是否基于 musl
ldd $(command -v sshd) | grep -i musl
apk info 2>/dev/null | grep -i musl

# 2. 确认 PAM 与密码登录状态
sshd -T | grep -Ei '^(passwordauthentication|usepam|permitrootlogin)'

# 3. 列出密码字段为 * 的账户
awk -F: '$2 == "*" {print $1}' /etc/shadow

三步全部命中(musl + usepam no + passwordauthentication yes + 至少一个 * 账户 + 至少一个真实哈希账户)即为受影响。

不受影响的情况

  • glibc 发行版(Ubuntu、Debian、CentOS、RHEL、Arch 等,且密码校验走 PAM);
  • PasswordAuthentication no;
  • openssh-server-pam 且 UsePAM yes;
  • 密码字段为 ! 的锁定账户(但这类账户同时也无法用密钥登录);
  • 系统中不存在任何 $ 风格的密码哈希;
  • 容器内不运行 sshd(绝大多数应用容器);
  • 由 cloud-init 写入 PasswordAuthentication no 的云镜像。

特别提醒:Alpine 官方云镜像

官方 tiny-cloud 镜像(qcow2 / VHD / AMI)在 3.18.11 ~ 3.24.3 之间,内置一个密码字段为 * 且拥有免密 doas 权限的 alpine 用户。首次启动时是安全的,但只要机器上出现任意一个 $6$ 哈希(例如管理员设置了控制台密码,或 user-data 里添加了 hashed_passwd 用户),漏洞立即变为可利用,攻击者可以 alpine 身份登录并直接 doas 提权到 root。

五、修复方案

1. 首选:升级并重启 sshd

# Alpine
apk upgrade -U -a
rc-service sshd restart

# 确认版本
ssh -V
apk info -v openssh | head -1

Alpine 各分支的修复版本如下(2026-11-06 时点):

  • edge:10.6_p2-r0
  • 3.24:10.3_p1-r2 3.23:10.2_p1-r1 3.22:10.0_p1-r11
  • 3.21:9.9_p2-r1 3.20:9.7_p1-r6 3.19:9.6_p1-r3
  • 3.18:9.3_p2-r4 3.17:9.1_p1-r7 3.16:9.0_p1-r6

最容易踩的坑:apk upgrade 只替换磁盘上的二进制,不会重启已经在运行的 sshd 进程。不执行 rc-service sshd restart(或 systemctl restart sshd),漏洞依然存活。

2. 不能立即升级:关掉密码登录

关掉 PasswordAuthentication 是等价有效的临时缓解——漏洞只在密码认证路径上触发。以下命令以 root 执行,先确认自己的密钥登录可用,并保持当前会话不关闭,直到新会话验证成功:

mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sed -i.bak -e '1i PasswordAuthentication no' \
  -e 's/^\(\s*PasswordAuthentication[^y#]*\)yes/\1no/i' \
  /etc/ssh/sshd_config
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication

最后一条命令应输出 passwordauthentication no。注意 Alpine 3.19 起才读取 sshd_config.d 目录,更老的版本靠第二条 sed 直接改主配置。回滚方式:删除 drop-in 文件并把 sshd_config.bak 拷回。

3. 其他发行版与容器

  • OpenWrt / Buildroot / Yocto:升级 openssh 包到含 patch 的版本,或同样关闭密码认证。Buildroot/Yocto 需要确认上游 recipe 已回补。
  • Docker:把基础镜像更新到 Alpine 3.21.10 / 3.22.8 / 3.23.8 / 3.24.4 或更新版本后重新构建;容器内不跑 sshd 的不受影响,但派生出"带 sshd 的运维镜像"要逐个排查。
  • 云镜像:新购实例优先选上述已修复版本;存量实例逐台 apk upgrade 并重启 sshd。
  • 自编译 OpenSSH:升级到 10.6p2,或手工合并上游 commit 4353fdc4。

4. 顺手加固

  • PermitRootLogin prohibit-password,避免 root 成为可登录的 * 目标;
  • 确实需要锁定的账户用 ! 而不是 *(代价是该账户同时失去密钥登录能力,按需选择);
  • 条件允许时改用 openssh-server-pam + UsePAM yes,从根上绕开这条 crypt() 路径;
  • SSH 端口只对跳板机/可信网段开放,配合 fail2ban 或 nftables 限速;
  • 把 sshd 的认证日志接入集中审计,便于事后追溯。

六、如何判断是否已被利用

漏洞利用会在日志里留下"某账户密码登录成功"的记录,而这些账户本不该出现这种记录。CERT-FR 与研究者的建议是同一条命令:

awk -F: '$2 == "*" {print $1}' /etc/shadow \
  | xargs -r -I{} grep "Accepted password for {}" /var/log/messages

任何命中都高度可疑。进一步排查:doas/sudo 审计日志、~/.ssh/authorized_keys 的近期改动、新增用户、异常 cron 与 systemd unit、可疑出站连接。无法排除的机器应按已失陷处理(隔离、取证、重装、轮换密钥)。

七、几个常见误区

  • "我用 Ubuntu/Debian,肯定没事":方向对(glibc + PAM),但请确认没在容器里跑 Alpine 版 sshd,也没自编译 musl 版本。
  • "改了端口 / 加了防火墙就安全":不是缓解,只是降低被扫到的概率。
  • "我把密码登录关了,为什么还提示受影响":那正是缓解措施生效的表现,可安心等维护窗口升级。
  • "升级了包就完事":必须重启 sshd 进程,否则补丁不生效。
  • "空密码账户最危险":恰恰相反,空字段与任何 crypt() 结果都不匹配;真正危险的是 * 字段。

八、小结

"sshole"是一次典型的组件边界缺陷:musl 为容错改了返回值语义,OpenSSH 为防计时攻击加了盐借用,两者在"字段为 *、密码超长"这一狭窄组合下正好对齐,把一个"永不允许密码登录"的账户变成了"任何超长密码都能登录"。它提醒我们三件事:跨库语义变更要端到端验证;时间侧信道的防御补丁本身需要安全评审;升级之后必须重启服务。

对 Alpine 用户来说,动作很小:apk upgrade -U -a + rc-service sshd restart;来不及升级,就 PasswordAuthentication no。两件事做任意一件,洞就关上了。

参考链接