Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsでセッションリプレイ攻撃を防ぐ方法(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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上に以下のデモアプリを用意しました。

ayushn21/session-replay-demo - 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ブランチが修正版です。

関連記事

保存版: Railsアプリケーションのセキュリティベストプラクティス(翻訳)

Railsアプリで実際にあった5つのセキュリティ問題と修正方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。