- 対象: 1 台の VPS / サーバーに複数の PHP サイト(WordPress など)を同居させている Web 担当者・制作会社の方
- 解決すること: 1 サイトが侵害されたとき、隣のサイトのファイルや DB パスワードまで読まれる 状態をなくします
- 方法: PHP-FPM の「プール」をサイトごとに分け、実行ユーザー・ソケット・ファイル権限・一時ディレクトリを切り離します(設定はコピペで使えます)
なぜ分離が必要か
Ubuntu / Debian の PHP-FPM は、インストール直後は www プール 1 つだけで、全サイトの PHP が同じ www-data ユーザーで動きます。この状態では、どれか 1 サイトのプラグインの脆弱性で PHP ファイルを置かれただけで、そのファイルは同じ権限で次のことができてしまいます。
- 隣のサイトの
wp-config.phpを読む(= DB パスワードが漏れる) - 隣のサイトのテーマやプラグインに不正なコードを書き込む
-
/tmpなどにある他サイトのセッションファイルを一覧する
PHP マニュアル自身が、open_basedir は「包括的ではない追加の安全網にすぎない」、disable_functions は「回避されうるので共有環境の十分な対策とみなすべきではない」と書いています。分離の本体は OS のユーザー分離とファイル権限 で、php.ini の設定はその上に重ねる層と考えてください。
以下は Ubuntu 24.04(PHP 8.3)の公式パッケージを前提にしたパスです。Debian 12(PHP 8.2)なら 8.3 を 8.2 に読み替えてください。例では site1 / site2 の 2 サイトを想定しています。
0. 現状確認
まず、どのユーザーで PHP が動いているか、ソケットとディレクトリの権限がどうなっているかを確認します。
# プールごとの実行ユーザー(全部 www-data なら分離されていない)
ps -eo user,group,cmd | grep '[p]hp-fpm: pool'
# プール定義の user / group / listen
sudo grep -RnE '^\s*(user|group|listen)\s*=' /etc/php/*/fpm/pool.d/
# ソケットとサイトディレクトリの所有者・権限
ls -l /run/php/
stat -c '%U:%G %a %n' /var/www/*/
www プール 1 つに全サイトがぶら下がっていたら、以下の 7 設定で分けていきます。
① サイトごとに専用ユーザーとプールを作る
ログインできないシステムユーザーをサイトごとに作り、そのユーザーで動くプールを定義します。
sudo useradd --system --no-create-home --shell /usr/sbin/nologin site1
sudo useradd --system --no-create-home --shell /usr/sbin/nologin site2
/etc/php/8.3/fpm/pool.d/site1.conf:
[site1]
user = site1
group = site1
listen = /run/php/site1.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
site2.conf も名前を置き換えて同じように作ります。ポイントは次の 2 点です。
-
user/groupを サイトごとに別ユーザー にする(userはプールの必須項目です) -
サイト用ユーザーを
www-dataグループに入れない(入れると分離が崩れます)
全サイトを新プールに移したら、既定の www.conf は使われなくなります。pool.d は *.conf だけを読み込むので、www.conf.disabled のようにリネームして無効化しておくと、誤って古いプールにつながる事故を防げます。
② ソケットに接続できるのを Web サーバーだけにする
PHP-FPM は Unix ソケットのパーミッションで接続元を制限できます。上の設定では、ソケットの所有者を www-data、モードを 0660(マニュアル上の既定値)にしています。
- nginx / Apache(
www-data)は全サイトのソケットに接続できる -
site2ユーザーで動く PHP は、site1.sockに接続できない
こうしておくと、侵害されたサイトの PHP がほかのサイトのプールへリクエストを投げて、別ユーザーとして処理させることができなくなります。POSIX ACL が使える環境なら、listen.owner / listen.group の代わりに listen.acl_users = www-data で指定することもできます(このとき owner / group の指定は無視されます)。
TCP(127.0.0.1:9000 など)で待ち受けていると、ファイル権限による制限が効きません。同居構成では Unix ソケットを使う のが基本です。
③ ファイルの所有者と権限を「読める・書ける」の最小に絞る
プールを分けても、ファイルが誰でも読める権限(644 / 755)のままでは隣のサイトから読めてしまいます。次の方針にします。
- サイトのディレクトリは そのサイトのユーザーのグループだけ が読める(other は 0)
- コードの所有者は root(またはデプロイ用ユーザー)にして、PHP 自身はコードを書き換えられない ようにする
- 書き込みが必要な場所(WordPress なら
wp-content/uploads)だけ、サイトのユーザーの所有にする - nginx / Apache が静的ファイルを配信できるよう、
www-dataには ACL で読み取りだけ付ける
sudo apt install -y acl
SITE=site1
DIR=/var/www/${SITE}
# コードは root 所有・サイトのグループだけ読める
sudo chown -R root:${SITE} "${DIR}"
sudo find "${DIR}" -type d -exec chmod 750 {} +
sudo find "${DIR}" -type f -exec chmod 640 {} +
# 書き込みが必要な場所だけサイトのユーザーに
sudo chown -R ${SITE}:${SITE} "${DIR}/wp-content/uploads"
# Web サーバーには読み取りだけ(以後作られるファイルにも継承)
sudo setfacl -R -m u:www-data:rX "${DIR}"
sudo find "${DIR}" -type d -exec setfacl -d -m u:www-data:rX {} +
# wp-config.php は Web サーバーにも読ませない(PHP 経由でのみ使う)
sudo setfacl -x u:www-data "${DIR}/wp-config.php"
確認は「隣のサイトのユーザーで読めないこと」を見るのが一番確実です。
sudo -u site2 cat /var/www/site1/wp-config.php
# → Permission denied になれば OK
コードを PHP から書き換えられなくすると、WordPress の管理画面からのコア・プラグイン更新は失敗するようになります。更新は wp-cli をデプロイ用ユーザーで実行する運用に切り替えてください(関連記事の自動更新運用も参照)。改ざん耐性と引き換えの仕様です。
④ open_basedir と一時ディレクトリ・セッションをプール別にする
ユーザー分離の上に、PHP 側の安全網を重ねます。php_admin_value で指定した値は、マニュアルにあるとおりスクリプト側の ini_set() で上書きできません。
sudo install -d -o site1 -g site1 -m 700 /var/lib/php/site1
sudo install -d -o site1 -g site1 -m 700 /var/lib/php/site1/tmp
sudo install -d -o site1 -g site1 -m 700 /var/lib/php/site1/sessions
site1.conf に追記:
; PHP が触れるディレクトリをサイトと専用領域だけに
php_admin_value[open_basedir] = /var/www/site1/:/var/lib/php/site1/
; アップロード中の一時ファイル・一時ディレクトリを専用に
php_admin_value[upload_tmp_dir] = /var/lib/php/site1/tmp
php_admin_value[sys_temp_dir] = /var/lib/php/site1/tmp
; セッションファイルを専用ディレクトリに(既定の /tmp は他ユーザーから一覧できる)
php_admin_value[session.save_path] = /var/lib/php/site1/sessions
php_admin_flag[session.use_strict_mode] = on
補足です。
-
open_basedirは区切りがコロン、シンボリックリンクは解決してから判定されます。末尾に/を付けてディレクトリ単位であることを明示しておくと読み違いがありません - マニュアルの注意として、
open_basedirを使うと realpath キャッシュが無効 になり、Unix ドメインソケットへの接続は制限されません。あくまで追加の安全網です -
session.save_pathについて、マニュアルは「/tmp(既定)のように誰でも読めるディレクトリだと、ほかのユーザーにセッションを乗っ取られうる」と警告しています。session.use_strict_modeも「一般的なセッションセキュリティに必須」とされています - Debian / Ubuntu は php.ini で
session.gc_probability = 0とし、古いセッションの削除を/usr/lib/php/sessionclean(systemd timer)に任せています。独自パスが掃除対象に含まれるかはバージョンで差があるので、grep -n save_path /usr/lib/php/sessioncleanで確認し、含まれない場合はプールにphp_admin_value[session.gc_probability] = 1を足してください -
disable_functionsをプールで指定すると、php.ini の値を置き換えるのではなく 追記 になります。前述のとおり回避されうる設定なので、これだけに頼らないでください
⑤ 実行できる拡張子を .php に限定し、存在しないファイルを渡さない
アップロード画像などを PHP として実行させられる設定ミスを、PHP-FPM 側と Web サーバー側の両方で塞ぎます。
site1.conf に追記(マニュアル上の既定値は .php .phar):
security.limit_extensions = .php
nginx は ファイルが実在するときだけ PHP-FPM に渡します。
server {
listen 443 ssl;
server_name site1.example.jp;
root /var/www/site1;
index index.php;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/site1.sock;
}
}
PHP マニュアルの nginx 導入手順も、存在しないファイルを PHP-FPM に渡さないよう php.ini で cgi.fix_pathinfo=0 にすることを求めています。/etc/php/8.3/fpm/php.ini もあわせて確認してください。
Apache(2.4.10 以降)は mod_proxy_fcgi でサイトごとのソケットに振り分けます。
<VirtualHost *:443>
ServerName site1.example.jp
DocumentRoot /var/www/site1
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/site1.sock|fcgi://site1/"
</FilesMatch>
</VirtualHost>
sudo a2enmod proxy_fcgi
# mod_php が有効だと PHP が Apache(www-data)の権限で動くので無効化する
sudo a2dismod php8.3
fcgi:// 以降の名前はワーカーの識別子です。サイトごとに別の名前(site1、site2…)にしておきます。アップロードディレクトリで PHP を実行させない設定は、関連記事のファイル権限の回で詳しく扱っています。
⑥ プールごとにリソース上限とログを分ける
1 サイトが重い処理や攻撃で詰まったときに、ほかのサイトを道連れにしないための設定です。
sudo install -d -o site1 -g adm -m 750 /var/log/php-fpm/site1
site1.conf に追記:
; 同時処理数の上限(このプールだけで使い切る)
pm.max_children = 10
; 1 リクエストの強制終了時間(max_execution_time が効かない場合の保険)
request_terminate_timeout = 60s
; 遅いリクエストの記録
request_slowlog_timeout = 10s
slowlog = /var/log/php-fpm/site1/slow.log
; エラーログをサイト別に
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm/site1/error.log
; 環境変数をワーカーに持ち込まない(既定値 yes のまま明示)
clear_env = yes
pm.max_children はマニュアルで「同時に処理できるリクエスト数の上限」とされています。サイトごとに分けると、1 サイトが上限に張り付いても、ほかのプールのワーカーは残ります。ログをサイト別にしておくと、侵害の兆候を追うときにどのサイトで起きたかがすぐ分かります。
⑦ 反映して、分離できているか確かめる
# 構文チェック → 反映
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
sudo nginx -t && sudo systemctl reload nginx
# Apache の場合
# sudo apachectl configtest && sudo systemctl reload apache2
# プールごとに別ユーザーで動いているか
ps -eo user,cmd | grep '[p]hp-fpm: pool'
PHP 側の値は、推測されにくい名前の確認用ファイルを一時的に置いて見ます(確認後すぐ削除 してください)。
<?php
header('Content-Type: text/plain');
echo 'user=', posix_getpwuid(posix_geteuid())['name'], "\n";
echo 'open_basedir=', ini_get('open_basedir'), "\n";
echo 'session.save_path=', ini_get('session.save_path'), "\n";
echo 'upload_tmp_dir=', ini_get('upload_tmp_dir'), "\n";
echo 'other_site_readable=', var_export(@is_readable('/var/www/site2/wp-config.php'), true), "\n";
curl -s https://site1.example.jp/check-7f3a9c.php
sudo rm /var/www/site1/check-7f3a9c.php
user=site1、other_site_readable=false になっていれば分離できています。
まとめ
| # | 設定 | 防ぐこと |
|---|---|---|
| ① | サイトごとの専用ユーザー + プール | 全サイトが同じ権限で動く状態 |
| ② | ソケットを www-data だけに 0660 | 他サイトのプールへの横入り |
| ③ | root 所有 + グループ限定 + ACL | 隣のサイトのファイル読み取り・コード書き換え |
| ④ | open_basedir・一時領域・セッションを専用に | /tmp 経由の覗き見・セッション乗っ取り |
| ⑤ | limit_extensions + try_files | 画像などを PHP として実行させる設定ミス |
| ⑥ | プール別の上限とログ | 1 サイトの詰まりの道連れ・追跡不能 |
| ⑦ | reload 後に実ユーザーと値を確認 | 「設定したつもり」 |
- 分離の本体は OS ユーザーとファイル権限(①②③)です。
open_basedirやdisable_functionsは上に重ねる安全網です - コードを PHP から書けなくすると管理画面からの更新はできなくなるので、wp-cli による更新運用とセットで導入してください
- 分離は侵害の「広がり」を止める対策です。各サイトの更新と脆弱性対応そのものは引き続き必要です
関連記事
本記事のような設定の抜けは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp