Edited at

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

前回$_FILES['userfile']['type'] の値は、信用できないので、PHP側で確認しましょうということで終わりました。

そもそも、リクエストメッセージ全体を偽装できるので、$_FILES['userfile']['type'] の値に限らず、ユーザーが入力・選択して送られてきたリクエストメッセージの値は全て信用できないため、PHP側で想定した値か確認する必要があります。

当然、$_FILES['userfile']['type'] の値もPHP側で確認する必要があります。

今回は、アップロードされたファイルの種類をどうやって確認するのか見ていきます。

そもそも、PHP側で正しいファイルかどうか確認する方法なんてあるのでしょうか?

リクエストメッセージで送られてくる MIME タイプは信用できないし、それ以前にリクエストメッセージ全体を偽装できるので、お手上げな感じがします。

リクエストメッセージを見ながら、ファイルの種類を判断できそうなところがないか探してみましょう。

今回も、Windows7 の XAMPP 環境で、GoogleChrome を使って確認しています。

ファイルを選択して、送信すると $_FILES['userfile']['type'] の値を表示する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(リクエストメッセージ)

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」で表現されているデータ形式のこと、あるいは、テキストではない情報でデータが書かれているファイル一般のことである。

バイナリ(binary)とは、元々「2進数の」という意味の英語である。コンピュータはデータを処理するために、全ての情報を2進数に変換しているので、コンピュータが解釈するために用意されたデータはすべてバイナリ形式となっている。

一般的には、データがバイナリで記述されているファイルはバイナリファイル、バイナリファイルのデータはバイナリデータと呼ばれている。バイナリファイルの主なものには、音声ファイルや画像ファイル、実行形式のプログラムファイル、圧縮ファイルなどがある。

引用:バイナリとは 「バイナリー」 (binary): - IT用語辞典バイナリ


画像ファイルは、バイナリファイルです。

89 50 4E 47 0D 0A 1A ・・・ という記述は、1バイトずつ区切って16進数で表示したバイナリデータです。

16進数のことを英語で Hexadecimal(ヘキサデシマル) と言い、この先頭の文字をとって Hex と表記することがあります。

ちなみに、私はリクエストメッセージを確認するのに Fiddler というソフトを使っていますが、HexView という項目を選択することで、リクエストメッセージを16進数表記で確認できます。

Fidder では Raw という項目を選択することで、リクエストメッセージ全体を表示することができますが、バイナリデータの箇所は正しく表示されません。

fiddler.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 - マジックナンバーまとめ by @forestsource さん

引用元には、他のファイルのマジックナンバーも記載されています。

マジックナンバーを一覧で掲載しているところがなくて、非常に助かりました。

ありがとうございます((_ _ (´ω` )ペコ

PNG のマジックナンバーは 89 50 4E 47 です。

先ほどのリクエストメッセージを、もう一度確認してみましょう。


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 などのサイトを利用して、ファイルをアップロードすれば確認できます

他にも、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 を使って、テンポラリファイルを別のディレクトリに移動することでサーバー上に残すことができます。

file_get_contents と記述していますが、これはアップロードされたファイル全体を読み込んでいます。

画像なら、バイナリデータとして読み込んでいます。

bin2hex という記述は、読み込んだバイナリデータを2進から16進表現に変換しています。

このように記述することで、先ほどのリクエストメッセージにあったバイナリデータ(Hex)と同じものを表示することができます。

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

89504e470d0a1a0a0000000d494844520000000a0000000a0802000000025058ea000000017352474200aece1ce90000000467414d410000b18f0bfc6105000000097048597300000ec300000ec301c76fa864000000274944415428537dc7310d0000080330fccb98bac9e0253d48fa7426fd7878787878787878f82b5ddb7299e9cb925ff90000000049454e44ae426082

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

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
PHP 4 >= 4.3.0, PHP 5, PHP 7
×

PHP 5.3 で Mimetype拡張モジュール が廃止されるのに伴い、一時期は非推奨扱いだったが、PHP 5.3 から 改良 して新たに置き換えられた Fileinfo拡張モジュール を使用している。PHP5.3を境に使用する拡張モジュールが異なる。

02
Fileinfo 関数
PHP 5 >= 5.3.0, PHP 7
×

PHP 5.3 までは PECL 拡張なので自分で追加する必要あり。PHP 5.3 からはデフォルトで有効。Fileinfo拡張モジュールが有効なら、使用可能。

03
finfo クラス
PHP 5 >= 5.3.0, PHP 7
×

上記と同じ。

04
getimagesize
PHP 4, PHP 5, PHP 7

画像処理(GD)拡張モジュールが有効なら、使用可能。

05
exif_imagetype
PHP 4 >= 4.3.0, PHP 5, PHP 7

Exif拡張モジュールが有効なら、使用可能。

mime_content_type は、PHP 5.3 で拡張モジュールが変わったこともあり、その前後のバージョンでは、拡張モジュールがなくて、動作しない場合があるようです。

動作しない場合は、拡張モジュールがインストールされているか、有効になっているか確認してください。

(そもそも、今時そんなバージョン使ってる人もいないと思いますが...)

PHP 5.3 以降なら、ほぼ問題なく動作すると思います。

こんなに関数やクラスがあると、何を使えばよいのか迷いますね。

PHPマニュアルを見てみましょう。

getimagesize には下記の記載があります。


getimagesize() を使って、そのファイルが画像であるかどうかを確かめることはできません。 そのようなことをしたい場合は、そのために用意されたソリューション (Fileinfo 拡張モジュールなど) を使いましょう。

引用:PHP: getimagesize - Manual


新しい拡張モジュール には下記の記載があります。


Fileinfo - 改良され、(完全に互換性を保ちながら) 堅牢な実装で Mimetype 拡張モジュールを置き換えたものです。

引用:PHP: 新しい拡張モジュール - Manual


PHPマニュアルを見ると、Fileinfo拡張モジュール を推しているようなので、PHP5.3以降なら Fileinfo拡張モジュール を利用している mime_content_type , Fileinfo , finfo のいずれかを選択するようになるかと思います。

※Fileinfo拡張モジュールも万能ではなく、稀に違うMIMEタイプを返すことがあるそうです。

(そのようなケースに出会ったことないので、残念ながらどのような条件下で違うMIMEタイプを返すのかわかりません...)

PHP 5.3 よりも前なら、getimagesize または exif_imagetype を選択するか、自分で拡張モジュールを追加して他の関数を利用するという選択になるかと思います。

(そもそも、今時そんなバージョン使ってる人もいないと思いますが...)


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

私はゴミみたいなコードしか書けないので、@mpyw さんのを参考にしてください。

ファイルのアップロードに関して、一通り説明されているので、必ず目を通してください。

ほぼ、@mpyw さん のコードを参考にしていますが、ファイルをアップロードするページを作る上でやることリストです。

あくまで参考であって、全てこの通りにやればいいということではないです。



  1. $_FILES['userfile'] 変数がセットされていること、NULLではないことを確認する


  2. $_FILES['userfile']['error'] は成功した場合もエラーコードを返すので、それにあわせて処理をする


  3. $_FILES['userfile']['type'] は、クライアント側で好き勝手に命名したファイル名の拡張子などをもとにしているため、PHP側でファイルのMIMEタイプを確認する

  4. リクエストメッセージのMIMEタイプと、ファイルのMIMEタイプが一致するか確認して、一致しない場合は、エラーを表示する

  5. ファイルのMIMEタイプが、アップロードを許可しているファイルと一致するか確認して、一致しない場合は、エラーを表示する

  6. Internet Explorer だけの問題ですが、最新(2017-04-27現在)の Internet Explorer 11 でも、BMP形式の中にスクリプトが埋め込まれていると、画像ではなく、スクリプトとして実行される場合があります。それが原因で脆弱性(画像XSS)が発生し、悪用されると利用者が不正アクセスなどの被害にあう場合もあるため、BMP形式のアップロードは許可しない

  7. 画像ファイルの中にスクリプトを埋め込まれると、ブラウザ側が勝手に画像ではなく、スクリプトとして実行してしまうことがあるため、header('X-Content-Type-Options: nosniff'); を記述して、ブラウザが勝手にスクリプトとして実行しないようにする


  8. $_FILES['userfile']['name'] は、クライアント側で好き勝手に命名したファイル名をもとにしており、そのまま使えないので、重複しないファイル名にしたい場合は、 sha1_file でアップロードされたファイルのバイナリデータをもとにしたハッシュ値をファイル名に使用する。sha256などを使用したい場合は、 hash_file を使用する。

  9. 拡張子も同様にクライアント側で好き勝手に指定できるため、ファイルのMIMEタイプから拡張子を決める

  10. アップロードされたファイルサイズの下限や上限を確認する

  11. アップロードするファイルが一つの場合は、$_FILES['userfile']['error']返り値は整数です。複数ファイルをアップロードする場合の返り値は配列になります。1つだけの場合は、整数しか返りませんので、is_int で整数ではない場合は、エラーにすることで、複数ファイルを送ってくる攻撃に対処できます

この中でも、ファイルのバイナリデータをもとにしたハッシュ値をファイル名に用いるという発想に感激しました٩( 'ω' )و スゴーイ!!

他のサイトや書籍などを見ると、マイクロ秒やIPアドレスなどを組み合わせた値をもとにハッシュ値を作成する方法が紹介されていましたが、重複しないファイル名を作成するなら、ファイルのバイナリデータをもとにするのが一番よさそうな気がします。

参考になりました。ありがとうございます((_ _ (´ω` )ペコ


今回は、「アップロードされたファイルの種類をどうやって確認するの?」ということで、その方法を見てきましたが、なぜファイルの種類を確認する必要があるのかという理由については説明していません。

次回は「なぜ、アップロードされたファイルの種類を確認する必要があるの?」というタイトルで、確認しない場合は、どのような危険があるのかなど、脆弱性(画像XSS)についての記事を投稿する予定です。(たぶん)


note

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

【初学者向けコードリーディング】 PHP の TODO アプリのコードを一緒に読み解こう


支援

Qiita に PHP や CSS などの記事をいくつか投稿してきました。

PHP の主な記事

CSS の主な記事

Laravel の主な記事

内容によりますが、1 つの記事を書くのに書籍を購入して学んだり、ネットで調べたり、多いときは 100 時間以上かかるときもあります。

役に立った、理解できたという方は note で支援(投げ銭/サポート)して頂けると助かります。

技術書や開発環境などの購入資金に充てたいと思います。