お疲れ様。今日も現場のデバッグや新機能のリリースに追われているところ、少し時間を取ってくれて助かる。
今日は、耳が痛いかもしれないが、避けては通れない「IAMアクセスキーの漏洩リスク」と、それをシステム的に根絶するための「自動ローテーション・無効化スクリプトの実装」について、ディープに語ろうと思う。
教科書的なセキュリティガイドラインにはよく「アクセスキーは定期的にローテーションしましょう」と1行で書かれている。だが、現場で泥をすすってきた身から言わせてもらうと、「手動でローテーションしてください」という運用ルールほど、形骸化し、破綻し、最終的に大事故を引き起こすものはない。
今回は、攻撃者が漏洩したアクセスキーをどのように検知し、数分以内にシステムを破滅させるのかという「攻撃側の論理(PoC)」を解説した上で、それを完全に防御するための「コピペで動いて即実戦投入できるPython自動無効化スクリプト」と、「IP制限をかけるセキュアなIAMポリシー」を伝授する。
本質的な暗号理論の視点も交えながら、一歩進んだ「Assume Breach(侵害前提)」のセキュリティを実装していこう。
—
1. なぜ「アクセスキー」はこれほどまでに脆弱なのか:暗号理論からのアプローチ
まず、根本的な問いから始めよう。なぜSSHの「公開鍵・秘密鍵(非対称鍵)」の管理に比べて、クラウドの「アクセスキー・シークレットアクセスキー」はこれほどまでに漏洩しやすく、悪用されやすいのか?
それは、アクセスキーが本質的に「静的な対称鍵(共通鍵)」だからだ。
【非対称鍵(SSHやECCなど)のモデル】
[手元の秘密鍵] --(署名検証)--> [サーバー側の公開鍵]
※ 秘密鍵そのものはネットワークを流れないし、サーバー側にも保存されない。
【対称鍵(IAMアクセスキー)のモデル】
[クライアント:Access Key / Secret] ==(同じ鍵を共有)== [クラウドプロバイダ]
※ シークレットキーそのものが「認証の原資」であり、漏洩した瞬間に完全な身代わり(なりすまし)が成立する。
公開鍵暗号(RSAや楕円曲線暗号 ECC)であれば、秘密鍵を手元から一歩も出さずに署名検証ができる。しかし、AWSなどのAPIアクセスキーは、本質的に「IDとパスワードのセット(対称鍵)」をAPIヘッダーに載せて(正確には署名を作成して)送り出す仕組みだ。
これがソースコードにハードコードされてGitHubに「誤プッシュ」されたり、踏み台サーバーの環境変数から引っこ抜かれたりした瞬間、攻撃者と君の権限は完全に「等価」になる。攻撃者にとって、これほど扱いやすい獲物はない。
—
2. 攻撃シナリオ:漏洩から「5分」で始まる地獄(PoCの現実)
「Gitに誤ってコミットしても、数日のうちに気づいて消せば大丈夫だろう」
もしチームにそう考えているメンバーがいたら、今すぐその認識を改めさせてほしい。GitHubのパブリックリポジトリは、攻撃者が張り巡らせた「スクレイピング・ボット」によって常時監視されている。コミットから最短10秒〜数分で、ボットはアクセスキーを検出し、自動で以下の攻撃コード(PoC)を実行する。
攻撃者の最初の一歩:偵察(Reconnaissance)
攻撃者がキーを手に入れた際、最初に行うのは「自分が手に入れた権限の確認」だ。むやみにリソースを作成すると検知されるため、まずは静かに、かつ確実にAPIを叩く。
# 1. 自分が誰なのか、どのOSアカウント/AWSアカウントに紐づいているかを確認
aws sts get-caller-identity --profile leaked_key
# 2. 自分にアタッチされているポリシー(権限)をリストアップ
aws iam list-attached-user-policies --user-name <暴いたユーザー名> --profile leaked_key
次のステップ:権限昇格とインフラの「乗っ取り」
もし、そのキーに AdministratorAccess や、それに準ずる強力な権限(iam:CreateUser や ec2:RunInstances など)が付与されていた場合、攻撃は一瞬で完了する。
1. バックドアの作成: 既存のセキュリティグループを改ざんし、SSH(ポート22)やRDP(ポート3389)を世界中に全開放する。
2. 暗号資産マイニングの開始: c5.metal や g4dn.metal といった、超高額で高火力なGPUインスタンスを、利用可能なすべてのリージョンで上限まで起動する。
3. データの窃取・暗号化(ランサムウェア): S3バケットの中身をすべてローカルにダウンロード(窃取)した上で、バケット内のデータを削除、または攻撃者の鍵で暗号化し、身代金を要求する。
翌朝、君が目にするのは、「一晩で数百万円に膨れ上がった請求書」と、「顧客データがすべて消失した本番環境」、そして「経営陣へのインシデント報告書の作成タスク」だ。
—
3. 防御の鉄則:なぜ「自動無効化」が救世主となるのか
この悲劇を防ぐための最善策は、そもそも「静的なアクセスキーを使わない」ことだ。AWSであれば IAM Role(一時的な認証情報) や、GitHub Actionsからのデプロイには OIDC(OpenID Connect) を使うのが現代の標準である。
しかし、オンプレミスのレガシーシステムからのバッチ処理や、サードパーティ製SaaSとの連携など、「どうしても静的なアクセスキーを発行せざるを得ない境界」が実務ではどうしても残る。
そこで導入すべきなのが、以下の2つの防衛ラインだ。
1. アクセスキーの「作成からの寿命(Age)」を制限し、期限切れ(例: 90日)のキーを自動で無効化(Inactive)する。
2. 最終使用日(Last Used)から一定期間(例: 30日)使われていない「休眠キー」を自動で無効化する。
これを人間の手で「カレンダーに予定を入れて毎月やる」のは不可能だ。システムに自動でやらせよう。
—
4. 【コピペOK】IAMアクセスキー自動スイーパー(Python 3 + Boto3)
以下に、AWS Lambdaなどで定期実行(Amazon EventBridgeで毎日深夜にキックするなど)することを想定した、実戦仕様のPythonスクリプトを提供する。
このスクリプトは以下の挙動を行う:
- ドライラン(テスト)モードを搭載。実際にキーを無効化する前に、対象となるキーをログに出力できる。
- 作成されてから 90日以上経過したキー を自動的に
Inactive(無効)にする。 - 最終使用日から 30日以上経過した未使用キー も自動的に
Inactiveにする。 - 監査ログやSlack通知に連携しやすいよう、処理結果を詳細に出力する。
実装コード:iam_key_sweeper.py
import boto3
from datetime import datetime, timezone, timedelta
import logging
# ログ設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# 定数定義(運用のポリシーに合わせて調整してください)
MAX_AGE_DAYS = 90 # 作成からの最大許容日数(これを超えたら無効化)
MAX_UNUSED_DAYS = 30 # 最終使用からの最大許容日数(これを超えたら無効化)
DRY_RUN = True # Trueの場合はログ出力のみ(本番適用時は False に変更)
iam_client = boto3.client('iam')
def lambda_handler(event, context):
logger.info("=== IAM Access Key Auto-Sweeper Started ===")
if DRY_RUN:
logger.info("[WARNING] DRY_RUN is enabled. No actual changes will be made.")
now = datetime.now(timezone.utc)
paginator = iam_client.get_paginator('list_users')
for response in paginator.paginate():
for user in response['Users']:
user_name = user['UserName']
# ユーザーごとのアクセスキー一覧を取得
keys_response = iam_client.list_access_keys(UserName=user_name)
for metadata in keys_response['AccessKeyMetadata']:
access_key_id = metadata['AccessKeyId']
status = metadata['Status']
create_date = metadata['CreateDate']
# すでに無効化されているキーはスキップ
if status == 'Inactive':
continue
# 1. 作成からの経過日数を計算
age_delta = now - create_date
# 2. 最終使用日を取得
last_used_response = iam_client.get_access_key_last_used(AccessKeyId=access_key_id)
last_used_date = last_used_response['AccessKeyLastUsed'].get('LastUsedDate')
should_deactivate = False
reason = ""
# 作成期限のチェック
if age_delta.days > MAX_AGE_DAYS:
should_deactivate = True
reason = f"Key age ({age_delta.days} days) exceeded limit ({MAX_AGE_DAYS} days)."
# 最終使用期限のチェック(1度も使われていない場合は作成日を基準にする)
elif last_used_date:
unused_delta = now - last_used_date
if unused_delta.days > MAX_UNUSED_DAYS:
should_deactivate = True
reason = f"Key unused for ({unused_delta.days} days) exceeded limit ({MAX_UNUSED_DAYS} days)."
else:
# 1度も使われておらず、作成から一定期間経っている場合
if age_delta.days > MAX_UNUSED_DAYS:
should_deactivate = True
reason = f"Key never used and created ({age_delta.days} days) ago."
# 無効化処理の実行
if should_deactivate:
deactivate_key(user_name, access_key_id, reason)
logger.info("=== IAM Access Key Auto-Sweeper Finished ===")
return {"status": "success"}
def deactivate_key(user_name, access_key_id, reason):
"""
対象のアクセスキーを無効化(Inactive)する関数
"""
logger.warning(f"[TARGET DETECTED] User: {user_name} | Key: {access_key_id} | Reason: {reason}")
if DRY_RUN:
logger.info(f"[DRY RUN] Would deactivate key {access_key_id} for user {user_name}")
else:
try:
# ステータスを 'Inactive' に更新
iam_client.update_access_key(
UserName=user_name,
AccessKeyId=access_key_id,
Status='Inactive'
)
logger.info(f"[SUCCESS] Successfully deactivated key {access_key_id} for user {user_name}")
# ※ 実務では、ここでSlackやSNS、SecurityHub等に通知を送るロジックを挟むとベスト
except Exception as e:
logger.error(f"[ERROR] Failed to deactivate key {access_key_id} for user {user_name}. Error: {str(e)}")
このスクリプトを実行するためのIAM権限(最小特権)
このLambda関数(または実行環境)に付与するIAMロールには、以下のポリシー(最小権限)を定義してほしい。不必要な * 権限は絶対に与えないこと。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:ListUsers",
"iam:ListAccessKeys",
"iam:GetAccessKeyLastUsed",
"iam:UpdateAccessKey"
],
"Resource": "*"
}
]
}
—
5. さらに上を行く「深度防御」:IP制限ポリシーの併用
スクリプトによる自動無効化は素晴らしい「時間軸の防御」だ。しかし、キーが作成されてから無効化されるまでの「隙間時間(ゴールデンタイム)」に漏洩した場合、攻撃は防げない。
そこで、もう一つの強力な盾である「ネットワーク軸の防御(IP制限)」を組み合わせる。
「このアクセスキーは、本社の固定IP、または社内VPN、あるいは特定のオンプレサーバーのグローバルIPからしか使えない」という制約を、IAMポリシー側で強制するのだ。
究極の防御:IP制限付きIAMポリシーテンプレート
以下は、特定のグローバルIPアドレス(例: 203.0.113.50/32)以外からのアクセスを、明示的に拒否(Deny)するポリシーだ。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSpecificActions",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::your-secure-bucket-name/*"
},
{
"Sid": "DenyAllExceptSpecificIPs",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"NotIpAddress": {
"aws:SourceIp": [
"203.0.113.50/32"
]
},
"Bool": {
"aws:ViaAWSService": "false"
}
}
}
]
}
ここがプロの視点:aws:ViaAWSService の落とし穴を回避せよ
上記のポリシーで非常に重要なのが、 aws:ViaAWSService の条件だ。これを入れずに単に NotIpAddress で Deny してしまうと、例えば「S3へのファイルアップロードをトリガーにして起動するAWS Lambda」や「CloudTrail」などのAWSのマネージドサービス内部からのAPIコールまで拒否されてしまう。
"aws:ViaAWSService": "false" を条件に加えることで、「AWSサービスを経由しない、直接の外部APIコール(=攻撃者によるCLIからの直接操作など)」だけを狙い撃ちして拒否することができる。
—
6. シニアエンジニアからのアドバイス:運用を破綻させない「猶予期間」の設計
最後に、この自動無効化スクリプトを実際に本番運用に載せる際のアドバイスを送る。
「明日からこのスクリプトを有効化します」と突然本番に適用すると、高確率で開発チームのどこかの秘伝のタレ(古いバッチ処理など)が死に、君のデスクの電話が鳴り響くことになる。
運用を成功させるためには、必ず以下のステップを踏むこと。
1. まずは「ドライラン(DRY_RUN = True)」で、ログ監視から始める。
- どのユーザーが、どれくらい古いキーを使っているかをリストアップする。
2. 「通知ファースト」の文化を作る。
- 無効化する「7日前」「3日前」に対象ユーザー(またはSlackチャンネル)に「あなたのキーはあと○日で無効化されます。速やかにローテーションしてください」と自動通知する仕組みをLambdaに統合する。
3. 無効化(Inactive)と削除(Delete)を分ける。
- スクリプトでいきなり「削除(Delete)」してはいけない。まずは「無効化(Inactive)」にする。もし何か問題が起きても、管理画面から「Active」に戻すだけで1秒で復旧できる。無効化して2週間何もクレームが来なければ、その時初めて「削除」のフェーズに進む。
セキュリティは、開発者の利便性をただ奪うものであってはならない。「システム的に安全な状態が、開発者にとっても一番楽である」という状態を作ることこそが、我々セキュリティエンジニアの腕の見せ所だ。
早速、今日の検証環境で、まずはドライランから試してみてほしい。何かわからないことがあれば、いつでもコードのレビューに付き合うよ。安全なシステムを、共に作っていこう。
コメント