PHPセキュリティ対策:定義域

(1/1)
PHPを使い、次の日曜日を求めるというカレンダー計算プログラムを作ってみることにする。

まず、組み込み関数  mktime  や  getdate  を使って作るのだが、数十年以上昔の日付や数十年以上未来の日付を入力すると、必ずエラーが発生する。これは日付関数の定義域エラーによるものだが、このようなエラーはセキュリティ・ホールに繋がる恐れがあるので、何らかの対策を施す必要がある。
日付に限らず、数学関数などには、かならず引数の定義域がある。入力データのバリデーションだけでなく、定義域をチェックすることも忘れないようにしよう。

(2022年6月27日)FastCGIで正常動作しない不具合を修正
(2022年5月22日)pahooInputData.php分離,pahooCalendarクラス利用,PHP8対応
年月日に「2008/8/25」などと入力して、「計算」ボタンをクリックしてみてほしい。
カレンダーと比較して正しい答えが計算されたろうか。では「1969/8/25」はどうだろうか。

目次

サンプル・プログラム(1)の実行例

PHPセキュリティ対策:定義域

サンプル・プログラム

圧縮ファイルの内容
nextsunday.phpサンプル・プログラム(1)本体。
nextsunday2.phpサンプル・プログラム(2)本体。
pahooInputData.phpデータ入力に関わる関数群。
使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
pahooCalendar.php暦計算クラス pahooCalendar。
暦計算クラスの使い方は「PHPで日出没・月出没・月齢・潮を計算」を参照。include_path が通ったディレクトリに配置すること。

解説:次の日曜日を計算する

0103: /**
0104:  * 次の日曜日を求める(UNIX TIMEで計算)
0105:  * @param   int $year, $month, $day基点年月日
0106:  * @return  int次の日曜日(UNIT TIME)/FALSE:定義域エラー
0107: */
0108: function getNextSunday($year$month$day) {
0109:     $thisday = @mktime(0, 0, 0, $month$day$year);
0110:     if ($thisday < 0)   return FALSE;
0111:     $arr = getdate($thisday);
0112:     $ww = $arr['wday'];              //指定日の曜日番号
0113: 
0114:     return $thisday + (7 - $ww) * 24 * 60 * 60;
0115: }

ユーザー関数 getNextSunday は、年月日を渡すと、その日から数えて次の日曜日を計算する関数である。

仕組みは簡単。
入力された年月日を関数  mktime  で UNIX TIME $thisday に変換する。
mktime は計算に失敗した場合は負数を返すので、$thisday が負数だったらエラーを返す。

次に、関数  getdate  を使い、その日の曜日番号を求める。曜日番号は 0=日曜日、1=月曜日‥‥6=土曜日なので、7から曜日番号を減じた結果を秒数に換算し、$thisday に加えれば、次の日曜日の UNIX TIME を計算することができる。

解説:入力エラーチェック

0186: //年月日に分解
0187: $arr = preg_split('/\//', $yyyymmdd);
0188: if (count($arr) == 0) {
0189:     $errmsg = '入力は 年/月/日 と指定してください';
0190: else if (count($arr) != 3) {
0191:     $errmsg = '年、月、日のいずれかが入力されていません';
0192: else if (checkdate($arr[1]$arr[2]$arr[0]) == FALSE) {
0193:     $errmsg = '入力した年月日は存在しません';
0194: //次の日曜日を求める
0195: else {
0196:     $sunday = getNextSunday($arr[0]$arr[1]$arr[2]);
0197:     if ($sunday == FALSE) {
0198:         $errmsg = 'この環境では入力した年月日を計算できません';
0199:     } else {
0200:         $res = '次の日曜日は ' . date('Y/m/d', $sunday) . ' です';
0201:     }
0202: }

HTMLフォームに入力された年月日を、関数  preg_split  を使って年、月、日に分解する。
ここで分解できなかったり、3つの数字にならないケースはエラーとして、変数 $msg にエラー・メッセージを設定する。

次に、うまく分解できたとしても、2008年2月31日のような異常な入力があるかもしれない。そこで、関数  checkdate  を使って年月日の整合性をチェックする。

以上のエラーチェックを通過した場合のみ、ユーザー関数 getNextSunday を適用する。

定義域エラー

サンプルプログラム "nextsunday.php" だが、年月日に「1969年8月25日」を入力して計算してみてほしい。本サーバではエラーとなってしまう。(エラーとならないサーバもある)

これは、 mktime  をはじめとする日付関数が、1970年1月1日を起点とする UNIX TIME(UNIX epoch)に起きる問題である。このため、1969年8月25日は負数となってしまう。⇒「国際原子時と協定世界時
この値は、1970年1月1日を原点として正確に負の数となっていればいいのだが、そうならない環境もある―― mktime  は仕様上、負の結果については保証していないので、一律にエラーとして扱う必要がある。

次に未来日を見ていこう。
年月日に「2038年1月1日」を入力して計算してみてほしい。正常に計算されるはずである。
では「2038年1月20日」はどうか――今度はエラーになってしまう。

これは「2038年問題」と呼ばれているエラーで、2038年1月19日にUNIX の time_t型(32bit長整数)が桁あふれを起こすのが原因である。
最近の UNIX/Linux では time_t型が64bitに拡張されており、PHP 5.xであれば64bit長のtime_t型に対応できる。
しかし、サーバOSの種類やPHPのバージョンによって2038年問題が起きるかどうかは不確定であるため、これも一律にエラーとして扱う必要がある。

とくに問題なのが、「2038年1月17日」を入力した場合である。
この日は関数  mktime  の定義域範囲内(2038年問題以前)なのだが、次の日曜日を求めるために日数を加算した結果、桁あふれを起こしてしまうのである。その結果、エラーは表示されないものの、異常な結果が表示される。
さらに、実行時エラー対策を施していない環境では、エラー発生箇所の行番号などが表示されてしまう。
悪意のあるアタッカーは、こうしたエラーメッセージを頼りに、スクリプトの脆弱性を突いてくる。このサンプルプログラムほど単純なものでは問題ないが、ある程度の規模のスクリプトになると、こうしたエラーがセキュリティホールになる恐れがある。何らかの対策を講じなければならない。

通日によるカレンダー計算

UNIX TIME は、1秒を1とする計算するので、1日なら8万6400、1年なら3155万7600、100年なら31億5576万という莫大な値になる。このため、32bit長整数でオーバーフローを起こしてしまうのである。
そこで、秒を基数にするのではなく、日を基数にしたらどうなるだろうか――1年は365、100年は3万6525――これなら16ビット長整数でもおさまる。

サンプル・プログラム(2)の実行例

PHPセキュリティ対策:定義域

サンプル・プログラム(2):ユリウス日版

紀元前4713年1月1日からの通日をあらわす「ユリウス日」(Julian Day)というものがある。これであれば人類の有史時代全体を網羅できるので、天文学や歴史学で重宝されている。

2番目のサンプルプログラムは、ユリウス日を使って「次の日曜日」を計算するものである。ユリウス日の計算などについては、「カレンダー計算 - PHPで祝日を求める」をご覧いただきたい。

解説:checkdateのユリウス日版

0155: /**
0156:  * グレゴリオ暦の年月日の妥当性を判定(checkdateの拡大版)
0157:  * @param   int $year  西暦年(A.D.0年以降、A.D.1万年未満)
0158:  * @param   int $month月
0159:  * @param   int $day   日
0160:  * @return  int日数/FALSE:引数の異常
0161: */
0162: function checkdate($year$month$day) {
0163:     $res = TRUE;
0164: 
0165:     //整数かどうか
0166:     if (!is_int($year|| !is_int($month|| !is_int($day)) {
0167:         $res = FALSE;
0168:     //西暦年の範囲
0169:     } else if ($year  < 0 || $year  > 9999) {
0170:         $res = FALSE;
0171:     //月の範囲
0172:     } else if ($month < 1 || $month > 12) {
0173:         $res = FALSE;
0174:     //日の判定
0175:     } else if (($day < 1) || ($day > $this->getDaysInMonth($year$month))) {
0176:         $res = FALSE;
0177:     }
0178: 
0179:     return $res;
0180: }

西暦年月日(グレゴリオ暦)が正しいかどうかを判断する関数  checkdate  のユリウス日版が、pahooCalendarクラスのメソッド mycheckdate である。

ここで、西暦0年未満、1万年以上はエラーとしている。
ユリウス日にしても、32ビット長整数で計算している限り、西暦1175万4266年にオーバーフローを起こしてしまう。天文学の世界ではあり得ない年数ではないので、エラーチェックをしておく必要がある。

解説:getNextSundayのユリウス日版

0106: /**
0107:  * 次の日曜日を求める(JulianDayで計算)
0108:  * @param   int $year, $month, $day基点年月日
0109:  * @return  int次の日曜日(UNIT TIME)/FALSE:定義域エラー
0110: */
0111: function getNextSunday($year$month$day) {
0112:     //pahooCalendarクラス
0113:     $pcl = new pahooCalendar();
0114:     $pcl->setLanguage('jp');
0115: 
0116:     $thisday = $pcl->Gregorian2JD($year$month$day);
0117: 
0118:     if ($thisday < 0)   return FALSE;
0119:     $ww = $pcl->getWeekNumber($year$month$day);       //指定日の曜日番号
0120: 
0121:     //オブジェクト解放
0122:     $pcl = NULL;
0123: 
0124:     return $thisday + (7 - $ww);
0125: }

同様に、getNextSunday のユリウス日版を用意した。

その他の解決策

--enable-calendar オプションでコンパイルされている PHP であれば、ユリウス日関係の関数のカレンダー関数が用意されている。

PEAR が利用できる環境であれば、PEAR::Date を使ってカレンダー計算を行うことができる。

参考サイト

(この項おわり)
header