How two sensible decisions, made four years apart in two different projects, turned into a password bypass on Alpine Linux. And why the same bug is probably hiding in your code.
On November 4, 2026, a researcher named Özgün Kültekin reported a bug to the OpenSSH project.
The next day, OpenSSH shipped an emergency release.
That almost never happens. OpenSSH is one of the most carefully reviewed pieces of software on the internet. It runs on nearly every Linux server you have ever touched. Its maintainers do not rush.
But this time, Damien Miller, one of OpenSSH's lead developers, told anyone running the affected setup to turn off password logins or disable SSH immediately.
The bug now has a name, *sshole, and a number, CVE-2026-123158.
Here is what it does, in one sentence:
On an affected Alpine Linux server, an account that has no password at all will accept any password between 257 and 1024 characters long.
Not a correct password. Any password.
A string of 300 letter A's works.
Wait, How Is That Even Possible?
The honest answer is the most interesting part of this story.
Nobody wrote a bad line of code.
No one was careless. No one skipped a review. Every piece involved made a reasonable decision for a good reason.
The bug only exists where those decisions meet.
To understand it, you need three small pieces of history. They happened in 2012, 2012 again, and 2016.
Decision One: musl Makes Failure Safe (2012)
Most Linux systems use a C library called glibc.
Alpine Linux uses a smaller one called musl. That is part of why Alpine images are so tiny, and why so many Docker images and cloud VMs are built on it.
Both libraries provide a function called crypt(). It takes a password and a salt, and returns the hashed form that gets stored in /etc/shadow.
In August 2012, musl changed what crypt() does when it fails.
Instead of returning NULL, it started returning the string "*".
That was a sensible safety choice. Plenty of old code calls crypt() and never checks for errors. If it got NULL, that code could crash. If it gets "*", it simply sees a hash that will never match a real password. Login fails. Nothing breaks.
A few weeks later, musl added a second protection. Its SHA-256 and SHA-512 code started rejecting passwords longer than 256 bytes. Without that limit, an attacker could send a giant password and make the server burn CPU hashing it.
Also sensible.
So from late 2012, on musl:
A password longer than 256 bytes makes
crypt()fail. And a failedcrypt()returns"*".
Hold on to that.
Decision Two: Linux Marks "No Password" With the Same Character
On Linux, /etc/shadow stores one line per account.
When an account has no password, the password field often holds a single character:
alpine:*:20000:0:99999:7::: That * means "nothing will ever match this." It is how you create a user who can only log in with an SSH key.
On Alpine, root itself ships as root:*.
You can probably see where this is going.
Decision Three: OpenSSH Fixes a Timing Leak (2016)
In 2016, OpenSSH fixed a different security problem, CVE-2016-6210.
Attackers had noticed that sshd answered faster for accounts with no real password hash. By timing the response, they could tell which usernames existed on a server.
The fix was smart. When the target account has no real hash, sshd now hashes the attempt anyway, borrowing the salt from another account on the system. Every login takes about the same time. The timing leak disappears.
Again, a reasonable decision.
Now Put Them Together
Here is a simplified version of what sshd does when it checks a password without PAM. This is an illustration of the logic, not the real OpenSSH source:
/* Simplified illustration. Not the actual OpenSSH source. */ char *stored = shadow_entry->password; /* "*" for a key-only account */ char *attempt_hash = crypt(attempt, salt); /* salt borrowed from another user */ if (strcmp(attempt_hash, stored) == 0) { return AUTH_SUCCESS; } return AUTH_FAILURE; Now send a 300-character password to a key-only account on Alpine:
- The account's stored field is
"*". -
sshdborrows a real salt from another account, socrypt()runs a SHA-512 hash. - musl's SHA-512 code sees a password over 256 bytes and refuses it.
-
crypt()fails, and musl returns"*". -
sshdcompares"*"with"*". - They match. Login succeeds.
The error value and the "no password" marker were the same string. Nobody planned that. It just happened, across two projects and four years.
The upper limit of 1024 comes from sshd itself, which caps password length at that size.
Who Is Actually Exposed
This is where the story gets calmer, and where a lot of headlines got it wrong.
According to Alpine's advisory, a host is exploitable only when all four of these are true:
-
sshdchecks passwords without PAM. Alpine's defaultopenssh-serverpackage is built this way. Theopenssh-server-pampackage is affected only if you setUsePAM no. -
PasswordAuthenticationisyes. That is OpenSSH's default. - An account that can log in has
*as its password field. For example, a key-only user, or thealpineuser on official cloud images. - At least one other account has a real
$5$or$6$password hash.sshdneeds that salt to borrow. One such account is enough to expose every passwordless account on the host.
So, what does that mean for a typical backend team?
| Environment | Status |
|---|---|
| Ubuntu, Debian, RHEL, Fedora, macOS | ✅ Not affected (glibc, PAM, or both) |
Alpine Docker containers that do not run sshd | ✅ Not affected, per Alpine. Your FROM alpine API service is fine. |
Alpine VMs and servers running sshd with password logins on | ⚠️ Affected. Patch today. |
| Official Alpine tiny-cloud images, 3.18.11 through 3.24.3 | ⚠️ Special attention (see below) |
| Embedded devices on musl with OpenSSH (Buildroot, Yocto, OpenWrt) | ⚠️ May be affected. OpenWrt's default SSH server, Dropbear, is not. |
About those tiny-cloud images: they create an alpine user with no password and passwordless doas. They are safe on first boot, because no account has a password hash yet. But the moment anyone sets a password on any account, an attacker can log in as alpine and become root.
What about root directly? OpenSSH's default PermitRootLogin prohibit-password blocks password logins to root, even though root has * on Alpine. That helps, until the attacker lands on another account that can become root.
The Part That Should Change How You Think About Patching
Kültekin did not find this by reading code line by line.
According to his write-up, he used AI agents to run about 17,600 unusual login attempts against sshd builds for glibc and musl, with and without PAM, using malformed hashes and oversized passwords.
The bug had been sitting there since 2016.
A decade of human review did not catch it. A systematic, machine-driven sweep of weird inputs did.
This is not a one-off. When OpenSSH released version 10.6 in October, the announcement said the team had been receiving a large number of security reports from AI models or made with AI help. It also said that bugs found by AI tools were later being found again, independently, by different researchers.
The team's conclusion was blunt. If friendly researchers can find these bugs with AI, attackers who never report anything probably can too. So OpenSSH would release fixes faster instead of batching them.
That is the real headline for backend engineers.
The time between "a bug exists" and "someone can find it" is shrinking. Long-lived assumptions like "nobody would ever try a 300-character password" are exactly what automated testing is good at breaking.
What to Do on Monday
These steps come from Alpine's advisory and the published guidance. Run them as root on each host, or inside any container that runs sshd.
Step 1: Find out if a host meets the conditions
# Is sshd running on musl? cat /etc/alpine-release 2>/dev/null || ldd "$(command -v sshd)" | grep -i musl # Which openssh-server package is installed? apk info -v 2>/dev/null | grep '^openssh-server' # What is sshd actually using right now? sshd -T | grep -Ei '^(usepam|passwordauthentication|permitrootlogin) ' # Accounts with no password awk -F: '$2=="*" {print $1}' /etc/shadow # Accounts with a real $5$ or $6$ hash awk -F: '$2 ~ /^\$[56]\$/ {print $1}' /etc/shadow How to read the output:
- If the first command prints nothing,
sshdis not on musl. You are not affected. - If
sshd -Tshowspasswordauthentication yesand eitherusepam noor nousepamline at all, the host is exposed until it runs the fixed package. - If both
awkcommands print names, the accounts from the first list are the ones an attacker could log in as.
One catch:
sshd -Tshows global settings. AMatchblock in your config can turn password logins back on for some users or addresses. Read the config file too.
Step 2: Patch, and actually restart sshd
apk upgrade -U -a apk info -v | grep '^openssh' rc-service sshd restart That last line matters more than it looks.
Upgrading the package does not replace the sshd process that is already running. Until you restart it, the old, vulnerable code is still the one answering on port 22.
Alpine backported the fix to every branch from 3.16 to edge. Stable branches keep their older OpenSSH version number and carry a patch, so do not go looking for "10.6" on 3.22. Compare your package version against Alpine's advisory table instead.
Step 3: If you cannot patch today, turn off password logins
Before you do this, confirm that SSH key login works, and keep your current session open until a new login succeeds.
mkdir -p /etc/ssh/sshd_config.d echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf sshd -t && rc-service sshd restart sshd -T | grep -i passwordauthentication # expected output: passwordauthentication no Alpine reads drop-in files from 3.19 onward. On older releases, change the setting in /etc/ssh/sshd_config directly, including inside any Match blocks.
Honestly, if nobody on your team needs password logins over SSH, leave them off after you patch. Key-only SSH would have made this entire bug irrelevant.
Step 4: Check whether anyone already got in
A successful attack leaves a normal-looking log line. The giveaway is who it names.
grep 'sshd.*Accepted password for' /var/log/messages* awk -F: '$2=="*" {print $1}' /etc/shadow grep doas /var/log/messages* An Accepted password for line naming alpine, root, or any key-only user cannot happen legitimately. Those accounts have no password.
Check your central log store too, because local logs can be wiped after a break-in.
As of November 5, no attacks had been reported, and the CVE was not in CISA's Known Exploited Vulnerabilities catalog. But the bug has existed since 2016. "No reports" is not the same as "nobody used it."
If you find a suspicious login you cannot explain, Alpine's advice is to treat the machine as compromised. Rebuild it rather than cleaning it, and rotate any secrets it held.
Step 5: Stop it from coming back in your images
If any of your Dockerfiles install an SSH server, you want to know about it. A quick check you can add to CI:
#!/usr/bin/env bash # Flag any Dockerfile that installs an OpenSSH server. # Most application containers should never run sshd. matches=$(grep -rniE 'apk add.*openssh(-server)?|apt(-get)? install.*openssh-server' \ --include='Dockerfile*' . || true) if [ -n "$matches" ]; then echo "Found images that install an SSH server:" echo "$matches" exit 1 fi If a container really needs remote access, there are usually better options than sshd inside the image, such as kubectl exec or your platform's own session tooling. If an image truly must run sshd, rebuild it on the fixed package and set PasswordAuthentication no in the image itself.
The Bug That Is Probably in Your Own Code
It is easy to read this as "an OpenSSH problem" and move on.
Please don't.
The pattern behind *sshole is one of the most common mistakes in backend code: an error value that looks like real data.
Here is a version of it that could easily exist in an API service today. This is a made-up example, written to show the pattern:
import hashlib def hash_api_key(raw_key: str) -> str: # Guard against huge inputs. if len(raw_key) > 256: return "" # failure looks like a normal value return hashlib.sha256(raw_key.encode()).hexdigest() def is_valid(raw_key: str, stored_hash: str) -> bool: # stored_hash is "" for revoked keys return hash_api_key(raw_key) == stored_hash Every piece looks reasonable on its own.
Limiting input length is good. Marking revoked keys with an empty string feels tidy.
But send a 300-character key against a revoked entry, and "" == "" returns True.
Same shape as *sshole. Two sensible choices, one shared value, one open door.
Here is a safer version:
import hashlib import hmac class KeyTooLong(ValueError): pass def hash_api_key(raw_key: str) -> str: if len(raw_key) > 256: raise KeyTooLong("API key exceeds 256 characters") # fail loudly return hashlib.sha256(raw_key.encode()).hexdigest() def is_valid(raw_key: str, stored_hash: str | None) -> bool: # A missing or malformed stored value can never be a match. if not stored_hash or len(stored_hash) != 64: return False try: candidate = hash_api_key(raw_key) except KeyTooLong: return False return hmac.compare_digest(candidate, stored_hash) Three habits made the difference:
- Errors are exceptions, not values. A failure should never be something that can be compared to stored data.
- Check the stored side too. A valid hash has a known shape. Anything else is an automatic "no." This is essentially what the OpenSSH fix does:
sshdnow refuses a match when the stored field is shorter than four characters. - Compare secrets with a constant-time function.
hmac.compare_digestavoids leaking information through timing, which was the very problem OpenSSH was solving back in 2016.
If you want a quick exercise for your team this week, search your codebase for every place that compares a computed hash, token, or signature against a stored value. For each one, ask a single question:
"What does this function return when it fails, and could that ever equal what is stored?"
Whose Bug Was It, Anyway?
The projects do not fully agree, and that is worth noticing.
OpenSSH's release notes describe the fix as a workaround for a root cause outside sshd.
musl's maintainer, Rich Felker, says it is not a musl bug but an erroneous use of crypt() by the caller. He also said he expects musl to change the behaviour in its next release cycle anyway.
Both positions are defensible. That is exactly the point.
When a bug lives in the seam between two components, each side can reasonably say the other one owns it. Meanwhile, the door stays open for whoever finds it first.
As a backend engineer or tech lead, you will rarely be the person writing the C library or the SSH daemon. But you are almost always the person who decides how they fit together. Which base image. Which defaults. Which accounts exist. Whether password logins are even on.
That seam is your job.
The Takeaway
*sshole was not a failure of skill. It was a failure of assumptions.
Nobody assumed anyone would send a 300-character password. Nobody assumed an error string and a shadow file entry would ever be compared. Nobody assumed a 2016 timing fix would meet a 2012 safety choice.
AI-driven testing is now very good at exactly one thing: trying the inputs nobody assumed.
So the question for your team is not "are we running Alpine?"
It is this:
"Where in our system do two sensible decisions meet, and has anyone ever tested what happens at the edge?"
Go find that seam before someone else does.
What's the scariest "two sensible decisions" seam you've found in your own stack? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
