OpenSSH/musl 口令认证绕过漏洞底层分析及修复版本对比剖析
2026-11-10 02:16:8 Author: xz.aliyun.com(查看原文) 阅读量:0 收藏

前言

11 月初 sshhole 这份披露出来的时候,五步根因链读下来,最不踏实的是第 4 步,也就是"触发前需要一次错误口令尝试"那句。披露把原因归给静态缓冲区被覆盖,可怎么读都觉得只说了一半。手边刚好有几份上游源码包,就把 10.6p1 / 10.6p2 / 7.2p2 / 7.3p1 的 auth-passwd.c 和 xcrypt.c 拉出来逐行对了一遍,又把 musl 1.2.6 的 crypt 后端单独编了个探针,跑了下长度差分。

第一版写完之后,又在隔离容器里补了一轮实测:两个只绑 127.0.0.1 的 sshd 实例,一个已回移修复、一个源码未打补丁,把链条端到端跑了一遍。这一轮证伪了初稿两处判断,也把"版本号能不能当判定依据"钉死了,分别写在第七节和第十一节。证据分级的口径跟着改过,A 级现在包含本地实测。

后半篇是给运维看的:几条只读自查命令、止损顺序、能写成规则的检测点。所有 file:line 都指向解包后的源码正本,sed -n 能直接复核。复现部分只给到环境、判据和日志证据,PoC 与握手脚本不展开。

一、事件概述

2026 年 11 月初,一份公开披露描述了 OpenSSH portable 在 musl libc 上的口令认证绕过:未启用 PAM 构建的 sshd,对影子字段恰好是单个 * 的账户,接受长度在 257 到 1024 字节之间的任意口令,前提是在同一条 TCP 连接内先失败一次。披露方给这个问题起名 sshhole。

证据按 A / B / C 分:A 是本机跑过或逐字读自源码正本的(含第七节那轮容器实测),B 是披露与公告里没法复现的结论,C 是单源未核实、文末单列。所有 file:line 都指向第二节表格里那几份 tar 包解包后的实物,sed -n 'X,Yp' 直接能复核。

几条结论先摆在这儿,依据后面逐条摊开。

  1. 链条在源码层面成立,五环都能落到具体行(第三节);这轮隔离环境里端到端跑通了,同连接第二次 Accepted password(第七节)。

  2. 披露对"为什么需要第二次尝试"的解释缺了决定性一环。让第二次不再被破坏的,是 pick_salt() 开头那两行的静态缓存短路(第四节)。

  3. 窗口只对借到 $5$ 或 $6$ 前缀的情况打开,实测可利用长度 257–1024 字节(第五节、第七节)。

  4. 10.6p2 的修复由两处互相独立的改动组成,只回移植其中一处会留下另一半(第八节)。

  5. 这条分支从 7.3p1 起存在;7.2p2 在同一位置写死 DES 盐 xx,不在窗口内(第六节)。

  6. 版本号不能作为受影响判定依据:Alpine 10.3_p1-r2 落在区间内却已回移修复,判定要落到二进制行为(第七节)。

触发条件:七条合取

披露把触发条件写成"musl 无 PAM + 影子字段是 * + 超长口令",三个词。真到主机上要凑齐的远不止这些。七条合取、各自的源码依据、还有运维能直接跑的只读自查,图1 里都摆开了。

第 5 条最容易看漏,展开说一下。pick_salt() 只认以 $ 开头、且中间还有 $ 的影子值(xcrypt.c:85-87),借来的前缀决定后面走哪条 crypt 后端,而长度阈值只写在 sha256 与 sha512 两条后端里。借到 md5 盐时,crypt_md5.c:182 的上限是 30000 字节,1024 字节的口令根本触不到;借到 blowfish 盐时,crypt_blowfish.c:766 直接调 BF_crypt,没有任何 key 长度拒绝;借到传统 DES 盐(也就是 pick_salt() 遍历失败时 xcrypt.c:80 写下的 xx)时,DES 把 key 截到 8 字节,多长的口令都是同一个结果。这三条都在第五节里实测过。

二、取证材料与证据分级

材料

sha256

用途

src/openssh-10.6p1.tar.gz

a9dc9565dffe8640f64d863cd29a32bc4a3dbdec0566a7fc44c5d6ee767d5f39

漏洞版本正本,第三节/第四节的行号来源

src/openssh-10.6p2.tar.gz

9ae44872090cf2f06b3cf9ff147e7f16f309671340763ed2f99248398ae29d5b

修复版本正本,第八节的来源

src/openssh-7.2p2.tar.gz

a72781d1a043876a224ff1b0032daa4094d87565a68528759c1c2cab5482548c

回归点下界,第六节

src/openssh-7.3p1.tar.gz

3ffb989a6dcaa69594c3b550d4855a5a2e1718ccdde7f5e36387b424220fbecc

回归点上界,第六节

src/openssh-7.5p1.tar.gz

9846e3c5fab9f0547400b4d2c017992f914222b3fd1f8eee6c7dc6bc5e59f9f0

中间版本抽样,确认形态未再变化

src/musl-1.2.6.tar.gz

d585fd3b613c66151fc3249e8ed44f77020cb5e6c1e635a616d3f9f82460512a

crypt 后端与 passwd 读取,第五节的编译对象

六份包的本机 shasum -a 256 结果汇总在 src/sshhole-sources.sha256。解包目录 src/openssh-10.6p1/ 这些只拿来读,一个字没改;代码卡片是生成器从这些目录逐字切出来的。

探针与输出:

文件

sha256

内容

sshhole-evidence/probe.c

47106d5f4e505660d3b3558f18f4ab5ffbfba1d22daf0e9856b5d8b3526c4077

四条 sha/md5/blowfish 后端的长度差分探针

sshhole-evidence/des_probe.c

034d1e4fb8a319d608b741e528f0fbbc18b5a1012701ada9e4386222d4ee31ea

DES 后端探针

sshhole-evidence/run-probes.sh

c5e07a9f3a9058a06415c13194eb20744fd6e3167b21488582bae3e02fa038eb

编译与运行入口

sshhole-evidence/probe-output.txt

2f4050183e932c7ab8e1c7662f5de328a98f5d5cc8e4eef7d43e6b4a6af6eae7

首轮输出,第五节表格来源

sshhole-evidence/des-output.txt

85612d40b9f0b936f0fbed6d472b954f40f01c3c327ff06b4000aa2f12b8b726

DES 输出

编译环境是 Apple clang 21.0.0,目标 arm64-apple-darwin25.3.0。2026-11-06 重跑 run-probes.sh,两份输出文件逐字节不变(上表哈希即重跑后计算)。

run-probes.sh 只编译 musl 自带的 src/crypt/*.c 与 src/passwd 源文件,不链接 libc 装配层,也不启动 sshd,测到的是这份 C 源码里的阈值判断与返回值行为。进程内的指针别名关系探针碰不到,那部分后来在容器实测里补上了。

三、根因链分析

披露给了五步。对着 10.6p1 与 musl 1.2.6 的具体行逐条核下来,五步都成立,但有两处得改:第 1 步的后端列举(第五节),第 4 步的因果(第四节)。图2 把五环串了起来,每环右边标着源码位置。

环 1 口令长度闸门与窗口上界

auth-passwd.c:70 定义 MAX_PASSWORD_LEN 1024,auth_password() 的第一条语句就是长度检查:

if (strlen(password) > MAX_PASSWORD_LEN)
    return 0;

位置在 auth-passwd.c:86-87,函数最前面,命中就返回。初稿据此判断"超长口令不留日志",这轮实测站不住:auth_log() 在 userauth_finish() 里无条件执行(auth2.c:407-408),1023 / 1024 / 1025 / 2048 / 4096 字节的口令各产生恰好一条 Failed password。上界 1024 是含在内的,比较用的是 > 不是 >=。

环 2 单字符影子值跳过自带盐

sys_auth_passwd() 是整条链的主体,全文放卡片 C1。

三个位置决定成败。auth-passwd.c:199 取影子值:

char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;

auth-passwd.c:212-213 决定要不要把影子值当盐用:

if (authctxt->valid && pw_password[0] && pw_password[1])
    salt = pw_password;

* 的长度是 1,pw_password[1] 是 '\0',条件为假,salt 保持 :196 初始化的 NULL。紧接着 :214 把 NULL 交给 xcrypt()。

auth-passwd.c:205-206 的空口令检查在这里不会拦下 *,它比的是 pw_password 与 "",* 不等于空串。

环 3 NULL 盐与跨账户借盐

xcrypt.c:106-107:

if (salt == NULL)
    salt = pick_salt();

pick_salt() 和 xcrypt() 的完整切片见卡片 C2。

xcrypt.c:76 的 static char salt[32]; 是进程内静态变量,:78-79 是非空即返回的短路,:80 先把传统 DES 盐 xx 写进去兜底,:82-90 遍历 /etc/passwd,对每个账户调 shadow_pw(),取第一个形如 $…$ 的影子值,把算法前缀抄进 salt:

strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
explicit_bzero(passwd, strlen(passwd));

xcrypt.c:87-88 这两行是第四节的核心。passwd 来自 shadow_pw(),而 shadow_pw() 在 xcrypt.c:137 与 :155 直接把 spw->sp_pwdp 返回给调用方,中间没有任何拷贝。在 musl 上,这个指针指向一块进程内静态缓冲区,于是 :88 的 explicit_bzero 清零的对象,正是上一层 sys_auth_passwd() 里 pw_password 指着的那块内存。

环 4 musl sha-crypt 的 256 字节上限

musl 的 crypt() 是 src/crypt/crypt.c:4-14,它把结果写进一个 static char buf[128],再交给 __crypt_r() 按前缀分派(crypt_r.c:10-20):$1$ 走 md5,$2$ 系列走 blowfish,$5$ 走 sha256,$6$ 走 sha512,其余走 DES。

sha512 后端关键的三段,卡片 C3。

crypt_sha512.c:192 定义 KEY_MAX 256,:218-220 是拒绝逻辑:

for (i = 0; i <= KEY_MAX && key[i]; i++);
if (i > KEY_MAX)
    return 0;

内层函数返回 NULL 之后,外层包装 __crypt_sha512 在 crypt_sha512.c:368-369 把它换成字面量 "*":

if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
    return "*";

这条 if 还兼着自我测试,!p 那一支就是长度拒绝的出口。

环 5 失败返回值与影子条目逐字相等

回到 auth-passwd.c:220-221:

return encrypted_password != NULL &&
    strcmp(encrypted_password, pw_password) == 0;

encrypted_password 是 "*",非 NULL;pw_password 是 "*";strcmp 返回 0。函数返回 1。auth_password() 在 :128 把它与 ok 相与后返回,ok 取自 :81 的 authctxt->valid,账户存在即为真。

auth-passwd.c:220 的 != NULL 是 sshd 侧唯一的失败防御,它假设的是 glibc 语义:crypt 失败返回 NULL。musl 的 crypt() 不返回 NULL:crypt.c:13 把 __crypt_r() 的返回值原样转出去,crypt_r.c:11-20 的五条分支各自返回缓冲区指针或字面量,没有 NULL 出口。失败的信号就是字面 "*",而这个字符在 /etc/shadow 里另有含义("此账户没有口令")。两种语义撞在了一起,strcmp 把"加密失败"读成了"口令正确"。

四、静态盐缓存与两次尝试

披露第 4 步说,第一次尝试是必要前置,因为 pick_salt() 遍历 /etc/passwd 调 getspnam() 时,musl 的静态缓冲区会把 pw_password 指着的 * 覆盖掉。观测没错,因果写反了一半:覆盖是第一次失败的原因,跟第二次为什么成功没关系。

真正让第二次成功的,是 xcrypt.c:78-79 那两行短路。

第一次尝试:pw_password 指向 musl 的静态影子缓冲区,内容是 *;salt 为 NULL;pick_salt() 进入遍历,借到 $6$BobSalt1$ 这样的前缀,然后 explicit_bzero 把借来的那一行清零。清零作用在同一块缓冲区上,于是 sys_auth_passwd() 手里的 pw_password 此刻读成空串。口令短,未触阈值,crypt() 返回真哈希。strcmp(真哈希, "") 不相等,认证失败。

披露给的观测值是 strcmp 返回 36。36 是 '$' 的 ASCII 码,正好是哈希首字节,说明比较的另一边是空串。跟我从源码推出来的对得上。

第二次尝试:sys_auth_passwd() 重新调 shadow_pw(),getspnam() 把 * 重新填回同一块缓冲区,pw_password 又是 *;salt 仍是 NULL;xcrypt() 走进 pick_salt(),salt[0] 已经不是 '\0',直接 return,既不遍历也不清零。口令 257 字节以上,crypt() 返回 "*",strcmp("*", "*") == 0,通过。

关键是第二次得跳过 pick_salt() 的遍历。照常遍历的话,pw_password 又会被清零,还是失败。所以起作用的是两件事凑一块:第一次把静态缓存填好了,第二次就不走那段有副作用的代码。只说"第一次留下了副作用",顶多讲了前半。

静态缓冲区的来源

musl-1.2.6/src/passwd/getspnam.c:7-8 是两个进程内静态变量:

static struct spwd sp;
static char *line;

:15 把 line 作为缓冲区传给 getspnam_r()。getspnam_r.c:108 用 fgets 把匹配行读进这块缓冲区,:118 调 __parsespent(),后者在 getspnam_r.c:29 把 sp->sp_pwdp 直接指向缓冲区内部的字符位置,不做拷贝。于是任何两次 getspnam() 调用返回的 sp_pwdp 指向同一块内存,后一次覆盖前一次。

OpenSSH 10.6p1 的 shadow_pw() 没有拷贝,pick_salt() 又在拿到指针后清零它。这两件事叠在一起才产生环 3 里那个跨函数的别名。

缓存寿命等于一条 TCP 连接

pick_salt() 的 salt[32] 是进程内静态变量。privsep 子进程 sshd-auth 按连接新建:sshd-session.c:363-364 用 execv(options.sshd_auth_path, saved_argv) 起新进程,可执行文件路径来自 pathnames.h:53-54 的 _PATH_SSHD_AUTH。新进程意味着 salt[32] 回到全零。

所以"先失败一次"这个前置条件的寿命正好是一条 TCP 连接。断开重连,缓存清空,又得先失败一次。这也解释了为什么 PoC 要在同一个 Transport 上连发两次认证请求,而不是起两条连接。

五、长度阈值差分验证

五环里只有环 4 不用起 sshd 就能测:musl 各 crypt 后端在不同 key 长度下到底返回什么。做法也简单,把 musl 1.2.6 自带的后端源文件直接编进探针,按长度逐个调,打印返回值类型和 strcmp(ret, "*")。

命令:

sh sshhole-evidence/run-probes.sh

脚本从 src/musl-1.2.6/src/crypt 取源文件,编译两个探针,输出写回 probe-output.txt 与 des-output.txt。实测结果:

借到的前缀

后端

1–256 字节

257 字节

300 字节

1024 字节

1025 字节

$1$

md5

真哈希

真哈希

真哈希

真哈希

真哈希

$5$

sha256

真哈希

"*"

"*"

"*"

"*"

$6$

sha512

真哈希

"*"

"*"

"*"

"*"

$2b$

blowfish

真哈希

真哈希

真哈希

真哈希

真哈希

xx

DES

13 字符哈希

同左

同左

同左

同左

"真哈希"列的具体值形如 $6$BobSalt1$k73x…,strcmp(ret, "*") 返回 -6('$' 减 '*')。"*" 列的 strcmp(ret, "*") 返回 0。DES 那行在 keylen 8、256、257、300、1024 下返回同一个 xxJJqYuu8A9oE,strcmp(ret, "*") 返回 78('x' 减 '*'),说明 DES 把 key 截断到 8 字节后不再看后面的内容。

四条后端的阈值来源分别是:crypt_sha256.c:171 与 crypt_sha512.c:192 的 KEY_MAX 256,crypt_md5.c:182 的 KEY_MAX 30000,crypt_blowfish.c:766 无长度检查。失败返回 "*" 的位置是 crypt_sha256.c:320、crypt_sha512.c:369、crypt_md5.c:283、crypt_blowfish.c:805,DES 在 crypt_des.c:1015,且它多一个分支:setting[0] == '*' 时返回 "x"。

影响面到这儿就收窄了。窗口要求借来的盐是 $5$ 或 $6$,而 pick_salt() 取的是遍历 /etc/passwd 遇到的第一个符合 $…$ 形态的值(xcrypt.c:82-90),顺序由 NSS 与 passwd 文件行序决定,不由攻击者控制。一个系统如果所有口令哈希都是 yescrypt($y$)或 blowfish($2b$),链子同样断在环 4:$2b$ 走 blowfish 后端,实测里没有长度拒绝;$y$ 在 crypt_r.c:11-18 的四条分支里都不匹配,落到 :20 的 DES,返回 13 字符哈希。Alpine 默认用 sha512,所以主流情形仍在窗口内。

六、影响范围与前置条件

披露给的范围是"2016 年以来至 10.6p2 之前,运行于 musl libc 且未启用 PAM 的 sshd"。源码层面能确认的更细一些。

PAM 分支的短路在 auth-passwd.c:115-116:

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

开了 PAM 就到不了 sys_auth_passwd()。Alpine 的 openssh 端口同时构建带 PAM 与不带 PAM 两种 flavor,APKBUILD:181 的 _do_configure --without-kerberos5 --without-pam 是无 PAM 那一支,APKBUILD:319-326 的 server() 函数把 usr/sbin/sshd、usr/lib/ssh/sshd-auth、usr/lib/ssh/sshd-session 装进默认包,带 PAM 的版本在另一个带 flavor 后缀的包里(APKBUILD:328-341)。按这份 APKBUILD 读出来的结果,装默认 openssh-server 的镜像走的是无 PAM 分支。这轮容器实测里 sshd -T 完全没有 usepam 键,比读配方更直接,这条从 B 级升成了 A 级。

PasswordAuthentication 默认开启,servconf.h:198 的默认值是 1;sshd_config:58 的 #PasswordAuthentication yes 是同一件事的配置面表述。

两道闸门会拦掉一部分账户。

第一道是锁定账户检查,auth.c:108-112:

if (!options.use_pam && platform_locked_account(pw)) {
    logit("User %.100s not allowed because account is locked",
        pw->pw_name);
    return 0;
}

留意条件里的 !options.use_pam:只有无 PAM 构建才走这道检查,正好是这条链要走的那一支。platform.c:157-214 的实现比对三种模式,Linux 上生效的是 LOCKED_PASSWD_PREFIX,configure.ac:971-972 把它定义成 "!"。结论是:影子值以 ! 开头的账户在 allowed_user() 阶段就被拒绝,日志里能看到 account is locked;影子值恰好是 * 的账户不匹配任何锁定模式,放行到口令认证。同一份 configure 在 HP-UX 分支里用的是 LOCKED_PASSWD_STRING,值正好是 "*"(configure.ac:880-881),那边同类账户会被拦下。musl 主要跑在 Linux 上,享受不到这条定义。

第二道是 shell 检查,auth.c:118-132:shell 不存在或不可执行则拒绝。大量 * 账户的 shell 是 /sbin/nologin,只要该文件存在且可执行,这道闸门拦不住。各发行版给系统账户配的默认 shell 我没逐一实测,B 级,文末单列。

root 另有一道:auth-passwd.c:90-91 在 pw->pw_uid == 0 且 permit_root_login != PERMIT_YES 时把 ok 置 0,:128 的 result && ok 因此为假。sshd_config:33 的默认注释值是 #PermitRootLogin prohibit-password。所以"可直取 root"这个说法要加限定:默认配置下 root 走不到通过,披露自己的复现步骤也显式设了 PermitRootLogin yes。非 root 的 * 账户不受这条限制。

回归起点:7.3p1

sys_auth_passwd() 在 7.2p2 和 7.3p1 里长得不一样,卡片 C6 并排摆着。

7.2p2 的 auth-passwd.c 文件头是 /* $OpenBSD: auth-passwd.c,v 1.44 2014/07/15 15:54:14 millert Exp $ */,:205-207 直接把盐写死:

encrypted_password = xcrypt(password,
    (pw_password[0] && pw_password[1]) ? pw_password : "xx");

影子值是 * 时传 "xx",走 DES,返回 13 字符哈希,与 * 不相等。这一版不在窗口内,而且它根本不进 pick_salt()。

7.3p1 的文件头是 v 1.45 2016/07/21 01:39:35 dtucker Exp,:69 出现 MAX_PASSWORD_LEN,:92 是长度检查,:214-216 改成先赋 NULL 再交给 xcrypt():

if (authctxt->valid && pw_password[0] && pw_password[1])
    salt = pw_password;
encrypted_password = xcrypt(password, salt);

从这一版起,* 账户的盐来源从写死的 xx 变成了"去系统里借",环 3 才存在。7.5p1 的同一函数与 7.3p1 形态一致(:214-216 逐字相同),此后到 10.6p1 没有再变。

所以受影响的分支起点是 OpenSSH portable 7.3p1,2016-07-21。披露给的"2016 年以来"与此一致,只是没有精确到版本。

还有一件初稿没料到的事:发行版会把 10.6p2 的修复回移到老版本号上。Alpine openssh-server 10.3_p1-r2 名义上落在 7.3p1–10.6p1 区间,实际已经带上 MIN_PASSWORD_HASH_LEN 4,两次尝试都 denied。所以上面的版本范围只是源码层面的起点,实际判定得看二进制行为,第七节 给了黑盒和反汇编两条路。

七、本地复现与结果对照

源码推到这儿,链条看着是通的,但"看着通"和"真能登进去"是两回事。所以又搭了一轮隔离环境,把两端都钉死。

环境设计

容器 sshhole-test 跑 alpine:latest(aarch64,Alpine 3.24.3 + musl)。两个 sshd 实例都只绑 127.0.0.1,没向宿主机发布端口,全程无外部流量:

实例

端口

用途

Alpine 包版 openssh-server 10.3_p1-r2

2222

阴性对照:版本号落在受影响区间,但已回移修复

源码自编 openssh-10.6p1(unpatched)

2223

阳性对照

账户按窗口形态配:realuser / testuser 是真实 $6$ 哈希(供借盐,排在 /etc/passwd 前面),staruser / nologinuser 影子值为 *,lockeduser 为 !locked,root 也是 *。

阳性对照不能用 apk add openssh-server,装到的就是回移过修复的包。必须从源码编:

# musl 容器内
apk add build-base openssl-dev zlib-dev linux-headers
./configure --prefix=/opt/ssh-vuln --sysconfdir=/opt/ssh-vuln/etc \
            --with-privsep-path=/opt/ssh-vuln/var/empty --without-pam
make -j"$(nproc)" && make install

编之前 grep -c MIN_PASSWORD_HASH_LEN auth-passwd.c 返回 0,确认源码未打补丁;sshd -t 报 Unsupported option UsePAM,比 ldd | grep pam 更硬的无 PAM 确证。

四条自查命令在容器里原样跑过,输出与第九节一致。前缀分布是关键:

!      17
*      3
$6$    2

存在可借的 $6$ 盐,pick_salt() 有料可借;staruser / nologinuser 两个 * 账户都走到了 Failed password,lockeduser 被 account is locked 拦下,第六节那道锁定闸门在真实 sshd 里如预期生效。

漏洞存在对照:链条端到端打通

同一条 TCP 连接内对 staruser 连发两次 257 字节口令:

negotiated: SSH-2.0-OpenSSH_10.6
attempt #1 (len=257): denied
attempt #2 (len=257): *** ACCEPTED ***

服务端日志:

Connection from 127.0.0.1 port 52146 on 127.0.0.1 port 2223 rdomain ""
Failed password for staruser from 127.0.0.1 port 52146 ssh2
Accepted password for staruser from 127.0.0.1 port 52146 ssh2

同源端口 52146 上 Failed 紧跟 Accepted。第九节那条检测正则在真实实例上被完整复现,也是整套检测里唯一实测可靠的入侵信号。

只发一次恒 denied:第一次 pick_salt() 遍历借盐时,pw_password 指向的静态缓冲被写成 testuser 的哈希,strcmp 不等;第二次缓存短路不再遍历,才拿到干净的 "*"。跟第四节推的一致。

可利用窗口

每次新连接、连发两次、目标 staruser:

口令长度

结果

断点

8 / 256

denied, denied

环 4:musl 返回真哈希

257

denied, ACCEPTED

下边界,KEY_MAX 256 +1

1024

denied, ACCEPTED

上边界,MAX_PASSWORD_LEN 1024

1025

denied, denied

环 1 提前 return 0

窗口实测 257–1024 字节,宽 768 字节,必须落在窗口内、必须连发两次。跟第三节环 1 与环 4 的推导完全吻合。

漏洞修复对照:版本号说谎

同一套操作打 2222,两次都 denied。借盐(环 3)和 musl 长度拒绝(环 4)在真实 sshd 里其实都发生了。用 LD_PRELOAD 拦 crypt() 能看到 crypt(keylen=257, setting=$6$JwcdvV..S5mbmiyC) -> "*",第二次调用前没有 setpwent() 遍历,静态缓存短路同样生效。断点在环 5。

反汇编 /usr/lib/ssh/sshd-auth 的 sys_auth_passwd:

a980:  bl   strlen             ; strlen(pw_password) == 1
a98c:  cmp  x0, #0x3           ; ★ 对应 MIN_PASSWORD_HASH_LEN 4
a990:  b.ls a9c0               ; ★ 1 <= 3 -> 直接失败
a994:  ... strcmp(encrypted, pw_password)

strcmp 前多出长度闸门,且 shadow_pw() 的结果在 a924 / a928 被 free(说明已改成 xstrdup() 副本)。发行版把第八节那两处修复都回移了。自编 10.6p1 的同一位置是 bl strcmp 前什么都没有、shadow_pw 结果也不 free:

检查项

自编 10.6p1(受影响)

Alpine 10.3_p1-r2(已修复)

strcmp 前 strlen + cmp #0x3 + b.ls

无

有

shadow_pw 结果被 free

无(裸指针)

有

源码含 MIN_PASSWORD_HASH_LEN

无

有(回移)

结论很直接:版本号不能作为受影响判定依据。回移会让老版本号带上新修复,也会让没回移的实例被误标为安全。判定要落到二进制行为:

# 黑盒:对 * 账户连发两次 257 字节口令,两次都 denied -> 已修复(不论版本号)
# 静态:在 strcmp 调用前找长度闸门
objdump -d /usr/lib/ssh/sshd-auth | grep -B2 -A2 "cmp.*#0x3"

顺带修正的两处初稿判断

  • "超长口令不留日志"是错的。auth_log() 在 userauth_finish() 里无条件执行(auth2.c:407-408),和 auth-passwd.c:86-87 的提前返回无关。1023 / 1024 / 1025 / 2048 / 4096 字节各产生恰好一条 Failed password。第十节"超长口令无日志"这条信号得删掉。

  • PAM 探测有更干净的方式。容器里 sshd -T 输出完全没有 usepam 键,grep -c usepam 返回 0 即无 PAM flavor,比 ldd | grep pam 直接。初稿"只读了 APKBUILD,没解包二进制核对"的 B 级限定由此升成 A 级。

验证边界

全程隔离容器,sshd 只绑 127.0.0.1,未向宿主机发布端口,未对任何外部主机发包。这轮验证只用于确认检测规则能区分受影响与已修复实例;PoC 输出、口令构造与完整握手脚本不在此展开。

问题发现

证明版本问题:

# 先看 2223 的版本号——名义上在受影响区间内
docker exec sshhole-test /opt/ssh-vuln/sbin/sshd -V

# 再看 2222 的版本号——10.3p1,同样落在 7.3p1–10.6p1 区间
docker exec sshhole-test /usr/sbin/sshd -V

漏洞验证

/tmp/sshhole.exp 2223 staruser 257

服务端日志(同源端口 Failed→Accepted)

docker exec sshhole-test tail -20 /var/log/sshd-vuln.log

多次版本比对

修复版本对照 — 同样的操作打 2222

/tmp/sshhole.exp 2222 staruser 257

必须两次 — 只发一次口令打 2223

/tmp/sshhole1.exp

可利用窗口是 257–1024 字节

for L in 8 256 257 1024 1025; do
  printf "len=%-5s -> " "$L"
  /tmp/sshhole.exp 2223 staruser "$L" 2>&1 | grep -q PWNED && echo "ACCEPTED" || echo "denied"
  echo
done

反汇编对照(断点根因)

# 受影响版:strcmp 前没有长度闸门
docker exec sshhole-test objdump -d --disassemble=sys_auth_passwd \
  /build/openssh/sshd-auth | tail -20

# 已修复版:反汇编里能搜到长度闸门
docker exec sshhole-test objdump -d /usr/lib/ssh/sshd-auth \
  | grep -B2 -A2 "cmp.*#0x3" | head -20

受影响版的关键三行(bl strcmp 前什么都没有):

e6d0:  ldr  x1, [sp, #16]
e6d4:  bl   be00 <strcmp@plt>    ; ★ 直接比较
e6d8:  cmp  w0, #0x0

已修复版在 strcmp 前多出:

a980:  bl   strlen               ; strlen("*") = 1
a98c:  cmp  x0, #0x3             ; ★ 1 <= 3 ?
a990:  b.ls a9c0                 ; ★ 是 -> 直接失败

命令自查

四条自查命令

docker exec sshhole-test sh -c '
  echo "=== A: musl 链接 ==="
  ldd /opt/ssh-vuln/sbin/sshd | grep -E "musl|libc"
  echo "=== B: PAM 探测 ==="
  /opt/ssh-vuln/sbin/sshd -T -f /opt/ssh-vuln/etc/sshd_config 2>&1 | grep -c usepam
  echo "=== C: 影子值为 * 的账户 ==="
  awk -F: "\$2==\"*\" {print \$1}" /etc/shadow
  echo "=== D: 可借哈希前缀分布 ==="
  awk -F: "{print substr(\$2,1,3)}" /etc/shadow | sort | uniq -c | sort -rn
'

环境关闭

验证完关掉受影响实例:

docker exec sshhole-test pkill -f "ssh-vuln/sbin/sshd"
docker exec sshhole-test sh -c 'netstat -ltn | grep 2223' || echo "2223 已停"

彻底清理:

docker rm -f sshhole-test
docker rmi sshhole-repro:latest

八、上游修复分析

10.6p2 的 sys_auth_passwd() 全文在卡片 C5。

一处是给比较加长度下限。auth-passwd.c:71 新增 #define MIN_PASSWORD_HASH_LEN 4,:226-228 的比较变成:

ret = encrypted_password != NULL &&
    strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
    strcmp(encrypted_password, pw_password) == 0;

* 的长度是 1,直接被这一条挡下。它同时也挡住了 "x" 这类退化值。这一处改动独立成立,即使不动内存管理,绕过也已经失效。

另一处是把静态缓冲区别名干掉。:202-203 把取影子值改成堆拷贝:

char *pw_password = authctxt->valid ?
    shadow_pw(pw) : xstrdup(pw->pw_passwd);

:209-212 的空口令分支加了 free(pw_password),:229 用 freezero() 收尾。配套的 xcrypt.c 改动在 p1 与 p2 的 diff 里能看到三处:pick_salt() 的 out: 标签前后加了清零,shadow_pw() 的返回改成 pw_password == NULL ? NULL : xstrdup(pw_password),函数上方的注释补了一句 "Caller must free returned value."。

两处都得回移植。只加长度下限,explicit_bzero 跨函数踩内存的问题还在,换一个失败返回值形态相同的场景仍可能出问题;只改堆拷贝,* 与 "*" 的比较照样相等,绕过照旧成立。

九、应急响应处置

A. 暴露面判定

四条命令,全部只读,不需要发起任何认证。

# 1. sshd 是否链接 musl
ldd /usr/sbin/sshd 2>/dev/null | grep -i musl
​
# 2. 是否链接 PAM(无输出即无 PAM 构建)
ldd /usr/sbin/sshd 2>/dev/null | grep -i pam
​
# 3. 影子字段恰好是 * 的账户清单
awk -F: '$2=="*"{print $1}' /etc/shadow
​
# 4. 系统里可借的哈希前缀分布
awk -F: '{print substr($2,1,3)}' /etc/shadow | sort | uniq -c

第 4 条输出里出现 $5$ 或 $6$,且第 3 条有非 root 账户,才需要继续。第 1 条或第 2 条任一不成立,本机不在这条链上。

版本判定:

/usr/sbin/sshd -V 2>&1 | head -3

初稿写的是"7.3p1 到 10.6p1 都按受影响处理",这轮实测证伪了:Alpine 10.3_p1-r2 版本号落在区间内,却已回移 MIN_PASSWORD_HASH_LEN 4,两次尝试均 denied。版本号只能用来初筛。-V 不够时去包的 changelog 找 MIN_PASSWORD_HASH_LEN,或者按 第七节 的黑盒两条 / 反汇编长度闸门定性。

止损

按代价从低到高:

  1. 把 * 改成 ! 前缀。影子值以 ! 开头即被 auth.c:108 的锁定检查拒绝,这条检查在窗口场景里一定会执行。改法:usermod -L <user>;工具集里没有这条命令时直接编辑 /etc/shadow。这是最小改动,也是唯一不需要重启 sshd 的改动。

  2. 给确实需要口令的账户设真实口令,影子值变成 $6$…,环 2 的条件不成立。

  3. 关闭这些账户的口令认证。sshd_config 里用 Match User 配 PasswordAuthentication no,或把 UsePAM 打开(sshd_config:85 的 #UsePAM no)。后者改变认证栈整体行为,需要连带验证 PAM 配置,属于较大动作。

  4. 换包。装带 PAM flavor 的 openssh 包,或升级到 10.6p2。

第 1 条要留意副作用:* 与 ! 在部分工具链里的语义不同,! 更明确地表示"被管理员锁定",改动会体现在 passwd -S 之类的输出里。对本来就不该用口令登录的系统账户,这个变化通常是加分项。

已登录痕迹排查

绕过成功时 sshd 记的是正常的 Accepted password 行,与真实口令登录无法从消息文本区分。可用的区分点是:同一连接内紧跟在 Failed password 之后、且中间没有其他认证方法参与的 Accepted password。日志行本身带客户端源端口(auth.c:294-303 的格式串里是 from %.200s port %d),源端口加源地址足以定位到一条 TCP 连接。

journalctl -u sshd --since "14 days ago" -o cat \
  | grep -E "(Accepted|Failed) password" \
  | grep -oE "(Accepted|Failed) password for .* port [0-9]+"

输出按 from <地址> port <端口> 分组后,同一组里 Failed 与 Accepted 并存就要人工核查。这条命令只做筛选,分组判断留给分析师,因为日志字段数量随 invalid user 是否出现而变化,写死列号的 awk 会漏。

真正的排查要看进程:sshd-auth 子进程按连接新建,sshd-session 记录的连接起止时间能对齐到具体会话。把可疑会话的起止时间与变更窗口、镜像构建时间对照。

对容器场景,日志通常丢失,处置方式按失陷处理:换镜像重建,而不是在容器里加个 ! 前缀后继续运行。

时间回溯

如果镜像已经在生产跑过一段时间,需要回答"有没有人用过这条路"。可用的证据面:

  • sshd 的 Accepted password 与 Failed password 成对出现在同一来源端口(auth.c:294-303 的格式里带源 IP 与端口)。

  • 超过 1024 字节的口令在 auth-passwd.c:86-87 被提前判失败,但 auth_log() 照样记一条 Failed password(auth2.c:407-408)。初稿"不留日志"的说法已证伪。如果中间设备(负载均衡、SSH 代理)记录了报文长度,那里还能看到探测尝试。

  • 系统账户被口令登录本身就是异常。* 账户设计上就不能口令登录,任何一次成功都是问题,不需要漏洞指纹也能定性。

十、检测方法

能写成规则的信号,按靠谱程度排一下。这一版按第七节的实测结果改过,初稿里那条"超长口令无日志"已经删掉:auth_log() 无条件记录,这个信号根本不存在。

  1. 同一连接内 Failed password 后紧跟 Accepted password,且源端口相同。唯一实测可靠的入侵信号:阳性对照里 52146 端口上两条日志完整出现,阴性对照里没有。正常用户失败后要么换口令、要么放弃,很少在第二次就输入一个恰好正确的口令。

  2. 影子字段恰为单个 * 且 shell 可执行的账户。环 2 的入口,配置层可直接扫:awk -F: '$2=="*"{print $1}' /etc/shadow 的结果与 getent passwd 的 shell 字段做交集。

  3. * 账户出现 Failed password。正常加固下这些账户根本不该走到口令认证;account is locked 之外的 Failed 值得看。

  4. 认证报文的口令字段长度落在 257–1024 字节。MaxAuthTries 默认 6(sshd_config:35),两次尝试在一秒内完成的连接也值得看。服务端日志里没有长度字段,得靠前置代理或跳板机的客户端侧记录,抓包看不到明文。披露的 PoC 用同一字符重复 300 次,全同字符只对那个 PoC 成立,不能当通用指纹。

  5. 无 PAM 构建的 sshd 实例清单。容器环境里最容易漏:镜像基于 Alpine 装了默认 openssh-server 就落进候选。但第七节说过,Alpine 包版可能已回移修复,必须按行为复核,别只按版本号拉清单。

两个改写结论的机制

PerSourcePenalties(9.8+ 默认开启)初稿没提。本环境 sshd -T 显示 crash:90 authfail:5 noauth:1 invaliduser:5 grace-exceeded:10 refuseconnection:10 max:600 min:15 overflow:permissive。它按源对 authfail 施加惩罚:localhost 单次失败后立即重试没被拦(overflow:permissive 且未达阈值),但生产中同一源 IP 反复触发 authfail 会开始惩罚性延迟甚至断连,这会压缩"同连接二次尝试"的时序窗口。写规则时别把它当成稳定的第二次机会。

前缀分布是天然的断路器。awk 输出里 $6$ / $5$ 计数为 0 时环 4 就断,但断法因可借后端而异:

可借的前缀

断在哪

原因

$y$(yescrypt)

环 5

musl 不认识,落到 DES,恒返回 13 字符真哈希

$2b$(blowfish)

环 4

实测全长度返回真哈希,无长度拒绝

$1$(md5)

环 1

窗口上移到 30000 字节,MAX_PASSWORD_LEN 1024 先拦

无任何 $…$ 可借

环 5

pick_salt() 落 DES 兜底 xx,同 yescrypt 路径

所以把口令哈希迁走 $6$ / $5$ 是普适断路器,优先迁到 $y$,成本最低。

总结

整条链拆到最后,其实就三件事:

  1. * 这个影子值让 sys_auth_passwd() 把 NULL 当盐传下去,pick_salt() 只好去系统里另借一个;借来的 $6$ 撞上 musl sha-crypt 的 256 字节上限,crypt() 失败时返回字面量 "*"。两边一比,正好相等。

  2. "先失败一次"不是仪式,是 pick_salt() 里那个 static char salt[32] 的缓存寿命。它跟着 privsep 子进程走,一条 TCP 连接清空一次,所以重连得重来。披露把这半截因果写漏了。

  3. 修复得两处一起回移植:长度下限挡住 * 与 "*" 的相等,堆拷贝掐断跨函数的别名。只做一处,另一半还留着。

这轮在隔离容器里把链条端到端跑通了:unpatched 10.6p1 同连接第二次 Accepted password,可用窗口实测 257–1024 字节;Alpine 包版 10.3p1-r2 虽在区间内却因回移修复两次 denied。版本号不可信,判定要落到二进制行为。这是整轮验证里最该带走的一条。

影响面比披露写的窄:窗口只认 $5$ / $6$,默认配置下 root 还有 PermitRootLogin 挡着,真正该盯的是所有非 root 的 * 账户。第九节那四条只读命令能圈出暴露面,再按第七节的黑盒或反汇编判据定性。检测上只留一条硬信号:同源端口 Failed 紧跟 Accepted;PerSourcePenalties 会压缩二次尝试的时序,别把它写成稳定假设。

剩下没独立核过的:CVE 编号与厂商通告、各发行版默认 /etc/shadow 的 * 账户清单、系统账户默认 shell 是否满足可执行检查、10.6p2 之前还有哪些发行版回移过修复。这几项按 B / C 级引用,处置时别当已证事实。

REF

  • OpenSSH portable 源码包 10.6p1 / 10.6p2 / 7.2p2 / 7.3p1 / 7.5p1,musl libc 1.2.6(哈希见第二节)

  • Alpine Linux aports testing/openssh/APKBUILD:PAM 与无 PAM flavor 的构建定义,引用 :163、:181、:219、:319-326、:328-341

  • sshhole 公开披露:成因五步、复现步骤与对照结果(本文不重复其 PoC)

  • 配图卡片 C1–C6 由 scripts/codecard.mjs 从解包正本按真实行号逐字切片:openssh-10.6p1/auth-passwd.c:191-222、openbsd-compat/xcrypt.c:70-95、musl-1.2.6/src/crypt/crypt_sha512.c:189-196、musl-1.2.6/src/passwd/getspnam.c:1-18、getspnam_r.c:23-32、openssh-10.6p2/auth-passwd.c:192-232;规格见 sshhole-images/cards/*.json

  • 本地验证:隔离容器 alpine:latest(aarch64,Alpine 3.24.3 + musl),Alpine openssh-server 10.3_p1-r2 与源码自编 openssh-10.6p1;探针与输出见第二节,实测结果见第七节


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