現実のインシデント:SSH鍵だけでは防げなくなった現場
昨年10月のある火曜日の午前2時14分、オンコール当番だった私のスマートフォンでPagerDutyのアラートが鳴り響きました。若手エンジニアのノートPCが、週末の個人プロジェクトでタイポスクワッティング(タイプミスを狙った悪意あるパッケージ)されたnpm依存関係を導入してしまい、情報窃取型マルウェア(Infostealer)に感染したのです。わずか90秒のうちに、マルウェアはシステム上のあらゆる認証情報を収集しました。アクティブなブラウザCookie、AWSセッショントークン、そして~/.ssh/id_ed25519にパスフレーズなしで無防備に置かれていた256ビットEd25519秘密鍵です。
それから18分後、攻撃者の自動化ボットスクリプトが3台のステージング環境ノードと本番環境のプライマリ踏み台サーバー(10.0.1.15)へ接続を試みました。当社のインフラは標準的なセキュリティ要件に沿って堅牢化されていました。パスワードログインは無効化、非標準のSSHポートを監視、root直接ログインも禁止。それにもかかわらず、アラートは一切トリガーされませんでした。着信接続は、暗号学的に正しい正当な鍵署名を提示していたからです。SSHデーモンから見れば、攻撃者は正当な信頼されたエンジニアにしか見えませんでした。
このようなインシデントは、あらゆるエンジニアリング組織で毎週のように繰り返されています。多くのチームがSSH公開鍵認証を「難攻不落の要塞」のように過信しています。しかし、ノートPCの紛失、GitHubへの誤コミット、あるいは端末のマルウェア感染によって秘密鍵が一度でも漏洩してしまえば、その要塞はただの開き戸と化してしまうのです。
根本原因の分析:非対称鍵における単一要素認証の脆弱性
多くの開発チームは、SSH鍵認証をデフォルトで多要素認証であると誤認しがちです。鍵ファイルが「所有要素(持っているもの)」を満たし、パスフレーズが「知識要素(知っているもの)」を満たしていると考えられているためです。しかし、本番環境の現場ではこの前提が簡単に崩れ去ります。
- ディスク上の暗号化されていない鍵: 毎日の数十回に及ぶGit操作、自動デプロイスクリプト、新しいtmuxペインを開くたびにパスワードを入力する手間を避けるため、パスフレーズを設定しないまま運用するエンジニアが少なくありません。
- 長時間稼働するSSHエージェント: 強力なパスフレーズを設定していても、
ssh-agentやGNOME Keyringなどのバックグラウンドツールによって、復号化された鍵がRAM上に何時間も、あるいは何日も保持され続けます。ノートPC上で低権限のコード実行権限を得た攻撃者は、有効なSSH_AUTH_SOCK環境変数を参照するだけで、パスフレーズを知ることなく鍵をハイジャックできてしまいます。 - クライアント側のコンテキスト検証の欠如:
sshdが暗号署名を検証する際に行うのは、authorized_keysとの数学的な整合性チェックのみです。その署名がシニアシステム管理者によって送信されたものなのか、盗まれたセッションソケット内で実行されているボットによるものなのかを、デーモン側で判別する術はありません。
クライアントのファイルシステムに保存された単一の静的ファイルに依存することは、アーキテクチャ上の単一障害点(SPOF)を生み出します。もしエンドポイントが1台でも侵害されれば、その鍵を信頼しているすべてのサーバーも同時に侵害されることになります。
対策の比較検討:ハードウェアトークン vs TOTP PAM
このセキュリティギャップを埋めるには、対話型シェルを開く前段階で、サーバー側による第2要素の検証を強制する必要があります。現実的な解決策として、以下の2つのアプローチが挙げられます。
アプローチA: ハードウェアセキュリティキー(FIDO2 / U2F)
モダンなOpenSSH(バージョン8.2以降)は、ed25519-skなどの鍵タイプを通じてFIDO2セキュリティキーをネイティブサポートしています。署名操作ごとに、YubiKey 5シリーズなどのUSBハードウェアデバイスへの物理的な静電容量タッチが求められます。
- メリット: フィッシング耐性のあるアーキテクチャ、チップからの秘密鍵エクスポート不可、サーバー側のPAMモジュールへの依存がゼロ。
- デメリット: ハードウェア調達の高コスト(50名以上の開発者に1本あたり45〜85ドル)、リモート採用者への配送のボトルネック、キー紛失時の複雑なアカウント復旧フロー、OpenSSH 8.2未満のレガシーなエンタープライズシステム(CentOS 7など)における非互換性。
アプローチB: PAM経由のTime-Based One-Time Password(TOTP)
Linuxでは、認証ハンドシェイクの制御にPluggable Authentication Modules(PAM)を使用します。オープンソースのGoogle Authenticator PAMモジュールをSSHパイプラインに組み込むことで、サーバーは動的で有効期限のある6桁のTOTPコードを要求できるようになります。ユーザーはAegis、1Password、Bitwarden、Google Authenticatorなどのモバイル認証アプリを使ってコードを生成します。
- メリット: ハードウェア調達コストがゼロ、主要なLinuxディストリビューション全般での優れた互換性、標準的な構成管理ツールを用いれば100人規模のチームでも1時間未満で展開可能。
- デメリット: 単一の認証要素として使用した場合はリアルタイムの中間者(MitM)リバースプロキシ攻撃に対して脆弱(ただし、SSH鍵認証と組み合わせることでこのリスクは完全に排除されます)。
最適なアプローチ:多要素認証の強制(SSH鍵 + TOTP)
標準的なクラウドVMやベアメタルサーバーにおいて、SSH鍵とGoogle Authenticator PAMの組み合わせは、最も優れた多層防御(Defense-in-Depth)環境を提供します。攻撃者が盗まれた秘密鍵を持っていたとしても、手元のスマートフォンにある物理的なTOTP生成器がなければ、堅牢な壁に阻まれて侵入できません。
AWS EC2シリアルコンソールやHetzner KVMなど、帯域外コンソールアクセスのための強固なフォールバック用認証情報を用意しておくことも重要です。推測不可能な緊急用root認証情報を生成する際は、toolcraft.app/ja/tools/security/password-generatorのようなブラウザベースのオフライン生成ツールを利用することで、ネットワーク上にパスワードを流さずに済みます。日常のSSHアクセスにおいては、暗号鍵と可変の6桁TOTPコードの両方を必須とします。
ステップ1: PAMモジュールのインストール
パッケージマネージャーを使用してGoogle Authenticator PAMモジュールをインストールします。Ubuntu(22.04 / 24.04 LTS)およびDebian系システムの場合:
sudo apt update
sudo apt install -y libpam-google-authenticator
RHEL 9、Rocky Linux、AlmaLinuxの場合は、EPELリポジトリからバイナリを取得します:
sudo dnf install -y epel-release
sudo dnf install -y google-authenticator
ステップ2: ユーザーシークレットとリカバリーコードの生成
SSHアクセスを必要とする一般ユーザーアカウント(非root)に切り替えます。シェルで直接セットアップウィザードを実行してください:
google-authenticator
堅牢な設定を適用するため、対話型プロンプトに対して次のように回答します:
- Do you want authentication tokens to be time-based (y/n)?(認証トークンを時間ベースにしますか?) RFC 6238標準のタイムベーストークンを適用するため、
yを入力します。 - ターミナル上にASCIIアートのQRコードが表示され、Base32シークレットキー、初期検証コード、および5つの8桁緊急用スクラッチコードが出力されます。QRコードを認証アプリでスキャンしてください。5つの緊急コードは、直ちに社内のオフラインパスワードマネージャーに保管してください。
- Do you want me to update your “~/.google_authenticator” file (y/n)?(”~/.google_authenticator”ファイルを更新しますか?)
yを入力して、パーミッション0400で設定状態をディスクに書き込みます。 - Do you want to disallow multiple uses of the same authentication token (y/n)?(同じ認証トークンの複数回使用を禁止しますか?)
yを入力します。これにより、30秒の有効枠内でのトークンのリプレイアタックを拒否します。 - By default, tokens are good for 30 seconds… Increase window (y/n)?(デフォルトではトークンは30秒間有効です… ウィンドウを広げますか?)
nを入力します。時刻ズレの許容範囲を3つのコード(±1ウィンドウ、計90秒)に厳格に制限し、リプレイ可能枠の拡大を防ぎます。 - Do you want to enable rate-limiting (y/n)?(レート制限を有効にしますか?)
yを入力します。30秒あたり3回のログイン試行失敗に制限し、自動化されたブルートフォース攻撃を即座に阻止します。
ステップ3: SSH向けPAMの設定
対話型ログイン時、Linux PAMがTOTPコードを要求するように設定します。エディタで/etc/pam.d/sshdを開きます:
sudo nano /etc/pam.d/sshd
標準のパスワード認証ディレクティブを探します。Debian/Ubuntuでは@include common-auth、RHEL系ではauth substack password-authを見つけます。従来のUnixパスワードをスキップさせるため、該当行の先頭に#を付けてコメントアウトします。その後、ファイルの末尾に以下の認証ディレクティブを追加します:
# @include common-auth
auth required pam_google_authenticator.so nullok
nullok引数は、社内ロールアウト時のセーフティネットとして機能します。まだgoogle-authenticatorを実行していないユーザーも、一時的にSSH鍵のみでログインできます。チーム全員のアカウントでステップ2が完了したら、nullokを削除してサーバー上の全アカウントにMFAを強制してください。
ステップ4: OpenSSHデーモン設定の更新
次に、OpenSSHが2段階の検証を順番に強制するよう設定します。/etc/ssh/sshd_config(または/etc/ssh/sshd_config.d/mfa.confなどの専用設定ファイル)を開きます:
sudo nano /etc/ssh/sshd_config
以下のパラメータを確認し、設定します:
# PAMおよび対話型チャレンジメカニズムの有効化
UsePAM yes
KbdInteractiveAuthentication yes
# プレーンテキストパスワードの無効化を維持
PasswordAuthentication no
# 多要素認証チェーンの強制: 最初にSSH鍵、次にTOTPプロンプト
AuthenticationMethods publickey,keyboard-interactive
特にAuthenticationMethods publickey,keyboard-interactiveには注意してください。このカンマ区切りの構文は極めて重要であり、両方の検証を厳格な順序で要求することを意味します。OpenSSHはまずクライアントの公開鍵を検証しなければなりません。鍵の検証が成功した後にのみ、サーバーはPAMのkeyboard-interactiveによるTOTPプロンプトをトリガーします。
ステップ5: ロックアウトを防ぎながらテストする
現在接続しているターミナルセッションは絶対に閉じないでください。まず、構文エラーによる障害を検出するため、OpenSSHの設定チェックを実行します:
sudo sshd -t
このコマンドが何も出力せずに終了コード0を返した場合は、安全にサービスをリロードまたは再起動できます:
sudo systemctl restart sshd
既存のターミナルウィンドウは緊急用のライフラインとして残しておきます。ローカルマシン上でまったく新しいターミナルウィンドウを開き、詳細ログ(verbose)付きで接続テストを開始します:
ssh -v username@your-server-ip
詳細なデバッグ出力を確認します。公開鍵の交換が完了すると、直ちにOpenSSHの部分的認証成功レスポンスが表示されます:
Authenticated with partial success.
Verification code:
スマートフォンのアプリに表示されている現在の6桁のTOTP番号を入力します。これでBashシェルに直接ログインできるはずです。万が一、将来攻撃者にSSH秘密鍵が持ち出されたとしても、自動接続は検証コードのプロンプトで急停止し、あなたのスマートフォンがなければ完全に手詰まりとなります。

