Two Safe Defaults, One Bypass: When Your Error Sentinel Becomes a Credential

There is a single character in your Linux password file that means "no one may ever log in as this account." It has meant that since 1980. In November 2026, a security researcher documented a configuration where typing a particular kind of wrong password turns it into "welcome in."
The character is an asterisk. The configuration is a specific C library, a specific SSH build, and a password longer than 256 bytes. None of those three things is a mistake on its own. That is the whole point.
This is not really an article about OpenSSH or Alpine Linux. It is about a design decision almost every codebase makes, usually without noticing: how do you signal failure? And what happens when two independently reasonable answers to that question meet in the same comparison.
How the asterisk got there
Password verification on Unix is a two-string problem. The real hash lives in a protected file. When you type a password, a library function called crypt() takes your typed password plus a salt — a short random prefix stored next to the hash so two people with the same password get different hashes — and returns a computed hash. The program compares that computed hash to the stored one. Equal means you are in.
Now the special case. Some accounts are deliberately unloggable: system accounts like daemon, and any account an administrator has locked. Rather than storing a hash, the password field holds a character that cannot be produced by crypt(). In 1980 that character was an asterisk. If nothing you type can hash to an asterisk, nothing you type gets in. Elegant, cheap, and still in use today.
The elegance depends entirely on one assumption: crypt() can never return an asterisk. Hold on to that.
Three reasonable decisions
The next thirty years produced three fixes, each correct in isolation.
1997. The Single UNIX Specification said crypt() returns NULL when it cannot do its job. Some programs never checked. They compared NULL to the stored field, and crashed. That is a denial of service, but it is a loud one.
2012. The musl C library — the one under Alpine Linux and a long list of embedded distributions — decided NULL was the wrong answer. Instead, when crypt() fails, it returns a single character that no real password can match, so callers that forgot to check get "wrong password" instead of a segfault. Which character did they pick? The asterisk.
Twenty days later. musl added SHA-512 password hashing, which gets slower as passwords get longer. A very long password could pin a CPU for a long time, so musl capped input at 256 bytes and refused anything longer. What does it return past 256 bytes? The same failure value. The asterisk. The code comment says it plainly. It has said so since 2012.
Now put the pieces on the table. A locked account stores an asterisk. A crypt() call on a 257-byte password returns an asterisk. The verification code asks: does the computed hash equal the stored field? It does. You are in, on the account that was defined as unreachable.
Why the comparison could not save you
Here is the structural problem, and it is not specific to passwords.
When a function signals failure out of band — a NULL pointer, an error code, an exception — the caller must do something. Forgetting is a visible bug, often a crash. The burden sits with the producer and the caller.
When a function signals failure in band — a magic string, a -1, an empty list, a null in your JSON — you move that burden onto whoever consumes the value. Callers stop crashing. But now the failure value is data. And data is exactly what your comparison, your lookup table, your cache, and your API response are built to accept.
musl's 2012 change was in-band failure, and for the caller that forgot to check, it was strictly better. But it quietly changed the contract of an asterisk from "a value crypt() cannot produce" to "a value crypt() produces whenever it is unhappy." The sentinel was no longer a claim about the value. It was a claim about the verifier — and the verifier had just learned to produce it.
This is not a one-off. Ask Alpine.
The same file has a sequel that proves the pattern.
In 2019, it turned out Alpine's Docker images shipped with no root password at all — a genuine vulnerability. The fix was to lock root. Apache-style. The chosen marker was an exclamation mark, which also means "locked." Problem: without PAM (the pluggable authentication module most distributions use), the exclamation mark also disables SSH key authentication. Cloud images log in with keys. So the fix broke the thing people actually used. Alpine 3.16 in 2022 switched root to the asterisk instead, which locks the password without touching keys.
Meanwhile Debian filed a request in 2013 asking glibc to copy musl and return an asterisk on failure. It sat for six years, then Debian moved to libxcrypt, which uses *0 as its marker — one character away from the same collision — and the request was closed. No one was wrong. Everyone was locally correct. The gap between the two conventions was the only thing standing in the way.
Why ten years of testing did not catch it
Because the bug lived in a seam.
OpenSSH's test suite runs on OpenBSD and glibc. musl does not test sshd. Most distributions route password checks through PAM, which never calls crypt() at all for an account whose field is a bare asterisk. So each of the three environments was individually fine, and the intersection — musl, no PAM, password logins enabled, an account stored as a bare asterisk — was nobody's test matrix.
This is the most transferable lesson in the whole story. Your test suite validates your decisions. It does not validate the spaces between your decisions and someone else's. The researcher who found this reportedly built sshd every way they could think of — glibc, musl, with and without PAM, with and without shadow — and threw roughly 17,600 malformed logins at each combination. Every build agreed. One did not.
A four-question audit for every sentinel you own
Go find your magic values. The empty string, the -1 user ID, the "not found" object, the placeholder row, the default admin password, the null that means "unknown" in one service and "deleted" in another. Then ask, in writing:
- Can a legitimate producer emit this value? If a valid password, a valid ID, or a valid name can ever equal your sentinel, you have a collision waiting for a slow afternoon. The asterisk was safe in 1980 and unsafe in 2012 because the producer changed, not the sentinel.
- What does the consumer do when it sees it? If the answer is "treats it as data," you are one code path away from an authorization decision built on an error. Failures should reach a branch that says refuse, not a branch that says compare.
- Is there a third state? Most sentinel bugs are two-state systems pretending to be three-state. Locked, valid, and broken are different things. Give them different shapes — a struct, an enum, a tagged union — and the compiler enforces what your comments could not.
- Who tests the combination? Not your module. Not their module. The place where your module and the vendor's service, the SSO provider's response, or the third-party callback meet. Write one integration test per seam and give it a name.
The trade-off you are actually choosing
In-band failure is not wrong, and I am not going to pretend otherwise. It makes callers simpler, it survives careless code, and it is the reason a broken crypt() today means a failed login instead of a crashed daemon. Out-of-band failure is not wrong either: it is louder, and loudness is a feature when the alternative is silence.
What matters is that you pick deliberately and then defend the choice at the boundary. If you choose in-band, your sentinel must be genuinely unproducible, and you owe it a test that asserts so. If you choose out-of-band, your comparisons must live where a missing check is a compile error or a CI failure, not a code review you were too tired to give.
The asterisk is harmless. The asterisk is also a password now, on exactly one configuration, for anyone who reads commit logs. That is the shape of most of the bugs that will actually reach production this year: not a bad decision, but two good ones that never met.
Key Takeaways
- A failure sentinel is a claim about the verifier, not about the value. When the producer changes, the claim can silently become false.
- The November 2026 sshd finding came from composition, not from any single mistake: a 1980 marker, a 2012 decision to return that marker on error, and a 2012 length cap that made the error reachable.
- In-band failure (magic string, -1, placeholder object) trades caller crashes for consumer collisions. That is often the right trade — but it must be paired with a test that proves the sentinel is unproducible.
- Model three states (valid, locked, broken), not two. Collapsing them is where authorization decisions get made on top of errors.
- Bugs like this live in the seams between test matrices. Enumerate the combinations — libc, auth module, config flags — and write one test per seam.
- Affected configuration, if you want to check yours: sshd on musl without PAM, password authentication on, and an account whose stored field is exactly an asterisk. Most distributions never touch that path.