Linux 平台上,版本早于 10.6p2 的 OpenSSH 中的 sshd 服务:在使用 musl libc、且未启用 PAM 的环境下,缺少针对密码认证绕过漏洞的修复逻辑(问题根源是程序错误判断一段字符串是否为密码哈希值)。在满足特定账号配置的情况下,长度介于 256~1024 字符之间的任意密码都能认证通过。使用 musl libc 并且不开启 PAM 的典型系统:Alpine Linux。
该漏洞源于muslibc的crypt()函数在失败时返回单字符“(而非POSIX规定的NULL),而OpenSSH在未启用PAM的认证路径中,对密码字段为的账户借用其他账户的真实盐值执行cryptO校验。当攻击者提交257~1024字节的超长密码时,musl的crypt0因KEY_MAX(256)长度限制返回,与影子密码字段比较相等,strcmp判定通过,攻击者可利用该漏洞无需任何有效凭据即可通过SSH密码认证登录仅允许密钥登录或已锁定密码的账户。成功绕过认证后,若目标账户具备免密sudo/doas权限(如Alpine云镜像默认alpine用户),攻击者可直接提权至root实现完全控制。
2016 年以来至 10.6p2 之前,运行于 musl libc 且未启用 PAM 的 OpenSSH sshd(Alpine、OpenWrt、Buildroot、Yocto 及 musl Docker 镜像)
OpenSSH 是 OpenBSD 项目维护的广泛使用的开源 SSH 远程登录与加密传输服务组件,被广泛应用于 Linux、Unix 及嵌入式系统。musl libc 是面向嵌入式与容器场景的轻量级 C 标准库,被 Alpine Linux、 OpenWrt、Buildroot、Yocto 及大量 Docker 镜像采用。
sshd 键接 muss libc, 且未启用 PAM;
PasswordAuthentication yes (默认开启);
存在 shadow 密码字段为 * 的帐户;
系统中至少还有一个带真实密码哈希 ($ 开头) 的帐户, 用于提供可借用的盐;
攻击者能在同一个 TCP 连接内完成两次认证尝试。
2、下载并解压 Alpine rootfs
下载 Alpine 的最小根文件系统(约 3.6 MB),解压到 /opt/lab/rootfs,之后所有的实验都在这里面,不会到宿主机的kali系统。
3、挂载 /dev、/proc,进入 chroot
4、在 chroot 里装编译工具
5、下载 OpenSSH 10.3p1
将OpenSSH 10.3p1源码并放进 chroot(影响范围是「10.6p2 之前」,10.3p1 在区间内)
源码 2.0 MB,已放进 rootfs/build
6、configure(在 chroot 里)
三个二进制分别对应:监听器 sshd 、会话进程 sshd-session 、认证逻辑 sshd-auth (漏洞代码在这里)。
8、给sshd链接两个helper
攻击目标:等价于“仅密钥登录 / 已锁定密码”的账户
shadow字段 $6$...
提供可借用的盐:漏洞前置条件之一是“机器上另有一个真实密码哈希的账户”
adduser -D 建出来是空密码,shadow 默认是 !。这里用 sed 强行改成 * —— 因为 ! 会被
platform_locked_account() 判定锁定账户、直接拒之门外,而 * 会被放行,到密码校验环节,漏洞才成立。
chpasswd 会调用 crypt() 生成 $6$ 哈希。这一步本身就会用到 musl 的实现,但密码长度只有 14 字
10、生成主机密钥 + 写 sshd 配置
alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.display='none'">
Unsupported option UsePAM,这里不是错误,是证明这个 sshd 编译时就没有 PAM。正是漏洞成立的条件
Server listening on 0.0.0.0 port 222,监听成功。
12、验证 musl crypt() 的失败边界(重点)
alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.display='none'"> alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.display='none'">结果可以看出:(1)256 字节还能正常出哈希 → 所以 256 打不通(2)257B / 308B / 1024B 超过上限,统一返回 ,有效区间是 257 ~ 1024(漏洞利用点)(3)salt 拿 当盐会返回 x,不是 , 所以必须去借别人的盐
alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.display='none'">成功进入系统,查看下ssh登录日志
首次登录失败,第二次成功,漏洞复现完成。
这里第一次登录密码输入随机字符(例如:ceshiceshi),pick_salt() 遍历 getsppnam() 借盐,顺手把 pw_password 覆盖成了空串;盐被写进 static salt[32],比较失败。第二次密码输入300个A,pick_salt() 命中缓存、不再遍历pw_password 保住 "*" ,crypt(300字节) 返回 "*" , strcmp("*","*")==0 ,对比通过,成功登入。
测试用户的shadow值:lockeduser:*:20763:0:99999:7:::
auth2-passwd.c -> userauth_passwd()
协议入口,做三件事:读 1 字节 change(是否请求改密)、读密码字符串并记录长度 len、确认包里没有多余数据。
对密码不做任何校验,取到什么就是什么。较长的字符串都能进入内存。
第 64 行的 freezero 说明作者对密码生命周期有意识:出错时先清零再释放。
monitor_wrap.c -> mm_auth_password()
校验要读 /etc/shadow,需要 root;而处理网络的进程已降权。于是密码被复制进消息缓冲,通过管道发给特权进程 monitor,然后阻塞等回复。
两个后果:内存里出现了第二份密码副本;同一条连接在同一时刻只有一个认证请求在处理,后续两次尝试是严格串行的。
3. monitor 取出密码并调用校验
monitor.c -> mm_answer_authpassword()
monitor 侧的处理器:确认配置允许密码认证,取出密码(第三份副本),调用 auth_password(),用完清零。
第 1026 行的 static int call_count 说明 monitor 进程在整条连接期间常驻。这为后面“盐缓存能活到第二次尝试”提供了进程基础。
auth-passwd.c -> auth_password()
整条链上唯一一次对密码长度的限制。判断用 >,所以 1024 字节通过、1025 字节被拒。
限制的初衷是防止超长输入拖慢哈希计算(拒绝服务防护),但它同时划定了攻击载荷的边界:
5. 岔路:启用 PAM 就不走内置校验
auth-passwd.c -> auth_password()
两个条件:编译期带 USE_PAM,运行期 UsePAM yes。两者都成立时函数直接返回,下面的代码全部不执行。
这就是“未启用 PAM”成为漏洞前提的原因:PAM 路径根本不经过内置的 sys_auth_passwd()。
6. 先取账户的存储值,而不是处理密码
auth-passwd.c -> auth_password()
真正处理密码的是第 125 行的 sys_auth_passwd()。
auth-passwd.c -> sys_auth_passwd()
第 199 行先取账户的密码字段:
区分两条路是为了避免通过“读 shadow 的耗时差异”判断账户是否存在。
对“只允许密钥登录”的账户,字段是 *。此时提交的密码还在旁边,一点都没被用到。
auth-passwd.c -> sys_auth_passwd()
真哈希最短也有十几字符,1 个字符显然不是哈希,拿它当盐会让 crypt() 报错。所以这里选择“不用它”。
判断本身没错,但产生了副作用:salt 保持 NULL。
第 213 行还有一个细节:赋的是指针,不是拷贝。salt 与 pw_password 从此指向同一块内存。
接着salt 为 NULL 会触发借盐。
openbsd-compat/xcrypt.c -> xcrypt()
注释交代了意图:没有盐时,是在为计时做一次“假验证”,让“账户无可用密码”的场景也走一遍哈希计算,使不同路径耗时接近,避免时序侧信道。
目标是对的,但把“选盐”的决定权完全交了出去。此时密码仍未参与。
openbsd-compat/xcrypt.c -> pick_salt()
第 76 行:static 缓冲,全进程共享;
第 78–79 行:缓存命中就直接返回,不再遍历;
第 80 行:默认值 xx(传统 DES 盐),兜底用;
第 81–82 行:从 /etc/passwd 头部开始遍历;
第 83 行:取每个账户的密码字段,取不到就跳过;
第 85 行:判断是否$6salt$hash 形式;
第 86–87 行:截取“算法前缀 + 盐”存进缓冲;
第 88 行:把取到的字段清零;
借到的不是整个哈希,只是前缀和盐:
整台机器没有 $ 开头的字段时,salt 保持 xx,crypt() 走 DES 分支,返回正常哈希(漏洞不成立)
接下来看在借盐中有什么“违规”操作?
10. 存储值被覆盖,盐进了缓存
pick_salt() 第 83 行调用 shadow_pw(),后者内部调 getspnam()。
openbsd-compat/xcrypt.c -> shadow_pw()
第 137 行把指针指向 spw->sp_pwdp,第 155 行直接返回,没有拷贝。
getspnam() 返回的是 libc 内部的静态缓冲区,每次调用都复用同一块内存:
但盐被缓存了(第 76 行 static、第 78 行判断):
第一次:缓存空 -> 完整遍历 -> 存储值被覆盖 -> 盐写入 salt[32]
第二次:缓存命中 -> 直接返回 -> 不遍历 -> 存储值不被覆盖
这就是“第一次必败、第二次才成”的技术原因,也解释了为什么必须同一连接——换连接就是新进程,缓存为空。
openbsd-compat/xcrypt.c -> xcrypt()
第 117 行,密码作为 crypt() 的第一个参数,和借来的盐一起送进去。
这是密码第一次真正参与运算——前面 10 步里它只被传递、被检查长度,从未影响任何判断。
第 120 行原样返回结果:不检查是否为合法哈希、不判 NULL、不判特殊字符串。
auth-passwd.c -> sys_auth_passwd()
第 220 行判空,防止 NULL 传进 strcmp;第 221 行逐字节比较。
判定标准是“两个字符串是否完全相同”,而不是抽象的“密码是否正确”。
正常情况下这个比较可靠:两边都是哈希,密码不对就不相等。
auth-passwd.c -> sys_auth_passwd()(第 196–221 行)
前面 12 步留下两个事实:盐是借来的,借的过程会覆盖 pw_password;比较是纯字符串比较。
输入密码:ceshiceshi
失败有两个原因叠在一起:密码本来就不对;存储值被覆盖,比较对象都变了。
但它做了一件关键的事:把借来的盐缓存进了 static salt[32]。
14. 第二次尝试:300 字节密码
同一连接,输入 300 个 A:
决定性的一步在第 117 行。
musl -> src/crypt/crypt.c -> crypt()
musl -> src/crypt/crypt_r.c -> __crypt_r()
musl -> src/crypt/crypt_sha512.c -> sha512crypt()
musl -> src/crypt/crypt_sha512.c -> __crypt_sha512()
密码 300 字节,超过 KEY_MAX(256);
内层 sha512crypt() 返回 0(NULL);
外层 __crypt_sha512() 把 NULL 翻译成哨兵串 "*"。
分层看,每一处都没有写错:长度检查是防护;盐不够格就不用;借盐做计时对齐;crypt() 失败返回哨兵值是为了调用方不崩。
问题出在两个正确设计的衔接处:
“加密失败”产生的哨兵字符串 "",恰好等于“无可用密码”账户的存储字段 "",而 OpenSSH 用纯字符串比较判定认证。
再叠加一个实现细节——借盐时 getspnam() 覆盖了存储值的指针来源——使得第一次尝试必败、第二次才能成功。
触发条件:musl libc 且未启用 PAM;目标账户 shadow 字段为 *;系统中存在其他真实 $ 哈希账户;密码长度 257~1024;同一连接内先失败一次。
所以第二次提交任意 257~1024 字节的内容,结果都一样。
密码在这条链上的唯一作用:用长度把 crypt() 逼到失败分支。
官方已发布安全补丁,请及时更新至最新版本:OpenSSH >= 10.6p2下载地址:https://www.openssh.org/portable.htmlAlpine 等 musl 发行版可执行 apk upgrade openssh 并重启 sshd;源码部署需升级到 10.6p2 后重新编译安装。
关闭密码认证并重启 sshd:在 /etc/ssh/sshd_config.d/00-no-passwords.conf 写入 PasswordAuthentication no(Alpine 执行 rc-service sshd restart);
关闭密码认证并重启 sshd:在 /etc/ssh/sshd_config.d/00-no-passwords.conf 文件中添加 PasswordAuthentication no 的配置项(在 Alpine 系统中执行 rc-service sshd restart 命令来实现这一操作);
将 PermitRootLogin 设置为 prohibit-password 或 no;
注意:在未启用 PAM 的 Alpine 等 musl 系统上,不要将 /etc/shadow 中的 "*" 改为 "!",否则会导致合法用户无法使用 SSH 密钥登录;
若已暴露,应检查 sshd 日志中本应无密码的账户是否出现 Accepted password 记录。