【実務・中級編】 AWS Lambdaの環境変数への機密情報埋め込みリスク – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

事故が起きてからでは遅い:AWS Lambdaの「環境変数」という名のパンドラの箱

おい、そこの君。新しいマイクロサービスの設計、順調に進んでいるか?
「サーバーレスだから管理が楽」「Lambdaならパッチ当ての心配もない」……よく聞くセリフだな。だが、少し待て。そのLambdaの管理画面、あるいはデプロイ用の serverless.yml や template.yaml を覗かせてもらおうか。

……ほら見ろ。環境変数(Environment Variables)の欄に、Stripeのプライベートキー、SendGridのAPIトークン、さらにはデータベースのマスターパスワードが平文(Plaintext)でベタ書きされているじゃないか。

現場のエンジニアからよく聞く言い訳がこれだ。

  • 「開発環境だからとりあえず動けばいいんです」
  • 「AWSのコンソールで暗号化(KMS)してるから安全です」
  • 「Lambdaのコードから直接見えないから大丈夫です」

甘い。甘すぎる。これだからレッドチームの格好の餌食になるんだ。
今回は、攻撃者がどのようにしてその「隠し持った機密情報」を奪い取るのかというリアルな脅威と、二度と言い訳できなくなるレベルのセキュアな実装手法を叩き込む。耳の穴をかっぽじってよく聞け。

—

攻撃者の視点:なぜLambdaの環境変数は危険なのか?

ペネトレーションテストや実際のインシデントにおいて、攻撃者は最初からサーバーのルート権限を奪えるわけではない。彼らが狙うのは「一番弱いリンク」、すなわちアプリケーション層の脆弱性や、設定ミスによる情報漏洩だ。

仮に、君が構築したLambda関数に何らかの脆弱性(例えば、不十分な入力検証や依存ライブラリの脆弱性など)が存在し、リモートコード実行(RCE)や任意のファイル読み込み、あるいは詳細なエラーハンドリングの不備による情報露出があったとする。

攻撃者はLambdaのコンテナ(実行環境)に足場を築いた瞬間、何を真っ先に見ると思う?
答えは /proc/self/environ や、環境変数のダンプだ。

# 攻撃者がLambdaのコンテナ内で実行する典型的なコマンドのイメージ
env
# または
cat /proc/self/environ | tr '\0' '\n'

Lambdaの実行環境はLinuxベースのサンドボックスだ。プロセスが起動した際、AWSのコンソールやデプロイツールで設定した環境変数は、すべてプロセス空間の環境変数としてメモリ上に展開される。
つまり、コード上から隠されているつもりでも、プロセスが生存している限り、そのメモリ空間や環境変数には平文でアクセス可能なのだ。

さらに言えば、次のようなリスクもある。
1. コードリポジトリへのコミットミス: git push の勢い余って、.env ファイルや設定ファイルをそのままGitHub等のリポジトリに公開してしまう(公開リポジトリスキャンはボットが24時間体制で監視している)。
2. IAM権限の過剰付与: 万が一、アプリケーションの脆弱性から iam:GetFunctionConfiguration などの権限が奪われた場合、API経由でLambdaの設定を取得するだけで、環境変数に埋め込まれたすべてのシークレットが丸裸になる。

AWS公式のKMS暗号化は「保存時(At-Rest)」の保護には有効だが、Lambdaが実行され、メモリ上に展開された瞬間には復号される。動的なメモリダンプやコンテナ内からのアクセスに対しては、無力な盾なのだ。

—

解決策:Secrets Manager & Parameter Store を使った動的取得

では、どうすればいいのか? 答えはシンプルだ。
「Lambdaの環境変数に機密情報を置くな。必要なときに、権限を絞った上で、動的にシークレットストアから取得しろ」

AWSには、これを行うためのデファクトスタンダードなサービスが2つある。

  • AWS Systems Manager (SSM) パラメータストア: 無料で使え、シンプルなキーバリューやSecureString(KMS暗号化)を扱える。
  • AWS Secrets Manager: ローテーション機能(DBパスワードの自動変更など)や、より高度なアクセス制御が必要な場合に適している。

今回は、実務で最も導入しやすく、コストパフォーマンスも高い「Parameter Store(SecureString)」と「Secrets Manager」を組み合わせた、Node.js(JavaScript)およびPythonによるセキュアな実装サンプルを提示する。

—

【実装サンプル】コピペで使えるセキュアなコード

実際のプロジェクトでそのまま使えるよう、キャッシュ機構(Lambdaのコールドスタート対策)を考慮した実装コードを用意した。毎回の実行ごとにAPIを叩くとレイテンシー(遅延)が悪化するため、グローバル変数領域に一度取得したシークレットをキャッシュするのがプロの技だ。

1. Node.js (JavaScript / AWS SDK v3) の場合

const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");

// リージョンを指定してSSMクライアントを初期化
const ssmClient = new SSMClient({ region: process.env.AWS_REGION || "ap-northeast-1" });

// コールドスタート対策:一度取得したシークレットをメモリ上にキャッシュする
let cachedApiKey = null;

async function getApiKey() {
    if (cachedApiKey) {
        return cachedApiKey;
    }

    try {
        const command = new GetParameterCommand({
            Name: "/my-app/production/stripe-api-key",
            WithDecryption: true, // SecureStringを復号して取得する必須フラグ
        });

        const response = await ssmClient.send(command);
        cachedApiKey = response.Parameter.Value;
        return cachedApiKey;

    } catch (error) {
        console.error("シークレットの取得に失敗しました:", error);
        throw new Error("Internal Server Error");
    }
}

exports.handler = async (event) => {
    // ここで動的に機密情報を呼び出す
    const apiKey = await getApiKey();

    // ビジネスロジックの処理...
    console.log("安全にAPIキーを取得し、処理を実行します。");

    return {
        statusCode: 200,
        body: JSON.stringify({ message: "Success" }),
    };
};

2. Python (Boto3) の場合

続いて、Pythonでの実装だ。こちらも同様にキャッシュ機構を組み込んでいる。

import os
import boto3
from botocore.exceptions import ClientError

# リージョンの取得
region = os.environ.get("AWS_REGION", "ap-northeast-1")

# SSMクライアントの初期化
ssm_client = boto3.client("ssm", region_name=region)

# キャッシュ用のグローバル変数
_cached_secret = None

def get_secure_parameter(parameter_name: str) -> str:
    global _cached_secret
    if _cached_secret:
        return _cached_secret

    try:
        # パラメータストアからSecureStringを取得
        response = ssm_client.get_parameter(
            Name=parameter_name,
            WithDecryption=True  # 復号フラグ
        )
        _cached_secret = response["Parameter"]["Value"]
        return _cached_secret

    except ClientError as e:
        print(f"AWS API Error: {e.response['Error']['Message']}")
        raise RuntimeError("Failed to retrieve critical configuration.")

def lambda_handler(event, context):
    # 必要になったタイミングで動的に取得
    api_key = get_secure_parameter("/my-app/production/stripe-api-key")

    # ビジネスロジック
    print("セキュアな環境で処理を実行中...")

    return {
        "statusCode": 200,
        "body": "Secure execution completed."
    }

—

インフラ・IAM設計の急所:最小権限の原則

コード側で動的取得に対応しても、Lambdaに与えるIAMロールが ssm:GetParameter や secretsmanager:GetSecretValue に対して Resource: "*"(すべて許可)になっていたら、それは片足をドブに突っ込んでいるようなものだ。

攻撃者が万が一Lambdaのコードを乗っ取った場合、他の重要なパラメータまですべて引っこ抜かれてしまう。IAMポリシーは、必ず対象のキーのARNを明示的に指定して最小権限に絞り込め。

以下は、TerraformにおけるセキュアなIAMポリシーの定義例だ。

# 特定のパラメータストアのパスのみにアクセスを許可するIAMポリシー
resource "aws_iam_policy" "lambda_ssm_policy" {
  name        = "lambda-ssm-secure-access-policy"
  description = "Allows Lambda to read only its specific SSM parameter."

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ssm:GetParameter",
          "ssm:GetParameters"
        ]
        # ワイルドカードを使わず、特定のARNのみに絞る
        Resource = "arn:aws:ssm:ap-northeast-1:123456789012:parameter/my-app/production/*"
      },
      {
        Effect   = "Allow"
        Action   = "kms:Decrypt"
        # パラメータストアの暗号化に使用しているKMSキーのARNを指定
        Resource = "arn:aws:kms:ap-northeast-1:123456789012:key/your-kms-key-uuid"
      }
    ]
  })
}

# Lambdaの実行ロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "attach_ssm" {
  role       = aws_iam_role.lambda_execution_role.name
  policy_arn = aws_iam_policy.lambda_ssm_policy.arn
}

—

チーフエンジニアからの総括

セキュリティとは、「穴をゼロにすること」ではなく、「万が一の突破(侵入)を許したときに、被害を最小限に食い止める多層防御の網を張り巡らせること」だ。

Lambdaの環境変数への機密情報のハードコードは、鍵をかけ忘れた金庫を道端に放置しているようなもの。今日この瞬間から、すべてのプロジェクトを見直し、シークレットは専用のストアへ移行し、IAMの権限を削ぎ落とせ。

もし、このルールを破ってコードレビューを通過させようとする輩がいたら、私のもとに連れてきなさい。たっぷり時間をかけて、セキュリティの現実を教えてやろう。
手を動かす前に、頭を働かせろ。セキュアなコードは、美しいコードだ。以上だ。

コメント

タイトルとURLをコピーしました