# Sshole: why a locked account can still pass OpenSSH password authentication

> OpenSSH 10.6p2 works around a password authentication bypass on musl systems without PAM. What has to line up, why it happens, and how to check your hosts.

Source: https://sheng.page/en/posts/sshole-musl-openssh-auth-bypass/ · Published: 2026-11-05 · Author: Sheng

OpenSSH 10.6p2, released on 5 November 2026, works around a password authentication bypass. The conditions are narrow: the host uses musl libc, sshd does not use PAM (built without it, or built with it and running with `UsePAM no`), password logins are enabled, and the target account's password field in /etc/shadow is a single `*`. Alpine's openssh-server ships in exactly that configuration, so Alpine is the obvious place to look, but plenty of Alpine machines and containers are not exposed. Treating every Alpine host as compromised is as wrong as assuming you are safe because the upstream version number hasn't moved.

OpenSSH calls the release a workaround: the root cause sits outside sshd, but sshd is where it can be exploited, so that is where it is blocked. Linux systems on glibc are unaffected, as are non-Linux systems and anything that checks passwords through PAM. The notes say the release came quickly because bugs found with AI tools have recently been rediscovered independently soon afterwards, and because attackers who don't report bugs may find them too, so fixes will now ship when they are ready instead of waiting for the next planned release. Sources were checked up to 22:00 Taipei time on 5 November 2026, with the OpenSSH release notes, the Alpine advisory and the original write-up treated as authoritative.

## What has to line up

All six conditions must hold at once.

| Component | Required state |
| --- | --- |
| libc | musl (the Alpine default) |
| sshd | Built without PAM, or openssh-server-pam with `UsePAM no` |
| Password logins | `PasswordAuthentication yes` (the OpenSSH default) |
| Target account | Password field in /etc/shadow is a single `*` |
| Another account | At least one local account with a `$5$` or `$6$` hash |
| Input | A password between 257 and 1024 bytes long |

Any one of these takes a host off the path: `PasswordAuthentication no`, `openssh-server-pam` with `UsePAM yes`, a password field starting with `!`, or glibc instead of musl.

## Three reasonable behaviours that add up to a bypass

When the key (here, the password) is longer than 256 bytes, musl's `crypt()` returns the string `*` as an error marker. A value that can never be a valid hash means a failed computation can never match a real one.

In /etc/shadow, a `*` in the password field means the account has no password login. Key-only accounts use it, and so does root on a default Alpine install. The idea is the same: a value that is never a hash stands for "nothing to compare against".

To stop user enumeration (CVE-2016-6210, where sshd answered faster for accounts that did not exist), OpenSSH started hashing the supplied password with a salt borrowed from another real account, so every attempt takes about the same time. Real accounts whose password field is `*` go down the same path, which is why the host also needs some account with a `$5$` or `$6$` hash for sshd to borrow from.

The collision is that both sides use the same `*`. musl did guard against this: call `crypt()` with `*` as the salt and it returns `x`, so as long as the caller hashes with a string and then compares against that same string, nothing collides. sshd breaks that assumption. For an account whose password field is one character long, it hashes with the borrowed `$6$` salt and then compares the result with the original `*`. Send a password of 257 to 1024 bytes, past musl's 256-byte limit, and `crypt()` returns `*`, which equals the password field. A failed hash is accepted as a successful match.

It is the same mistake I wrote about in [Only 86 Million Ways to Shuffle: What Game RNG Certification Checks](/en/posts/game-rng-certification/): check the random source and the output separately. Audit musl, shadow and OpenSSH on their own and each holds up, contract included. The hole is at the boundary, where an error signal and a legitimate value share one string. OpenSSH says the root cause isn't in sshd and blocks it there anyway. Whose bug it is can wait; whoever sits on the exploitable side closes it first.

## The pieces were already public

Everything this path needs had shown up in public before. In 2013 someone opened a Debian bug (#714219) asking for glibc's `crypt()` to return `*` on failure, as musl does; Debian later moved to libxcrypt, which returns `*0`, one character off, and the bug was closed. In December 2024 the musl mailing list discussed BusyBox `passwd` failing on a 257-character password, the same `*` behaviour, without anyone connecting it to sshd. In October 2026 OpenSSH committed a fix to `pick_salt()`. According to the original write-up, this bug sits a few lines below it, and the researcher found it four days later.

None of these looked like a vulnerability on its own. The write-up's own conclusion is careful: "as far as anyone knows, nobody noticed."

## Checking your own hosts

Before rebuilding every Alpine box, work through these:

1. Run `ldd` against sshd; `ld-musl` means musl.
2. You are in scope if the package has no PAM files, or if `usepam` in `sshd -T` is not `yes`.
3. Check `passwordauthentication` in `sshd -T`.
4. Look for any account whose password field is a single `*`. Accounts created with `adduser -D` on Alpine 3.22 get `!`, which is off the path.
5. Confirm there is another account with a `$5$` or `$6$` hash. sshd borrows its salt for the hash, and without one the path doesn't work.

Root is not necessarily reachable by password, because `PermitRootLogin` still applies. The machine tested below uses `without-password`, so root cannot log in with a password even with `*` in its field. An ordinary account that can log in and has passwordless `doas` is another matter, and the default `alpine` user on Alpine's official AWS and Azure images has exactly that.

```sh
# Run on the host you are checking; every command is read-only
ldd "$(command -v sshd)" | grep -i musl
sshd -T 2>/dev/null | grep -iE '^(passwordauthentication|usepam|permitrootlogin)'
awk -F: '$2=="*"{print $1}' /etc/shadow
```

Output from Alpine 3.19 (aarch64, openssh-server 9.6_p1-r3) in OrbStack. That image already carries 9.6_p1-r3, the fixed version listed in the advisory, so it shows the checks rather than an unpatched host.

```text
/lib/ld-musl-aarch64.so.1 (0xffff95de0000)
libc.musl-aarch64.so.1 => /lib/ld-musl-aarch64.so.1 (0xffff95de0000)
permitrootlogin without-password
passwordauthentication yes
root
```

This build has no PAM, so `sshd -T` prints no `usepam` line, and adding `UsePAM no` to the config makes `sshd -t` fail with "Unsupported option UsePAM". A PAM-enabled build prints the line, and only `UsePAM yes` keeps it off this path. The `root` on the last line is the account with a single `*`.

If you are exposed, or want to stop the bleeding first, upgrade `openssh` and restart `sshd`. The fixed sshd checks the length of the password field after computing the hash and fails anything of three characters or fewer (markers such as `*` and `!`), so the timing protection stays intact. As the Alpine advisory points out, upgrading the package does not replace the daemon that is already running. If sshd runs inside a container, such as a bastion or an SFTP or Git server, rebuild from a fresh base image and redeploy.

If you can't upgrade yet, set `PasswordAuthentication no` and restart `sshd`. Check the result with `sshd -T -C user=…,host=…,addr=…`, because overrides in a `Match` block only show up with `-C`; without it you see the global value. If the aim is to turn off password logins entirely, a PAM-enabled host also needs `KbdInteractiveAuthentication no`, or simply `AuthenticationMethods publickey`, otherwise passwords still get in through keyboard-interactive. Keep your current session open and test key login first so you don't lock yourself out.

Distributions usually backport the fix to older branches, so look at the installed package version rather than the upstream release number. Every Alpine branch from 3.16 to edge has its own `-rN`.

To see whether anyone has used the path, look for `Accepted password for` in `/var/log/messages`. That line for an account whose password field is `*` means someone got in this way. There is no reliable evidence of exploitation in the wild so far.

## CVE and credit

As of the primary sources, no new CVE has been assigned. The Alpine advisory says "No CVE identifier had been assigned at the time of writing", and neither the OpenSSH notes nor the write-up give one. CVE-2016-6210, mentioned above, is the 2016 user-enumeration bug and only background. If you see a 2026 CVE attached to this issue that the primary sources don't mention, treat it with suspicion.

The reporter is Özgün Kültekin of Wakefield (wakefield.co). Under the disclosure post, someone replied that they remembered a very similar SSH bug from about ten years ago, but gave no reference and did not claim this one, so it doesn't count as an independent second discovery. The 2024 musl thread is likewise a separate matter from this sshd bypass.

## Reproduce it in a container

To see the key behaviours yourself, run this in a throwaway container. It only calls library functions, checks configuration and disassembles one function. It never starts sshd or logs in, and the container is removed afterwards.

```sh
docker run --rm alpine:3.22 sh -c '
apk add --no-cache openssh-server openssh-dbg gdb build-base >/dev/null 2>&1
cat /etc/alpine-release
apk info -v 2>/dev/null | grep -E "^(musl|openssh-server)-[0-9]"

# 1. musl crypt(): * as the salt returns x; a borrowed $6$ salt with an over-long password returns *
cat > /tmp/c.c <<\EOF
#include <crypt.h>
#include <stdio.h>
#include <string.h>
int main(void) {
	char p[300];
	memset(p, 65, 5);   p[5] = 0;   printf("crypt(5B, \"*\")         -> %s\n", crypt(p, "*"));
	memset(p, 65, 256); p[256] = 0; printf("crypt(256B, \"$6$abc$\") -> len %zu\n", strlen(crypt(p, "$6$abc$")));
	memset(p, 65, 257); p[257] = 0; printf("crypt(257B, \"$6$abc$\") -> %s\n", crypt(p, "$6$abc$"));
	return 0;
}
EOF
gcc /tmp/c.c -o /tmp/c && /tmp/c

# 2. adduser -D writes !, not *
adduser -D demo && grep ^demo: /etc/shadow | cut -d: -f1,2

# 3. A build without PAM rejects UsePAM
ssh-keygen -A >/dev/null 2>&1
echo "UsePAM no" > /etc/ssh/sshd_config.d/01-pam.conf
sshd -t 2>&1 | head -1
rm /etc/ssh/sshd_config.d/01-pam.conf

# 4. Match overrides only show up with -C
printf "PasswordAuthentication no\nMatch Address 127.0.0.1\n\tPasswordAuthentication yes\n" >> /etc/ssh/sshd_config
sshd -T | grep -i ^passwordauthentication
sshd -T -C user=demo,host=localhost,addr=127.0.0.1 | grep -i ^passwordauthentication

# 5. The fixed build calls strlen and compares with 3 before strcmp (aarch64 disassembly)
gdb -batch -ex "disassemble sys_auth_passwd" /usr/lib/ssh/sshd-auth 2>/dev/null | grep -E "xcrypt|strlen|#0x3|strcmp"
'
```

Output on OrbStack (aarch64):

```text
3.22.7
musl-1.2.5-r13
openssh-server-10.0_p1-r11
crypt(5B, "*")         -> x
crypt(256B, "$6$abc$") -> len 93
crypt(257B, "$6$abc$") -> *
demo:!
/etc/ssh/sshd_config.d/01-pam.conf line 1: Unsupported option UsePAM
passwordauthentication no
passwordauthentication yes
   0x000000000000a67c <+148>:	bl	0x80a9c <xcrypt>
   0x000000000000a688 <+160>:	bl	0x7200 <strlen@plt>
   0x000000000000a694 <+172>:	cmp	x0, #0x3
   0x000000000000a6a4 <+188>:	bl	0x7fe0 <strcmp@plt>
```

Addresses and instructions differ on x86_64; what matters is that `strlen` sits between `xcrypt` and `strcmp`.

## Notes

- Every condition has to hold: musl, sshd without PAM, password logins on, a target account with a single `*`, another account with a `$5$` or `$6$` hash to borrow a salt from, and a password of 257 to 1024 bytes.
- The cause is two identical `*` values: musl's error return matches shadow's "no password" marker, so an error signal and a legitimate value share one string.
- 10.6p2 is a workaround. The root cause is outside sshd, but it is blocked there, and glibc, non-Linux and PAM-based systems are unaffected.
- To check a host, look at musl, PAM, `PasswordAuthentication`, `*` password fields and an account to borrow a salt from, plus `PermitRootLogin` for root. Restart sshd after upgrading and verify settings with `sshd -T -C`.
- No new CVE is confirmed, CVE-2016-6210 is background only, and there is no reliable evidence of exploitation in the wild. The reporter is Özgün Kültekin (Wakefield).

## Further reading

- [OpenSSH 10.6p2 release notes](https://www.openssh.org/txt/release-10.6p2): the primary announcement, with affected configurations, credit, the workaround framing and the reason for more frequent releases.
- [Alpine Linux security advisory: openssh-server password authentication bypass](https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/): fixed versions per branch, conditions and interim steps; notes that no CVE had been assigned.
- [The sshole write-up](https://sshhole.com/): the reporter's explanation of musl's return value, the shadow convention and the link to CVE-2016-6210.
- [LWN coverage](https://lwn.net/Articles/1105464/): an independent report with background and timeline.
- [musl mailing list thread, December 2024](https://www.openwall.com/lists/musl/2024/12/31/2): long passwords and `crypt()` returning `*`; nobody linked it to sshd at the time.
- [OpenSSH `pick_salt()` commit, October 2026](https://lists.mindrot.org/pipermail/openssh-commits/2026-October/013271.html): the write-up says this bug sits near this code; it is not a co-discovery.
- [CVE-2016-6210 (NVD)](https://nvd.nist.gov/vuln/detail/CVE-2016-6210): the 2016 user-enumeration bug, background only.

> Ideas and technical judgement by Sheng; drafted with Claude · Research by Grok, second review of the Chinese version by Codex (gpt-6-astra), tone edits by Grok; checks run in OrbStack on Alpine 3.19 (openssh-server 9.6_p1-r3) and 3.22 (musl 1.2.5-r13, openssh-server 10.0_p1-r11), aarch64; sources checked to 22:00 Taipei on 5 November 2026.