PHP/Apache httpdのファイルアップロード/ダウンロード処理

(Last Updated On: 2018年10月9日)

最近、ファイルアップロード/ダウンロード対策に関する検索が増えているようなので書きました。PHPの場合、スクリプトがアップロードされ実行されてしまうと致命的です。アップロードされたファイルを公開ディレクトリに保存することは好ましくありあせん。しかし、既にそうなっているアプリケーションの場合、改修が困難な時もあります。このような場合もより安全に利用できる設定を紹介します。

参考:「スクリプトアップロード対策」も合わせてどうぞ。

ファイルアップロード処理

PHPのファイルロードは簡単です。PHPマニュアルを一度読むと利用できると思います。ポイントのみ解説します。詳しくはマニュアルを参照してください。ファイルアップロード用のHTMLフォームを作り、エンコーディングをenctype=”multipart/form-data”と設定すれば$_FILES配列に保存されます。

<!-- データのエンコード方式である enctype は、必ず以下のようにしなければなりません -->
<form enctype="multipart/form-data" action="__URL__" method="POST">
    <!-- MAX_FILE_SIZE は、必ず "file" input フィールドより前になければなりません -->
    <input type="hidden" name="MAX_FILE_SIZE" value="30000" />
    <!-- input 要素の name 属性の値が、$_FILES 配列のキーになります -->
    このファイルをアップロード: <input name="userfile" type="file" />
    <input type="submit" value="ファイルを送信" />
</form>

http://php.net/manual/ja/features.file-upload.post-method.php 

上記のHTMLフォームでは

<input name="userfile" type="file" />

があるので$_FILES[‘userfile‘]配列にファイル情報とファイルデータが保存されます。

$_FILES[‘userfile’][‘name’]

クライアントマシンの元のファイル名。

$_FILES[‘userfile’][‘type’]

ファイルの MIME 型。ただし、ブラウザがこの情報を提供する場合。 例えば、“image/gif” のようになります。 この MIME 型は PHP 側ではチェックされません。そのため、 この値は信用できません。

$_FILES[‘userfile’][‘size’]

アップロードされたファイルのバイト単位のサイズ。

$_FILES[‘userfile’][‘tmp_name’]

アップロードされたファイルがサーバー上で保存されているテンポラ リファイルの名前。

$_FILES[‘userfile’][‘error’]

このファイルアップロードに関する エラーコード

$_FILES[‘userfile’][‘name’]には元のファイル名が保存されます。通常はファイル名のみが保存されますが、攻撃者は”../../../../etc/passwd”や”../../some-other-bad-dir/attack.php”のようなファイル名を設定して、ディレクトリトラバーサルを利用したファイルアップロード攻撃を実行できます。単純に$_FILES[‘userfile’][‘tmp_name’]を$_FILES[‘userfile’][‘name’]にコピー/リネームして保存しようとすると攻撃されます。PHPはアップロードされたファイルを移動/コピーする為のmove_uploaded_file関数を用意しています。この関数はコピー元のファイル($_FILES[‘userfile’][‘tmp_name’])と実際にアップロードされたファイル名と一致しているか確認し、一致しない場合はエラーを返します。攻撃者がtmp_nameを書き換える場合もあるので、アップロードファイルの移動には必ずmove_uploaded_file関数を利用します。(注:現在のPHPでは対策が取られているので攻撃できません。レガシーPHPを使っている方は注意してください)

しかし、move_uploaded_file関数はコピー先のパスには何もチェックを行いません。従って

move_uploaded_file($_FILES['userfile']['tmp_name'], '/var/www/html/myapp/upload/'.$_FILES['userfile']['name']);

とした場合に$_FILES[‘userfile’][‘name’]に”../attack.php”が保存されている場合、/var/www/html/myapp/attack.phpを書き込まれる可能性があります。(備考:最近は自動更新やモジュールインストールの為にアプリケーションがアプリのディレクトリへの書き込み権限を持っている場合も多い。更新を容易にしないと多くのユーザーが更新を怠り危険になります。しかし、スクリプトアップロードが容易になる危険は増加します。)

このようにディレクトリを変えて不正なファイルを書き込んだり、読み込んだりする攻撃はディレクトリトラバーサル攻撃/パストラバーサル攻撃と呼ばれています。

このような攻撃を防ぐにはバリデーションを行います。可能であればホワイトリスト型のバリデーションが良いですが、UTF-8の文字は許可したい場合は許可する範囲が大きく、処理も複雑になります。

今回はASCII値の下の方の制御文字以外の場合(スペース未満)、.(ドット)、/(フォワードスラッシュ) 、\(バックスラッシュ) 以外はOKとしてバリデーションします。こうするとヌル文字/改行文字インジェクションも防げます。

<?php
$name = $_FILES['userfile']['name'];
$len = strlen($name);

// バリデーション
if (mb_check_encoding($name, 'UTF-8') === FALSE) {
    trigger_error('Invalid encoding', E_USER_ERROR);
}
for($i = 0, $dot = 0, $slash = 0; $i< $len; $i++) {
    $v = ord($name[$i]);
    if ($v < 0x20) { //スペース未満
        trigger_error('Invalid input - '.$v, E_USER_ERROR);
    } else if ($v == 0x2e) { //ドット
        $dot++;
        if ($dot > 1) trigger_error('Invalid input - multiple dots', E_USER_ERROR);
    } else if ($v == 0x2f || $v == 0x5c) { //スラッシュ
        trigger_error('Invalid input - '.$v, E_USER_ERROR);
    }
}


// ファイルを保存
if (move_uploaded_file($_FILES['userfile']['tmp_name'], '/path/to/upload-dir/'.$name) === FALSE) {
    trigger_error('Failed to move uploaded file. Possible attack.', E_USER_ERROR);
}

任意のファイル名でなく、半角英数字のみを許可する場合は上記のような緩いバリデーションを行うのではなく半角英数字のみであることをバリデーションすると良いです。

アップロードしたファイル名とサーバーにアップロードしたファイル名は必ずしも一致する必要はありません。SHA256などで”ファイル名”のハッシュ値を保存時し、ファイル名とハッシュ値のマッピングをデータベースで管理する方法でも良いでしょう。異なるユーザー間でのファイル名の衝突(同じファイル名)が困る場合、ユーザー名とファイル名でハッシュ値を取得すると衝突しません。

このスクリプトは文字エンコーディングにUTF-8を想定しています。つまり、文字エンコーディングがSJISなどの場合は文字エンコーディング変換が必要です。

現在のmbstringにはマルチバイト文字のコード値を直接取得する関数が無いので、きっちりバリデーションしようとすると多少面倒なことをしなければなりません。JavaScriptの文字列をエスケープする関数の最適化版が参考になります。

ファイルアップロード先のディレクトリ

ファイルアップロードを行うディレクトリはWebサーバー公開ディレクトリ内である必要はありません。ファイルへのアクセス制御を行いたい場合、Webサーバーの公開ディレクトリに保存してはなりません。基本的には、アップロードされたファイルは非公開ディレクトリに保存し、スクリプトでダウンロードさせると良いでしょう。こうすれば、将来アクセス制御が必要になった場合も簡単に対応できます。

※ 参考:ファイル全体を読み出力

ファイル名の変換処理

ユーザーが指定したファイル名の文字エンコーディング変換やUnicode正規化を行う場合は必ずファイル名のバリデーション処理の前に行います。これはファイル名などの変換に限らない、バリデーション処理を行う場合の基本ルールです。全ての変換処理を先に行わないと、変換処理を利用したバリデーション/フィルタ処理の無効化が行われる可能性があります。(例:正規化による/から/への変換など)

※ 参考:正規化文字エンコーディング確認

ファイルタイプの検出

ファイルの種別は拡張子で検出できます。しかし、拡張子が実際のファイル種別と一致しているとは限りません。PHPはFileinfoモジュールでファイル種別を検出可能です。しかし、完全にファイルの種別を確認できません。

画像ファイルとして表示可能であるかどうか確認するには、GDモジュールなどで別のファイル形式に変換しエラーが発生しないか確認(バリデーション)します。

ファイルダウンロード処理

アクセス制御が必要な場合、適切なアクセス制御を行います。ここではアクセス制御の説明は省略します。

<?php
//アクセス制御が必要であれば制御しておく
$name = $_GET['filename'];
$len = strlen($name);

// バリデーション - アップロードと同じ
if (mb_check_encoding($name, 'UTF-8') === FALSE) {
    trigger_error('Invalid encoding', E_USER_ERROR);
}
for($i = 0, $dot = 0, $slash = 0; $i< $len; $i++) {
    $v = ord($name[$i]);
    if ($v < 0x20) {
        trigger_error('Invalid input - '.$v, E_USER_ERROR);
    } else if ($v == 0x2e) {
        $dot++;
        if ($dot > 1) trigger_error('Invalid input - multiple dots', E_USER_ERROR);
    } else if ($v == 0x2f || $v == 0x5c) {
        trigger_error('Invalid input - '.$v, E_USER_ERROR);
    }
}


// HTTPヘッダーを設定する
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment');
header('X-Content-Type-Options: nosniff');

// ファイルを送信
readfile('/path/to/download-dir/'.$name); 
// エラー処理省略。出力バッファを利用して処理すべき。

ダウンロード処理はアップロード処理とほぼ変わりません。設定しているHTTPヘッダーは次のセクションで説明します。

追加で付けた方が良いヘッダー以下の通りです。

  • Last-Modified – 最終更新日
  • Content-Length – ファイルの大きさ

この他に必要に応じてキャッシュヘッダー、E-Tagなどを設定します。リクエストヘッダを見て適切なHTTPステータスコード、Not modified(304)などを返すようにするとより良いです。キャッシュの解説は今回の本題ではないので省略します。

参考:HTTP キャッシュの作成

Apache httpdでファイルを常にダウンロードさせる設定

Fedora20のApache httpd 2.4で確認しています。FedoraやRHEL系のApache httpdはコンテンツディレクトリの.htaccessがデフォルトでは無効です。必要な場合は有効に設定してください。PHPがインストールされた状態のデフォルトでは、.php拡張子を持つファイルタイプがPHPスクリプトとして実行されるようになっています。以下はFedora20のPHPパッケージの設定です。

#
# Cause the PHP interpreter to handle files with a .php extension.
#
<FilesMatch \.php>
SetHandler application/x-httpd-php
</FilesMatch>

# Allow php to handle Multiviews
#
AddType text/html .php

# Add index.php to the list of files that will be served as directory
indexes.
#
DirectoryIndex index.php

PHPスクリプトとして実行させなくするには、mod_phpの設定ディレクティブを利用します。PHPスクリプトを実行できないようにしても、JavaScriptを実行されたり、HTMLをページとして利用されては困るので、.htaccessまたは<Directory>設定内で以下のように設定しダウンロードさせます。

# PHPを無効化
RemoveHandler .php
RemoveType .php
php_flag engine off

# ダウンロード設定
Header add Content-Type application/octet-stream
Header add Content-Disposition attachment

# IE用の設定
Header add X-Content-Type-Options nosniff

# ブラックリスト型でファイル名で区別する場合、
# 全てのブラウザ(特にIE)がスクリプトとして解釈
# する可能性がある物を指定する必要があり、リスク
# が高い。
#<Files *>
#Header set Content-Disposition attachment
#</Files>

php_flag engine offでPHPスクリプトとして実行されなくなりますが、念の為にハンドラとタイプ設定も削除しています。その後、全てのファイルをバイナリデータの添付ファイルとしてダウンロードさせるようにHTTPヘッダーを設定しています。最後にIEがコンテントタイプで勝手に解釈してクライアントサイドスクリプトを実行しないように、X-Content-Type-Optionsを付けています。

Content-TypeはRFC 2616で定義されたHTTPヘッダーです。メディアタイプの値はIANAが管理しています。application/octet-streamは任意のバイナリデータの指定です。この指定が無くても次のContent-DIsposition設定でダウンロードされるはずですが、念のために指定しています。

Content-DispositionはRFC 1806で定義されています。Experimental RFCですが普通のブラウザはContent-DispositionヘッダーをRFC通りに取り扱います。

X-Content-Type-OptionsはInternet Explorerの拡張でした。nosniffが指定されると、Internet Explorer はMIME タイプが以下の値の 1 つと一致しない限り、”script” ファイルを読み込みません。

  • “application/ecmascript”
  • “application/javascript”
  • “application/x-javascript”
  • “text/ecmascript”
  • “text/javascript”
  • “text/jscript”
  • “text/x-javascript”
  • “text/vbs”
  • “text/vbscript”

先に書いた通りX-Content-Type-OptionsはInternet Explorerの拡張でした。しかし、この拡張はChrome/Firefoxにも取り入れられてしまいました。これは<script>タグによりJSONスクリプトが読み込まれると、Same Origin Policyを乗り越えてJSONファイル(application/json –  RFC 4627)を指定していても、Javascript(application/javascript)として解釈してしまう動作を明示的に禁止する目的で導入されています。Firefoxの場合はCSSにも影響します。1

ダウンロードの場合はapplication/octet-streamを利用するので”nosniff”の影響はない、とも言えます。しかし、余計な解釈を防止する為に X-Content-Type-Options: nosniff は常に付けておくと良いです。

Apache httpdに別のハンドラが設定されてある場合、例えば.pl拡張子を持つファイルがPerlスクリプトとして実行できる場合、これらのハンドラも無効化します。PHPファイルやその他のファイルタイプ/ハンドラを無効にしたりしなくてもダウンロードされますが、念には念を入れましょう。

上記の設定をするとディレクトリ内のファイル一覧を表示させる設定をしていても、ディレクトリ一覧のHTMLデータもダウンロードされてしまします。必要であれば”.”をダウンロードさせない設定を追加してください。

まとめ

サイトでファイルダウンロードをサポートする場合、通常は直接ファイルを公開ディレクトリに配置せず、リクエストをバリデーションしてから送信する方が良いです。しかし、無条件で公開したい場合や既にアプリケーションがそうなっている場合には直接Webサーバーからダウンロードさせた方が好ましい場合もあります。

ここではあまり詳しく解説しませんでしたが、ファイルアップロード/ダウンロードを処理する場合にはディレクトリトラバーサル攻撃に注意してください。(重要: ブラックリスト方式で単純に”../”を除去する方法では全く役立ちません。)普通はアップロードできるファイル形式が決まっていると思います。ホワイトリスト方式で許可している拡張子のみを受け付けると良いです。

PHPのファイル関連関数はヌル文字インジェクションに脆弱ではありません。しかし、つい先月イメージファイル内のファイル名に対するヌル文字インジェクション脆弱性が修正されたPHPがリリースされました。こういった普通は気にしなくても良い脆弱性に対しても気を付けているとより堅牢なアプリケーションを作れます。

参考

ハッシュ(HMAC)を使って有効期限付きURL/URIを作る方法

ハッシュ(HMAC)を使ってパスワード付きURL/URIを作る方法

HMACハッシュの使い方のまとめ


  1. Chromeの影響範囲は把握していません。明示されたContent Typeに従うコードになっていれば問題は起きないはずです。 

投稿者: yohgaki