Today, we examine a severe vulnerability involving SSH that lets hackers log in to a server without a password. The good news is that this security flaw does not exist on most servers, but only on servers with specific conditions. A server is potentially contains this vulnerability when all the following conditions are met:
- Alpine Linux: Versions 3.16 through 3.24 and Edge
- OpenSSH: versions before 10.6p2
- PAM disabled
- Password authentication: ON (allow user to login to server using password).
- An account has exactly
*in its password field in/etc/shadow. The*in/etc/shadowfile indicates that this account has no usable Unix password hash, meaning this user cannot login using password. - Another account with a valid password.
You can use Docker to spin up a server with this vulnerability by using below script:
FROM alpine:3.24.1# Install the pre-fix OpenSSH package revision.ARG ALPINE_MAIN_REPO=https://dl-cdn.alpinelinux.org/alpine/v3.24/mainARG OPENSSH_VERSION=10.3_p1-r1RUN set -eux; \ apk update; \ case "$(apk --print-arch)" in \ x86_64) ARCH=x86_64 ;; \ aarch64) ARCH=aarch64 ;; \ *) echo "Unsupported architecture"; exit 1 ;; \ esac; \ for PACKAGE in openssh-server-pam openssh-server-common openssh-keygen; do \ wget -O "/tmp/${PACKAGE}.apk" \ "${ALPINE_MAIN_REPO}/${ARCH}/${PACKAGE}-${OPENSSH_VERSION}.apk"; \ done; \ apk add /tmp/*.apk; \ rm -f /tmp/*.apk; \ ssh-keygen -A# Configure the vulnerable authentication path.RUN { \ echo "PasswordAuthentication yes"; \ echo "UsePAM no"; \ cat /etc/ssh/sshd_config; \ } > /tmp/sshd_config \ && mv /tmp/sshd_config /etc/ssh/sshd_config# Create a passwordless target account.RUN adduser -D alpine \ && sed -i 's/^alpine:[^:]*:/alpine:*:/' /etc/shadow# Create another account with a valid password hash.RUN adduser -D authsource \ && echo 'authsource:lab-only-password' | chpasswdEXPOSE 22CMD ["/usr/sbin/sshd.pam", "-D", "-e"]
Then try logging in to that server as the user alpine using this command (note that the alpine user has no usable password and is not intended for login! )
ssh \ -o StrictHostKeyChecking=no \ -o PreferredAuthentications=password \ -o PubkeyAuthentication=no \ -p 2222 alpine@localhost
When it prompted for a password, enter a string longer than 256 characters twice. The expected result is successful login despite the target account having no usable password.
1. How did that happen?
This false authentication stems from a bad programming practice in error handling. The problematic behavior originates in musl libc, specifically its crypt() function, which is responsible for generating password hashes and verifying password credentials. Musl is a lightweight implementation of the standard C library and serves as an alternative to GNU C Library (glibc), which is commonly used by distributions such as Ubuntu and Debian.
Alpine Linux adopts musl instead of glibc primarily to keep the distribution small, lightweight, and resource-efficient. This design makes Alpine particularly suitable for Docker containers, embedded systems, and environments where minimizing system dependencies and storage consumption is important.
The problem is that when password hashing fails because the input exceeds the supported length, musl returns * as an error indicator instead of returning a distinct error value such as NULL. However, the character asterisk (*) already has a special meaning in Linux’s /etc/shadow file: it indicates that an account has no usable password hash. Consequently, OpenSSH can mistakenly interpret this error indicator as a valid password hash when comparing authentication results. The code opened this severe vulnerability looks like so:
/* src/crypt/crypt.c */char *crypt(const char *key, const char *salt){ static char buf[128]; return __crypt_r(key, salt, (struct crypt_data *)buf);}/* src/crypt/crypt_r.c */if (salt[1] == '6' && salt[2] == '$') return __crypt_sha512(key, salt, output);/* src/crypt/crypt_sha512.c */#define KEY_MAX 256static char *sha512crypt( const char *key, const char *setting, char *output){ size_t i; /* Reject passwords longer than 256 bytes */ for (i = 0; i <= KEY_MAX && key[i]; i++); if (i > KEY_MAX) return 0; /* SHA-512 password hashing continues here... */}/* Convert hashing failure into "*" */char *__crypt_sha512( const char *key, const char *setting, char *output){ char *p; p = sha512crypt(key, setting, output); if (!p) return "*"; // <= the asterisk that causes the bug here!!! return p;}
Execution flow:
crypt()calls__crypt_r().__crypt_r()dispatches SHA-512 salts to__crypt_sha512().__crypt_sha512()callssha512crypt().sha512crypt()rejects passwords longer than 256 bytes by returningNULL(0).__crypt_sha512()converts that failure into the string"*".
Then the hash comparison happens in sys_auth_passwd() in OpenSSH’s auth-passwd.c:
int sys_auth_passwd(struct ssh *ssh, const char *password){ /* Retrieve the user's stored password hash */ ... (truncated code) pw_password = ... (password hash stored in /etc/shadow file) /* Encrypt the candidate password */ ... (truncated code) encrypted_password = xcrypt(password, salt); // compute hash by calling musl libc /* * Compare returned hash */ 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 bug occurs when both encrypted_password and pw_password are a single asterisk character (*). Login succeeds as how it is programmed.
Why does a successful attack require submitting password twice?
The reason SSHole requires two password attempts within the same SSH connection is a subtle interaction between OpenSSH’s pick_salt() function and musl’s static password-database buffer.
There are actually two separate issues involved:
pick_salt()caches the salt it borrows from another account.- musl’s
getspnam()reuses a static buffer, which causes the target account’s password field to be overwritten during the first attempt.
First attempt: Prepare the cached salt
- The attacker connects over SSH and submits a password.
- OpenSSH retrieves the target account’s password field (
*) from/etc/shadow. - Because
*does not contain a usable salt,xcrypt()callspick_salt(). pick_salt()searches other accounts and borrows a valid SHA-512 salt.- During this search, musl’s
getspnam()reuses its static buffer. This overwrites the memory referenced by OpenSSH’spw_passwordpointer. pick_salt()also clears the borrowed hash usingexplicit_bzero().- The password comparison fails because the original
*value is no longer intact.
However, pick_salt() saves the borrowed salt in a static variable for subsequent login attempts.
Second attempt: Trigger the bypass
- The attacker submits another password, this time longer than 256 bytes.
- OpenSSH calls
xcrypt()again. pick_salt()immediately returns its cached salt without searching the password database again.- The target’s
pw_passwordvalue is now intact and contains*. - musl’s
crypt()rejects the oversized password and returns*
More interestingly, pick_salt() was introduced as part of the fix for another security flaw: CVE-2016-6210, an OpenSSH username-enumeration vulnerability.
The original problem was that OpenSSH used a hardcoded fake password hash when authenticating nonexistent users. That hash used the Blowfish algorithm, while real accounts might use SHA-512. Because different hashing algorithms could produce noticeably different processing times, attackers could distinguish existing usernames from nonexistent ones.
2. How to fix it?
The fix addresses the problem is in OpenSSH rather than changing musl’s crypt() implementation. OpenSSH introduced a minimum password-hash length check, defined by MIN_PASSWORD_HASH_LEN=4, before accepting a password-hash comparison. This prevents a short error indicator returned by musl, such as "*", from being treated as a valid password hash matching an account’s /etc/shadow entry. In other words, even if crypt() returns "*", OpenSSH rejects the comparison because the stored password field is shorter than the required minimum length. The patch looks like this:
ret = encrypted_password != NULL && strlen(pw_password) >= MIN_PASSWORD_HASH_LEN && strcmp(encrypted_password, pw_password) == 0;
Below is a list of patched versions, make sure you can upgrade to these versions if you are using Alpine Linux. If you are using Docker containers, double check Docker images because they often use Alpine Linux as a lightweight Linux distribition.
| 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 |
3. What Can We Learn From That?
3.1. Never Use Valid String As Error Indicators
One important lesson from this vulnerability is to never use a value that has a legitimate meaning in the application as an error indicator. In this case, musl’s `crypt()` returned `*` to signal a hashing failure, but the same character was also used in `/etc/shadow` to represent an account without a usable password hash. When OpenSSH compared these values, an error condition could be mistaken for a successful password match under the vulnerable conditions. Error handling should use unambiguous values, such as explicit error codes, NULL pointers, or structured return values, rather than ordinary strings that may also represent valid application data.
3.2. Always Write Edge Case Tests
Security vulnerabilities often emerge not from normal application behavior but from unusual inputs and unexpected interactions between components. This case demonstrates why testing only successful authentication scenarios is insufficient. Developers should also test edge cases, such as excessively long passwords, empty or malformed inputs, missing password hashes, locked accounts, and unexpected return values from external libraries. These tests help reveal assumptions that may remain unnoticed during ordinary testing. In security-sensitive code, edge cases are not merely exceptional scenarios; they are potential attack surfaces that must be deliberately examined and covered by regression tests.
3.3. Review Open-source Code As If It Is Your Coworker’s Code
Using an open-source library does not mean you can blindly trust its behavior. Every dependency becomes part of your application’s execution path, and its assumptions, return values, and error-handling mechanisms can directly affect your security. This vulnerability demonstrates how an unexpected return value from musl’s crypt() could interact with OpenSSH’s authentication logic and produce a security flaw. Developers should therefore review critical dependency code with the same attention they give to their own team’s code. Understand what functions actually return, how errors are represented, and how those results are consumed by your application. A library passing its own tests does not necessarily mean it behaves safely in every context where it is integrated.
3.4. Security Fixes Can Introduce New Vulnerabilities
Similar to bugs described in Full Account Takeover On Keycloak 26 – Why Latest Version Does Not Always Mean Safest Version, the vulnerable code in this post also came from a fix for a previous bug that allowed attackers to enumerate valid usernames through timing differences during password authentication (CVE-2016-6210). And new code, no matter what it is for, is not always safer than old code, but it can always contain new bugs or even open unknown security flaws, which are known as zero-day vulnerabilities.
