古いPHPのSQLインジェクションとXSSを出力1バイトも変えずに直す
10年物のアプリ用PHPバックエンドで36ファイル・90か所を修正し、保存した実レスポンスとのバイト比較で無変更を確認。スキャナーが見逃した2パターンと、WAFがある環境での検証方法。
アプリがまだ呼んでいる古いPHPバックエンドのSQLインジェクションとXSSを、レスポンスを1バイトも変えずに直したい人向けの記録です。結論から言うと、SQLを組み立てる行と出力する行だけでその場でエスケープし、query() はそのまま残しました。そのうえで、パッチを目で読むのではなく、保存しておいた実レスポンスとの差分で「変わっていない」ことを確かめました。この差分のおかげで、ソースレビューでは見つからなかった穴が2つ見つかっています。
対象は共用ホスティング(Hostinger、LiteSpeed+ホストのCDN)上のPHPです。8本のモバイルアプリが使うJSON APIと、WebView時代のPHPページがあります。インストール済みの古いバージョンがいまもWebViewでそのページを開くので、消すわけにはいきません。数値はすべて2026年11月9日の作業で実測したものです。
棚卸しの結果
| 区分 | ファイル数 | 備考 |
|---|---|---|
| 稼働中のJSON API | 12 | 現行版と旧版のアプリが呼ぶ |
| WebView時代のPHPページ | 20 | 旧版アプリが今も開く |
| 共通インクルード | 4 | ナビ、共通ユーティリティクラスなど |
| 使われていないフォルダ | 7 | どのアプリからも呼ばれていない |
いちばん危なかったのは最後の行です。7フォルダの中には、パスワードを平文で比較するREST風のログインAPIや、行を追加・削除できるテスト用スクリプトがありました。どこからも参照されていませんが、インターネットからは普通に届く状態でした。これらは削除せず、Webルートの外へ移動しました。今は404を返します。もし何かが依存していたと分かっても、数秒で戻せます。
スキャナーが見逃した2つの形
最初に、正規表現で書いた小さなPythonスキャナーで候補を出しました。
- HIGH:SQLを含む行に
$_GET/$_POST/$_REQUESTが直接ある - MED:入力から無害化せずに代入した変数が、あとでSQL文字列に入る
「SQLを含む行」かどうかは、SELECT、INSERT INTO、UPDATE … SET、DELETE FROM、WHERE、ORDER BY、LIMIT、VALUES、LIKE というキーワードと ->query( 呼び出しで判定していました。大半はこれで拾えましたが、2種類が漏れました。
見逃し1:継続行。 古いコードはクエリを1行ずつ連結していきます。
$Query = "SELECT … FROM … WHERE col1 = '".$_GET['k1']."'";
$Query .= " AND col2 = '".$_GET['k2']."'";
1行目は検出されましたが、2行目は検出されませんでした。キーワード一覧に AND がなかったからです。スキャナーはこの行を「入力からの代入」とだけ判断して $Query を汚染済みにし、行そのものは報告しませんでした。
見逃し2:配列を経由した汚染。 検索ページにはこんな形がありました。
$arr[0] = $_GET['keyword1'];
$arr[1] = $_GET['keyword2'];
sort($arr);
$kw = implode(' ', $arr);
…
$Query .= " AND name LIKE '%".$kw."%'";
$Query .= " OR code = '".$kw."'";
スキャナーが追っていたのは $x = … という普通の変数への代入だけで、$arr[0] = … という配列要素への代入は追っていませんでした。入力は配列に入ってソートされ、文字列に連結され、LIKE '%…%' と = '…' の両方に使われます。そのつながりは最後まで見えていませんでした。
この2つの形を知ったうえで手で追うと、配列パターンのファイルがさらに3つ見つかりました。もっと厄介なものもありました。共通ユーティリティクラスが $_GET をグローバル変数にコピーし、別のファイルがそのグローバル変数をSQLで使っていたのです。ファイル単位のスキャナーではまず見つかりません。インクルードの連鎖をたどって初めて分かります。
正規表現のスキャナーが出すのは調べ始めるための一覧で、「安全」の証明にはなりません。
修正:プリペアドステートメントではなく、その場でエスケープ
新しいコードならプリペアドステートメントが正解です。ただ今回は、それではAPIの出力が変わってしまいます。mysqlnd環境のプリペアドステートメントは整数や浮動小数点の列をネイティブな数値で返すので、"7" が 7 になり、文字列を前提にした旧版アプリが落ちる可能性があります。実測はプリペアドステートメントとJSONの型の記事にまとめました。
そこで、$Query や $Sub_Query を組み立てる行だけで値をエスケープし、それ以外には触れていません。
// 修正前
$Query .= " AND col2 = '".$_GET['k2']."'";
// 修正後
$Query .= " AND col2 = '".$Conn->real_escape_string((string)($_GET['k2'] ?? ''))."'";
(string) は k2[]=x のような配列入力対策です。これがないとPHP 8ではTypeErrorになります。?? '' は、パラメータがないときに旧コードが実質的に使っていた空文字を維持するためです。
実際のトラフィックでバイト単位で同じ出力になると見込めた理由はこうです。real_escape_string が触るのはNUL、\n、\r、\、'、"、Ctrl-Zの7文字だけです。UTF-8のマルチバイト文字は全バイトが0x80以上なので、アプリが実際に送ってくる日本語・韓国語・数字はそのまま通ります。SQL文字列が同じなら、結果もJSONも同じです。
日本の古いシステムを触る人には大事な注意があります。この理屈が成り立つのは、set_charset('utf8mb4') で接続の文字コードをUTF-8にしている場合だけです。Shift_JISでは「ソ」や「表」のように2バイト目が0x5C(バックスラッシュ)になる、いわゆる5C問題の文字があります。接続の文字コードと実際のデータが食い違ったままエスケープすると、引用符の外に出られてしまうことがあります。
| 対象 | SQL修正 | 出力修正 | ファイル数 |
|---|---|---|---|
| API | 13か所 | – | 12 |
| ページ(1回目20ページ、2回目に共通インクルード4つ) | 64行 | 13か所 | 24 |
XSS:出力先の文脈でエスケープが変わる
ページはリクエストパラメータをそのままHTMLに書き戻していました。どこに出力されるかで直し方が変わります。
| 出力先 | 対策 |
|---|---|
HTML本文(<h2>、<h3>) |
htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
属性値(value="…") |
同上。シングルクォートの属性があるので ENT_QUOTES は必須 |
共通クラスの中で組み立てる <title> |
呼び出し側ではなく、タイトル文字列を組み立てる場所でエスケープ |
onclick 内のJS文字列の中のURL |
先に rawurlencode、その後に htmlspecialchars |
最後の行には説明が必要です。
<a onclick="location.href='?d=<?= $d ?>'"> // 脆弱
<a onclick="location.href='?d=<?= htmlspecialchars($d, ENT_QUOTES, 'UTF-8') ?>'"> // まだ脆弱
<a onclick="location.href='?d=<?= htmlspecialchars(rawurlencode($d), ENT_QUOTES, 'UTF-8') ?>'"> // OK
htmlspecialchars は ' を ' に変えます。しかしブラウザは属性値の実体参照をデコードしてからJavaScriptに渡すので、JSから見ると本物の ' になり、文字列がそこで終わってしまいます。rawurlencode なら ' は %27 になり、HTMLにもJSにも意味を持ちません。その上の htmlspecialchars は結果を何も変えませんが、「属性には必ずエスケープ」という単純なルールを守るために付けています。ここに入る実際の値は 20251228 のような8桁の日付で、rawurlencode しても変わらないので、出力は同じままでした。
出力が変わっていないことの証明
パッチを当てる前に、アプリから取った実際のパラメータで27ページを呼び、レスポンスを保存しておきました。どのリクエストにもキャッシュよけの一意なクエリを付けて、CDNのキャッシュが応答しないようにしています(CDNのキャッシュには以前だまされました)。
デプロイ後の結果です。
- 24ページ:バイト単位で一致
- 3ページ:差分あり。ランダムな予想番号を表示するページで、もともと毎回内容が変わります。
この3ページを「たぶん乱数の部分だろう」で済ませるのはやめました。パッチなしの状態で2回続けて取得し、差が出る行番号の集合を記録します。次に、ベースラインとパッチ後で差が出る行の集合と比べます。
# 比較スクリプトから抜粋
def diff_lines(a: str, b: str) -> set[int]:
return {i for i, (x, y) in enumerate(zip(a.splitlines(), b.splitlines())) if x != y}
noise = diff_lines(fresh1, fresh2) # 乱数の行だけ
changed = diff_lines(baseline, patched)
assert changed == noise # どちらも同じ200行
どちらも同じ200行だったので、変わったのは乱数の行だけだと言えます。共通インクルード4つを直した2回目では、さらに8ページを確認し、すべて一致しました。APIのスナップショット比較は失敗0件、サイト全体のURLチェックは53/53でした。
WAFがあるときの攻撃テスト
定番の ' OR '1'='1 は役に立ちませんでした。PHPに届く前にホストのWAFが「Forbidden」を返すので、修正前でも修正後でも結果は同じです。WAFはバグを隠すだけで、直しはしません。少し形を変えたペイロードなら通るかもしれません。
そこで、WAFが気にしない入力を使いました。
- SQL:シングルクォート1文字。修正済みのエンドポイントは200で
[]か空の結果を返します。エスケープされていなければSQL構文エラーになります。 - XSS:すべてのパラメータに無害な独自タグ
<x1>を入れ、レスポンスのHTMLから生の<x1>を探します。
1回目のXSS修正のあとも、プローブは2か所でそのまま出てきました。共通ナビのインクルードにある検索ボックスと、ユーティリティクラスの中で設定されるページタイトルです。値がインクルード経由で入り、読み込んだ場所から遠いところで出力されていたので、ソースのgrepでは見落としていました。レスポンスを検索したから見つかったのです。共通インクルード4つを対象に2回目をやったのはこのためです。
もともと壊れていたもの
回帰チェックが役に立つのは、作業前に何が壊れていたかを知っているときだけです。先に次のことを記録しました。
- 1ページはPHPの構文エラー(閉じていない波かっこ)で、以前からずっと500を返していました。このページの修正版はデプロイしていません。直すと挙動が変わり、今回の作業範囲を超えるからです。
- 該当行がない検索は、修正前も修正後も500を返していました。ベースラインがなければ、これは回帰にしか見えません。
デプロイで事故を起こさないために
- 更新専用モード:サーバーにまだ存在しないファイルなら中止します。パスのタイプミスで、新しく公開されるファイルを作ってしまうことがありません。
- 先にバックアップ:上書きする前に、サーバー上の元のファイルをダウンロードします。
- 全ファイルに
php -l:手元のPHP 8.4は一部の古いファイルでCase statements followed by a semicolon are deprecatedと表示しました。以前からある無害な警告ですが、古いものだと分かっていないと、これを理由にデプロイが止まってしまいます。
どれを選ぶか
| 状況 | 方針 |
|---|---|
| 更新を強制できないクライアントが使う既存エンドポイント | SQLを組み立てる行でその場でエスケープし、query() を維持。保存したレスポンスと比較 |
| 新しいエンドポイント、新しいAPIバージョン | プリペアドステートメント+型の取り決めを文書化 |
| 使われていないのに外から届くコード | Webルートの外へ移し、404を確認してから後日削除 |
| WAFがペイロードを止める | クォート1文字と無害なプローブタグ。ソースではなくレスポンスを見る |
| ランダムな内容を含むページ | 変化した行の集合を、2回取得したときの揺らぎの集合と比べる |
大事なのは、パッチを当てる前に「変わらない」とは何かを決め、証拠を保存しておき、合っていたかどうかを差分で確かめることです。
よくある質問
real_escape_stringで日本語の入力は変わってしまう?
接続の文字コードがUTF-8(utf8mb4)なら変わりません。エスケープ対象はNUL・\n・\r・バックスラッシュ・シングルクォート・ダブルクォート・Ctrl-Zの7種だけで、UTF-8のマルチバイト文字は全バイトが0x80以上なので、日本語や数字の入力ではSQLがバイト単位で同じになります。
onclick属性の中の値はhtmlspecialcharsだけで足りる?
足りません。ブラウザは属性値の実体参照をデコードしてからJavaScriptに渡すので、'は本物の ' に戻ります。JS文字列内のURLに入れる値は、先にrawurlencode、その後にhtmlspecialcharsをかけます。
サーバーのWAFが攻撃文字列をブロックして修正を確認できないときは?
WAFが通す入力で試します。SQLはシングルクォート1文字(修正済みなら200で空の結果、SQLエラーにならない)、XSSは無害な独自タグ <x1> を入れ、ソースではなくレスポンスのHTMLから生の <x1> を探します。