日記
PHPで安全なセッション管理を実現する方法Edit

なんかこの辺の情報って、ちゃんとまとまっているのを見たことがない気がするんで、まとめておく。特にPHPのセッションを標準設定で使った場合の問題とその対策について。ツッコミ歓迎。

まずセッションの仕組みの基本から。

Webアプリケーションは、通常ステートレス(=状態がない)である。ステートレスというのは何かというと、ユーザー(ブラウザ)が連続的に複数回のアクセス(Webページの表示)しても、サーバー側はそれを特定のユーザーの連続したアクセスと認識せず、単に誰ともしらない複数のユーザーが複数回アクセスしたものとして処理すること。

ステートレスの反対のステートフル(状態を持つ)ってのは、ユーザーがページA→ページB→ページCなんてアクセスをしたり、あるいはページBでフォームからデータを投稿したりしたときに、そのアクセス履歴やら入力したデータなどの情報(=状態)を保持した上で、次にそのユーザーがアクセスしたときに、そういう状態を持つユーザーからのアクセスであることをサーバーが(認識しようと思えば)認識できることを表す。

ステートレスなWebアプリケーションにステートを持たせるための機能が、いわゆるセッション管理機能。これは、

  • Webアプリケーションにアクセスするユーザーを特定する
  • そのユーザーに関する状態(情報)を、サーバー側で保持する

という二つの仕組みを組み合わせて実現している。

ユーザーを特定するための仕組みとしては、ユーザーに対して自動的に(通常初回アクセス時に)識別コードを割り振り、その識別コードを使って特定する。この識別コードのことをセッションIDという。ユーザー(ブラウザ)は、そのWebアプリケーションにアクセスするたびに毎回必ずセッションIDを送信する必要がある。そうしないと、サーバーは同一のユーザーだと識別できない。

セッションIDは通常Cookieとしてブラウザに記憶され、Webアプリケーション(というか、特定のドメインの特定のパス以下)にアクセスするときには、ブラウザが自動的に送信してくれる。Cookieに対応していないブラウザ(携帯とか)では、あらゆるリンクのQUERY_STRINGにセッションID情報をくっつけて送ったりする場合もある。

このセッションIDというのは、ユニークでありさえすれば機能的(ユーザーの識別)には問題ないんだけど、セキュリティまで考えると、ユニークであればなんでもいいという訳ではない。

たとえばセッションIDを、アクセスしてきた人に連番で割り振るような仕組みになっているとする。自分のセッションIDが100だったときに、試しにセッションIDを99とか98に書き換えてアクセスしてみたら、他人(=自分の直前にそのWebアプリケーションを利用した人)のセッションIDを使ってアクセスできてしまうかもしれない。

その場合、そのセッションIDに結びつけられた情報には、他人の個人情報などが含まれていたりして、そういうものが表示されてしまうかもしれない。あるいは、そういう他人の情報を書き換えてしまったり、あるいはその人の権限で何らかの処理を実行できてしまうかもしれない。

それを考えると、セッションIDは第三者が推測可能な内容であってはいけない。そのため一般的には、ランダム(=ロジックで推測されない)かつ十分に大きい(=可能性のある値を順次試す(=ブルートフォースアタック)ことで、実用的な時間内に正解に行き当たることがない)識別コードを生成して利用する。たとえば、マイクロ秒単位の現在時間+乱数発生器+サーバー固有の値+ハッシュ関数の組み合わせを使って、数十桁の16進数文字列を生成したり、とか。

ともかくそうやってセッションIDを使ってユーザーを識別できるようにしたら、そのセッションIDをキーにサーバー側で情報を保存する場所を用意する。どこに保存してもいいんだけど、PHPのデフォルトのセッション機能では、指定されたディレクトリ(/tmpとか)の下にセッションIDをファイル名の一部に持つファイルを作成し、その中にシリアライズ(バイト列表現に変換)されたPHPの配列を保存するようになっている。セッションハンドラーを変えれば、DBに保存したり、メモリキャッシュに保存したり、いろいろできる。

これで、

  • ユーザーがセッションIDをサーバーに送る
  • セッションIDにマッチするセッション保存ファイルを読み込む
  • セッション保存ファイルの内容を復元(unserialize)して、$_SESSION変数に入れる

といった形でPHPのセッション機能が実現されるようになる。

って、セッションの仕組みの基本を説明しているだけで、ずいぶん長くなったな。ここまでは前振り。次からが本論。

PHPのセッション機能は、セッション固定攻撃(session fixation)に対して脆弱だ。セッション固定攻撃っつーのは、攻撃者が用意したセッションIDを強制的に使わせることによって、本来推測不可能なはずのセッションIDの、推測(というかあらかじめ知ること)を可能にしてしまう方法。

PHPのセッション機能は通常、Cookie経由のセッションIDもQUERY_STRING経由のセッションIDも、どちらも認識できる(session.use_only_cookiesオプションでQUERY_STRING経由のセッションIDを認識しないようにできる)。また、セッションIDが外部から渡された場合、そのセッションIDに対応するセッションデータ(通常はファイル)が存在しない場合は、自動的にそのセッションIDに対応するセッションファイルを生成し、正常にセッションを開始してしまう。

つまり、あるユーザーがあるWebアプリケーションにアクセスする入り口のところで、何らかの方法(Cookieのドメインによる有効範囲をうまく利用したり、セッションID付きリンクを踏ませたり)でセッションIDを固定してしまえば、そのユーザーはそのアプリケーションで、既知のセッションIDを使ってセッションを開始してしまうことになる。

今のところPHPには、セッションデータが生成されていないセッションIDが渡されたときに、そのセッションIDを利用したセッションを有効にさせない、というようなオプションがない(パッチはある)ので、外部から渡されたセッションIDでセッションが開始されてしまうことは避けられない。

じゃあどうすればいいかというと、

  • セッション変数の内容を見て、その正当性を確認する
  • セッションIDを変更することによって、危険なセッションIDを無効にしてしまう

あたりの組み合わせが対策となる。たとえば、

session_start();
if (!isset($_SESSION['_SESSION_CHECK']) {
session_regenerate_id();
$_SESSION['_SESSION_CHECK'] = array(
'startTime' => time(),
);
}

などとする。

これで、もしも正しく初期化されていないセッションが開始された場合は、session_regenerate_id()関数を使って、新しいセッションIDを生成しなおすことによって、もしかしたら外部から渡されたのかもしれないセッションIDは使わないことになる。

また、次のようにすることで、より安全性を高めることもできる。

session_start();
if (
!isset($_SESSION['_SESSION_CHECK']) ||
($_SESSION['_SESSION_CHECK']['REMOTE_ADDR'] != $_SERVER['REMOTE_ADDR']))
{
session_regenerate_id();
$_SESSION['_SESSION_CHECK'] = array(
'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'],
'startTime' => time(),
);
}

これは、セッション変数に入っている(=セッション初期化時の)ユーザーのIPアドレスと、今回アクセスしたユーザーのIPアドレスがマッチしない場合も、同様にsession_regenerate_id()関数を使って新しいセッションIDが生成されるようにするコードだ。

ただし、複数のゲートウェイを持つネットワークからのアクセスなどの場合は、正当なユーザーでもアクセスのたびに別のIPアドレスが使われることもあるので、このような書き方はいつでも使えるわけではない。利用するユーザーの環境が限定できる場合のみ利用できる方法となる(あと、リバースプロキシとか使っている環境でもこれだと無意味)。

また、上記のような特定の(不正な可能性のある)条件にマッチした場合に限らず、たとえば、

session_start();
if (!isset($_SESSION['_SESSION_CHECK']) || (rand(0, 100) > 99)) {
session_regenerate_id();
$_SESSION['_SESSION_CHECK'] = array(
'startTime' => time(),
);
}

のように、ランダムに1%の確率でセッションIDの変更を行うという方法もありだろう。これによって、未知の(準備したロジックでは判別できない)攻撃に対しても、ある程度安全性を高めることができるようになる。

しかし、実際には上記のコードはあまり役に立たない。

というのは、session_regenerate_id()関数は、セッションIDは付け替えるが、古いセッションIDに結びつけられたセッションデータ自体はそのまま残してしまう(PHP 5.1.0以降ならば、session_regenerate_id(true)とすることで、古いセッションIDに結びつけられたデータを破棄してくれるので、以下の話は関係なくなる)。たとえば、元のセッションIDがxxxxxxで、session_regenerate_id()後のセッションIDがyyyyyyyだった場合、セッションデータファイルとしてはその両方が残ってしまう。

そのため、せっかくセッションIDを変更しても、古いセッションデータに有効な情報(たとえばあるユーザーのログイン権限と結びつけられた情報など)が含まれていた場合、せっかくセッションIDを付け替えても、古いセッションIDの方が悪用されてしまう可能性がある。

単純に、session_regenerate_id()でセッションIDを変更するだけで安全性が確保できるのは、あくまでもsession_regenerate_id()する前のセッションデータに、有効な情報が含まれていなかった場合のみなのだ。

そこで、

if (isLoginOk()) { // 認証が通った
session_regenerate_id();
$_SESSION['userId'] = [ユーザーID];
}

のような形で、ログイン処理の後など、セッション内に重要なデータを登録するタイミングで、session_regenerate_id()を実行してセッションIDを付け替えることによって、セッションの安全性を高めることになる。

たとえそれまで使っていたセッションIDが危険なものだったとしても、それには重要な情報は含まれておらず、重要な情報は必ずサーバー側で新しく生成したセッションIDに結びつけられることになるからだ。

しかし、未知の危険性に対策のために、すでにセッションデータに有効な情報が含まれている状態で、セッションIDを変更したい場合もあるだろう。その場合はどうすればいいだろうか。

セッションを継続する必要がないのならば、session_destroy()を実行することで、現在のセッションIDに結びつけられたセッションデータは破棄される。しかし、それでは新しいセッションIDで今までの情報を引き継ぐことができない。

そこで、次のようなコードを使うことになる。

session_start();
$tmp = $_SESSION;
session_destroy();
session_id(md5(uniqid(rand(), true)));
session_start();
$_SESSION = $tmp;

テンポラリ変数に現在のセッション変数を待避してから、現在のセッションデータを破棄し、新しいセッションIDをsession_regenerate_id()を使わずに独自のコードで生成してから、再びセッションを開始し、先ほどテンポラリ変数に待避してあったセッション変数を、新しいセッション変数にセットし直す、という方法だ(なぜsession_regenerate_id()ではなくsession_id(md5(uniqid(rand(), true)))を使っているかについてはコメント欄参照)

これで、すでにセッションデータに有効な情報が含まれている場合でも、セッションIDを変更して安全性を高めることができることになる。


何か問題がありそうな記述があったらツッコミください。修正しますんで。


2006/11/20 http://tdiary.ishinao.net/20061120.html#p01に追加情報を書きました

Published At2006-08-25 00:00Updated At2006-08-25 00:00