ポケモン金銀で、ボックスを切り替えるレポートの最中に電源を切ると、預けたはずのポケモンが手元にも残る。子どものころ、あれで増やした人は多いと思います。
ボックスに書き込む処理も、手持ちから消す処理も、単体ではどこも壊れていません。壊れていたのは処理と処理の「継ぎ目」でした。
2026年11月、これとよく似た形のバグが OpenSSH で見つかりました。名前は *sshole。「256文字を超えるパスワードを打つと、ログインできないはずのアカウントに入れてしまう」というものです。X では「Linuxに致命的な脆弱性」という見出しで回っていました。
Linuxに致命的な脆弱性 パスワードに適当な256文字を打つとroot権限が奪えてしまうバグ見つかる
— ツイッター速報〜BreakingNews (@tweetsoku1) November 6, 2026
musl系で約10年潜伏していました。
最初に聞きたくなるのは「なんでこんなバグが?」だと思います。
先に安心材料:すべての Linux の話ではありません
見出しだけ読むと Linux 全体が危なそうに見えますが、対象はかなり狭いです。
鍵になるのは C ライブラリです。ファイル操作や文字列の比較、パスワードのハッシュ化など、Linux 上のほぼすべてのプログラムが共通で使う部品集です。Linux では主に2系統あり、Ubuntu や Debian、RHEL は glibc、Alpine Linux や OpenWrt は musl を使っています。今回の穴が開くのは musl の上だけです。
そのうえで、次の条件が全部揃ったときだけ起きます。
| 条件 | よくある環境 |
|---|---|
| C ライブラリが musl | Alpine Linux、OpenWrt など |
| OpenSSH を PAM なしで動かしている | Alpine の openssh-server は PAM なし(PAM 版は openssh-server-pam という別パッケージ) |
PasswordAuthentication が有効 |
鍵認証だけなら対象外 |
パスワード欄が * のアカウントがある |
ログイン禁止のアカウント |
| パスワードハッシュを持つ別のアカウントがある | 普通にパスワードを設定したユーザー |
コラム:条件表のわかりにくい行
1. C ライブラリが musl
同じ crypt() という名前の関数でも、glibc と musl では中身が別物です。失敗したときに "*" を返すのは musl のほうです。Docker で FROM alpine と書けば、中は musl になります。
2. OpenSSH を PAM なしで動かしている
PAM は、Linux のログイン確認を一手に引き受ける窓口です。Ubuntu などの sshd は「この人を通していい?」と PAM に聞くだけで、自分ではパスワードを比べません。PAM なしの sshd は、自分で crypt() を呼んで /etc/shadow の値と文字列で比べます。今回の穴はこの「自分で比べる」経路にあるので、PAM ありならこの経路を通りません。
3. パスワード欄が * のアカウントがある
/etc/shadow は1行1アカウントで、: 区切りの2番目がパスワードのハッシュです。ここが * のアカウントは、パスワードでのログインを禁止されています。システム用のアカウントや、鍵認証専用のユーザーによく使われる書き方です。今回入れてしまうのは、このアカウントです。
4. パスワードハッシュを持つ別のアカウントがある
本文の「なんで?③」で説明する、OpenSSH が計算方式を借りる相手です。$6$... のような正しいハッシュを持つアカウントが1つでもあると、条件が揃います。
逆に、次の環境は対象外です。
- Ubuntu、Debian、RHEL などの glibc 系(PAM を通るので経路が違う)
-
PasswordAuthentication noにして鍵認証だけで運用しているサーバー - sshd を動かしていないコンテナ(Alpine のコンテナの大半はこれ)
root への直接ログインは、OpenSSH の標準設定で止まります。PermitRootLogin prohibit-password が、root のパスワードログインを許さないからです。報道で「root を奪える」とされているのは、sudo や doas を使えるアカウントに入れた場合の話です。
対処は OpenSSH 10.6p2 への更新です。すぐ更新できないなら、パスワード認証を切って sshd を再起動すれば塞がります。
ここからが本題の「なんで?」です。
なんで?① musl は親切のつもりで "*" を返していた
パスワードの照合には、昔から crypt() という関数を使います。入力したパスワードを、保存されているハッシュと同じ方式で計算し直して、文字列として比べる仕組みです。
musl の crypt() には、入力の長さに上限があります。SHA-256 / SHA-512 方式では256バイトです。とても長い入力で計算させ続ける DoS を防ぐための、まっとうな制限です。
上限を超えたらどうするか。musl のソースの冒頭コメントにはこう書いてあります。
/*
* public domain sha512 crypt implementation
* ...
* on error "*" is returned.
*/
失敗したときに返すのは NULL でなく "*" です。これは親切な設計です。呼び出し側が NULL チェックを忘れていてもクラッシュしません。しかも "*" は、どんな正しいハッシュとも一致しません。比べても必ず「不一致」になる、安全側の値のはずでした。
この記事では、あえて「親切」という言葉を使います。ここから出てくる部品は、どれも誰かがよかれと思って作ったものです。一つひとつを見ても、手抜きや悪意は見当たりません。それでも穴が開いた、というのがこのバグの一番おもしろいところです。
なんで?② shadow の * も同じ親切だった
一方、/etc/shadow のパスワード欄にも * が入ることがあります。これは昔からの「パスワードでログインさせない」という印です。システム用のアカウントや鍵認証専用のユーザーに使われます。
なぜ * なのか。理由は musl とまったく同じで、どんな正しいハッシュとも一致しない文字列だからです。crypt() がどんな入力から計算しても * 1文字にはならないので、照合は必ず失敗します。
並べるとこうなります。
| 値 | 意味 | 選ばれた理由 | |
|---|---|---|---|
musl の crypt()
|
"*" |
計算に失敗した | どのハッシュとも一致しないから |
/etc/shadow |
* |
ログイン禁止 | どのハッシュとも一致しないから |
どちらも「ハッシュとは絶対に一致しない値」として選ばれています。誰も想定していなかったのは、この2つどうしを比べる場面でした。
なんで?③ 2016年の良かれと思った変更が、2つを出会わせた
もうひとつの部品が OpenSSH です。
ログインに失敗したとき、ユーザーが存在しない場合だけ応答が速いとします。すると時間を測るだけで、「このユーザー名は存在する」と推測されてしまいます。そこで OpenSSH は、存在しないユーザーやログイン禁止のユーザーでも、わざと crypt() を走らせるようになりました。応答時間を揃えるためです。The Register の報道によれば、この対策は2016年からです。
ただ、保存値が * 1文字だと、ハッシュ計算に使う salt(計算方式の指定)として使えません。そこで OpenSSH は、他のアカウントの正しいハッシュから方式を借りて計算します。ソースのコメントにも「salt がなければタイミング対策のための偽の計算だ」と書いてあります。
/*
* If we don't have a salt we are encrypting a fake password for
* for timing purposes. Pick an appropriate salt.
*/
ここまで揃うと、継ぎ目がつながります。
アスタリスク(*)は、ギリシャ語で「小さな星」という意味です。musl の "*" と shadow の *。別々の場所で生まれた2つの星が、OpenSSH という天の川を渡って出会ってしまいました。七夕なら年に一度ですが、こちらは10年かかっています。
修正前の OpenSSH の判定は、要約すると次の1行でした。
return encrypted_password != NULL &&
strcmp(encrypted_password, pw_password) == 0;
musl は NULL を返さないので、1つ目のチェックは通ります。2つ目の strcmp は "*" と "*" を比べるので一致します。「失敗」と「禁止」が、同じ1文字で書かれていました。
冒頭の条件表にあった「パスワードハッシュを持つ別のアカウント」は、この「方式を借りる」部分から来ています。
修正と教訓:「無効」どうしを等しいとみなさない
OpenSSH 10.6p2 の修正(コミット 4353fdc)は小さなものです。保存されている値が4文字未満なら、一致とはみなさない条件が1行足されました。
- return encrypted_password != NULL &&
+ ret = encrypted_password != NULL &&
+ strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
strcmp(encrypted_password, pw_password) == 0;
MIN_PASSWORD_HASH_LEN は 4 です。* や ! のような印は、比較の前に弾かれます。
コミットメッセージにはこう書かれています。「根本原因は OpenSSH の中にあるとは考えていないが、回避できる立場にある」。どの部品が悪いとも言い切れないので、継ぎ目に一番近い OpenSSH が蓋をしました。
glibc 系のディストリでは、libxcrypt という実装がよく使われます。そのマニュアルは、失敗時の戻り値をこう定めています。
This string will be shorter than 13 characters, will begin with a
*, and will not compare equal to setting.
失敗の印が比較相手と一致しないことまで、仕様で約束しています。
この「無効な値どうしは等しくない」という考え方には、有名な先例があります。浮動小数点の NaN です。NaN == NaN が false になるのは、「計算できなかった」どうしを等しいと判定させないためです。
自分のコードに持ち帰るなら、次の3点だと思います。
- エラーを表す値(
"*"、-1、空文字、null)を、正常な値と同じ型・同じ比較で扱わない - 番兵の値を選ぶとき、別の層が同じ文字を別の意味で使っていないか確認する
- 比較の前に、「どちらかが失敗の印なら不一致」と先に判定する
まとめ
*sshole は、次の3つがそれぞれ正しく動いた結果でした。
- 長すぎる入力を断った musl
- ログイン禁止に
*を使う shadow の慣習 - ユーザー名を推測させないために、禁止アカウントでも計算を走らせた OpenSSH
どれも「一致しないはずの値」を選んでいたのに、その2つが同じ文字でした。電源切り増殖と同じく、部品の中には穴がなく、穴は継ぎ目にありました。
Alpine で sshd を動かし、パスワード認証を有効にしている方は要注意です。10.6p2 への更新か、パスワード認証の停止をおすすめします。それ以外の Linux は、慌てなくて大丈夫です。
あなたのコードで返している -1 や空文字は、どこか別の層で「正しい値」として読まれていないでしょうか。似た衝突を見たことがあれば、コメントで教えてください。
X(@kenimo49)でも、こういう「えっ、なんで?」な技術の話を発信しています。よければフォローしてください。Qiita のフォローでも、新しい記事の通知が届きます。
参考
- OpenSSH 10.6p2 リリースノート(OpenSSH project): https://www.openssh.org/txt/release-10.6p2
- 修正コミット
4353fdc"avoid password authn bypass"(Damien Miller): https://github.com/openssh/openssh-portable/commit/4353fdc43ef646c43a8dda7025cc1a898caf1482 - musl
src/crypt/crypt_sha512.c(musl libc): https://git.musl-libc.org/cgit/musl/tree/src/crypt/crypt_sha512.c - libxcrypt
crypt(3)マニュアル(Björn Esser ほか): https://github.com/besser82/libxcrypt/blob/develop/doc/crypt.3 - The Register "Get root on a musl Linux system by slamming it with an overly long password": https://www.theregister.com/security/2026/11/05/get-root-on-a-musl-linux-system-by-slamming-it-with-an-overly-long-password/5307910
- 発見者: Özgün Kültekin(Wakefield)

