本番環境のgRPCを堅牢化する:マイクロサービスのためのmTLS認証、インターセプター、DoS防御

Security tutorial - IT technology blog
Security tutorial - IT technology blog

現場の現実:午前2時14分、内部で発生した連鎖障害

午前2時14分、オンコールのアラートが鳴り響きました。チェックアウトおよび決済のマイクロサービスが、受信リクエストの42%をドロップ(破棄)していたのです。16 GBのKubernetesワーカーノードでは、ノードメモリが平常時の3.2 GBから危険水域の15.8 GBへと急上昇し、p99レイテンシは14ミリ秒から6.2秒へと跳ね上がりました。Cloudflareやエッジのインgressゲートウェイは、約1,200 req/secという至って正常なトラフィックパターンを報告していました。しかしクラスタの内部では、Podが窒息状態に陥っていたのです。

私たちは古典的なアーキテクチャの罠にはまっていました。それは「VPCの境界を絶対的なセキュリティ境界と見なす」ことです。サービスがエッジのリバースプロキシの背後にあるという理由から、すべての内部gRPCリスナーをgrpc.insecure_server_credentials()で稼働させていました。あるエンジニアのステージング環境のコンテナが侵害され、本番環境へのネットワーク到達性を獲得した際、攻撃者は暗号化を破る必要すらありませんでした。そのPodから、認証されていないリモートプロシージャコール(RPC)をコアバックエンドサービスに向けて直接乱射し始めるだけで済んだのです。

根本原因分析:デフォルトのgRPCが破綻する理由

デフォルトのgRPC設定は、防御的な隔離よりも開発者の利便性を優先しています。インシデント後の振り返り(ポストモーテム)において、3つのアーキテクチャ上の欠陥が即座に浮き彫りになりました。

  • 平文の非セキュアな認証情報: サービスはgrpc.insecure_server_credentials()で起動されていました。クラスタのサブネットを共有するワークロードであれば、暗号論的な識別情報を一切交換することなく、公開された任意のProtocol BuffersメソッドへRPCを発行できる状態でした。
  • 境界のみの認可: エッジゲートウェイは外部のJWTを検証・除去していましたが、内部のgRPCハンドラーは受信したすべてのHTTP/2フレームを安全であると盲信していました。TransferFundsやUpdateAccountRoleなどのセンシティブなメソッドも、どのPodが呼び出し元であるかを確認せずに実行されていたのです。
  • 無制限のリソース消費: デフォルトのgRPCサーバーは、同時HTTP/2ストリーム数やKeepalive Pingの乱用を制限しません。攻撃者は断片化されたペイロードを送信する永続的なストリーミングチャネルを18,000本確立し、ワーカーのスレッドプールを枯渇させ、4台のクラスタノードでLinuxのOOM Killerを発動させました。

アーキテクチャの比較:サービス間トラフィックの保護

これらの脆弱性を軽減するには、実装の手間、ランタイムのオーバーヘッド、多層防御の深さの間でトレードオフを検討する必要があります。

選択肢1:Kubernetes NetworkPolicy

CalicoやCiliumを用いてIP CIDRを制限すれば、不正なPodが名前空間(namespace)を越えて生のTCPソケットを開くのを防ぐことができます。これは基本的なセキュリティ対策として有効です。しかし、NetworkPolicyでは転送中のデータを暗号化できず、同一ワーカーノード上でのパケット傍受を防ぐこともできず、アプリケーションのアイデンティティを検証することもできません。

選択肢2:gRPCメタデータによるベアラートークン

gRPC呼び出しのメタデータ(ヘッダー)に共有トークンを注入することで、ハンドラー側で呼び出し元を識別できるようになります。しかし、平文のHTTP/2上で静的なシークレットを送信することは致命的な事態を招きます。ノードのインターフェースでtcpdumpを実行したり、サイドカーのログを閲覧できるエンジニアであれば誰でもトークンを詐取し、リクエストを無制限にリプレイできてしまいます。

選択肢3:多層防御(mTLS + メソッドインターセプター + チャネル制限)

このアプローチは、暗号論的なトランスポートセキュリティと詳細なアクセス制御を組み合わせたものです。相互TLS(mTLS)により、X.509証明書を用いてPodのアイデンティティを双方向で証明します。インターセプターがRPCの境界でロール権限を強制し、チャネルレベルの制限によってペイロードがメモリに到達する前にストリームフラッドを遮断します。

ステップ別の実装:Python gRPCスタックの堅牢化

以下は、侵害を封じ込め、すべてのサービス間通信を保護するために本番環境へ導入した実装です。

ステップ1:SANを含む内部CAおよび証明書の生成

近年のTLS実装では、Subject Alternative Name(SAN)が含まれていない証明書は拒絶されます。以下のコマンドを実行してプライベート認証局(CA)を作成し、呼び出し側と受信側の双方に対してSAN検証済みの証明書を発行します。

# 1. プライベートルートCAの生成(有効期間: 1年間)
openssl req -x509 -newkey rsa:4096 -days 365 -nodes \
  -keyout ca-key.pem -out ca-cert.pem \
  -subj "/CN=Cluster-Internal-CA"

# 2. サーバー秘密鍵およびSAN拡張を含むCSRの生成
openssl req -newkey rsa:2048 -nodes \
  -keyout server-key.pem -out server.csr \
  -subj "/CN=order-service.infra.local"

openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \
  -out server-cert.pem -days 90 \
  -extfile <(printf "subjectAltName=DNS:order-service.infra.local")

# 3. 決済クライアント用のクライアント秘密鍵およびCSRの生成
openssl req -newkey rsa:2048 -nodes \
  -keyout client-key.pem -out client.csr \
  -subj "/CN=payment-client.infra.local"

openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial \
  -out client-cert.pem -days 90 \
  -extfile <(printf "subjectAltName=DNS:payment-client.infra.local")

ステップ2:厳格なmTLSおよびメタデータインターセプターの構築

ビジネスロジック関数の中にアイデンティティ検証処理を散在させてはなりません。gRPCコンテキストからTLSのCommon Name(CN)を直接検査し、続いてトークン検証を行うカスタムServerInterceptorを使用します。

import grpc

class SecureAuthInterceptor(grpc.ServerInterceptor):
    def __init__(self, allowed_common_names, shared_secret):
        self._allowed_cns = set(allowed_common_names)
        self._secret = shared_secret

    def intercept_service(self, continuation, handler_call_details):
        handler = continuation(handler_call_details)
        if not handler or not handler.unary_unary:
            return handler

        def wrapper(request, context):
            # 1. mTLSピア証明書のCommon Name(CN)を検証
            auth_ctx = context.auth_context()
            peer_cns = auth_ctx.get("x509_common_name", [])
            if not peer_cns:
                context.abort(grpc.StatusCode.UNAUTHENTICATED, "クライアント証明書の識別情報が見つかりません")

            client_cn = peer_cns[0].decode("utf-8")
            if client_cn not in self._allowed_cns:
                context.abort(grpc.StatusCode.PERMISSION_DENIED, f"信頼されていない呼び出し元の識別情報です: {client_cn}")

            # 2. メタデータ内のエフェメラルサービストークンを検証
            metadata = dict(context.invocation_metadata())
            token = metadata.get("x-service-token")
            if token != self._secret:
                context.abort(grpc.StatusCode.UNAUTHENTICATED, "サービストークンが無効または期限切れです")

            return handler.unary_unary(request, context)

        return grpc.unary_unary_rpc_method_handler(
            wrapper,
            request_deserializer=handler.request_deserializer,
            response_serializer=handler.response_serializer,
        )

ステップ3:DoS保護機能を備えたサーバークレデンシャルのバインド

クライアント証明書を要求(require_client_auth=True)し、厳格なバッファ制限を適用するようにgRPCサーバーを設定します。

from concurrent import futures
import grpc

def run_server():
    # 証明書チェーンの読み込み
    with open("ca-cert.pem", "rb") as f:
        ca_cert = f.read()
    with open("server-key.pem", "rb") as f:
        server_key = f.read()
    with open("server-cert.pem", "rb") as f:
        server_cert = f.read()

    # 相互TLS(mTLS)認証の強制
    server_creds = grpc.ssl_server_credentials(
        [(server_key, server_cert)],
        root_certificates=ca_cert,
        require_client_auth=True
    )

    # リソース枯渇攻撃に対抗するためのHTTP/2トランスポートの堅牢化
    channel_options = [
        ("grpc.max_receive_message_length", 4 * 1024 * 1024),          # 受信ペイロード上限(4 MB)
        ("grpc.max_send_message_length", 4 * 1024 * 1024),             # 送信レスポンス上限(4 MB)
        ("grpc.max_concurrent_streams", 150),                          # コネクションごとの同時ストリーム数を制限
        ("grpc.keepalive_time_ms", 45000),                             # 通信がない状態が45秒続いた後にPingを送信
        ("grpc.keepalive_timeout_ms", 8000),                           # 応答のないクライアントを8秒後に切断
        ("grpc.http2.min_ping_interval_without_data_ms", 5000),        # Pingの間隔を最短5秒にレート制限
        ("grpc.http2.max_ping_strikes", 2),                            # 過度なPing送信者を切断
    ]

    interceptor = SecureAuthInterceptor(
        allowed_common_names=["payment-client.infra.local"],
        shared_secret="prod-vault-token-98f2b1a"
    )

    server = grpc.server(
        futures.ThreadPoolExecutor(max_workers=32),
        options=channel_options,
        interceptors=(interceptor,)
    )

    server.add_secure_port("0.0.0.0:50051", server_creds)
    server.start()
    print("mTLSが有効化された堅牢なgRPCサーバーがポート50051で起動しました。")
    server.wait_for_termination()

if __name__ == "__main__":
    run_server()

検証と攻撃シミュレーション

変更内容を本番環境へ適用する前に、grpcurlを用いて防御境界をテストします。

# テスト1: 平文通信の試行(TLSハンドシェイク層で即座に拒絶)
grpcurl -plaintext order-service.infra.local:50051 list
# 出力: Failed to dial target host: connection closed

# テスト2: 有効なTLS証明書だが未許可の呼び出し元CN(インターセプターにより拒絶)
grpcurl -cacert ca-cert.pem -cert rogue-pod-cert.pem -key rogue-pod-key.pem \
  order-service.infra.local:50051 order.OrderService/GetOrder
# 出力: Error invoking method "...PERMISSION_DENIED: 信頼されていない呼び出し元の識別情報です: rogue-pod.infra.local"

# テスト3: 有効なサービストークンを持つ正規のmTLSクライアント(許可)
grpcurl -cacert ca-cert.pem -cert client-cert.pem -key client-key.pem \
  -H "x-service-token: prod-vault-token-98f2b1a" \
  -d '{"order_id": "1001"}' \
  order-service.infra.local:50051 order.OrderService/GetOrder
# 出力: {"order_id": "1001", "status": "CONFIRMED"}

これら3つの防御層により、水平移動(ラテラルムーブメント)の攻撃経路は完全に遮断されました。仮に攻撃者がステージングクラスタのワーカーノードでroot権限を奪取したとしても、本番環境のマイクロサービスはアプリケーションデータを1バイトも処理することなく、TLSハンドシェイクの時点で接続を拒否します。

Share: