core-jmp core-jmpdeath of core jump

*sshole: OpenSSH on musl Accepts Any Password Over 256 Bytes

On musl libc without PAM, OpenSSH treated any password of 257 to 1,024 bytes as valid for accounts whose shadow field is a single asterisk. Özgün Kültekin of Wakefield published the finding on 5 November 2026 as *sshole. OpenSSH 10.6p2 and Alpine packages through 3.16 shipped the same day. Alpine tiny-cloud images add passwordless doas on the default alpine user.

oxfemale November 5, 2026 33 min read 48 reads
Export PDF
*sshole: OpenSSH on musl Accepts Any Password Over 256 Bytes
Original text: “*sshole” — Özgün Kültekin of Wakefield (oz9un/sshole), 5 November 2026. Companion sources: Alpine Linux advisory, OpenSSH 10.6p2. Code, tables and figures below are reproduced verbatim with attribution captions.

Executive Summary

On 5 November 2026, Özgün Kültekin of Wakefield published sshhole.com and OpenSSH shipped portable 10.6p2 the same day. The finding is a password-authentication bypass on Linux systems that link sshd against musl libc and run it without PAM. Alpine Linux is the common case: the default openssh-server package is built without PAM, and PasswordAuthentication is still yes unless someone changed it. For any account whose shadow field is a single asterisk — the old Unix mark for “this account has no password and none should ever match” — a password of 257 to 1,024 bytes logs in. One connection. No retries. The short wrong password still fails, which is why a terminal capture of the bug looks almost ordinary until the long string goes through.

Five conditions have to hold together. musl. No PAM (or UsePAM no). Password logins still on. At least one account stored as *. At least one other local account with a real $5$ or $6$ hash, because that is where sshd borrows a salt. Alpine’s official tiny-cloud images from 3.18.11 through 3.24.3 ship an alpine user with * and passwordless doas, so the shell you get is effectively root once any SHA-crypt hash exists on the box. Debian, Ubuntu, Fedora, anything on glibc, Dropbear, OpenBSD, and PAM builds are outside the hole. Alpine backported packages through 3.16. Upgrading the package does not restart a running daemon. Neither Alpine nor OpenSSH assigned a CVE identifier in the 5 November notes.

The login that should have been impossible

The public demonstration is a two-prompt SSH session against a host named alpine-cloud. The first password is short and wrong. OpenSSH answers the way it always answers: Permission denied, please try again. The second password is a few hundred A characters. The banner comes back Welcome to Alpine! and id prints uid=0(root). AJ Stuyvenberg circulated a capture of that session on 5 November while the write-up at sshhole.com was spreading. The overlay in the screenshot below is a machine translation of his post; the terminal text is the demonstration itself.

Nothing in that session is a guessed password. The account is not supposed to take a password at all. On Alpine since 3.16, root ships as root:*. Key-only users created with adduser without a password look the same. Directory users that arrive through musl-nscd (nslcd, winbind, sssd) have no shadow entry and also present as *. The long string is not hashed into a matching SHA-512 digest. musl refuses to hash it, returns the error token *, and sshd compares that token to the stored field. Equal strings. Login succeeds.

*sshole research logo
Project mark from the companion repository github.com/oz9un/sshole. Source: github.com/oz9un/sshole (MIT License).

1980: the first star

In early Unix the password hash sat in /etc/passwd, in a file every process could read. In 1980 Bill Joy put a * in that field for an account named daemon. Nobody was supposed to log in as it. A star is not a hash, so no password can match it. The GECOS field on that line reads “The devil himself.” Unix still uses the same mark.

3BSD /etc/passwd daemon:*:1:1:The devil himself:/:
3BSD /etc/passwd, 1980. Address: github.com/dspinellis/unix-history-repo/blob/BSD-3/etc/passwd. Source: original article.

The convention survived the move to /etc/shadow. A modern SHA-512 hash looks like $6$abc$k2P9…: algorithm, salt, digest. sshd takes what you typed, hashes it with that salt, and compares. Same string, you are in. If the field is only *, there is nothing to match. Type whatever you want, it never gets you in. That is the contract the rest of this story spends forty-six years undoing, one correct patch at a time.

How sshd checks a password when PAM is out of the way

Most Linux distributions never hit this path. They build OpenSSH with PAM and leave UsePAM yes. pam_unix owns the password check. For an account locked with * it does not call crypt() on the candidate at all; it fails closed. Alpine’s openssh-server package is the other design. No PAM. sshd calls the C library itself.

The non-PAM path in portable OpenSSH lives in sys_auth_passwd() in auth-passwd.c. It loads the stored field, optionally through shadow_pw(). If the stored field is too short to hold a salt, it still has to hash something, for reasons that arrive in 2016. It then compares crypt(candidate, salt) to the stored field with strcmp. OpenSSH also caps a password at 1,024 bytes (MAX_PASSWORD_LEN). That cap is why the working window is 257 to 1,024, not “any huge string.”

POSIX said NULL

The Single UNIX Specification is blunt about failure. If crypt() cannot do the work — unknown algorithm, unusable salt, and so on — it returns a null pointer and sets errno. A careful caller checks for NULL and treats the attempt as a failure. A lot of callers never check. They compare whatever came back with the stored field, walk into NULL, and crash.

POSIX crypt() RETURN VALUE: null pointer on error
Single UNIX Specification, crypt(), 1997. Address: pubs.opengroup.org/onlinepubs/7908799/xsh/crypt.html. Source: original article.

2012: musl stops saying NULL

musl is the C library under Alpine, and under a long tail of embedded and container systems: OpenWrt, Buildroot, Yocto, postmarketOS, Void-musl, Chimera. In August 2012 commit b3c4cc12 changed crypt() so it would not return NULL on failure. The commit message says always succeeding but giving an unmatchable hash is reportedly a better behaviour than failing on error. The unmatchable hash they picked is a single character: *. If the stored setting is already *, they return x instead, so a failed crypt cannot match even that.

musl commit b3c4cc12: crypt returns unmatchable hash rather than NULL
musl b3c4cc12, August 2012. Address: git.musl-libc.org/cgit/musl/commit/?id=b3c4cc12. Source: original article.

The diff is three lines of intent. The comment Should not happen. goes away. return NULL; goes away. In their place: return (setting[0]=='*') ? "x" : "*";. Programs that never NULL-checked stop crashing. They see a wrong password. That is the whole point of the change.

musl crypt_des.c diff replacing return NULL with return *
musl b3c4cc12 diff in crypt_des.c. Source: original article.

Twenty days later, KEY_MAX 256

On 29 August 2012 Rich Felker added SHA-256 and SHA-512 crypt in commit 88bf5a8a. SHA-crypt gets slower as the password gets longer. A client that sends a giant key can keep the server busy. musl caps the key at 256 bytes and refuses anything longer. The cap is not part of the original SHA-crypt design; the source says it was added for DoS protection. if (i > KEY_MAX) return 0; — and a failed SHA-crypt, after b3c4cc12, is the string *.

musl commit 88bf5a8a adding SHA-256/SHA-512 crypt
musl 88bf5a8a, 29 August 2012. Address: git.musl-libc.org/cgit/musl/commit/?id=88bf5a8a. Source: original article.
musl crypt_sha512.c KEY_MAX 256 DoS cap
musl crypt_sha512.c at 88bf5a8a: KEY_MAX 256. Address: git.musl-libc.org/cgit/musl/tree/src/misc/crypt_sha512.c?id=88bf5a8a. Source: original article.
musl crypt_sha512.c header: on error * is returned
The failure token is in the file header. Address: git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_sha512.c. Source: original article.

glibc and libxcrypt took a different failure token. When Debian later moved to libxcrypt, the error string became *0. One extra character. That single character is why a glibc box with the same OpenSSH source does not have this hole: "*0" never equals the stored "*". musl’s token is the stored field.

2016: sshd borrows Bob’s salt

CVE-2016-6210 was a username-enumeration bug. Ask sshd about a user that does not exist and it said no immediately, because there was nothing to hash. A real user took a few milliseconds longer. Time the rejection, learn who exists. The July 2016 fix, shipped in OpenSSH 7.3p1, made invalid users expensive too. If there is no usable salt, pick_salt() walks the password database, steals a real account’s salt, and hashes the candidate anyway. Still a no. Just a slower no.

OpenSSH commit mail: Determine appropriate salt for invalid users
openssh-commits, July 2016. Address: lists.mindrot.org/pipermail/openssh-commits/2016-July/005428.html. Source: original article.

The mail says “for invalid users.” The same path runs for a real user whose stored field is too short to be a salt. A * account is a real user. sshd therefore hashes the attacker’s password against Bob’s $6$ salt, then compares the result to Alice’s stored *. On glibc the result is a long hash, or NULL, or *0, and the compare fails. On musl, if the password is longer than 256 bytes, the result is *.

Six lines

Kültekin’s companion repository states the compare in six assignments. This is the public mechanism, not a scanner. Alice is any account stored as *. Bob is any account with a SHA-crypt hash. The candidate is 300 A characters, inside OpenSSH’s 1,024-byte cap and over musl’s 256-byte KEY_MAX.

pw_password = "*"                          # alice has no password
salt = NULL                                # "*" is too short to be a salt
salt = pick_salt()                         # so sshd takes bob's: "$6$abc$"
hash = crypt("AAAA…x300", "$6$abc$")       # musl: key is over 256 bytes -> returns "*"
strcmp(hash, pw_password) == 0             # "*" == "*"
return 1                                   # welcome, alice

sshd has compared against * this way since the 1990s. musl started returning * on failure in 2012. sshd started using other accounts’ salts in 2016. The bug is the product of those three facts. Each change is defensible in isolation. Together, a locked account accepts a password.

2019 to 2022: Alpine’s fix for the fix

CVE-2019-5021 was an empty root password on Alpine Docker images. Alpine locked root with !. Without PAM, ! also disables SSH keys. Cloud images log in with keys. In 2022, Alpine 3.16 switched root to * in aports commit fbe926af. The commit message says this still forbids password login but allows logins via SSH keys with PAM disabled. Password login is what the star was invented to forbid. After 2016, on musl, it is also the field that a 257-byte candidate hashes to.

Alpine aports fbe926af: root:! becomes root:*
alpine/aports fbe926af, shipped in 3.16, 2022. Address: gitlab.alpinelinux.org/alpine/aports/-/commit/fbe926af. Source: original article.

OpenSSH’s default PermitRootLogin prohibit-password still keeps network password logins away from root itself. That default does not help if another account that can log in is able to become root. On the official tiny-cloud images that other account is named alpine.

tiny-cloud, passwordless doas

Alpine’s advisory is specific about the cloud artifacts. The official tiny-cloud images — qcow2, VHD, AMI — from 3.18.11 through 3.24.3 ship an alpine user with a * password field and passwordless doas. They are safe on first boot, because a fresh image has no SHA-crypt donor. They become exploitable as soon as any $6$ hash exists on the machine: an admin sets a console password, user-data adds a user with hashed_passwd, someone runs passwd once. An attacker then gets a shell as alpine and doas to root. Images built from 3.21.10, 3.22.8, 3.23.8, 3.24.4 and later include the fixed package. Running instances from earlier point releases do not, until they are upgraded and sshd is restarted.

cloud-init images that write PasswordAuthentication no are outside the hole. Docker users that never run sshd in the container are outside it too. The exposure is sshd on musl, not “every Alpine container on the internet.” That distinction is easy to lose in a headline and expensive to get wrong in an inventory.

Ten quiet years

Kültekin’s question is why nobody noticed between 2016 and 2026. OpenSSH’s own tests run on OpenBSD and glibc. musl does not test sshd. Most distributions go through PAM, which never calls crypt() for a * account. A fresh box with no passwords set is not affected, because there is no salt to borrow. Someone has to have run passwd once, and someone else has to send a password longer than 256 bytes to a * account. Pasting a private key or a Slack message into a password prompt would do it. A fresh box with only keys would not.

People got close. In 2013 Debian bug 714219 and a libc-alpha thread discussed glibc’s NULL crashing programs. A developer proposed copying musl’s *. The scheme of returning * or x, which are never valid matches, comes from dalias of musl, and was described as the best compromise. Six years later Debian moved to libxcrypt, which returns *0, and closed the bug. One character away.

libc-alpha 2013: returning * or x never valid matches, from dalias/musl
libc-alpha / Debian #714219, July 2013. Address: sourceware.org/legacy-ml/libc-alpha/2013-07/msg00046.html. Source: original article.

In December 2024 the musl mailing list discussed busybox passwd breaking on a 257-character password. Same *, same 256-byte limit. The thread is about a read-only error token and a segfault. Nobody says sshd.

musl mailing list December 2024: busybox passwd 257-character password
openwall.com/lists/musl/2024/12/31/2. Source: original article.

On 30 October 2026 an OpenSSH security review fixed fake password salt generation for bcrypt hashes, commit 84f8f4666. Kültekin writes that the bug sits four lines below that change, and that he found it four days later.

OpenSSH commit 84f8f4666: fix fake password salt generation for bcrypt hashes
openssh-commits, 30 October 2026. Address: lists.mindrot.org/pipermail/openssh-commits/2026-October/013271.html. Source: original article.

November 2026: a differential matrix, then a same-day release

Kültekin was looking at the boring libc-facing parts of OpenSSH, the password code people skip because they look at the crypto. He built sshd every way he could think of — glibc, musl, PAM, no PAM, shadow, no shadow — and threw about 17,600 odd logins at the matrix: weird hashes, broken accounts, passwords far too long. He used agent swarms to drive the matrix. Every build agreed on every login except musl without PAM. That one said yes to things it should refuse.

The public timeline in the repository is dated. 3 November: found, SHA-256 commitment tweeted from @kultekin. Same day: reported to openssh@openssh.com; a patch back in under an hour. 4 November: Alpine and musl notified. 5 November: OpenSSH 10.6p2, Alpine packages the same minute, sshhole.com. Damien Miller’s note on the release is that the team learned of the problem the day before, and that they now treat long embargoes as dangerous because they have watched AI agents rediscover the same bugs, sometimes within hours.

Six-panel timeline of the *sshole bug from 1980 through 2026
Original comic.svg from sshhole.com, rasterised at 2× because WordPress rejects SVG MIME. Panels: 1980 daemon gets a star; 2012 musl crypt returns star instead of NULL; 2016 OpenSSH borrows Bob’s salt; Alpine changes root from ! to star; 2016–2026 the years in between; 2026 a 300-character password yields uid 0. Source: original article.

The in-browser crypt demo

sshhole.com ships a client-side demo that never leaves the browser. Two columns, Debian/glibc against Alpine/musl. You type a password, or press a button for 256 As or 300 As. Both sides hash against Bob’s salt and compare to Alice’s stored *. At 256 bytes both still fail. At 300 bytes musl returns * and the Alpine column matches. glibc does not. That is the entire bug, without SSH in the loop. OpenSSH’s 1,024-byte cap is the other wall; past that, sshd will not even hand the string to crypt().

Who is affected

Kültekin’s checklist and Alpine’s advisory agree on five gates. All of them have to be true at once. The companion check.sh prints which gate is missing if you are not exposed.

  • sshd is running on musl (Alpine, OpenWrt, Buildroot, Yocto, postmarketOS, Void-musl, Chimera).
  • It is not using PAM. Alpine’s openssh-server is built that way. openssh-server-pam with UsePAM yes is safe. The same binary with UsePAM no is not: Damien Miller confirmed that a --with-pam build still hits the hole if PAM is turned off at runtime.
  • PasswordAuthentication is still yes. That is the OpenSSH default.
  • There is an account whose password field is exactly *: root on Alpine since 3.16, key-only users, LDAP/Winbind/SSSD users through musl-nscd, the alpine user on tiny-cloud.
  • Some other local account has a real $5$ or $6$ hash. One passwd is enough. That hash is the salt donor.

Then that * account takes any password between 257 and 1,024 bytes. On the official AWS and Azure Alpine images that account is alpine, and it has passwordless doas.

Who is not

  • Debian, Ubuntu, Fedora, RHEL, SUSE, and anything else on glibc.
  • Any sshd that actually uses PAM for the password check.
  • Systems with PasswordAuthentication no, including cloud-init images that write that setting.
  • Accounts locked with ! (a line starting user:!:). They cannot log in with a password or a key.
  • Systems where no account has a $-style password hash. The next passwd changes that.
  • Docker and other container users that do not run sshd in the container.
  • Dropbear. OpenBSD. FreeBSD. macOS.
  • Accounts that already have a real hash. The bypass does not crack or skip a $6$ password. It only equals the stored *.

Alpine checked every release from 3.16 through 3.24 and edge. The oldest combination Kültekin’s lab tried was OpenSSH 7.5p1 on musl 1.1.16 from 2017, and it worked there too. The salt-borrow behaviour shipped in 7.3p1. The GitHub tree keeps a Docker lab for local reproduction against Alpine 3.24; this article does not re-host that client.

Timeline

Every line in the repository timeline points at a commit or a mailing-list archive. Reproduced cell for cell from TIMELINE.md.

whenwhatwhere
1980Bill Joy puts a * in /etc/passwd for daemon. No password can match this.3BSD /etc/passwd
1990ssshd checks passwords with strcmp(crypt(pw, field), field). A * field can never match.OpenSSH auth-passwd.c
1997POSIX: crypt() returns NULL on error.SUS crypt()
2012-08-09musl: crypt() returns "*" instead of NULL on failure, “unmatchable hash”, because callers don’t null-check.musl b3c4cc12
2012-08-29musl adds SHA-256/SHA-512 crypt with a 256-byte key limit. Over the limit = failure = *.musl 88bf5a8a
2013-07Debian #714219: glibc’s NULL breaks programs; a dev proposes copying musl’s "*". Debian later moves to libxcrypt, which returns "*0". One character away.libc-alpha
2016-07-15CVE-2016-6210 timing fix: for accounts without a usable salt, sshd hashes with another account’s salt (pick_salt). Still compares against the stored field. The bug is born. Shipped in 7.3p1.OpenSSH 9286875a7, dbf788b4d
2019-05CVE-2019-5021: Alpine Docker images have an empty root password. Fixed by locking root with !.Alpine advisory
2022Alpine 3.16 changes root to * so SSH keys work again. Root is now a target on every Alpine image.aports fbe926af
2024-12musl list: busybox passwd segfaults on a 257-byte password (the read-only "*"). Limit and token kept. Nobody mentions sshd.musl list
2026-10-30OpenSSH security review fixes bcrypt handling in pick_salt(). Four lines above the bug.OpenSSH 84f8f4666
2026-11-03Found, via a differential auth matrix across libcs and build flags. SHA-256 commitment tweeted.tweet @kultekin
2026-11-03Reported to openssh@openssh.com. Patch back in under an hour.
2026-11-04Alpine, musl notified.
2026-11-05OpenSSH 10.6p2 released. Alpine packages same minute.release notes, fix 4353fdc43, aports d482fb50
2026-11-05sshhole.com
Public timeline from github.com/oz9un/sshole/TIMELINE.md. Source: github.com/oz9un/sshole (MIT License).

What to do on the box

Kültekin’s page puts the operator commands first, in the voice of someone already on the host. OpenSSH 10.6p2 is the upstream workaround. Alpine’s package upgrade is the distro one. Either path requires a restart of sshd. The package install does not replace a running daemon.

# update. openssh 10.6p2 fixes it.
$ apk upgrade openssh

# can't update yet? turn password logins off.
$ echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
$ rc-service sshd restart

Alpine’s advisory is stricter about older releases and about Match blocks. Confirm key login from a second session and keep the current one open. Then, as root:

apk upgrade -U -a
rc-service sshd restart

If you cannot update yet, the advisory writes a drop-in, rewrites every PasswordAuthentication yes in the main file including lines inside Match, tests the config, restarts, and prints the effective value. Alpine’s default sshd_config reads /etc/ssh/sshd_config.d from 3.19 onward; older releases ignore the drop-in, which is why the sed also edits the main file.

mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sed -i.bak -e '1i PasswordAuthentication no' \
  -e 's/^\(\s*PasswordAuthentication[^y#]*\)yes/\1no/i' \
  /etc/ssh/sshd_config
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication

The last command should print passwordauthentication no. To undo, delete the drop-in and copy /etc/ssh/sshd_config.bak back over /etc/ssh/sshd_config.

Fixed Alpine packages

Alpine 3.21.10, 3.22.8, 3.23.8 and 3.24.4 contain the fixed package. 3.21 reached end of support on 1 November 2026; they still published fixed packages back to 3.16. Table reproduced from the 5 November advisory.

BranchFixed openssh version
edge10.6_p2-r0
3.2410.3_p1-r2
3.2310.2_p1-r1
3.2210.0_p1-r11
3.219.9_p2-r1
3.209.7_p1-r6
3.199.6_p1-r3
3.189.3_p2-r4
3.179.1_p1-r7
3.169.0_p1-r6
Fixed openssh versions by Alpine branch. Source: Alpine Linux security advisory, 5 November 2026.

OpenSSH 10.6p2, called a workaround on purpose

Portable OpenSSH 10.6p2 was released on 5 November 2026. The security section is one paragraph:

sshd(8): 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)

OpenSSH 10.6p2 release notes

Damien Miller posted the operator instruction the same morning:

If you operate a Linux system that uses musl libc without PAM (Alpine Linux is one example) then stop reading and set PasswordAuthentication=no or disable SSH for these systems IMMEDIATELY. This vulnerability does not exist on non-Linux systems, Linux/glibc systems and Linux systems that use PAM for password authentication. 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, 5 November 2026

On oss-security, Loganaden Velvindron underlined the same point: the change is a workaround for musl behaviour, not a defect repaired inside OpenSSH’s own crypto. Coverage that frames 10.6p2 as “an OpenSSH password bug” misses why glibc boxes with the same daemon are fine. The compare in auth-passwd.c is old. The error token in musl is old. The salt borrow is old. The workaround is new: refuse to treat a stored field shorter than four characters as a hash that can match.

What 4353fdc43 actually changes

Commit 4353fdc43 is dated 5 November 2026, Damien Miller. The repository copies it unchanged as patch/4353fdc43-avoid-password-authn-bypass.patch, MIT-licensed along with the rest of oz9un/sshole, and notes it applies back to 7.3p1. Two things happen in sys_auth_passwd(). The stored field is copied with xstrdup so sshd does not keep a pointer into libc’s static crypt() buffer. The compare gains strlen(pw_password) >= MIN_PASSWORD_HASH_LEN with MIN_PASSWORD_HASH_LEN set to 4. A stored * is length 1. A stored ! is length 1. Neither can match, even if crypt() returns the same byte. shadow_pw() now always returns an allocated copy the caller must free with freezero.

From 4353fdc43ef646c43a8dda7025cc1a898caf1482 Mon Sep 17 00:00:00 2001
From: Damien Miller <djm@mindrot.org>
Date: Thu, 5 Nov 2026 08:18:18 +1100
Subject: [PATCH] avoid password authn bypass

This bypass affects some configurations and runtime configurations.
We do not consider the root cause to be within OpenSSH but we are
well equipped to avoid it.
---
 auth-passwd.c            | 17 +++++++++++++----
 openbsd-compat/port-uw.c | 16 ++++++++--------
 openbsd-compat/xcrypt.c  | 18 +++++++++++++-----
 3 files changed, 34 insertions(+), 17 deletions(-)

diff --git a/auth-passwd.c b/auth-passwd.c
index 37dba168c6da..5406e2d55a8a 100644
--- a/auth-passwd.c
+++ b/auth-passwd.c
@@ -50,6 +50,7 @@
 #include "ssherr.h"
 #include "log.h"
 #include "misc.h"
+#include "xmalloc.h"
 #include "servconf.h"
 #include "sshkey.h"
 #include "hostfile.h"
@@ -67,6 +68,7 @@ extern login_cap_t *lc;
 #define DAY		(24L * 60 * 60) /* 1 day in seconds */
 #define TWO_WEEKS	(2L * 7 * DAY)	/* 2 weeks in seconds */
 
+#define MIN_PASSWORD_HASH_LEN	4
 #define MAX_PASSWORD_LEN	1024
 
 /*
@@ -198,16 +200,20 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 	Authctxt *authctxt = ssh->authctxt;
 	struct passwd *pw = authctxt->pw;
 	char *encrypted_password, *salt = NULL;
+	int ret;
 
 	/* Just use the supplied fake password if authctxt is invalid */
-	char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
+	char *pw_password = authctxt->valid ?
+	    shadow_pw(pw) : xstrdup(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);
+	if (strcmp(pw_password, "") == 0 && strcmp(password, "") == 0) {
+		free(pw_password);
+		return 1;
+	}
 
 	/*
 	 * Encrypt the candidate password using the proper salt, or pass a
@@ -221,7 +227,10 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 	 * Authentication is accepted if the encrypted passwords
 	 * are identical.
 	 */
-	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;
 }
 #endif
diff --git a/openbsd-compat/port-uw.c b/openbsd-compat/port-uw.c
index 074f80c8d3d5..5c50ffa0fb87 100644
--- a/openbsd-compat/port-uw.c
+++ b/openbsd-compat/port-uw.c
@@ -58,14 +58,17 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 	int result;
 
 	/* Just use the supplied fake password if authctxt is invalid */
-	char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
+	char *pw_password = authctxt->valid ?
+	    shadow_pw(pw) : xstrdup(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);
+	if (strcmp(pw_password, "") == 0 && strcmp(password, "") == 0) {
+		free(pw_password);
+		return 1;
+	}
 
 	/* Encrypt the candidate password using the proper salt. */
 	salt = (pw_password[0] && pw_password[1]) ? pw_password : "xx";
@@ -83,11 +86,8 @@ sys_auth_passwd(struct ssh *ssh, const char *password)
 #endif /* UNIXWARE_LONG_PASSWORDS */
 		result = (strcmp(xcrypt(password, salt), pw_password) == 0);
 
-#ifdef USE_LIBIAF
-	if (authctxt->valid)
-		free(pw_password);
-#endif
-	return(result);
+	freezero(pw_password, strlen(pw_password));
+	return result;
 }
 
 #ifdef UNIXWARE_LONG_PASSWORDS
diff --git a/openbsd-compat/xcrypt.c b/openbsd-compat/xcrypt.c
index 4e0d0d64414a..80b13a5c4b67 100644
--- a/openbsd-compat/xcrypt.c
+++ b/openbsd-compat/xcrypt.c
@@ -25,6 +25,8 @@
 #include "includes.h"
 
 #include <sys/types.h>
+#include <stdarg.h>
+#include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
 #include <pwd.h>
@@ -59,6 +61,8 @@
 #  define crypt DES_crypt
 # endif
 
+#include "xmalloc.h"
+
 #define MINIMUM(a, b)	(((a) < (b)) ? (a) : (b))
 
 /*
@@ -71,7 +75,7 @@ static const char *
 pick_salt(void)
 {
 	struct passwd *pw;
-	char *passwd, *p;
+	char *passwd = NULL, *p;
 	size_t typelen;
 	static char salt[32];
 
@@ -80,6 +84,8 @@ pick_salt(void)
 	strlcpy(salt, "xx", sizeof(salt));
 	setpwent();
 	while ((pw = getpwent()) != NULL) {
+		if (passwd != NULL)
+			freezero(passwd, strlen(passwd));
 		if ((passwd = shadow_pw(pw)) == NULL)
 			continue;
 		if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
@@ -97,6 +103,8 @@ pick_salt(void)
 		}
 	}
  out:
+	if (passwd != NULL)
+		freezero(passwd, strlen(passwd));
 	endpwent();
 	return salt;
 }
@@ -129,9 +137,8 @@ xcrypt(const char *password, const char *salt)
 
 /*
  * Handle shadowed password systems in a cleaner way for portable
- * version.
+ * version. Caller must free returned value.
  */
-
 char *
 shadow_pw(struct passwd *pw)
 {
@@ -145,7 +152,8 @@ shadow_pw(struct passwd *pw)
 # endif
 
 #ifdef USE_LIBIAF
-	return(get_iaf_password(pw));
+	pw_password = get_iaf_password(pw);
+	return pw_password == NULL ? NULL : xstrdup(pw_password);
 #endif
 
 # if defined(HAVE_GETPWANAM) && !defined(DISABLE_SHADOW)
@@ -159,5 +167,5 @@ shadow_pw(struct passwd *pw)
 		pw_password = spw->ufld.fd_encrypt;
 # endif
 
-	return pw_password;
+	return pw_password == NULL ? NULL : xstrdup(pw_password);
 }

Am I exposed: check.sh

The public check is a shell script meant to run as root on the host you are worried about. Exit 0 means exposed, 1 means not, 2 means the script could not tell. It looks for musl, for a version string that already contains 10.6p2, for libpam on the listener and on sshd-auth/sshd-session (the password check moved out of the listener in OpenSSH 10.0), for the effective PasswordAuthentication and UsePAM from sshd -T, for accounts whose second colon-field is exactly *, for nsswitch directory sources, and for a donor $5$/$6$ hash. A missing donor or a missing star prints “not exposed right now” and tells you the next passwd or adduser -D changes that. Reproduced in full from the MIT tree.

curl -sO https://raw.githubusercontent.com/oz9un/sshole/main/check.sh
sudo sh check.sh
libc                         musl
sshd                         OpenSSH_10.3p1, OpenSSL 3.5.9 29 Sep 2026
pam                          off (not built in)
PasswordAuthentication       yes
accounts with '*'            root alice deploy
sha-crypt hash present       yes (bob)

EXPOSED. anyone can log in as: root alice deploy
fix: apk upgrade openssh (10.6p2), or set PasswordAuthentication no until you can.
#!/bin/sh
# am i an *sshole?  run as root on the host you're worried about.
# exits 0 = exposed, 1 = not exposed, 2 = can't tell
# https://sshhole.com

# alpine ships two flavours; /etc/conf.d/sshd picks one
flavor=$(grep -E '^sshd_flavor=' /etc/conf.d/sshd 2>/dev/null | cut -d= -f2 | tr -d '"')
sshd=""
for c in "/usr/sbin/sshd${flavor:+.$flavor}" /usr/sbin/sshd /usr/local/sbin/sshd "$(command -v sshd 2>/dev/null)"; do
  [ -n "$c" ] && [ -x "$c" ] && { sshd=$c; break; }
done
[ -z "$sshd" ] && { echo "no sshd here. you're fine."; exit 1; }
# since 10.0 the password check lives in sshd-session / sshd-auth, not the listener
suffix=${sshd#/usr/sbin/sshd}
auth_bins=$(ls /usr/lib/ssh/sshd-auth$suffix /usr/lib/ssh/sshd-session$suffix /usr/lib/openssh/sshd-auth /usr/lib/openssh/sshd-session /usr/libexec/openssh/sshd-auth /usr/libexec/openssh/sshd-session /usr/libexec/sshd-auth /usr/libexec/sshd-session 2>/dev/null)
bins="$sshd $auth_bins"

say() { printf '%-28s %s\n' "$1" "$2"; }
exposed=1 unsure=0

# 1. musl?
if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1 || grep -qa 'ld-musl' "$sshd" 2>/dev/null; then
  say "libc" "musl"
  musl=1
else
  say "libc" "not musl (glibc, uclibc, bsd...)"
  musl=0
fi

# 2. version
ver=$("$sshd" -V 2>&1 | head -1)
say "sshd" "$ver"
case "$ver" in
  *OpenSSH_10.6p2*|*OpenSSH_10.[7-9]*|*OpenSSH_1[1-9]*) fixed=1 ;;
  *OpenSSH_*) fixed=0 ;;
  *) fixed=0; unsure=1 ;;
esac
# distros may backport without bumping the version; alpine bumps it, others say so in their changelog.

# 3. pam?
pam_built=0
for b in $bins; do
  if ldd "$b" 2>/dev/null | grep -q libpam || grep -qa 'libpam\.so' "$b" 2>/dev/null; then pam_built=1; fi
done
cfg=$("$sshd" -T 2>/dev/null)
[ -z "$cfg" ] && cfg=$("$sshd" -T -C user=root,host=localhost,addr=127.0.0.1 2>/dev/null)
if [ -z "$cfg" ]; then  # no host keys yet (fresh container): read the config file instead
  f=/etc/ssh/sshd_config
  pa=$(grep -hiE '^[[:space:]]*PasswordAuthentication[[:space:]]' $f /etc/ssh/sshd_config.d/*.conf 2>/dev/null | tail -1 | awk '{print tolower($2)}')
  up=$(grep -hiE '^[[:space:]]*UsePAM[[:space:]]' $f /etc/ssh/sshd_config.d/*.conf 2>/dev/null | tail -1 | awk '{print tolower($2)}')
  cfg="passwordauthentication ${pa:-yes}
usepam ${up:-no}"
  unsure=1
fi
usepam=$(printf '%s\n' "$cfg" | awk '$1=="usepam"{print $2}')
pwauth=$(printf '%s\n' "$cfg" | awk '$1=="passwordauthentication"{print $2}')
if [ $pam_built = 1 ] && [ "$usepam" = yes ]; then
  say "pam" "built in and on"
  pam=1
else
  say "pam" "off ($( [ $pam_built = 1 ] && echo 'built in, UsePAM no' || echo 'not built in'))"
  pam=0
fi
say "PasswordAuthentication" "${pwauth:-unknown}"

# 4. targets: accounts with exactly "*"
stars=$(awk -F: '$2=="*"{print $1}' /etc/shadow 2>/dev/null | tr '\n' ' ')
pstars=$(awk -F: '$2=="*"{print $1}' /etc/passwd 2>/dev/null | tr '\n' ' ')
targets="$stars$pstars"
say "accounts with '*'" "${targets:-none}"
# directory users via nss (nslcd/winbind/sssd) all look like "*" on musl
if grep -qE '^passwd:.*(ldap|winbind|sss)' /etc/nsswitch.conf 2>/dev/null; then
  say "directory users" "yes (nsswitch) - every one of them is a target"
  targets="$targets directory-users"
fi

# 5. donor: a local $5$/$6$ hash
donor=$(awk -F: '$2 ~ /^\$[56]\$/{print $1; exit}' /etc/shadow 2>/dev/null)
if [ -n "$donor" ]; then say "sha-crypt hash present" "yes ($donor)"; else say "sha-crypt hash present" "no"; fi

echo
if [ $fixed = 1 ]; then
  echo "not exposed: sshd has the fix."
elif [ $musl = 0 ]; then
  echo "not exposed: not musl."
elif [ $pam = 1 ]; then
  echo "not exposed: pam does the password check."
elif [ "$pwauth" = no ]; then
  echo "not exposed: password auth is off. (update anyway.)"
elif [ -z "$targets" ]; then
  echo "not exposed right now: no '*' accounts. the next 'adduser -D' changes that. update."
elif [ -z "$donor" ]; then
  echo "not exposed right now: no sha-crypt hash on the box. the next 'passwd' changes that. update."
else
  echo "EXPOSED. anyone can log in as: $targets"
  echo "fix: apk upgrade openssh (10.6p2), or set PasswordAuthentication no until you can."
  exposed=0
fi
[ $unsure = 1 ] && [ $exposed = 1 ] && { echo "(some checks couldn't run; treat this as 'probably'.)"; exit 2; }
exit $exposed

Hunting leftovers

Alpine’s own detection advice is a log line. Look in /var/log/messages for sshd lines reading Accepted password for an account that has no password: alpine, root, or any key-only user. Then review doas use and recently created users, files, and authorized keys on exposed machines. If you cannot rule out a login, treat the machine as compromised. The write-up’s punchline version of that test is the same idea: a line that says a star-account took a password.

# leftovers only: password accepted for an account that should not have one.
# run on the host. this is not a scanner.

awk -F: '$2=="*"{print $1}' /etc/shadow
awk -F: '$2 ~ /^\$[56]\$/{print $1}' /etc/shadow

grep -E 'sshd\[.*\]: Accepted password for ' /var/log/messages /var/log/auth.log 2>/dev/null
# interesting when the user on that line is also in the first awk output.

sshd -T | awk 'tolower($1) ~ /passwordauthentication|usepam|permitrootlogin/'
# passwordauthentication no  -> path closed even before the package bump
# usepam yes                 -> PAM owns the check; Alpine default openssh-server will not show this

ls /lib/ld-musl-*.so.1 2>/dev/null && echo musl || echo not-musl

Successful key logins for the same users are expected on cloud images and are not this bug. The signal is a password accept for an account whose shadow field is *. Busybox syslog on Alpine often lands in /var/log/messages rather than /var/log/auth.log. Hosts that ship logs to a collector should search the same string there. A single accept is enough; the attack does not need retries, so brute-force dashboards will not light up.

CWE, ATT&CK, and what this is not

The failure is authentication that succeeds because two independent error paths produced the same one-byte string. CWE-287 (Improper Authentication) is the user-visible class. CWE-755 (Improper Handling of Exceptional Conditions) is musl returning a sentinel that collides with a lockout mark, and sshd treating that sentinel as a hash. CWE-697 (Incorrect Comparison) is the strcmp against a field that is not a hash. CWE-20 sits underneath the missing length check on the stored field before the compare. OpenSSH 10.6p2 fixes the last of those in sshd. It does not change musl.

IDNameWhere it sits
CWE-287Improper AuthenticationA locked account accepts a network password.
CWE-755Improper Handling of Exceptional Conditionsmusl crypt() failure token is the lockout mark.
CWE-697Incorrect Comparisonstrcmp of crypt() output against a 1-byte stored field.
CWE-20Improper Input ValidationNo minimum length on the stored hash before compare; 10.6p2 adds MIN_PASSWORD_HASH_LEN 4.
CVE-2016-6210OpenSSH user enumerationThe 2016 salt-borrow that feeds musl a real $6$ salt for * accounts.
CVE-2019-5021Alpine empty root passwordThe 2019 lock that later became root:* so keys still worked.
Classification against the public write-up and the two historical CVEs it depends on.

In ATT&CK terms the login is Valid Accounts (T1078) over SSH (T1021.004). It is not Password Guessing in the usual sense: T1110.001 is the closest technique because a password is sent, but there is no dictionary and no retry loop. One string, one connection. On tiny-cloud the follow-on is Sudo and Sudo Caching (T1548.003) via passwordless doas. There is no persistence primitive in the bug itself. Persistence after a successful login is whatever the operator does next: an authorized_keys line, a new user, a crontab. Those are the artifacts to hunt.

This is not a cryptanalytic break of SHA-512. It is not a bypass of a real $6$ password. It is not Dropbear, not OpenBSD, not glibc, not PAM. It is not pre-auth RCE. The attacker needs a reachable sshd, password authentication still on, a name of a * account, and a donor hash already on the box. Usernames on Alpine cloud images are not a secret: alpine and root are the ones the advisory names.

What the GitHub tree deliberately leaves out

oz9un/sshole is small on purpose. README says the full test setup — 17 environments, a cloud image in qemu, OpenWrt, Buildroot, LDAP and AD users, prevalence numbers, a per-distro table — lives in the write-up and may be published later. What is in the tree today: check.sh, the upstream patch, and a Docker lab that builds stock Alpine 3.24 with openssh-server, an alice account stored as *, and a bob account with a real password. The lab client sends a short reject then 300 As. attack.py with paramiko is the length slider: 256 rejected, 257 accepted. That client stays on GitHub. This page does not re-host it, and it is not a mass-scan tool.

The public terminal capture already shows the same two-prompt shape against root@alpine-cloud. Anyone reproducing in a lab they own can use the repository’s Docker file. Pointing that client at third-party hosts is the usual unauthorised-access problem, and it is not what the disclosure is for.

Key Takeaways

  • On musl sshd without PAM, any password of 257 to 1,024 bytes logs in to accounts whose shadow field is *. One connection, no retries.
  • Five conditions, not three: musl, no PAM, PasswordAuthentication yes, a * account, and a donor $5$/$6$ hash.
  • Alpine openssh-server is built that way by default. tiny-cloud images 3.18.11–3.24.3 add passwordless doas on the alpine user.
  • glibc, PAM, Dropbear, OpenBSD, PasswordAuthentication no, and accounts locked with ! are outside the hole.
  • The collision is musl’s crypt() failure token * against the Unix lockout mark *, after OpenSSH’s 2016 salt-borrow (CVE-2016-6210).
  • OpenSSH 10.6p2 is a workaround in sshd (MIN_PASSWORD_HASH_LEN 4). Alpine backported packages from edge through 3.16. Restart sshd after the upgrade.
  • Hunt Accepted password for on accounts that have no password. Brute-force counters will not move.

Defensive Recommendations

  1. Inventory every host and image that runs sshd on musl: Alpine VMs, tiny-cloud AMIs/VHD/qcow2, OpenWrt, Buildroot, Yocto, musl Docker images that actually start sshd. Skip glibc fleets.
  2. On those hosts run check.sh from github.com/oz9un/sshole, or the equivalent five-gate checks by hand. Treat “not exposed right now” (no donor, no star) as a reason to patch anyway.
  3. Upgrade openssh to the branch’s fixed package and restart sshd. Confirm sshd -V and, on Alpine, the apk version from the advisory table.
  4. If you cannot upgrade in the same change window, set PasswordAuthentication no, including inside Match blocks, prove a new key login, then restart. Keep the old session open until that proof.
  5. On tiny-cloud, remove passwordless doas for the default user, or replace * with ! on accounts that must not accept keys either. Prefer key-only with PermitRootLogin prohibit-password.
  6. Rebuild and redeploy container images that install openssh-server. A patched builder does not patch a running container until it is replaced.
  7. Search logs for Accepted password for on alpine, root, and other star-accounts. If you find one, rotate keys, inspect authorized_keys, cron, and new users, and treat the host as compromised.
  8. Prefer openssh-server-pam with UsePAM yes where PAM is acceptable. A --with-pam binary with UsePAM no is still in scope.

Conclusion

A 1980 lockout mark, a 2012 crash-avoidance token, a 2012 DoS cap, and a 2016 timing fix are each reasonable. On musl without PAM they compare equal. Özgün Kültekin showed that with a matrix of 17,600 logins and a page of receipts; Damien Miller and Darren Tucker shipped a four-character minimum on the stored field the next day; Natanael Copa put packages on every Alpine branch back to 3.16 the same afternoon. The remaining work is operational: find sshd on musl, restart it after the upgrade, and read the log for a password accept that should never have happened. The long string in the public capture is not a clever password. It is the error value of crypt(), and on these boxes that error value was already the account.

Original text: “*sshole” by Özgün Kültekin of Wakefield; oz9un/sshole (MIT License); Alpine Linux advisory (5 November 2026); OpenSSH 10.6p2.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion