Railsでセッションリプレイ攻撃を防ぐ方法(翻訳)
Deviseのような既製品を使わずに、独自の認証システムを構築するのがよい理由はいろいろありますが、独自の認証システムには独自の落とし穴があります。やりがちなミスとしては、「ユーザーID」だけを認証手段に使ってしまい、そのせいでセッションリプレイ攻撃(session replay attack: セッション再生攻撃とも)に対して脆弱になってしまうというものがあります。
本記事では、セッションリプレイ攻撃が具体的にどのように行われるか、この攻撃が深刻な理由、およびアプリでこの攻撃を防ぐ方法について解説します。
🔗 典型的なログインメカニズム
Railsの認証システムでよく用いられるパターンは、SessionsController
を立ててそこにログイン用のcreate
アクションと、ログアウト用のdestroy
アクションを作成するというものです。通常、これらのアクションは以下のような形になります。
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
reset_session
session[:user_id] = user.id
redirect_to home_path
else
flash[:error] = "メールアドレスとパスワードのいずれかが無効です"
redirect_to login_path
end
end
def destroy
reset_session
flash[:info] = "ログアウトしました"
redirect_to root_path
end
原注
かの有名なRailsチュートリアル(Michael Hartl著)では、最初にあえて上述の手法を用い、その後の演習問題でこのセッションリプレイ攻撃の問題を解説するとともに、完全な修正方法についても説明しています。
user_id
は、データベースレコードの主キー(primary key)として使われることがほとんどなので、user_id
は永続的なエンティティです。しかも作成後に変更されることはありません。
それ以降のリクエストを認証するメソッドは、以下のようなものになります。
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
def authenticate
if current_user.nil?
flash[:error] = "このページを表示するにはログインが必要です"
redirect_to login_path
end
end
Railsのセッションは、CookieStore
を用いて暗号化済みcookieに保存されるのが普通なので、ユーザーが生データを読み出したり改ざんしたりすることはできないようになっています。
このuser_id
は、リクエストを認証するときの唯一のデータポイントであり、セッションcookieにはuser_id
が含まれているので、セッションcookieを使えばアプリに恒久的にアクセスできます。ユーザーはuser_id
の値を見ることはできませんが、アプリは暗号化された値を復号して読めるので、暗号化されていることは問題ではありません。
🔗 セッションリプレイ攻撃を実演する
セッションリプレイ攻撃では、攻撃者が何らかの形で標的ユーザーのセッションcookieにアクセスする必要があります。攻撃者がセッションcookieを奪い取るには、中間者攻撃や、標的ユーザーの物理マシンに直接アクセスするといった方法があります。どちらのシナリオもかなりレアケースではありますが、それでも可能性は残されています。セッションcookieはアプリにアクセスするときに恒久的に利用可能なので、この攻撃を防ぐ価値は間違いなくあります。
セッションリプレイ攻撃を実演するために、基本的な認証システムを備えたデモアプリが必要なので、GitHub上に以下のデモアプリを用意しました。
このアプリにあるのはサインアップ(=ユーザー登録)、ログイン、ログアウト機能だけなので、Railsに慣れていれば理解しやすいはずです。上記のサンプルコードはこのデモアプリから抜粋したものです。
デモアプリを実行し、ユーザーを作成してログインすると、ブラウザのDevToolsコンソールのStorage
タブにセッションcookieが作成されていることがわかります。セッションcookieは暗号化されているので中身を読んでも意味はわかりませんが、攻撃するうえでは問題ではありません。
暗号化済みセッションcookieは、どのブラウザでもDevToolsで表示可能
ここでは、攻撃者が既にこのセッションcookieを手に入れたという前提で始めるので、このcookieをテキストエディタにコピペしておく必要があります。これによって、今後そのアプリでセッションを「リプレイ(再生)」して特権情報にアクセス可能になります。
次に「Log out」をクリックします。これによってセッションcookieからuser_id
が削除され、root
パスにリダイレクトします。
この時点で上のスクリーンショットのようにユーザーのhome
パスにアクセスしてみると、ログインページにリダイレクトされます。これで、正常にログアウトしていることが証明されました。
認証されていないユーザーが/home
にアクセスしてもリダイレクトされる
ここで攻撃者は、先ほどテキストエディタにコピペしておいた値をセッションcookieに貼り付けて上書きします。そして再びhome
パスにアクセスしてみると、今度はリダイレクトされず、ログイン中と同様にユーザーのhome
ページが表示されてしまいます。
以前手に入れたセッションcookieを使うと、ログイン中と同じように保護ページにアクセス可能になる
このシナリオが恐ろしいのは、user_id
そのものを変更しない限り、攻撃者が今後も永続的にこのcookie値で標的ユーザーに成りすまし可能になる点です。user_id
の変更は望ましくないうえに、変更する方法がありません。先ほど証明したように、標的ユーザーがログアウトしたとしても、たとえパスワードを変更したとしても、ユーザー認証の唯一のデータポイントであるuser_id
には影響しないので、そうした方法でなりすましユーザーを追い出すことは不可能です。
同じ攻撃方法はcURLのリクエストでも可能なので、攻撃者はセッションcookieのみを用いて特権情報を抜き取るスクリプトをいとも簡単に作成できてしまいます。
curl "http://localhost:3000/home" \ -H 'Cookie: \_session\_replay\_demo\_session=UilsMmVIWNLxP942Thy4rsDVPRtA4rxQay3XY67KvsGb1L0rmj44msG74tybL3fCXQMvR6HZZRzEh2NuM4obAOiTfBTyJl4riaxOJoUnllemicERpqDX2VHz6N2hHlPnfXzhymloTMcEdFpp9ya44%2BJ3%2FlRcD28kBXz6rdRmnLOCd7V3NFU%2FKL1O7dciYpPUdO%2BYeWCqHo3d3Dy%2BIPb8mK8YjAieROV9W0fiUByqLinHu%2Ff4R3aT10FhmnnhA3fXkgnTvp75tLp2aOyvTOwKnZvaJeQDlrOoKTXxEHm98%2BySz2aLvWD8toL4ApHg1WVrfIk95A%3D%3D--2UyaUc6l2osxLVBK--RkADbyHYZk48p4o507jlcA%3D%3D'
<!DOCTYPE html>
<html>
<head>
<title>Session Replay Demo</title>
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="3Zf9qkAdJRKMm6C0Gh5OAj231fc+YYhXp4YhfFj8NEGXLmCepLmfymZpvrk75DVbwOvItEe4DdxvrzHpBn477A==" />
<link rel="stylesheet" media="all" href="/assets/application.debug-df863906fdbe195d0c50142af134379b3ef729118094b1fd531c9c61b0d8d74a.css" data-turbolinks-track="reload" />
<script src="/packs/js/application-0ac71d79a9cfb06b89c2.js" data-turbolinks-track="reload"></script>
</head>
<body>
<h1>Home</h1>
<h2>Ayush</h2>
<a rel="nofollow" data-method="delete" href="/logout">Log out</a>
</body>
</html>
上のcURLリクエストとレスポンスに示されているように、リダイレクトされずにユーザーのhome
ページが返されました。
セッションcookieがこのような形で攻撃に利用される可能性は比較的低い方です。中間者攻撃はSSL/TLSで防げますし、ユーザーがログイン中で、かつロックされていないコンピュータに攻撃者が物理的にアクセス可能になる状況はかなりレアですが、理論上の可能性はゼロではありません。
この問題は、たった数行のコードで修正できます。この攻撃を受ける可能性がどれほど小さくても、可能性はゼロでないのですから、この欠陥を修正しなくてよい理由はありません!
🔗 セッションリプレイ攻撃を防ぐ方法
既にある程度お話したように、この脆弱性の原因は、ユーザー認証にuser_id
という変更不可能なデータポイントだけを使っていたためです。この問題を修正するには、user_id
に加えてもう1つミュータブルなデータポイントを追加し、両方が有効な場合にのみユーザーを認証するようにします。
ここで追加するミュータブルなデータポイントは「セッショントークン(session token)」と呼ばれます。セッショントークンはセッションcookieと同様にデータベースに安全に保存されますが、値が永続的ではない点が異なります。ユーザー認証は、従来のuser_id
に加えて、この「セッショントークン」も有効な場合にのみ行うようにします。
もしセッションcookieが漏洩したら、セッショントークンをデータベースから削除してユーザーに再ログインを要求するだけで対応が完了します。攻撃者が手に入れたcookieに含まれているセッショントークンの値は古いままなので、攻撃者のアクセスは取り消されます。
セッショントークンの追加を実装するには、usersテーブルにsession_digest
カラムを追加して、Userモデルに以下のメソッドを追加します。
def log_in
session_token = SecureRandom.urlsafe_base64
update(session_digest: BCrypt::Password.create(session_token))
return session_token
end
def log_out
update(session_digest: nil)
end
def authenticated?(token)
return false if session_digest.nil?
BCrypt::Password.new(session_digest).is_password?(token)
end
上のコードを見ればおおよそ一目瞭然でしょう。
log_in
メソッドはセッショントークンを新規作成してデータベースに安全に保存してからセッショントークンを返します。
log_out
メソッドは、保存済みのセッションダイジェストをクリアします。
authenticated?
メソッドは、セッショントークンの値をデータベース内に保存されている値と照合します。
これを私たちの認証システムでも使えるようにする必要があります。最初に示したcreate
アクションとdestroy
アクションを以下のように変更します。
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
reset_session
session[:user_id] = user.id
session[:token] = user.log_in
redirect_to home_path
else
flash[:error] = "メールアドレスとパスワードのいずれかが無効です"
redirect_to login_path
end
end
def destroy
current_user&.log_out
reset_session
flash[:info] = "ログアウトしました"
redirect_to root_path
end
また、認証に用いられるcurrent_user
メソッドでは、user_id
の比較に加えてセッショントークンも比較する必要があります。
def current_user
if (user_id = session[:user_id])
user = User.find_by(id: user_id)
if user && user.authenticated?(session[:token])
@current_user ||= user
end
end
end
これでおしまいです!このアプリは、セッションcookieの漏洩などの攻撃に対して安全になりました。
🔗 論より証拠
上で実演したのと同じ攻撃を加えて、どうなるかを見てみましょう。
ユーザーがログアウトすれば、漏洩したcookieはもう有効ではなくなります
上の動画で示したように、ログイン中にセッションcookieを盗み出して保存し、ログアウト後に同じcookieを用いて保護されたhome
ページにアクセスしようとしても、同じ手はもう通用しなくなりました。
理由はもちろん、漏洩したcookieに保存されているセッショントークンは、ユーザーがログアウトしたときに削除されたので古くなっており、もはや有効ではなくなったからです。
🔗 まとめ
独自の認証システム構築は時に厄介なことになりますが、それと引き換えに得られる高い柔軟性には大きな価値があります。本記事がRailsの認証システム構築でよくある落とし穴を避けるのに役立てば幸いです。
本記事および修正は、Jason MellerによるRailsConfでの以下の発表にインスパイアされました。
本記事でデモに用いたアプリはGitHubリポジトリに置いてあります。masterブランチが脆弱版で、session-replay-fixedブランチが修正版です。
概要
原著者の許諾を得て翻訳・公開いたします。