一个星号吃掉所有密码: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 官方公告,逐个抄的):

分支修复版本
edge10.6_p2-r0
3.2410.3_p1-r2
3.2310.2_p1-r1
3.2210.0_p1-r11
3.219.9_p2-r1
3.209.7_p1-r6
3.199.6_p1-r3
3.189.3_p2-r4
3.179.1_p1-r7
3.169.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...

itadol5j
2026年11月6日
一个星号吃掉所有密码:OpenSSH「sshole」CVE-2026-123158 排查与止血

musl + 无 PAM + 一个密码字段为 * 的账号 + 一个真 $6$ 哈希,四条凑齐就能用 257 字节以上的任...

itadol5j
2026年11月6日
这个 ARL 二开版确实能打,但二开全藏在一个非默认分支里

把 buddingworld/ARL-Next2 和上游 owl234/ARL-Next 逐个 blob 对完:默认 m...

itadol5j
2026年11月6日
ChatGPT 能一键出 GIF 了:表情包自由是真的,但自由的边界比你想象的窄

ChatGPT Images 2.6 在 11 月 4 日上线,一句话或一张图直接出 GIF,能调速、能逐帧改。但 AP...

itadol5j
2026年11月5日
Android 11月补丁:216 个洞、23 个严重,零点击 RCE 在列——先查这三个数再点升级

Android 2026 年 11 月安全公告修了 216 个 CVE,23 个 Critical,最严重的是 Fram...

itadol5j
2026年11月5日