【PHP】アップロードされたファイルの種類をどうやって確認するの？

[前回](http://qiita.com/7968/items/89e3f53b9cf9d96e3d6b)、`$_FILES['userfile']['type']` の値は、信用できないので、PHP側で確認しましょうということで終わりました。
そもそも、リクエストメッセージ全体を偽装できるので、`$_FILES['userfile']['type']` の値に限らず、**ユーザーが入力・選択して送られてきたリクエストメッセージの値は全て信用できないため、PHP側で想定した値か確認する必要があります。**
当然、`$_FILES['userfile']['type']` の値もPHP側で確認する必要があります。

今回は、アップロードされたファイルの種類をどうやって確認するのか見ていきます。
そもそも、PHP側で正しいファイルかどうか確認する方法なんてあるのでしょうか？
リクエストメッセージで送られてくる MIME タイプは信用できないし、それ以前にリクエストメッセージ全体を偽装できるので、お手上げな感じがします。
リクエストメッセージを見ながら、ファイルの種類を判断できそうなところがないか探してみましょう。
今回も、Windows7 の XAMPP 環境で、GoogleChrome を使って確認しています。
ファイルを選択して、送信すると `$_FILES['userfile']['type']` の値を表示するPHPファイルを作って確認していきます。

```php:PHP
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>PHP</title>
</head>
<body>
<form  method="post" enctype="multipart/form-data">
    <p>Pictures:
        <input type="file" name="pictures">
        <input type="submit" value="送信">
    </p>
</form>
<?php
if(isset($_FILES['pictures'])) {
    echo $_FILES['pictures']['type'];
}
?>
</body>
</html>
```

10px 四方の `square.png` という PNG ファイルを選択して、送信したときのリクエストメッセージです。

```http:HTTP（リクエストメッセージ）
POST http://localhost/test/ HTTP/1.1
Host: localhost
Connection: keep-alive
Content-Length: 333
Cache-Control: max-age=0
Origin: http://localhost
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryOxvx77UbxyArWSz4
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Referer: http://localhost/test/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6

------WebKitFormBoundaryOxvx77UbxyArWSz4
Content-Disposition: form-data; name="pictures"; filename="square.png"
Content-Type: image/png

89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 00 00 00 0A 00 00 00 0A 08 02 00 00 00 02 50 58 EA 00 00 00 01 73 52 47 42 00 AE CE 1C E9 00 00 00 04 67 41 4D 41 00 00 B1 8F 0B FC 61 05 00 00 00 09 70 48 59 73 00 00 0E C3 00 00 0E C3 01 C7 6F A8 64 00 00 00 27 49 44 41 54 28 53 7D C7 31 0D 00 00 08 03 30 FC CB 98 BA C9 E0 25 3D 48 FA 74 26 FD 78 78 78 78 78 78 78 78 F8 2B 5D DB 72 99 E9 CB 92 5F F9 00 00 00 00 49 45 4E 44 AE 42 60 82 
------WebKitFormBoundaryOxvx77UbxyArWSz4--

```

よく見ると、メッセージボディに `89 50 4E 47 0D 0A 1A ・・・` とあります。
これはアップロードしたファイルの**バイナリデータ**です。
バイナリデータとは何でしょうか。

> バイナリとは、コンピュータ用語としては、データが「0」と「1」で表現されているデータ形式のこと、あるいは、<ins>テキストではない情報でデータが書かれているファイル一般のこと</ins>である。
> 
> バイナリ（binary）とは、元々「2進数の」という意味の英語である。コンピュータはデータを処理するために、全ての情報を2進数に変換しているので、コンピュータが解釈するために用意されたデータはすべてバイナリ形式となっている。
> 
> 一般的には、データがバイナリで記述されているファイルはバイナリファイル、バイナリファイルのデータはバイナリデータと呼ばれている。<ins>バイナリファイルの主なものには、音声ファイルや画像ファイル</ins>、実行形式のプログラムファイル、圧縮ファイルなどがある。
> 
> 引用：[バイナリとは 「バイナリー」 (binary)： - IT用語辞典バイナリ](http://www.sophia-it.com/content/%E3%83%90%E3%82%A4%E3%83%8A%E3%83%AA)

画像ファイルは、バイナリファイルです。
`89 50 4E 47 0D 0A 1A ・・・` という記述は、1バイトずつ区切って16進数で表示したバイナリデータです。
16進数のことを英語で Hexadecimal（ヘキサデシマル） と言い、この先頭の文字をとって Hex と表記することがあります。
ちなみに、私はリクエストメッセージを確認するのに [Fiddler](http://www.telerik.com/fiddler) というソフトを使っていますが、HexView という項目を選択することで、リクエストメッセージを16進数表記で確認できます。
Fidder では Raw という項目を選択することで、リクエストメッセージ全体を表示することができますが、バイナリデータの箇所は正しく表示されません。

![fiddler.png](https://qiita-image-store.s3.amazonaws.com/0/72985/75de8cdb-7dd2-6956-2f20-8cf87627bcf2.png)

先ほどのリクエストメッセージは、私が Raw と HexView で表示したものを組み合わせたものです。

実はこのバイナリデータにはファイルの種類を識別できる情報が含まれています。
この識別できる情報のことを

+ マジックナンバー
+ フォーマット識別子
+ マジックバイト
+ マジックバイトシーケンス

などと呼びます。
様々な呼び名がありますが、この記事では**マジックナンバー**と呼びます。
マジックナンバーは、ファイルの先頭に記述されていることが多いです。

画像ファイルのマジックナンバーは、下記の通りです。

|拡張子|マジックナンバー（Hex）|
|:--|:--|
|png|89 50 4E 47|
|jpeg|FF D8 DD E0|
|jpg|FF D8 FF EE|
|GIF(89a)|47 49 46 38 39 61|
|GIF(87a)|47 49 46 38 37 61|

引用： [Qiita - マジックナンバーまとめ](http://qiita.com/forestsource/items/15933888466ba9c3f048) by @forestsource さん

引用元には、他のファイルのマジックナンバーも記載されています。
マジックナンバーを一覧で掲載しているところがなくて、非常に助かりました。
ありがとうございます((_ _ (´ω｀ )ﾍﾟｺ

PNG のマジックナンバーは `89 50 4E 47` です。
先ほどのリクエストメッセージを、もう一度確認してみましょう。

```http:HTTP（リクエストメッセージ）
POST http://localhost/test/ HTTP/1.1
Host: localhost
Connection: keep-alive
Content-Length: 333
Cache-Control: max-age=0
Origin: http://localhost
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryOxvx77UbxyArWSz4
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Referer: http://localhost/test/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6

------WebKitFormBoundaryOxvx77UbxyArWSz4
Content-Disposition: form-data; name="pictures"; filename="square.png"
Content-Type: image/png

89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 00 00 00 0A 00 00 00 0A 08 02 00 00 00 02 50 58 EA 00 00 00 01 73 52 47 42 00 AE CE 1C E9 00 00 00 04 67 41 4D 41 00 00 B1 8F 0B FC 61 05 00 00 00 09 70 48 59 73 00 00 0E C3 00 00 0E C3 01 C7 6F A8 64 00 00 00 27 49 44 41 54 28 53 7D C7 31 0D 00 00 08 03 30 FC CB 98 BA C9 E0 25 3D 48 FA 74 26 FD 78 78 78 78 78 78 78 78 F8 2B 5D DB 72 99 E9 CB 92 5F F9 00 00 00 00 49 45 4E 44 AE 42 60 82 
------WebKitFormBoundaryOxvx77UbxyArWSz4--

```

PNG のマジックナンバー `89 50 4E 47` と一致してますね。
このようにファイルの中にあるマジックナンバーを確認することで、何のファイルか判断することができます。
PHPには、ファイルの種類を確認できる関数やクラスがありますので、それらを使うことでファイルからMIMEタイプを判断できます。
それらの関数やクラスを確認する前に、ファイルをバイナリデータとして表示する方法を見ていきます。

# ファイルをバイナリデータとして表示する方法

画像ファイルなどのバイナリデータは、基本的にテキストエディタなどでは開けません。
バイナリ表記に対応したテキストエディタなら開けますが、対応していないテキストエディタで開くと文字化けしたように表示されます。

さくっと見たい方は、 [HexEd.it](https://hexed.it/) などのサイトを利用して、ファイルをアップロードすれば確認できます

他にも、PHPを使って、アップロードしたファイルをバイナリデータとして表示することができます。

```php:PHP（バイナリデータを表示する方法）
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>PHP</title>
</head>
<body>
<form method="post" enctype="multipart/form-data">
    <p>Pictures:
        <input type="file" name="pictures">
        <input type="submit" value="送信">
    </p>
</form>
<?php
if(isset($_FILES['pictures'])) {
    $tmp_file_data = bin2hex(file_get_contents($_FILES['pictures']['tmp_name']));
    echo $tmp_file_data;
}
?>
</body>
</html>
```

ファイルをアップロードすると、バイナリ（Hex）が表示されるPHPです。
ポイントとなる `bin2hex(file_get_contents($_FILES['pictures']['tmp_name']))` の記述について簡単に見ていきます。
アップロードしたファイルは、サーバー上のテンポラリフォルダ内にテンポラリファイルとして一時的に保存されます。
テンポラリとは一時的という意味で、テンポラリファイルは一時的なファイルということです。
`$_FILES['pictures']['tmp_name']` で、サーバーにあるテンポラリファイルの名前を取得できます。
XAMPP環境の場合は、`C:\xampp\tmp\php2EE4.tmp` という形でテンポラリファイルの名前を取得できます。
取得したものを見るとわかりますが、テンポラリファイルは、アップロードしたファイル名ではなく、`php2EE4.tmp` のような名称で一時的に保存されています。
`2EE4` の英数の部分は、アップロードするたびに変わります。
テンポラリファイルは、一時的なファイルのため、PHPの処理が終わるとなくなり、サーバー上に残りません。
今回は、バイナリデータを表示するだけですので、アップロードされたファイルをサーバー上に残す必要はありません。
例えば、掲示板のようにアップロードした画像を表示したい場合は、サーバー上にファイルを残す必要があります。
そのような場合は、[move_uploaded_file](http://php.net/manual/ja/function.move-uploaded-file.php) を使って、テンポラリファイルを別のディレクトリに移動することでサーバー上に残すことができます。
[file_get_contents](http://php.net/manual/ja/function.file-get-contents.php) と記述していますが、これはアップロードされたファイル全体を読み込んでいます。
画像なら、バイナリデータとして読み込んでいます。
[bin2hex](http://php.net/manual/ja/function.bin2hex.php) という記述は、読み込んだバイナリデータを2進から16進表現に変換しています。
このように記述することで、先ほどのリクエストメッセージにあったバイナリデータ（Hex）と同じものを表示することができます。

試しに先ほどと同じ 10px の PNG を送信すると、下記が表示されました。

```txt
89504e470d0a1a0a0000000d494844520000000a0000000a0802000000025058ea000000017352474200aece1ce90000000467414d410000b18f0bfc6105000000097048597300000ec300000ec301c76fa864000000274944415428537dc7310d0000080330fccb98bac9e0253d48fa7426fd7878787878787878f82b5ddb7299e9cb925ff90000000049454e44ae426082
```

先ほどのリクエストメッセージのバイナリデータは下記です。

```txt
89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52 00 00 00 0A 00 00 00 0A 08 02 00 00 00 02 50 58 EA 00 00 00 01 73 52 47 42 00 AE CE 1C E9 00 00 00 04 67 41 4D 41 00 00 B1 8F 0B FC 61 05 00 00 00 09 70 48 59 73 00 00 0E C3 00 00 0E C3 01 C7 6F A8 64 00 00 00 27 49 44 41 54 28 53 7D C7 31 0D 00 00 08 03 30 FC CB 98 BA C9 E0 25 3D 48 FA 74 26 FD 78 78 78 78 78 78 78 78 F8 2B 5D DB 72 99 E9 CB 92 5F F9 00 00 00 00 49 45 4E 44 AE 42 60 82 
```

表示されている英数字が一致しています。
このようにPHPでもバイナリを表示することができます。

やっと本題に入りましょう。

# PHPでアップロードされたファイルのMIMEタイプを確認する方法

PHP で MIME タイプを判定する関数またはクラスを探したら、5つ見つけました。
動作確認するために、Windows7 に PHP 5.2.1 と PHP 5.3.1 の 2 つの XAMPP 環境で確かめました。

|No|関数・クラス|PHPマニュアル記載|PHP 5.2.1（XAMPP 1.6.1）|PHP 5.3.1（XAMPP 1.7.3）|補足|
|:--|:--|:--|:--|:--|:--|
|01|[mime_content_type](http://php.net/manual/ja/function.mime-content-type.php)|PHP 4 >= 4.3.0, PHP 5, PHP 7|×|○|PHP 5.3 で [Mimetype拡張モジュール](http://php.net/manual/ja/mime-magic.installation.php) が廃止されるのに伴い、一時期は非推奨扱いだったが、PHP 5.3 から [改良](http://php.net/manual/ja/migration53.new-extensions.php) して新たに置き換えられた [Fileinfo拡張モジュール](http://php.net/manual/ja/fileinfo.installation.php) を使用している。PHP5.3を境に使用する拡張モジュールが異なる。|
|02|[Fileinfo 関数](http://php.net/manual/ja/ref.fileinfo.php)|PHP 5 >= 5.3.0, PHP 7|×|○|PHP 5.3 までは PECL 拡張なので自分で追加する必要あり。PHP 5.3 からはデフォルトで有効。[Fileinfo拡張モジュール](http://php.net/manual/ja/fileinfo.installation.php)が有効なら、使用可能。|
|03|[finfo クラス](http://php.net/manual/ja/class.finfo.php)|PHP 5 >= 5.3.0, PHP 7|×|○|上記と同じ。|
|04|[getimagesize](http://php.net/manual/ja/function.getimagesize.php)|PHP 4, PHP 5, PHP 7|○|○|[画像処理（GD）拡張モジュール](http://php.net/manual/ja/book.image.php)が有効なら、使用可能。|
|05|[exif_imagetype](http://php.net/manual/ja/function.exif-imagetype.php)|PHP 4 >= 4.3.0, PHP 5, PHP 7|○|○|[Exif拡張モジュール](http://php.net/manual/ja/book.exif.php)が有効なら、使用可能。|

`mime_content_type` は、PHP 5.3 で拡張モジュールが変わったこともあり、その前後のバージョンでは、拡張モジュールがなくて、動作しない場合があるようです。
動作しない場合は、拡張モジュールがインストールされているか、有効になっているか確認してください。
（そもそも、今時そんなバージョン使ってる人もいないと思いますが...）
PHP 5.3 以降なら、ほぼ問題なく動作すると思います。

こんなに関数やクラスがあると、何を使えばよいのか迷いますね。
PHPマニュアルを見てみましょう。

[getimagesize](http://php.net/manual/ja/function.getimagesize.php) には下記の記載があります。

> getimagesize() を使って、そのファイルが画像であるかどうかを確かめることはできません。 そのようなことをしたい場合は、そのために用意されたソリューション (Fileinfo 拡張モジュールなど) を使いましょう。
> 
> 引用：[PHP: getimagesize - Manual](http://php.net/manual/ja/function.getimagesize.php)

[新しい拡張モジュール](http://php.net/manual/ja/migration53.new-extensions.php) には下記の記載があります。

> Fileinfo - 改良され、(完全に互換性を保ちながら) 堅牢な実装で Mimetype 拡張モジュールを置き換えたものです。
> 
> 引用：[PHP: 新しい拡張モジュール - Manual](http://php.net/manual/ja/migration53.new-extensions.php)

PHPマニュアルを見ると、[Fileinfo拡張モジュール](http://php.net/manual/ja/fileinfo.installation.php) を推しているようなので、PHP5.3以降なら [Fileinfo拡張モジュール](http://php.net/manual/ja/fileinfo.installation.php) を利用している `mime_content_type` , `Fileinfo` , `finfo`  のいずれかを選択するようになるかと思います。
※Fileinfo拡張モジュールも万能ではなく、稀に違うMIMEタイプを返すことがあるそうです。
（そのようなケースに出会ったことないので、残念ながらどのような条件下で違うMIMEタイプを返すのかわかりません...）

PHP 5.3 よりも前なら、`getimagesize` または `exif_imagetype` を選択するか、自分で拡張モジュールを追加して他の関数を利用するという選択になるかと思います。
（そもそも、今時そんなバージョン使ってる人もいないと思いますが...）

# 具体的にはどうやって書くの？

私はゴミみたいなコードしか書けないので、@mpyw さんのを参考にしてください。
ファイルのアップロードに関して、一通り説明されているので、必ず目を通してください。

+ [Qiita - ファイルアップロードの例外処理はこれぐらいしないと気が済まない](http://qiita.com/mpyw/items/939964377766a54d4682) by @mpyw さん

ほぼ、@mpyw さん のコードを参考にしていますが、ファイルをアップロードするページを作る上でやることリストです。
あくまで参考であって、全てこの通りにやればいいということではないです。

1. `$_FILES['userfile']` 変数がセットされていること、NULLではないことを確認する
1. `$_FILES['userfile']['error']` は成功した場合も[エラーコード](http://php.net/manual/ja/features.file-upload.errors.php)を返すので、それにあわせて処理をする
1. `$_FILES['userfile']['type'] ` は、クライアント側で好き勝手に命名したファイル名の拡張子などをもとにしているため、PHP側でファイルのMIMEタイプを確認する
1. リクエストメッセージのMIMEタイプと、ファイルのMIMEタイプが一致するか確認して、一致しない場合は、エラーを表示する
1. ファイルのMIMEタイプが、アップロードを許可しているファイルと一致するか確認して、一致しない場合は、エラーを表示する
1. Internet Explorer だけの問題ですが、最新（2017-04-27現在）の Internet Explorer 11 でも、BMP形式の中にスクリプトが埋め込まれていると、画像ではなく、スクリプトとして実行される場合があります。それが原因で脆弱性（画像XSS）が発生し、悪用されると利用者が不正アクセスなどの被害にあう場合もあるため、BMP形式のアップロードは許可しない
1. 画像ファイルの中にスクリプトを埋め込まれると、ブラウザ側が勝手に画像ではなく、スクリプトとして実行してしまうことがあるため、`header('X-Content-Type-Options: nosniff');` を記述して、ブラウザが勝手にスクリプトとして実行しないようにする
1. `$_FILES['userfile']['name']` は、クライアント側で好き勝手に命名したファイル名をもとにしており、そのまま使えないので、重複しないファイル名にしたい場合は、 [sha1_file](http://php.net/manual/ja/function.sha1-file.php) でアップロードされたファイルのバイナリデータをもとにしたハッシュ値をファイル名に使用する。sha256などを使用したい場合は、 [hash_file](http://php.net/manual/ja/function.hash-file.php) を使用する。
1. 拡張子も同様にクライアント側で好き勝手に指定できるため、ファイルのMIMEタイプから拡張子を決める
1. アップロードされたファイルサイズの下限や上限を確認する
1. アップロードするファイルが一つの場合は、`$_FILES['userfile']['error']` の[返り値](http://php.net/manual/ja/features.file-upload.errors.php)は整数です。複数ファイルをアップロードする場合の返り値は配列になります。1つだけの場合は、整数しか返りませんので、[is_int](http://php.net/manual/ja/function.is-int.php) で整数ではない場合は、エラーにすることで、複数ファイルを送ってくる攻撃に対処できます

この中でも、ファイルのバイナリデータをもとにしたハッシュ値をファイル名に用いるという発想に感激しました٩( 'ω' )و ｽｺﾞｰｲ!!
他のサイトや書籍などを見ると、マイクロ秒やIPアドレスなどを組み合わせた値をもとにハッシュ値を作成する方法が紹介されていましたが、重複しないファイル名を作成するなら、ファイルのバイナリデータをもとにするのが一番よさそうな気がします。
参考になりました。ありがとうございます((_ _ (´ω｀ )ﾍﾟｺ

---

今回は、「アップロードされたファイルの種類をどうやって確認するの？」ということで、その方法を見てきましたが、なぜファイルの種類を確認する必要があるのかという理由については説明していません。
次回は「なぜ、アップロードされたファイルの種類を確認する必要があるの？」というタイトルで、確認しない場合は、どのような危険があるのかなど、脆弱性（画像XSS）についての記事を投稿する予定です。（たぶん）

# note

note でも記事を公開してるので、興味がある方はご覧ください。

・[【初学者向けコードリーディング】 PHP の TODO アプリのコードを一緒に読み解こう](https://note.mu/7968/n/n46a8beb87c93)