未来を形作るテクノロジーの深掘り記事。

Alpine LinuxのSSHバグ:10年間見過ごされた1文字

Alpine LinuxのSSHバグで、256文字超のパスワードが鍵専用アカウントに通っていた10年間。muslのcrypt()の挙動が原因でした。

暗いサーバールームに、アスタリスク形の開口部が光る金庫の扉
システムファイル中の1文字が、ロックされたアカウントと開いた扉を分けていました。

ここで気になる数字を一つ挙げます。257です。約10年間、SSHのパスワード入力欄に257文字以上のパスワードを入力すると、拒否されるどころかログインできてしまう環境がありました。どんなパスワードでもです。キーボードの適当な連打でも、休暇の写真のbase64ダンプでも、誤って貼り付けたSlackのメッセージ半分でも、長ささえ足りていれば扉が開きました。「sshole」と名付けられたAlpine LinuxのSSHバグは、条件を確認するまではジョークのように見えますが、確認すると障害報告書として読めてしまう類の脆弱性です。

要点はこうです。musl libcでビルドされたOpenSSHを使う環境、つまりAlpine、OpenWrt、Buildroot、Yocto、そしてインターネット上のDockerベースイメージのかなりの割合がこれに当たりますが、そこでは数学的にログイン不可能なはずのアカウントに、257〜1,024文字の任意のパスワードでログインできてしまいました。rootも、鍵認証専用のデプロイ用ユーザーも対象です。Alpineの公式AWS・Azureイメージに含まれる既定のalpineユーザーは、パスワードなしでdoasが使える状態で出荷されていました。修正はOpenSSH 10.6p2に入っています。ただ、この問題が生まれた経緯は1980年までさかのぼる小さくて合理的な判断の連鎖で、その一つひとつが当時は正しく見えていたのです。

Alpine LinuxのSSHバグの仕組み

この衝突を理解するには、3つの要素が必要です。それぞれ別のコードベースと別の年代に属しています。まず、Linuxがパスワードをどう保存するかです。パスワードを設定すると、ハッシュは/etc/shadowに1アカウント1行で書き込まれます。通常のアカウントの行は$6$salt$hashのような形で、$6$はsha512cryptを意味します。ところが、デーモンや鍵専用ユーザー、ロックされたrootのように、そもそもパスワードを受け付けるべきでないアカウントには、代わりにプレースホルダーが入ります:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

ログイン時、sshdは入力されたパスワードを保存済みのsaltでハッシュ化し、結果を保存されたフィールドと文字列比較します。同じ文字列なら認証成功です。アスタリスクは番兵値で、どんなパスワードのハッシュでもないため、どのパスワードとも一致しません。これは初期のUnixの時代から鍵専用アカウントのロック機構として使われてきました。比較が絶対に成功しないからこそ機能するわけですが、ハッシュ関数そのものがアスタリスクを出力するように仕向けられると話は別です。そこで二つ目の要素に進みます。

二つ目の要素はmuslのエラー処理です。伝統的にcrypt()は仕事を遂行できないとき、たとえばsaltが環境に存在しないアルゴリズムを指定している場合にNULLを返します。問題は、何十年分のソフトウェアがNULLのチェックをせず、そのままstrcmpに渡してセグメンテーション違反で落ちることでした。そこで2012年、muslは意図的な設計判断をしました。そのcrypt()は決してNULLを返さない、というものです。失敗時には、実在するパスワードのハッシュには絶対に一致しない文字列を返すことで、壊れた呼び出し元がクラッシュではなく「パスワードが違う」という結果を見るようにしたのです。失敗時の文字列として選ばれたのは*でした。その3週間後、muslは入力256バイト上限付きでsha512cryptを追加します。ハッシュの計算コストはパスワード長に比例するため、極端に長いパスワードはサービス拒否攻撃の手段になり得るからです。長すぎるものは拒否する。拒否は失敗であり、失敗は*を返す。この挙動を説明するコメントは、ずっとソースコードに残っていました。

三つ目の要素は、sshdの「丁寧すぎる」振る舞いです。2016年、CVE-2016-6210により、応答時間の差を使ってユーザー名を列挙できることが示されました。存在しないユーザーはハッシュを計算すべきものがないため、即座に拒否されていたのです。修正は巧妙でした。使用可能なハッシュを持たないアカウントに対しては、別のアカウントのsaltを借りて、入力されたパスワードをあえてハッシュ化し、同じ時間をかけるようにしたのです。つまり、わざと遅くするわけです。問題は、この経路が存在しないユーザーだけでなく、shadowのフィールドが*である実在ユーザーにも発動することでした。アスタリスクは使用可能なハッシュではないからです。こうしてsshdはcrypt(your_password, borrowed_salt)を計算し、その結果を*と比較するようになりました。

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

すべての要素が噛み合う必要があります。保存されたフィールドが*で、cryptが失敗し、その失敗値も*である。glibcでは、crypt失敗時にNULLが返るため、比較は安全に失敗します。libxcryptのシステムでは失敗時の文字列が*0で、1文字違うだけです。しかし、その1文字がすべてを左右します。*0は*と決して等しくならないからです。muslでは、エラー値とロック値が同じ1文字です。strcmpにはエラーと一致を区別する手段がありません。同じ値が二つの意味を持ち、受け取る側がそれを見分けられないのです。

10年間の、どれも妥当だった判断

この脆弱性が学ぶ価値を持つ理由は、仕組みそのものではありません。関係者の誰一人として、明らかに愚かなことはしていないのです。1980年を振り返ると、パスワードハッシュがまだ誰でも読める/etc/passwdに置かれていた時代、Bill Joyは、誰もログインすべきでない「daemon」というアカウントを作り、そのパスワードハッシュの位置に*を置きました。数十年先を行く、きれいな番兵値です。(本名欄には「the devil himself」と書かれていたそうです。)Unixは今でもこの方法でアカウントをロックしています。

2012年のmuslの判断も、どの角度から見ても擁護できるものでした。クラッシュする呼び出し元は現実の問題ですし、何十年分の杜撰なアプリケーションコードに対してNULLを武器のように使うCライブラリは、多くの敵を作ります。決して一致しない文字列を返すのは、穏やかな機能縮退です。ハッシュに起因するサービス拒否を防ぐためにパスワード長に上限を設けるのも同様です。sshdのタイミング対策も、ハッシュ化できないユーザーへの応答を埋め合わせるのは標準的な手法でした。Alpineが次にしたことも、同じく妥当に見えました。2019年、CVE-2019-5021により、AlpineのDockerイメージのrootパスワード欄が空で出荷されており、構成によってはパスワードがまったくない状態だったことが明らかになりました。Alpineはrootを!でロックしました。ところが、PAMがない場合、sshdは!を完全なロックとして扱い、鍵によるSSHログインまで止めてしまいます。クラウドイメージは鍵ログインに依存しているため、これは致命的でした。そこで2022年、Alpine 3.16はrootを*に移しました。ロックされたままで鍵は使え、全員が満足しました。ただし、*こそがmuslのcryptに出力させられてしまう、まさにその文字列だったのです。

どの一歩も、実在する問題に対する妥当な対応でした。だからこそ居心地が悪いのです。あなたも各判断の場にいたら、うなずいていたはずです。

ニアミスの方がむしろ厄介です。あるDebianの開発者は、glibcにmuslと全く同じ*の挙動を採用するよう求めました。その6年後、Debianはlibxcryptに移行しましたが、その*0は偶然にも穴を塞ぎました。BusyBoxのスレッドでは、257文字のパスワードでpasswdが詰まるという不満が出ていました。同じアスタリスク、同じ上限でしたが、スレッドの誰も「sshd」とは言いませんでした。セキュリティレビュー担当者はsshdのpick_salt()関数にパッチを当てましたが、本当のバグはその4行下にありました。誰かが最終的に見つけたのは、その4日前のことです。このバグは、ほぼ正面から見ていた人々によって、何度も見過ごされてきたのです。

暗いテーブルの上を連鎖的に倒れていく、さまざまな素材のドミノ
数十年にわたる小さくて妥当な判断が、次々と連鎖的に倒れていきました。

お使いのインフラはmusl版OpenSSHのバグの影響を受けますか?

ssholeの救いは、被害を受けるには条件がすべて揃う必要があることです。一つでも欠ければ安全です。次の条件がすべて当てはまる場合、影響を受けます:

  • sshdがmuslでビルドされている。Alpine、OpenWrt、Buildroot、Yocto、またはalpineから派生しopenssh-serverを入れたDockerイメージ。
  • sshdがPAMを経由していない。AlpineのOpenSSHは既定ではPAMを使わず、あるいはUsePAM noを設定している。PAMの経路では*のアカウントに対してcryptが呼ばれることはありません。
  • パスワード認証が有効。既定で有効です。
  • 少なくとも一つのアカウントの/etc/shadowに素の*がある。鍵専用ユーザー、LDAP連携のアカウント、Alpineのrootなど。
  • 少なくとも別のアカウントが実際のパスワードハッシュを持っている。借りるsaltが必要だからです。

この五つすべてが揃うと、そのマシン上のすべての*アカウントが、257〜1,024文字の任意のパスワードを受け入れてしまいます。最悪の構成は、Alpineの公式AWS・Azureイメージです。既定のalpineユーザーは鍵専用(つまり*)で、パスワードなしのdoasが使えます。そこで長いパスワードでログインするのは権限昇格ではなく、rootへの裏口そのものです。長い間この問題が表に出なかった理由の一つとして評価すべき点があります。OpenSSHのPermitRootLoginは2015年以降、rootのパスワードログインを拒否しているため、最も分かりやすい標的は既定である程度守られていました。しかし、デプロイ用ユーザーやサービスアカウントを守るものは何もありませんでした。

Debian、Ubuntu、Fedoraなど、glibcベースのディストリビューションなら問題ありません。PAMを経由する構成も大丈夫です。Dropbearも、OpenBSDも大丈夫です。懐疑的な方への注意点として、この問題の再現を試みたコミュニティの報告には一貫性がありませんでした。五つの条件は一つずつ見落としやすいからです。公式Alpineイメージを試した人が、rootのフィールドが*ではなく!であることに気づかず、再現できずに終わったケースがありました。「自分のノートPCでは再現しなかった」は、本番環境について何の根拠にもならないと考えてください。雰囲気ではなく、条件を確認してください。

本当の根本原因:インバンドシグナリングとマジックストリング

ssholeを「変わったlibcのエッジケース」として片付けたくなるかもしれません。しかし、障害モードはそれよりも古く、より一般的なものです。インバンドシグナリングです。文字*は二つの役割を担わされていました。/etc/shadowでは「このアカウントにはパスワードが存在しない」を意味し、muslのcryptでは「ハッシュ化に失敗した」を意味します。一つの経路を二つのメッセージが共有し、受け取る側はそれを区別できません。通常のデータに埋め込まれた番兵値こそ、システムが自分自身を攻撃してしまう典型的な原因です。NULLは(クラッシュしやすい呼び出し元はさておき)許容可能なエラーシグナルでした。例外の方がより良いです。失敗を呼び出し元に明示的に処理させるResult<T, E>型が最善です。実践的なルールは、すべてのシステムプログラマーに刺青として入れてほしいものです。マジックストリングを使うな。どうしても必要なら、問題領域のどの値とも衝突しないものを選ぶこと。libxcryptの*0は、muslの*より1バイト多いだけです。そしてその1バイトが、単なる癖とCVEの違いになったのです。

沈黙する互換性シムに関する二つ目の教訓もあります。muslの「穏やかな*」という判断は、壊れたソフトウェアがクラッシュしないようにするためのものです。つまり、壊れていることが気づかれないようにしてしまうのです。セグメンテーション違反は醜いものですが、同時にシグナルでもあります。誰かがバグを報告し、誰かが呼び出し元を直します。エラーを飲み込んでしまうと、ミスは10年間静かに積み重なります。システム設計における最も深い分断は、これだと私はますます確信しています。破局に抵抗する設計は、大きく早く失敗する傾向があります。一方、滑らかさを最適化した設計は、請求書が一度にまとめて届くまで、隠れた負債を積み上げていくのです。

また、古くから広く使われているコードは実績があるから信頼できる、という神話も崩すべきです。古さは精査を均等に行き渡らせるわけではありません。ホットパスには精査が集中し、コールドパスは化石化するに任されます。誰もが暗号処理は監査します。しかし、sshdがパスワードについてlibcとやり取りする地味な接着部分を監査する人はほとんどいません。その部分は「ずっと動いてきた」からです。muslシステム、そしてその中でもPAMを使わない構成という、二つの少数派構成の交差点こそ、バグが10年間気づかれずに潜んでいられる場所なのです。

ssholeのバグを修正し、ログを確認する方法

パッチは簡単な部分です。OpenSSH 10.6p2で穴は塞がります。Alpineなら、コマンド一つで済みます。今日すぐにアップデートできない場合の緩和策も同じく単純で、完全に効果があります。パスワード認証を無効にすればよいのです。パスワードチェックがなければ、バグもありません。率直に言えば、両方実施すべきです。鍵専用認証は、このバグが存在する前から正解であり、全員がパッチを当てた後も正解であり続けます。これは他の「パッチか緩和か」の議論で見てきた教訓と重なります。緩和策が安くて完全に効くなら、小細工はしないことです。

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
暗闇の中で、一行だけ赤く光るログ出力の束を照らすランタン
10年分の認証ログのどこかで、非常に長いパスワードがロックされたアカウントを開けていたかもしれません。

次に、より難しい問いです。誰かがすでに先にたどり着いていたか。10年は長い潜伏期間です。探すべきものは、パスワードを持つはずのないアカウントへの、パスワード方式による認証成功の記録です。鍵専用のデプロイ用ユーザー、サービスアカウント、LDAPのID、Alpineマシンのrootなどです。これらのアカウントでpassword方式によるログインが成功することは、ほぼあり得ません。もし見つかれば、最優先の緊急事態です。不快な事実として、sshdが記録するのは成功という結果であって、パスワードの長さではありません。そのため、直接的な証拠の多くは書き込みの時点で消えてしまいます。また以前にも取り上げたとおり、クラウドの認証ログには、正常な日でも死角があります。短命なVM上のインスタンスレベルのsshdログこそ、まさにその死角に存在するのです。手元にあるものをgrepしてください。ただし、不完全なものとして扱ってください。

10年の静かな歳月が教えてくれること

この発見そのものも、バグと同じくらい注目に値します。ssholeを見つけた研究者は、暗号処理を凝視したわけではありません。OpenSSHがlibcとやり取りする、地味な境界に目を向けたのです。考えられるあらゆる構成(glibc、musl、PAMあり・なし、shadowあり・なし)でsshdをビルドし、約17,600件の不正な形式のログイン試行をそのマトリクスに投げ込みました。奇妙なハッシュ、壊れたアカウント、極端に長いパスワードです。すべてのビルドはすべてのログインで同じ結果を返しましたが、一つだけ例外がありました。PAMのないmuslです。そのビルドだけが、受け入れるべきでないものを受け入れたのです。これは幸運な発見ではなく、網羅性の成果です。設定マトリクスにわたる差分テストこそ、交差点に隠れたバグを見つける方法であり、まさにエージェントの群れが驚くほど得意とする、網羅的で疲れを知らない作業なのです。次のssholeは、10年の静かな歳月を与えてはくれないでしょう。

運用者を夜眠れなくさせるべき問いは、10年間誰にも気づかれなかったのか、それとも気づいてはいけない人に気づかれていたのか、です。偶発的なケースだけを考えてみてください。間違ったクリップボードの内容をパスワード入力欄に貼り付けるのです。Slackのスレッドの半分でも、SSHの秘密鍵全体でも、257文字を超えるものなら何でもかまいません。脆弱なマシンでは、ロックされたアカウントが単純に開いてしまいます。パスワード欄に誤って貼り付けてしまったことが一度もない人は、私がこれまで一緒に働いた誰よりも慎重な人です。どこかの誰かが、意図しないマシンにログインして、そっとターミナルを閉じたはずです。もしかすると、多くの誰かが。

だからパッチを当てましょう。パスワード認証は、もちろん無効にしてください。そのうえで一時間ほど時間を取り、自分のシステムの中で、二つの役割を担う文字を探してください。番兵値、サイレントなフォールバック、「これは絶対に起きない」という分岐、誰も書いたことを覚えていない互換性シムです。成熟したコードベースには必ずこれらがあります。目立たないのは、誰の目にも見える問題を一度も起こしていないからです。今この瞬間もあなたのスタックのどこかで、一つのアスタリスクが同時に二つの意味を静かに持っています。非常に長いパスワードを持つ誰かが見つける前に、それを探し出してください。