在运行 musl 标准库的 Linux 系统(涵盖 Alpine Linux、OpenWrt、Buildroot、Yocto 以及海量 Docker 容器与云镜像)上,一个本该彻底封死密码登录的账户,只要碰上任意一段超过 256 个字符的密码,大门就会瞬间洞开。安全研究人员近期通过 sshhole.com 披露了这个自 2016 年起潜伏至今的 OpenSSH 认证绕过漏洞(被称为 Sshole)。最令人警醒的地方不在于某个程序员写错了判断符号,而在于它是由四次跨越四十年的合理工程修补,在各自独立演进中彼此架空防线而撞出的系统级事故。

四块跨越四十年的多米诺骨牌与双重防线失灵

理解这场事故,得先看清类 Unix 系统里一个延续了四十多年的老规矩。1980 年,Bill Joy 在 3BSD 系统的 /etc/passwd 文件里首次放进了一个星号 ,用来标记禁止密码登录的 daemon 账户;后来密码哈希迁入 /etc/shadow,由于没有任何合法哈希算法会算出单个星号,用 代表禁止密码登录(但允许 SSH 公钥等其他方式)就成了沿用至今的行业惯例。

问题出在 C 语言标准库处理加密失败的方式上。1997 年的 Single UNIX 规范规定,crypt() 函数在无法完成哈希计算时应返回空指针 NULL,但现实中大量老旧程序从不检查空指针,一旦遇到 NULL 就会直接崩溃。为了给这些脆弱的程序兜底,musl 标准库在 2012 年 8 月的一次提交(b3c4cc12)中决定不再返回 NULL,而是返回一个号称永远无法匹配合法哈希的单字符 。仅仅 20 天后的 2012 年 8 月 29 日,musl 引入 SHA256 与 SHA512 哈希支持(提交 88bf5a8a),为了防止攻击者发送超长密码耗尽服务器算力(DoS 攻击),在原始设计外硬性加上了 #define KEY_MAX 256 的长度上限——只要密码超过 256 字节,crypt() 就会判定失败并吐出那个代表报错的 。

四次合理修补如何拼出 10 年认证绕过漏洞 1980 3BSD 传统 用 * 禁密登录 2012.08 musl 防崩与防 DoS 超 256 字节报错返 * 2016.07 OpenSSH 防计时枚举 借用真实 $6$ 盐计算 2022 Alpine 3.16 修密钥 root 字段改回 * 最终效果:输入 257-1024 字符密码 → 触发 musl 报错返回 * → 与账户存储的 * 完美匹配

看到这里,熟悉底层代码的读者往往会提出一个疑问:难道当初写代码的维护者就没有想过报错返回的 会撞上 /etc/shadow 里的 吗?

他们其实想到了,甚至设下了两道防碰撞保护。在 musl 2012 年的那次提交里,作者特意写下了 return (setting[0]=='') ? "x" : "" 的防线——只要发现传入的盐(salt,即账户原本存储的密码字段)以 开头,报错时就会故意返回 "x" 而不是 "",彻底避开碰撞;而在 OpenSSH 一侧,针对单字符的异常密码字段,原本也有回退到 "xx" 假盐的保护机制。如果这两道防线正常运转,Sshole 根本不可能发生。

真正把这两道防线同时架空的,是 2016 年 7 月 OpenSSH 修复用户枚举计时漏洞 CVE-2016-6210 的一次安全提交(9286875a7)。当时为了防止攻击者通过毫秒级的响应时间差猜出系统里存在哪些真实用户,OpenSSH 决定替无效用户以及密码字段为 * 的账户“借用”系统里其他正常账户的真实 SHA-512 盐(以 $6$ 开头)去跑一遍哈希计算,以此消耗相同的时间。

计算与比对错位:双重防碰撞机制如何被借盐补丁架空 1. 借盐输入 OpenSSH 借来正常用户的 $6$ 盐 + 257 字符密码 2. 绕过防线并报错 首字符是 $ 而非 *,骗过检查 超 256 字节触发返回 * 3. 跨层错位比对 拿报错吐出的 * 与目标账户 原本的 * 比对:放行登录 传统 musl 预期状态(未借盐) 传入 setting[0] 为 * → 触发保护返回 x x 与 * 不相等,拒绝登录 现代 libxcrypt 互斥设计(Debian 等) 输入 *0 则返 *1,反之返 *0 错误哨兵永远不等于输入或 *,免疫此漏洞

就是这个“借盐计算、本尊比对”的跨层错位,成了整场风暴的胜负手。当攻击者向一个密码为 的账户输入超过 256 字符的密码时,OpenSSH 把借来的 $6$... 盐传给了 musl 的 crypt()。因为传入字符串的首字符是 $ 而不是 ,musl 内置的 setting[0]=='' 保护瞬间失明;紧接着超长密码触发 256 字节防 DoS 限制,crypt() 名正言顺地返回了 "";最后 OpenSSH 拿着这个计算出来的 "",回头去跟目标账户自己 /etc/shadow 里存着的 "" 做字符串比对——两者完全相等,认证直接放行。

到了 2022 年,最后一块拼图落下。Alpine Linux 此前为了修补 Docker 镜像空 root 密码漏洞(CVE-2019-5021),曾用代表锁定的 ! 封死 root,但在没有启用 PAM 的极简环境里,! 会连带杀死云镜像依赖的 SSH 公钥登录;于是 Alpine 3.16(提交 fbe926af)把 root 的密码字段改回了 *。本意是既禁密码又保住公钥,结果直接把 root 账户送进了这扇潜伏已久的任意门。


带内错误哨兵的契约原罪

如果跳出这起漏洞本身,把 musl 与主流 Linux 发行版(如 Debian、Ubuntu、Fedora)采用的底层库做一次横向对照,就能看清系统软件设计里一条铁律的分量。

同样是为了照顾老旧程序、避免返回 NULL 引发崩溃,现代主流发行版广泛迁移采用的 libxcrypt 库走出了一条截然不同的路。早在 2013 年,曾有 Debian 开发者提议让 glibc 照搬 musl 返回 "" 的方案,但六年后的最终演进是切换到了 libxcrypt:它在加密失败时绝不返回固定的单字符 "",而是根据输入动态返回严格互斥的 "0" 或 "1"(若输入以 0 开头就返回 1,反之返回 0)。仅仅多出一个字符的状态翻转,就从数学契约上保证了“计算层的错误返回值”永远不可能等于输入参数,也永远不会撞上存储层里代表禁用密码的单个 ""。

把报错信号塞进业务数据的合法命名空间里,等于在暗处埋下定时炸弹。

在启用 PAM(可插拔认证模块)的传统发行版里,账户状态检查与密码哈希计算是解耦的,遇到 * 根本不会去调用底层的 crypt();但在容器和嵌入式设备偏爱的 musl 生态中,为了追求极致的体积与性能,系统默认不启用 PAM,OpenSSH 直接走内置的 xcrypt 路径调起 crypt() 并用 strcmp() 比对结果。当上层调用方依赖带内特殊符号表示状态,下层基础库又用同一个符号充当错误哨兵时,任何一次像 OpenSSH 借盐这样的中间层改动,都会立刻引爆两边隐藏的假设冲突。

  • 结论.防崩溃与防碰撞从来不是非此即彼的零和博弈,固定不变的带内错误码本质上是底层 API 的契约缺陷。

谁真正裸奔在危险区与排查盲点

原文将 Alpine 的 root、仅限密钥的部署账户、LDAP 用户以及 AWS 和 Azure 官方云镜像的默认账户一并列为受影响对象,很容易在运维团队里制造“所有 Alpine 容器和 OpenWrt 路由器都能被一键敲开 root”的恐慌。但如果仔细拆解 OpenSSH 的配置边界,真正的重灾区其实另有所在。

漏洞触发必须同时跨过几道门槛:系统使用 musl 且未启用 PAM(UsePAM no 或编译时未包含 PAM,Alpine 默认如此)、开启了密码认证(PasswordAuthentication yes),并且系统里至少存在另一个拥有真实密码的账户供 OpenSSH 借盐(或者攻击者先尝试一次错误登录触发盐缓存)。

这里最关键的防御分水岭在于 PermitRootLogin 参数。OpenSSH 默认将该参数设为 prohibit-password(禁止密码但允许公钥),只要运维人员没有手动把它改成 yes,即便 root 账户在 /etc/shadow 里写着 *,OpenSSH 也会在进入密码比对结果采纳前直接拦截 root 的密码登录请求。

真正的隐蔽盲区,是那些为了方便个别普通用户登录而保持 PasswordAuthentication yes、同时又在机器上配置了大量非 root 免密账户的生产环境。比如 CI/CD 流水线专用的 key-only 部署账户(deploy)、外部接入的 LDAP 账户,或是 Alpine 官方 AWS 与 Azure 云镜像里的默认 alpine 用户——这个默认云账户不仅密码字段是 *,还自带无需密码的 doas 提权配置,一旦被敲开就等同于拿到了 root 权限。此外,即便关闭了 PasswordAuthentication,如果意外开启了基于键盘交互的认证路径,同样可能暴露输入窗口。

对于正在管理 Alpine 容器集群、云主机或 OpenWrt、Buildroot 嵌入式固件的团队,眼下最稳妥的动作不是只盯着上游的 OpenSSH 10.6p2 版本号看。因为 Alpine 等发行版普遍采用向后移植(backport)机制将安全补丁合入旧版本分支,单纯比对主版本号极易漏报或误判,必须直接核对发行版安全公告里的具体软件包修订号(执行 apk upgrade openssh 更新)。在无法立即重启升级的设备上,直接写入 PasswordAuthentication no 彻底掐断密码认证入口,并翻查系统日志里是否存在无密码账户通过 password 方式成功登录的记录,才是守住防线的当务之急。

  • 提醒.排查时切勿只看 root 是否安全,开启了密码认证主机上的 deploy 等非 root 星号账户及具备免密提权能力的云镜像默认账户,才是攻击者最顺手的突破口。