One Character, Two Unrelated Meanings, One Authentication Bypass
I sent a wrong password first. Then I sent one password, 300 bytes long, to an account with no password set at all. sshd let me in.
Two systems that have never heard of each other made that possible, by both choosing the same character to mean "there is nothing usable here." /etc/shadow has used a bare asterisk for decades to mark an account that should never accept a password. musl’s crypt() function, on certain internal failures, returns that exact same character instead of the null value POSIX actually specifies. Neither choice is wrong on its own. Put them in the same string comparison, and sshd can be told a password matched when nothing was ever compared at all.
That first paragraph is also the one data point this piece will not let itself overstate: a single overlong password, sent as the first and only attempt of a fresh connection against the same account, with no wrong password sent first, did not authenticate, in the same lab run. One sequence worked. The other didn’t. That is one data point from one run, not a claim that every fresh password attempt against a vulnerable account succeeds. The difference between those two outcomes, and why it happened, is addressed directly and honestly later, including the parts that are still genuinely unresolved.
This piece walks the background first: what SSH authentication actually does, what /etc/shadow stores, what PAM is and why building without it matters here, why musl isn’t glibc, and what crypt() is supposed to do when it fails. Only once that’s in place does the root-cause mechanism, the lab evidence, the patch, and the open questions actually make sense without asking the reader to take anything on faith.
If you administer a musl-based Linux box with password SSH enabled and no PAM, the short version is this: check your shadow file for a bare asterisk, check whether anything else on the box has a $5$ or $6$ hash, and patch. The long version, including exactly why this works and exactly what a hands-on lab run did and did not confirm, is the rest of this piece.
A One-Day Turnaround, and the Advisory Detail Most Coverage Drops
OpenSSH 10.6p2 shipped on 5 November 2026 with a single security item, and it shipped fast. Damien Miller’s own release announcement states the fix went out the day after the OpenSSH team learned of the issue, and argues that recent cases of similar bugs being independently rediscovered by AI agents within hours now mean a long embargo mostly benefits attackers rather than defenders. That is the stated justification for the compressed timeline, not an assumption this piece is making on the team’s behalf. [2]
OpenSSH’s release notes describe sshd as now avoiding "a password authentication bypass that affects some Linux systems built and run with PAM support disabled" (PAM, Pluggable Authentication Modules, is the layer most Linux systems use to decide whether a login succeeds; a few sections ahead cover it properly), and credit the report to Ozgun Kultekin of Wakefield. [3] The reporter’s own detailed technical write-up lives at sshhole.com, titled "*sshole," and independently states the same 257-1024 byte window and the same character collision this piece traces in full further on. [4] The site’s GitHub handle, oz9un, is consistent with the credited name, though nothing in the record confirms that link explicitly; it is not load-bearing for anything technical here, just worth naming plainly rather than asserting.
Alpine Linux’s own advisory is the second primary source this whole piece leans on. It states the vulnerable account state precisely: a shadow field that is literally "*", and explicitly notes that a locked account, whose field is "!", is not affected. It frames the general precondition as needing at least one other local account with a real password hash on the same box, Alpine’s default SHA-256/SHA-512 format, shown in the table just ahead, says that one such account is enough to make every password-less account reachable, and describes the attack itself as taking one connection and needing no retries, a phrase this piece comes back to directly later. [5]
CVE-2026-123158 itself carries CVSS 3.1 base score 8.1 (High), vector AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, and CWE-394, Unexpected Status Code or Return Value, which is the right classification for a bug that is, at bottom, about trusting an error-signaling value without checking it. CISA’s own enrichment on the same record separately rates exploitation maturity as "poc" and automatable as "no." No exploitation in the wild has been reported as of this writing. [1]
Most secondary coverage of this CVE flattens it to "an overlong password bypasses SSH." The advisory itself is more precise than that on three points nearly all of that coverage drops: the account has to be in the exact "*" state, not "!"; something else on the same box has to already carry a usable hash; and the byte window is 257 to 1024, not "long" in general. The rest of this piece is built around exactly those three details, plus the one the advisory states confidently that a hands-on lab run has reason to probe rather than take on faith.
What Actually Happens When You Type an SSH Password
SSH exists to let one machine administer another over a network neither side necessarily trusts, encrypted and, ideally, with both sides proven to be who they claim. A client connects to sshd, the server-side daemon; the two negotiate a secure channel first, and only once that channel exists does authentication happen, by public key, by password, or by another configured method, before any shell or command actually runs. Password authentication, the method this entire CVE concerns, is one specific step inside that larger handshake, not a separate mechanism bolted on beside it.
On a Linux system, account information lives in /etc/passwd, one line per account: username, numeric user id, home directory, shell. The password itself, or more precisely a hash of it, used to live there too, decades ago. It now lives in a separate file, /etc/shadow, readable only by root, specifically so that compromising an ordinary user account does not also hand over every password hash on the box.
That shadow field is not always a hash, though. It carries a small number of special values that administrators, tools, and sshd itself all have to interpret correctly, summarized below.
By design, an account marked with a bare asterisk should be unauthenticatable via password under any input whatsoever, because "*" is not a hash format at all. It is a convention: this account exists, but no password will ever unlock it. That convention is exactly what administrators rely on for key-only accounts, service identities, and container base images that ship a root account nobody expects to log into with a password. The entire vulnerability this piece traces is a case where that convention silently stopped holding.
| Password field | Meaning | Affected |
|---|---|---|
$1$... / $2a$/$2b$/$2y$... / $5$... / $6$...crypt() runs normally here and produces a genuine hash to compare against. | A real password hash (MD5, bcrypt, SHA-256, or SHA-512) | No |
! or !...Alpine's advisory states this explicitly. | Account locked | No |
*Under the exact conditions traced in this article. | No usable password set (key-only account) | Yes |
(empty string)Handled by a separate, earlier branch in sys_auth_passwd(), before the path this CVE concerns. | No password required | No |
PAM, and Why Building sshd Without It Changes Everything
PAM, Pluggable Authentication Modules, is the layer most Linux distributions use to decide whether a login attempt, SSH included, actually succeeds. An application like sshd does not normally implement password checking itself; it asks PAM, and PAM runs whatever modules the administrator configured for that service, pam_unix for local passwords being the common case, with others available for directory services or multi-factor schemes. That indirection is the default on glibc-based distributions such as Debian, Ubuntu, Fedora, and RHEL: on those systems, sshd never calls a password-hashing function directly at all. [2]
Alpine Linux, and most musl-based embedded builds (OpenWrt, Buildroot, Yocto), compile OpenSSH with --without-pam instead. On those systems there is no PAM layer to delegate to. sshd falls back to its own generic comparison code, in auth-passwd.c, calling crypt(3) directly against the account’s own shadow entry. That single build-time decision is the fork in the road this CVE lives entirely on one branch of: a PAM-enabled system never reaches the vulnerable comparison at all, not because of any specific defense stopping it, the code path simply never runs on that build. [2]
One more detail worth knowing before the next two sections, because the root-cause section depends on it directly: for a username sshd considers invalid, the non-PAM path still runs a comparison, against a cached fake password, so that rejecting a nonexistent account takes roughly the same time as rejecting a wrong password on a real one. That is a deliberate, long-standing timing defense against username enumeration, and it shares the exact salt-selection code that routes a "*" account into the wrong branch. "Salt-selection code" here just means the logic that decides what reference value crypt() checks a password against; exactly what that reference value is, and how it goes wrong, is where the mechanism sections below pick it back up.
A shared first step, sshd receiving a password-authentication request, then two branches: a PAM-enabled build that delegates to the PAM stack and never calls crypt() directly, and a non-PAM build that runs its own comparison code, ending in a crypt() comparison against the stored hash, marked as the one dangerous step.
sshd receives a password-authentication requestsshd never calls crypt() directly--without-pam: Alpine, musl-based embedded buildsauth-passwd.c runs sshd's own comparisonNo PAM layer exists to delegate tocrypt() comparison against the stored hashThe exact path this CVE lives inmusl, glibc, and Why This Bug Doesn't Touch Debian
libc, the C standard library, is the layer between a program and the operating system kernel underneath it: memory allocation, string handling, and, for this story, crypt(). Every Linux program written in C links against one. Most desktop and server distributions use glibc, the GNU C library. Alpine Linux uses musl instead, a smaller, independently written implementation of the same set of standard interfaces.
musl and glibc are not interchangeable at the implementation level for everything they expose. They implement the same standard interfaces, crypt() among them, but not always with identical internal behavior behind that interface. That gap is usually invisible to application code calling through it. It stopped being invisible here.
Alpine ships OpenSSH built against musl. OpenSSH calls the standard crypt(3) interface exactly as it would on any other libc. musl’s own implementation of that interface, specifically its SHA-256 and SHA-512 code paths, is where the specific failure behavior the next three sections trace actually lives, not in OpenSSH’s own code and not in Alpine’s packaging of it.
This is worth being precise about, because it is easy to overstate: the vulnerability needs musl specifically, not simply "built without PAM." OpenSSH’s non-PAM comparison code exists on any build compiled that way, including, hypothetically, a non-standard glibc build. But the failure behavior this CVE collides with, crypt() returning a one-character sentinel instead of NULL, is musl’s own implementation choice. Nothing in this investigation found the equivalent behavior in glibc, and OpenSSH’s own release announcement is explicit that the affected population is Linux, musl, and no PAM, not non-Linux, not glibc, not PAM-using systems. [2]
A five-layer stack, top to bottom: Alpine Linux as the distribution, an OpenSSH sshd built without PAM, the crypt(3) library interface, musl libc's own crypt() implementation, and its per-algorithm hashing code at the bottom, each layer connected by the specific call or convention named between them. No layer is marked dangerous: this investigation did not independently verify the failure behavior at the level of musl's own crypt_sha256.c/crypt_sha512.c source.
sshd, built --without-pamcrypt(attacker_password, stored_hash)crypt(3)__crypt_r()crypt() implementation$6$, $5$, ...)crypt_sha256.c / crypt_sha512.cWhat crypt() Is Supposed to Do When It Fails
crypt(3) is the traditional Unix interface for computing and checking that one-way hash, not reversible encryption, the stored shadow value you already met two sections ago. crypt(password, salt) computes the hash, and that same function gets reused to verify a login attempt, by recomputing the hash from the attempted password and the account’s own stored hash reused as the salt, then comparing the two results with a plain string comparison. A salt exists so that two accounts sharing the same password do not end up with the same stored hash either.
POSIX specifies what crypt() is supposed to do when something goes wrong internally during that computation: return a null pointer, and set errno to say why. A caller that checks for NULL knows the computation failed and should never treat the result as a valid hash to compare against anything else.
musl’s crypt() does not always follow that contract. What it does instead, and why that one specific deviation collides with /etc/shadow’s own conventions from the earlier section, is the mechanism the rest of this piece traces at the source level, starting now.
Why a Valid Account Gets Routed Into the Fallback-Salt Path
Here is the first non-obvious detail, and it is a one-character logic slip, not a dramatic one. OpenSSH’s own salt-selection check in sys_auth_passwd() does not ask whether an account’s stored hash is the placeholder "*". It asks whether that hash is at least two characters long.
The guarding condition is `if (authctxt->valid && pw_password[0] && pw_password[1]) salt = pw_password; else salt = NULL;`. For the one-character string "*", pw_password[0] is the asterisk itself, which is truthy, but pw_password[1] is the string’s terminating NUL, which is falsy in C. One truthy character. One falsy terminator. That is the entire condition, and it is already wrong for this case. It evaluates false even though authctxt->valid is true, that is, even for a perfectly real, existing account. The code falls through to the same salt = NULL branch used for a username that does not exist at all, not because anything checks for "*" specifically, but as a side effect of a length check that happens to read as "is this long enough to plausibly be a hash." [6]
When salt is NULL, xcrypt() calls pick_salt(), reproduced in full below with its original BSD license notice preserved. pick_salt() keeps a static 32-byte buffer scoped to one process and returns it immediately once already populated. On its first call per process, it walks every local account via setpwent()/getpwent(), and copies the algorithm-identifying prefix of the first hash it finds that starts with "$" and contains a second "$", which is any modular-crypt-format hash: MD5 ($1$), bcrypt ($2a$/$2b$/$2y$), SHA-256 ($5$), or SHA-512 ($6$), not a specific one of them. If nothing on the system has a usable hash, it falls back to the traditional DES-style salt "xx". [7]
Alpine’s advisory frames the precondition as needing another account with specifically a $5$ or $6$ hash. The code itself is broader than that: it accepts the first modular-crypt-format hash in whatever order getpwent() happens to enumerate accounts, which also covers MD5 and bcrypt. That gap between the advisory’s wording and the code’s actual behavior is this investigation’s own reading, not something either source states outright, but it is worth flagging: a box using only MD5 or bcrypt hashes elsewhere would still satisfy the real precondition, even if Alpine’s own phrasing might suggest otherwise.
One more boundary, and it is OpenSSH’s own, unrelated to musl: auth_password() rejects any password longer than MAX_PASSWORD_LEN, defined as 1024, before sys_auth_passwd() and therefore crypt() is ever reached. That is exactly why the advisory’s window tops out at 1024 bytes rather than being open-ended; a longer password never gets near the vulnerable comparison at all. [8]
/* auth-passwd.c, sys_auth_passwd() -- pre-fix */
char *pw_password = authctxt->valid ?
shadow_pw(pw) : xstrdup(pw->pw_passwd);
...
if (authctxt->valid && pw_password[0] && pw_password[1])
salt = pw_password;
else
salt = NULL;
encrypted_password = xcrypt(password, salt);
...
ret = strcmp(encrypted_password, pw_password) == 0;
/* openbsd-compat/xcrypt.c -- Copyright (c) 2003 Ben Lindstrom, BSD license */
static const char *
pick_salt(void)
{
struct passwd *pw;
char *passwd = NULL, *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 != NULL)
freezero(passwd, strlen(passwd));
if ((passwd = shadow_pw(pw)) == NULL)
continue;
if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
typelen = p - passwd + 1;
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:
if (passwd != NULL)
freezero(passwd, strlen(passwd));
endpwent();
return salt;
}Where /etc/shadow’s "*" Meets musl’s Own "*"
sshd believes one specific thing at this point in the code: the string crypt() returns for a genuine password can never equal the account’s own placeholder hash. The attacker controls exactly the one input that breaks that belief: how long their password is.
Alpine’s advisory states the vulnerable account state plainly: a shadow password field that is literally "*", the convention for an account with no usable password hash, a key-only account. It is explicit that a locked account, field "!", is not affected. [5]
musl’s crypt(), in its SHA-256 and SHA-512 hashing paths, returns the literal string "*", a string-literal constant stored in read-only memory, instead of NULL, when internal hash verification fails. That is exactly the contract violation the previous section described, now triggered by one concrete condition: an overlong input password. The exact per-algorithm check inside crypt_sha256.c and crypt_sha512.c was not independently read in this investigation; what is documented here is bounded to Alpine’s advisory, the reporting researcher’s own write-up, and a musl mailing-list thread from December 2024 describing the identical "*"-on-failure behavior in a completely different context, almost two years before this CVE existed. [5] [4] [9] musl’s own crypt.c is a thin dispatcher and does none of this checking itself, confirmed by direct inspection of the file. [10]
The failure that produces "*" is triggered by an overlong input password. Alpine’s advisory and the CVE record both give the same window: 257 to 1024 bytes. The lower bound is musl’s own internal threshold; the upper bound, as covered two sections ago, is OpenSSH’s pre-existing MAX_PASSWORD_LEN cap, unrelated to musl at all. [5] [1]
Put together: an attacker who sends a password in that byte range, against an account whose stored hash is "*", causes crypt() to compute and return "*". The comparison that follows is `strcmp("*", "*")`, which is true, by definition, because both sides are now the exact same one-character string. Authentication succeeds, for an account explicitly configured to never accept a password at all.
Neither half of this looks wrong in isolation, which is exactly why it is easy to miss. Type systems do not help: both sides of the comparison are char*, and "*" is a syntactically ordinary, successfully-returned string on both the shadow-read side and the crypt() side. Nothing here looks like an error condition to a static analyzer. And PAM would have caught this behavior by accident, through a worse bug: on a PAM-enabled system the same musl return value instead crashes pam_unix, because pam_overwrite_string() tries to write to that read-only string literal, a denial of service rather than a bypass, and a problem already reported independently in December 2024. Non-PAM systems have no equivalent intervening call, so the same underlying musl behavior produces a silent authentication success instead of a crash. [9]
/* musl libc, src/crypt/crypt.c -- MIT license */
char *crypt(const char *key, const char *salt)
{
static char buf[128];
return __crypt_r(key, salt, (struct crypt_data *)buf);
}
/* a thin dispatcher only -- the overlong-key / "*"-on-failure behavior
lives inside crypt_sha256.c / crypt_sha512.c, not here. That specific
file was not independently read in this investigation; see the text. */Two sides, each marked with an asterisk glyph: on the left, /etc/shadow’s convention that a bare asterisk means no password is ever valid; on the right, musl crypt()’s failure sentinel, which also returns a literal asterisk on certain failures. Both converge on a highlighted point reading strcmp of asterisk and asterisk equals zero, with the verdict that authentication succeeds.
/etc/shadow's own conventionBare asterisk means: no password is ever valid.
crypt()'s failure sentinelReturns the same character instead of NULL on failure.
strcmp("*", "*") == 0
MATCH: AUTHENTICATION SUCCEEDS
What Was Actually Built, Pinned, and Run
Everything in the next two sections comes from one actual run, not a thought experiment: a digest-pinned Alpine 3.24.4 image, built from a modified copy of a public proof-of-concept repository, executed on 7 November 2026 and independently re-verified against its own hash-recorded evidence before anything below was written.
The base image was alpine:3.24, pinned to its linux/amd64 manifest digest rather than trusted as a floating tag. Nothing here was left to a floating tag’s whim. The lab image itself was built deliberately with OpenSSH 10.6p1, compiled with --without-pam: the pre-fix, vulnerable version, not the patched 10.6p2. musl, libcrypto3, shadow, and zlib versions were captured directly from the built image’s own package metadata, not approximated from a live branch index.
Shadow entries for root, alice, alpine, and victim were set to "*"; bob was given a real, deterministic SHA-512 hash, built with a fixed salt fed through a file rather than a command-line argument, as a deliberate negative-test control. sshd_config set PasswordAuthentication yes and, deliberately changed from the public proof of concept’s own default, PermitRootLogin no, since root was never part of this lab’s approved test scope.
Isolation was tighter than Docker’s own defaults, not looser: the container ran on a bridge network configured --internal, with no default route out, Privileged false, CapDrop ALL, and only CAP_NET_BIND_SERVICE, CAP_SETGID, CAP_SETUID, and CAP_SYS_CHROOT added back, each justified against OpenSSH 10.6p1’s own privilege-separation source rather than assumed, plus no-new-privileges. No password or secret appeared on any process’s command line; every fixture was read from a file.
Only alice and bob were part of the approved test plan. What that boundary did, and did not, actually hold to is its own section, placed directly after the results rather than folded quietly into them.
Base image alpine:3.24, pinned to linux/amd64 digest
sha256:0f49f331d9212aff11b932863be4eb01cdf962969
aa86dab7d1ee21ac6f5f52b (resolves to Alpine 3.24.4)
Lab image sshole-lab:revision3
sha256:b973cd33dfd98a767b93cd5f95987d817f7b8eeda0
6e804e173b431ae6540e45
OpenSSH 10.6p1, built --without-pam (the pre-fix version,
deliberately -- this run tests the vulnerable build)
musl 1.2.6-r3
Capabilities --cap-drop=ALL
--cap-add=NET_BIND_SERVICE --cap-add=SETUID
--cap-add=SETGID --cap-add=SYS_CHROOT
--security-opt=no-new-privileges
Network sshole-lab-net, --internal (no default route out)
sshd_config PasswordAuthentication yes
PermitRootLogin noCase A Failed. Case B Succeeded. Bob Didn’t Move Either Way.
Three hypotheses went into this run, framed explicitly as things to test, not things to confirm. True, false, or inconclusive were each a legitimate, reportable outcome going in.
Case A tested a single, un-primed overlong password: 300 bytes, sent as the first and only password-authentication request of a fresh connection, against alice, whose shadow field is "*". It did not succeed. The server log shows one connection, one "Failed password for alice" line, and the connection closing. No "Accepted" line appears anywhere for that connection. This is recorded in the dossier as not-reproduced.
Case B tested the same password, primed: a short, deliberately wrong password sent first, immediately followed by the same 300-byte overlong password, as two sequential authentication requests inside one SSH connection against alice. It succeeded. The server log shows "Failed password for alice ... port 33394 ssh2" immediately followed by "Accepted password for alice ... port 33394 ssh2", both on the identical source port, start to finish, a single connection. The client transcript confirmed the expected marker of command execution as alice. This is recorded as reproduced.
Case C1 and C2 used bob, the real-hash control. C1, bob’s actual password, succeeded as a positive-control sanity check. C2, the identical 300-byte overlong password that had just worked against alice, failed against bob: "Accepted password for bob ... ssh2" for the correct password, "Failed password for bob ... ssh2" for the overlong one. Together these confirm that the overlong password alone does not bypass authentication generally; it only matches the degenerate "*" placeholder, exactly as the root-cause mechanism above predicts.
These four results are one data point from one specific pinned-digest build, run once, on 7 November 2026. They are not a claim about how CVE-2026-123158 behaves on every musl and OpenSSH combination everywhere; they are what this exact image, this exact OpenSSH 10.6p1 build, and these exact inputs actually did, on that one run. The dossier records them with that scope attached deliberately, and this article keeps the same framing rather than generalizing it.
Case A -- single-shot, alice, no priming -- not reproduced
Failed password for alice ... ssh2
[connection closes; no Accepted line for this connection]
Case B -- primed (short wrong, then overlong, one connection), alice -- reproduced
Failed password for alice ... port 33394 ssh2
Accepted password for alice ... port 33394 ssh2
Case C1 -- bob, real password, positive control -- reproduced
Accepted password for bob ... ssh2
Case C2 -- bob, same overlong password, negative control -- reproduced
Failed password for bob ... ssh2A table of four test cases from one lab run. Case A, a single un-primed overlong password against alice, logged a failed password and a closed connection: not reproduced. Case B, the same password primed by a short wrong attempt first, logged a failed password followed by an accepted password: reproduced. Case C1, bob's real password, logged an accepted password as a baseline. Case C2, the same overlong password against bob, logged a failed password as a negative control. An open question, tracked separately, notes that why priming mattered in this run is still unresolved at the source level.
Why priming mattered in this run is still unresolved at the source level: pick_salt() returns a salt synchronously on any call, with nothing in its own logic that obviously requires priming first.
What This Run Doesn’t Prove, and the Root Attempts Nobody Disclosed
A lab run is only as trustworthy as what it admits it did not check. This one admits three specific things plainly, rather than letting a clean set of in-scope results stand in for a clean process.
The first is a scope finding, not a mechanism finding. The same evidence file that recorded Case A’s single-shot test against alice also contains, earlier in the same capture window, an undisclosed sequence against root: one "Failed none for root" line, then two consecutive "Failed password for root ... ssh2" lines inside one connection, then the connection closing. No "Accepted" line for root appears anywhere. The submitted value behind those two attempts is unknown, and this account will not guess at it. root was never part of this lab’s approved scope, alice and bob only, at every layer of this task’s authorization, and the run operator’s own summary of what was tested never mentioned it.
Nothing was compromised: both attempts against root failed, and PermitRootLogin no was confirmed in the daemon’s own effective configuration for this run. A scope deviation that fails anyway is still a scope deviation. That is exactly why this dossier’s lab status is recorded as "partial" rather than "reproduced," a distinction worth keeping even where the in-scope evidence otherwise checks out cleanly. Why the sequence happened at all, whether a broader default username list built into the tooling ran instead of the plan’s scoped custom commands, or something else entirely, is a question for whoever ran the lab to answer directly, not one to speculate on here.
The second gap is a measurement limitation, not a scope one. Case B’s test ran a concurrent process-ID polling loop specifically to capture direct evidence of whether the same OS process served both the priming attempt and the overlong attempt, evidence that would bear directly on the open question in the next section. It collected exactly one usable sample during the exchange. That is consistent with single-process continuity across both attempts, not a continuous before-and-after comparison across the gap between them. Suggestive. Not conclusive.
The third gap concerns the lab’s own network path, not the vulnerability itself. The plan specified publishing the lab on 127.0.0.1:8222; the run operator reports that binding did not provide working host connectivity once the container was also attached to an --internal, no-egress network, and used a second, disposable test-client container on that same internal network instead, reaching the lab by its container DNS name. The container’s own inspect output still shows the requested port binding accepted into HostConfig.PortBindings, while the same file’s NetworkSettings.Ports, which records what Docker actually established at runtime rather than what was merely requested, reads empty: `{"22/tcp": []}`. That corroborates the report. It does not conclusively prove it. Nobody in this run actually attempted a connection to 127.0.0.1:8222 from outside the container to confirm it truly was not live.
None of this changes what Case A, B, C1, or C2 showed. It changes how much weight this one run can carry as a precedent for the next one, and the honest answer is less than a fully scope-compliant run would, specifically on the root-account point.
The Fix Doesn’t Touch musl. It Adds One Condition to One Comparison.
The fixing commit, 4353fdc43ef646c43a8dda7025cc1a898caf1482, "avoid password authn bypass," authored by Damien Miller on 5 November 2026, does not touch musl, does not touch pick_salt(), and does not touch the overlong-password behavior that triggers any of this. [11]
It adds `#define MIN_PASSWORD_HASH_LEN 4` next to the existing MAX_PASSWORD_LEN in auth-passwd.c, and changes the final authentication decision to require `strlen(pw_password) >= MIN_PASSWORD_HASH_LEN` in addition to the unchanged strcmp() check, where pw_password is the account’s own stored hash, never the attacker-influenced side of the comparison. [11]
Since "*" and "!" are both exactly one character, `strlen(pw_password) >= 4` is false for every account this bug could ever have reached, and the `&&` short-circuits before strcmp() even runs, regardless of what crypt() returns for any input, long or short.
The same memory-ownership change, a heap copy via xstrdup() and freezero() rather than a pointer the caller does not own, was applied across auth-passwd.c, openbsd-compat/port-uw.c (the UnixWare-specific equivalent path), and xcrypt.c’s pick_salt() and shadow_pw(). The new length check itself, however, landed only in auth-passwd.c’s generic path, not port-uw.c’s separate comparison. UnixWare is not a musl target, so this specific collision cannot occur via that path, which may be why the check was not considered necessary there; that is this investigation’s own plausible reading, not something the commit states outright. [11]
The commit message itself states that the bypass "affects some configurations and runtime configurations," and that the team does "not consider the root cause to be within OpenSSH" while being "well equipped to avoid it." That framing matches what this fix actually does: it blocks exactly the collision described above, a one-character sentinel colliding with a one-character shadow placeholder, by putting a floor on the stored hash’s length rather than validating that the computed hash is well-formed for its claimed algorithm. If musl, or any libc musl-compatible software links against, ever returned some other short, non-placeholder string on a different failure path, four characters or longer, this specific check would not catch it. Nothing in this investigation found such a path to currently exist; it is a limit on what the fix guarantees, not a known residual bug. [11]
/* auth-passwd.c, commit 4353fdc -- post-fix */
#define MIN_PASSWORD_HASH_LEN 4
...
ret = encrypted_password != NULL &&
strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;Alpine Didn’t Jump Every Branch to 10.6p2
Alpine did not move every supported branch to OpenSSH 10.6p2. It backported the fix into whatever version each branch was already running, which is standard distribution practice: keep the branch’s existing package version, patch the specific defect.
Alpine’s security tracker lists seven branch-specific fixed versions rather than one fleet-wide number. Every currently supported branch received the backport. [12]
If you are checking a fleet, match the branch to the table below rather than checking for the literal string "10.6p2" anywhere. A 3.21-main box running the backported 9.9_p2-r1 is patched, even though `sshd -V` will never print 10.6.
| Branch | Fixed package version |
|---|---|
edge-main | 10.6_p2-r0 |
3.24-main | 10.3_p1-r2 |
3.23-main | 10.2_p1-r1 |
3.22-main | 10.0_p1-r11 |
3.21-main | 9.9_p2-r1 |
3.20-main | 9.7_p1-r6 |
3.19-main | 9.6_p1-r3 |
What to Check in Your Logs Right Now
Alpine’s own advisory gives detection and mitigation guidance specific enough to act on tonight, not generic advice to "review your logs."
The detection logic is: identify every account whose shadow password field is exactly "*", then search the daemon’s own logs for a successful password login against exactly those accounts, the string "Accepted password for" is the marker to look for. Alongside that, review doas usage, recently created users, new files, and authorized_keys changes. If a password login against a passwordless account during the exposure window cannot be ruled out, the advisory’s own position is to treat the machine as compromised. [5]
Mitigation is either a package upgrade followed by a daemon restart, since upgrading the package alone does not replace the already-running process, or disabling password authentication entirely as a stopgap, validated before the restart takes effect. [5]
One root-specific nuance worth stating precisely: Alpine’s default PermitRootLogin prohibit-password already protects root from this bypass unless an administrator has explicitly set PermitRootLogin yes, even though root itself ships as root:* in the base image. That default does not cover every path to root, though: any other password-login-capable account that can escalate, for example through doas, still creates real exposure even while root’s own direct password login stays protected. [5]
# Package upgrade, then restart -- upgrading alone does not
# replace the already-running daemon
apk upgrade -U -a
rc-service sshd restart
# Or: disable password authentication entirely
# PasswordAuthentication no (in sshd_config)
sshd -t
rc-service sshd restart
# Detection: look for a successful password login on an account
# that should never have one
grep "Accepted password for" /var/log/messages
# then cross-reference each username against /etc/shadow for a
# password field that is exactly "*"Why Did the Same Password Fail on the First Try and Work on the Second?
Why did the exact same overlong password fail the first time and succeed the second time, against the same account, in the same run?
Alpine’s own advisory states the attack "takes one connection... and needs no retries." Both independently authored proof-of-concept scripts reviewed for this dossier, the one supplied for the lab build and a separate one referenced directly in the CVE record’s own CNA references, use two password-authentication attempts inside one connection anyway: a short wrong password first, then the overlong one, both attributing this in their own code comments to priming a cached salt before the real attempt. That is not necessarily a contradiction. Both still use a single SSH transport, and "no retries" most plausibly describes the attack being deterministic, not needing repeated probabilistic guessing, rather than asserting that exactly one authentication message is sufficient. But it also does not settle the question in the other direction. [5] [13] [14]
Tracing pick_salt()’s own code does not resolve it either. The function scans for and returns a usable salt synchronously, within the same call, the first time it runs. Nothing in that logic obviously requires an earlier call to have already happened before a long password can be matched against a freshly found salt. By the code’s own logic, a single, un-primed overlong password sent on the very first authentication request of a connection should also reach pick_salt(), get a salt back, and have crypt() run against it, exactly as the second attempt does in both known proof of concepts.
What the lab run added is real, and it is exactly one data point: single-shot failed (Case A), primed succeeded (Case B), in this one specific build. That is hash-backed evidence that priming mattered in this one run. It is not an explanation of why. The pick_salt() puzzle above is untouched by that result. "Primed works, single-shot doesn’t" is consistent with several explanations this investigation did not independently confirm: a detail of OpenSSH’s own privilege-separation process lifetime (sshd splits each connection’s authentication work between a privileged monitor process and an unprivileged worker process; this investigation did not trace how long each one actually lives), specifically which of those two processes executes pick_salt() and whether its lifetime spans one authentication attempt or the whole connection; some other stateful behavior neither proof of concept’s authors nor this investigation identified; or something specific to this exact build and timing that might not generalize at all.
So: this question is still open at the source-mechanism level after this lab run, exactly as it was before it. Treat the primed-success, single-shot-failure split as one data point worth taking seriously, not as a settled explanation either way. The next useful step, if anyone pursues it, is tracing OpenSSH’s exact privilege-separation process model for this specific build, or re-running the single-shot case under different timing to see whether the result holds.
Two smaller loose threads, flagged rather than guessed at: musl’s own source for the exact overlong-key check inside crypt_sha256.c and crypt_sha512.c was never independently read in this investigation, only described by the advisory and the 2024 mailing-list report. And whether the GitHub handle behind sshhole.com’s write-up is the same person OpenSSH’s release notes credit by name is plausible, from the matching technical content, but confirmed by nothing in the record. Neither is load-bearing for any claim made here.
The Lesson Is About Interface Contracts, Not About musl or OpenSSH
Patch musl when it ships a fix. Patch OpenSSH, which already has one. Neither instruction is the actual engineering lesson here.
crypt()’s interface contract promises NULL on failure. musl’s own implementation of that contract does not always keep that promise, and the promise /etc/shadow’s "*" convention separately relies on, that this specific character never shows up as a genuine crypt() result, turned out to depend entirely on the first promise holding. Neither subsystem is wrong in isolation: /etc/shadow’s "*" convention predates musl and has nothing to do with it, and musl’s choice of failure sentinel has nothing to do with OpenSSH or shadow files. The bug lives entirely in the gap between them, in an assumption nobody had written down because it had simply always held.
CWE-394, the classification this CVE actually carries, is "Unexpected Status Code or Return Value," and that is precisely what this is. It is also a "harmless-looking" bug in two separate senses at once: a "*" shadow field looks inert by design, and musl’s "*"-on-failure behavior had already been reported, independently, in December 2024, in a completely different context, crashing pam_unix, almost two years before anyone connected it to an OpenSSH bypass. Same underlying contract violation, two very different consequences, entirely dependent on which caller happened to touch it.
The practical version for anyone maintaining a comparison like this one: an interface contract is only as strong as every caller’s willingness to check it, not as strong as the contract’s own wording. If you compare a computed value against a stored one, and either side has a documented "something went wrong here" sentinel, check whether that sentinel could ever land on the other side’s "nothing to see here" value too, before you trust the comparison at all. That is not a musl-specific or an OpenSSH-specific check. It is the actual invariant this entire CVE turned out to be missing.
This is exactly the kind of boundary a secure code review is built to find: not the obvious injection point, but the comparison nobody thought to question, because both sides had always looked clean on their own. If your own stack has a C-level authentication or comparison path that has never been traced function by function the way this one was traced here, that is precisely the gap a code-level review exists to close.
Sources
- [1] CVE Program / MITRE, CISA ADP: CVE-2026-123158 record
- [2] Damien Miller: OpenSSH 10.6p2 released (openssh-unix-dev)
- [3] OpenSSH Project: 10.6p2 release notes
- [4] oz9un: *sshole (reporting researcher’s write-up)
- [5] Alpine Linux: OpenSSH password authentication bypass advisory
- [6] OpenSSH Project / OpenBSD: auth-passwd.c
- [7] OpenSSH Project: openbsd-compat/xcrypt.c
- [8] AOSP mirror: auth-passwd.c (MAX_PASSWORD_LEN)
- [9] musl mailing list (Openwall): crypt() returns "*" from read-only memory, Dec 2024
- [10] musl libc project: src/crypt/crypt.c
- [11] OpenSSH Project: commit 4353fdc, "avoid password authn bypass"
- [12] Alpine Linux security tracker: CVE-2026-123158
- [13] halilkirazkaya: CVE-2026-123158 (PoC / lab repository)
- [14] predkambrij: sshhole-repro (independent PoC)
Need this checked in your own product?
Security code reviewWant to learn to find vulnerabilities like this yourself?
Secure code review course, one to one