CVE-2026-123158 is "SSHole," an unauthenticated SSH password-login as any /etc/shadow-locked account on Alpine Linux and every other distribution that ships OpenSSH without PAM on musl libc. The exploit is a 300-byte password. musl's SHA-256 and SHA-512 crypt() return the string "*" when the key exceeds 256 bytes. Accounts with * in their shadow field store "*" because * is the Unix convention for "no password hash lives here; do not authenticate." The two projects picked the same sentinel. OpenSSH's password check is strcmp(crypt(submitted, salt), shadow_field) == 0. The ten-year-old CVE-2016-6210 timing-side-channel patch is the reason that strcmp ever runs with both operands set to "*".
CVE-2026-123158 (SSHole): The 2016 Timing-Side-Channel Fix Is The Reason The 2026 Bypass Reaches
pattern
cve
proof of concept
- 0xBlackash/CVE-2026-123158%20(%E2%98%850)
- coboliqcom/cve-2026-123158-sshole-safe-rust%20(%E2%98%850)
- rothoma2/cve-2026-123158-sshole%20(%E2%98%851)
- gabrielunknown/CVE-2026-123158_SSHole%20(%E2%98%850)
- ansonhsu-ai/sshhole-cve-2026-123158-ste%20(%E2%98%850)
- infosec.exchange/@vuldb/117568642469996326
- Maalfer/CVE-2026-123158%20(%E2%98%850)
- infosec.exchange/@threatcodex/117569615942581532
- HackSpeak/CVE-2026-123158%20(%E2%98%851)
- infosec.exchange/@cloud/117570602831687005
- CriticalWombat/sshole_scanner-CVE-2026-123158%20(%E2%98%850)
- mc493/openssh-musl-enterprise--hardening-cve-2026-123158%20(%E2%98%850)
- infosec.exchange/@karenit/117571244936367386
- lpizziniacofortinet/cve-2026-123158-openssh-musl-lab%20(%E2%98%850)
- infosec.exchange/@stackwatch/117576869127306586
- ashrafzaryouh/CVE-2026-123158%20(%E2%98%851)
- Galaxy-sc/CVE-2026-123158%20(%E2%98%850)
- infosec.exchange/@sans_isc/117581593450037176
- GarphenGate/cve-2026-123158-defender-toolkit%20(%E2%98%850)
- infosec.exchange/@sans_isc/117584031964492300
- D1se0/CVE-2026-123158%20(%E2%98%850)
- aciddust/CVE-2026-123158-LAB%20(%E2%98%850)
- infosec.exchange/@ssvc/117587842105930939
- infosec.exchange/@axnews/117587810491335351
- projectdiscovery/nuclei-templates
- infosec.exchange/@threataft/117589758484778389
- infosec.exchange/@xploitdigest_kev/117590714304070604
- Certbn/CVE-2026-123158-OpenSSH-sshd-SSHhole-sshole-%20(%E2%98%850)
- infosec.exchange/@edwardk/117591575397154130
- infosec.exchange/@beyondmachines1/117592508700836707
- pablocerdeira1/CVE-2026-123158%20(%E2%98%850)
The two asterisks have separate histories and the same byte
A locked Unix account has * in the second field of /etc/shadow. The convention predates any version of OpenSSH currently in anyone's package manager. The semantics are "no password hash lives here; a password login must never succeed." Alpine's adduser -D (the default system-account form) writes *. Cloud-init's alpine, ec2-user, and ubuntu templates write *. apk add nginx creates an nginx user with *. On a default Alpine box after openssh-server is installed, the output of awk -F: '$2=="*"{print $1}' /etc/shadow is not a short list. Every one of those rows is a target under this CVE.
musl's SHA-256 and SHA-512 crypt() return the string "*" when the submitted key exceeds 256 bytes. The convention is older than musl; the * return is Ulrich Drepper's SHA-crypt reference, which musl adopted verbatim. The public comment at the top of src/crypt/crypt_sha512.c is admirably direct about it:
/*
* public domain sha512 crypt implementation
*
* original sha crypt design: http://people.redhat.com/drepper/SHA-crypt.txt
* in this implementation at least 32bit int is assumed,
* key length is limited, the $6$ prefix is mandatory, '\n' and ':' is rejected
* in the salt and rounds= setting must contain a valid iteration count,
* on error "*" is returned.
*/OpenSSH's auth-passwd.c reads /etc/shadow through shadow_pw(pw), obtains the stored field, calls xcrypt(password, salt) to run the submitted password through the system crypt(), and compares the two with strcmp(). When the stored field is "*" and the submitted password is 300 bytes, xcrypt() on musl returns "*", and strcmp("*", "*") == 0. The account authenticates. The account the attacker just logged in as is the account whose shadow field was written specifically to say "a password login must never succeed."
The code path, pre-patch
Here is sys_auth_passwd in auth-passwd.c at the commit just before the fix. This is the function sshd calls for every password-auth attempt when UsePAM no is in effect, which is the Alpine default.
int
sys_auth_passwd(struct ssh *ssh, const char *password)
{
Authctxt *authctxt = ssh->authctxt;
struct passwd *pw = authctxt->pw;
char *encrypted_password, *salt = NULL;
/* Just use the supplied fake password if authctxt is invalid */
char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;
if (pw_password == NULL)
return 0;
/* Check for users with no password. */
if (strcmp(pw_password, "") == 0 && strcmp(password, "") == 0)
return (1);
/*
* Encrypt the candidate password using the proper salt, or pass a
* NULL and let xcrypt pick one.
*/
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
encrypted_password = xcrypt(password, salt);
/*
* Authentication is accepted if the encrypted passwords
* are identical.
*/
return encrypted_password != NULL &&
strcmp(encrypted_password, pw_password) == 0;
}Walk the line-by-line for an account with shadow field "*" and a 300-byte submitted password.
pw_password = shadow_pw(pw) returns "*". The empty-password short-circuit is a no-op; strcmp("*", "") != 0. The salt branch is the first interesting one: the condition pw_password[0] && pw_password[1] requires the stored field to be at least two characters long before sshd will trust it as a crypt() setting string. For a one-character "*", the string terminates at index 1, pw_password[1] is '\0', the branch does not fire, and salt stays NULL. xcrypt(password, NULL) then calls pick_salt().
pick_salt() is where the 2016 patch lives.
pick_salt is the 2016 timing-side-channel armor
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 function walks /etc/shadow looking for the first row whose password field starts with $ and contains an interior $, which means "this is a real hash with a parseable prefix." It copies the salt portion (the $6$rounds=5000$abcdef$ prefix, or the full bcrypt setting) into a static buffer and returns it. On any Alpine box with at least one actual user account, pick_salt() returns a legitimate $5$ or $6$ setting.
The comment inside xcrypt() explains why this function exists at all:
/*
* If we don't have a salt we are encrypting a fake password for
* for timing purposes. Pick an appropriate salt.
*/That comment is the CVE-2016-6210 remediation, dated June 2016. The original bug was a username-enumeration oracle: for nonexistent users or users with * in shadow, sshd used to short-circuit the password check, and the response was faster than for users with a real hash. An attacker could time the responses and enumerate accounts. The fix was to make sshd always call crypt() with a real salt, so the oracle closed. pick_salt() is the machinery that guarantees a real salt is always available. The entire ceremony of walking /etc/shadow to borrow a salt from a different account exists exclusively to make timing uniform.
That ceremony is what reaches the musl bug. Without it, sshd on musl could have exited early for * accounts and the oversized password would never have been fed to crypt(). With it, every password attempt against every * account on Alpine runs crypt(submitted_password, real_salt_from_some_other_account), and the return value is compared against the stored "*" with strcmp(). The 2016 patch didn't introduce the bug. It made the bug reachable.
The researcher who disclosed this (Özgün Kültekin) did not create the collision. Rich Felker's musl inherited the "*" error convention from Drepper's 1999 SHA-crypt reference. The OpenSSH portable layer inherited the strcmp(encrypted, stored) comparison from BSD sys_auth_passwd written before SHA-crypt existed. The 2016 patch closed one timing oracle and, by construction, built the data pipeline that would make the collision reachable ten years later. Three independent decisions, all defensible in isolation, converge at strcmp("*", "*").
musl knew
This is the line that forces the reread. src/crypt/crypt_des.c line 1015, in the DES crypt implementation:
return (setting[0]=='*') ? "x" : "*";The DES path, on error, returns "*" unless the caller passed a setting whose first byte is also "*", in which case it returns "x". The author of musl's DES implementation saw the collision. They knew that returning "*" as an error sentinel would match any caller who passed "*" as the setting string. They wrote a one-character-long defense: if the setting looks like it could collide, return a different sentinel. It is a crude defense; it addresses exactly the collision CVE-2026-123158 exploits.
The SHA-256 and SHA-512 paths do not have that defense. Their wrappers look like this:
char *__crypt_sha256(const char *key, const char *setting, char *output)
{
static const char testkey[] = "Xy01@#\x01\x02\x80\x7f\xff\r\n\x81\t !";
static const char testsetting[] = "$5$rounds=1234$abc0123456789$";
static const char testhash[] = "$5$rounds=1234$abc0123456789$3VfDjPt05VHFn47C/ojFZ6KRPYrOjj1lLbH.dkF3bZ6";
char testbuf[128];
char *p, *q;
p = sha256crypt(key, setting, output);
/* self test and stack cleanup */
q = sha256crypt(testkey, testsetting, testbuf);
if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))
return "*";
return p;
}sha256crypt() returns 0 (NULL) when the key exceeds 256 bytes. The wrapper calls it, gets NULL, and collapses that NULL into the string "*". The inner function has a non-colliding sentinel (NULL). The outer wrapper converts that non-colliding sentinel into a colliding one. The DES path's defense was dropped when the SHA paths were added. This is Sentinel Conflates Failure Modes: the public wrapper's return type is char *, and three distinct internal failures (key too long, self-test failed, output buffer corrupted) all collapse to the same two-byte string. The caller cannot discriminate.
The patch is three words: MIN_PASSWORD_HASH_LEN 4
Here is OpenSSH's fix, committed by Damien Miller as 4353fdc on 2026-11-05:
+#define MIN_PASSWORD_HASH_LEN 4
#define MAX_PASSWORD_LEN 1024
...
- return encrypted_password != NULL &&
+ ret = encrypted_password != NULL &&
+ strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;A minimum stored-hash length of 4 bytes. "*" is 1 byte. "!" is 1 byte. "!!" is 2 bytes. "x" is 1 byte. None of the sentinels the shadow convention uses to mean "do not authenticate" clears the gate. Any legitimate crypt() output clears the gate because every real crypt() prefix ($1$, $5$, $6$, $2a$, $2b$, DES) is at least 11 bytes. The fix is one && clause and one three-digit constant.
The commit message is two sentences:
This bypass affects some configurations and runtime configurations. We do not consider the root cause to be within OpenSSH but we are well equipped to avoid it.
Damien Miller is shipping the fix while naming where he thinks the responsibility lives. "We do not consider the root cause to be within OpenSSH" is the kind of sentence that appears in security commit messages roughly never. OpenSSH is shipping a defense against a sentinel collision that OpenSSH did not create and cannot repair upstream, because the upstream in question is musl, and musl's position on returning "*" as an error token has been stable since the 1999 Drepper reference. The fix is three words and a four. The sentence under it is the one that would survive cross-examination.
The two-attempt quirk
The published PoCs send the oversized password twice on one SSH transport. The first attempt usually fails; the second usually succeeds. The explanation is in a scanner comment from one of the third-party audits:
A known quirk means the FIRST password attempt in a session may fail (buffer-reuse in getspnam()); the SECOND attempt on the SAME connection is the one that succeeds.
getspnam() on musl returns a pointer into a static buffer shared with other NSS lookups. The first auth attempt's shadow read can be stomped by an interleaved lookup before strcmp() reads it. The second attempt, with the buffer settled, is the one that lands. Operationally this means a vulnerability scanner that reports "not vulnerable" after one attempt is wrong; the PoCs that ship a for attempt in (1, 2) loop are the ones that are right. The CVSS vector AV:N/AC:H encodes exactly that: attack complexity is High because the first attempt looks like a reject.
The ecosystem response is 19 repositories, mostly not exploits
Three days after disclosure, the GitHub search for CVE-2026-123158 returns 13 repos with more than a README. Two of them (0xBlackash/CVE-2026-123158 and ashrafzaryouh/CVE-2026-123158) are byte-identical, same Attack-CVE-2026-123158.py, same Attack-CVE-2026-123158.sh, same Check-CVE-2026-123158.sh, same CVE-2026-123158.yaml. One (coboliqcom/cve-2026-123158-sshole-safe-rust) describes itself as "synthesized via the DecodeIQ CodeTruth Engine (AST-SLS Synaptic Topology)," reimplements musl_crypt_safe in Rust, and claims formal verification by GitHub Actions. The rest are defender toolkits: eBPF hardening, Terraform labs for reproducing the bug safely, exposure-audit shell scripts, am i an *sshole detector shell.
The ratio is the signal. The exploit code is six lines of paramiko.auth_password(user, "A" * 300) and anyone who has ever opened a Python REPL can write it. What takes judgement is answering "is my fleet exposed," which depends on the libc, the sshd build, UsePAM, PasswordAuthentication, and the shadow contents of every account. The repos that attracted effort are the ones that answer that question, not the ones that execute the exploit. Alpine is the default Docker base image. The exposed population is a large multiple of the "Alpine Linux servers on the internet" number, because every CI runner on a shared Kubernetes cluster is a reachable Alpine box with nginx, postgres, redis, and node accounts that all have * in shadow.
Who the case is about
The victim is the Alpine sysadmin who typed apk add openssh-server at some point in the last decade and did not know they had signed up for a sentinel-collision problem. The accused is a tie between the two codebases whose sentinel conventions met at strcmp(): musl, which returns "*" from __crypt_sha256 and __crypt_sha512 as a string-typed error token, and OpenSSH, whose BSD-descended sys_auth_passwd compared crypt() output to the shadow field without a length floor for ten and a half years after the CVE-2016-6210 patch made the comparison reachable for * accounts. Both parties have shipped their responses. Alpine's advisory calls the bug SSHole and links openssh-10.6p2-r0. musl's position has not been updated.
The patch closes this CVE. The convention that put "*" on both sides of a strcmp() is still the convention tomorrow. The MIN_PASSWORD_HASH_LEN guard is a fence around one specific collision; pick_salt() still calls crypt() with attacker-chosen inputs on every * account on Alpine; __crypt_sha256 still conflates "key too long" and "self-test failed" into "*". The next collision between those two layers is the one the defender toolkits are built to detect before the next CVE number is assigned.
PoC: gabrielunknown/CVE-2026-123158_SSHole, CriticalWombat/sshole_scanner-CVE-2026-123158
The 2016 patch closed a timing oracle. It also guaranteed, as a side effect, that a decade later sshd on Alpine would run strcmp() with an attacker-pickable string on one side and a stored "*" on the other. The sentinel was waiting the whole time.