Skip to content

Add vuln-sshhole check for OpenSSH on musl without PAM - #53

Merged
hdm merged 7 commits into
runZeroInc:mainfrom
pasadoorian:feature/sshhole
Nov 5, 2026
Merged

hdm merged 7 commits into
runZeroInc:mainfrom
pasadoorian:feature/sshhole

Conversation

@pasadoorian

@pasadoorian pasadoorian commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Adds vuln-sshhole for the password bypass at https://sshhole.com/ (no CVE yet). OpenSSH sshd built against musl without PAM accepts any 257 to 1024 byte password for a valid user whose shadow field is *, when another account has a $5$ or $6$ hash. OpenSSH 10.6p2 (2026-11-05, "avoid password authn bypass") rejects short stored hashes, and Alpine backported that as pw.patch to 3.20, 3.22 and 3.24.

The first password attempt on a connection always fails. musl's getspnam() returns a pointer into a static buffer, and pick_salt() calls it for every account and zeroes the hash it copies, so sys_auth_passwd() compares against an empty string. pick_salt() caches the salt, so later attempts on the same connection compare against *. A probe that sends one long password per connection never finds the bug.

The check runs per user on OpenSSH servers older than 10.6p2 that offer password; fixed releases are skipped without a connection, and distribution backports with an older banner are probed but rejected cleanly. On one connection it sends a 256-byte random control password, then 257 bytes, then 1024 bytes if 257 fails, and reports only when the control is rejected and a longer password is accepted. A Too many authentication failures disconnect at MaxAuthTries counts as a rejected attempt. The proof records the output of id; uname -a; ls /lib/ld-musl-*; head -3 /etc/os-release.

A second connection then authenticates with the accepted password alone, wrapped in ssh.RetryableAuthMethod so it is resent once. When that opens a session the password is cached as SessionAuth, so -I first and -I all and the session checks work on real musl hosts, and the proof records which attempt logged in.

analyze now writes <id>.jsonl for each reported vuln. docs/targets/sshhole builds Docker controls: unpatched 10.3p1 from the tarball, the same with no $6$ account, a PAM build, Alpine's patched package, and Debian with glibc. docs/targets/README.md describes the layout for future targets.

It also fixes parseTargets, which appended every address of an expanded CIDR a second time, so each host was scanned twice and the password checks doubled their failed logins. TestParseTargetsCIDR covers it.

Testing: the original check flagged root and alpine only on 10.3p1/musl 1.2.6 and 9.7p1/musl 1.2.5 with the four negative controls clean. The merged check adds an in-process SSH server that models the first-attempt failure and covers the finding, MaxAuthTries 2, the version gate, the password-any skip, and reuse of the cached credential. go test ./... passes.

pasadoorian and others added 5 commits November 5, 2026 12:14
OpenSSH sshd built against musl libc without PAM accepts any password of
257 to 1024 bytes for a valid user whose shadow field is "*"
(https://sshhole.com/, no CVE yet). pick_salt() borrows the $5$/$6$ salt
of another account, musl's SHA-256/512 crypt returns "*" for keys over
256 bytes, and that matches the "*" field.

The first password attempt on a connection always fails, because
pick_salt() runs getspnam() for every account and overwrites the static
buffer that sys_auth_passwd() still points at. So the check sends a
256-byte control password first, then 257 and 1024 bytes on the same
connection, and reports a finding only when the control is rejected and
a trigger is accepted. A second connection records whether a single
attempt works; it does not on 9.7p1 or 10.3p1, so no reusable session is
claimed.

analyze now writes vuln-<id>.jsonl for every reported vuln.

testdata/sshhole builds controls on Alpine and Debian. Alpine's own
openssh packages carry the upstream fix as pw.patch since 2026-11-05, so
the vulnerable control is built from the upstream tarball. Verified:
10.3p1/musl 1.2.6 and 9.7p1/musl 1.2.5 flag root and alpine only; the
no-hash, PAM, patched-package and Debian controls are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseTargets appended every address of an expanded CIDR a second time,
so each host was scanned twice (and with a user@ prefix, once with and
once without the user). Against password checks that doubles the failed
logins per host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpenSSH answers the attempt that reaches MaxAuthTries with a "Too many
authentication failures" disconnect instead of USERAUTH_FAILURE. The
check counted that attempt as never tried, so servers with
MaxAuthTries 3 (Cisco FMC, NX-OS, ISE in the lab) were reported as
inconclusive. The reason now also says how many attempts were rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renames vuln-sshhole to vuln-musl-crypt-star, following the mechanism-named
vuln checks, and combines the two implementations of the same probe.

Kept from the sshhole check: every password goes out on one connection
(256-byte control, then 257 and 1024 bytes), since the first attempt on a
connection always fails on musl; a MaxAuthTries disconnect counts as a
rejection; PerSourcePenalties refusals are logged instead of reported; the
finding is reported on an accepted trigger password with a proof command
that shows the libc and OS.

Added from vuln-musl-crypt-star: the check skips OpenSSH 10.6p2 and later,
the release that rejects short password hashes, so no failed logins are
spent on fixed servers; an exec that ends without an exit status still
counts as having run; and an in-process SSH server covers the check end to
end, now modelling the first-attempt failure and MaxAuthTries 2.

The cached session credential now wraps the trigger password in
ssh.RetryableAuthMethod, so a fresh connection resends it once and interact
and the session checks work on real musl hosts. The check confirms that on
a second connection before claiming a reusable session and records the
attempt that logged in.

analyze writes each vuln record as <id>.jsonl, since vuln IDs already carry
the vuln- prefix.
docs/targets holds one directory per check with a README that explains how
to build the target, run the check against it, and confirm the finding and
its absence on the controls. The Alpine and Debian images, setup script and
run.sh move there from testdata/sshhole, with the container names following
the check name, and the README covers the five variants, the version gate,
the interactive session and the unit tests.
@hdm hdm changed the title Add vuln-sshhole check for OpenSSH on musl without PAM Add vuln-musl-crypt-star check for OpenSSH on musl without PAM Nov 5, 2026
The check, its files, the docs/targets directory and the container names
all use the advisory name, so one name refers to the bug everywhere.
@hdm hdm changed the title Add vuln-musl-crypt-star check for OpenSSH on musl without PAM Add vuln-sshhole check for OpenSSH on musl without PAM Nov 5, 2026
@hdm
hdm merged commit 29bcc09 into runZeroInc:main Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants