
OpenSSH + musl 密码验证绕过:一个星号就能登录
2026 年 11 月 5 日,OpenSSH 发布了 10.6p2,其中带一个"critical security workaround"。它改的不是 OpenSSH 自己的代码,而是 musl libc 里一个 2012 年就存在的行为——两者撞在一起,让一台服务器上「本该没有密码」的账户,可以被任意一串超长密码登录。
在 musl + OpenSSH 且未启用 PAM 的机器上,提交一个长度超过 256 字节的密码,会让 musl 的哈希计算直接失败并返回 *,而这个 * 恰好和目标账户在 /etc/shadow 里的锁定标记 * 完全一致——于是密码认证被绕过。
这个漏洞为什么成立
要理解它,得把五个环节串起来:musl 的 crypt 失败返回值、* 这个账户锁定标记的历史、musl 的 256 字节密钥上限、OpenSSH 为了防用户枚举而借盐的逻辑,以及 * 账户怎么走进这条路径。
环节一:musl 把「失败」变成了一个合法字符串
POSIX 规定 crypt 在无法执行指定算法时返回 NULL。但很多调用方并不检查这个 NULL,直接拿去和哈希比对,结果是段错误。
2012 年 8 月 9 日,musl 的提交 b3c4cc12 改掉了这个行为。提交说明写得很直白:
make crypt return an unmatchable hash rather than NULL on failure
理由就是「很多调用方不检查 null 结果」。与其让调用方崩掉,不如返回一个永远匹配不上的值。改动是这一行:
- return NULL;+ return (setting[0]=='*') ? "x" : "*";
注意这个 *:它被设计成「不可能匹配任何合法哈希」。
环节二:*这个标记的历史
* 作为账户锁定标记可以追溯到 1980 年。当时管理员用 * 禁用 daemon 这类账户的密码。因为 * 本身不是合法的密码哈希,它天然能挡住密码认证。
这个「天然安全」有个前提:crypt 永远不返回 *。musl 之后这个前提不成立了。

环节三:musl 的 256 字节密钥上限
2012 年 8 月 29 日,距前一个提交只有 20 天,musl 提交 88bf5a8a 加入了 SHA-256/SHA-512 的 crypt 支持。为了防止超长密码引发的拒绝服务攻击(哈希计算开销随密钥长度增长),它设了上限:
#define KEY_MAX 256for (i = 0; i <= KEY_MAX && key[i]; i++);if (i > KEY_MAX) return 0;
提交说明里写:「key limit is not part of the original design, added for DoS protection」。
关键细节在失败路径上:内部函数返回 NULL,而外层包装会把它转成前面那个 *:
if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash)) return "*";
于是, 任何超过 256 字节的密码,在 musl 上的哈希结果都是字符串 *。

环节四:OpenSSH 为了防用户枚举,借了别人的盐
while ((pw = getpwent) != NULL) { if ((passwd = shadow_pw(pw)) == NULL) continue; if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) { typelen = p - passwd + 1; strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt))); ... }}

环节五:*账户走进了这条路径
看 10.6p1 里 auth-passwd.c 的 Linux 分支:
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;...if (authctxt->valid && pw_password[0] && pw_password[1]) salt = pw_password;encrypted_password = xcrypt(password, salt);...return encrypted_password != NULL && strcmp(encrypted_password, pw_password) == 0;
对一个密码字段是 * 的账户:
- pw_password = "*",pw_password[1] 是字符串结束符 \0,所以 pw_password[0] && pw_password[1] 为假 → salt 保持 NULL
- salt 为 NULL → xcrypt 调用 pick_salt,借到某个真实账户的 $6$ 盐
- 用这个 $6$ 盐算 257 字节的密码 → musl 判定超出 KEY_MAX → 返回 "*"
- strcmp("*", "*") == 0 → 认证通过
攻击者不需要知道这个账户的任何密码——不用知道哈希,不用知道盐,也不用知道明文。 任意一串 257 字节以上的字符就是密码。
触发条件
五个条件必须同时满足,缺一不可:
- musl libc——Alpine、OpenWrt、Buildroot、Yocto,以及基于它们的容器镜像
- OpenSSH 未启用 PAM——UsePAM no。Alpine 的 openssh-server 默认就编译为无 PAM,openssh-server-pam 配 UsePAM yes 则不受影响
- 密码认证处于开启状态——默认是开的
- 目标账户的密码字段恰好是 *——密钥专用账户、LDAP 账户、Alpine 的 root(2021 年底提交、随 3.16 于 2022 年从 ! 改成了 *)、tiny-cloud 镜像里的 alpine 用户
- 存在另一个带真实 $5$/$6$ 哈希的账户——用来提供借取的盐

关于第 5 点为什么是必需的:pick_salt 只认 $ 开头的哈希。如果机器上没有任何真实密码哈希,它会退回到默认的 "xx"——而 "xx" 是 DES 盐,DES 截断密钥取前 8 字节,不受 KEY_MAX 限制,也不会返回 *,走不到同一个失败路径。复现脚本 predkambrij/sshhole-repro 的 README 提到,它配置了两次密码尝试(NumberOfPasswordPrompts=2),第一次用于让 OpenSSH 缓存借来的假盐,第二次才提交长密码。
另外,密码长度还有上界:auth-passwd.c 里定义了 MAX_PASSWORD_LEN 1024,超过 1024 直接拒绝。所以有效区间是 257–1024 字节。
影响面在受影响系统上,一个密码字段为 * 的账户可以接受任意 257–1024 字节的密码登录。这直接影响:
- 密钥专用部署账户
- LDAP / 目录服务账户
- Alpine 云镜像中的 root 账户
- 官方 AWS/Azure tiny-cloud 镜像里的 alpine 用户—— 这个账户拥有免密 doas,登录即可提权到 root
关于 root 账户,PermitRootLogin prohibit-password(默认值)会挡住这条路径,但其他成功登录的账户仍可能通过 doas 提权。
glibc 系统(Debian、Ubuntu、Fedora)、启用 PAM 的系统、Dropbear、OpenBSD,以及全新未配置密码字段的机器都不受影响。

为什么十年没人发现
触发条件叠得太窄:
- OpenSSH 团队主要在 OpenBSD 和 glibc 上测试,不跑 musl
- musl 项目本身不测 sshd
- 绝大多数发行版默认启用 PAM,链条断在这里
- 目标账户还得恰好是 *,不是 ! 或别的形式
发现者 Özgün Kültekin 构建了覆盖 glibc/musl × PAM/无 PAM × shadow/无 shadow 的 OpenSSH 组合,用约 17600 次畸形和超长登录尝试去撞。所有组合行为一致, 只有 musl + 无 PAM 这一组接受了本该被拒绝的输入。
这位研究者把这归功于 AI 辅助的 agent swarm。OpenSSH 团队在 10.6p2 的发布说明里也专门加了一段:
We have seen a number of cases where a security bug identified by AI tools is subsequently independently discovered by a different researcher. This suggests that adversaries who do not report bugs to OSS projects are likely to be able to discover these bugs too.
修复
OpenSSH 10.6p2
补丁是 4353fdc4,提交信息很坦率:
We do not consider the root cause to be within OpenSSH but we are well equipped to avoid it.
核心改动是给哈希长度设一个下限:
#define MIN_PASSWORD_HASH_LEN 4...ret = encrypted_password != NULL && strlen(pw_password) >= MIN_PASSWORD_HASH_LEN && strcmp(encrypted_password, pw_password) == 0;
补丁还把散落在内存里的密码哈希用 freezero 清零,避免残留。
10.6p2 于 2026 年 11 月 5 日发布。
Alpine Linux
Alpine 官方当天发了公告,为所有分支提供了修复版本:
分支 | 修复版本 |
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 |
受影响范围是 3.16 到 3.24 以及 edge 的全部版本。
apk upgrade -U -arc-service sshd restart
注意:只换包不重启,老的 sshd 进程还在跑。
临时缓解
没法立刻升级时,关掉密码认证:
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.confrc-service sshd restart
3.19 及以后的版本用 sshd_config.d 目录,更早的分支需要直接改主配置文件。
不要把 * 换成 !。Alpine 的公告明确说明:在没有 PAM 的情况下,! 同样会阻断 SSH 密钥认证,等于把可用的登录方式也一起关掉了。
另一个选项是换用带 PAM 的包:openssh-server-pam 配合 UsePAM yes。PAM 路径对 * 账户根本不会调用 crypt,链条直接断掉。
排查是否被利用
先找出密码字段为 * 的账户:
再看这些账户有没有成功过密码认证:
grep -E 'Accepted password for ' /var/log/auth.log
两条命令的交集就是被利用的账户。如果一个密码字段为 * 的账户出现在 Accepted password for 里,而它的正确密码不可能是那串超长字符,那就是有人进去了。
也可以直接跑官方的检测脚本:
curl -sO https://raw.githubusercontent.com/oz9un/sshole/main/check.shsudo sh check.sh
输出形如 EXPOSED. anyone can log in as: root alice deploy。
这个漏洞的真正教训
这不是一个 OpenSSH 的 bug。OpenSSH 团队的判断是准确的:根因在 musl。
musl 把 crypt 的失败返回值从 NULL 改成 *,出发点是善意的——防止调用方因为不检查返回值而崩溃。但这个 * 和系统里另一处含义完全不同的 *(账户锁定标记)撞到了一起,两个含义之间没有任何文档化的关联。
修复同样体现了 OpenSSH 的务实——不去改根因(改 musl 意味着影响所有依赖 crypt 返回值的程序),而是在自己这一侧加一道长度下限,把问题挡在外面。
对运维来说,教训很具体:跑 Alpine 或任何 musl 系统,升级优先级要提到最前面;如果你的 SSH 只允许密钥登录、密码认证是关着的,这个漏洞跟你无关—— 第五个条件(另一个带真实哈希的账户)和第三个条件(密码认证开启)任意一个不成立,漏洞就无法触发。
参考资料
- OpenSSH 10.6p2 发布说明 — https://www.openssh.org/releasenotes.html#10.6p2
- 上游修复补丁 — https://github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482
- Alpine Linux 公告 — https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/
- 检测与 PoC — https://github.com/oz9un/sshole
- musl 提交 b3c4cc12 — https://git.musl-libc.org/cgit/musl/commit/?id=b3c4cc121f70faea45389fe7ddc1127ed5cbd8bb
- musl 提交 88bf5a8a — https://git.musl-libc.org/cgit/musl/commit/?id=88bf5a8a8d7d796f63cca8589f4de67aa8345f1a














