深度解析塑造未来的技术文章。

一个星号,十年之久:Alpine Linux 的静默 SSH 漏洞

Alpine Linux 的一个 SSH 漏洞让超过 256 字符的任意密码都能登录锁定账户,潜伏十年之久。看 musl crypt() 的怪癖如何酿成此祸。

黑暗服务器机房里,一扇保险库门上有个发光的星号形开口
一个字符的差别,就是锁定账户与敞开大门之间的全部距离。

这里有个数字值得你警觉:257。大约十年间,在错误的 Linux 系统上通过 SSH 登录时,只要输入 257 个字符或以上的密码,不但不会被拒绝,反而直接放行。任何密码都行:乱敲的键盘字符、一段 base64 编码的度假照片、误粘贴的半条 Slack 消息,只要够长,门就开了。被披露为“sshole”的 Alpine Linux SSH 漏洞,乍看像个恶作剧,细看触发条件之后,读起来就像一份事故报告了。

简单说:OpenSSH 如果是基于 musl libc 编译的,就会中招。这包括 Alpine、OpenWrt、Buildroot、Yocto,以及互联网上数量惊人的 Docker 基础镜像。原本在数学上不可能登录的账户,会接受任意 257 到 1024 个字符的密码。Root 也好,只允许密钥登录的部署用户也好,还有 Alpine 官方 AWS 和 Azure 镜像里默认的 alpine 用户(它配置了免密 doas)。修复已在 OpenSSH 10.6p2 中发布。但这个漏洞的来龙去脉,是一连串小而合理的决定,可以一直追溯到 1980 年,而且其中每一环在当时看来都是对的。

Alpine Linux SSH 漏洞的实际原理

要理解这次碰撞,需要三个部分,它们分属不同的代码库,也来自不同的年代。第一,Linux 如何存储密码。设置密码后,哈希值会写入 /etc/shadow,每个账户一行。普通账户的那一行类似 $6$salt$hash,其中 $6$ 表示 sha512crypt。但那些本来就不该接受密码的账户(守护进程、只用密钥的用户、被锁定的 root),得到的是一个占位符:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

登录时,sshd 会把你输入的内容用存储的 salt 做哈希,然后把结果和存储字段做字符串比较。一致就放行。星号是个哨兵值,它不是任何东西的哈希,所以没有任何密码能与之匹配。自 Unix 早期以来,这一直是只用密钥账户的锁定机制。它之所以有效,是因为比较永远不可能成功,除非哈希函数本身能被诱导输出一个星号。这就引出了第二部分。

第二部分是 musl 的错误处理。传统上,crypt() 在无法完成任务时返回 NULL,比如 salt 指定的算法系统并不支持。问题在于,多年来许多软件从不检查 NULL,而是直接把它传给 strcmp,然后段错误崩溃。于是在 2012 年,musl 做了一个刻意的设计选择:它的 crypt() 永远不返回 NULL。失败时,它返回一个保证不会匹配任何真实密码哈希的字符串,这样有缺陷的调用方看到的是“密码错误”,而不是崩溃。他们选的失败字符串就是 *。三周后,musl 加入了 sha512crypt,并设置了 256 字节的输入上限,因为哈希开销随密码长度增长,超长密码可能成为拒绝服务攻击的途径。于是超过上限的输入被拒绝,这属于失败,失败就返回 *。记录这一点的注释在源码里一直都在。

第三部分是 sshd 的“礼貌”。2016 年,CVE-2016-6210 表明,攻击者可以通过计时响应来枚举有效用户名:不存在的用户会被即时拒绝,因为根本没有哈希可算。修复方案很巧妙:对于没有可用哈希的账户,sshd 借用另一个账户的 salt,仍然对提交的密码做一次哈希,纯粹为了消耗同样的时间。慢一点的“不”。问题在于,这条路径不只对不存在的用户触发,对影子文件字段为 * 的真实用户也会触发,因为星号不是可用的哈希。于是 sshd 现在会计算 crypt(你的密码, 借来的salt),再把结果与 * 比较。

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

每个环节都必须严丝合缝:存储字段是 *,crypt 失败,而失败值也是 *。在 glibc 上,crypt 失败返回 NULL,比较安全地失败。在 libxcrypt 系统上,失败字符串是 *0,只差一个字符,而这一个字符就是全部关键,因为 *0 永远不等于 *。在 musl 上,错误值和锁定值是同一个字符,strcmp 无法区分错误与匹配。一根线,两种含义,接收方无法分辨。

十年间完全合理的决定

这个漏洞值得研究的地方不在于机制本身,而在于参与其中的每个人都没做什么明显愚蠢的事。先说 1980 年:那时密码哈希还存放在所有人都能读取的 /etc/passwd 里,Bill Joy 创建了一个叫 daemon 的账户,没人应该登录它,并把 * 放在了本应是哈希的位置。一个干净的哨兵值,比它的时代超前了几十年。(据说它的真实姓名字段写的是“the devil himself”。)Unix 至今仍以这种方式锁定账户。

musl 在 2012 年的决定,从各个角度看也站得住脚。崩溃的调用方确实是问题,一个把 NULL 当武器对付几十年来粗糙应用代码的 C 库,树敌不少。返回一个永远不会匹配的字符串,是一种优雅的降级。限制密码长度以防止基于哈希的拒绝服务,同样站得住脚。sshd 的计时修复也是如此,为不可哈希的用户补齐响应时间,是标准做法。Alpine 接下来的操作,甚至也说得过去。2019 年,CVE-2019-5021 揭示 Alpine 的 Docker 镜像随附的 root 密码字段是空的,在某些配置下甚至完全没有密码。Alpine 于是用 ! 锁定 root。但在没有 PAM 的情况下,sshd 把 ! 当作完全锁定,这也会断掉 SSH 密钥登录,而云镜像恰恰靠密钥登录活着。所以到了 2022 年,Alpine 3.16 把 root 改成了 *:依然锁定,密钥可用,大家都开心。可偏偏 * 正是 musl 的 crypt 可能被诱导输出的那个字符串。

每一步都是对真实问题的合理回应。这正是让人不安的地方:你可能在每个决定的现场,都会点头附和。

险些出事的案例反而更让人心惊。一位 Debian 开发者曾请求 glibc 采用 musl 那种精确的 * 行为;六年后 Debian 转向 libxcrypt,它的 *0 无意中堵上了这个漏洞。一个 BusyBox 讨论串抱怨 passwd 在 257 字符密码上出错,同样的星号、同样的上限,而讨论串里没有人提到“sshd”。一位安全审查人员修补了 sshd 的 pick_salt() 函数,而真正的漏洞就在四行之下,四天之后才被人找到。这个问题被反复路过,而路过的人几乎是直视着它的。

深色桌面上一连串不同材质的多米诺骨牌依次倒下
几十年的小而合理的决定,一个接一个地推倒下一个。

你的基础设施是否受 musl OpenSSH 漏洞影响?

sshole 的一个幸运之处在于,它需要满足一整套条件才会生效。缺一条你就是安全的。只有当以下全部条件成立时,你才处于风险之中:

  • 你的 sshd 基于 musl 编译:Alpine、OpenWrt、Buildroot、Yocto,或者基于 alpine 派生、安装了 openssh-server 的 Docker 镜像。
  • sshd 没有通过 PAM:Alpine 的 openssh-server 默认不走 PAM,或者你设置了 UsePAM no。PAM 的代码路径不会对 * 账户调用 crypt。
  • 已启用密码认证。默认就是启用的。
  • 至少有一个账户在 /etc/shadow 中是裸 *:只用密钥的用户、基于 LDAP 的账户,以及 Alpine 上的 root。
  • 至少有另一个账户拥有真实的密码哈希,这样才有 salt 可借。

五个条件全部满足时,机器上每个 * 账户都会接受任意 257 到 1024 字符的密码。最糟糕的配置是 Alpine 官方 AWS 和 Azure 镜像:默认的 alpine 用户只用密钥(因此字段为 *),还配置了免密 doas。在那里,用超长密码登录算不上权限提升,而是直通 root 的后门。有一个机制值得肯定,它让这个问题保持沉默这么久:OpenSSH 的 PermitRootLogin 自 2015 年起就拒绝 root 使用密码登录,所以最明显的目标在默认情况下得到了部分保护。但部署用户和服务账户,什么保护都没有。

如果你用的是 Debian、Ubuntu、Fedora 或其他基于 glibc 的系统,你是安全的。通过 PAM 走的:安全。Dropbear:安全。OpenBSD:安全。给持怀疑态度的人一个提醒:社区早期的复现尝试结果并不一致,因为这五个条件任何一个都很容易漏掉。有人试了官方 Alpine 镜像,却没意识到 root 的字段是 ! 而不是 *,复现失败,就此作罢。所以“我笔记本上复现不了”这种说法,对生产环境几乎没有任何证明力。看条件,别看感觉。

真正的根因:带内信令与魔术字符串

很容易把 sshole 归入“奇怪的 libc 边缘情况”然后翻篇。但这种失败模式更古老,也更普遍:带内信令。字符 * 被派了两份差事:在 /etc/shadow 里,它表示“该账户不存在密码”;在 musl 的 crypt 里,它表示“哈希失败”。两条消息共用一个通道,接收方无法分辨。把哨兵值嵌入普通数据,正是系统最终攻击自己的方式。NULL 作为错误信号是可以接受的(尽管它容易让调用方崩溃)。异常会更好。而一个强制调用方显式处理失败的 Result<T, E> 是最佳选择。我最想刻在每个系统程序员身上的实践准则是:不要用魔术字符串。如果非用不可,就挑一个不可能与问题域中任何值冲突的东西。libxcrypt 的 *0 比 musl 的 * 多出恰好一个字节,而这一个字节,就是“小怪癖”与 CVE 之间的差别。

这里还有第二个教训,关于静默的兼容性垫片。musl 那种优雅地返回 * 的决定,是为了防止有缺陷的软件崩溃,而这也意味着它让有缺陷的软件永远不会被发现有缺陷。段错误很难看,但它也是一种信号:有人提交 bug,有人修复调用方。把错误吞掉,错误就会在十年间悄悄累积。我越来越确信,这是系统设计中最深的分野:能抵御灾难的工程倾向于早而响亮地失败,而一味追求顺滑的工程则会积累隐形债务,直到账单一次性到期。

它还应该打破一个让人安心的迷思:古老、被广泛部署的代码因为经过实战检验就更可靠。岁月并不会均匀地分配审视——它把审视集中在热路径上,让冷门路径慢慢化石化。大家都审计加密代码,几乎没人审计 sshd 与 libc 讨论密码的那些无聊胶水代码,因为它“一直都好好的”。musl 系统,以及其中不使用 PAM 的那些,这两种少数配置的交集,恰恰是一个漏洞可以安稳潜伏十年的地方。

如何修复 sshole 漏洞并检查日志

补丁是最简单的部分:OpenSSH 10.6p2 堵上了漏洞。在 Alpine 上,只需一条命令。如果今天无法升级,缓解措施同样简单直接,而且非常有效:关闭密码认证。没有密码检查,就没有这个漏洞。坦率地说,你应该两件事都做,因为只用密钥的认证,在这个漏洞出现之前就是正确的做法,在所有人打完补丁之后依然如此。这让人想起我们在其他关于打补丁与缓解的争论中看到的教训:当缓解措施既便宜又彻底时,就别耍小聪明。

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
黑暗中一盏灯照亮层层堆叠的日志打印件,其中一行发出红光
在长达十年的认证日志里,某个超长密码可能曾经打开过一个锁定的账户。

那么更难的问题来了:有没有人抢先一步进去过?十年是很长的潜伏期。你要找的是:对从未设置过密码的账户,成功通过密码认证的记录——只用密钥的部署用户、服务账户、LDAP 身份、Alpine 机器上的 root。这些账户通过 password 方式成功登录应该极为罕见;任何一次命中都是最高级别的警报。令人不安的事实是,sshd 记录的是登录成功,而不是密码长度,所以大量直接证据在写入时就已经消失。正如我们之前讨论过的,云认证日志即使在状况良好时也有盲区,而短生命周期虚拟机上的实例级 sshd 日志,恰恰就存放在那里。用你手上有的东西去搜,并假定它是不完整的。

十年无声,我们该学到什么

这次发现本身和漏洞一样值得关注。发现 sshole 的研究者并没有死盯着加密代码,而是盯着 OpenSSH 与 libc 交互的那个无聊边界。他们用他们能想到的每一种配置构建了 sshd(glibc、musl、PAM、无 PAM、shadow、无 shadow),然后向这个矩阵投掷了大约 17,600 次畸形登录。怪异的哈希、损坏的账户、超长的密码。每个构建在每次登录上的结论都一致,只有一个例外:没有 PAM 的 musl。那个构建对不该答应的事情说了“是”。这不是运气好的发现,而是覆盖率。跨配置矩阵的差分测试,正是找到那些藏在交集里的漏洞的方法,而这种详尽、不知疲倦的工作,恰好是智能体集群出奇擅长的。下一个 sshole 将不会有十年的安静时光。

真正该让运维人员夜不能寐的问题是:这十年是真的无人察觉,还是错误的人察觉了?光考虑意外情况就够吓人了:把错误的剪贴板内容粘贴进密码提示框——半条 Slack 对话、一整个 SSH 私钥,任何超过 257 字符的东西——在一台有漏洞的机器上,一个锁定的账户就这样悄悄打开了。如果你从来没有把内容误粘贴进密码框,那你比我共事过的所有人都细心。某个地方,有人登录过一台他本不想登录的机器,然后悄悄关掉了终端。也许不止一个人。

所以,打补丁,没错。关闭密码认证,绝对要做。但之后再花一个小时,在你自己的系统里搜寻那些身兼两职的字符:哨兵值、静默回退、“这永远不可能发生”的分支、没人记得写过的兼容垫片。每个成熟的代码库里都有这些东西。它们之所以隐形,正是因为它们从未造成任何人能看见的问题。此刻你技术栈的某处,很可能有个星号正悄悄地同时表达两种含义。在有人带着超长密码找上门之前,去把它找出来。