On November 5, 2026, OpenSSH and Alpine Linux published fixes for CVE-2026-123158, a bug its finder named sshole. On an affected Alpine server, an account that has no password at all can be logged into over SSH. The attacker types one wrong password, then a password of 300 letter A's. Any string between 257 and 1024 bytes works.
No memory corruption is involved and nothing gets cracked. Two pieces of code each do something reasonable. OpenSSH borrows a salt from another account to keep its timing constant, and musl's crypt() returns the string "*" when a password is too long. Put them together and "*" ends up compared with "*", which counts as a match.
This article walks through the bug one line of code at a time. We reproduced it in an isolated lab using the real vulnerable and fixed Alpine packages. Below you will find the lab recording, the exact conditions, what the server log shows, read-only commands to check your own hosts, and the fix.
🎬 Watch it happen
The recording below runs in an isolated container network with no internet access: Alpine 3.24 with the vulnerable openssh-server 10.3_p1-r1. The alpine account has no password, only * in /etc/shadow. As on some Alpine cloud images, it is allowed to run doas without a password. In the second scene the defender checks the log and upgrades to 10.3_p1-r2. In the third scene the same attack fails.
A.🧩 The short version
A host is exposed only when all of the following are true:
- musl libc. In practice this means Alpine Linux, plus containers and appliances built on it. glibc systems are not affected.
- sshd built without PAM, or
UsePAM no. Alpine's plainopenssh-serverpackage is built--without-pam. PasswordAuthentication yesin the effective configuration, including inside anyMatchblock.- An account whose shadow field is exactly
*, the usual mark for "no password, key login only". Alpine 3.16 and later shiproot:*. Some Alpine cloud images from 3.18.11 to 3.24.3 also have analpineuser with*. - At least one real
$5$or$6$(SHA-256/SHA-512) hash in the password database. It must be the first$…$hash sshd finds when it reads the accounts in order.
Root is reachable directly only with PermitRootLogin yes. The more realistic path is a * user with passwordless doas or sudo, which is exactly what the video shows.
🔍 Step 1: how sshd checks a password without PAM
Without PAM, OpenSSH portable checks passwords itself in sys_auth_passwd() in auth-passwd.c. It reads the stored hash, hashes what you typed with the same salt, and compares the two strings. This is the vulnerable version (10.6p1 and earlier):
/* Just use the supplied fake password if authctxt is invalid */
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
if (pw_password == NULL)
return 0;
/* Check for users with no password. */
if (strcmp(pw_password, "") == 0 && strcmp(password, "") == 0)
return (1);
/*
* 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;
Look at the salt line. A real hash such as $6$aCbS… is long, so it becomes the salt. The stored value * is one character long, so pw_password[1] is the terminating zero. The salt stays NULL, and xcrypt() is told to "pick one".
🧂 Step 2: no salt, so sshd borrows one
Picking a salt was added in 2016 as a fix for a timing leak (CVE-2016-6210). Hashing a fake password with the weak DES algorithm was much faster than SHA-512, so an attacker could tell real accounts from fake ones by response time. The fix: when there is no usable salt, borrow the salt of the first real hash on the system. Every check then costs the same. This is pick_salt() in openbsd-compat/xcrypt.c:
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;
}
}
Two details matter later. The salt is cached in a static buffer, so the account loop runs only once per process. And the loop calls shadow_pw() for each account, then wipes the hash it found with explicit_bzero().
So our * account now gets hashed with someone else's $6$ salt. That is still harmless on its own: a real SHA-512 hash never equals *. Unless the hash function does not return a hash.
📏 Step 3: musl returns "*" for long passwords
musl's SHA-crypt code limits the key to 256 bytes. The header comment of src/crypt/crypt_sha512.c says what happens otherwise: "on error "*" is returned."
#define KEY_MAX 256
/* ... */
/* reject large keys */
for (i = 0; i <= KEY_MAX && key[i]; i++);
if (i > KEY_MAX)
return 0;
/* ... */
char *__crypt_sha512(const char *key, const char *setting, char *output)
{
/* ... */
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;
}
Returning "*" is a deliberate convention. A result that can never be a valid hash cannot match a stored hash by accident. We checked it in an Alpine 3.24 container with the same $6$ salt:
len=8 crypt -> $6$aCbSYq76Jftc68Jl$76JgfZEfQl1YvT8jVKPe…
len=256 crypt -> $6$aCbSYq76Jftc68Jl$c4tLHEodMuYaRhE80FBp…
len=257 crypt -> *
len=300 crypt -> *
len=1024 crypt -> *
That convention holds as long as no stored hash is literally *. On Alpine, many are.
🎯 Step 4: "*" equals "*", on the second try
Put the three steps together for an account stored as * and a 300-byte password:
sys_auth_passwd()sees a one-character hash and passessalt = NULL.pick_salt()returns a borrowed$6$salt.- musl's
crypt()rejects the 300-byte key and returns"*". strcmp("*", "*") == 0, so the login is accepted.
Why does the first attempt fail? On the first password attempt in a connection, pick_salt() has not run yet. Its loop calls shadow_pw() for every account. On musl the returned string lives in a static buffer that is reused on each call, and pw_password in step 1 points into that same buffer. The loop overwrites it, and explicit_bzero() then zeroes the hash it found. By the time the comparison runs, pw_password is an empty string, and "*" does not equal "".
The salt is now cached. On the second attempt over the same connection, pick_salt() returns at once without touching the buffer, pw_password is still *, and the comparison succeeds. OpenSSH allows several password attempts per connection by default, so the attacker needs nothing special: the first prompt gets anything, the second gets the long string.
This explanation comes from our own reading of the code. The lab result matches it exactly: one attempt with 300 A's is always denied, and a wrong password followed by 300 A's is always accepted.
🔢 Why exactly 257 to 1024 bytes
- 256 bytes or fewer: musl hashes the password normally. The result starts with
$6$and does not match*. - 257 to 1024 bytes: musl returns
"*", and the comparison matches. - More than 1024 bytes: sshd rejects the password before hashing it (
MAX_PASSWORD_LEN 1024inauth-passwd.c).
The CVE text says "between 256 and 1024". In our lab, 256 bytes was denied and 257 was accepted, so the window starts at 257.
The salt condition comes from the same code. If no account has a $…$ hash, the salt stays xx (classic DES). DES uses only the first 8 characters and never returns *. musl's MD5 code ($1$) accepts keys up to 30,000 bytes, far above sshd's 1024-byte cap. The 256-byte limit exists only in musl's SHA-256 and SHA-512 code, so only a borrowed $5$ or $6$ salt triggers it.
🧪 What we saw in the lab
We tested with Alpine's own aarch64 packages: openssh-server 10.3_p1-r1 (vulnerable) and 10.3_p1-r2 (fixed), on Alpine 3.24. The lab had three accounts: alpine with *, dev with a real SHA-512 password, and locked with !. A Python SSH client sent exact byte lengths and controlled how many attempts each connection made.
alpine, wrong password then 257, 300, 512 or 1024 A's: accepted.alpine, wrong password then 256 or 1025 A's: denied.alpine, 300 A's as the only attempt: denied.locked(!),root(defaultPermitRootLogin), a nonexistent user, anddevwith a wrong long password: denied.- After
apk upgradeto10.3_p1-r2and a restart of sshd: every case denied, with no newAcceptedline.
A successful bypass leaves one ordinary log line in /var/log/messages. It looks the same as a real password login (the address below is a placeholder):
Nov 6 08:55:04 alpine-lab auth.info sshd-session[139]: Accepted password for alpine from 203.0.113.50 port 56134 ssh2
The tell is who logged in by password. An account stored as * has no password, so it should never appear in an Accepted password line. The failed first attempt right before it (Failed password for alpine) is a second hint.
👑 From user to root
The default PermitRootLogin prohibit-password blocks root, even though Alpine's root is stored as *. The block comes from a separate check in auth-passwd.c: a password login with uid 0 is refused unless PermitRootLogin is yes. Hosts that set PermitRootLogin yes and allow password logins are exposed directly.
The indirect path is more common. Some Alpine cloud images (tiny-cloud, 3.18.11 to 3.24.3) create an alpine user that is key-only (*) and may run doas without a password. An attacker who gets in as alpine runs doas sh and is root, as the video shows. Images set up with cloud-init are not affected, according to the Alpine advisory.
🚫 What is not affected
- glibc distributions (Debian, Ubuntu, RHEL and its relatives). The OpenSSH team states the bug does not exist on Linux with glibc, on PAM builds, or on non-Linux systems.
- sshd with PAM (
UsePAM yeson a PAM build), where PAM checks the password instead ofsys_auth_passwd(). - Locked accounts with
!. sshd rejects them earlier, and the log saysinvalid user. - Empty password fields and nonexistent users.
- Key-only servers with
PasswordAuthentication no. - Dropbear, the default SSH server on many small devices such as OpenWrt routers. It is a different codebase.
🔎 Read-only checks you can run today
All commands below only read. Run them as root on each Alpine host or container that runs sshd.
1. Which OpenSSH package is installed? Compare the version with the fixed list in the next section.
cat /etc/alpine-release
apk info -v 2>/dev/null | grep '^openssh-server'
2. What is the effective configuration? On Alpine's non-PAM build, sshd -T prints no usepam line at all. That is expected.
sshd -T 2>/dev/null | grep -iE '^(usepam|passwordauthentication|permitrootlogin) '
grep -rniE '^\s*(Match|PasswordAuthentication|PermitRootLogin)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null
The second command finds Match blocks that turn password logins back on for some users or addresses. sshd -T shows only the default case.
3. Which accounts are stored as *?
awk -F: '$2=="*" {print $1}' /etc/shadow
4. Did any of those accounts log in with a password? Replace the names in the pattern with the output of check 3.
grep -h 'sshd.*Accepted password for' /var/log/messages* 2>/dev/null
grep -hE 'sshd.*Accepted password for (root|alpine) ' /var/log/messages* 2>/dev/null
5. Was doas or sudo used after such a login?
grep -hiE 'doas|sudo' /var/log/messages* 2>/dev/null | tail -n 50
Any Accepted password line for an account from check 3 means the account was entered without its owner's key. Treat that host as compromised: check what the session did, look for new SSH keys in ~/.ssh/authorized_keys, new users and cron entries, and rebuild if in doubt. Hosts that log elsewhere (a central syslog, journald) need the same search there.
🩹 The fix
OpenSSH 10.6p2 (commit 4353fdc4, Damien Miller, November 5, 2026) refuses to accept any stored hash shorter than four characters. It also works on its own copy of the stored hash, so another call to shadow_pw() can no longer change it during the check:
+#define MIN_PASSWORD_HASH_LEN 4
#define MAX_PASSWORD_LEN 1024
...
- char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
+ char *pw_password = authctxt->valid ?
+ shadow_pw(pw) : xstrdup(pw->pw_passwd);
...
- 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 commit message reads: "We do not consider the root cause to be within OpenSSH but we are well equipped to avoid it." musl's maintainer called it an erroneous use of crypt() by the caller, not a musl bug, but said musl will likely change its behavior anyway. Both sides are partly right. That is why the bug survived for about ten years: each piece is correct in isolation.
Fixed Alpine packages (openssh-server):
- edge:
10.6_p2-r0 - 3.24:
10.3_p1-r2· 3.23:10.2_p1-r1· 3.22:10.0_p1-r11· 3.21:9.9_p2-r1 - 3.20:
9.7_p1-r6· 3.19:9.6_p1-r3· 3.18:9.3_p2-r4· 3.17:9.1_p1-r7· 3.16:9.0_p1-r6 - Fixed point releases and images: 3.21.10, 3.22.8, 3.23.8 and 3.24.4.
Upgrade and restart. The restart matters: upgrading the package does not replace the sshd process that is already running.
apk upgrade -U -a
rc-service sshd restart
If you cannot upgrade right away, turn off password logins. Alpine 3.19 and later read drop-in files from /etc/ssh/sshd_config.d/. A file whose name sorts first wins, because sshd uses the first value it reads:
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd -t && rc-service sshd restart
Check again for Match blocks that set PasswordAuthentication yes. A setting inside Match overrides the global one for the users it matches. Container images built on Alpine need a rebuild from a fixed base image. Upgrading a running container is lost on its next restart.
💡 Takeaways for defenders
- Two correct pieces can make one hole. Constant-time password checks and a "never-matching" error string are both good ideas. Together they turned
*, "no password", into "any long password". - Password logins on servers are a liability. Hosts with
PasswordAuthentication nowere never exposed, whatever their libc or account setup. - Passwordless
doas/sudoturns any user compromise into root. The bug only gave a normal user. The sudo rule did the rest. - Restart what you patch. A fixed package on disk does not help while the old daemon keeps running.
- Logs record it. An
Accepted passwordline for an account that has no password is unusual and easy to search for. - Expect fast rediscovery. The finder used automated differential testing across thousands of logins, and the OpenSSH team noted rapid rediscovery of bugs by AI agents. Expect old bugs like this to keep surfacing, and expect others to find them too.
❓ FAQ
Is my Ubuntu or Debian server affected?
No. They use glibc, and their sshd normally uses PAM. The OpenSSH team states the bug does not exist on glibc or PAM systems. The bug needs musl libc and a non-PAM sshd, which in practice means Alpine Linux and systems built on it.
Why does the attacker need two password attempts?
On the first attempt, sshd's salt lookup reads every account and overwrites the buffer that holds the stored *, so the comparison fails. The salt is then cached, so on the second attempt in the same connection the stored value stays * and matches.
Does a locked account with "!" protect me?
Yes. sshd rejects accounts locked with ! before it checks the password, and the log shows invalid user. The bug needs a stored value of exactly *.
Can an attacker get root directly?
Only if PermitRootLogin yes is set and password logins are allowed. The default blocks root password logins. Root is usually reached through another * account that may use doas or sudo without a password.
Is upgrading the package enough?
Only after you also restart sshd. The running process keeps the old code until it restarts. Containers need a rebuild from a fixed base image.
How do I know if it was used against me?
Search your SSH logs for Accepted password lines for accounts stored as * in /etc/shadow. Those accounts have no password, so a successful password login for them is a strong sign of this bypass.
📚 Sources
- Alpine Linux, "OpenSSH password authentication bypass," November 5, 2026: alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/
- OpenSSH 10.6p2 release notes: openssh.com/txt/release-10.6p2
- OpenSSH portable, commit "avoid password authn bypass": github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482
- CVE record CVE-2026-123158: cve.org/CVERecord?id=CVE-2026-123158 and nvd.nist.gov/vuln/detail/CVE-2026-123158
- oss-security mailing list, November 5, 2026: openwall.com/lists/oss-security/2026/11/05/3
- musl mailing list, maintainer response: openwall.com/lists/musl/2026/11/05/4
- LWN.net coverage: lwn.net/Articles/1105464/
- Özgün Kültekin (Wakefield), the original sshole write-up: sshhole.com
- musl source,
src/crypt/crypt_sha512.candcrypt_sha256.c; OpenSSH portable 10.6p1 source,auth-passwd.candopenbsd-compat/xcrypt.c.
Version lists and dates come from these sources as published on November 5–6, 2026. The lab reproduction, the step-by-step explanation of the two-attempt requirement, and the checks above are our own work.