Kubernetes(K8s)クラスターのペネトレーションテストやセキュリティ診断を行っていると、いまだに「おや、ご馳走様」と言いたくなるようなシークレット管理の不備に出くわします。
「いやいや、ちゃんと kubectl create secret で機密情報を登録しているから安全だよ」――そんな風に思っていませんか?
レッドチームの視点から言わせてもらえば、暗号化されていないデフォルトのK8s環境において、シークレットの管理は「鍵をかけ忘れた金庫の前に、堂々と札束を積み上げている状態」に等しいのです。
今回は、K8sの基盤を揺るがす etcd の脆弱性と、それを根本から断つための外部KMS(Key Management Service)統合による Encryption at Rest(保存データの暗号化)の実装アプローチを、現場のリアルな知見を交えて徹底解説します。
—
なぜデフォルトのK8s Secretは「丸見え」なのか?
K8sの Secret オブジェクト(データベースのパスワード、APIトークン、TLS証明書など)は、Base64エンコードされて保存されます。
しかし、レッドチームの基本中の基本ですが、Base64は暗号化ではなく単なるエンコードです。数秒で元の平文に戻せます。
さらに致命的なのは、K8sのバックエンドデータストアである etcd 内に、これらのシークレットが完全に平文で格納されているという事実です。
攻撃シナリオ:どのようにしてシークレットが奪われるのか?
もし、アプリケーションの脆弱性(RCEやSSRF、あるいは不十分なRBAC設定など)を突かれて、クラスター内部への足がかりを奪われたとしましょう。攻撃者は次のようなステップで機密情報を根こそぎ奪い去ります。
1. Service Accountの乱用: 権限昇格(Privilege Escalation)に成功し、APIサーバーへの直接アクセス権や etcd への通信経路を確保する。
2. etcdctlの直叩き: クラウドプロバイダのマネージドK8s(EKS, GKE, AKSなど)であっても、コントロールプレーンへの不正アクセスや、Misconfiguration(設定ミス)によるetcdポート(通常2379)の露出があれば、以下のようなコマンドで一網打尽です。
# etcdから全てのSecretを平文で抜き出す悪夢のコマンド
ETCDCTL_API=3 etcdctl \
--endpoints=https://[etcd-ip]:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
get /registry/secrets/ --prefix --keys-only
これだけで、本番環境のDB接続情報や外部APIキーがすべて丸裸になります。このリスクを塞ぐ唯一にして最強の防衛策が、外部KMSを用いた暗号化(Encryption at Rest)です。
—
外部KMS統合による Encryption at Rest の設計と実装
APIサーバーに対して「etcdに書き込む前に、クラウドのKMS(AWS KMS、GCP Cloud KMS、Azure Key Vaultなど)を使ってシークレットを暗号化してくれ」と指示するのが、KMSプロバイダーとの統合です。
これにより、万が一 etcd のスナップショットが外部に流出したり、不正に読み出されたりしても、肝心のデータは強固な暗号文(Ciphertext)のままとなり、実害を防ぐことができます。
ステップ1: 暗号化設定ファイル(EncryptionConfiguration)の作成
まずは、K8sのAPIサーバーに対して「どのリソースを、どのKMSプロバイダーで暗号化するか」を定義するYAMLファイルを作成します。コントロールプレーンのマスターノード上の適切なパス(例: /etc/kubernetes/etcd/encryption-config.yaml)に配置します。
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
# まず最初にAWS KMS(または他のプロバイダ)による暗号化を試みる
- aescbc:
keys:
- name: key1
#openssl rand -base64 32 で生成したランダムなAES鍵を設定
secret: "Z3Vyc3VzLWVkaXRvci1zZWNyZXQta2V5LWZvci1ldGNk"
# または外部KMSプラグインを使用する場合(推奨)
# - kms:
# name: aws-kms-provider
# endpoint: unix:///var/run/kmsplugin/socket.sock
# cachesize: 100
# timeout: 3s
# 過去のデータ復号化のためにidentity(平文)を残すこともありますが、新規保存は必ず暗号化させます
- identity: {}
> セキュリティチーフの現場メモ:
> 上記の aescbc はK8sネイティブな暗号化ですが、鍵自体のライフサイクル管理を厳密に行うためには、AWS KMSやGCP KMS等の外部サービスとgRPCで連携する kms プロバイダーの利用を強く推奨します。鍵のローテーション(Key Rotation)が自動化され、監査ログもクラウド側に残るため、コンプライアンス要件もクリアしやすくなります。
ステップ2: APIサーバー(kube-apiserver)の起動引数変更
作成した EncryptionConfiguration ファイルを、APIサーバーに読み込ませます。kubeadm等で構築した環境であれば、Static Podのマニフェスト(/etc/kubernetes/manifests/kube-apiserver.yaml)を編集します。
spec:
containers:
- command:
- kube-apiserver
- --encryption-provider-config=/etc/kubernetes/etcd/encryption-config.yaml
# 既存のボリュームマウントに設定ファイルを追加するのを忘れずに
volumeMounts:
- mountPath: /etc/kubernetes/etcd/
name: encryption-config
readOnly: true
volumes:
- hostPath:
path: /etc/kubernetes/etcd/
type: DirectoryOrCreate
name: encryption-config
APIサーバーが再起動すると、これ以降に作成・更新される Secret は自動的に暗号化されてetcdに保存されるようになります。
—
既存シークレットのマイグレーション(忘れてはいけない泥臭い作業)
設定を変えただけでは、過去に作成された既存のSecretは平文のままetcdに残っています。「設定したから大丈夫」と油断していると、レッドチームの侵入テストで足元をすくわれます。
必ず既存のシークレットを再書き込み(マイグレーション)して、強制的に暗号化させましょう。以下のコマンドを実行することで、APIサーバー経由でデータが読み書きされ、新しい暗号化方式が適用されます。
# すべてのネームスペースのSecretを取得して、強制的に上書き保存(パッチ)する
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
このコマンドを実行した後、再度 etcdctl get でデータを覗いてみてください。k8s:enc:aescbc:v1:... のようなプレフィックスが付いた、完全に解読不可能な暗号文に変わっているはずです。
—
アプリケーション側でのセキュアなハンドリング(Python実装例)
K8s側でシークレットが安全に保護されるようになったとしても、それを呼び出すアプリケーション側(マイクロサービス等)のコードがガバガバであれば意味がありません。
K8sのシークレットは、一般的にコンテナ内の環境変数(Environment Variables)やボリュームとしてマウントされます。ここでは、Python(FastAPI / Flaskなど)を用いて、環境変数として渡された機密情報を安全に読み込み、ログアウトやミスによる漏洩を防ぐ実装サンプルを紹介します。
import os
import sys
import logging
from typing import Optional
# ロガーの設定(機密情報がログに出力されないようにする基本)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class SecureConfigLoader:
"""
Kubernetesのシークレット(環境変数)を安全にロードし、
未設定の場合や不正な値の場合に即座にフェイルストップするクラス。
"""
@staticmethod
def get_secret(key_name: str, default: Optional[str] = None, required: bool = True) -> str:
value = os.getenv(key_name, default)
if not value and required:
logger.critical(f"FATAL: 必須の機密環境変数 '{key_name}' が設定されていません。")
# セキュリティインシデントを防ぐため、即座にプロセスを強制終了する
sys.exit(1)
return value
# --- 実使用例 ---
if __name__ == "__main__":
# データベースの接続パスワードを安全に取得
# 万が一ログに出力されてもマスク処理を施す設計にする
DB_PASSWORD = SecureConfigLoader.get_secret("DB_PASSWORD", required=True)
# マスクされた値のみをログに出力する(絶対に平文をloggerに通さない)
masked_password = "***" if DB_PASSWORD else "EMPTY"
logger.info(f"アプリケーション初期化完了: DB_PASSWORD Loaded successfully [{masked_password}]")
—
まとめ:セキュリティは「多層防御」の哲学で成り立っている
「etcdの中身まで誰も見られないだろう」という性善説に基づいたインフラ設計は、今日の高度なサイバー攻撃の前では数秒で崩れ去ります。
1. etcdの平文保存リスクを認識する(Base64は暗号化ではない)。
2. Encryption at Restを導入し、外部KMSと連携する。
3. 既存のシークレットのマイグレーション(再書き込み)を忘れない。
4. アプリケーション層でも環境変数の扱いを厳格化し、フェイルセーフを徹底する。
この基本を徹底するだけで、攻撃者が得られるはずだった最大の「獲物」を完全に無価値化することができます。
日々の面倒な設定や泥臭い検証の積み重ねこそが、組織のセキュリティを担保する唯一の盾です。後輩エンジニアの皆さん、明日からのインフラ設計にぜひこの知見を組み込んでください。
コメント