画像フォーマットの自動判定技術 - マジックナンバーによるファイル識別の仕組み
なぜ拡張子だけではフォーマット判定が不十分なのか
ファイルの拡張子 (.jpg, .png, .webp) は人間やオペレーティングシステムがファイルの種類を識別するための慣習的なラベルに過ぎず、ファイルの実際の内容を保証するものではありません。拡張子は自由に変更可能であり、悪意のあるファイルが無害な拡張子に偽装されるケースは日常的に発生しています。
拡張子に依存する問題点:
- セキュリティリスク: 実行可能ファイルや悪意のあるスクリプトが .jpg や .png に偽装されてアップロードされる可能性があります。サーバーサイドで拡張子のみをチェックしている場合、この攻撃を防げません
- 互換性の問題: ユーザーが手動で拡張子を変更した場合 (例: .png を .jpg にリネーム)、実際のフォーマットと拡張子が不一致になります。画像処理ライブラリがこの不一致を検出できないと、デコードエラーが発生します
- MIME タイプの不正確さ: Web サーバーが拡張子ベースで Content-Type を設定している場合、実際のフォーマットと異なる MIME タイプが返されることがあります
- フォーマット変換の残骸: 画像変換ツールが拡張子を更新せずにフォーマットを変換した場合、.jpg ファイルの中身が実際には WebP だったというケースが発生します
これらの問題を解決するために、ファイルのバイナリデータ先頭に含まれる「マジックナンバー」(ファイルシグネチャ) を検査する手法が広く採用されています。マジックナンバーはファイルフォーマットの仕様で定義された固定バイト列であり、拡張子と異なり改ざんが困難です。
マジックナンバーの仕組み - 主要画像フォーマットのシグネチャ一覧
マジックナンバー (Magic Number) とは、ファイルの先頭数バイトに配置される固定のバイト列で、ファイルフォーマットを一意に識別するための署名です。ほぼすべての画像フォーマットが独自のマジックナンバーを定義しており、これを検査することでファイルの真のフォーマットを判定できます。
主要画像フォーマットのマジックナンバー:
- JPEG:
FF D8 FF(3 バイト)。すべての JPEG ファイルはこの 3 バイトで始まります。4 バイト目は JFIF (E0)、EXIF (E1)、Adobe (EE) などのマーカーによって異なります - PNG:
89 50 4E 47 0D 0A 1A 0A(8 バイト)。ASCII で読むと "\x89PNG\r\n\x1a\n" です。この長いシグネチャは、テキスト転送での破損検出も兼ねています - GIF:
47 49 46 38 37 61(GIF87a) または47 49 46 38 39 61(GIF89a)。ASCII で "GIF87a" または "GIF89a" と読めます - WebP:
52 49 46 46 ?? ?? ?? ?? 57 45 42 50。先頭 4 バイトが "RIFF"、オフセット 8-11 が "WEBP"。中間の 4 バイトはファイルサイズです - AVIF:
00 00 00 ?? 66 74 79 70 61 76 69 66。ISOBMFF (ISO Base Media File Format) コンテナの ftyp ボックスに "avif" ブランドが含まれます - BMP:
42 4D(2 バイト)。ASCII で "BM"。Windows Bitmap の識別子です - TIFF:
49 49 2A 00(リトルエンディアン) または4D 4D 00 2A(ビッグエンディアン)。"II" または "MM" で始まります - SVG: バイナリシグネチャなし。XML ベースのため
<?xmlまたは<svgタグの存在で判定します
マジックナンバーの検査に必要なバイト数は最大でも 12 バイト程度であり、ファイル全体を読み込む必要がないため、大量ファイルの高速判定に適しています。
JavaScript でのフォーマット判定実装 - ブラウザと Node.js
フロントエンド (ブラウザ) とバックエンド (Node.js) の両方で画像フォーマットの自動判定を実装する方法を、具体的なコード例とともに解説します。
ブラウザでの実装 (File API + ArrayBuffer):
- ユーザーがアップロードしたファイルの先頭バイトを読み取り、マジックナンバーと照合します
FileReader.readAsArrayBuffer(file.slice(0, 12))で先頭 12 バイトのみを読み込み、メモリ効率を確保します- 読み込んだ ArrayBuffer を Uint8Array に変換し、バイト値を比較します
実装例:
function detectFormat(buffer) { const bytes = new Uint8Array(buffer); if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'jpeg'; if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'png'; if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'gif'; if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[8] === 0x57 && bytes[9] === 0x45) return 'webp'; return 'unknown'; }
Node.js での実装:
fs.read(fd, buffer, 0, 12, 0)でファイルの先頭 12 バイトを読み取ります- npm パッケージ「file-type」(v18+) は 4500 以上のファイルタイプに対応した判定ライブラリで、ストリーム入力にも対応しています
- 大量ファイルの処理では、ファイルディスクリプタを開いて先頭バイトだけ読む方法が最も高速です
エッジケースへの対応:
- 0 バイトのファイル (空ファイル) に対するガード処理
- マジックナンバーが一致しない場合の適切なエラーハンドリング
- HEIC/HEIF は AVIF と同じ ISOBMFF コンテナを使用するため、ftyp ブランド ("heic", "heix", "mif1") で区別する必要があります
サーバーサイドでのセキュアなフォーマット検証
Web アプリケーションでファイルアップロードを受け付ける場合、サーバーサイドでのフォーマット検証はセキュリティの最後の砦です。クライアントサイドの検証は容易にバイパスされるため、サーバーサイドでの多層的な検証が不可欠です。
検証の多層防御戦略:
- 第 1 層: Content-Type ヘッダーの確認: リクエストの Content-Type が許可リスト (image/jpeg, image/png, image/webp, image/avif) に含まれるか確認します。ただしこの値はクライアントが自由に設定できるため、これだけでは不十分です
- 第 2 層: マジックナンバーの検証: ファイルの先頭バイトを読み取り、宣言された Content-Type と実際のフォーマットが一致するか検証します。不一致の場合はリクエストを拒否します
- 第 3 層: 画像デコードの試行: 実際に画像ライブラリ (Sharp, Pillow, ImageMagick) でデコードを試み、正常にデコードできるか確認します。破損ファイルやポリグロットファイル (複数フォーマットとして解釈可能なファイル) を検出できます
- 第 4 層: メタデータの検証: 画像の寸法 (width, height) が妥当な範囲内か、ファイルサイズが上限を超えていないかを確認します。極端に大きな寸法 (例: 100,000 × 100,000 px) はデコンプレッションボム攻撃の可能性があります
Python (Flask) での実装例:
import magic; mime = magic.from_buffer(file.read(2048), mime=True); if mime not in ALLOWED_MIMES: abort(415)
python-magic ライブラリは libmagic のバインディングで、マジックナンバーデータベースを使用して 1000 以上のファイルタイプを判定できます。Node.js では「file-type」パッケージが同等の機能を提供します。
MIME タイプスニッフィングとブラウザの挙動
ブラウザは Content-Type ヘッダーが不正確または欠落している場合、ファイルの内容を検査して MIME タイプを推定する「MIME スニッフィング」を行います。この挙動はユーザビリティを向上させる一方で、セキュリティリスクも生み出します。
MIME スニッフィングの仕組み:
- WHATWG MIME Sniffing Standard: ブラウザが MIME タイプを推定するアルゴリズムは WHATWG によって標準化されています。レスポンスの先頭バイトを検査し、既知のシグネチャと照合します
- 画像の判定: ブラウザは JPEG (FF D8 FF)、PNG (89 50 4E 47)、GIF (47 49 46 38)、BMP (42 4D)、WebP (RIFF...WEBP)、AVIF (ftyp avif) のシグネチャを認識し、Content-Type が不正確でも正しくレンダリングします
- セキュリティリスク: 攻撃者が HTML や JavaScript を画像ファイルに偽装してアップロードし、ブラウザの MIME スニッフィングで text/html として解釈させる XSS 攻撃が存在します
対策としての X-Content-Type-Options ヘッダー:
X-Content-Type-Options: nosniffを設定すると、ブラウザは Content-Type ヘッダーを厳密に尊重し、MIME スニッフィングを行いません- 画像配信サーバーでは必ずこのヘッダーを設定し、Content-Type を正確に返すことが推奨されます
- CDN (CloudFront, Cloudflare) のレスポンスヘッダーポリシーで一括設定できます
Content-Type の正確な設定方法:
- S3 にアップロードする際は、マジックナンバーから判定した正確な MIME タイプを ContentType パラメータに設定します
- Nginx では
typesディレクティブで拡張子と MIME タイプのマッピングを定義します - 動的に生成する場合は、画像処理ライブラリの出力フォーマットに対応する MIME タイプを設定します
高度な判定テクニック - コンテナフォーマットと多重判定
AVIF、HEIC、WebP などの現代的な画像フォーマットは、汎用コンテナフォーマット内に画像データを格納する構造を持っています。これらのフォーマットを正確に判定するには、単純なマジックナンバー照合を超えた、コンテナ構造の解析が必要です。
ISOBMFF (ISO Base Media File Format) ベースのフォーマット:
- AVIF: ftyp ボックスの major_brand が "avif" または "avis" (シーケンス AVIF)
- HEIC: ftyp ボックスの major_brand が "heic"、"heix"、"heim"、"heis" のいずれか
- HEIF: ftyp ボックスの major_brand が "mif1" または "msf1"
- これらはすべて先頭が
00 00 00 ?? 66 74 79 70(ftyp) で始まるため、ftyp 以降のブランド文字列で区別する必要があります
RIFF コンテナベースのフォーマット:
- WebP: RIFF ヘッダー + "WEBP" チャンク。さらに VP8 (Lossy)、VP8L (Lossless)、VP8X (Extended) のサブフォーマットを判定可能
- AVI: RIFF ヘッダー + "AVI " チャンク。WebP と同じ RIFF コンテナを使用するため、オフセット 8-11 の 4 バイトで区別します
実装上の考慮事項:
- 判定に必要なバイト数はフォーマットによって異なります。JPEG は 3 バイト、PNG は 8 バイト、AVIF/HEIC は最大 32 バイト程度が必要です
- ストリーミング処理では、十分なバイト数が到着するまでバッファリングする必要があります
- 複数のフォーマットに一致する可能性がある場合 (ポリグロットファイル) は、最も厳密な条件で判定します
- 判定結果をキャッシュすることで、同一ファイルへの重複判定を回避できます。Redis や DynamoDB に MIME タイプを保存し、ファイルハッシュをキーとして参照する設計が効率的です