本日は、ASP.NETでログイン機能をつくる際のセッション固定対策について書きます。
ログイン状態の管理には、ASP.NETが提供するセッション機構(ASP.NET_SessionId Cookie)を使っているとします。
ASP.NETでのセッション再生成
ログイン機能のセッション固定対策は、ログイン時に新たなセッションを開始することです。既存のセッションがなければ新たにセッションを開始し、既存のセッションがあるならばそのセッションは再生成されなければなりません。
しかし、ASP.NETはセッションを再生成する方法を提供していません。
それはJavaも同じなのですが、TomcatだとHttpSession#invalidateでセッションを無効化することで、セッションを再生成することができます*1。
ASP.NETでも普通に考えると、Session.Abandonという同等のメソッドを利用することでセッションを再生成できそうだと考えてしまいますが、Session.Abandonには以下のような問題があります。
ところで、Abandonメソッドのドキュメントをよく読むと、次の記述がある。
Abandonメソッドで破棄したセッションオブジェクトに代わる、次のセッションオブジェクトが生成されるのは、Abandonメソッドを呼び出したHTTPリクエストの、次回のHTTPリクエスト時だというのだ。 ASP.NETで「ログイン成功後に新しいセッションを開始」は可能なんだろうか? - atsukanrockのブログ
Abandon メソッドを呼び出すと、現在のセッションが無効になり、新しいセッションを開始できます。Abandon により End イベントが発生します。次の要求に対して Start イベントが発生します。
引用元の「熱燗ロックのブログ」が問題を判り易く解説していますが、私なりにまとめると、
- ログイン成功時にSession.Abandonを呼ぶとセッションが無効化されるが、新しいセッションは次のリクエストを発行するまで開始されない。
- そのためログイン成功時にセッション変数にユーザID等を保存して、それを次のページに引き継ぐことができない。
ですので、次のページに安全に(改竄されない形で)ユーザID等を渡すにはどのようにすればよいのか?という問題が生じます。
安全に(改竄されない形で)データを渡す
改竄防止を意図すると、ログイン成功時に以下のような応答を返す方法が考えられます。
<form action="SessionRegenerate.aspx" method="post"> <input type="hidden" name="UserID" value="(UserID)"> <input type="hidden" name="MAC" value="(改竄を防止するためのMAC)"> <input type="submit" value="次へ"> </form>
この画面で、ユーザが「次へ」ボタンをクリック(もしくはJavaScriptで自動サブミット)すると、UserIDが改竄防止のためのMACとともにSessionRegenerate.aspxに渡されます。SessionRegenerate.aspxではMACを検証して、改竄がされていなければセッション変数にユーザIDを格納する処理を行います。
// SessionRegenerate.aspx String UserID = Request.Form["UserID"]; String MAC = Request.Form["MAC"]; If (checkMAC(UserID, MAC)) { Session["UserID"] = UserID; Response.Redirect("MemberTop.aspx"); }
なお、ASP.NETには改竄対策が施されたViewStateやFormsAuthenticationTicketといった仕組みがあるため、実際にはMACを独自実装する必要はありません。上のコードでMACを使っているのは、単にその方が説明がしやすいという理由です。
Session Adoption
しかし、このMAC(あるいはViewState等)を使う方法はうまく機能しません。そもそもの話としてこの通りに実装するとセッション変数はクリアされるものの、セッションIDが変更されないのです。
実はASP.NETは無効になったセッションIDであっても再利用してしまう"癖"があります。そのためSessionRegenerate.aspxは、その前のログイン処理でAbandonされたセッションIDの値を変更することなく使い続けます。
この癖に対処するためには、ログイン処理でCookie(ASP.NET_SessionID)を消してサラな状態にしてから、SessionRegenerate.aspxに移動しなければなりません。
// Login.aspx If (checkPassword(UserID, Password)) { Session.Abandon(); // Cookieを消す HttpCookie c = new HttpCookie("ASP.NET_SessionId", ""); Response.Cookies.Add(c); }
なんとも汚い感じでいやなのですが、この辺りはどうも仕様のようで、Microsoftのサポートページ()でも同じ方法が紹介されています。
なお、ASP.NETが無効になったセッションIDを再利用するという事実は、ASP.NETにSession Adoptionの問題があることを示唆しています。実際のところ、一定の長さや文字種類の条件を満たすセッションIDであればなんでもASP.NETは受け入れてしまいます*2。
この方法の問題点
Cookieを消すコードをLogin.aspxに追加することで、ようやくログイン時にセッションが再生成されるようになります。一見するとこれで所期の目的を達成したかのようですが、実はまだ問題があってセッション固定攻撃を成功させることができます。
攻撃のシナリオは2つあります。
シナリオ1
1つ目のシナリオでは「消えないCookie」を使います。
// Login.aspx HttpCookie c = new HttpCookie("ASP.NET_SessionId", ""); Response.Cookies.Add(c);
これは、上のアプリのログイン処理(Login.aspx)で使われているCookieを消すコードです。これにより、下のSet-Cookieヘッダが返されます。
Set-Cookie: ASP.NET_SessionId=; path=/
攻撃者は、このコードでは消えないようなCookieを被害者のブラウザに植えつけておきます。
Cookieを消させない方法の1つ目は、Cookieのスコープを利用するものです。Cookieを削除するには、発行された時と同じドメインやパスを指定しなければなりません。しかし、ブラウザから送られるCookieのドメインやパスをサーバ側で知る方法はなく、決め打ちして削除を試みるしかありません。ですので、うまいことドメインやパスを操作したCookieを被害者のブラウザに植えつけておけば、Cookieは消されなくなります。
攻撃者のCookieが消えずに、被害者のUserIDとそのMAC(POSTパラメータ)とともにSessionRegenerate.aspxに送られれば、SessionRegenerate.aspxは攻撃者のセッションIDを被害者のアカウントでログインした状態に変えてしまいます。
2つ目の方法は、事前に被害者に植えつけておくCookieの名前や値を操作するものです。例えば「asp.net_sessionid」のような小文字のCookieを被害者のブラウザにセットします。ブラウザはCookieの大文字・小文字を区別するため、このCookieは上のSet-Cookieヘッダによって削除されません。一方でASP.NETは基本的にCookieの大文字・小文字を区別しないため、小文字でもセッションIDとして受け入れてしまいます((Set-Cookie: a="1;ASP.NET_SessionId=qftslj3ooxlf5j552jly2p55;b=1"; path=/
のようなCookieを植えつけておく手もあります。ブラウザ(Firefox)にとっては、これはaという名前の1つのCookieですが、Cookieヘッダでこれを受け取ったASP.NETは3つのCookieであると解釈します。))。
シナリオ2
2つ目のシナリオはタイミングを利用します。
Login.aspxの応答のSet-CookieヘッダによってCookieが消されてから、SessionRegenerate.aspxにアクセスするまでの間には時間があります。この間に、被害者のブラウザにセッションID Cookieを植えつけます。
これは針の穴を通すような攻撃ですが、成功する可能性はゼロではありません。
Cookieが無いことを確認する
上の攻撃シナリオ1,2に対処するために、ログイン成功直後にアクセスするSessionRegenerate.aspxを以下のように変更します。
// SessionRegenerate.aspx // リクエストにCookieがない && MACが正しいことを確認する If (Request.Headers["Cookie"] == null && checkMAC(UserID, MAC)) { Session["UserID"] = UserID; Response.Redirect("MemberTop.aspx"); }
何らかのCookieが付いている場合にはログイン処理(セッション変数にユーザIDを格納する処理)をさせないようにしています。
これにより、いかなるCookieも持たないサラな状態でSessionRegenerate.aspxにアクセスすることが保証されます。サラな状態でアクセスした場合には、常に新しいセッションが開始されます。
ところで、この方法だとASP.NET_SessionId以外のCookieも禁止されてしまうという弊害があります。本当はASP.NET_SessionIdだけを禁止できればよいのですが、ASP.NETのHttpCookieCollection(Request.Cookies)はそのようなことができないように作られています。
Cookie のコレクションは常に ASP.NET_SessionId の値を持っているため、Cookie が存在するかどうかを単にテストすることはできません。
つまり、例えばFooという名前のCookieがリクエストに存在するかは「Request.Cookies["Foo"]
」がnullか調べることで確認できますが、「ASP.NET_SessionId」については同じ方法で確認することができません。
シンプルにする
上のプログラムでは、ユーザIDとパスワードを受け取った後に、MAC(もしくはViewStateなど)を生成して次のページに渡していますが、よくよく考えてみるとそのような面倒なことは不要であることが判ります。
つまり、以下のような処理をすれば、ログイン成功のタイミングでセッションが新たに生成されます。
1. Session.AbandonとCookieを消す処理をしてログインフォームをユーザに返す
2. ログイン処理(ユーザIDとパスワードを受け取り検証する処理)で、
2-1. Cookieが無い && パスワードが正しければ、セッション変数にユーザIDを入れる
2-2. そうでなければ1の処理を行う
要は、ASP.NETはSession.Abandonした次のリクエストでセッションを再生成するため、セッションを再生成させたいリクエストよりも1つ手前でSession.Abandonしてやるということです*3。
なお注意が必要なのは、これはASP.NETに限ったある種のハックです。PHPやJava(Tomcat)のようにセッションをアトミックに(単一のリクエスト・レスポンスで)再生成する機能を持つプラットフォームを使っているのならば、ログイン成功時にセッションを再生成すれば済む話です。
とりあえずのまとめ
以上、ASP.NETでセッションを再生成する方法について書きました。
まとめると、ASP.NETはまともにセッションを再生成する機能を提供していません。そのため、ASP.NET_SessionIdを使ったログイン状態管理を行う場合には、ダーティーでなおかつ制約のある方法でしかセッション固定対策はできないという結論です。
したがって、基本的にはASP.NETのセッション機構を使用してログイン状態を管理する方式はお勧めできません。
それではどうすればよいかというと、ASP.NETが提供するフォーム認証機構であるFormsAuthenticationをログイン管理に使います。Microsoftも、通常のセッションではなくこれを使うべしと考えています(多分)。
FormsAuthenticationは、通常のセッションID(ASP.NET_SessionId)とは別の認証チケット(Cookie)をログイン成功時に発行し、この認証チケットによってログイン後のユーザの識別を行う仕組みです。これらの処理はアプリ側の作りこみをほとんどせずに利用できます。この仕組みを使うならば、セッション固定によるなりすましを避けるために、無理やりにセッションIDを変更する必要もありません。
私自身はFormsAuthenticationは自由が利かずに使いづらいと思っていましたが、よくよく調べてみるとそうでもありませんでした。IDとパスワードの検証は自作のメソッドで行って、認証チケットの発行とそのデコードの仕組みだけをアプリから利用することもできますし、Cookieが使えないデバイスにも対応できるようになっています。
ただし、FormsAuthenticationを使えばセッションに関わる問題が全て解決するかというと、実はそうではありません。セッションIDと認証チケットを分離しているアプリの注意点については、またあらためて書きたいと思います。
補足:Cookieの固定化
今日の日記は、他人のブラウザに対してCookieを固定化できることを前提として書いています。
Cookieの固定化はブラウザ側(FirefoxやSafari)でCookie Monster対策が進められてきており、以前よりもリスクは減っていると思います。
とはいっても、少なくともHTTPSを使うサイトや地域型JPドメインを使うサイトでは、現実的な問題ととらえて対処すべきだと思います。
その理由について少し補足すると、まずHTTPSのサイトは攻撃者が通信経路にいても安全であることが期待されます。仮に攻撃者が通信経路にいるならば、対象サイトへのHTTPの(HTTPSではない)リクエストを被害者のブラウザに発行させて、Set-Cookieヘッダを付けたレスポンスを勝手に返すことで、被害者のブラウザに好きなCookieを植えつけることができます。
また地域型JPドメインについては、IEはバージョン8になった現在でもtokyo.jp等ドメインへのCookieの発行を許すCookie Monsterバグを持っています。
補足:Microsoftへの機能追加要望
本日の日記に書いた、ASP.NETでまともにセッション再生成ができない問題は以前から知られています。Microsoft Connectには、セッション再生成機能を追加する要望が出されてもいます。
参考:Microsoft Connect is Retired - Collaborate | Microsoft Docs
ですが、このページを見る限りMicrosoftに機能追加の意思はないようです。「ログインではFormsAuthenticationを使え、フォームではViewStateを使え」ということなのかもしれませんが、それならばセッションは何のためにあるんだ?という疑問がわいてきます。
Microsoft Connectには、機能追加やバグ修正要望に対して、他人が賛成・反対票を投じる機能がありますので、この機能追加要望に賛成票を投じておきました。
*1:私自身は検証していませんが、JBossではセッションIDの値が変わらない実装がされているとの話もあります。
afongen » Generate new session ID in Java EE?
*2:ただし、AdoptionされるのはCookieを使う場合だけで、URLにセッションIDを埋め込む方式を選択した場合には、無効なセッションIDを受け入れない設定であるregenerateExpiredSessionIdが有効になります。このあたりは、HttpSessionState.IsCookieless Property (System.Web.SessionState) | Microsoft Docsで背景を含めて説明されています。
*3:ただしURLにセッションIDを埋め込む方式の場合、この手は使えません。Session.Abandonした次のリクエストでは新たなセッションIDを埋め込んだURLに302でリダイレクトさせる応答が返るため、POSTで送ったユーザIDとパスワードの情報が失われるためです。