PHP
セキュリティ

is_uploaded_file() / move_uploaded_file() の必要性?

More than 1 year has passed since last update.

まだ 「ファイルアップロードの例外処理はこれぐらいしないと気が済まない」 をご覧になっていない方は先にそちらからどうぞ。


問題提起

ファイルアップロード関連の記事を書いているとき、いつも疑問に思っていることがあった。

ss (2014-03-21 at 11.12.04).png

ss (2014-03-21 at 11.12.45).png

だからさぁ、 アップロードされなかったファイル の名前がどうやったら $_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 のときである。

ss (2014-03-21 at 11.38.43).png

php.iniの設定次第で致命的な脆弱性が生まれてしまうのは気持ち悪く感じるので、これより古いバージョンを使用している場合はやはりこういったチェックを行ったほうがいいように思える…が、ファイルアップロード以外にも register_globals の存在を想定していないコードは山ほどあるよな…別にここだけやったところで特に意味ないよな…


上書きに関する挙動の違い

但し、以下のような上書きに関する挙動の違いがあるので、 move_uploaded_file 関数に限っては必要と言えるかもしれない。上書きが行えるアップロードファイルの移動が可能な関数はこれしか存在しない。もちろん


  • テンポラリファイルはスクリプト実行後すぐ削除されてしまうからコピーでも十分

  • そもそもWindowsでPHP使ってないから大丈夫

と言われればそれまでではあるが…


OSを問わず上書きする


Windows上でのみ Warning を発生して処理を中止する


C言語レベルでの考察


php_do_open_temporary_file関数(テンポラリファイル生成に関与)

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

/* 省略 */

}



VCWD_*のマクロ定義

#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)


virtual_open関数(open関数のラッパー)

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);
}

/* 省略 */

}



move_uploaded_file関数のC言語レベルでの実装

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.jpPHP5.3.3 の環境では VCWD_RENAME マクロによる上書きが行われているようであった。どうしてこんな中途半端なアップデートしたんだよ、やるならもうちょっと一貫性持たせろよ…


結論


  • ファイルを move_uploaded_file 関数で移動させてもいいし、 copy 関数でコピーしてもいい。

  • 移動またはコピーにどちらの関数を用いたとしても、パーミッションを直後に chmod 関数によって定めておくのが一番無難である。 umask 関数による設定方法はスクリプト全体に影響を及ぼす上に、上書き時の挙動に一貫性が無いので推奨されない。


  • is_uploaded_file 関数を使う必要性は全くない。