一个星号吃掉所有密码:OpenSSH「sshole」CVE-2026-123158 排查与止血
先说结论,因为它决定你今晚要不要加班。
如果你跑的是 glibc 的 Linux,或者 sshd 走了 PAM,或者 PasswordAuthentication no,你没事,关掉页面去睡觉。
如果你在 Alpine / OpenWrt / Buildroot / Yocto 这类 musl 系统上跑 sshd,而且没编 PAM,同时机器上有一个密码字段是 * 的账号——那是一个能一次登录成功的认证绕过。攻击者不需要知道任何密码,只需要一个 257 字节以上的字符串。
这个洞 11 月 5 日才被公开,OpenSSH 当天发了 10.6p2。CVE 编号是事后补的,NVD 现在还是 Received 状态——也就是说,NVD 自己还没分析过,我们看到的 8.1 分是 MITRE 打的,别把它当成 NVD 的口径。
我把这条链从 1980 年一路查到今天,越查越觉得这事不能只当”又一个 CVE”看。

一、三十秒判定:你到底在不在射程内
五个条件必须同时成立,缺一个就没事。

- libc 是 musl。 Alpine、OpenWrt、Buildroot、Yocto、postmarketOS、void-musl、chimera 都算。
- sshd 没编 PAM,或者编了但
UsePAM no。 Alpine 的openssh-server包默认就是不带 PAM 的;openssh-server-pam配UsePAM yes是安全的。 PasswordAuthentication yes。 这是 OpenSSH 的默认值,绝大多数人没改过。- 至少有一个账号的密码字段是
*。 key-only 用户、usermod -p '*'建的账号、目录服务(nslcd/winbind/sssd)里的用户,在 musl 上看全都是*。Alpine 3.16 起 root 就是root:*。 - 至少还有一个账号有真的
$5$或$6$哈希。 也就是确实有人跑过passwd。
最后这条看着奇怪,但它就是这个洞的开关——下面讲为什么。
补一个容易漏的点:冻结账号用 ! 的不受影响,用 * 的受影响。 ! 前缀在 musl 上会让密码和密钥都失效,而 * 只是”没有密码”,偏偏这正是这个洞要吃的状态。
还有一类人特别该紧张:Alpine 官方 AWS / Azure 云镜像里那个默认 alpine 用户,密码字段就是 *,而且配了免密 doas。 换句话说,一个云镜像开箱出来的机器,可能直接是 root。
二、原理:三个各自正确的决定,凑出一个后门
这个洞最好玩的地方是——没有任何一处代码是”写错的”。三处都是当年深思熟虑的正常设计。

第一站,1980 年,Bill Joy 在 /etc/passwd 里给 daemon 账号写了一个 *。 * 不是哈希,任何密码都不可能跟它匹配,所以这是个”永远登不进来”的锁。这套做法用到现在,四十多年没变。
第二站,2012 年 8 月,musl 的 Rich Felker 决定让 crypt() 不再返回 NULL。
原来的行为是:crypt() 算不出来就返回 NULL。但大量程序根本不检查返回值,直接拿去 strcmp,于是段错误。Rich Felker 的提交标题写得很直白——”make crypt return an unmatchable hash rather than NULL on failure”,理由是大批调用方没准备好处理失败,返回一个不可匹配的假哈希比直接失败更好。
那他挑了什么字符当这个假哈希?
一个星号。
同一个提交里的代码是这样的:
return (setting[0]=='*') ? "x" : "*";
二十天后,同一个 8 月,musl 补上 sha256/sha512 crypt。sha512crypt 的运行时间随密码长度超线性增长,为了防止有人拿一个超长密码把服务器算死,作者加了一行保护——注释写得很清楚,”key limit is not part of the original design, added for DoS protection”:
#define KEY_MAX 256
...
for (i = 0; i <= KEY_MAX && key[i]; i++);
if (i > KEY_MAX)
return 0;
超过 256 字节,直接失败。而失败,根据上一层包装,返回的是 *。
第三站,2016 年,sshd 为了修 CVE-2016-6210 引入了 pick_salt()。
CVE-2016-6210 是个用户名枚举漏洞:问 sshd 一个不存在的用户,因为没有哈希可算,它会秒回;真实用户要算几毫秒。时间差就把用户名卖了。
OpenSSH 的修法是——没有真哈希可算的时候,随便找系统里另一个账号的 salt,把你的密码也哈希一遍,纯粹为了把时间浪费掉。太聪明了。
这就是那颗被埋下的地雷。
三、代码级:为什么 strcmp("*", "*") 会放人进去
看 10.6p1 的 sys_auth_passwd()。为了简洁我删掉了无关行:
char *salt = NULL;
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 是 *,长度 1。 所以 pw_password[1] 是 \0,那个 if 不成立,salt 保持 NULL。
② xcrypt(password, NULL) 看到 salt 是 NULL,调 pick_salt()。 它遍历 getpwent(),找到系统里第一个 $5$ / $6$ 开头的哈希,把哈希类型和 salt 抠出来当 salt 用。这就是为什么”必须存在一个真哈希账号”——它是这个洞的供体。
③ 你发一个 300 字节的密码。 musl 的 sha512crypt 一看超过 KEY_MAX,return 0;__crypt_sha512 收到 NULL,return "*"。
④ strcmp("*", "*") 等于 0。认证通过。

整个过程没有一处溢出、没有一处越界、没有一处内存破坏。就是两个字符串相等了。
顺带解释几个细节:
- 为什么必须 257 字节以上。 256 字节时
i == 256,不满足i > KEY_MAX,正常算哈希,算出来的东西当然不等于*,拒绝。257 字节才触发失败路径。上限 1024 是 sshd 自己的MAX_PASSWORD_LEN,超了直接拒。 - 为什么要连着试两次。
pick_salt()把挑出来的 salt 缓存在一个static char salt[32]里,得先有一次不成功的尝试把它填上。所以 PoC 是同一个连接里两次尝试:第一次随便什么,第二次 300 个A。 - 为什么 Alpine 官方说 257–1024,而 MITRE 的 CVE 描述写 256–1024。 差一位。实测以 Alpine 和 PoC 仓库的 257 为准,MITRE 描述里的 256 是边界描述不严谨。
四、修复:一个长度判断
上游的修复提交是 4353fdc4,标题就一句话:avoid password authn bypass。
核心就一行:
ret = encrypted_password != NULL &&
strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;
MIN_PASSWORD_HASH_LEN 定义为 4。理由很朴素:一个真的 crypt 哈希不可能短于 4 个字符,所以长度不足 4 的字段根本不可能是哈希,别拿去比对。
补丁顺带还改了 shadow_pw(),让它返回堆上的副本,调用方用 freezero() 擦干净再释放——修的是同一段逻辑里的第二个隐患:别拿着指向 libc 静态缓冲区的指针去跟 crypt() 的返回值比对。这个隐患平时不显形,在某些 libc 实现上会让比对结果指向被覆盖的内存。
OpenSSH 在发布说明里的措辞很有意思:
We consider the root cause of this bug to be outside sshd, but it is exploitable via sshd and can be avoided there.
根因不在我这儿,但我能从这儿堵。 这话技术上站得住——锅确实在 crypt() 返回 * 和 * 恰好是锁标记这两件事的巧合上。但对运维没意义,你只想知道怎么修。
五、修复清单:升级之后还有一步别忘

Alpine 各分支的修复版本(数字来自 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.21.10、3.22.8、3.23.8、3.24.4。
然后是最容易踩空的一步:apk upgrade 只换磁盘上的包,不换正在跑的进程。Alpine 的公告专门用一句话警告这件事——升级完必须 rc-service sshd restart,否则你看着版本号是新的,跑着的还是旧 sshd。
来不及升级的话,一条命令就能把洞焊死:
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
两种修法任选其一,都能关死。 但注意 Alpine 的 openssh-server-pam 配 UsePAM no 也在受影响范围内——如果你以为自己装了 PAM 版就安全,去看一眼 UsePAM 的实际取值。
其他系统:glibc 的发行版不受影响(Debian/Ubuntu/RHEL 走 PAM,PAM 那一层根本不会拿 * 去调 crypt);OpenBSD、FreeBSD、macOS、Dropbear 也不受影响。
六、怎么查自己是不是已经被打进去了
作者 oz9un 在 GitHub 上放了自检脚本,跑一遍会直接告诉你哪几个账号是活靶子:
curl -sO https://raw.githubusercontent.com/oz9un/sshole/main/check.sh
sudo sh check.sh
输出里会列出 libc / sshd 版本 / pam 状态 / PasswordAuthentication / accounts with '*' / sha-crypt hash present,最后给一句 EXPOSED 或者告诉你差哪一条。
但这只说明你现在有洞,不说明有没有人进来过。 查入侵痕迹得看认证日志——这个洞成功登录时,日志里记的是一次正常的 password 认证,用户的密码字段却是 *。所以筛法很实在:
- 找
Accepted password for <账号>,而这个账号在/etc/shadow里是* - 找同一连接里连续两次密码尝试、第二次特别长的记录
- 看有没有莫名其妙的成功登录后紧跟提权(
doas/sudo)
作者的说法是”* 账号但凡有一条 password 登录成功记录,就是被打过了”——因为正常情况这条路根本走不通。
顺带说一句,取证的时候别只看 sshd 日志就完事。这个洞拿到的就是一个正常 shell,后面做什么都不留异常,得从行为侧看。
七、这件事比漏洞本身更值得记的三点
第一,AI 让漏洞的发现和再发现速度变了个量级。
OpenSSH 在 10.6p2 和 10.6 的发布说明里连着两段都在讲这件事,措辞罕见地直白:他们收到大量来自 AI 模型或 AI 辅助的安全报告,很多在真实威胁模型下不成立,但欢迎——尤其欢迎带人工研判和用例的。更关键的是这句:
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.
AI 找到的洞,很快会被另一个人再找到一次。 所以 OpenSSH 决定改变发布节奏:不再攒到下一个计划版本,有修复就尽快发。Damien Miller 在邮件列表里说得更直接——他们 11 月 4 日才知道这个洞,第二天就发版,因为”长embargo 期现在是一种危险的做法,主要受益者是攻击者”。
这个判断值得所有做安全的人记一下:披露窗口的默认值正在从 90 天往几天塌缩。
第二,NVD 还没消化完。
CVE 记录里 vulnStatus 是 Received,NVD 自己的评分还没出。现在能看到的 8.1(AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H)来自 MITRE。CVSS 里 AC:H 是有争议的——攻击者只需要发一个长字符串,从攻击者视角看复杂度一点都不高;这条 AC:H 大约是给”需要两个条件同时成立”(有 * 账号 + 有真哈希账号)打的。CWE 分类是 CWE-394,Unexpected Status Code or Return Value——分类本身就在说这个洞的形状:一个没人检查的返回值。
CISA KEV 里暂时没有它(我看的是 2026.11.04 那版目录,1768 条)。但这不说明没人用,只能说明还没被确认在野利用。
第三,这个洞的年龄是个提醒。
按披露者的说法,可 exploited 的状态至少能追到 2016 年 pick_salt() 落地那一刻。也就是说这东西在 musl 系统上待了十年。
为什么十年没人报?披露页面的解释挺扎心:OpenSSH 自己在 OpenBSD 和 glibc 上测,musl 不测 sshd;大部分发行版走 PAM,PAM 那一层根本不会拿 * 去调 crypt——所有测试矩阵里,恰好漏掉了唯一会出事的那个组合。
测试矩阵的盲区,等于攻击面。 这句话对自研系统同样成立:你不测的组合,就是你没防守的组合。
八、我这边机器的检查结果
顺手把手上这台机器查了,结论是不受影响:
libc glibc 2.39 (Ubuntu)
ssh client OpenSSH_9.6p1 Ubuntu-3ubuntu13.20
sshd 未安装
/etc/pam.d/sshd 存在
glibc + PAM 路径 + 本机根本没跑 sshd,三个条件全不满足。顺便说明一下,这种”自查”能不能算数取决于你真的跑了命令而不是看了眼文档——我这台是 ldd --version、ssh -V、ls /etc/pam.d/sshd 三条命令逐个敲出来的。
真正该去查的是你那些容器和云主机:Alpine 基础镜像里装 openssh-server 的、K8s 里跑 sshd sidecar 的、OpenWrt 路由器的、以及所有从 Alpine 云镜像开出来没改过默认 alpine 用户的机器。这些地方最容易同时满足那五个条件。
最后
这个洞的名字叫 *sshole,作者自己起的,脏但贴切——它精准地骂了一个星号。
我把它归到”好漏洞”那一类:不需要内存破坏、不需要绕过任何防护、不需要用户交互,就是两个本该不相干的字符串撞在了一起。而且每一处设计当年都对:* 当锁是对的,crypt() 不返回 NULL 是对的,pick_salt() 防时间攻击也是对的。
三个正确的决定叠起来,就是一个能一次登录成功的后门。
这也解释了为什么这类洞总是最后由”闲着没事把两边代码摆一起看”的人发现,而不是由覆盖率的数字发现。安全这件事,堆测试数量不如把组合矩阵摊开看一眼。
延伸阅读:
最强的矛也成了最强的盾:GPT-6 Cyber 发布,但成绩单里有四栏是往下走的
把 GPT-6 Cyber 的发布稿、11 月 5 日 System Card、Linux 内核研究报告和 Mozill...
一个星号吃掉所有密码:OpenSSH「sshole」CVE-2026-123158 排查与止血
musl + 无 PAM + 一个密码字段为 * 的账号 + 一个真 $6$ 哈希,四条凑齐就能用 257 字节以上的任...
这个 ARL 二开版确实能打,但二开全藏在一个非默认分支里
把 buddingworld/ARL-Next2 和上游 owl234/ARL-Next 逐个 blob 对完:默认 m...
ChatGPT 能一键出 GIF 了:表情包自由是真的,但自由的边界比你想象的窄
ChatGPT Images 2.6 在 11 月 4 日上线,一句话或一张图直接出 GIF,能调速、能逐帧改。但 AP...
Android 11月补丁:216 个洞、23 个严重,零点击 RCE 在列——先查这三个数再点升级
Android 2026 年 11 月安全公告修了 216 个 CVE,23 个 Critical,最严重的是 Fram...