2013年10月21日月曜日

PHPのsetcookie関数で空文字列を設定しようとするとクッキーが削除される

PHPでスクリプトを書いていて、setcookieの第2パラメータ(クッキーの値)の変数をタイプミスしたところ、以下のレスポンスヘッダが送信されていました。
setcookie('A', $misspelled_variable);
 ↓ 結果
Set-Cookie: A=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT
日付が大昔になっているし、クッキーの値に「deleted」は指定していません。これは、クッキーを削除する時の書き方ですが、PHPでクッキーの削除というと、expiresに過去日付を明示する方法をよく見かけますが、単に第2パラメータを空文字列にすればよかったのか…と思いマニュアルを見たら、一応書いてありました。
http://php.net/manual/ja/function.setcookie.php
陥りやすい失敗
クッキーは設定されたものと同じパラメータで削除する必要があります。 値が空文字列あるいは FALSE で、その他の全ての引数が前に setcookie をコールした時と同じである場合に、指定された名前のクッキーが リモートクライアント上から削除されます。 内部的な動作として、これは値を 'deleted' に変更した上で有効期限を 1 年前に設定しています。
「一応」と保留したのは、マニュアルの本文ではなく「陥りやすい失敗」という項に書いてあったからです。それに、マニュアルには「1年前」とありますが、実際に設定される日付はずっと古い日付です。
そう思って、PHP5.2.6で試したら、以下のように、1年前の日付になります。
Set-Cookie: A=deleted; expires=Sat, 20-Oct-2012 07:17:38 GMT
どこかで仕様変更されたのだろうと思い、バイナリサーチで調べたところ、PHP5.3.6までは「1年前」、PHP5.3.7以降が1970年1月1日に変更されていました。

ソース上で確認すると、ext/standard/head.cのphp_setcookie関数で以下のように修正されていました。
// PHP5.3.6
if (value && value_len == 0) {
  time_t t = time(NULL) - 31536001;
  dt = php_format_date("D, d-M-Y H:i:s T", sizeof("D, d-M-Y H:i:s T")-1, t, 0 TSRMLS_CC);
  snprintf(cookie, len + 100, "Set-Cookie: %s=deleted; expires=%s", name, dt);
  efree(dt);
} else {


// PHP5.3.7
if (value && value_len == 0) {
  dt = php_format_date("D, d-M-Y H:i:s T", sizeof("D, d-M-Y H:i:s T")-1, 1, 0 TSRMLS_CC);
  snprintf(cookie, len + 100, "Set-Cookie: %s=deleted; expires=%s", name, dt);
  efree(dt);
} else {
この変更はChangeLogにも載っていないので変な感じですが、クッキーを削除したい場合はexpiresをできるだけ古い時刻にするという考え方は妥当です。そうしないと、端末の時計が狂っている場合に、クッキーが削除されずに、「PHPSESSID=deleted」という形に設定される危険性が高くなります。これが複数ユーザ存在すると、一種のセッション固定の状態になります。PHP5.5.2以降で導入されたstrict sessionsを設定していれば大丈夫ですが、まだstrict sessionsを設定しているサイトはごくわずかでしょう。

時計をあわせていない利用者の方が悪いという方もいるでしょうが、例えばこちらの事例のように、携帯機器のブラウザが「4年前の日付」になっている例もあるので、1年前くらいでは確かに不安です。「1時間前」の時刻をセットしてクッキーを削除する例をよく見ますが、端末の時計が狂っている前提では、意図に反して脆弱になる可能性があります。

私は「ログアウト時にセッションクッキーを削除する必要はない」という意見を持っています(参考:「ログアウト機能の目的と実現方法」)が、仮に削除するのであれば、端末の時刻が狂っている場合を想定して、削除時のクッキーの値には暗号論的に安全な乱数(openssl_random_pseudo_bytes関数か、/dev/urandomが利用できます)を設定しておくのがよいと思います。そうすれば、万一クッキーが削除されず設定された場合でも、脆弱性にはなりません。

2013年10月15日火曜日

session_regenerate_id関数の第1引数はtrueにすべきか

以前tumblrに書いたエントリ「データベースのデータを信用してはいけないか?」にて、PHP技術者認定試験の想定問題について取り上げましたが、その後、書籍「徹底攻略 PHP5 技術者認定 [上級] 試験問題集 [PJ0-200]対応」が刊行されたことを知り、購入しました。
同試験は、比較セキュリティの配点が高い(12%)ことから、試験問題集にはセキュリティの独立した章として第10章が割り当てられ、セキュリティの問題が21個集められています。

先のエントリで紹介した「ITトレメ PHP技術者認定・初級 過去問題一覧 - @IT自分戦略研究所」の問題を見た時の印象は、問題の癖が強く、独自の用語を使っている箇所が多いことが懸念点でしたので、そのような観点から同書第10章「セキュリティ」の問題を確認したところ、全体的に下記の印象を持ちました。
  • 用語としてIPA等で使われている一般的なもの(例:静的プレースホルダ)が用いられ、妥当と考えられる
  • 対策手法についても、IPA「安全なウェブサイトの作り方」などで説明されている方法を正とし、独自の手法はほとんど見られない
  • 「上級試験」ということで、かなり際どいところを攻めているという印象
ということで、私が懸念した問題は見当たりませんでした。執筆陣の努力を称賛したいと考えます。
しかし、最後に指摘した「際どいところ」に関しては、正誤という点では問題ない(力のある解答者なら正当に到達できる)ものの、微妙なところで議論の余地があるなと感じました。(こちらの「大、小、展、外、誤」参照)。
それは、国語入試問題必勝法的な微妙さであって、明らかに正しい選択肢を載せるとすぐに答えがわかるために、微妙な問題を残すものを正答としているのではないかと勘ぐりたくなりました。
ここでは、その例として、セッション固定化攻撃の対策方法として、session_regenerate_id()関数の使い方に関する問題を取り上げます。

session_regenerate_idの第1引数はどうすべきか

同書P324のセキュリティ問題13には、セッション固定化攻撃の対策として適切なものを2つ選択するように支持していますが、ここではその選択肢CとDに関係する話題です。元の本が問題集なので、引用はせずに要旨のみを示します。
if (認証OKの場合) {
  $_SESSION['auth'] = true;
  session_regenerate_id( ● );
}
●のところ、片方が空、片方は true となっています。実は「国語入試問題必勝法」的には正答はあきらかで、trueを指定した方が正答です。この関数の第1引数は、元のセッションIDに紐づくセッションを破棄することを指定するもので、過去のセッションを破棄したほうが安全方向に倒れることは明らかです。すなわち、これがセキュリティの問題という前提では、trueを指定しないほうが正答ということは通常ありえないわけで、先に「正誤という点では問題ない」と書いた理由はこれです。
しかし、上記のスクリプトを検討すると、微妙な問題、いや、はっきり言えば正答にもバグがあります。一方で、誤答の方にも実質的な危険性があるわけではありません。

誤答がただちに危険なわけではない

問題文のスクリプトは、まずセッションに「認証状態にある」ことをセットしたあとで、セッションIDの再生成を行っています。このため、問題の趣旨としては、元のセッションIDに対して認証状態がいったんセットされるため、古いセッションを破棄しないとセッション固定化攻撃が成立してしまうとしています。
しかし、このスクリプトを動かしてみると分かりますが、古いセッションを保存するファイルには、認証情報は保存されません。その理由は、session_write_closeが呼ばれていないからです。ファイルに認証情報が保存されていない以上、そのファイルを積極的に破棄する必要もありません。例外として、「ログイン前セッション固定化攻撃」の問題がありますが、これについて別のエントリにしたいと思います。
ということで、誤答(trueを指定しない方)に実質的な危険性があるわけではありません。

正答が模範的なわけではない

一方、正答に問題がないわけではありません。現状のスクリプトでも外部から攻撃できるわけではありませんが、PHPのセッションが仮にsession_write_closeを呼ばなくてもファイルに書き込まれる実装に変更された場合は、一瞬とはいえ元のセッションIDで認証状態になります。これは直ちに、session_regenerate_id関数の第1引数 true により削除されるわけですが、いわゆるレースコンディションの状態となり、第三者にセッションハイジャックされる危険性があります。これらを下表にまとめました。

第1引数falseあるいは指定なしtrue
現状のPHPの実装問題なし問題なし
セッションが直ちにファイルに書き込まれる実装セッションハイジャックされるレースコンディションによりセッションハイジャックされる可能性

本来は、認証確認後に直ちにsession_regenerate_idすべし

前述のように、この問題は現状のPHPの仕様(実装)では誤答・正答とも実質的な問題はありませんが、PHPの仕様が変わると、正答の方がベターな書き方であるものの、ベストではない、ということになります。
では、ベストの書き方はどうかというと、下記のように、session_regenerate_idしてから認証状態をセッションにセットすべきです。
if (認証OKの場合) {
  session_regenerate_id( ● );
  $_SESSION['auth'] = true;
}
これにより、一瞬たりとも元のセッションIDが認証状態になることはないので安全です。そして、●の部分は、それ以前のセッションをどうすべきかで判断することになります。これはすなわち、「ログイン前セッション固定化攻撃」の対策をどうすべきかということになりますが、こちらは一層ややこしい問題であるので、別稿にて説明します。一般的には true を指定した方が安全ということには、異論ありません。

まとめ

PHP5 技術者認定 [上級] 試験問題集を題材として、session_regenerate_id関数の第1引数について検討しました。これをtrueにすることは、保険的な対策としては有効であると私も考えますが、trueを指定しないために脆弱性となる例を作るのはかなり難しいと考えます。
PHP5 技術者認定 [上級] 試験問題集の第10章13番の問題は、「trueを指定しないために脆弱となる」例を無理に作ろうとしたために、前述したように「際どい」問題になってしまいました。それにより、正答が模範的なスクリプトでないため、この問題集により「正しいPHPアプリの書き方が身につくわけではない」という点も気になったところです。(たぶん続く)

フォロワー