Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 17 min read

sshole CVE-2026-123158 on Alpine: ssh -V can't tell you if it's patched, so check the package revision and restart sshd

On Alpine's backported stable branches, ssh -V cannot tell you whether CVE-2026-123158 (sshole) is patched: on 3.22, the vulnerable 10.0_p1-r10 and the fixed 10.0_p1-r11 both print OpenSSH_10.0p2. Compare the package rev

sshole CVE-2026-123158 on Alpine: ssh -V can't tell you if it's patched, so check the package revision and restart sshd

On Alpine's backported stable branches, ssh -V cannot tell you whether CVE-2026-123158 (sshole) is patched: on 3.22, the vulnerable 10.0_p1-r10 and the fixed 10.0_p1-r11 both print OpenSSH_10.0p2. Compare the package revision against the Alpine advisory instead, then confirm that the running sshd was actually restarted.

The issue, nicknamed sshole, was disclosed on 2026-11-05 alongside OpenSSH 10.6p2 and Alpine's patches. Plenty of coverage already explains the bug, so this post sticks to the judgment call: is my own sshd on musl exposed, patched, and running the patched binary? I don't run sshd on musl or Alpine myself, so I only verified the check procedure in clean containers on 2026-11-06. I did not reproduce the bypass or attempt any login. A read-only POSIX shell script is included below.

All four conditions must hold for CVE-2026-123158 to apply

The Alpine advisory lists four conditions. If any one is missing, the bypass does not work. The measured column shows the default state of containers with the fixed package on Alpine 3.22.8, 3.23.8 and 3.24.4.

Condition How to check Measured on a default install
sshd without PAM usepam line in sshd -T no such line (build without PAM)
PasswordAuthentication is on sshd -T yes
An account whose password field is * second field of /etc/shadow root only
Another local account with a $5$ or $6$ hash second field of /etc/shadow none

So a fresh install lacks the fourth condition. The moment someone sets one local password, all four are met. A server with key-only * accounts next to a password account fits this.

The advisory also counts directory users coming through musl-nscd under the third condition. They have no local shadow entry, so /etc/shadow will not show them.

The root cause sits outside OpenSSH, as the OpenSSH release notes say. According to the advisory, musl's crypt() returns * as its error value for an overlong key, and that matches the * stored for an account with no password. The fix commit refuses a match when the stored field is shorter than MIN_PASSWORD_HASH_LEN.

The CVE record scores it CVSS 3.1 8.1. CISA's assessment when I checked on 2026-11-06 was public PoC, not automatable. The record names Alpine as an example, while the Alpine advisory itself did not cite the CVE number on that date. The notices and reports I read mention no exploitation in the wild. That proves nothing, so the log check further down is still worth doing.

These setups are not affected

This list follows the Alpine advisory and the OpenSSH notice:

  • Containers that don't run sshd. I opened the default alpine:3.22, 3.23 and 3.24 images and none contained sshd.
  • Servers using openssh-server-pam with UsePAM yes.
  • Servers with PasswordAuthentication no.
  • Linux without musl. The notice targets Linux with musl libc and PAM disabled.
  • Cloud images configured through cloud-init, which writes PasswordAuthentication no.

root is only directly reachable with PermitRootLogin yes, even when its field is *. The defaults I measured were without-password on 3.22 and prohibit-password on 3.23 and 3.24. The advisory adds that another account able to become root through doas still gets there.

Official tiny-cloud images from 3.18.11 through 3.24.3 ship an alpine account with a * field and passwordless doas. They are safe on first boot, and the conditions are met as soon as a $6$ hash exists, for example from a console password.

Same ssh -V, different patch state: the revision decides

Two packages can print the same OpenSSH_10.0p2 and still differ in revision and binary, because Alpine's stable branches backported the fix instead of moving to 10.6p2. The vulnerable packages are already gone from the 3.22, 3.23 and 3.24 repository listings. So I took 10.0_p1-r10 from the alpine-virt-3.22.7 ISO after verifying its signature, and installed it in a container with no network. I never started that sshd and only compared versions:

## Before the fix (Alpine 3.22.7 + package from the ISO)
$ ssh -V
OpenSSH_10.0p2, OpenSSL 3.5.9 29 Sep 2026
$ apk info -v | grep openssh-server-1
openssh-server-10.0_p1-r10
$ sha256sum /usr/sbin/sshd   # first 16 characters
2a78bc6d0cabd9bb  /usr/sbin/sshd

## After the fix (Alpine 3.22.8)
$ ssh -V
OpenSSH_10.0p2, OpenSSL 3.5.9 29 Sep 2026
$ apk info -v | grep openssh-server-1
openssh-server-10.0_p1-r11
$ sha256sum /usr/sbin/sshd   # first 16 characters
54fbd1f03a0aeeb4  /usr/sbin/sshd

ssh -V prints the same line for the vulnerable r10 and the fixed r11, while the package revision and sshd binary hash differ

The two ssh -V lines are identical to the character. Only r10 versus r11 in apk info -v and the sha256 differ. Note also that the package is named 10.0_p1 while ssh -V prints 10.0p2.

The CVE record lists versions below 10.6p2 as affected, so a plain version string comparison flags the patched r11 as vulnerable. Reading 10.0p2 as "patched" is just as wrong.

These are the fixed versions from the advisory. At or above this version on a branch, the fix is in:

Branch Fixed openssh Measured ssh -V
edge 10.6_p2-r0 not measured
3.24 10.3_p1-r2 OpenSSH_10.3p1
3.23 10.2_p1-r1 OpenSSH_10.2p1
3.22 10.0_p1-r11 OpenSSH_10.0p2
3.21 9.9_p2-r1 not measured
3.20 9.7_p1-r6 not measured
3.19 9.6_p1-r3 not measured
3.18 9.3_p2-r4 not measured
3.17 9.1_p1-r7 not measured
3.16 9.0_p1-r6 not measured

No usepam line in sshd -T means a build without PAM

A missing usepam line is not a safe result. The default openssh-server package is built without PAM, and that build's sshd -T prints no usepam line at all. The missing line is the build that meets the condition. Skipping past an empty grep usepam flips your verdict:

$ sshd -T | grep -iE '^(usepam|passwordauthentication) '
passwordauthentication yes

$ sshd.pam -T | grep -iE '^(usepam|passwordauthentication) '   # openssh-server-pam
usepam no
passwordauthentication yes

Installing openssh-server-pam is not enough either. Its default is usepam no, which the advisory lists as affected. That package also ships sshd.pam and no sshd binary.

Install state Binary usepam in sshd -T Condition
openssh-server default sshd no line met
openssh-server + UsePAM yes sshd no line met
openssh-server-pam default sshd.pam no met
openssh-server-pam + UsePAM yes sshd.pam yes not met

The second row is the trap. Adding UsePAM yes to the default package only printed an Unsupported option UsePAM warning. sshd -t still exited 0 and the usepam line stayed absent, so passing the config test does not mean PAM is on.

One read-only script gives the verdict

The script below prints all of the above in one pass. It writes no files, opens no network connection and never attempts a login. From /etc/shadow it prints only * account names and counts, never a hash. Run it as root on the server itself:

#!/bin/sh
# musl-sshd-check.sh β€” read-only exposure check for CVE-2026-123158 (OpenSSH + musl, no PAM).
# Reads local package data, sshd -T output, /etc/shadow and /var/log/messages.
# Writes nothing, opens no network connection, never attempts a login and never prints a hash.
# Run as root on the machine itself: sh musl-sshd-check.sh

LOG=/var/log/messages

# Fixed revisions from the Alpine advisory of 2026-11-05.
fixed_for() {
  case "$1" in
    edge) echo 10.6_p2-r0 ;;
    3.24) echo 10.3_p1-r2 ;;
    3.23) echo 10.2_p1-r1 ;;
    3.22) echo 10.0_p1-r11 ;;
    3.21) echo 9.9_p2-r1 ;;
    3.20) echo 9.7_p1-r6 ;;
    3.19) echo 9.6_p1-r3 ;;
    3.18) echo 9.3_p2-r4 ;;
    3.17) echo 9.1_p1-r7 ;;
    3.16) echo 9.0_p1-r6 ;;
    *) echo "" ;;
  esac
}

SSHD=""
for c in sshd sshd.pam sshd.krb5; do
  p=$(command -v "$c" 2>/dev/null) && { SSHD=$p; break; }
done

if [ -z "$SSHD" ]; then
  echo "sshd_binary: none"
  echo "VERDICT: NOT AFFECTED (no OpenSSH sshd installed)"
  exit 0
fi
echo "sshd_binary: $SSHD"

# 1. libc
if ldd "$SSHD" 2>/dev/null | grep -q 'musl'; then libc=musl; else libc=other; fi
echo "libc: $libc"
if [ "$libc" != musl ]; then
  echo "VERDICT: NOT AFFECTED (sshd is not linked against musl)"
  exit 0
fi

# 2. package revision
patched=unknown
if [ -r /etc/alpine-release ] && command -v apk >/dev/null 2>&1; then
  rel=$(cut -d. -f1,2 /etc/alpine-release)
  case "$(cat /etc/alpine-release)" in *_alpha*) rel=edge ;; esac
  pkg=$(apk info -v 2>/dev/null | grep -E '^openssh-server(-pam|-krb5)?-[0-9]' | head -1)
  ver=$(echo "$pkg" | sed -E 's/^openssh-server(-pam|-krb5)?-//')
  fix=$(fixed_for "$rel")
  echo "alpine_branch: $rel"
  echo "package: ${pkg:-not found}"
  echo "fixed_revision: ${fix:-unknown for this branch}"
  if [ -n "$ver" ] && [ -n "$fix" ]; then
    case "$(apk version -t "$ver" "$fix")" in
      '<') patched=no ;;
      *) patched=yes ;;
    esac
  fi
else
  echo "package: not an apk system, compare your vendor's advisory"
fi
echo "patched: $patched"

# 3. effective config. A build without PAM prints no usepam line at all.
cfg=$("$SSHD" -T 2>/dev/null)
if [ -z "$cfg" ]; then
  echo "sshd_T: failed (run as root, host keys required)"
  pwauth=unknown
  usepam=unknown
else
  pwauth=$(echo "$cfg" | awk '$1=="passwordauthentication"{print $2}')
  usepam=$(echo "$cfg" | awk '$1=="usepam"{print $2}')
  [ -n "$usepam" ] || usepam="absent(no PAM in this build)"
  echo "permitrootlogin: $(echo "$cfg" | awk '$1=="permitrootlogin"{print $2}')"
fi
echo "passwordauthentication: $pwauth"
echo "usepam: $usepam"
# sshd -T shows global values only, so a Match block can turn passwords back on.
m=0; pm=0; inc=0
for f in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do
  [ -r "$f" ] || continue
  n=$(grep -ciE '^[[:space:]]*Match[[:space:]]' "$f")
  m=$((m+n))
  n=$(awk '{l=tolower($0)} l ~ /^[ \t]*match[ \t]/{i=1} i && l ~ /^[ \t]*passwordauthentication[ \t=]+"?yes/{c++} END{print c+0}' "$f")
  pm=$((pm+n))
  n=$(grep -iE '^[[:space:]]*Include[[:space:]]' "$f" | grep -vc 'sshd_config\.d/\*\.conf')
  inc=$((inc+n))
done
echo "match_blocks: $m"
echo "match_password_yes: $pm"
echo "other_includes: $inc"

# 4. account state. Names of '*' accounts and counts only.
star_names=""
star=0; bang=0; sha=0
if [ -r /etc/shadow ]; then
  star_names=$(awk -F: '$2=="*"{printf "%s ", $1}' /etc/shadow)
  star=$(awk -F: '$2=="*"{n++} END{print n+0}' /etc/shadow)
  bang=$(awk -F: '$2 ~ /^!/{n++} END{print n+0}' /etc/shadow)
  sha=$(awk -F: '$2 ~ /^\$[56]\$/{n++} END{print n+0}' /etc/shadow)
  echo "shadow_star_accounts: $star ($star_names)"
  echo "shadow_bang_accounts: $bang"
  echo "shadow_sha_hash_accounts: $sha"
else
  echo "shadow: not readable (run as root)"
fi

# Directory users through musl-nscd have no shadow entry, so the counts above miss them.
nscd=no
if apk info -e musl-nscd >/dev/null 2>&1 || [ -S /var/run/nscd/socket ]; then nscd=yes; fi
echo "musl_nscd: $nscd"

# 5. listeners still using a replaced binary. Connections opened before the update
# keep the old binary until they close, and restarting sshd does not end them.
stale=0; old=0; running=0
for d in /proc/[0-9]*; do
  e=$(readlink "$d/exe" 2>/dev/null) || continue
  case "${e% (deleted)}" in
    */sshd|*/sshd.pam|*/sshd.krb5|*/sshd-session*|*/sshd-auth*) running=$((running+1)) ;;
    *) continue ;;
  esac
  case "$e" in *" (deleted)") ;; *) continue ;; esac
  case "$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null)" in
    *"[listener]"*) stale=$((stale+1)) ;;
    *) old=$((old+1)) ;;
  esac
done
echo "sshd_processes: $running"
echo "listeners_on_replaced_binary: $stale"
echo "connections_on_replaced_binary: $old"

# 6. password logins for accounts that have no password
hits=0
if [ -r "$LOG" ]; then
  for a in $star_names; do
    n=$(grep -c "Accepted password for $a " "$LOG")
    [ "$n" -gt 0 ] || continue
    if [ "$usepam" = yes ]; then
      echo "trace_note: $n 'Accepted password for $a' in $LOG (UsePAM yes, check how this user authenticates)"
    else
      echo "trace: $n 'Accepted password for $a' in $LOG"
      hits=$((hits+n))
    fi
  done
  if [ "$nscd" = yes ]; then
    for u in $(grep -o 'Accepted password for [^ ]*' "$LOG" | awk '{print $4}' | sort -u); do
      grep -q "^$u:" /etc/shadow 2>/dev/null && continue
      n=$(grep -c "Accepted password for $u " "$LOG")
      echo "trace_note: $n 'Accepted password for $u' in $LOG (no shadow entry, check how this user authenticates)"
    done
  fi
  echo "trace_hits: $hits"
else
  echo "trace_hits: $LOG not readable or absent, check your log destination"
fi

# Verdict
pw_open=no
if [ "$pwauth" != no ] || [ "$pm" -gt 0 ]; then pw_open=yes
elif [ "$m" -gt 0 ] || [ "$inc" -gt 0 ]; then pw_open=unknown
fi
conditions=no
if [ "$pw_open" != no ] && [ "$usepam" != yes ]; then
  if [ ! -r /etc/shadow ]; then conditions=unknown
  elif [ "$sha" -gt 0 ] && [ "$star" -gt 0 ] && [ "$pw_open" = yes ]; then conditions=yes
  elif [ "$sha" -gt 0 ] && { [ "$star" -gt 0 ] || [ "$nscd" = yes ]; }; then conditions=unknown
  fi
fi
if [ "$hits" -gt 0 ]; then
  echo "VERDICT: INVESTIGATE (password login recorded for an account with no password)"
elif [ "$patched" = no ] && [ "$conditions" = yes ]; then
  echo "VERDICT: EXPOSED (old revision, all conditions met)"
elif [ "$patched" = no ] && [ "$conditions" = unknown ]; then
  echo "VERDICT: UPDATE NEEDED (old revision, conditions could not be confirmed)"
elif [ "$patched" = no ]; then
  echo "VERDICT: UPDATE NEEDED (old revision, conditions not all met now)"
elif [ "$patched" = yes ] && [ "$stale" -gt 0 ]; then
  echo "VERDICT: RESTART REQUIRED (package fixed, running listener is the old binary)"
elif [ "$patched" = yes ]; then
  echo "VERDICT: PATCHED"
else
  echo "VERDICT: UNKNOWN (compare your vendor's advisory, conditions met: $conditions)"
fi

This is the result on a default fixed 3.22.8 install. root is *, but the revision is fixed, so it ends in PATCHED:

sshd_binary: /usr/sbin/sshd
libc: musl
alpine_branch: 3.22
package: openssh-server-10.0_p1-r11
fixed_revision: 10.0_p1-r11
patched: yes
permitrootlogin: without-password
passwordauthentication: yes
usepam: absent(no PAM in this build)
match_blocks: 0
match_password_yes: 0
other_includes: 0
shadow_star_accounts: 1 (root )
shadow_bang_accounts: 16
shadow_sha_hash_accounts: 0
musl_nscd: no
sshd_processes: 0
listeners_on_replaced_binary: 0
connections_on_replaced_binary: 0
trace_hits: /var/log/messages not readable or absent, check your log destination
VERDICT: PATCHED

musl-sshd-check.sh output on a patched Alpine 3.22.8 container

There are seven possible verdicts. All test environments in the table were containers with no network.

VERDICT Meaning State I tested
EXPOSED old revision, conditions met r10 + one hash account, sshd -T failing
UPDATE NEEDED old revision, conditions not met now or not confirmed r10 + no hash account, sshd -T failing; the "not confirmed" paths are branch tests only
RESTART REQUIRED package fixed, running sshd is the old file fixed sshd started, then the file replaced
PATCHED at or above the fixed revision fixed package defaults
INVESTIGATE password login recorded for a * account one synthetic log line
NOT AFFECTED no sshd, or not musl default image without sshd
UNKNOWN fixed version not known release changed to a branch outside the table

The two r10 rows are branch tests only. To avoid running the r10 sshd I masked it with another file, so passwordauthentication printed unknown, which the script treats as on. On a real server, run it as root so the value is read properly. I did not exercise the path for an sshd that isn't linked against musl.

EXPOSED is deliberately broad. permitrootlogin is printed but not used in the verdict, so a server whose only * account is root with direct login blocked can still show EXPOSED.

Even with a global no, a PasswordAuthentication yes inside a Match block counts as open, and match_password_yes is the number of those lines. I tested this by reading the fixed sshd's real config while faking only the apk response as r10, and it printed EXPOSED. The script reads only /etc/ssh/sshd_config and /etc/ssh/sshd_config.d/*.conf.

It does not follow conditional Include lines. So with a global no plus a Match block or an Include of another path, it won't claim the conditions are unmet. It ends in conditions could not be confirmed, and you check per-account values with sshd -T -C as shown below.

The script cannot count directory users. If musl_nscd: yes and there is no local * account, it also ends in conditions could not be confirmed, and you need to check the directory accounts yourself. The same wording appears when it can't read /etc/shadow because it isn't root.

Check scripts and reproduction code are circulating online, and I ran none of them. Read any script before running it as root on a server, and that includes this one.

After the package upgrade, restart sshd or you aren't done

Replacing the package does not replace the running daemon. With a fixed sshd running, I swapped the file underneath it and the process kept holding the deleted old file. To avoid starting the vulnerable revision, this test reinstalled the same revision to replace only the file:

$ readlink /proc/18/exe      # before replacing
/usr/sbin/sshd
$ apk fix -r openssh-server
$ readlink /proc/18/exe      # package replaced, not restarted
/usr/sbin/sshd (deleted)
$ readlink /proc/38/exe      # after restart
/usr/sbin/sshd

The order below follows the Alpine advisory:

  1. Keep one SSH session open while you upgrade the package.
   apk upgrade -U -a
  1. Restart sshd.
   rc-service sshd restart
  1. Confirm key login works from a new session, then close the old one.
  2. Run the check script again and confirm RESTART REQUIRED is gone.
  3. Rebuild any container image with sshd baked in. Per the advisory, images built from 3.21.10, 3.22.8, 3.23.8 and 3.24.4 onward contain the fix.

RESTART REQUIRED looks at the listener only. Connections opened before the update, like the session you kept in step 1, may hold the old file, but they are counted separately as connections_on_replaced_binary and don't affect the verdict. The Alpine OpenRC script only ends connections at system shutdown, so a restart won't lower that number. Closing those sessions will.

If you can't upgrade yet, block it with PasswordAuthentication no

The advisory says either the update or this setting closes the hole. Confirm key login works first, or you will lock yourself out. A drop-in line alone leaves any Match override in place. The advisory's full command also rewrites lines inside Match blocks in sshd_config:

mkdir -p /etc/ssh/sshd_config.d
echo 'PasswordAuthentication no' > /etc/ssh/sshd_config.d/00-no-passwords.conf
sed -i.bak -e '1i PasswordAuthentication no' \
  -e 's/^\(\s*PasswordAuthentication[^y#]*\)yes/\1no/i' \
  /etc/ssh/sshd_config
sshd -t && rc-service sshd restart
sshd -T | grep -i passwordauthentication

Alpine reads the drop-in directory from 3.19 on, and earlier releases ignore it. The sed keeps the original as sshd_config.bak. It only touches sshd_config, so a Match inside a drop-in file has to be fixed by hand.

sshd -T shows global values only, so the final no alone isn't proof. I added a Match User keyonly block in fixed 3.22 and 3.24 containers, and both gave the same result:

# drop-in only
sshd -T: passwordauthentication no
sshd -T -C user=keyonly: passwordauthentication yes
# after the advisory's sed
sshd -T: passwordauthentication no
sshd -T -C user=keyonly: passwordauthentication no

On a server that uses Match, query the value for a specific account like this. With Match Address, replace addr with a real client address too:

sshd -T -C user=<account>,host=localhost,addr=127.0.0.1 | grep ^passwordauthentication

Check Accepted password for lines first to see if you were hit

An Accepted password for line on an account with no password should be treated as suspected compromise, because that account should not be able to log in with a password. This is also the detection method the advisory gives:

grep "Accepted password for" /var/log/messages

Compare the account names with shadow_star_accounts from the script. The script does the same comparison and prints INVESTIGATE on a match. I tested that path with one synthetic log line only and have never seen a real compromise log.

trace_note is a reference line. On UsePAM yes servers, or for directory users with no shadow entry, PAM may have handled the login normally, so those records don't become INVESTIGATE. Check how that user actually authenticates.

If you find such a record, or no logs survive, continue with:

  • doas usage records
  • recently created users and files
  • changes to authorized_keys

If your server logs somewhere other than /var/log/messages, look there, since the script reads only that file. The advisory says to treat the machine as compromised if you can't rule out a login.

Takeaways

On Alpine, judge CVE-2026-123158 by the package revision rather than ssh -V, then confirm the listener restarted. If you use PasswordAuthentication no as a stopgap, check Match overrides with sshd -T -C.

Unverified

Actual bypass behavior. I did not reproduce the bypass or attempt a long-password login. The description comes from the OpenSSH notice, the CVE record and the Alpine advisory.

Whether the fix blocks it. I did not test that r11 actually stops the bypass. The patched verdict relies only on the advisory's version table.

Exploitation in the wild. As of 2026-11-06 the notices and reports I read mention none. That doesn't mean there was none.

OpenWrt, Buildroot, Yocto. These come up because they use musl, but I found no official notice, and I did not check OpenWrt's default SSH server setup. The script is built around Alpine packages.

Cloud images. Statements about tiny-cloud and cloud-init images are quoted from the advisory. I did not boot a real cloud image.

OpenRC restart path. OpenRC doesn't run in the containers, so I did not run rc-service sshd restart. I stopped and started the process directly and saw (deleted) disappear.

The vulnerable revision's sshd. r10 was installed but its sshd was never run, so I have no sshd -T output from it.

Vulnerable revisions outside 3.22. My before/after comparison covers 3.22's r10 only. I did not confirm the same ssh -V pattern on other branches with vulnerable packages.

Drop-ins on 3.18 and older. I confirmed drop-ins on 3.22 and 3.24 only. The 3.19 boundary is quoted from the advisory, and older releases need sshd_config edited directly.

Existing connection handling. Without logging in I could not test a real connection. I saw the branch only with a fake process, a busybox copy started under the name sshd-session. The [listener] title that identifies the listener was confirmed on 3.22, 3.23 and 3.24 only.

musl-nscd branch. I tested this branch by faking the apk response without installing musl-nscd, and never saw a real directory user setup.

Public check scripts. I did not run any external check script or reproduction code.

NVD assessment. I could not confirm the content of the NVD page.

Changelog

Version Description
1.0 2026-11-06 first English version, based on Korean version 1.0 (container measurements on Alpine 3.22.8, 3.23.8, 3.24.4, version comparison with the vulnerable r10, read-only check script)

AI disclosure: translated and edited with AI assistance (Claude) from my Korean original. Measurements were run with Claude Code under my supervision; I reviewed the result.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.