Send sshd on Alpine a password longer than 256 bytes for a key-only account and crypt() fails. The failure comes back as the string *. That account's stored hash is also *. Two equal strings, and you're logged in.
An error value that can equal valid data isn't an error value. That's all of sshole: a comparison that was handed a failure and compared it anyway.
The Mechanism
Without PAM, sshd checks passwords itself: run crypt(), compare the result with the shadow field. Here's how the pieces line up:
- Key-only accounts store
*, which means "no password can match". It's too short to hold a salt. - So sshd borrows the salt of another local account with a real
$5$or$6$hash. That dates to the 2016 fix for CVE-2016-6210, a user enumeration bug. - sshd accepts passwords up to 1024 bytes. musl's SHA crypt rejects keys over 256.
- On failure, musl returns
*instead of NULL, and has since 2012. *equals*.
On glibc, step 4 returns NULL and sshd rejects it. Alpine's openssh-server is built without PAM, so it gets this path by default.
Each decision has a reason. Returning an unmatchable hash on failure protects callers who forget to check for NULL. Burning a real crypt() call on accounts with no password closes a timing leak. Together, they turned "this can't match" into "this always matches", and it sat there for about a decade.
OpenSSH says the root cause is outside sshd, though sshd can block the path. Atlas reports the musl side sees it as caller misuse. I don't need to pick a winner here. The bug lives in the contract between the two, and that contract was never written down.
It's Narrower Than the Headline
The headlines say root. The default says otherwise: PermitRootLogin prohibit-password blocks root over a password, so root needs PermitRootLogin yes.
The default target is more boring and works fine. Official tiny-cloud images from 3.18.11 to 3.24.3 have an alpine user with * and passwordless doas, exploitable once any account gets a real hash. Key-only deploy users are the same shape. And most Alpine containers never run sshd, so don't panic over every FROM alpine. Outside Alpine, other musl systems like OpenWrt, Buildroot and Yocto builds without PAM may be exposed too. No exploitation is reported so far.
Check It
|
|
You're exposed on an unpatched package if usepam isn't yes, passwordauthentication is yes, the awk lines print a * account and a salt donor. Compare the first line with the fixed revisions (3.24 is 10.3_p1-r2, 3.23 is 10.2_p1-r1).
Don't trust ssh -V here. Alpine backported the fix under the old version numbers, so 10.3 can be safe and a version scanner can't tell you.
The fix is apk upgrade and a restart of sshd. Until then, PasswordAuthentication no. The tradeoff: anyone who really logs in with a password is locked out, so keep a session open while you do it. Orca says images older than 3.16 get no fix, so for those the workaround is the fix.
Make the Failure a Different Type
OpenSSH's patch rejects stored hashes too short to be real, which closes this path. I read that as a patch on the caller, though. The in-band error is still there for whoever calls crypt() next.
This is why I like errors as types:
A failure can't reach that ==. The compiler refuses to compare a Result with a string, so you have to say what failure means before you get to compare anything. It's not a magic shield: wrap C's crypt() and pass its output straight through and you've rebuilt the bug in Rust. Still, the default points the right way.
Grep your own code for the same thing: -1 as an ID, "" as "not found", 0 as "no limit". Anywhere a failure and a legitimate value share a type, some caller will eventually compare them.