お疲れ。最近、マルチアカウント戦略を導入する企業が増えてきたな。開発用、ステージング、本番用、そしてログ集約用と、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時間未満)に設定し、長時間のセッション発行を避ける。
セキュリティは足し算ではなく引き算の美学だ。余計な権限、余計な緩いパスをすべて削ぎ落とした先にある設計こそが、我々のシステムを守り抜く唯一の盾となる。
頼んだぞ、次のリリースでも隙のない堅牢なコードを期待している。
コメント