要約:PHPで脆弱なアプリを作らないために、どのようなことを考えておけばよいかできる範囲でまとめました。間違い・補足があればご指摘いただければ幸いです。
PHPでのセキュリティ施策については前々からいささかながら気を払っていました。ただ、本を読んで体系だって勉強したわけではないので、調べてまとめたことを晒しておいて、みなさんのご指摘をいただこうかと思います。
以下の内容が正しいとは限らないこと、かつ、対策を実行しても未知の事象で脆弱性が生じうる可能性があることをご了承の上、お読みください。より詳細な内容を得たい場合は参考リンク先の文書を辿ってみてください。
脆弱性と対策方法
まず、具体的に気をつけているのは以下の5つの脆弱性についてです。むろん、まだまだあるかと思いますが最善より次善の策ということで代表的そうな例を考えておきます。
これらは、いずれもユーザ入力に対して適切に対処できていなかった場合に起こる脆弱性です。が、だからといってユーザ入力値部分だけ対策しておこうという方法では対策漏れが生じる(User-Agent文字列をそのまま出力してしまうなど)可能性があるため、全ての変数出力箇所に対して検証を行うべきでしょう。
- SQLインジェクション
- クロスサイトスクリプティング(略称:XSS)
- クロスサイトリクエストフォージェリ(略称:CSRF)
- ディレクトリトラバーサル
- サーバリソースを浪費させる脆弱性
以下で一つずつ説明していきます。あ、その前にPHP自体の脆弱性を防ぐためにPHPは利用できる最新版を利用するということで。
SQLインジェクション

- どんな脆弱性?
- 管理者側が実行する予定だったSQL文を終了させ、攻撃者側が用意したSQL文を挿入することで、例えばデータベースを破壊したりすることが出来る脆弱性
- どう対策する?
-
- SQL文を発行する際は準備済みのクエリ文 (prepared statement)を用いる*1 か、DBのAPI関数を用いて、全ての変数をエスケープする(例えばPHP::PDOならPDO::quoteを用いる。しかし、なぜ準備済みの文を使わないとエスケープが行われないんでしょうか?)
- エスケープで防ぎきれないインジェクションについては全ての変数を文字列として処理するという方法もありますが、これだと数値が数値として評価されなくなるので、is_numeric関数などを用いて型を検証するか、許可した文字だけ通るようにするとよいでしょう。
- 壊れた文字エンコーディングによっても攻撃が可能なので、エスケープをするだけでは不十分。入力文字列の文字エンコーディングが正しいものかチェックを忘れないこと。なお、これは他の脆弱性でも同じ。
- IPAによる解説
クロスサイトスクリプティング(略称:XSS)

- どんな脆弱性?
- 入力された文字列がHTML中に出力され、かつ文字参照に変換されない場合はHTMLとして解釈されるので、結果として任意のタグが出力可能(=任意の javascript が実行可能)になる脆弱性
- どう対策する?
-
- 全ての変数をエスケープする(htmlspecialchars関数)
- HTMLタグ中の属性値を
"(ダブルクオート)で適切に囲む(セキュリティ以前の問題ですが) - 文字エンコーディングが正しいものかチェックする。また
header("Content-type:text/html; charset=utf-8");などとしてサーバ側で文字エンコーディングを確定させておく
- IPAによる解説
クロスサイトリクエストフォージェリ(略称:CSRF)

- どんな脆弱性?
- あらかじめ特定のフォームにアクセスするように設定されたページをインラインフレームなどで読み込ませることで、掲示板に意図せず書き込ませることが出来るなど利用者の意図しないところでフォームが実行されてしまう脆弱性
- どう対策する?
-
- 第三者が知り得ない文字列(安全な乱数、詳細はこちらのご返信を書いたページを参照のこと)をフォームに埋め込んでおき、サーバ側で照合することで、リクエストが利用者の意図した動作かどうかをチェックする
- リファラをチェックし、空だったり、想定されうるページと違う場合エラーを返す(ただし、リファラは容易に偽装されうること、ファイアーウォールソフトなどでリファラが空になっている場合があることを考慮すること)
- IPAによる解説
ディレクトリトラバーサル

- どんな脆弱性?
- ファイルの要求にユーザ入力値が混じっている場合、例えば
../などの入力値を混ぜることで、サーバ上の任意のファイル(パスワードファイルなど)を閲覧できてしまう脆弱性。なお、ユーザ入力値を元にファイルを読み込んでいる場合、外部の攻撃用コードを読み込まれるリモートファイル・インクルード脆弱性も起こりうる - どう対策する?
-
- 避けられる場合は、ファイルの読み込みにCGI入力値を含めることを避ける
- そうでない場合は、まずパスを相対パスから絶対パスに変換 (realpath関数) し、そのパスが外部に公開してもよいフォルダかどうかを判定した上で、出力する(詳しくはウノウラボ Unoh Labs: いまさら、ディレクトリトラバーサルについて語ってみるを参照ください)
- IPAによる解説
サーバリソースを浪費させてしまう脆弱性

- どんな脆弱性?
- 異常な数値や異常に大きな外部データを読み込ませる(ループ回数を半無限に設定したり)ことで、処理を半無限に実行させることでサーバに大きな負荷をかける脆弱性(おさかなラボ – サニタイズ言うなキャンペーン参照のこと)
- どう対策する?
-
- 入力値が一定の範囲内に収まっているか検証する
- 外部のデータ(URL)を読み込む場合、上限のファイルサイズを常識的な値に設定する
- IPAによる解説
- IPA – 知っていますか?脆弱性:サービス運用妨害 (DoS)
いつ対策するか

大きく、処理の最初期に値を処理する方法と問題が起こりうるとき(出力時やSQL文発行時)に値を処理する方法があると思います。
処理の最初期に処理する
出力時に値を処理するという方法では、検証忘れによって抜け漏れが生じる可能性があり、脆弱性が生じる可能性を否定しきれません。ですので、入力値を処理の最初期で全てエスケープ(文字参照に変換)し、HTMLとして出力する場合のみ文字参照を展開するという方法があり、具体的な方法はXSSの脆弱性を限りなくなくす方法:to-Rで紹介されています。
一方でこの方法に問題がないわけではありません。例えば、サニタイズ言うなキャンペーン:おさかなラボではHTML出力やSQL文発行など脆弱性が発生しうる環境には様々なものがあることが挙げられており、SQL文の扱いなど単に入力値をエスケープしただけでは脆弱性を防げない事例も存在します。この方法は、入力時に処理を行ったことにより、出力時はあまり手をかけなくてよいと思いこんでしまう危険があり、結果として別の対策漏れを生んでしまう可能性があります。よって、この方法を用いるときは、単に文字エスケープしたことで安全と思いこむのではなく、本当に用いる環境で安全になっているかどうか確認する必要があります*2 。
また、リンク先でも言及されていますがscript要素やstyle要素内などのCDATA区間内に出力した場合は脆弱性につながりますし、$_REQUESTグローバル変数は変換されていないのでこちらも注意が必要です。
その他、「入力時に文字参照に変換するのがよろしくない理由」:水無月ばけらのえび日記では、エスケープされた文字は処理に手間取ることも指摘されており、結果として二重手間となるのであれば、問題が起こる場所で適宜処理する方法が良いのではないかと思います。
問題が起こりうる場所(出力時やクエリ発行時など)に処理する
ということで、次善の策としては、入力時にエスケープ処理を行うよりは、入力時に値の検証(ホワイトリスト方式を利用して必要な文字だけ許可するといったような)を行った上で、HTML出力時やSQLクエリ発行時など全ての問題が起こりうる箇所で対処するというのが次善の策となりそうです。もちろん、対処とはエスケープだけではなく(それでは防げない場合もあるので)環境に合わせた対策も併せて行っていく必要があります。
以下では、その方法について簡単なものではありますが具体的なコードを示しておこうと思います。
許可した文字のみを通過させる
以下は代表的な正規表現の例を示したものです。数字に関してはis_numeric関数を用いても良いと思います。
数字だけ許可する式
if (preg_match("/^([0-9]+)$/", $user_input, $match)){
$my_var = $match[1];
} else{
die("入力値は数字だけで構成してくださるようお願いいたします");
} 英数字だけ許可する式
if (preg_match("/^([a-zA-Z0-9]+)$/", $user_input, $match)){
$my_var = $match[1];
} else{
die("入力値は英数字だけで構成してくださるようお願いいたします");
} 英字だけ許可する式
if (preg_match("/^([a-zA-Z]+)$/", $user_input, $match)){
$my_var = $match[1];
} else{
die("入力値は英字だけで構成してくださるようお願いいたします");
} 緯度・経度だけ許可する式
// 百分率形式。できれば度分秒形式との変換も行っておくこと
if (preg_match("/^([0-9\.]+)$/", $user_input, $match)){
$my_var = $match[1];
} else{
die("緯度・経度の値がおかしいです");
} 【実践例】 URLで指定された範囲の価格に収まる商品データを取り出す例
// PDOによるデータベース(price,dataの2カラム構成)の接続と
// インスタンスの生成ができていると仮定
// 価格の範囲を設定
foreach (array("low", "high") as $range){
if (preg_match("/^([0-9+])$/", $_GET[$range], $match)){
$prices[$range] = $match[1];
}
}
// 数値かどうか検証
foreach ($prices as $price){
if (is_numeric($price) === false){
die("価格の設定がおかしいです");
}
}
// クエリを設定
$sql = "SELECT * FROM '{$dbName}' WHERE ".
"price > ".$db->quote($prices["low"])." AND ".
"price < ".$db->quote($prices["high"]);
// クエリを実行
try{
$request = $db->query($sql);
} catch(PDOException $exception){
die("エラー(クエリ発行時):".$exception->getMessage());
}
$i = 0;
// データを取得して配列に格納
while ($row = $request->fetch(PDO::FETCH_ASSOC)){
$results[$i]["data"] = $row["data"];
$results[$i]["price"] = $row["price"];
$i++;
}
return $results;
エスケープ出力をデフォルトにする
特別な場所だけhtmlspecialchars関数を使い、そうでない場所にはecho や print_rなどの関数を使うという方法では抜け漏れが生じてしまうので、デフォルトでエスケープしたラッパー関数を使うとよいでしょう(エスケープする場合はshow関数、HTMLタグを出力したい場合はshow_tag関数を用いる)
つきつめるならば(どうしても使い慣れたechoを使ってしまうので)echoやprint_r関数を禁止できればいいのですが、どうもその方法が見つかりません。レンタルサーバでは難しいかもしれませんが、PHP組み込み関数を上書きする方法もありますので、こちらを用いてもよいかもしれません*3 。
いずれにしろ、うっかり抜け漏れることをどう減らすかが焦点になりそうです。
function encode($str=""){
if (is_array($str)){
return array_map("encode", $str);
} elseif ($str){
return htmlspecialchars($str, ENT_QUOTES, "UTF-8");
} else{
return false;
}
}
function decode($str=""){
if (is_array($str)){
return array_map("decode", $str);
} elseif ($str){
return htmlspecialchars_decode($str, ENT_QUOTES, "UTF-8");
} else{
return false;
}
}
function show_dump($str=""){
if ($str){
var_dump(encode($str));
} else{
return false;
}
}
function show_tag($str=""){
if ($str){
print_r($str);
} else{
return false;
}
}
function show($str=""){
if ($str){
print_r(encode($str));
} else{
return false;
}
}
設定の確認と入力時検証を行う
resiger_globalsの設定は有効になっている時に脆弱性になり得ますので、その場合はスクリプトを停止するように設定しておきます。また、magic_quotes_gpcはエスケープが不完全なので、これも同様に処置することにします。
加えて、壊れたShift_JISの文字によって起こる問題を防ぐため、文字エンコーディングが壊れたものになっていないかチェックします(出典:gihyo.jp:なぜPHPアプリにセキュリティホールが多いのか?:【スクリプトインジェクション対策06】入力文字列の文字エンコーディングを検証する)
/*** コンストラクタ ***/
function __construct(){
$inis = array(
"register_globals",
"magic_quotes_gpc",
);
$user_inputs = array($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER, $_SESSION, $_ENV);
foreach ($inis as $ini){
if (ini_get($ini)){
trigger_error("このアプリケーションは".$ini."=onの環境では動作しません", E_USER_ERROR);
exit;
}
}
foreach ($user_inputs as $inputs){
if ($inputs){
parent::input_encoding_check($inputs);
}
}
}
/*** input_encoding_checkのコールバック関数 ***/
function input_encoding_check_cb($k, $v){
if (!mb_check_encoding($k, "utf-8") || !mb_check_encoding($v, "utf-8")){
trigger_error("不正な文字エンコーディングを検出しました");
die("System detected some errors");
}
}
/*** ユーザ入力値のチェック ***/
function input_encoding_check($v){
array_walk_recursive($v, array($this, "input_encoding_check_cb"));
}
allow_url_include,allow_url_fopenも脆弱性(リモートファイル・インクルード脆弱性)になりえますので無効にした方がよいのですが、こちらはユーザレベル(.htaccess)で無効に出来ないことも多いので、こちらは外しておきました。
なお、最初に挙げた2つの設定を無効にするには以下のような記述を.htaccessファイルに書いてください。
php_flag magic_quotes_gpc Off
php_flag register_globals Off参考にしたURL
- IPAのページ
- コーディングやセキュリティ施策に対する認識を批判した一連の記事
- なお、これについてはマイコミジャーナルの記事(の後半)や以下の記事を読む限り、高木さんは脆弱性対策以前に、安全に処理が進むために本来されるべき検証作業が、「サニタイズせよ」という言葉が大きくなることで認識されにくくなっていることを批判しているのだとおおまかに理解しています。
- 入力時エスケープ処理に対する方法と是非
- その他セキュリティ施策関連の記事
読者のコメントとご返信
「というわけでHTML出力時やSQLクエリ発行時など全ての問題が起こりうる箇所で対処するというのが次善の策」< 逆。入力でも何かする策の方が「次善」。IPAの「安全なウェブサイトの作り方」を読んでないと思われる。
はい、読んでないというか知らなかったので早速読ませていただきました。入力でも何かする策
というのはこちらでは具体的に値が想定されうる物かチェックする(ホワイトリスト)くらいしか思い浮かばないのですが、ほかにやるべき検証方法がありましたら教えてください。
高木先生も仰っているが、XSS対策に関しては出力時に処理するのが最善の策。最初期にやるのが次善の策。手前味噌で恐縮ですが、こちらも参考にどうぞ → 「http://www.e-3lab.com/security_guideline/」
ありがとうございます。IPAのもそうですが、私のより数段詳しくて信頼がおけますね。
「第三者が知り得ない文字列(ID・パスワードやワンタイムトークンなど)をフォームに埋め込んでおき」!? id/passをフォームに埋め込むってあんた!有害記事。
うーん、確かになんでこんなこと書いちゃったのか。ありがとうございます、ハッシュ関数を用いて擬似乱数化するよう訂正しました。
文字エンコーディングの検証もいれておいたいいかもset names問題。
PHPからSET NAMESを使わない方が良い理由と対策まとめ:twk @ ふらっとというページを見つけました。最初に行っている文字エンコーディングチェックで問題ないように思うのですが、不十分でしょうか。
入力時のエスケープはやめたほうがいいです。全てにおいて出力の直前にやるのが基本。
それは皆さんご指摘いただくことなのですが、決まって理由は一緒に書かれていなかったりする(探した中では水無月ばけらさんのページくらいしかみつかりませんでした、結論だけ一人歩きしているような感じすらします)ので、理由が書かれたURLを示していただけると助かります。 →だいたい、示した内容で理由として問題なさそうですね。
$_POST|GETだけじゃなくて、$_SESSION|COOKIEも組み合わせて話せばもっと良い鴨
ですね。追記しておきます。
第三者が知り得ない文字列(ユーザIDやワンタイムトークンなど)を ハッシュ関数(md5関数やsha1関数)を複数回用いて擬似乱数化< 安全な乱数を理解していない。/ わからないことは「わからない」と書いた方が良い。
お返事が長くなったので、はてなダイアリーに書きました。結論から言えば<?php $var = bin2hex(mhash(MHASH_SHA512, 'hoge hoge')); ?>で、だいたい安全な乱数が生成できるのではないかと。
まだサニタイズ脳の呪縛から解かれていないもよう。たとえば http://note.openvista.jp/?s=%3Cs%3E で「この検索結果の続きを見る」のリンク先がおかしくなるのはなぜか。
この記事を書いて勉強したので、これ以前に書いたコードのうちで正しくない箇所もありましょうが、それもまぁぼちぼち再点検していこうと思います。ありがとうございます。
更新履歴
この記事は、読者のフィードバックにより内容を大幅に加筆修正する可能性があるため、更新履歴を附記しておきます。
- 2008-10-28
- CSRF対策としての乱数生成過程を修正
- タイトルを修正
- 2008-10-27
- CSRFに関するトークン埋め込み対策に追記
- 問題が起こりうる場所での対策方法を修正
- 文字エンコーディングのチェック方法を示したコードを修正
- それぞれの脆弱性にIPAの解説ページを附記
- 読者の反応とそれに対する返信を追記
- 2008-10-26
- 初版公開
- 一般には「準備された文」と言われるみたいです。これを使う際は、DBのバインド機構を使うことになります(バインド機構はこちらで解説されてます。ただし、準備済みのクエリ文を用いてもクエリが安全でない環境があるので注意[戻る]
- 念のため。リンク先ではXSSを限りなく無くすことに言及しているのであって、他の脆弱性については何も言ってないわけですから、XSS対策においてリンク先に誤りがあるわけではないことを強調しておきます[戻る]
- echoは関数ではなく言語構造なので、変更できないようですが[戻る]
- キーワード:





読者のコメント
0件