本文へ移動
Monaural Sound

コード集開発関連

PHP脆弱性バージョン確認用スクリプト

2026年10月に公表されたPHPの脆弱性「CVE-2026-109618/CVE-2026-109619(通称「php2shell」)」は、未認証のリモートコード実行につながる PHP公式がWebリクエストを処理する全環境に即時更新を推奨する 程に影響範囲の大きい脆弱性だった。 世の中の約70%ぐらいのWebサービスがPHPで動いているとのことで、Webサービスの運営各社が緊急メンテを実施して対応にあたる一大イベントになったが、その例に漏れず自前の零細サービスを運営している私個人も対応に追われた次第だ。

ちなみに、同時にNode.jsの脆弱性も明るみになって、そっちも同時に対応することになったのだが、一挙に両方対応できたので結果的に見ればタイミングが良かったと言えなくもない。

──で、今回PHPのバージョンを最新パッチ適応版である下記のバージョンに更新する必要があった。

  • PHP 8.2.35
  • PHP 8.3.36
  • PHP 8.4.27
  • PHP 8.5.12

私は自分のブログサイトの他に複数の零細Webサービスを運営していて、ステージング環境や開発環境等のサブドメインを含めると対応が必要な FQDN が10個以上ある。 基本、Webサーバー側の PHP CLI のバージョンは FQDN をホストしているサーバーで php -v でバージョン確認すれば良く、サーバーホスティングしている環境についてはホスティング元の会社のメンテナンス後にPHPバージョンが正しく変更されているかの最終確認をするだけの手間で済んだ。 ただ、この php -v で確認できるのはサーバー側の PHP の CLI版 のバージョンであり、FQDNのWeb側 PHP がそのバージョンで動作している保証にはならない のだ。 これを確認するには、各FQDNのドキュメントルート等に <? phpinfo() ?> のようなバージョンを確認できる PHP ファイルを作成して、それに直接アクセスする必要がある。しかも確認後はそのファイルを残すとそこから別の攻撃を受ける可能性が高いので、確実に削除しないといけない。確認するFQDN数が多い私にとっては非常に面倒だ。 なので、それらを一気に自動でやってくれるシェルスクリプトを作ってみた。

まず、PHPバージョンの確認を行うヘルスチェック用PHPファイル helthcheck.php を作成する。 phpinfo() は環境変数やパスなども表示してしまい危険なため、PHPのバージョン確認に特化したファイルにしている。

<?php
header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: no-store');
echo 'PHP: ', PHP_VERSION, "\n";
echo 'SAPI: ', PHP_SAPI, "\n";

これを、サーバ側のホームディレクトリ直下の ~/test/helthcheck.php へ配置し、同じ階層に ~/test/check-web-php.sh のシェルスクリプトを新規作成する(chmod a+x check-web-php.sh で実行権限を付ける)。シェルスクリプトの内容は下記の通り。

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

# ---- 設定欄:同じ添字のFQDNとドキュメントルートが1組 ----
FQDNS=(
  'fqdn1.com'
  'fqdn2.com'
  'sub1.fqdn2.com'
)
DOCUMENT_ROOTS=(
  "/var/www/html/fqdn1.com"
  "/var/www/html/fqdn2.com"
  "$HOME/public_html/sub1.fqdn2.com"
)
# 実際の公開ディレクトリへ修正する。URLは https://FQDN/一時ファイル名.php。
PHP_SOURCE="$HOME/test/helthcheck.php"
REPORT_DIR="$HOME/test/reports"
EXPECTED_PHP_VERSION='8.4.27' # 完全一致。版の判定が不要なら空文字にする。
CONNECT_TIMEOUT=10
REQUEST_TIMEOUT=30
# ---- 設定欄ここまで ----

fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
for tool in curl mktemp ln readlink rm mkdir date awk basename; do
  command -v "$tool" >/dev/null || fail "必要なコマンドがありません: $tool"
done
[[ ${#FQDNS[@]} -gt 0 && ${#FQDNS[@]} -eq ${#DOCUMENT_ROOTS[@]} ]] || fail '配列の件数を揃えてください。'
[[ $PHP_SOURCE == /* && -f $PHP_SOURCE && -r $PHP_SOURCE ]] || fail "PHPファイルを読めません: $PHP_SOURCE"
for i in "${!FQDNS[@]}"; do
  [[ ${FQDNS[$i]} =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "FQDNが不正です: ${FQDNS[$i]}"
  [[ ${DOCUMENT_ROOTS[$i]} == /* && -d ${DOCUMENT_ROOTS[$i]} && -w ${DOCUMENT_ROOTS[$i]} ]] || fail "公開ディレクトリを確認してください: ${DOCUMENT_ROOTS[$i]}"
  [[ ${DOCUMENT_ROOTS[$i]} != *$'\t'* && ${DOCUMENT_ROOTS[$i]} != *$'\n'* ]] || fail 'パスにタブ・改行は指定できません。'
done

work_dir=''
active_link=''
cleanup_link() {
  [[ -n $active_link ]] || return 0
  # 作成したリンクが同じ対象を指している場合だけ削除する。
  if [[ -L $active_link && $(readlink -- "$active_link") == "$PHP_SOURCE" ]]; then
    rm -- "$active_link" || return 1
  elif [[ -e $active_link || -L $active_link ]]; then
    printf 'ERROR: 一時パスが変更されたため削除しません: %s\n' "$active_link" >&2
    return 1
  fi
  active_link=''
}
cleanup() {
  local result=$?
  trap - EXIT
  if ! cleanup_link; then
    printf 'ERROR: 一時リンクの手動確認が必要です: %s\n' "$active_link" >&2
    result=1
  fi
  [[ -z $work_dir ]] || rm -rf -- "$work_dir" || result=1
  exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

mkdir -p -- "$REPORT_DIR"
report=$(mktemp "$REPORT_DIR/php-healthcheck-$(date -u +%Y%m%dT%H%M%SZ)-XXXXXXXX.tsv")
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/famie-php-healthcheck.XXXXXXXX")
link_name="$(basename -- "$work_dir").php"
printf '# Checked at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$report"
printf '# Expected PHP: %s (exact match; empty means no version check)\n' "$EXPECTED_PHP_VERSION" >> "$report"
printf 'FQDN\tDOCUMENT_ROOT\tHTTP\tCURL_EXIT\tPHP\tSAPI\tRESULT\n' >> "$report"
printf 'Report: %s\n' "$report"

failed=0
for i in "${!FQDNS[@]}"; do
  fqdn=${FQDNS[$i]}
  root=${DOCUMENT_ROOTS[$i]%/}
  link="$root/$link_name"
  # lnに-fを付けない。既存ファイル・リンクは上書きしない。
  if ! ln -sT -- "$PHP_SOURCE" "$link"; then
    printf '%s\t%s\t-\t-\t-\t-\tLINK_ERROR\n' "$fqdn" "$root" >> "$report"
    printf 'NG %s: リンク作成失敗\n' "$fqdn"
    failed=1
    continue
  fi
  active_link=$link
  curl_exit=0
  # TLS検証を有効にし、リダイレクトは追わない。Basic認証やIP制限は解除しない。
  http=$(curl --silent --show-error --proto '=https' \
    --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" \
    --max-filesize 65536 --header 'Cache-Control: no-cache' \
    --output "$work_dir/body" --write-out '%{http_code}' \
    "https://$fqdn/$link_name" 2> "$work_dir/curl-error") || curl_exit=$?

  # 本文全体はレポートへ残さない。想定した最小表示だけを抽出する。
  php=''
  sapi=''
  if [[ $curl_exit == 0 && $http == 200 ]]; then
    php=$(awk '/^PHP: [0-9]+\.[0-9]+\.[0-9]+([A-Za-z0-9.+~-]*)?\r?$/ { sub(/^PHP: /, ""); sub(/\r$/, ""); print; exit }' "$work_dir/body")
    sapi=$(awk '/^SAPI: [A-Za-z0-9_-]+\r?$/ { sub(/^SAPI: /, ""); sub(/\r$/, ""); print; exit }' "$work_dir/body")
  fi
  result=OK
  if [[ $curl_exit != 0 ]]; then result=CURL_ERROR
  elif [[ $http != 200 ]]; then result=HTTP_ERROR
  elif [[ -z $php || -z $sapi || $sapi == cli ]]; then result=INVALID_RESPONSE
  elif [[ -n $EXPECTED_PHP_VERSION && $php != "$EXPECTED_PHP_VERSION" ]]; then result=VERSION_MISMATCH
  fi
  if ! cleanup_link; then result=CLEANUP_ERROR; fi
  printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$fqdn" "$root" "$http" "$curl_exit" "${php:--}" "${sapi:--}" "$result" >> "$report"
  printf '%s %s: HTTP=%s PHP=%s SAPI=%s\n' "$result" "$fqdn" "$http" "${php:--}" "${sapi:--}"
  if [[ $result != OK ]]; then failed=1; fi
  # 削除失敗時は次のリンクを作らず、EXITでも再度削除を試みる。
  if [[ $result == CLEANUP_ERROR ]]; then break; fi
done
printf '完了: %s\n' "$report"
exit "$failed"

あとは bash check-web-php.sh で実行するだけで、各FQDNのドキュメントルート直下に一時リンクを作成して HTTPS 確認、リンク削除を順番に行って、結果を ~/test/reports/ 下へ TSV 出力してくれる。中断時も一時ファイルは削除して、既存ファイルには上書きしないようにしてある。

実際の実行結果例:

OK fqdn1.com: HTTP=200 PHP=8.4.27 SAPI=cgi-fcgi
HTTP_ERROR fqdn2.com: HTTP=302 PHP=- SAPI=-
OK sub1.fqdn2.com: HTTP=200 PHP=8.4.27 SAPI=cgi-fcgi

これでWeb側の PHP 実行環境のバージョンも一気に確認できた。

有用なスクリプトだと思ったので、コードスニペットとして共有しておこうかと思った次第だ。FQDN等の環境変数周りを外出ししてサーバー毎に設定ファイル化するなどのカスタマイズをすれば、通常時のサービス生存確認用のヘルスチェックの仕組みにも組み込めそうである。

  • PHP
  • 脆弱性

関連記事

Ka2(カツ)

ゲームと、アメコミと、アニメと、歴史が好きなフリーランスのフルスタック系ITエンジニア。
仕事は気に入ったものだけを「最小限」に、趣味のオープンソース開発と小説書くのを「最大限」に、
好きなこと『だけ』して生きていく。