You run Alpine. Your deploy user logs in with an SSH key, so you set no password for it, and you have not thought about password authentication in years. Here is the thing nobody told you this week: a single SSH connection with a long enough password just logged in as that user. No key. No password. No brute force.
That is CVE-2026-123158, nicknamed *sshole by the researcher who found it, Ozgun Kultekin. OpenSSH shipped an emergency release, 10.6p2, on 5 November 2026 to block it, and Alpine backported the fix to every branch back to 3.16 the same day. It only bites a specific build: musl libc, sshd without PAM, password logins left on. That happens to be the default Alpine openssh-server. Debian, Ubuntu, RHEL and anything else on glibc are not affected.

Start here
If you run sshd on Alpine (or any musl system with OpenSSH built without PAM) and you have not touched PasswordAuthentication, assume you are exposed and act now:
- Patch and restart.
apk upgrade -U -athenrc-service sshd restart. The upgrade alone does not replace the running daemon. - Or close it in one line if you cannot patch yet: set
PasswordAuthentication noand restart sshd, after you confirm key login from a second session. - Then hunt. Look for
Accepted passwordon any account whose shadow field is*. That is the signature of this being used against you.
Everything below was run against a throwaway sshd, not a diagram. OpenSSH 10.0p2, built from source against musl with --without-pam, the same shape Alpine's default package ships. If a screenshot says a login succeeded, the daemon logged it.
Are you actually affected? Thirty seconds
Four things have to be true at once. Miss any one and the bug cannot fire:
- musl, no PAM. sshd is linked against musl and built
--without-pam, or run withUsePAM no. Alpine's defaultopenssh-serverpackage is built without PAM.openssh-server-pamwithUsePAM yesis fine. - Password auth on.
PasswordAuthentication yes, which is the OpenSSH default. - A "*" account. Some account you can name has
*in its password field: key-only users, directory users, androoton Alpine since about 3.16. - A donor hash. At least one other local account has a
$5$or$6$hash for the attack to borrow a salt from. One is enough, and it exposes every "*" account on the box.
Here is a read-only script that checks all four and then looks for signs it already happened. Save it as detect.sh and run it as root:
#!/bin/sh
# detect.sh - are you exposed to the OpenSSH-on-musl password bypass
# (sshhole / CVE-2026-123158)? Read-only. Run as root for the shadow checks.
# Exposed = musl + no PAM + PasswordAuthentication yes + an account whose
# password field is '*' + at least one account with a $5$/$6$ hash.
set -u
sshd_bin=$(command -v sshd || echo /usr/sbin/sshd)
verdict=ok
echo "== 1. Is sshd linked against musl? =="
if ldd "$sshd_bin" 2>/dev/null | grep -qi musl; then
echo " musl: YES ($sshd_bin)"
else
echo " musl: no -> not affected (glibc). Stop here."; exit 0
fi
echo "== 2. Does sshd use PAM, and is password auth on? =="
conf=$("$sshd_bin" -T 2>/dev/null)
usepam=$(printf '%s\n' "$conf" | awk '/^usepam/{print $2}')
pwauth=$(printf '%s\n' "$conf" | awk '/^passwordauthentication/{print $2}')
echo " usepam=${usepam:-<not compiled in>} passwordauthentication=${pwauth:-unknown}"
[ "${usepam:-no}" = "yes" ] && { echo " PAM handles passwords -> not affected via this path."; usepam_safe=1; }
[ "${pwauth:-yes}" = "no" ] && { echo " Password auth is off -> not affected."; exit 0; }
echo "== 3. Accounts whose password field is '*' (no password, reachable) =="
star=$(awk -F: '$2=="*"{print $1}' /etc/shadow 2>/dev/null)
if [ -z "$star" ]; then echo " none"; else printf ' %s\n' $star; verdict=EXPOSED; fi
echo "== 4. Is there a donor hash ($5$ or $6$) on the box? =="
if awk -F: '$2 ~ /^\$[56]\$/{f=1} END{exit !f}' /etc/shadow 2>/dev/null; then
echo " yes - a long password can borrow this salt"
else
echo " no \$5\$/\$6\$ hash -> the bug cannot fire"; verdict=ok
fi
echo "== 5. Already abused? 'Accepted password' for a no-password account =="
nopass=$(awk -F: '$2=="*"{print $1}' /etc/shadow 2>/dev/null | paste -sd'|' -)
if [ -n "$nopass" ]; then
grep -hE "Accepted password for ($nopass) " /var/log/messages /var/log/auth.log /var/log/secure 2>/dev/null \
| tail -5 | sed 's/^/ HIT: /' || true
fi
echo
echo "VERDICT: $verdict"
[ "$verdict" = EXPOSED ] && [ "${usepam_safe:-0}" != 1 ] && \
echo "Fix: upgrade openssh (>=10.6p2 / patched Alpine), or set PasswordAuthentication no, then restart sshd."
exit 0Run it on the lab box and you get a clean read of the four signals, plus a log hit once the attack has run:

One string compare is the whole bug
When sshd is built without PAM, it checks a password itself. It calls crypt() on what you typed and compares the result to the stored field in /etc/shadow. A real hash looks like $6$salt$longbase64. An account with no password stores a single character: *.
Since the fix for CVE-2016-6210 in 2016, sshd does not short-circuit on a * field. To keep the timing identical for accounts with and without a password (so you cannot tell which usernames exist), it borrows another account's salt and hashes your password anyway. For a $5$ or $6$ salt, musl's crypt() refuses any password longer than 256 bytes and returns the string "*" to signal the failure. The stored field is "*". The two strings match. sshd accepts the login.
You can see the core of it without SSH at all. Compile one tiny program on musl and on glibc and feed it passwords of growing length against a real $6$ salt:
/* cryptprobe.c - show what crypt() returns for long passwords.
* Build the same code on musl (Alpine) and glibc (Debian) and compare.
* The bug: on musl, a password over 256 bytes makes crypt() return "*",
* which is exactly what an account with no password stores in /etc/shadow.
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <crypt.h>
static void probe(const char *pw, const char *salt) {
char *h = crypt(pw, salt);
if (h == NULL) {
printf("len=%-4zu -> NULL\n", strlen(pw));
return;
}
char head[17];
snprintf(head, sizeof head, "%s", h);
printf("len=%-4zu -> \"%s%s\"%*s match stored '*': %s\n",
strlen(pw), head, strlen(h) > 16 ? "..." : "",
(int)(strlen(h) > 16 ? 0 : 19 - strlen(h)), "",
strcmp(h, "*") == 0 ? "YES <-- login accepted" : "no");
}
int main(void) {
/* a real $6$ (sha512) salt borrowed from an account that has a password */
const char *salt = "$6$zjuNBj795LQ6wlk9$";
char buf[2048];
size_t lens[] = {1, 8, 256, 257, 300, 1024};
for (unsigned i = 0; i < sizeof(lens) / sizeof(*lens); i++) {
memset(buf, 'A', lens[i]);
buf[lens[i]] = '\0';
probe(buf, salt);
}
return 0;
}
Read the right-hand column. On musl, everything past 256 bytes collapses to "*", which matches the stored "*". On glibc the same inputs keep hashing, and at 1024 bytes it returns "*0", not "*". That one extra character is the whole reason Debian and friends are safe. glibc was, quite literally, one byte away from the same bug.
300 A's, and bob has a shell
The primitive is convincing, but people want to see the login. So here is the full thing against a real sshd. The lab builds OpenSSH 10.0p2 from source the vulnerable way and sets up three accounts:

alice has a normal password. bob is a key-only user with *. carol is locked with !. Now log in as bob, who has no password, using 300 copies of the letter A:

The first attempt in the connection fails, the second one drops you into a shell as bob, uid 1001. The server's own log says it plainly: Accepted password for bob. The two-attempt quirk is real and worth knowing for detection: on the first try sshd scans /etc/passwd to pick a donor salt, and on musl that scan overwrites the buffer holding your password. On the second try the salt is cached, the password is read clean, crypt() returns "*", and the match lands.
Rebuild the daemon the vulnerable way if you want the same result on your own box. The 10.0p2 tarball unpacks as openssh-10.0p1. Do this only on a machine you own:
# OpenSSH 10.0p2, musl, no PAM
./configure --prefix=/opt/oss --with-shadow --without-pam \
--with-privsep-path=/var/empty
make && make installWho gets in, and who does not
This is the part the headlines get wrong. A lot of them say "locked accounts." That is not it. Here is the same 300-character attack against all three account types, plus a short password for contrast:

- bob (
*, no password): in. This is the key-only marker. On Alpine it is normal, deliberate, and everywhere. - carol (
!, locked): denied. A real lock.!never matches acrypt()result. Safe. - alice (real
$6$hash): denied. A real password still has to match. Accounts with passwords are not the target. - bob with a short password: denied. Under 257 bytes, musl hashes normally and the
"*"collision never happens.
So the exposed set is exactly: accounts that carry *, reached with a password over 256 bytes. On Alpine that includes root (shipped as root:*), every key-only user, and users that come from a directory service. root is still behind PermitRootLogin prohibit-password by default, but a key-only user who can run doas is a straight line to root anyway.
Patch it, then restart sshd
The real fix is the package update, which refuses the match when the stored field is shorter than four characters. OpenSSH calls it a workaround and puts the root cause outside sshd; musl's maintainer agrees it is a caller problem, not a musl bug. Either way, 10.6p2 and the backported Alpine packages close it.
| Alpine branch | Fixed openssh package |
|---|---|
| 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 |
Those ship in point releases 3.21.10, 3.22.8, 3.23.8 and 3.24.4. Branches 3.16 to 3.20 are past normal support and still got a backport. Update and restart:
apk upgrade -U -a
rc-service sshd restart # the upgrade does NOT restart the running daemon
sshd -T | grep -i passwordauthenticationCannot patch this minute? Turn password auth off. Confirm key login from a second session first, and keep your current session open until a new login works:
mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication # should print: passwordauthentication noNote the drop-in directory: Alpine's default config reads /etc/ssh/sshd_config.d/ from 3.19 onward. On 3.16 through 3.18 that directory is ignored, so put the line in /etc/ssh/sshd_config itself. Either way, the same attack is dead afterwards:

Hunt the log line
The signature is the cleanest you will ever get: a password login succeeding on an account that has no password. Grep for it:
# an 'Accepted password' for any account whose shadow field is '*'
nopass=$(awk -F: '$2=="*"{print $1}' /etc/shadow | paste -sd'|' -)
grep -hE "Accepted password for ($nopass) " \
/var/log/messages /var/log/auth.log /var/log/secure 2>/dev/nullOn Alpine the log is usually /var/log/messages. If you find a hit, or you simply cannot rule one out because logs rotated or a container was recreated, treat the machine as compromised. Review doas use, recently created users, authorized_keys files, and anything written since the box was first reachable. No in-the-wild exploitation was reported as of the advisory, but public reproduction labs already exist, so the technique is not secret.
Why this sat around for ten years
None of the ingredients is new. They just never stood next to each other where anyone was looking.
- 2012, musl.
crypt()is supposed to returnNULLon failure, but too many callers never check and crash. musl changed it to return the string"*"instead. Three weeks later it capped SHA-512 crypt at 256 bytes to stop a long password from pinning a CPU. Past the cap, failure returns"*". - 2016, OpenSSH (CVE-2016-6210). The salt-borrowing fix for the username-timing leak made sshd hash a password even for accounts with no real hash. That path now runs for
*accounts too. - 2022, Alpine. After an earlier incident where root had no password at all, Alpine switched
roottoroot:*so SSH keys keep working while the account stays "passwordless." That is the exact marker this bug treats as a successful hash.
Why did nobody trip over it? OpenSSH tests on OpenBSD and glibc. musl does not test sshd. Most distros check passwords through PAM, which never calls crypt() for a * field. And a fresh box with no $6$ hash anywhere is not exploitable, so a clean image looks fine until the first real password shows up. The researcher found it by building sshd every way he could (glibc, musl, PAM, no PAM) and throwing roughly 17,600 odd logins at each. Only one build said yes to things it should have refused.
The tiny-cloud trap
One case deserves its own callout because it is a quiet root escalation. Alpine's official tiny-cloud images (the qcow2, VHD and AMI artifacts) from 3.18.11 through 3.24.3 ship an alpine user with a * password field and passwordless doas. On first boot there is no $6$ hash anywhere, so the box is safe. The moment anyone sets a console password, or cloud user-data adds a user with a hash, the alpine user becomes reachable with a long password, and doas turns that into root. If you run instances from those images, update openssh or set PasswordAuthentication no on every running instance - a patched base image does nothing for a VM that has been up since spring.
Hand each line to an owner
Give each line an owner and a piece of evidence. "We use keys" is not evidence.
- Inventory: which hosts actually run
sshdon musl without PAM. Alpine VMs, appliances, tiny-cloud instances, embedded OpenWrt/Buildroot/Yocto builds that added OpenSSH. Evidence:ldd $(command -v sshd) | grep musl. - Patch + restart: fixed
opensshpackage, daemon restarted. Evidence: package version plus a fresh sshd start time. - Or disable:
PasswordAuthentication nowhere you cannot patch yet. Evidence:sshd -T | grep passwordauthenticationprints no. - tiny-cloud: every running instance from an old image patched or locked, not just the base. Evidence: per-instance check.
- Hunt: logs reviewed for
Accepted passwordon a*account. Evidence: the grep above, saved to the ticket.
Sources
- OpenSSH 10.6p2 release notes
- Alpine Linux security advisory (affected list, fixed packages, tiny-cloud)
- musl advisory for OpenSSH+musl users (Rich Felker)
- CVE-2026-123158 record
- sshhole.com - the researcher's writeup (Ozgun Kultekin)
FAQ
I only allow SSH keys. Password login is off in my head. Am I safe?
Only if it is off in sshd_config. The default is PasswordAuthentication yes, and that is all the attacker needs. "We use keys" is a habit, not a setting. Run sshd -T | grep -i passwordauthentication and read the real answer.
Is my account safe because it is locked?
Depends which kind of locked. A password field of ! (true lock) is safe. A field of * is the one that gets in. On Alpine, key-only users and root ship as *, not !, on purpose so SSH keys keep working. That is exactly the marker this bug matches.
I run Alpine containers everywhere. How worried should I be?
If the container does not run sshd, not at all - this is an sshd bug. The exposed machines are Alpine VMs, appliances and cloud images that actually listen on 22, plus anything that compiled OpenSSH --without-pam against musl. Check which of your hosts run sshd, not which run Alpine.
Does a fail2ban or a rate limit help?
Not really. The attack is one connection, two password attempts, no brute force. There is nothing noisy to rate-limit. Turning password auth off or patching is the only thing that closes it.
We are on glibc (Debian, Ubuntu, RHEL). Do we need to do anything?
No. glibc's crypt() never returns a bare "*" - it returns NULL or "*0", which cannot equal a stored "*". Systems that check passwords through PAM never hit this path either. This is a musl-without-PAM problem.
Is there a CVE, and how bad is it?
Yes, CVE-2026-123158, published 5 November 2026, scored CVSS 3.1 8.1 High. It is High rather than Critical only because several local conditions have to line up (attack complexity high). When they do, it is a full remote login.
I upgraded the package. Why can I still log in with a long password?
Because apk upgrade replaces the files on disk but does not restart the running daemon. The old sshd is still in memory. Run rc-service sshd restart and test again.