一个字符,两种毫不相干的含义,一次认证绕过
我先发送了一个错误的密码。然后我向一个根本没有设置任何密码的账户,发送了一个长达 300 字节的密码。sshd 让我登录了进去。
两个彼此从未听说过对方的系统共同造成了这一切,它们都选择了同一个字符来表示"这里没有任何可用的东西"。/etc/shadow 几十年来一直用单个星号来标记一个永远不应该接受密码的账户。musl 的 crypt() 函数在某些内部失败时,返回的正是这同一个字符,而不是 POSIX 实际规定的空值。这两个选择单独看都没有错。但把它们放进同一次字符串比较里,就可以让 sshd 误以为密码匹配成功,而实际上根本什么都没有真正比较过。
第一段同时也是本文不会让自己夸大的那一个数据点:在同一次实验室运行中,一个超长密码,作为一次全新连接针对同一账户的第一次也是唯一一次尝试发送,事先没有先发送一个错误密码,结果没有通过认证。一种顺序成功了。另一种没有。这是一次运行得出的一个数据点,而不是断言针对一个存在漏洞的账户的任何首次密码尝试都会成功。这两种结果之间的差异,以及它为何发生,本文稍后会直接而坦诚地讨论,包括其中仍然真正悬而未决的部分。
本文先讲背景:SSH 认证实际做了什么,/etc/shadow 存储了什么,PAM 是什么、为什么不编译它在这里很重要,为什么 musl 不是 glibc,以及 crypt() 失败时本该做什么。只有把这些铺垫好之后,根因机制、实验室证据、补丁,以及那些悬而未决的问题才能真正说得通,而不必要求读者凡事都凭信任接受。
如果您管理的是一台基于 musl、启用了密码 SSH 登录且未使用 PAM 的 Linux 机器,简短版本是这样的:检查您的 shadow 文件中是否存在单个星号,检查机器上其他地方是否存在 $5$ 或 $6$ 格式的哈希,然后打补丁。完整版本,包括这到底为何会起作用,以及一次实际的实验室运行到底证实了什么、又没有证实什么,就是本文接下来的全部内容。
一天之内的响应周转,以及大多数报道都略去的那条公告细节
OpenSSH 10.6p2 于 2026 年 11 月 5 日发布,只包含一个安全修复项,而且发布得很快。Damien Miller 本人的发布公告指出,补丁是在 OpenSSH 团队得知该问题的第二天就发布的,并指出,最近有类似漏洞在数小时内被 AI 代理独立重新发现的案例,意味着如今长时间的禁运期更多是让攻击者而非防御者受益。这是这一压缩时间表的官方说明,不是本文代其团队做出的揣测。[2]
OpenSSH 的发行说明描述称,sshd 现在避免了"一个影响部分在禁用 PAM 支持的情况下构建和运行的 Linux 系统的密码认证绕过"(PAM,Pluggable Authentication Modules,是大多数 Linux 系统用来决定登录是否成功的那一层;几个小节之后会正式介绍它),并将此次报告归功于 Wakefield 的 Ozgun Kultekin。[3] 这位报告者本人详细的技术分析文章就发布在 sshhole.com 上,标题为"*sshole",其中独立给出了与本文稍后完整追溯的同一个 257-1024 字节窗口以及同一个字符冲突。[4] 该站点的 GitHub 用户名 oz9un 与被致谢的姓名相符,但记录中没有任何内容明确证实这一关联;这对本文中任何技术性论断都不构成支撑,只是值得坦率地提一下,而不是当作事实断言。
Alpine Linux 自己的公告是本文整体依赖的第二份主要来源。它精确说明了存在漏洞的账户状态:一个字面值恰好是 "*" 的 shadow 字段,并明确指出一个被锁定的账户(字段为 "!")不受影响。它把一般前提条件表述为同一台机器上至少还需要另一个拥有真实密码哈希的本地账户--Alpine 默认的 SHA-256/SHA-512 格式,见下方表格--声称只要有这样一个账户就足以让每一个无密码账户都变得可触及,并把这次攻击本身描述为只需要一次连接、无需重试,这一措辞本文稍后会直接回应。[5]
CVE-2026-123158 本身的 CVSS 3.1 基础评分为 8.1(高危),向量为 AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H,CWE 分类为 CWE-394,Unexpected Status Code or Return Value(意外的状态码或返回值),这对于一个本质上是"信任一个表示错误的值却未加检查"的漏洞而言,是恰当的分类。CISA 对同一记录的独立补充评估将利用成熟度评为"poc",将可自动化程度评为"否"。截至撰写本文时,尚未有在真实环境中被利用的报告。[1]
大多数针对这个 CVE 的二次报道都把它简化成"一个超长密码绕过了 SSH"。公告本身在三个几乎所有这类报道都省略的点上更为精确:账户必须恰好处于 "*" 状态,而不是 "!";同一台机器上必须已经存在其他拥有可用哈希的账户;字节窗口是 257 到 1024,而不是泛泛而言的"长"。本文剩余部分正是围绕这三个细节构建的,再加上公告自信陈述、但一次实际的实验室运行有理由去验证而非凭信任接受的那一点。
当您输入 SSH 密码时,实际发生了什么
SSH 的存在,是为了让一台机器能够通过一个双方都不必然信任的网络去管理另一台机器,过程加密,并且理想情况下双方都要证明自己确实是其所声称的身份。客户端连接到 sshd(服务器端的守护进程);双方首先协商建立一个安全通道,只有在这个通道建立之后,才会发生认证--通过公钥、密码或其他配置的方式--然后才会运行任何 shell 或命令。密码认证,也就是整个这个 CVE 所涉及的方法,是这一更大握手过程中的一个具体步骤,而不是一个外挂在旁边的独立机制。
在 Linux 系统上,账户信息存放在 /etc/passwd 中,每个账户一行:用户名、数字用户 ID、主目录、shell。密码本身,或者更准确地说是密码的哈希值,几十年前也曾存放在那里。现在它存放在一个单独的文件 /etc/shadow 中,只有 root 才能读取,专门是为了让一个普通用户账户被攻破时,不会同时把机器上所有密码哈希都一并交出去。
不过,那个 shadow 字段并不总是一个哈希值。它可以携带少数几种特殊取值,管理员、工具以及 sshd 本身都必须正确解读这些取值,下面做了总结。
按设计,一个被标记为单个星号的账户,无论输入任何内容都不应该能通过密码认证,因为 "*" 根本就不是一种哈希格式。它是一种约定:这个账户存在,但没有任何密码能够解锁它。管理员恰恰依赖这个约定来处理仅凭密钥登录的账户、服务身份,以及那些自带一个没人预期会用密码登录的 root 账户的容器基础镜像。本文追溯的整个漏洞,正是这一约定悄无声息地不再成立的一个案例。
| 密码字段 | 含义 | 是否受影响 |
|---|---|---|
$1$... / $2a$/$2b$/$2y$... / $5$... / $6$...crypt() 在这里正常运行,并生成一个真正用于比较的哈希值。 | 真实的密码哈希(MD5、bcrypt、SHA-256 或 SHA-512) | 否 |
! 或 !...Alpine 的公告明确说明了这一点。 | 账户已锁定 | 否 |
*在本文追溯的确切条件下。 | 没有设置可用密码(仅凭密钥登录的账户) | 是 |
(空字符串)由 sys_auth_passwd() 中一个独立、更早的分支处理,发生在这个 CVE 所涉及的路径之前。 | 不需要密码 | 否 |
PAM,以及为什么不带它编译 sshd 会改变一切
PAM,Pluggable Authentication Modules,是大多数 Linux 发行版用来决定登录尝试(包括 SSH)是否真正成功的那一层。像 sshd 这样的应用程序通常自己并不实现密码检查;它会请求 PAM 来完成,而 PAM 会运行管理员为该服务配置的模块--本地密码的常见情况是 pam_unix,还有其他模块可用于目录服务或多因素方案。这种间接层是基于 glibc 的发行版(如 Debian、Ubuntu、Fedora、RHEL)的默认做法:在这些系统上,sshd 根本从不直接调用密码哈希函数。[2]
Alpine Linux,以及大多数基于 musl 的嵌入式构建(OpenWrt、Buildroot、Yocto),则改为使用 --without-pam 编译 OpenSSH。在这些系统上,不存在可以委托的 PAM 层。sshd 退回到自己的通用比较代码,在 auth-passwd.c 中直接针对账户自己的 shadow 条目调用 crypt(3)。这一个在编译时做出的决定,正是这个 CVE 完全栖身于其中一条分支上的那个分岔点:一个启用了 PAM 的系统根本永远不会到达那个存在漏洞的比较逻辑,不是因为有任何具体的防御措施阻止了它,而是这段代码路径在那种构建上根本就不会被执行。[2]
还有一个细节值得在接下来两节之前先了解,因为根因小节会直接依赖它:对于 sshd 认为无效的用户名,非 PAM 路径仍然会执行一次比较--针对一个缓存的假密码--以便拒绝一个不存在的账户所花的时间,大致与拒绝一个真实账户上错误密码所花的时间相同。这是一种蓄意且由来已久的、用来防御用户名枚举的时间防御手段,而它与那段把 "*" 账户导向错误分支的盐值选择代码,恰好共用同一套逻辑。这里的"盐值选择代码",指的就是决定 crypt() 拿密码去和哪个参考值比较的那段逻辑;这个参考值到底是什么,以及它是怎么出错的,下面的机制小节会接着讲。
一个共同的第一步:sshd 收到一个密码认证请求,随后分成两条分支:启用 PAM 的构建把决定权委托给 PAM 栈,从不直接调用 crypt();未启用 PAM 的构建则运行自己的比较代码,最终落在与存储哈希进行 crypt() 比较这一步,该步骤被标记为唯一的危险步骤。
sshd 收到一个密码认证请求sshd 从不直接调用 crypt()--without-pam:Alpine,以及基于 musl 的嵌入式构建auth-passwd.c 运行 sshd 自己的比较逻辑不存在可以委托的 PAM 层crypt() 比较这个 CVE 所在的确切路径musl、glibc,以及为什么这个漏洞不涉及 Debian
libc,也就是 C 标准库,是程序与其底层操作系统内核之间的一层:内存分配、字符串处理,以及就本文而言最重要的,crypt()。每一个用 C 编写的 Linux 程序都要链接到其中一个实现。大多数桌面和服务器发行版使用 glibc,即 GNU C 库。Alpine Linux 则改用 musl--同一套标准接口的一个更小、独立编写的实现。
musl 和 glibc 在它们对外暴露的每一处实现层面上并不总是可以互换。它们实现了同一套标准接口,crypt() 也在其中,但在这个接口背后,内部行为并不总是完全一致。这种差异对通过接口调用它的应用层代码来说通常是不可见的。而在这里,它不再是不可见的了。
Alpine 发布的 OpenSSH 是基于 musl 构建的。OpenSSH 调用标准的 crypt(3) 接口,方式与它在任何其他 libc 上的调用完全一样。musl 自己对该接口的实现,特别是它的 SHA-256 和 SHA-512 代码路径,正是接下来三个小节所追溯的那种具体失败行为真正所在的地方,而不是在 OpenSSH 自身的代码里,也不是在 Alpine 对它的打包方式里。
这一点值得精确说明,因为很容易被夸大:这个漏洞具体需要 musl,而不仅仅是"不带 PAM 编译"。OpenSSH 的非 PAM 比较代码存在于任何以这种方式编译的构建中,理论上甚至包括一个非标准的 glibc 构建。但这个 CVE 所撞上的那种失败行为--crypt() 返回一个单字符哨兵值而不是 NULL--是 musl 自身实现所做的一个选择。这项调查没有在 glibc 中找到等价的行为,而 OpenSSH 自己的发布公告明确指出,受影响的群体是 Linux、musl、且未使用 PAM,而不是非 Linux、不是 glibc、也不是使用 PAM 的系统。[2]
一个从上到下共五层的堆栈:作为发行版的 Alpine Linux,未启用 PAM 编译的 OpenSSH sshd,libc 接口 crypt(3),musl libc 自己的 crypt() 实现,以及最底层按算法划分的哈希代码,每一层之间都通过两者之间标注的具体调用或约定相连。没有任何一层被标记为危险:这项调查并未独立核实 musl 自身 crypt_sha256.c/crypt_sha512.c 源代码层面的失败行为。
--without-pam 构建的 OpenSSH sshdcrypt(attacker_password, stored_hash)crypt(3)__crypt_r()crypt() 实现$6$、$5$、……)crypt_sha256.c / crypt_sha512.ccrypt() 失败时本该做什么
crypt(3) 是传统的 Unix 接口,用来计算并校验那种单向哈希--不是可逆加密--也就是您在两小节之前已经接触过的那个存储在 shadow 中的值。crypt(password, salt) 计算出哈希值,而同一个函数会被重新用来校验一次登录尝试:用尝试输入的密码和该账户自己存储的哈希(被重新当作盐值使用)重新计算出一个哈希,然后用普通的字符串比较把两个结果对比一下。盐值的存在,是为了让两个共用同一密码的账户,最终也不会得到相同的存储哈希。
POSIX 规定了 crypt() 在该计算过程中内部出错时应该做什么:返回一个空指针,并设置 errno 说明原因。一个检查了是否为 NULL 的调用方就会知道计算失败了,并且绝不应该把这个结果当作一个可以用来和其他任何东西比较的有效哈希。
musl 的 crypt() 并不总是遵守这个契约。它实际做的是什么,以及为什么这一具体偏差会与上一小节提到的 /etc/shadow 自身的约定相撞,正是本文接下来将在源代码层面追溯的机制,从现在开始。
为什么一个有效账户会被导向备用盐值路径
这是第一个不那么显而易见的细节,而它只是一个单字符的逻辑失误,并不是什么戏剧性的东西。OpenSSH 在 sys_auth_passwd() 中自己的盐值选择检查,并不去问一个账户存储的哈希是不是占位符 "*"。它问的是这个哈希的长度是否至少有两个字符。
这道守卫条件是 `if (authctxt->valid && pw_password[0] && pw_password[1]) salt = pw_password; else salt = NULL;`。对于单字符字符串 "*" 来说,pw_password[0] 就是那个星号本身,为真;但 pw_password[1] 是字符串的结尾空字符 NUL,在 C 语言里为假。一个为真的字符。一个为假的终止符。这就是整个条件,而它在这种情况下已经是错的了。即便 authctxt->valid 为真--也就是即便对于一个完全真实、确实存在的账户--这个条件也会被判定为假。代码会落入与一个根本不存在的用户名所使用的同一个 salt = NULL 分支,不是因为有什么专门检查了 "*",而是作为一个长度检查的副作用--这个检查碰巧读起来像是在问"这够不够长,看起来像是个哈希值"。[6]
当 salt 为 NULL 时,xcrypt() 会调用 pick_salt(),下面完整复现了它,并保留了其原始的 BSD 许可声明。pick_salt() 维护着一个作用域限定在单个进程内的静态 32 字节缓冲区,一旦该缓冲区已被填充,就会立即返回它。在每个进程的第一次调用时,它会通过 setpwent()/getpwent() 遍历所有本地账户,并复制它找到的第一个以 "$" 开头且包含第二个 "$" 的哈希的算法标识前缀--也就是任何模块化 crypt 格式的哈希:MD5($1$)、bcrypt($2a$/$2b$/$2y$)、SHA-256($5$)或 SHA-512($6$),而不是其中某一种特定算法。如果系统上没有任何可用的哈希,它就会退回到传统的 DES 风格盐值 "xx"。[7]
Alpine 的公告把前提条件表述为需要另一个具体拥有 $5$ 或 $6$ 哈希的账户。但代码本身的范围比这更广:它接受 getpwent() 枚举账户时碰到的第一个模块化 crypt 格式哈希,不管顺序如何,这同样覆盖了 MD5 和 bcrypt。公告措辞与代码实际行为之间的这一差距,是本次调查自己的解读,并非任何一个来源直接声明的内容,但值得指出:一台机器即便在别处只使用 MD5 或 bcrypt 哈希,仍然会满足真正的前提条件,即便 Alpine 自己的措辞可能会让人以为并非如此。
还有一个边界,是 OpenSSH 自己的,与 musl 无关:auth_password() 会拒绝任何长度超过 MAX_PASSWORD_LEN(定义为 1024)的密码,而这发生在 sys_auth_passwd()、也就是 crypt() 根本还没被触及之前。这正是为什么公告给出的窗口上限止于 1024 字节而不是开放式的;一个更长的密码根本永远不会靠近那个存在漏洞的比较逻辑。[8]
/* auth-passwd.c, sys_auth_passwd() -- pre-fix */
char *pw_password = authctxt->valid ?
shadow_pw(pw) : xstrdup(pw->pw_passwd);
...
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
else
salt = NULL;
encrypted_password = xcrypt(password, salt);
...
ret = strcmp(encrypted_password, pw_password) == 0;
/* openbsd-compat/xcrypt.c -- Copyright (c) 2003 Ben Lindstrom, BSD license */
static const char *
pick_salt(void)
{
struct passwd *pw;
char *passwd = NULL, *p;
size_t typelen;
static char salt[32];
if (salt[0] != '\0')
return salt;
strlcpy(salt, "xx", sizeof(salt));
setpwent();
while ((pw = getpwent()) != NULL) {
if (passwd != NULL)
freezero(passwd, strlen(passwd));
if ((passwd = shadow_pw(pw)) == NULL)
continue;
if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
typelen = p - passwd + 1;
if (passwd[1] == '2' && strlen(p + 1) >= 22)
typelen += 1 + 22;
strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
explicit_bzero(passwd, strlen(passwd));
goto out;
}
}
out:
if (passwd != NULL)
freezero(passwd, strlen(passwd));
endpwent();
return salt;
}/etc/shadow 的 "*" 与 musl 自己的 "*" 相遇之处
sshd 在代码执行到这一点时相信一件具体的事:crypt() 为一个真实密码返回的字符串,永远不可能等于账户自己的占位符哈希。攻击者恰好控制着打破这一假设的唯一输入:他们密码的长度。
Alpine 的公告明确说明了存在漏洞的账户状态:一个字面值恰好是 "*" 的 shadow 密码字段,这是一个没有可用密码哈希的账户、一个仅凭密钥登录的账户的约定。它明确指出被锁定的账户(字段为 "!")不受影响。[5]
musl 的 crypt() 在其 SHA-256 和 SHA-512 哈希路径中,当内部哈希校验失败时,会返回字面字符串 "*"--一个存储在只读内存中的字符串常量--而不是 NULL。这正是上一小节描述的那种契约违反,现在被一个具体条件触发:一个超长的输入密码。crypt_sha256.c 和 crypt_sha512.c 内部那个按算法划分的具体检查逻辑,本次调查并未独立阅读过;这里记录的内容仅限于 Alpine 的公告、报告研究者本人的分析文章,以及一条 2024 年 12 月的 musl 邮件列表帖子,该帖子描述了在一个完全不同的上下文中出现的完全相同的 "*" 失败行为,比这个 CVE 存在还早了近两年。[5] [4] [9] musl 自己的 crypt.c 只是一个轻量分发器,本身不执行任何这类检查,这一点已经通过直接查看该文件得到确认。[10]
产生 "*" 的失败,是由一个超长的输入密码触发的。Alpine 的公告和 CVE 记录给出的是同一个窗口:257 到 1024 字节。下限是 musl 自己的内部阈值;上限,正如两小节前提到的,是 OpenSSH 早已存在的 MAX_PASSWORD_LEN 上限,与 musl 完全无关。[5] [1]
把这些放在一起看:攻击者发送一个落在该字节范围内的密码,针对一个存储哈希为 "*" 的账户,会导致 crypt() 计算并返回 "*"。接下来的比较是 `strcmp("*", "*")`,根据定义它为真,因为两侧现在正好是同一个单字符字符串。认证成功了--针对的是一个明确配置为永远不接受任何密码的账户。
这两部分单独看都不像是错的,这正是它如此容易被忽略的原因。类型系统在这里帮不上忙:比较的两侧都是 char*,而 "*" 在 shadow 读取侧和 crypt() 侧都是一个语法上普通、成功返回的字符串。对静态分析器来说,这里没有任何东西看起来像是一个错误条件。而 PAM 本来会通过一个更糟的错误,意外地拦住这种行为:在启用了 PAM 的系统上,同样的 musl 返回值反而会让 pam_unix 崩溃,因为 pam_overwrite_string() 会试图写入那个只读的字符串字面值,这是一次拒绝服务,而不是绕过,并且这个问题早在 2024 年 12 月就已经被独立报告过。没有 PAM 的系统没有等价的中间调用,所以同样的底层 musl 行为产生的是一次静默的认证成功,而不是崩溃。[9]
/* musl libc, src/crypt/crypt.c -- MIT license */
char *crypt(const char *key, const char *salt)
{
static char buf[128];
return __crypt_r(key, salt, (struct crypt_data *)buf);
}
/* a thin dispatcher only -- the overlong-key / "*"-on-failure behavior
lives inside crypt_sha256.c / crypt_sha512.c, not here. That specific
file was not independently read in this investigation; see the text. */两侧各自标有一个星号符号:左侧是 /etc/shadow 自己的约定--单个星号意味着任何密码都永远不会有效;右侧是 musl crypt() 的失败哨兵值,在某些失败情况下也会返回同一个字面星号。两侧在一个高亮的点上汇聚,写着 strcmp 比较星号与星号结果为零,判定结果为认证成功。
/etc/shadow 自己的约定单个星号意味着:任何密码都永远不会有效。
crypt() 的失败哨兵值失败时返回同一个字符,而不是 NULL。
strcmp("*", "*") == 0
匹配:认证成功
实际构建、固定并运行的内容
接下来两节中的所有内容,都来自一次真实的运行,而不是一个思想实验:一个固定摘要的 Alpine 3.24.4 镜像,由一个公开概念验证仓库的修改副本构建而成,于 2026 年 11 月 7 日执行,并在下文写出之前,针对其自身以哈希记录的证据,独立地重新核实过。
基础镜像是 alpine:3.24,固定在其 linux/amd64 清单摘要上,而不是信任一个浮动标签。这里没有任何东西被交给浮动标签随意摆布。实验室镜像本身是特意用 OpenSSH 10.6p1 构建的,编译时带有 --without-pam:也就是补丁前、存在漏洞的版本,而不是已经修复的 10.6p2。musl、libcrypto3、shadow 和 zlib 的版本,是直接从构建好的镜像自身的包元数据中获取的,而不是从某个实时分支索引近似估算出来的。
root、alice、alpine 和 victim 的 shadow 条目都被设置为 "*";bob 则被赋予了一个真实、确定性的 SHA-512 哈希,用一个通过文件(而不是命令行参数)提供的固定盐值构建而成,作为有意设置的阴性测试对照。sshd_config 将 PasswordAuthentication 设为 yes,并且特意改动了公开概念验证自身的默认值,将 PermitRootLogin 设为 no,因为 root 从来都不在这个实验室批准的测试范围之内。
隔离程度比 Docker 的默认设置更严格,而不是更宽松:容器运行在一个配置为 --internal 的桥接网络上,没有默认出口路由,Privileged 设为 false,CapDrop 设为 ALL,只重新加回了 CAP_NET_BIND_SERVICE、CAP_SETGID、CAP_SETUID 和 CAP_SYS_CHROOT,每一项都是对照 OpenSSH 10.6p1 自身的权限分离源代码加以论证的,而不是凭假设,另外还加上了 no-new-privileges。没有任何密码或密钥出现在任何进程的命令行中;每一项测试数据都是从文件中读取的。
只有 alice 和 bob 属于批准的测试计划范围。这个边界究竟守住了什么、又没有守住什么,有它自己单独的一节,放在结果之后,而不是悄悄混进结果里。
Base image alpine:3.24, pinned to linux/amd64 digest
sha256:0f49f331d9212aff11b932863be4eb01cdf962969
aa86dab7d1ee21ac6f5f52b (resolves to Alpine 3.24.4)
Lab image sshole-lab:revision3
sha256:b973cd33dfd98a767b93cd5f95987d817f7b8eeda0
6e804e173b431ae6540e45
OpenSSH 10.6p1, built --without-pam (the pre-fix version,
deliberately -- this run tests the vulnerable build)
musl 1.2.6-r3
Capabilities --cap-drop=ALL
--cap-add=NET_BIND_SERVICE --cap-add=SETUID
--cap-add=SETGID --cap-add=SYS_CHROOT
--security-opt=no-new-privileges
Network sshole-lab-net, --internal (no default route out)
sshd_config PasswordAuthentication yes
PermitRootLogin no案例 A 失败了。案例 B 成功了。bob 无论哪个方向都没有动摇。
这次运行纳入了三个假设,明确地表述为需要去检验的事项,而不是需要去证实的事项。真、假,或者无法确定,从一开始就都是合法且应当报告的结果。
案例 A 测试了一个未经预热的单次超长密码:300 字节,作为一次全新连接针对 alice(其 shadow 字段为 "*")发出的第一次、也是唯一一次密码认证请求。它没有成功。服务器日志显示一次连接、一行 "Failed password for alice",以及连接关闭。这次连接中任何地方都没有出现 "Accepted" 行。这在档案中被记录为未复现。
案例 B 测试了同一个密码,但经过预热:先发送一个简短且故意错误的密码,紧接着立即发送同样的 300 字节超长密码,作为针对 alice 的一次 SSH 连接内的两次连续认证请求。它成功了。服务器日志显示 "Failed password for alice ... port 33394 ssh2" 紧接着 "Accepted password for alice ... port 33394 ssh2",两者的源端口完全相同,从头到尾都是同一次连接。客户端记录确认了以 alice 身份执行命令时预期出现的标记。这被记录为已复现。
案例 C1 和 C2 使用了 bob--真实哈希对照组。C1,bob 的真实密码,作为阳性对照的健全性检查而成功了。C2,刚刚对 alice 奏效的那同一个 300 字节超长密码,针对 bob 却失败了:"Accepted password for bob ... ssh2" 对应正确密码,"Failed password for bob ... ssh2" 对应超长密码。两者共同证实,超长密码本身并不能普遍地绕过认证;它只是碰巧匹配上了退化的占位符 "*",正如上面的根因机制所预测的那样。
这四个结果,是来自一次特定的固定摘要构建、运行一次、于 2026 年 11 月 7 日得出的一个数据点。它们并不是在断言 CVE-2026-123158 在所有地方的每一种 musl 和 OpenSSH 组合上会如何表现;它们只是这个确切的镜像、这个确切的 OpenSSH 10.6p1 构建、以及这些确切的输入,在那一次运行中实际做了什么。档案特意附上了这个范围限定来记录它们,本文也保持同样的表述,而不是将其泛化。
Case A -- single-shot, alice, no priming -- not reproduced
Failed password for alice ... ssh2
[connection closes; no Accepted line for this connection]
Case B -- primed (short wrong, then overlong, one connection), alice -- reproduced
Failed password for alice ... port 33394 ssh2
Accepted password for alice ... port 33394 ssh2
Case C1 -- bob, real password, positive control -- reproduced
Accepted password for bob ... ssh2
Case C2 -- bob, same overlong password, negative control -- reproduced
Failed password for bob ... ssh2一张来自单次实验室运行的四个测试用例表。案例 A,针对 alice 发送的一个未经预热的超长密码,日志记录了一条失败密码并关闭了连接:未复现。案例 B,同样的密码先经过一次简短的错误尝试预热,日志记录了一条失败密码紧接着一条被接受的密码:已复现。案例 C1,bob 的真实密码,日志记录了一条被接受的密码作为基线。案例 C2,同样的超长密码针对 bob,日志记录了一条失败密码作为阴性对照。一个单独跟踪的未解决问题指出,为什么预热在这次运行中起了作用,在源代码层面仍未得到解答。
为什么预热在这次运行中起了作用,在源代码层面仍未得到解答:pick_salt() 在任何一次调用中都会同步返回一个盐值,它自身的逻辑中没有任何东西明显要求事先经过预热。
这次运行没有证明什么,以及没有人披露的针对 root 的尝试
一次实验室运行的可信程度,取决于它坦率承认自己没有核实过什么。这一次坦率承认了三件具体的事,而不是让一组干净的、范围之内的结果去充当一个干净流程的替代品。
第一件是一个范围发现,不是一个机制发现。记录了案例 A 针对 alice 的单次测试的同一份证据文件,也在同一个捕获窗口中更早的位置,包含了一段未披露的针对 root 的序列:一行 "Failed none for root",接着在同一次连接内两行连续的 "Failed password for root ... ssh2",然后连接关闭。这次连接中任何地方都没有出现针对 root 的 "Accepted" 行。这两次尝试背后提交的值是未知的,本报告也不会去猜测它。root 从来都不在这个实验室批准的范围之内--批准范围在这项任务授权的每一层都只有 alice 和 bob--而运行操作者自己对测试内容的总结也从未提到过它。
没有任何东西被攻破:针对 root 的两次尝试都失败了,并且 PermitRootLogin no 在这次运行中守护进程自身的有效配置里得到了确认。一个即便失败了也依然是范围偏离的偏离,仍然是范围偏离。这正是为什么这份档案的实验室状态被记录为"部分"而不是"已复现"--即便范围之内的证据在其他方面看起来很干净,这个区分依然值得保留。这段序列究竟为何发生--是工具内置的一份更广泛的默认用户名列表运行了,而不是计划中那些范围受限的自定义命令,还是完全别的原因--这是一个应该由运行实验室的人直接回答的问题,而不是在这里推测的内容。
第二个缺口是测量上的局限,不是范围上的。案例 B 的测试运行了一个并发的进程 ID 轮询循环,专门用来捕获直接证据,以判断是否是同一个操作系统进程同时处理了预热尝试和超长尝试--这一证据会直接关系到下一节中的那个未解问题。它在交换过程中恰好收集到了一个可用样本。这与两次尝试之间单一进程连续性的说法一致,但不是在两者之间的间隙上做出的持续的前后对比。只能说有提示性,并不足以下结论。
第三个缺口涉及实验室自身的网络路径,而不是漏洞本身。计划中指定要把实验室发布在 127.0.0.1:8222 上;运行操作者报告称,一旦容器同时接入了一个无出口的 --internal 网络,这个绑定就没能提供可用的主机连通性,于是改用了同一内部网络上的第二个一次性测试客户端容器,通过其容器 DNS 名称来访问实验室。该容器自身的检查输出仍然显示,所请求的端口绑定被接受进了 HostConfig.PortBindings,而同一份文件中的 NetworkSettings.Ports--它记录的是 Docker 在运行时实际建立的内容,而不是仅仅被请求的内容--读出来却是空的:`{"22/tcp": []}`。这佐证了这份报告。但它并不能作为确凿的证明。这次运行中没有任何人实际尝试从容器外部连接到 127.0.0.1:8222,以确认它确实没有在监听。
以上这些都不会改变案例 A、B、C1 或 C2 所展示的内容。但它确实改变了这一次运行能在多大程度上成为下一次运行的先例,而坦诚的答案是:比一次完全符合范围要求的运行要小一些,尤其是在 root 账户这一点上。
补丁没有触及 musl。它只是给一次比较加了一个条件。
修复这个问题的提交,4353fdc43ef646c43a8dda7025cc1a898caf1482,"avoid password authn bypass",由 Damien Miller 于 2026 年 11 月 5 日提交,没有触及 musl,没有触及 pick_salt(),也没有触及触发这一切的超长密码行为本身。[11]
它在 auth-passwd.c 中既有的 MAX_PASSWORD_LEN 旁边添加了 `#define MIN_PASSWORD_HASH_LEN 4`,并修改了最终的认证判定,要求在未改动的 strcmp() 检查之外,还必须满足 `strlen(pw_password) >= MIN_PASSWORD_HASH_LEN`,这里的 pw_password 是账户自己存储的哈希,永远不是比较中受攻击者影响的那一侧。[11]
由于 "*" 和 "!" 都正好是一个字符,`strlen(pw_password) >= 4` 对这个漏洞曾经可能触及的任何账户都为假,于是 `&&` 会在 strcmp() 甚至还没有执行之前就短路,不管 crypt() 对任何输入(无论长短)返回了什么。
同样的内存所有权变更--通过 xstrdup() 和 freezero() 做堆上复制,而不是使用一个调用方并不拥有的指针--被应用到了 auth-passwd.c、openbsd-compat/port-uw.c(UnixWare 专用的等价路径)以及 xcrypt.c 中的 pick_salt()/shadow_pw()。然而,新增的长度检查本身只落在了 auth-passwd.c 的通用路径上,并没有落到 port-uw.c 的独立比较逻辑中。UnixWare 不是 musl 的目标平台,所以这个具体的冲突不可能通过那条路径发生,这也许正是那里没有被认为需要这项检查的原因;这是本次调查自己做出的一个合理解读,并不是提交本身直接声明的内容。[11]
提交说明本身指出,这次绕过"影响部分配置以及运行时配置",并且团队"不认为根因在 OpenSSH 内部",同时又"完全具备避免它的能力"。这种表述与这个补丁实际所做的事情相符:它恰好堵住了上面描述的那次冲突--一个单字符哨兵值与一个单字符 shadow 占位符相撞--做法是给存储哈希的长度设一个下限,而不是去验证计算出的哈希是否符合其所声称算法的正确格式。如果 musl,或者任何与之链接的、musl libc 兼容的软件,在某个不同的失败路径上曾经返回过某种长度为四个字符或更长、但同样并非占位符的其他短字符串,这项具体检查就不会抓住它。本次调查没有发现目前存在这样的路径;这是对该补丁所能保证范围的一个限制,而不是一个已知的残留漏洞。[11]
/* auth-passwd.c, commit 4353fdc -- post-fix */
#define MIN_PASSWORD_HASH_LEN 4
...
ret = encrypted_password != NULL &&
strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;Alpine 并没有把每个分支一次性跳到 10.6p2
Alpine 并没有把所有受支持的分支都迁移到 OpenSSH 10.6p2。它把补丁回移到了每个分支原本就在运行的版本上,这是标准的发行版做法:保留分支现有的包版本,修复那个具体的缺陷。
Alpine 的安全追踪系统列出了七个按分支划分的具体修复版本,而不是一个适用于整个机群的统一数字。每一个当前受支持的分支都收到了这个回移补丁。[12]
如果您在核对一个机群,请把分支与下面的表格对照,而不是在任何地方去查找字面字符串 "10.6p2"。一台运行着回移补丁版本 9.9_p2-r1 的 3.21-main 分支机器就是已经修复的,即便 `sshd -V` 永远不会显示 10.6。
| 分支 | 已修复的包版本 |
|---|---|
edge-main | 10.6_p2-r0 |
3.24-main | 10.3_p1-r2 |
3.23-main | 10.2_p1-r1 |
3.22-main | 10.0_p1-r11 |
3.21-main | 9.9_p2-r1 |
3.20-main | 9.7_p1-r6 |
3.19-main | 9.6_p1-r3 |
您现在就该在日志里检查的内容
Alpine 自己的公告给出了足够具体、今晚就能照做的检测和缓解指导,而不是一句泛泛的"检查一下你的日志"。
检测逻辑是:找出每一个 shadow 密码字段恰好是 "*" 的账户,然后在守护进程自己的日志中搜索一次针对这些账户的、成功的密码登录--要寻找的标记字符串是 "Accepted password for"。与此同时,检查 doas 的使用情况、最近创建的用户、新文件,以及 authorized_keys 的变更。如果在暴露窗口期内,无法排除针对一个无密码账户发生过一次密码登录,公告自身的立场是把这台机器当作已被攻破来处理。[5]
缓解措施是:先升级软件包、再重启守护进程--因为单独升级软件包并不会替换已经在运行的进程--或者作为临时权宜之计,彻底禁用密码认证,并在重启生效之前进行验证。[5]
一个值得精确说明的、针对 root 的细微之处:Alpine 默认的 PermitRootLogin prohibit-password 已经可以保护 root 不受这一绕过影响,除非管理员明确把它设为 PermitRootLogin yes,即便 root 账户本身在基础镜像中就是以 root:* 的形式发布的。但这个默认设置并不覆盖通往 root 的每一条路径:任何其他能够进行密码登录且能够提权的账户--例如通过 doas--仍然会造成真实的暴露风险,即便 root 自身的直接密码登录依然受到保护。[5]
# Package upgrade, then restart -- upgrading alone does not
# replace the already-running daemon
apk upgrade -U -a
rc-service sshd restart
# Or: disable password authentication entirely
# PasswordAuthentication no (in sshd_config)
sshd -t
rc-service sshd restart
# Detection: look for a successful password login on an account
# that should never have one
grep "Accepted password for" /var/log/messages
# then cross-reference each username against /etc/shadow for a
# password field that is exactly "*"为什么同一个密码第一次尝试失败,第二次却成功了?
为什么完全相同的超长密码,针对同一个账户、在同一次运行中,第一次失败了,第二次却成功了?
Alpine 自己的公告称这次攻击"只需要一次连接……且无需重试"。为本档案审阅过的两份独立编写的概念验证脚本--一份是为实验室构建提供的,另一份则直接出现在 CVE 记录自身的 CNA 参考资料中--却都在一次连接内使用了两次密码认证尝试:先是一个简短的错误密码,然后是超长密码,两者都在自己的代码注释中把这归因于在真正尝试之前预热一个缓存的盐值。这不一定构成矛盾。两者都仍然只使用单一的 SSH 传输层,而"无需重试"最合理的解读,可能是在描述这次攻击是确定性的,不需要反复的概率性猜测,而不是在断言恰好一条认证消息就足够。但这同样也没能从另一个方向上把这个问题彻底解决。[5] [13] [14]
追溯 pick_salt() 自身的代码同样无法解决这个问题。这个函数在第一次运行时,会在同一次调用内同步地扫描并返回一个可用的盐值。这段逻辑中没有任何东西明显要求在一个长密码能够与新找到的盐值进行比较之前,必须先发生过一次更早的调用。按照代码自身的逻辑,一个未经预热的单次超长密码,作为一次连接的最初一次认证请求发送出去,本应同样到达 pick_salt(),取回一个盐值,并让 crypt() 针对它运行--正如两份已知概念验证脚本中的第二次尝试所做的那样。
这次实验室运行所补充的内容是真实的,而且正好是一个数据点:单次尝试失败(案例 A),预热后成功(案例 B),都发生在这一个特定构建中。这是有哈希支撑的证据,证明预热在这次运行中确实起了作用。但这并不是在解释为什么。上面提到的 pick_salt() 谜题并没有被这一结果触及。"预热后能用,单次尝试不行"这一现象,与本次调查未能独立证实的若干解释都是一致的:OpenSSH 自身权限分离进程生命周期的某个细节(sshd 把每次连接的认证工作拆分给一个具有特权的监控进程和一个无特权的工作进程;本次调查没有追溯清楚这两者各自实际存活多久),具体来说就是这两个进程中到底是哪一个执行了 pick_salt(),以及它的生命周期覆盖的是一次认证尝试,还是整个连接;某种既不是任何一份概念验证作者、也不是本次调查所识别出的其他有状态行为;又或者是这个确切构建和时序所特有的某种情况,完全可能无法推广到别处。
所以:这个问题在这次实验室运行之后,在源代码机制层面依然悬而未决,正如它在运行之前一样。请把"预热成功、单次尝试失败"这一分化,当作一个值得认真对待的数据点来看待,而不是朝任何一个方向上已经敲定的解释。如果有人愿意继续追查,下一个有用的步骤是针对这个具体构建去追溯 OpenSSH 确切的权限分离进程模型,或者用不同的时序重新运行单次尝试的案例,看看结果是否依然成立。
另外两条较小的未解线索,这里是指出来,而不是去猜测:musl 自身关于 crypt_sha256.c 和 crypt_sha512.c 内部那个确切超长密钥检查的源代码,本次调查从未独立阅读过,只是通过公告和 2024 年的邮件列表报告得到的描述。而 sshhole.com 这篇分析文章背后的 GitHub 用户名,是否就是 OpenSSH 发行说明中按姓名致谢的同一个人,从技术内容的吻合程度来看是可信的,但记录中没有任何内容能够证实这一点。这两点都不构成本文所做任何论断的支撑。
这里的教训关乎接口契约,而不是 musl 或 OpenSSH
在 musl 发布修复时给它打补丁。给 OpenSSH 打补丁,它已经有修复了。这两条指示都不是这里真正的工程教训。
crypt() 的接口契约承诺在失败时返回 NULL。musl 自己对这份契约的实现并不总是信守这个承诺,而 /etc/shadow 的 "*" 约定所依赖的另一份承诺--也就是这个特定字符永远不会作为一个真实的 crypt() 结果出现--结果却完全取决于第一份承诺是否成立。两个子系统单独看都没有错:/etc/shadow 的 "*" 约定比 musl 更早出现,与它毫无关系;而 musl 对失败哨兵值的选择,也与 OpenSSH 或 shadow 文件毫无关系。这个漏洞完全存在于两者之间的空隙里,存在于一个没有人写下来的假设之中,因为它一直以来都理所当然地成立。
CWE-394,这个 CVE 实际携带的分类,是"Unexpected Status Code or Return Value"(意外的状态码或返回值),而这正是这里发生的事情。它同时还是一个在两种不同意义上都"看起来无害"的漏洞:一个 "*" 的 shadow 字段按设计看起来是惰性的,而 musl 这种失败时返回 "*" 的行为,早在 2024 年 12 月就已经被独立报告过--在一个完全不同的上下文中,导致 pam_unix 崩溃--比任何人把它与 OpenSSH 的绕过联系起来,还早了将近两年。同样的底层契约违反,两种截然不同的后果,完全取决于恰好是哪个调用方碰到了它。
对任何维护类似这种比较逻辑的人而言,实际可用的版本是这样的:一个接口契约的强度,取决于每一个调用方愿意去检查它的程度,而不是取决于契约本身措辞有多严密。如果您要把一个计算出来的值与一个存储的值进行比较,而其中任何一侧带有一个记录在案的"这里出了点问题"的哨兵值,那么在您信任这次比较之前,先检查一下这个哨兵值是否可能恰好落到另一侧"这里没什么可看"的那个值上。这不是一项针对 musl 或针对 OpenSSH 的特定检查。它是这整个 CVE 最终被发现缺失的那条真正的不变量。
这恰恰正是一次安全代码审查本该发现的那种边界:不是那个显而易见的注入点,而是那个没有人想过要去质疑的比较,因为两侧单独看起来都一直很干净。如果您自己的技术栈中有一条 C 层面的认证或比较路径,从未像本文这样被逐函数地追溯过,那正是一次代码层面的审查存在的意义所在--去补上那个缺口。
参考来源
- [1] CVE Program / MITRE,CISA ADP:CVE-2026-123158 记录
- [2] Damien Miller:OpenSSH 10.6p2 发布公告(openssh-unix-dev 邮件列表)
- [3] OpenSSH 项目:10.6p2 发行说明
- [4] oz9un:*sshole(报告研究者本人的技术分析文章)
- [5] Alpine Linux:OpenSSH 密码认证绕过安全公告
- [6] OpenSSH 项目 / OpenBSD:auth-passwd.c
- [7] OpenSSH 项目:openbsd-compat/xcrypt.c
- [8] AOSP 镜像:auth-passwd.c(MAX_PASSWORD_LEN)
- [9] musl 邮件列表(Openwall):crypt() 从只读内存返回 "*",2024 年 12 月
- [10] musl libc 项目:src/crypt/crypt.c
- [11] OpenSSH 项目:提交 4353fdc,"avoid password authn bypass"
- [12] Alpine Linux 安全追踪系统:CVE-2026-123158
- [13] halilkirazkaya:CVE-2026-123158(概念验证 / 实验室仓库)
- [14] predkambrij:sshhole-repro(独立概念验证)
需要在您自己的产品中检查这个问题吗?
安全代码审计想学会自己发现这类漏洞吗?
安全代码审计课程(一对一)