はじめに
S3にユーザーがファイルをアップロードするシステムを運用している場合、マルウェアが含まれたファイルが本番環境に混入するリスクがあります。
本記事では、Amazon GuardDuty Malware Protection for S3 を使って、S3にアップロードされたファイルのマルウェアスキャンを行い、結果に応じてファイルを振り分ける仕組みを実際に構築・検証の結果をまとめてみます。
構成概要
今回構築した構成は以下のとおりです。
ユーザー
↓ ファイルをPUT
一時保存用S3バケット(malware-protection-dev)
↓ 自動スキャン
GuardDuty Malware Protection for S3
↓ スキャン結果をイベント通知
Amazon EventBridge
↓ Lambdaを起動
AWS Lambda(振り分け処理)
├─ NO_THREATS_FOUND / SKIPPED / UNSUPPORTED → 本番S3バケットに転送
└─ THREATS_FOUND → 一時S3バケットから削除
一時バケットと本番バケットを分離することで、マルウェアが含まれるファイルが本番環境に混入するリスクを低減しています。
使用するAWSサービスとその構成
| サービス | 役割 |
|---|---|
| Amazon S3(一時バケット) | ユーザーのアップロード先 |
| GuardDuty Malware Protection for S3 | マルウェアスキャン |
| Amazon EventBridge | スキャン結果の検知・ルーティング |
| AWS Lambda | ファイルの転送・削除処理 |
| Amazon S3(本番バケット) | クリーンなファイルの保存先 |
| Amazon CloudWatch Logs | Lambdaのログ出力先 |
料金について
| 課金軸 | 料金 |
|---|---|
| スキャンデータ量 | 約$0.09/GB |
| スキャンオブジェクト数 | 一定数ごとに課金 |
2025年2月のアップデートにより、スキャンデータ量の料金が85%値下げされています。
検証が終わったら即時無効化できるため、検証目的であれば費用はほぼかかりません。
私の検証でかかったコストも$1以下でした。
構築手順
1. S3バケットの作成
一時バケットと本番バケットをそれぞれ作成します。
- 一時バケット:
malware-protection-dev - 本番バケット:
malware-protection-prod
バージョニングはデフォルトのままで問題ありません。
2. GuardDuty Malware Protection for S3の有効化
GuardDutyコンソール
→ 左メニュー「Malware Protection for S3」
→ 「Discover GuardDuty Protection features」を選択
→ 「Malware Protection for S3」を選択して有効化
→ 対象バケット:malware-protection-dev
→ タグ付け:有効化
→ IAMロール:自動作成
→ 「保護プランを追加」
ポイント:「Enable All GuardDuty features」ではなく「Discover GuardDuty Protection features」を選択することで、GuardDuty本体を有効化せずにMalware Protection for S3だけを使えます。これにより余計な料金の発生を防げます。
3. IAMロールの作成(Lambda用)
LambdaがS3を操作するためのIAMロールを作成します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::malware-protection-dev/*"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::malware-protection-prod/*"
}
]
}
注意:AmazonS3FullAccessを付与したい気持ちも分かりますが、最小権限のためGetとDeleteのみ付与しています。
4. Lambda関数の作成
Lambdaコンソール → 「関数の作成」
→ 関数名:guardduty-malware-handler
→ ランタイム:Python 3.12
→ 実行ロール:手順3で作成したロール
以下のコードを貼り付けて「Deploy」をクリックします。
import boto3
import json
s3 = boto3.client('s3')
SOURCE_BUCKET = 'malware-protection-dev'
DEST_BUCKET = 'malware-protection-prod'
def lambda_handler(event, context):
print(json.dumps(event))
detail = event['detail']
scan_status = detail['scanResultDetails']['scanResultStatus']
key = detail['s3ObjectDetails']['objectKey']
print(f"File: {key}, Status: {scan_status}")
if scan_status == 'THREATS_FOUND':
# マルウェア検知 → 削除
s3.delete_object(Bucket=SOURCE_BUCKET, Key=key)
print(f"Deleted malware file: {key}")
else:
# NO_THREATS_FOUND / SKIPPED / UNSUPPORTED → 本番に転送
s3.copy_object(
CopySource={'Bucket': SOURCE_BUCKET, 'Key': key},
Bucket=DEST_BUCKET,
Key=key
)
s3.delete_object(Bucket=SOURCE_BUCKET, Key=key)
print(f"Transferred clean file: {key}")
return {'statusCode': 200}
5. EventBridgeルールの作成
EventBridgeコンソール → 「ルールを作成」
→ 名前:guardduty-malware-scan-rule
→ イベントバス:default
→ ルールタイプ:イベントパターンを持つルール
イベントパターンは「カスタムパターン(JSONエディター)」で以下を入力します。
{
"source": ["aws.guardduty"],
"detail-type": ["GuardDuty Malware Protection Object Scan Result"],
"detail": {
"s3ObjectDetails": {
"bucketName": ["malware-protection-dev"]
}
}
}
ターゲットには手順4で作成したLambda関数を指定します。
スキャン結果のタグについて
GuardDutyはスキャン後、S3オブジェクトに以下のタグを付与します。
| タグ値 | 意味 |
|---|---|
NO_THREATS_FOUND |
マルウェアなし(正常) |
THREATS_FOUND |
マルウェアを検出 |
SKIPPED |
サイズ超過等でスキャンをスキップ |
UNSUPPORTED |
パスワード付きZIP等、スキャン非対応 |
FAILED |
スキャン処理自体が失敗 |
Findingsについて
GuardDuty本体を有効化した状態でMalware Protection for S3を使用している場合、スキャン結果はGuardDuty Findingsにも記録されます。
Findingsには以下の情報が含まれます。
- 検出されたマルウェアの種類
- 対象のS3バケット名・オブジェクトキー
- 検出日時
GuardDutyを有効化せずにMalware Protection for S3を単体で使用している場合、Findingsは生成されません。スキャン結果はEventBridgeのイベントとCloudWatchメトリクスで確認できます。
SKIPPEDになった場合のCloudWatchでの確認
SKIPPEDタグが付いたオブジェクトについては、なぜSKIPPEDになったかをCloudWatchメトリクスで確認できます。
CloudWatchコンソール
→ 左メニュー「メトリクス」
→ 「GuardDuty」を選択
→ スキャンステータスごとのカウントを確認
また、LambdaのCloudWatch Logsを確認することで、対象ファイルのスキャン結果をより詳細に把握できます。
検証
テストファイルの準備
EICARテストファイルを使用しました。EICARファイルは実際のマルウェアではなく、セキュリティ製品のテスト用に業界標準として使われる無害なファイルです。
# ①クリーンファイル(マルウェアなし)
echo "This is a clean file" > clean_file.jpg
# ②EICARファイル(マルウェアあり)
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar_file.jpg
# ③パスワード付きZIP(クリーン)
echo "This is a clean file" > test_clean.txt
zip -e clean_encrypted.zip test_clean.txt
# ④パスワード付きZIP(EICARを含む)
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > test_eicar.txt
zip -e eicar_encrypted.zip test_eicar.txt
検証結果
| ファイル | GuardDutyタグ | 本番S3への転送 |
|---|---|---|
| ①クリーンファイル | NO_THREATS_FOUND |
転送される |
| ②EICARファイル | THREATS_FOUND |
削除される |
| ③パスワード付きZIP(クリーン) | UNSUPPORTED |
転送される |
| ④パスワード付きZIP(EICAR) | UNSUPPORTED |
転送されてしまう |
注意点:UNSUPPORTEDの落とし穴
パスワード付きZIPはGuardDutyがスキャンできないため、UNSUPPORTEDタグが付きます。
今回の実装ではTHREATS_FOUND以外は本番に転送する設計にしているため、マルウェアが含まれるパスワード付きZIPも本番バケットに転送されてしまいます。
対策としては、アプリケーション側でパスワード付きZIPのアップロードを禁止するか、UNSUPPORTEDもLambdaで削除する運用にすることが考えられます。
スキャン上限について
2025年9月のアップデートにより、スキャン可能なファイルサイズの上限が5GBから100GBに拡張されました。
100GBを超えるファイルはSKIPPEDタグが付き、スキャンがスキップされます。
つまずいたポイント
EventBridgeのイベントパターンのキー名が想定と異なっていた
EventBridgeのイベントパターンで最初は以下を指定していました。
{
"detail": {
"s3Bucket": {
"name": ["malware-protection-dev"]
}
}
}
しかし、実際にGuardDutyが送るイベントのJSONを確認すると、キー名が異なっていました。
{
"detail": {
"s3ObjectDetails": {
"bucketName": "malware-protection-dev"
}
}
}
原因の特定方法: EventBridgeのイベントパターンを{"source": ["aws.guardduty"]}に一時的に緩め、Lambdaに届いたイベントのJSONをCloudWatch Logsで確認することで特定しました。
Lambdaのコードでもキー名が誤っていた
同様に、Lambdaコード内のキー参照も誤っていました。
# 誤り
key = detail['s3Object']['key']
# 正しい
key = detail['s3ObjectDetails']['objectKey']
IAMロールの権限設定ミス
最初はIAMロールの作成を手動で行い、Lambda作成時に既存ロールを指定するつもりでした。しかし実際にはLambda作成時に自動生成されたロールが使われており、そのロールにはS3への操作権限が付与されていませんでした。
[ERROR] AccessDenied: An error occurred (AccessDenied)
when calling the DeleteObject operation:
User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/
guardduty-malware-handler-role-xxxxxxxx/
guardduty-malware-handler is not authorized to perform:
s3:DeleteObject
原因の特定方法: CloudWatch LogsでLambdaのエラーログを確認し、エラーメッセージ内のロール名を確認することで、意図したロールとは別のロールが使われていることが判明しました。
対処法: 自動生成されたロール(guardduty-malware-handler-role-xxxxxxxx)に対して必要な権限を付与しました。
なお、最初は検証のためAmazonS3FullAccessを付与しましたが、これは最小権限の原則に反します。本来必要なのは以下の権限のみです。
- 一時バケット:
s3:GetObject、s3:DeleteObject - 本番バケット:
s3:PutObject
まとめ
GuardDuty Malware Protection for S3を使うことで、S3へのマルウェアファイルのアップロードをサーバーレスかつシンプルな構成で検知・振り分けできることを確認しました。
ただし、以下の点には注意が必要です。
- GuardDuty単体ではファイルの削除・隔離はできない(タグ付けのみ)EventBridge + Lambdaとの組み合わせが必要
- パスワード付きZIPやサイズ超過ファイルはスキャンされない(UNSUPPORTED / SKIPPED)
- ゼロデイマルウェアや難読化されたマルウェアはすり抜ける可能性がある
完全なセキュリティはGuardDuty単体では実現できないため、アプリケーション側のバリデーションと組み合わせた多層防御が重要だと身をもって認識できました。
