-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The two asterisks have separate histories and the same byte\n\nA 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.\n\nmusl'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:\n\n```c\n/*\n * public domain sha512 crypt implementation\n *\n * original sha crypt design: http://people.redhat.com/drepper/SHA-crypt.txt\n * in this implementation at least 32bit int is assumed,\n * key length is limited, the $6$ prefix is mandatory, '\\n' and ':' is rejected\n * in the salt and rounds= setting must contain a valid iteration count,\n * on error \"*\" is returned.\n */\n```\n\nOpenSSH'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.\"\n\n## The code path, pre-patch\n\nHere 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.\n\n```c\nint\nsys_auth_passwd(struct ssh *ssh, const char *password)\n{\n Authctxt *authctxt = ssh->authctxt;\n struct passwd *pw = authctxt->pw;\n char *encrypted_password, *salt = NULL;\n\n /* Just use the supplied fake password if authctxt is invalid */\n char *pw_password = authctxt->valid ? shadow_pw(pw) : pw->pw_passwd;\n\n if (pw_password == NULL)\n return 0;\n\n /* Check for users with no password. */\n if (strcmp(pw_password, \"\") == 0 && strcmp(password, \"\") == 0)\n return (1);\n\n /*\n * Encrypt the candidate password using the proper salt, or pass a\n * NULL and let xcrypt pick one.\n */\n if (authctxt->valid && pw_password[0] && pw_password[1])\n salt = pw_password;\n encrypted_password = xcrypt(password, salt);\n\n /*\n * Authentication is accepted if the encrypted passwords\n * are identical.\n */\n return encrypted_password != NULL &&\n strcmp(encrypted_password, pw_password) == 0;\n}\n```\n\nWalk the line-by-line for an account with shadow field `\"*\"` and a 300-byte submitted password.\n\n`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()`.\n\n`pick_salt()` is where the 2016 patch lives.\n\n## pick_salt is the 2016 timing-side-channel armor\n\n```c\nstatic const char *\npick_salt(void)\n{\n struct passwd *pw;\n char *passwd, *p;\n size_t typelen;\n static char salt[32];\n\n if (salt[0] != '\\0')\n return salt;\n strlcpy(salt, \"xx\", sizeof(salt));\n setpwent();\n while ((pw = getpwent()) != NULL) {\n if ((passwd = shadow_pw(pw)) == NULL)\n continue;\n if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {\n typelen = p - passwd + 1;\n /*\n * bcrypt ($2*$NN$) has no '$' between its 22 character\n * salt and the hash, so keep the salt too or crypt(3)\n * will reject the setting without doing any work.\n */\n if (passwd[1] == '2' && strlen(p + 1) >= 22)\n typelen += 1 + 22;\n strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));\n explicit_bzero(passwd, strlen(passwd));\n goto out;\n }\n }\n out:\n endpwent();\n return salt;\n}\n```\n\nThe 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.\n\nThe comment inside `xcrypt()` explains why this function exists at all:\n\n```c\n/*\n * If we don't have a salt we are encrypting a fake password for\n * for timing purposes. Pick an appropriate salt.\n */\n```\n\nThat 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.\n\nThat 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.\n\nThe researcher who disclosed this ([Özgün Kültekin](https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/)) 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(\"*\", \"*\")`.\n\n## musl knew\n\nThis is the line that forces the reread. `src/crypt/crypt_des.c` line 1015, in the DES crypt implementation:\n\n```c\nreturn (setting[0]=='*') ? \"x\" : \"*\";\n```\n\nThe 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.\n\nThe SHA-256 and SHA-512 paths do not have that defense. Their wrappers look like this:\n\n```c\nchar *__crypt_sha256(const char *key, const char *setting, char *output)\n{\n static const char testkey[] = \"Xy01@#\\x01\\x02\\x80\\x7f\\xff\\r\\n\\x81\\t !\";\n static const char testsetting[] = \"$5$rounds=1234$abc0123456789$\";\n static const char testhash[] = \"$5$rounds=1234$abc0123456789$3VfDjPt05VHFn47C/ojFZ6KRPYrOjj1lLbH.dkF3bZ6\";\n char testbuf[128];\n char *p, *q;\n\n p = sha256crypt(key, setting, output);\n /* self test and stack cleanup */\n q = sha256crypt(testkey, testsetting, testbuf);\n if (!p || q != testbuf || memcmp(testbuf, testhash, sizeof testhash))\n return \"*\";\n return p;\n}\n```\n\n`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](/patterns/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.\n\n## The patch is three words: MIN_PASSWORD_HASH_LEN 4\n\nHere is OpenSSH's fix, committed by Damien Miller as `4353fdc` on 2026-11-05:\n\n```diff\n+#define MIN_PASSWORD_HASH_LEN\t4\n #define MAX_PASSWORD_LEN\t1024\n ...\n-\treturn encrypted_password != NULL &&\n+\tret = encrypted_password != NULL &&\n+\t strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&\n \t strcmp(encrypted_password, pw_password) == 0;\n```\n\nA 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.\n\nThe commit message is two sentences:\n\n> 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.\n\nDamien 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.\n\n## The two-attempt quirk\n\nThe 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:\n\n> 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.\n\n`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.\n\n## The ecosystem response is 19 repositories, mostly not exploits\n\nThree 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.\n\nThe 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.\n\n## Who the case is about\n\nThe 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.\n\nThe 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.\n\nPoC: [gabrielunknown/CVE-2026-123158_SSHole](https://github.com/gabrielunknown/CVE-2026-123158_SSHole), [CriticalWombat/sshole_scanner-CVE-2026-123158](https://github.com/CriticalWombat/sshole_scanner-CVE-2026-123158)","closing_line":"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.","hook_md":"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 `\"*\"`.","post_id":849,"slug":"cve-2026-123158-sshole-2016-timing-fix-is-why-it-reaches","title":"CVE-2026-123158 (SSHole): The 2016 Timing-Side-Channel Fix Is The Reason The 2026 Bypass Reaches","type":"initial","unreadable_sentence":"The 2016 patch didn't introduce the bug. It made the bug reachable."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCavDMtQAKCRDeZjl4jgkQ JqqeAP4z1pEhe8m8V1gXPoQ8nWJSUN3t1sxQ21+jvhywXytAOgEA+se8NiMLORPc ROCVxSYo2qc7WbNM/usjrmlM3885sQs= =Xx2E -----END PGP SIGNATURE-----