[小ネタ] S3にアップロードされたファイルタイプの真偽を見抜く
はじめに
みなさんこんにちは、今回はS3に関する小ネタです。
S3コンソール画面のタイプ表示やそれに付随するシステムメタデータのContent-Typeはアップロードされたファイルの拡張子を参照しています。なのでこのタイプが実際に保存されているオブジェクトの形式とは限りません。
PNG形式の画像ファイルを拡張子のみさまざま形式に変更したもの↓
さらに言えば以下のバケットポリシーで「.jpg」以外の拡張子のアップロードを明示的に禁止すると、当然ですが拡張子「.png」などはアップロードできません。「.jpeg」「.JPG」なども不可です
{
"Version": "2012-10-17",
"Id": "AllowOnlyJpgUpload",
"Statement": [
{
"Sid": "DenyNonJpgUpload",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"NotResource": "arn:aws:s3:::{バケット名}/*.jpg"
}
]
}
しかし、拡張子を「.jpg」に変更すれば実際のデータはPNG形式なのにアップロードできてしまいます。
拡張子はあくまで人間が判断できるように存在しており、システム的に制限をかけたりする場合にはバイナリの先頭識別子でファイル形式を判定する「マジックナンバー」という仕組みがあります。
そこで今回はファイル識別子に関するマジックナンバーの仕組みを使って、拡張子ではない本当のファイルタイプを判別してくれる様に以下のフローで検証してみます。
- S3オブジェクトアップロード
- 「.jpg」拡張子のみイベント通知
- Lambda関数でマジックナンバーの仕組みを使い実際のMIMEタイプを判定
- 本当に「JPEG」か検証
今回はあくまで確かめるだけです。その後の展開として通知を送信したり、禁止ファイルを削除したり等の処理が期待されます。
Lambda用IAMロールの作成
まず、Lambda用のIAMロールを作成します。
aws iam create-role \
--role-name image-extension-validation-function-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}'
- CloudWatch Logs 書き込み用ポリシーをアタッチ
- S3からファイルを読み込むためのポリシーをアタッチ
aws iam attach-role-policy \
--role-name image-extension-validation-function-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam attach-role-policy \
--role-name image-extension-validation-function-role \
--policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
Lambda関数の用意
まずは検知に使用するLambda関数を用意します。
環境
項目 | 設定値 |
---|---|
ランタイム | Python 3.12 |
ハンドラー | main.lambda_handler |
タイムアウト | 3秒(default) |
メモリ設定 | 128MB(default) |
使用ライブラリ
ライブラリ名 | 用途 |
---|---|
boto3 |
S3とのやりとり |
python-magic |
MIME判定 |
import boto3
import magic
import urllib.parse
s3 = boto3.client('s3')
def lambda_handler(event, context):
record = event['Records'][0]
bucket = record['s3']['bucket']['name']
key = urllib.parse.unquote_plus(record['s3']['object']['key'])
obj = s3.get_object(Bucket=bucket, Key=key)
body = obj['Body'].read(1024)
mime = magic.from_buffer(body, mime=True)
print(f"[ファイル] {key} の実際のMIMEタイプ: {mime}")
if key.endswith('.jpg') and mime != 'image/jpeg':
print(f"警告: {key} はJPEGではありません!(実際: {mime})")
「python-magic」はfileコマンド等で使われている「libmagic」というC言語ライブラリのラッパーで、パターン定義済みのマジックファイルと読み込んだバイナリの先頭バイトを照らし合わせてMIMEタイプを判定してくれます。今回はこちらを使ってS3にアップロードされたファイルタイプの真偽を検証していきます。
下記の箇所でオブジェクトの先頭1kBの部分のみを読み取り、from_buffer関数にてマジックナンバーからMIMEタイプを返してもらい判定しています。
body = obj['Body'].read(1024)
mime = magic.from_buffer(body, mime=True)
また今回はS3イベント通知を使うので、以下のJSON形式でイベント通知が期待されます。
{
"Records":[
{
"eventVersion":"2.2",
"eventSource":"aws:s3",
"awsRegion":"us-west-2",
"eventTime":"The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, when Amazon S3 finished processing the request",
"eventName":"event-type",
"userIdentity":{
"principalId":"Amazon-customer-ID-of-the-user-who-caused-the-event"
},
"requestParameters":{
"sourceIPAddress":"ip-address-where-request-came-from"
},
"responseElements":{
"x-amz-request-id":"Amazon S3 generated request ID",
"x-amz-id-2":"Amazon S3 host that processed the request"
},
"s3":{
"s3SchemaVersion":"1.0",
"configurationId":"ID found in the bucket notification configuration",
"bucket":{
"name":"amzn-s3-demo-bucket",
"ownerIdentity":{
"principalId":"Amazon-customer-ID-of-the-bucket-owner"
},
"arn":"bucket-ARN"
},
"object":{
"key":"object-key",
"size":"object-size in bytes",
"eTag":"object eTag",
"versionId":"object version if bucket is versioning-enabled, otherwise null",
"sequencer": "a string representation of a hexadecimal value used to determine event sequence, only used with PUTs and DELETEs"
}
},
"glacierEventData": {
"restoreEventData": {
"lifecycleRestorationExpiryTime": "The time, in ISO-8601 format, for example, 1970-01-01T00:00:00.000Z, of Restore Expiry",
"lifecycleRestoreStorageClass": "Source storage class for restore"
}
}
}
]
}
S3イベント通知設定
Lambda関数のコンソールの「設定」タブから「トピック」→「トピックの追加」を選択し以下を設定
- 設定したいバケット
- イベントタイプを「すべてのオブジェクト作成(s3:ObjectCreated:*)」を選択
- サフィックスを「.jpg」へ
この設定にて「.jpg」ファイルが指定のバケットにPUT,POST,COPY,マルチパートアップロードされるとLambda関数の処理が走ります。
検証
検証ファイル
検証として以下の6ファイルを「.jpg」としてアップロードします
- PNG: イラスト屋
- JPEG: 上記イラスト屋画像をJPEGに変換した本物
- PDF: Amazon Simple Storage Service ユーザーガイド
- MP4: 自前で用意した適当な動画
- 暗号化JPEG: OpenSSLを使って上記イラスト屋のJPEGを共通鍵暗号方式(AES-256-CBC)で暗号化して保存
openssl enc -aes-256-cbc -in ohanami_walk_mask.jpg -out encrypted_ohanami_walk_mask.jpg
- 空ファイル(0byte): touchコマンドで作成
結果
PNGの検知結果のCloudwatchログイベントです
「.jpg」と偽ってアップロードしましたが、image/pngと検知でき、警告文が出ています。
その他結果です。
アップロードファイル | 結果 | 検知したMIMEType |
---|---|---|
PNG | 検知 | image/png |
JPEG | スルー | image/jpeg |
検知 | application/pdf | |
MP4 | 検知 | video/mp4 |
暗号化JPEG | 検知 | application/octet-stream |
空ファイル | 検知 | application/x-empty |
PNG,JPEG,PDF,MP4に関しては大体想定通りでした。暗号化JPEGに関しては暗号化方式にもよると思いますが「application/octet-stream」というMIMEタイプ判定になりました。MIMEが未知のバイナリデータという定義で扱われているらしいです。
また、空ファイルに関しても「application/x-empty」となっていますが、これはIANA管理の正式なMIMEではなく作成したシステムに依存する様です。
最新のIANA管理の公式MIMEタイプ一覧
最後に
S3のファイル判定は拡張子に依存しますし、メタデータの「Content-Type」は後から変更可能です。
バケットポリシーで「.jpg」以外禁止と設定しても、実態はPNGやPDFでも拡張子さえ合わせれば素通りしてしまいます。
今回の検証で本当のファイル形式を検知することができましたが、この仕組みを拡張するとセキュリティ対策やコンプライアンス要件の実装、メディアファイル管理の自動化など、幅広い用途に応用できそうです。S3の便利さを活かしつつ、その制約を理解して補完する工夫が大切ですね!