PHP は、問い合わせフォーム、会員サイト、社内の受発注システムなど、中小企業の Web システムで今も広く使われている言語です。他社から保守を引き継いだシステムのコードを読むと、10 年以上前から知られている脆弱性が、そのままの形で残っていることが少なくありません。

結論:PHP の脆弱性の大半は、外部から来た値を、確認しないまま「SQL」「HTML」「ファイルパス」「コマンド」に混ぜてしまうことから生まれます。本記事では、引き継ぎ案件の点検で特によく見つかる 7 つを、危ない書き方と安全な書き方の対比で解説し、最後に php.ini の設定チェックリストをまとめます。PHP 本体のバージョンが古い場合は、コードより先に PHP のサポート期限 を確認してください。

7 つの脆弱性の早見表

脆弱性起きること基本の対策
SQL インジェクション顧客情報の抜き取り、データの改ざん・削除プリペアドステートメント
XSS偽の入力フォーム表示、管理者のセッション乗っ取り出力時の HTML エスケープ
CSRFログイン中の利用者に意図しない操作をさせるトークンの検証、SameSite Cookie
ファイルアップロードPHP ファイルを置かれてサーバーを乗っ取られる種類の検証、保存先の分離、実行禁止
ファイルインクルード設定ファイルの読み取り、任意コードの実行許可リスト方式
OS コマンドインジェクションサーバー上で任意のコマンドを実行されるコマンドを使わない、使うなら引数をエスケープ
安全でないデシリアライズ任意コードの実行外部入力に unserialize() を使わない

1. SQL インジェクション

フォームや URL の値を文字列連結で SQL に埋め込むと、値の中に SQL の一部を紛れ込ませることで、検索条件を書き換えられてしまいます。顧客情報の大量流出につながる、もっとも被害の大きい脆弱性のひとつです。

危ない書き方

$id = $_GET['id'];
$sql = "SELECT * FROM customers WHERE id = " . $id;
$result = $pdo->query($sql);

安全な書き方

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = :id');
$stmt->execute([':id' => $_GET['id']]);
$customer = $stmt->fetch();

値はすべてプレースホルダ(:id や ?)経由で渡します。並び替えの列名やテーブル名のようにプレースホルダが使えない箇所は、あらかじめ許可した値のリストから選ぶ方式にします。

2. クロスサイトスクリプティング(XSS)

利用者が入力した値をそのまま HTML に出力すると、値に含まれたスクリプトが他の閲覧者のブラウザで実行されます。問い合わせ内容を一覧表示する管理画面は、管理者だけが見る画面だからこそ狙われやすい箇所です。

危ない書き方

<p>お名前:<?= $_POST['name'] ?></p>

安全な書き方

function h(?string $s): string {
    return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<p>お名前:<?= h($_POST['name'] ?? '') ?></p>

ポイントは「入力時」ではなく「出力する直前」にエスケープすることです。JavaScript の中や URL の属性値に値を埋め込む場合は、HTML エスケープだけでは足りません。JavaScript に渡すなら json_encode() を使い、href に入れる URL は https:// で始まるかを確認します。加えて Content-Security-Policy ヘッダーを設定すると、万一漏れがあったときの被害を抑えられます。

3. CSRF(クロスサイトリクエストフォージェリ)

管理画面にログインしたまま別のサイトを開くと、そのサイトに仕込まれたフォームから、管理画面への「削除」「設定変更」などのリクエストを勝手に送られてしまう攻撃です。ブラウザはログイン中の Cookie を自動で付けて送るため、サーバー側からは本人の操作と区別がつきません。

安全な書き方(トークンの発行と検証)

// フォームを表示するとき
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// <input type="hidden" name="csrf_token" value="<?= h($_SESSION['csrf_token']) ?>">

// POST を受け取ったとき
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '')) {
    http_response_code(403);
    exit;
}

状態を変える処理(登録・更新・削除)は必ず POST で受け、トークンを検証します。セッション Cookie には SameSite=Lax 以上を設定しておくと、多くのケースで二重の防御になります。

4. ファイルアップロードの不備

画像や PDF のアップロード機能で、拡張子やファイル名を利用者の申告どおりに保存すると、PHP ファイルをアップロードされ、URL を開くだけでサーバー上で実行される状態になります。いわゆる「Web シェル」を置かれると、サーバー全体を乗っ取られます。

安全な書き方

$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($_FILES['file']['tmp_name']);
if (!isset($allowed[$mime]) || $_FILES['file']['size'] > 5 * 1024 * 1024) {
    http_response_code(400);
    exit;
}

// 名前はサーバー側で作り直し、公開ディレクトリの外に保存する
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($_FILES['file']['tmp_name'], '/var/app/uploads/' . $name);
  • ファイルの種類は、申告された $_FILES['file']['type'] ではなく中身から判定する
  • ファイル名は利用者のものを使わず、サーバー側で生成する
  • 保存先は公開ディレクトリの外にし、表示は PHP 経由で行う
  • やむを得ず公開ディレクトリに置く場合は、Web サーバーの設定でそのディレクトリでの PHP 実行を禁止する

5. ファイルインクルード・ディレクトリトラバーサル

?page=about のようなパラメータで読み込むファイルを切り替える作りは、古い PHP サイトでよく見かけます。値に ../ を含めると、想定外の場所にある設定ファイルなどを読み込まれてしまいます。

危ない書き方

include __DIR__ . '/pages/' . $_GET['page'] . '.php';

安全な書き方

$pages = ['home', 'about', 'service', 'contact'];
$page  = in_array($_GET['page'] ?? '', $pages, true) ? $_GET['page'] : 'home';
include __DIR__ . '/pages/' . $page . '.php';

ファイルのダウンロード機能も同じ構造です。利用者の入力をパスに使うのではなく、データベースの ID からサーバー側でパスを引く形にします。

6. OS コマンドインジェクション

画像変換や PDF 生成のために、exec() や shell_exec() で外部コマンドを呼んでいるコードがあります。引数に利用者の値をそのまま入れると、; などの記号で別のコマンドをつなげて実行されます。

危ない書き方

exec("convert " . $_POST['filename'] . " output.png");

安全な書き方

// まず PHP の拡張機能やライブラリで代替できないか検討する。
// どうしても使う場合は、引数を 1 つずつエスケープする
exec('convert ' . escapeshellarg($path) . ' ' . escapeshellarg($outPath));

いちばん確実なのは、外部コマンドを使わないことです。PHP 7.4 以降なら proc_open() に配列で引数を渡すと、シェルを経由せずにコマンドを起動できます。

7. 安全でないデシリアライズ

Cookie や hidden フィールドに PHP の serialize() した値を入れ、受け取った側で unserialize() している作りは危険です。細工した文字列を渡されると、読み込み済みのクラスの処理を悪用して任意のコードを実行される恐れがあります。

危ない書き方

$cart = unserialize($_COOKIE['cart']);

安全な書き方

$cart = json_decode($_COOKIE['cart'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);

外部とやり取りするデータは JSON を使い、unserialize() は自分で書き出した信頼できるデータにだけ使います。どうしても必要な場合は ['allowed_classes' => false] を指定してください。

あわせて確認したい:ログインとセッション

  • パスワードの保存:password_hash() と password_verify() を使う。md5() や sha1()、独自の暗号化は使わない
  • ログイン成功時:session_regenerate_id(true) でセッション ID を作り直す
  • セッション Cookie:Secure、HttpOnly、SameSite を付ける
  • 総当たり対策:ログイン失敗の回数で一時的にロックする、管理画面は IP で制限する

php.ini の設定チェックリスト

コードの対策に加えて、PHP 本体の設定でも被害を小さくできます。本番環境では次の値になっているかを確認してください。

display_errors = Off          ; エラー内容を画面に出さない(ログには残す)
log_errors = On
expose_php = Off              ; レスポンスヘッダーに PHP のバージョンを出さない
allow_url_include = Off       ; URL のファイルを include させない
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"
session.use_strict_mode = 1

display_errors が On のままだと、エラー画面にファイルのパスや SQL 文が表示され、攻撃者にヒントを与えてしまいます。expose_php を Off にすると、X-Powered-By ヘッダーからバージョンが分からなくなり、古いバージョンを狙った機械的な攻撃の的になりにくくなります。

サーバーに届く攻撃リクエストの実例と、Web サーバー側での防ぎ方は サーバーログに届く PHP 攻撃の正体 で解説しています。

よくあるご質問

フレームワーク(LaravelやWordPress)を使っていれば安全ですか?

多くの対策が標準で組み込まれているため、素のPHPで書くよりは安全です。ただし、生のSQLを組み立てる、テンプレートでエスケープを無効にする出力を使う、CSRF対策の対象からルートを外す、といった書き方をすると、フレームワークの守りを自分で外してしまいます。仕組みを理解したうえで使うことが前提です。

自社のPHPシステムに脆弱性があるか、どうやって調べればよいですか?

まずはソースコードを対象に、本記事の7項目に当てはまる書き方がないかを確認します。外部入力を受け取る箇所(フォーム、URLパラメータ、Cookie、アップロード)から、データベース・画面出力・ファイル操作・コマンド実行までの流れを追うのが基本です。重要なシステムは、専門業者による脆弱性診断も検討してください。

addslashesやmysql_real_escape_stringでエスケープしていれば大丈夫ですか?

おすすめしません。文字コードの組み合わせや、数値として埋め込む箇所でエスケープが効かないケースがあり、漏れも起きやすいためです。なお mysql_ で始まる関数は PHP 7.0 で削除されています。SQLインジェクション対策は、プリペアドステートメント(プレースホルダ)を使うのが基本です。

パスワードはどう保存するのが正しいですか?

password_hash() でハッシュ化して保存し、照合には password_verify() を使います。md5() や sha1() でのハッシュ化、独自の暗号化は避けてください。ログイン成功時には session_regenerate_id(true) でセッションIDを作り直すことも忘れずに行います。