PHPerの皆さんはphp.iniをちゃんと設定していますか?
全てデフォルト値を使用しているなんてことありませんよね? (煽り
セキュリティの観点から設定した方がいいパラメータの設定値を可能な限り説明を交えつつ紹介していきたいと思います
前提
php.ini
php.iniで設定可能な全パラメータは公式ドキュメント、PHP-7.3/php.ini-productionを参照ください
では、セキュリティの観点から設定した方がいいパラメータについて説明していきます
open_basedir
説明
PHPによってアクセス可能なディレクトリを制限します
デフォルトでは全てのディレクトリにアクセス可能です
directory traversalなどの攻撃を防御するために必ず設定をしておきましょう
基本的には/var/www/htmlを指定するだけで十分なケースが多いですが、:
を区切り文字として複数のディレクトリを指定可能です
DeepDive
open_basedirの実装を追ってみましょう
以下はphp_check_open_basedir_exのコアな処理を抜粋したコードです
https://github.com/php/php-src/blob/PHP-7.3/main/fopen_wrappers.c#L300-L319
while (ptr && *ptr) {
end = strchr(ptr, DEFAULT_DIR_SEPARATOR);
if (end != NULL) {
*end = '\0';
end++;
}
if (php_check_specific_open_basedir(ptr, path) == 0) {
efree(pathbuf);
return 0;
}
ptr = end;
}
if (warn) {
php_error_docref(NULL, E_WARNING, "open_basedir restriction in effect. File(%s) is not within the allowed path(s): (%s)", path, PG(open_basedir));
}
efree(pathbuf);
errno = EPERM; /* we deny permission to open it */
return -1;
DEFAULT_DIR_SEPARATORは:
として定義されています
php_check_specific_open_basedirの返り値が0 ( = open_basedirで許可されている)か、strchr(ptr, DEFAULT_DIR_SEPARATOR)の返り値がNULL ( = open_basedirの設定値による探査終了) になるまでループします
if (warn)
ではphp_check_open_basedirから呼び出される時に第2引数warnに1を指定しているので、open_basedirに指定されていないディレクトリを呼び出そうとするとphp_error_docref
によって警告が出力されます
警告用の引数を渡しておいて、その条件分岐前にreturnするかどうかで警告出すかどうかを決めるのはいい実装方法ですよね
内部で警告出すフラグをゴニョゴニョする必要がなく、すっきりしている印象を抱きます
php_check_specific_open_basedirはsymlinkにも対応しているので、ファイル本体がopen_basedir外であればエラーになります
以下がsymlinkの処理を行っているコードです
https://github.com/php/php-src/blob/PHP-7.3/main/fopen_wrappers.c#L167-L181
#if defined(PHP_WIN32) || defined(HAVE_SYMLINK)
if (nesting_level == 0) {
ssize_t ret;
char buf[MAXPATHLEN];
ret = php_sys_readlink(path_tmp, buf, MAXPATHLEN - 1);
if (ret == -1) {
/* not a broken symlink, move along.. */
} else {
/* put the real path into the path buffer */
memcpy(path_tmp, buf, ret);
path_tmp[ret] = '\0';
}
}
#endif
余談
ちなみにopen_basedirを設定するとrealpath cacheは使用できなくなるので注意が必要です
realpath cacheの設定をしていて、「あれ?」とならないようにしましょう
タイプは違えどopcacheは利用できます
open_basedirを設定している場合にrealpath cacheを無効にしているコードは以下になります
https://github.com/php/php-src/blob/PHP-7.3/main/main.c#L1802-L1805
/* Disable realpath cache if an open_basedir is set */
if (PG(open_basedir) && *PG(open_basedir)) {
CWDG(realpath_cache_size_limit) = 0;
}
realpath_cache_size_limitを0に設定して無効化しています
シンプルで美しい (ただ言いたかっただけw
disable_functions
説明
実行禁止する関数を設定できます
一般的なアプリケーションはファイルシステム・OSユーザなどをいじることはないと思うので、これらの変更が可能な関数は禁止しておきましょう
disable_fuctionsを設定するTipsとして、最初は厳し目に設定しておいてテストなどを通じて実行が必要な関数を設定から削除していくのがいいでしょう
自身で書いたコード以外にも使用しているライブラリやフレームワークなどにも影響が出るので注意が必要です
(1行だと見にくいと思うのでリスト化していますが、php.iniに書く際は,
を区切り文字として1行で書きます)
- system
- exec
- shell_exec
- passthru
- phpinfo (開発時は許可してもよい)
- show_source,
- highlight_file
- popen
- fopen_with_path
- dbmopen
- dbase_open
- putenv
- move_uploaded_file
- chdir
- mkdir
- rmdir
- chmod
- rename
- filepro
- filepro_rowcount
- filepro_retrieve
- posix_* (prefixがposixである関数)
DeepDive
disable_functionsの実装を追っていきましょう
disable_functionsはphp_module_startup、つまり起動時に読み込まれます
なので、PHPスクリプトで上書きすることはできません
PHPの関数はfunction_tableというハッシュテーブルで管理されています
disable_functionsで指定されて関数はfunction_tableから削除されるわけではなく、関数のハンドラーにdisplay_disabled_functionというエラーハンドラー(zend_error)をラップした関数をセットします
display_disabled_functionはエラーを発生させて、セキュリティの設定上関数が無効化されているメッセージを出力します
以下は関数を無効化するzend_disable_functionのコードです
https://github.com/php/php-src/blob/PHP-7.3/Zend/zend_API.c#L2847-L2859
ZEND_API int zend_disable_function(char *function_name, size_t function_name_length) /* {{{ */
{
zend_internal_function *func;
if ((func = zend_hash_str_find_ptr(CG(function_table), function_name, function_name_length))) {
func->fn_flags &= ~(ZEND_ACC_VARIADIC | ZEND_ACC_HAS_TYPE_HINTS | ZEND_ACC_HAS_RETURN_TYPE);
func->num_args = 0;
func->arg_info = NULL;
func->handler = ZEND_FN(display_disabled_function);
return SUCCESS;
}
return FAILURE;
}
/* }}} */
zend_hash_str_find_ptrによってfunction_tableから無効化したい関数を探査します
そして、該当の関数が存在すれば無効化します
expose_php
説明
expose_phpをOffにすると、PHPで処理していることやバージョンをHTTP Headerに含めないようにできます
プロダクションでは必ずOffにすべきパラメータです
DeepDive
HTTP Headerにセットする内容は以下のように定義されています
https://github.com/php/php-src/blob/PHP-7.3/main/SAPI.h#L289
#define SAPI_PHP_VERSION_HEADER "X-Powered-By: PHP/" PHP_VERSION
display_errors
説明
実行時のPHPエラーをHTMLの一部として画面に出力するかどうかを設定します
開発時には役に立ちますが、内部事情がバレてしまうのでプロダクションではOffにしましょう
DeepDive
エラーハンドラーのセットアップ
エラーハンドラーの呼び出し
html_errors
説明
display_errorsかlog_errorsが有効な場合に、HTMLタグをエラーメッセージ内に含めることができます
上記の2つのパラメータが無効な場合、html_errorsを有効にしていてもHTMLタグを含んだエラーメッセージは出力されません
こちらのパラメータもプロダクションではOffにしておきましょう
DeepDive
php_error_cb、php_verrorやphp_error_docref内の条件分岐のパラメータとして使用されます
HTMLタグを含めるかどうかのシンプルな条件分岐で使われている例
https://github.com/php/php-src/blob/PHP-7.3/main/main.c#L1338-L1358
if (PG(html_errors)) {
if (type == E_ERROR || type == E_PARSE) {
zend_string *buf = php_escape_html_entities((unsigned char*)buffer, buffer_len, 0, ENT_COMPAT, get_safe_charset_hint());
php_printf("%s<br />\n<b>%s</b>: %s in <b>%s</b> on line <b>%" PRIu32 "</b><br />\n%s", STR_PRINT(prepend_string), error_type_str, ZSTR_VAL(buf), error_filename, error_lineno, STR_PRINT(append_string));
zend_string_free(buf);
} else {
php_printf("%s<br />\n<b>%s</b>: %s in <b>%s</b> on line <b>%" PRIu32 "</b><br />\n%s", STR_PRINT(prepend_string), error_type_str, buffer, error_filename, error_lineno, STR_PRINT(append_string));
}
} else {
/* Write CLI/CGI errors to stderr if display_errors = "stderr" */
if ((!strcmp(sapi_module.name, "cli") || !strcmp(sapi_module.name, "cgi") || !strcmp(sapi_module.name, "phpdbg")) &&
PG(display_errors) == PHP_DISPLAY_ERRORS_STDERR
) {
fprintf(stderr, "%s: %s in %s on line %" PRIu32 "\n", error_type_str, buffer, error_filename, error_lineno);
#ifdef PHP_WIN32
fflush(stderr);
#endif
} else {
php_printf("%s\n%s: %s in %s on line %" PRIu32 "\n%s", STR_PRINT(prepend_string), error_type_str, buffer, error_filename, error_lineno, STR_PRINT(append_string));
}
}
allow_url_fopen
説明
外部のリソース(URL)をファイルオブジェクトのように扱います
fopenのラッパーであるfile_get_contentsなどによってURLにアクセス可能になるので、任意のスクリプトをアプリケーションに挿入できてしまいます
悪意ある外部のスクリプトによってインジェクションされないように、allow_url_fopenは必ず無効化しましょう
DeepDive
実際にallow_url_fopenによってURLにアクセス可能か条件分岐しているコード
https://github.com/php/php-src/blob/PHP-7.3/main/streams/streams.c#L1850-L1866
if (wrapper && wrapper->is_url &&
(options & STREAM_DISABLE_URL_PROTECTION) == 0 &&
(!PG(allow_url_fopen) ||
(((options & STREAM_OPEN_FOR_INCLUDE) ||
PG(in_user_include)) && !PG(allow_url_include)))) {
if (options & REPORT_ERRORS) {
/* protocol[n] probably isn't '\0' */
if (!PG(allow_url_fopen)) {
php_error_docref(NULL, E_WARNING, "%.*s:// wrapper is disabled in the server configuration by allow_url_fopen=0", (int)n, protocol);
} else {
php_error_docref(NULL, E_WARNING, "%.*s:// wrapper is disabled in the server configuration by allow_url_include=0", (int)n, protocol);
}
}
return NULL;
}
return wrapper;
allow_url_include
説明
includeやrequireでURL(file://、http://)をオープンするかを制御する
外部リソースの状態は不明な場合が多く不確実性があるので、allow_url_includeは無効化しましょう
信頼できる外部リソースでもURL経由ではなく、ダウンロードして内部に保存し、検証して安全性を確保された状態のものを使用しましょう
終わりに
公式ドキュメントとソースコードを追いながら、実際にパラメータを設定するとどうなるのってところまで深堀してみました
基本的なパラメータしか深掘りできていないので、sessoin周りとかもいずれはDeep Diveしていきたいですね