まだ 「ファイルアップロードの例外処理はこれぐらいしないと気が済まない」 をご覧になっていない方は先にそちらからどうぞ。
問題提起
ファイルアップロード関連の記事を書いているとき、いつも疑問に思っていることがあった。
だからさぁ、 アップロードされなかったファイル の名前がどうやったら $_FILES['upfile']['tmp_name']
に混入できるんだよ!?このチェックいるのかホントに!?
なんて思いながら「PHPマニュアルが勧めているから」という理由で訳も分からず記事を書いていた。
歴史的な理由
そう、実はこの関数の背景には 歴史的な理由 があったのだ… register_globals という害悪機能の存在だ。もしこの機能が有効な場合、以下のようなURLでリクエストを受けたとき、不正に /etc/passwd
を閲覧される可能性がある。
<?php
printf(
'<a href="http://example.com/upload.php?%s">不正ファイル作成</a>',
http_build_query(array(
'_FILES' => array(
'upfile' => array(
'name' => 'passwd.txt',
'tmp_name' => '/etc/passwd',
'error' => UPLOAD_ERR_OK,
)
)
))
);
但し、最早この機能が有効になっている環境に遭遇すること自体がレアであり、よく分からないようなレンタルサーバーであっても PHP 5 であるとさえ書いてくれてあればおそらくほぼ全てのものが無効とされているだろう。というか今更、サポート切れてて、なんちゃってオブジェクト指向しか扱えないような PHP 4 みたいな欠陥クソ言語使うな、うん。
PHP言語レベルでの考察
register_globals との向き合い方
register_globals がphp.iniのディレクティブから削除されたのは PHP 5.4.0 のときである。
php.iniの設定次第で致命的な脆弱性が生まれてしまうのは気持ち悪く感じるので、これより古いバージョンを使用している場合はやはりこういったチェックを行ったほうがいいように思える…が、ファイルアップロード以外にも register_globals の存在を想定していないコードは山ほどあるよな…別にここだけやったところで特に意味ないよな…
上書きに関する挙動の違い
但し、以下のような上書きに関する挙動の違いがあるので、 move_uploaded_file 関数に限っては必要と言えるかもしれない。上書きが行えるアップロードファイルの移動が可能な関数はこれしか存在しない。もちろん
- テンポラリファイルはスクリプト実行後すぐ削除されてしまうからコピーでも十分
- そもそもWindowsでPHP使ってないから大丈夫
と言われればそれまでではあるが…
OSを問わず上書きする
- move_uploaded_file 関数
- copy 関数
- imagepng 関数
- imagejpeg 関数
- imagegif 関数
Windows上でのみ Warning を発生して処理を中止する
- rename 関数
C言語レベルでの考察
static int php_do_open_temporary_file(/* 省略 */) {
/* 省略 */
#ifndef HAVE_MKSTEMP
int open_flags = O_CREAT | O_TRUNC | O_RDWR
#ifdef PHP_WIN32
| _O_BINARY
#endif
;
#endif
/* 省略 */
#ifdef PHP_WIN32
if (GetTempFileName(new_state.cwd, pfx, 0, opened_path)) {
/* Some versions of windows set the temp file to be read-only,
* which means that opening it will fail... */
if (VCWD_CHMOD(opened_path, 0600)) {
efree(opened_path);
efree(new_state.cwd);
return -1;
}
fd = VCWD_OPEN_MODE(opened_path, open_flags, 0600);
}
#elif defined(HAVE_MKSTEMP)
fd = mkstemp(opened_path);
#else
if (mktemp(opened_path)) {
fd = VCWD_OPEN(opened_path, open_flags);
}
#endif
/* 省略 */
}
#define VCWD_OPEN(path, flags) virtual_open(path TSRMLS_CC, flags)
#define VCWD_OPEN_MODE(path, flags, mode) virtual_open(path TSRMLS_CC, flags, mode)
#define VCWD_CREAT(path, mode) virtual_creat(path, mode TSRMLS_CC)
CWD_API int virtual_open(const char *path TSRMLS_DC, int flags, ...) {
/* 省略 */
if (flags & O_CREAT) {
mode_t mode;
va_list arg;
va_start(arg, flags);
mode = (mode_t) va_arg(arg, int);
va_end(arg);
f = open(new_state.cwd, flags, mode);
} else {
f = open(new_state.cwd, flags);
}
/* 省略 */
}
PHP_FUNCTION(move_uploaded_file) {
/* 省略 */
if (!SG(rfc1867_uploaded_files)) {
RETURN_FALSE;
}
if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss", &path, &path_len, &new_path, &new_path_len) == FAILURE) {
return;
}
if (!zend_hash_exists(SG(rfc1867_uploaded_files), path, path_len + 1)) {
RETURN_FALSE;
}
/* 省略 */
if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
#ifndef PHP_WIN32
oldmask = umask(077);
umask(oldmask);
ret = VCWD_CHMOD(new_path, 0666 & ~oldmask);
if (ret == -1) {
php_error_docref(NULL TSRMLS_CC, E_WARNING, "%s", strerror(errno));
}
#endif
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR TSRMLS_CC) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}
/* 省略 */
}
テンポラリファイルのパーミッションは必ず 0600
になる(?)
Windows環境の場合
WindowsAPIの GetTempFileName
関数はテンポラリファイルを作成するが、作成直後にパーミッションを 0600
に手動変更しているのが見て取れる。
mkstemp
関数が利用可能なLinux環境の場合
現在のほとんどの環境で使用されているglibcはバージョン2.0.7以降という条件を満たしており、その場合はパーミッション 0600
として作成される。
mkstemp
関数が利用できないLinux環境の場合
「そもそもこんな環境が存在するのか…?」と疑問に感じたが、一応そのケースでコールされている VCWD_OPEN
マクロを辿ってみると、何やら存在しない引数を得ようとしてしまうようなコードが目に入る。 O_CREAT
をフラグに指定している場合はそもそも使うべきマクロは VCWD_OPEN_MODE
であり、これでパーミッションを第3引数に指定する必要がある。もしくは VCWD_CREAT
を使用する必要がある。もし強引に VCWD_OPEN
を利用してファイルを作成しようとした場合、 パーミッションの値は未定義 となる。おそらくバグだと思われる。
move_uploaded_file 関数・ is_uploaded_file 関数はどちらか1つで十分
コメント欄で指摘されている通り、この関数の最初のブロックでは is_uploaded_file 関数と同等のチェックが行われているため、 is_uploaded_file とこの関数を併用する必要性は例え register_globals が有効であったとしても皆無である。
移動後のファイルのパーミッションに一貫性が無い
重要な前提条件
ファイル内容が上書きされるとき、パーミッションまでは上書きされない。
VCWD_RENAME
マクロをコールしているところに着目してほしい。リネームが成功したとき、Windows環境でなければその下のパーミッション変更処理が適用される。この変更は PHP5.2.5 のときに行われたようだ。
失敗したときには手段が変わるが、本質的にはリネームと同等の処理が行われるようになっている。 open_basedir ディレクティブが関係しているようだが、ここで重要なのは後者のフローではパーミッション変更処理が行われないことにある。これによって以下のような差異が生まれてしまう。
PHP5.2.5以降
copy | move_uploaded_file | |
---|---|---|
php_copy_file_* 関数による新規コピー |
umask 設定値が 適用される |
umask 設定値が 適用される |
VCWD_RENAME マクロによる新規リネーム |
- |
umask 設定値が 適用される |
php_copy_file_* 関数による上書きコピー |
もとのパーミッションが 維持される |
もとのパーミッションが 維持される |
VCWD_RENAME マクロによる上書きリネーム |
- |
umask 設定値が 適用される |
PHP5.2.4以前
copy | move_uploaded_file | |
---|---|---|
php_copy_file_* 関数による新規コピー |
umask 設定値が 適用される |
umask 設定値が 適用される |
VCWD_RENAME マクロによる新規リネーム |
- | もとのパーミッションが 維持される |
php_copy_file_* 関数による上書きコピー |
もとのパーミッションが 維持される |
もとのパーミッションが 維持される |
VCWD_RENAME マクロによる上書きリネーム |
- | もとのパーミッションが 維持される |
検証してみたところ、手元の PHP5.5.10 の環境では php_copy_file_*
関数による上書き、 atpages.jp の PHP5.3.3 の環境では VCWD_RENAME
マクロによる上書きが行われているようであった。どうしてこんな中途半端なアップデートしたんだよ、やるならもうちょっと一貫性持たせろよ…
結論
- ファイルを move_uploaded_file 関数で移動させてもいいし、 copy 関数でコピーしてもいい。
- 移動またはコピーにどちらの関数を用いたとしても、パーミッションを直後に chmod 関数によって定めておくのが一番無難である。 umask 関数による設定方法はスクリプト全体に影響を及ぼす上に、上書き時の挙動に一貫性が無いので推奨されない。
- is_uploaded_file 関数を使う必要性は全くない。