【実務・中級編】 クラウド環境におけるクロスアカウント攻撃と信頼関係の悪用 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

お疲れ。最近、マルチアカウント戦略を導入する企業が増えてきたな。開発用、ステージング、本番用、そしてログ集約用と、AWSなどのクラウド環境を綺麗に分割するのは、ガバナンスの観点から見ても非常に正しいアプローチだ。

だが、レッドチームの視点から言わせてもらうと、「分割された綺麗な城壁の間にかけられた、ずさんな跳ね橋」を見つけるのは、侵入テストの最初の楽しみの一つなんだよ。

今回は、クラウド環境におけるクロスアカウント攻撃、特にIAMロールの信頼関係(Trust Policy)の悪用と、その対策について現場のリアルな泥臭い知見を交えて解説しよう。後輩の君たちには、綺麗事の設計図ではなく、実際に攻撃者がどこを見て、どうやって踏み込んでくるのかを叩き込んでおく。

—

1. 攻撃者が狙う「クロスアカウントの盲点」

マルチアカウント環境では、別のアカウントのリソースにアクセスするために AssumeRole を使うのが定石だ。例えば、ログ集約用のアカウントから、各本番アカウントのログバケットやCloudWatchにアクセスするケースがこれに該当する。

ここでセキュリティチーフとして君たちに問いたい。
「その信頼関係、本当に最小権限の原則(Principle of Least Privilege)を満たしているか?」

甘い設計の現場では、次のような信頼ポリシーを見かける。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:root"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

一見して何が問題かわかるか?
プリンシパルに arn:aws:iam::111122223333:root を指定している点だ。これだと、アカウント 111122223333 の「誰でもいいからIAMポリシーで権限を持っていれば、このロールをAssumeできてしまう」状態になる。

攻撃シナリオ:コンテナ脱出から別アカウントへのラテラルムーブメント

もし君たちのチームの誰かが、アカウント 111122223333 の片隅にある、脆弱な開発用EC2インスタンスやECSタスク(権限が弱く設定されたもの)を踏み台として奪取されたとする。

攻撃者はそのインスタンスのメタデータサービス(IMDSv2など)から一時クレデンシャルを奪い、アカウント 111122223333 内を探索する。その過程で、先ほどのようなガバガバな信頼関係を持つ本番アカウント(999988887777)のIAMロールの存在を見つけたらどうなるか?

攻撃者は一行のAWS CLIコマンドで、本番環境の権限をいとも簡単に手に入れている。
これが、信頼関係の悪用によるクロスアカウント攻撃の典型的な手口だ。

—

2. なぜ「外部ID(External ID)」が必須なのか?

先ほどの脆弱な信頼関係を防ぐための強力な防壁が 「外部ID(External ID)」 だ。

サードパーティ製のSaaS(例えばDatadogやSplunk、あるいは自社グループ内の別組織)にAWSリソースへのアクセスを許可する場合、自社のアカウントから相手のロールをAssumeしてもらうことがある。この時、もし相手のSaaSがマルチテナント構造で、悪意ある別のテナントのオーナーも同じSaaSを使っていたらどうなるか?

理論上、そのSaaSが悪意あるテナントに対して、あなたのAWSアカウントのロールを不正にAssumeさせる設定を組まれてしまうリスクがある。これが有名な 「Confused Deputy(迷子の代理人)問題」 だ。

このリスクを完全にシャットアウトするのが sts:ExternalID 条件キーである。

—

3. 【実践】セキュアなIAMロール信頼関係とPython実装

では、実務でどう書くべきか。
ここでは、自社内の信頼できる特定のアカウント、かつ特定のロール、さらに推測不可能な外部IDを要求する堅牢なIAM信頼ポリシーの設定例と、それを安全に呼び出すためのPython(Boto3)のコードを見ていこう。

セキュアなIAMロールの信頼ポリシー(JSON)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SecureCrossAccountAssumeRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/TrustedDeploymentPipelineRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "SuperSecretRandomExternalId-202X-DefC0de"
        }
      }
    }
  ]
}

ここがポイント:

  • Principal には、アカウントのルートではなく、特定のロールARNを明示している。これにより、アカウント内での権限昇格を連鎖させない。
  • Condition で sts:ExternalId を強制しているため、仮にロールARNを知られても、この秘密の文字列を知らなければAssumeは失敗する。

—

セキュアなクロスアカウント呼び出しコード(Python / Boto3)

次に、開発チームがアプリケーションやデプロイパイプラインからこのロールを安全に安全にスイッチするためのPythonコードだ。エラーハンドリングも含めて実務レベルで記述している。

import boto3
from botocore.exceptions import ClientError
import logging

# ログ設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def assume_secure_cross_account_role(target_role_arn: str, external_id: str):
    """
    外部IDを指定して、安全にクロスアカウントのIAMロールをAssumeする関数。
    
    :param target_role_arn: アクセス先(別アカウント)のIAMロールARN
    :param external_id: 信頼関係の条件に設定されている一意の外部ID
    :return: 一時的なセッションから取得したSTSクライアント、またはNone
    """
    try:
        # 1. まず通常の認証情報(環境変数やインスタンスプロファイル)でSTSクライアントを初期化
        sts_client = boto3.client('sts')
        
        logger.info(f"Target Role [{target_role_arn}] へのAssumeRoleを開始します。")

        # 2. AssumeRoleの実行(ExternalIdを必ず含める)
        response = sts_client.assume_role(
            RoleArn=target_role_arn,
            RoleSessionName='SecureCrossAccountSession',
            ExternalId=external_id,
            DurationSeconds=3600 # 最小限の有効期間に設定(デフォルトは1時間)
        )

        # 3. 取得した一時クレデンシャルを抽出
        credentials = response['Credentials']
        
        logger.info("AssumeRoleに成功しました。一時クレデンシャルを取得します。")

        # 4. 取得した一時クレデンシャルを使って、目的のアカウント用のリソース(例: S3)にアクセスするクライアントを生成
        secure_s3_client = boto3.client(
            's3',
            aws_access_key_id=credentials['AccessKeyId'],
            aws_secret_access_key=credentials['SecretAccessKey'],
            aws_session_token=credentials['SessionToken']
        )

        return secure_s3_client

    ClientError as e:
        logger.error(f"AWS APIエラーが発生しました: {e.response['Error']['Message']}")
        return None
    except Exception as e:
        logger.error(f"予期せぬエラーが発生しました: {str(e)}")
        return None

if __name__ == "__main__":
    # 設定値の定義(実際には環境変数やSecrets Managerから取得すること)
    TARGET_ROLE_ARN = "arn:aws:iam::999988887777:role/ProductionDataAccessorRole"
    EXTERNAL_ID = "SuperSecretRandomExternalId-202X-DefC0de"

    s3_client = assume_secure_cross_account_role(TARGET_ROLE_ARN, EXTERNAL_ID)
    
    if s3_client:
        # 例として、クロスアカウント先からバケット一覧を取得してみる
        try:
            response = s3_client.list_buckets()
            logger.info("取得したバケット一覧:")
            for bucket in response.get('Buckets', []):
                logger.info(f"  - {bucket['Name']}")
        except ClientError as e:
            logger.error(f"バケットのリスト取得に失敗しました: {e}")

—

4. セキュリティチーフからの現場の戒め

マルチアカウント環境におけるクロスアカウント設定は、利便性の高さゆえに「とりあえず動く緩い設定」で放置されがちだ。しかし、攻撃者にとってそこは 「一度侵入すれば、全ての城の門を開け放たせるためのマスターキー」 になり得る。

インフラやアプリケーションを設計・実装する際は、以下の3点を必ずチームのレビュー項目に入れてほしい。

1. Principal にアカウントのルート(:root)を指定していないか? -> 必ず特定のロールやユーザーARNに絞る。
2. クロスアカウントの信頼関係に sts:ExternalId が設定されているか? -> 特に外部サービスやサードパーティ連携では必須要件とする。
3. AssumeRoleで取得する一時クレデンシャルの有効期間は適切か? -> 必要最小限の時間(可能なら1時間未満)に設定し、長時間のセッション発行を避ける。

セキュリティは足し算ではなく引き算の美学だ。余計な権限、余計な緩いパスをすべて削ぎ落とした先にある設計こそが、我々のシステムを守り抜く唯一の盾となる。

頼んだぞ、次のリリースでも隙のない堅牢なコードを期待している。

コメント

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