いつもの「ロック済み」が、なぜか扉になるのか
Linux の世界では、パスワード欄に * が入っているアカウントは「実質ログイン不可」の印です。root や鍵専用ユーザー、LDAP 連携のユーザー、あるいは初期状態のクラウドイメージに置かれたユーザーでも、* が入っていればパスワード認証では通さない、という前提で運用してきました。文字としてはただの 1 文字ですが、意味はかなり重い。
ところが OpenSSH と musl の組み合わせでは、その * が思った通りに効かない場面がありました。条件はかなり絞られます。OpenSSH 10.6p2 より前、musl を使う環境、PAM なし、そして PasswordAuthentication yes。この並びがそろうと、* を持つアカウントに対して、ある長さを超えたパスワードが通ってしまう。しかも「どんなパスワードでも」ではなく、長さの条件があるところが厄介です。
この手の不具合は、ひとつの巨大な穴というより、複数の前提が静かに噛み合った結果として現れます。OpenSSH 側は「パスワードをハッシュ化して照合する」だけの素直な流れを持ち、musl 側は crypt() が失敗したときに * を返す設計を採った。どちらも単体では筋が通っているのに、その境目で「失敗の印」と「本物の拒否文字」が同じ形になってしまったわけです。
パスワード照合は、見た目よりずっと素朴だ
SSH のパスワード認証を、むずかしい暗号の話だと思う人は多いはずです。ですが入口の考え方自体は単純で、入力された文字列を同じ salt でハッシュ化し、保存されている文字列と一致するかを見るだけです。$6$... のような形式が見えたら、それは SHA-512 系のハッシュを表していると考えればよい。照合結果が一致すれば入れる、しなければ拒否する。
問題は「そもそも保存されている値が * だったら何を比較するのか」です。* はハッシュではないので、どんな通常のパスワードとも一致しないはずです。古い Unix の考え方では、これで「このアカウントはログイン対象ではない」と明示できる。/etc/passwd 時代の名残として、今でも広く使われています。
ところが OpenSSH は、照合の前に「そのアカウントに見せかけのハッシュを作る」ため、他のアカウントから salt を借りて crypt() を呼ぶ経路を持っています。ふつうはこれで時間をそろえるためのダミー計算になるのですが、musl 側の crypt() が特定条件で * を返すと事情が変わる。保存済みの * と、失敗時に返る * が同じ文字列になり、比較が成立してしまうのです。
2012 年の musl と 2016 年の OpenSSH が、別々に正しいことをした
musl は 2012 年に crypt() の失敗時挙動を変えました。従来のように NULL を返す代わりに、単一文字の * を返すようにしたのです。理由はわかりやすく、呼び出し側が NULL を見落として落ちるより、必ず失敗として扱える値を返したかったからです。ここまでは、かなり堅い設計に見えます。
一方、OpenSSH は 2016 年に別の問題へ対処しました。存在しないユーザーのときに認証が早く終わりすぎると、時間差からユーザーの有無が推測できる。そこで「別のアカウントの salt を借りて、わざと同じくらいの時間を使う」修正が入った。違うユーザーでも、見た目の処理時間を揃えるためです。これも筋は通っています。
ただ、この二つを重ねると、* の意味がぶつかります。OpenSSH は「比較のための仮の計算」をし、musl は「失敗したら * を返す」。そして比較対象の本物の値が、ちょうど * で止まっている。ここで長い入力だけが通る、という現象が顔を出します。公開された説明では 257 文字以上とされていますが、どの境目でそうなるかの実装上の細部は、ここではまだ追いません。
10年気づかれなかったのは、条件が薄すぎたから
「10 年も放置された」と聞くと、派手なミスに見えます。ですが実際には、かなり狭い条件がそろわないと発火しません。まず musl 系であること。Alpine、OpenWrt、Buildroot、Yocto などが該当しうる一方、glibc の一般的なディストリビューションでは踏みにくい。次に PAM なしであること。OpenSSH は PAM を使う構成だと別の経路に入り、この * のすれ違いを通りません。
さらに、対象アカウントが * を持っている必要があります。root であれ、鍵専用ユーザーであれ、LDAP ユーザーであれ、そもそもパスワードを持たないアカウントでなければ意味がない。しかも長い入力を送る必要があるので、普通の試し打ちではまず見えません。何かを誤って貼り付けたときにだけ、突然「通る」側に転ぶ。人間が気づきにくいのは自然です。
OpenSSH のテストが OpenBSD や glibc 中心だったこと、PAM 経由の運用が多かったことも、見逃しやすさを後押ししました。Debian の議論では以前から似た違和感に触れた痕跡があり、別の文脈では * に似た扱いを巡る話も出ていましたが、sshd のこの経路に真正面からつながるまでには至っていなかったようです。つまり、誰も全然見ていなかったというより、見えても別のものに見えていた。
そして修正は、たった数行のように見える
2026 年 11 月 5 日、OpenSSH 10.6p2 が出ました。公開リリースノートでは、この問題を「Linux systems that use the musl libc and PAM disabled」に対する回避策として明示しています。GitHub のコミットを見ると、修正の中心はかなり短い。保存されたパスワード文字列の長さを見て、短すぎるものは比較しない。さらに、libc の静的バッファに依存する扱いもやめる。
ここで面白いのは、原因のすべてを OpenSSH に押しつけていない点です。OpenSSH 側は「根本原因は自分たちの外にある」と述べつつ、実際の認証経路では被害を避けるようにふるまう。musl 側も crypt() の返し方に理由がある。つまり、片方だけを責めても話は終わらない。境界面で事故が起きた以上、境界面で止血するしかない、という構図です。
利用者側の話に落とすと、影響範囲は意外に現場寄りです。Alpine の公式クラウドイメージのように、* のユーザーに doas が絡むと、単なるログイン可否の話では済みません。PasswordAuthentication no を入れるだけで避けられるなら、そこを先に塞ぐのが現実的です。では、なぜ 257 文字で境目が出るのか。crypt() が何を返し、OpenSSH がどの文字列を比べていたのか――その噛み合わせをたどると、ようやく一本の線になります。
👉 続き(核心はここから)は PaPoo で読めます: https://papoo.work/doc/14650d873099b4c0