OpenSSH/musl 口令认证绕过漏洞底层分析及修复版本对比剖析
前言 11 月初 sshhole 这份披露出来的时候,五步根因链读下来,最不踏实的是第 4 步,也就是"触发前需要一次错误口令尝试"那句。披露把原因归给静态缓冲区被覆盖,可怎么读都觉得只说了一半。手边刚好有几份上游源码包,就把 10.6p1 / 10.6p2 / 7.2p2 / 7.3p1 的 auth-passwd.c 和 xcrypt.c 拉出来逐行对了一遍,又把 musl 1…
前言
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' 直接能复核。
几条结论先摆在这儿,依据后面逐条摊开。
链条在源码层面成立,五环都能落到具体行(第三节);这轮隔离环境里端到端跑通了,同连接第二次
Accepted password(第七节)。披露对"为什么需要第二次尝试"的解释缺了决定性一环。让第二次不再被破坏的,是
pick_salt()开头那两行的静态缓存短路(第四节)。窗口只对借到
$5$或$6$前缀的情况打开,实测可利用长度 257–1024 字节(第五节、第七节)。10.6p2 的修复由两处互相独立的改动组成,只回移植其中一处会留下另一半(第八节)。
这条分支从 7.3p1 起存在;7.2p2 在同一位置写死 DES 盐
xx,不在窗口内(第六节)。版本号不能作为受影响判定依据: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 | 用途 |
|---|---|---|
|
| 漏洞版本正本,第三节/第四节的行号来源 |
|
| 修复版本正本,第八节的来源 |
|
| 回归点下界,第六节 |
|
| 回归点上界,第六节 |
|
| 中间版本抽样,确认形态未再变化 |
|
| crypt 后端与 passwd 读取,第五节的编译对象 |
六份包的本机 shasum -a 256 结果汇总在 src/sshhole-sources.sha256。解包目录 src/openssh-10.6p1/ 这些只拿来读,一个字没改;代码卡片是生成器从这些目录逐字切出来的。
探针与输出:
文件 | sha256 | 内容 |
|---|---|---|
|
| 四条 sha/md5/blowfish 后端的长度差分探针 |
|
| DES 后端探针 |
|
| 编译与运行入口 |
|
| 首轮输出,第五节表格来源 |
|
| 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 字节 |
|---|---|---|---|---|---|---|
| md5 | 真哈希 | 真哈希 | 真哈希 | 真哈希 | 真哈希 |
| sha256 | 真哈希 |
|
|
|
|
| sha512 | 真哈希 |
|
|
|
|
| blowfish | 真哈希 | 真哈希 | 真哈希 | 真哈希 | 真哈希 |
| 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 包版 | 2222 | 阴性对照:版本号落在受影响区间,但已回移修复 |
源码自编 | 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 | 下边界, |
1024 | denied, ACCEPTED | 上边界, |
1025 | denied, denied | 环 1 提前 |
窗口实测 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(已修复) |
|---|---|---|
| 无 | 有 |
| 无(裸指针) | 有 |
源码含 | 无 | 有(回移) |
结论很直接:版本号不能作为受影响判定依据。回移会让老版本号带上新修复,也会让没回移的实例被误标为安全。判定要落到二进制行为:
# 黑盒:对 * 账户连发两次 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,或者按 第七节 的黑盒两条 / 反汇编长度闸门定性。
止损
按代价从低到高:
把
*改成!前缀。影子值以!开头即被auth.c:108的锁定检查拒绝,这条检查在窗口场景里一定会执行。改法:usermod -L <user>;工具集里没有这条命令时直接编辑/etc/shadow。这是最小改动,也是唯一不需要重启 sshd 的改动。给确实需要口令的账户设真实口令,影子值变成
$6$…,环 2 的条件不成立。关闭这些账户的口令认证。
sshd_config里用Match User配PasswordAuthentication no,或把UsePAM打开(sshd_config:85的#UsePAM no)。后者改变认证栈整体行为,需要连带验证 PAM 配置,属于较大动作。换包。装带 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() 无条件记录,这个信号根本不存在。
同一连接内
Failed password后紧跟Accepted password,且源端口相同。唯一实测可靠的入侵信号:阳性对照里52146端口上两条日志完整出现,阴性对照里没有。正常用户失败后要么换口令、要么放弃,很少在第二次就输入一个恰好正确的口令。影子字段恰为单个
*且 shell 可执行的账户。环 2 的入口,配置层可直接扫:awk -F: '$2=="*"{print $1}' /etc/shadow的结果与getent passwd的 shell 字段做交集。*账户出现Failed password。正常加固下这些账户根本不该走到口令认证;account is locked之外的Failed值得看。认证报文的口令字段长度落在 257–1024 字节。
MaxAuthTries默认 6(sshd_config:35),两次尝试在一秒内完成的连接也值得看。服务端日志里没有长度字段,得靠前置代理或跳板机的客户端侧记录,抓包看不到明文。披露的 PoC 用同一字符重复 300 次,全同字符只对那个 PoC 成立,不能当通用指纹。无 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 就断,但断法因可借后端而异:
可借的前缀 | 断在哪 | 原因 |
|---|---|---|
| 环 5 | musl 不认识,落到 DES,恒返回 13 字符真哈希 |
| 环 4 | 实测全长度返回真哈希,无长度拒绝 |
| 环 1 | 窗口上移到 30000 字节, |
无任何 | 环 5 |
|
所以把口令哈希迁走 $6$ / $5$ 是普适断路器,优先迁到 $y$,成本最低。
总结
整条链拆到最后,其实就三件事:
*这个影子值让sys_auth_passwd()把 NULL 当盐传下去,pick_salt()只好去系统里另借一个;借来的$6$撞上 musl sha-crypt 的 256 字节上限,crypt()失败时返回字面量"*"。两边一比,正好相等。"先失败一次"不是仪式,是
pick_salt()里那个static char salt[32]的缓存寿命。它跟着 privsep 子进程走,一条 TCP 连接清空一次,所以重连得重来。披露把这半截因果写漏了。修复得两处一起回移植:长度下限挡住
*与"*"的相等,堆拷贝掐断跨函数的别名。只做一处,另一半还留着。
这轮在隔离容器里把链条端到端跑通了: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 libc1.2.6(哈希见第二节)Alpine Linux aports
testing/openssh/APKBUILD:PAM 与无 PAM flavor 的构建定义,引用:163、:181、:219、:319-326、:328-341sshhole 公开披露:成因五步、复现步骤与对照结果(本文不重复其 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),Alpineopenssh-server 10.3_p1-r2与源码自编openssh-10.6p1;探针与输出见第二节,实测结果见第七节