OpenSSH took the blame for musl's Alpine login bug

This post is open for corrections. Highlight any line of text to challenge it or ask for a clearer explanation and leave a comment.

The OpenSSH Alpine login bug is musl’s fault more than OpenSSH’s. When the password function crypt() fails, the POSIX standard says it should return NULL. Musl returns the string "*" instead, and /etc/shadow uses that same * for accounts with no password. Send a password longer than 256 bytes and the two stars match. Only musl systems with PAM off and password logins on are exposed.

Key Takeaways

  • OpenSSH 10.6p2 closes a password bypass on musl Linux systems like Alpine.
  • The cause is musl’s password function returning “*” where the standard says NULL.
  • A password over 256 bytes opens accounts whose shadow entry is just “*”.
  • Most Linux servers, which use glibc or PAM, are not affected.
  • A patched Alpine box won’t say 10.6p2, so check the package revision.

What happened with the OpenSSH 10.6p2 Alpine login bug?

OpenSSH 10.6p2 is an urgent release that fixes a password bypass in sshd, the SSH server. The bug hits Linux systems built on musl libc with PAM turned off. Alpine Linux is the common case: it ships musl and builds its SSH server without PAM.

Alpine Linux 3.21 console showing OpenRC boot messages, then a root login at the localhost prompt
Alpine 3.21 boots through OpenRC and BusyBox, with musl underneath
Image: Alpine Linux development team, Wikimedia Commons, GPL

A build that has PAM compiled in but runs with UsePAM no is exposed too.

Özgün Kültekin of Wakefield reported the bug. The OpenSSH team got the report one day before they shipped 10.6p2 with the fix. In the release announcement on the openssh-unix-dev mailing list, Damien Miller told affected admins to set PasswordAuthentication=no or turn SSH off right away.

On Mastodon the bug spread under the hashtag #SSHHole, the name most people search for. Its official ID is CVE-2026-123158.

How a long password turns into a successful login

Without PAM, sshd checks a password the old Unix way. It runs what you typed through crypt(), which turns it into a hash. Then it compares that hash with the one stored in /etc/shadow. If the two strings are equal, you’re in.

Musl’s SHA-256 and SHA-512 code refuses passwords longer than 256 bytes. The limit protects the server, because hashing gets much slower as the password grows. When a password is too long, musl’s crypt_sha512.c gives up and returns the one-character string "*". The POSIX standard says a failed crypt() should return NULL here.

At the same time, the star has a special meaning in /etc/shadow. A password field that holds only * marks an account with no usable password, such as a key-only user, a service account or a locked account.

So sshd ends up comparing two stars. The first comes from crypt(), which failed on the long password. The second is the * stored in /etc/shadow for that account. They are equal, so sshd accepts the login.

Diagram showing a 300-byte password making musl crypt() return a star, which matches the star stored in /etc/shadow

The attack works with any password from 257 to 1,024 bytes. At 256 bytes or fewer, musl hashes the password normally and the result doesn’t match. Above 1,024 bytes, sshd rejects the password before checking it. OpenSSH added that cap in 2016, after CVE-2016-6515 showed that very long passwords could keep the server busy inside crypt().

An account with a real password hash is safe, because its stored hash never equals "*". Logins that go through PAM are safe too, because PAM runs its own check and refuses a * account. The exposed accounts are the ones admins locked down on purpose. An attacker needs all four conditions at once:

  • musl libc as the C library
  • No PAM in the password path, either at build time or through UsePAM no
  • PasswordAuthentication yes, which is the OpenSSH default
  • A reachable account whose shadow field is exactly *

How OpenSSH 10.6p2 patched the bypass

The fix is commit 4353fdc “avoid password authn bypass” in openssh-portable. It changes three files: auth-passwd.c, openbsd-compat/xcrypt.c and openbsd-compat/port-uw.c, which holds a UnixWare copy of the same function. A follow-up commit, 102bee7, only adds a missing #include <stdlib.h>.

The old code in sys_auth_passwd()

When PAM is off, sshd checks passwords in a function called sys_auth_passwd(). The snippets below show the old code, before the patch, in the order it runs.

First it reads the account’s password field from /etc/shadow. For a key-only account, pw_password is the one-character string *.

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

Next it picks a salt, the prefix that crypt() mixes into the hash. Normally the stored hash doubles as the salt. But the code only uses it when it is at least two characters long, and * is just one character. So salt stays NULL, which means “no salt”.

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

With no salt, xcrypt() calls a helper named pick_salt(). The account has no password, but sshd still runs crypt() on purpose. If it skipped that work, a login to such an account would fail faster than a normal one, and an attacker could time the answers to learn which accounts exist. Running crypt() needs a salt, so pick_salt() borrows one. It goes through the accounts on the system and takes the salt of the first one with a real password hash, such as a $6$ (SHA-512) hash.

With that borrowed $6$ salt and a 300-byte password, musl’s crypt() fails and returns "*", where POSIX says it should return NULL. The old code then compared the two strings:

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

sshd did check for NULL, as POSIX asks. Musl returned a string instead, so that check passed. Then strcmp("*", "*") gave 0, which means the strings match, and the login went through.

By my reading of pick_salt(), the bypass only works when the borrowed salt is SHA-256 or SHA-512, because only that musl code fails on long passwords. If the first real hash on the system is MD5 or bcrypt, crypt() returns a normal hash and the login fails. If no account has a real password, pick_salt() falls back to the old DES salt xx. DES reads only the first 8 characters of a password, so it never fails on a long one, never returns *, and the login fails there too.

The new length check

+#define MIN_PASSWORD_HASH_LEN	4

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

The main fix makes password login fail whenever the stored password field is shorter than 4 characters, whatever crypt() returns.

No user with a password gets locked out by this, because real hashes are always longer. A DES hash is 13 characters and a SHA-512 hash is over 90. Anything of three characters or fewer, like *, !, !! or x, means “no password here”. The rule also protects against any other C library that returns a short error string.

A private copy of the shadow entry

-	return pw_password;
+	return pw_password == NULL ? NULL : xstrdup(pw_password);

sshd now keeps its own copy of the account’s password field. The commit doesn’t say why, but my guess is this: before, sshd and pick_salt() read from the same shared buffer, and pick_salt() loads other accounts into it while it looks for a salt. That could overwrite the value sshd was about to compare, and a private copy rules this out.

Cleanup on every return path

Every way out of the function now frees that copy. freezero() wipes the hash from memory before freeing it, and pick_salt() wipes each borrowed hash as it goes. The UnixWare file gets the same changes and drops its old #ifdef USE_LIBIAF free.

Why musl is the one at fault

The POSIX crypt() spec says that when the function fails, it returns NULL and sets errno. A program that follows the standard checks for NULL, and OpenSSH does. Musl has returned "*" on errors instead since its crypt code landed in 2012.

Musl does this for old programs that never check for NULL and would crash on one. They get a string meant to fail any comparison instead. Its dispatcher, crypt_r.c, sends each salt type to its own helper, and every helper returns "*" on error.

But shadow files store that exact * for accounts with no password. Musl does guard one narrow case: its DES code returns "x" instead of "*" when the salt itself starts with *. That guard doesn’t help here, because sshd borrowed the salt from a different account.

libxcrypt, the crypt library on most glibc distros, returns *0 or *1 on error, and its error string never equals the salt it was given. A two-character string can’t equal a one-character *, so the same OpenSSH code is safe there.

Miller put the blame outside his project in the first announcement:

We consider the root cause of this bug to be outside OpenSSH itself, but we’re in a good position to deliver a mitigation quickly.

Damien Miller (Mastodon, 467 boosts)

The case for blaming OpenSSH anyway

Rich Felker, the musl maintainer, does not see it as a musl vulnerability. In a follow-up the next day, he explained why:

The problem was OpenSSH checking one user’s password against another user’s hash to waste time (to mask timing leaks) then still trusting the result of that check to let them log in (rather than hard-coding failure when doing this).

Rich Felker (dalias) (Mastodon)

When pick_salt() runs, sshd already knows the account has no usable password. It could mark the login as failed right there and still do the timing work for show. It could also reject * and ! entries before calling crypt() at all. The 10.6p2 patch shows such a check takes only a few lines.

Portable OpenSSH runs on many C libraries and already works around their quirks, so it could have handled one more. Distros that build sshd without PAM share the blame for that code path too.

Every other non-PAM crypt() caller on musl deserves the same audit, from su and login replacements to web apps that check passwords against /etc/shadow.

Why OpenSSH shipped with only a day’s notice

The team shipped the fix within a day of the report, with no embargo, the usual quiet period before a bug goes public. Miller explained why in the release announcement:

we have recently observed multiple cases of rapid bug rediscovery by AI agents, sometimes within hours, and so now consider long embargo periods to be a dangerous practice that primarily benefits attackers

Damien Miller (openssh-unix-dev)

OpenSSH will also release more often instead of saving fixes for the next planned version.

Alpine shipped fixed packages the same day, and Miller thanked the Alpine developers for it in the announcement. The project also backported the fix as far back as Alpine 3.16, a branch released in 2022.

Check and fix your Alpine server

Alpine patched the OpenSSH version each stable branch already had instead of moving to 10.6p2. So a patched Alpine box won’t say 10.6p2 when you ask for its version. On a fresh alpine:3.24 container, ssh -V prints OpenSSH_10.3p1 even though the package has the fix. Check the package revision instead.

BranchFirst fixed package
v3.2410.3_p1-r2
v3.2310.2_p1-r1
v3.2210.0_p1-r11
v3.219.9_p2-r1
v3.209.7_p1-r6
v3.199.6_p1-r3
v3.189.3_p2-r4
v3.179.1_p1-r7
v3.169.0_p1-r6

Update, restart and confirm the revision:

apk update && apk upgrade openssh
rc-service sshd restart
apk list -I openssh-server

Then check whether the box was ever exposed:

sshd -T | grep -Ei 'passwordauthentication|usepam'
awk -F: '$2=="*" {print $1}' /etc/shadow

Here’s the output from that stock alpine:3.24 container after installing openssh-server:

openssh-server-10.3_p1-r2 x86_64 {openssh} (SSH-OpenSSH) [installed]
passwordauthentication yes
root

Password logins are on by default. The usepam line is missing because Alpine builds sshd without PAM. root has a * password, and the only thing keeping password logins away from it is the default PermitRootLogin prohibit-password. A box that changed that to yes left root open to the bypass.

The setting that blocks the bypass is PasswordAuthentication no, with key-only logins. Docker images based on alpine that run sshd need a rebuild too, because updating the host doesn’t touch the package inside the image. Embedded and router images often run Dropbear instead of OpenSSH, and Dropbear is not affected.