[CVE-2026-123158] *sshole : How Two Unrelated '*' Strings Broke OpenSSH on musl
TL;DR Item Detail CVE CVE-2026-123158 Nickname *sshole (sshhole) Reported by Özgün Kültekin (Wakefield) Disclosed November 5, 2026 Affected OpenSSH sshd on musl libc without PAM (Alpine Linux's default c
TL;DR
| Item | Detail |
|---|---|
| CVE | CVE-2026-123158 |
| Nickname | *sshole (sshhole) |
| Reported by | Özgün Kültekin (Wakefield) |
| Disclosed | November 5, 2026 |
| Affected | OpenSSH sshd on musl libc without PAM (Alpine Linux's default config, among others) |
| Affected versions | OpenSSH 7.3 – 10.6p1 (since 2016) |
| Fixed in | OpenSSH 10.6p2 |
| CVSS 3.1 | 8.1 (High) |
| Trigger condition | A passwordless (*) account + at least one other local account with a $5$/$6$ hash |
| Trigger | Send any 257–1024 byte string as the password |
This post walks through the sshd and musl source code to explain exactly how an attacker who knows no password at all can log in, just by sending a 257-byte string.
The core idea: two unrelated "*" strings turn out to be equal
Unix systems never store your password as-is — they store a hash, and compare hashes at login time. A normal shadow entry looks like this:
alice:$6$Qx7Lm2Pa$k3J9...(long hash)...:20000:0:99999:7:::
$6$ means SHA-512 crypt, and Qx7Lm2Pa is the salt (a random string mixed into the password so two users with the same password don't get the same hash).
Accounts that should only ever authenticate with an SSH key — never a password — follow a decades-old Unix convention and store a single asterisk instead:
keyonly:*:20000:0:99999:7:::
That convention only holds if no real password can ever hash down to the single character *. sshole is exactly the case where that assumption breaks.
musl libc's crypt() refuses passwords longer than 256 bytes and signals the failure by returning the literal string "*". It turns out that, under the right conditions, sshd can be tricked into generating exactly that "*" from an attacker-controlled password — and once it does, it matches the "*" stored for the passwordless account byte-for-byte.
Reading the source
Step 1 — How sshd checks a password without PAM
With PAM disabled, sshd verifies passwords itself in sys_auth_passwd(), in auth-passwd.c. This is the pre-patch code, as shipped in OpenSSH 9.3p2 on Alpine Linux 3.18:
/* Just use the supplied fake password if authctxt is invalid */
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
...
/*
* Encrypt the candidate password using the proper salt, or pass a
* NULL and let xcrypt pick one.
*/
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
encrypted_password = xcrypt(password, salt);
/*
* Authentication is accepted if the encrypted passwords
* are identical.
*/
return encrypted_password != NULL &&
strcmp(encrypted_password, pw_password) == 0;
Textbook logic: read the stored hash, use it as the salt to hash the password the user just typed, and strcmp() the two strings.
The interesting line is this one:
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
Checking both pw_password[0] and pw_password[1] means the stored value needs at least two characters before sshd will use it as a salt. * is only one character, so it fails this check, and salt stays NULL. That forces the very next line, xcrypt(password, salt), to come up with a salt on its own.
Step 2 — Borrowing a salt from another account
When salt is NULL, pick_salt() in openbsd-compat/xcrypt.c picks one:
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;
It walks /etc/passwd front to back, looking for the first account whose hash starts with $ and has a second $ delimiter — a modern crypt format like $5$... or $6$.... It returns the hash-type-plus-salt prefix of that account, or falls back to the legacy DES placeholder "xx" if nothing matches.
This function exists because of CVE-2016-6210. In 2016, researchers showed that sshd's response time differed for nonexistent users versus real ones — a timing side-channel that let attackers enumerate valid usernames. The fix was to always borrow a real account's salt format when none was available, so the timing cost looked the same either way. The intent was to protect against usernames that don't exist. But because of the pw_password[1] check above, a real account with * falls into the exact same code path.
So our passwordless keyonly account ends up borrowing, say, alice's $6$Qx7Lm2Pa salt for the comparison.
Step 3 — musl crypt()'s 256-byte cap
Next, sshd hashes the attacker's password using that borrowed salt. musl's SHA-512 crypt implementation (src/crypt/crypt_sha512.c) caps the input length:
/* 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
...
/* reject large keys */
for (i = 0; i <= KEY_MAX && key[i]; i++);
if (i > KEY_MAX)
return 0;
Anything over 256 bytes is refused outright — a sensible DoS protection. What matters is what the caller gets back on that failure:
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;
Not NULL — the string "*". POSIX says crypt() should return a null pointer and set errno on error, but musl deliberately returns a string instead, because (per its own commit message) "a large portion of programs which call crypt are not prepared for its failure and do not check that the return value is non-null." glibc's libxcrypt, by contrast, returns NULL on failure, which sshd already filters out with encrypted_password != NULL — this is exactly why glibc distros like Debian, Ubuntu and RHEL are not affected.
Worth noting: musl does guard against the obvious version of this bug. In crypt_des.c:
return (setting[0]=='*') ? "x" : "*";
If the salt itself starts with *, musl returns "x" instead — so crypt(password, "*") can never produce "*". That guard protects the classic pattern, where a program uses the stored field as its own salt. But sshd never does that here; it uses a borrowed $6$... salt from a different account. As far as musl is concerned, it's just doing an ordinary SHA-512 crypt with a valid salt that happens to fail because the input is too long — the guard never triggers.
Step 4 — Where the two "*" meet
Putting it together:
encrypted_password = xcrypt(password, salt); /* salt borrowed from alice, password is 257 bytes */
return encrypted_password != NULL &&
strcmp(encrypted_password, pw_password) == 0; /* "*" vs "*" */
-
pw_password: the stored value forkeyonly→"*" -
encrypted_password: musl's error string from trying to hash a 257-byte password with the borrowed salt →"*"
strcmp("*", "*") returns 0, and sshd accepts the login.
In practice this usually takes two attempts in the same SSH connection. musl's getspnam() reuses an internal shared buffer, and pick_salt()'s lookup of another account overwrites and then wipes that buffer with explicit_bzero(). So on the first attempt, pw_password ends up pointing at corrupted memory and the comparison simply fails. From the second attempt onward in the same connection, the salt is cached and the comparison runs cleanly. Since sshd allows up to 6 password attempts per connection by default (MaxAuthTries), this extra attempt costs the attacker nothing.
The full attack flow
Conditions required
All of the following must hold:
- sshd is built against musl libc and without PAM (Alpine Linux's default
openssh-serverpackage) -
PasswordAuthentication yes(OpenSSH's default) - The target account's shadow field is exactly
*(accounts locked with!are safe) - The earliest
$-hashed account in/etc/passwdorder uses a vulnerable hash type
-
$5$/$6$(SHA-256/512): needs a 257–1024 byte password - bcrypt (
$2a$/$2b$/$2y$):pick_salt()miscalculates the$delimiter position for bcrypt salts, so any 1–1024 byte password works regardless of length (fixed separately upstream in commit84f8f466) - If
$1$(MD5) or$y$(yescrypt) comes first, neither path applies
Root itself uses * on Alpine too, but the default PermitRootLogin prohibit-password blocks direct root logins this way. However, Alpine's official cloud images (3.18.11–3.24.3) ship an alpine user with passwordless doas — so bypassing login for that single account is effectively root.
The fix: OpenSSH 10.6p2
The patch to auth-passwd.c is just a few lines:
+#define MIN_PASSWORD_HASH_LEN 4
#define MAX_PASSWORD_LEN 1024
...
- return encrypted_password != NULL &&
+ ret = encrypted_password != NULL &&
+ strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;
+ freezero(pw_password, strlen(pw_password));
+ return ret;
The key addition is strlen(pw_password) >= MIN_PASSWORD_HASH_LEN. The shortest valid hash (legacy DES) is 13 characters, so anything stored under 4 characters is excluded from the comparison entirely — whether it's *, !, or any error string musl might return. This single check closes every path described in this post, bcrypt salt-parsing bug included.
The patch also changes shadow_pw() to return an xstrdup()'d copy instead of a pointer into a shared buffer, and wipes it with freezero() after use — removing the root cause of the "needs two attempts" buffer-reuse behavior described above.
Checking for exposure
Run these as root to check quickly:
# musl-based?
ldd "$(command -v sshd)" | grep -i musl
# PAM in use?
sshd -T 2>/dev/null | grep -i '^usepam'
# password login allowed?
sshd -T 2>/dev/null | grep -i '^passwordauthentication'
# accounts with "*" in the shadow field
awk -F: '$2=="*"{print $1}' /etc/shadow
# accounts with a $5$/$6$ hash
awk -F: '$2 ~ /^\$[56]\$/{print $1}' /etc/shadow
If you see a musl match, usepam no, passwordauthentication yes, at least one * account, and at least one $5$/$6$ account, the host is vulnerable on any pre-patch version.
Remediation
- Update and restart sshd — updating the package alone doesn't help if the running process isn't restarted.
apk upgrade -U
rc-service sshd restart
- Can't update right now? Turn off password auth (make sure key-based login works first):
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication
Alpine 3.16–3.18 don't read sshd_config.d by default, so add PasswordAuthentication no directly to /etc/ssh/sshd_config on those versions.
-
Check for signs of abuse — look for
Accepted password forlog lines naming an account that should never accept a password login:
grep -h 'Accepted password for' /var/log/messages /var/log/auth.log 2>/dev/null
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
