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

Rails: sessionをブラウザに保存されるCookieの値から読み取ってみる

ebiです。最近はRails記事ネタが安定供給できてると見せかけて、実はギリギリまで記事仕上げてないせいもあって、サムネイル作成の手間を減らすためにRailsネタに寄せに行ってるのはここだけの話です。

今回やりたいこと

最近、Railsアプリのいくつかの改修の中でRails標準のセッションやRedisキャッシュストアに保存された値の実態を確認しておこうと思った機会があったのでそう言う試行の備忘録を兼ねた記事です。

さて、セッションと言っても文脈によって様々な意味合い、範囲を指してしまいそうですが、
この記事中で取り扱うのはRailsのコントローラ内の実装session[:current_user_id] などで取り扱うセッション、
あるいは config.session_store で設定されるセッションストア内に保存されているセッションのことを指すものとします。

何をやりたかったかと言うと、普段何気なく利用している session[:hoge] = "hugahuga" の様なセッション、あるいはDeviseのようなGem内の実装であまり意識せずに利用しているセッションについて、具体的にはどのように管理されているのか、クライアントのブラウザ側に保存されたCookieの値から読み取ることで体感してみようと言うお話でございます。

また、今回記事専用のRailsアプリを準備する時間的余裕が無かったので、以降の話は手元にあった適当なRails 8.0.1 / Ruby 3.4.5環境のとあるRailsプロジェクトを元に必要な情報だけ掻い摘んでお届けします。予めご了承ください。

ActionDispatch::Session::CookieStore のsessionを読み取る

先にネタバレと言うか、重複した内容が含まれますので、別の視点から書かれた記事なのですが馬場さんの記事を紹介しておきます。

secret_key_baseが漏れると何が起きるのか実際に試してみた

この馬場さんの記事では secret_key_base の値の流出をきっかけにセッションの改ざん、なりすましが行えることを確かめるのをゴールにしてました。
今回はCookieの値からセッションとして保存している値の読み取りまでをゴールにしつつも、もちろん読み取るためには secret_key_base が分かっている前提にはなります。
そんなわけで馬場さんの記事の方で紹介してる導出はサラッと流していきます。

使用するRailsアプリの config.session_store はこの状態です。
rails new をしたままで、特に config.session_store 設定が無い場合もデフォルトの振る舞いでこの状態になってるはずです。

app(dev)> Rails.application.config.session_store
=> ActionDispatch::Session::CookieStore

適当なセッションデータを書き込んでみていざ確認

次のようなコントローラの処理を定義してsession利用してみます。

class SessionTestsController < ActionController::Base
  def show
    session[:secret] = "内緒だよ"
    flash[:notice] = "フラッシュもセッション管理なんだよね?"
    render plain: "ここはsessionのテスト用ページさ"
  end
end

この処理を行うURLにアクセスした時のレスポンスの内容を確認してみると……

set-cookie: _app_session=m9UfAAE0274HBvMKYLgqmbtILzZIfsjzgaIABkEZokwzZXNLiTFOVhka1yZ7rClUmMQDdrj%2BRWQsxdotmroIfEwb1nb3kSM3rpl9qpQDJLCPvzxiDp8lJ4EdZ9IAh8y92Os5kbzPpe%2FQIi3fqHBDAbHb8P2yj4pAKSHWcyVWO1ZflcPVSvsY9f6sYmAzt%2Beq5WVlVT%2F0n8QEFd8COv%2BMedhQyetwArnNDXz6WFlwaHcxkvBgr5Aj1ZfjVJ5r8s%2BlODtLezAzlJWKHDSPEjAkJTSUgPqlp3K%2BpXwrN0zTfKZzuXjYvAtm81KqcY61CxqFVW3DeSdk9V9kZUzg2pZl4pC46u9OAm0LOJNeEPA8WnwyZhXmpaqrag2106rM1Y34zvuv%2BCfRj4jqzT3v--BuSVHo%2BILut%2Banc8--NVqX9upc0WbygEwRFzBjyA%3D%3D; path=/; httponly; samesite=lax

と言う値のCookieをセットする指示が来てます。これがブラウザに保存されるsession関連のデータです。
今回は _app_session と言うキーで保存されてますが、このキー名は config.session_store の設定に従って変わります。

そして、この値は暗号化こそされてますが、設定したい値そのものをクライアント側に保存させるだけのものになります。
そのため暗号化の鍵として使用される secret_key_base の値さえ分かれば復号も、任意の値で状態を改ざんしたなりすましもできてしまうわけですね。
と言う内容を馬場さんの記事でもやってるので丸々内容が被っているのですが、一応改めてこの場でも復号してみます。

Loading development environment (Rails 8.0.1)
app(dev)> secret = OpenSSL::PKCS5.pbkdf2_hmac( Rails.application.secret_key_base, "authenticated encrypted cookie", 1000, 32, OpenSSL::Digest::SHA256.new)
=> "6\xA2\xEC\xE1\xA43\xD2Oh\x8E\xE9\xE1l\xCE\\\xA8\x84C\x17\xB0G\x951\x06g\xE0\xB9\xA1b\xD8\x830"
app(dev)> cookie = "m9UfAAE0274HBvMKYLgqmbtILzZIfsjzgaIABkEZokwzZXNLiTFOVhka1yZ7rClUmMQDdrj%2BRWQsxdotmroIfEwb1nb3kSM3rpl9qpQDJLCPvzxiDp8lJ4EdZ9IAh8y92Os5kbzPpe%2FQIi3fqHBDAbHb8P2yj4pAKSHWcyVWO1ZflcPVSvsY9f6sYmAzt%2Beq5WVlVT%2F0n8QEFd8COv%2BMedhQyetwArnNDXz6WFlwaHcxkvBgr5Aj1ZfjVJ5r8s%2BlODtLezAzlJWKHDSPEjAkJTSUgPqlp3K%2BpXwrN0zTfKZzuXjYvAtm81KqcY61CxqFVW3DeSdk9V9kZUzg2pZl4pC46u9OAm0LOJNeEPA8WnwyZhXmpaqrag2106rM1Y34zvuv%2BCfRj4jqzT3v--BuSVHo%2BILut%2Banc8--NVqX9upc0WbygEwRFzBjyA%3D%3D"
=>
"m9UfAAE0274HBvMKYLgqmbtILzZIfsjzgaIABkEZokwzZXNLiTFOVhka1yZ7rClUmMQDdrj%2BRWQsxdotmroIfEwb1nb3kSM3rpl9qpQDJLCPvzxiDp8lJ4EdZ9IAh8y92Os5kbzPpe%2FQIi3fqHBDAbHb8P2yj4pAKSHWcyVWO1ZflcPVSvsY9f6...
app(dev)> encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer)
=> #<ActiveSupport::MessageEncryptor:0x000000000073c0>
app(dev)> JSON.parse( encryptor.read_message(URI.decode_uri_component(cookie), purpose: 'cookie._app_session') )
=> {"session_id" => "2fda725783b521ec78db6d5f8ecf0e06", "secret" => "内緒だよ", "flash" => {"discard" => [], "flashes" => {"notice" => "フラッシュもセッション管理なんだよね?"}}}

と言うことで _app_session の中にはユーザのセッションを識別するための session_id に加えて、コントローラ内で session[:hoge] = "hugahuga" で保存した値、flashメッセージ関連の内容の値そのものが暗号化されて保存されていることが確かに確認できました。

Devise ユーザのログインセッション

Rails標準の認証機能ジェネレータこそ追加されましたが、まだまだ世の中の数多くのRailsアプリは Devise のお世話になっていることでしょう。
そんなわけでDevise管理のユーザー認証によるログイン成功後のセッション内容にも触れておきます。

細かいことは置いておいて、例えばログイン直後に次のようなSet-Cookieの内容が来ました。

set-cookie: _app_session=3eUn5hydcHGx2wlgNPi16LwKdc4X3sjzP2RelGRgxHqTxKa3hz82RFZfxOsmkHSgE7Fv9%2F663DM2SWDmU3IW%2F2DOYqQL9usjFgkT3fDxD3JZ6lP5vQSyksyRKQxyrBOW7XzsTFWI9BRd9t6DWSyhdnX%2FSEoawCcv%2FFfDbKsA7O93Wex0kku6bdYfwioHkaJGhfZAIgfjooBvdqjAlzO%2F%2FlxoHPdGBNtj2k3G2eGW6SqXSUOEtdraCb0bSGZ8Yyx3gWv%2F3%2FEbU6E0kUCrA%2FAiQYM4MVvYfcKmyGTexXvhZ6tL%2FJiDI7wpv2qnyiiz9BSr0h6ZcyXz55GMxeISOnXOBgGBYq2tjrw9isQ0i1pZeAyWnfYQru7Y2cVHppr%2Bes0QDTxW%2BqFr8bGX5XLTlYnJcEL%2BHK9xNuMnUCRfwwjcbpk9KhSQjanOJ5RlnhUyWMoR0yfXSZPfdTcQA88Fylmj8PGasVnh1IuwfIHr0YjWme8gD1HltzB6Hnxkvli0boI%2FBgSUWg%3D%3D--nUkPAU13yi4N4GPk--X4d3XVWUrciH%2FgQBrWvnkw%3D%3D; path=/; httponly; samesite=lax

先ほどと同様に復号するとこうなります(モデル的には Admin のユーザーです)。

app(dev)> JSON.parse( encryptor.read_message(URI.decode_uri_component(cookie), purpose: 'cookie._app_session') )
=>
{"session_id" => "0b2054a30ecca7c21d6dea2d51279484",
 "warden.user.admin_admin.key" => [[1], "$2a$12$QtrOesaHcEYhcSSOQ/5aRe"],
 "warden.user.admin_admin.session" => {"last_request_at" => 1766539085},
 "_csrf_token" => "x-ME2lwJwc2YhDx72mXLFmWJvCRDYfjgjlNmsq8xCFc"}

warden.user.admin_admin.key って値の方がDeviseとしてログイン中のユーザーを管理するような情報になってそうです。
いかにもIDっぽい 1 の値はともかく $2a$12$QtrOesaHcEYhcSSOQ/5aRe の方は所謂シリアライズと言うやつだろうなと言う経験則で serialize みたいなキーワードで適当に探してみるとそれっぽいセッション向けのシリアライズ処理が見つかります。

# https://github.com/heartcombo/devise/blob/v4.9.4/lib/devise/models/authenticatable.rb#L237
def serialize_into_session(record)
  [record.to_key, record.authenticatable_salt]
end

コンソールで直接同様の処理を呼び出してみると同じ値が再現できます。

app(dev)> [Admin.first.to_key, Admin.first.authenticatable_salt]
  Admin Load (0.6ms)  SELECT "admins".* FROM "admins" ORDER BY "admins"."id" ASC LIMIT 1 /*application='App'*/
  Admin Load (0.5ms)  SELECT "admins".* FROM "admins" ORDER BY "admins"."id" ASC LIMIT 1 /*application='App'*/
=> [[1], "$2a$12$QtrOesaHcEYhcSSOQ/5aRe"]

ちなみに authenticatable_salt の値はどうやらパスワードから生成されているようです。

# https://github.com/heartcombo/devise/blob/v4.9.4/lib/devise/models/database_authenticatable.rb#L177
def authenticatable_salt
  encrypted_password[0,29] if encrypted_password
end

secret_key_base の値だけ流出したとして to_key の適当なIDの値を決め打ちするのはともかく authenticatable_salt の値を再現するのは難しそうですが、DBのアクセスに関する脆弱性があった場合は任意のユーザの id , encrypted_password の情報も流出してるかもなのでその場合はなりすましできちゃいそうですね。

まとめ

  • ブラウザ側に保存されるCookieの値は保存したいsessionの値をRails側で暗号化したもの
  • secret_key_base の値(正確にはRails ver.や設定によって暗号化方式の差異があるとは思いますが)さえ分かれば任意のRails環境からCookieの値を元にsessionの値を復号できる

ActionDispatch::Session::CacheStore のsessionを読み取る

今度はRailsが管理するキャッシュの中でセッションデータを管理する構成例での挙動を確認してみます。
config.session_store を定義しつつ、分かりやすくキー名も変更しておきます。

config.session_store :cache_store, key: '_for_redis_session'

コンソールで確認してみて以下のように切り替わっていることを確認します。

app(dev)> Rails.application.config.session_store
=> ActionDispatch::Session::CacheStore

保存先の CacheStore については、例えばrails new時のデフォルトでは、
development環境( config/environments/development.rb )が config.cache_store = :memory_store でRailsを動かしてるサーバ内のメモリ利用 、
production環境( config/environments/production.rb )が config.cache_store = :solid_cache_store でSolid Cache用のDB利用だったりするはずです。

確かにSolid Cacheの例を確認しても良かったなぁ……
なんて思いつつも今回は良くある構成のRedisを使う設定を確認します。

  • compose.yml に以下を追記して
  redis:
    image: redis
  • Gemfileに gem "redis" を追記して必要なgemをインストールして
  • config.cache_store = :redis_cache_store, { url: "redis://redis:6379/1" } の設定状態にしました

適当なセッションデータを書き込んでみていざ確認

再度この設定の場合のCookieの値はどうなってるかを確認してみましょう。

先ほどと比べてcookieの値は大分短めになりました。

set-cookie: _for_redis_session=10de0f4e106f1696133770f00deb0679; path=/; httponly

きっとセッションの値を読み取るためのキーだけを保存しているからでしょう。
と言うことでRedisに保存されたデータのキー一覧をまずは確認してみましょう。

Loading development environment (Rails 8.0.1)
app(dev)> Redis.new(url: "redis://redis:6379/1").keys
=> ["_session_id:2::15813e1aa657e23d09645008a623e47717e9127841b7a8d2671a5c284dbae26a"]

おや?どうやらCookieに保存した値とは全く違うキー名っぽいものが記載されています……。

仕方がないので読み取りだけでも試みてみます。
直接Redisから読み取ろうとするといまいち分かり辛い記載だったので、実態は Rails.cahce だろうと言うことでその方針で読み取りました。

app(dev)> Redis.new(url: "redis://redis:6379/1").get("_session_id:2::15813e1aa657e23d09645008a623e47717e9127841b7a8d2671a5c284dbae26a")
=> "\x00\x11\x01\x00\x00\x00\x00\x00\x00\xF0\xBF\xFF\xFF\xFF\xFF\x04\b{\aI\"\vsecret\x06:\x06EFI\"\x11\xE5\x86\x85\xE7\xB7\x92\xE3\x81\xA0\xE3\x82\x88\x06;\x00TI\"\nflash\x06;\x00T{\aI\"\fdiscard\x06;\x00T[\x00I\"\fflashes\x06;\x00T{\x06I\"\vnotice\x06;\x00FI\">\xE3\x83\x95\xE3\x83\xA9\xE3\x83\x83\xE3\x82\xB7\xE3\x83\xA5\xE3\x82\x82\xE3\x82\xBB\xE3\x83\x83\xE3\x82\xB7\xE3\x83\xA7\xE3\x83\xB3\xE7\xAE\xA1\xE7\x90\x86\xE3\x81\xAA\xE3\x82\x93\xE3\x81\xA0\xE3\x82\x88\xE3\x81\xAD\xEF\xBC\x9F\x06;\x00T"
app(dev)> Rails.cache.read("_session_id:2::15813e1aa657e23d09645008a623e47717e9127841b7a8d2671a5c284dbae26a")
=> {"secret" => "内緒だよ", "flash" => {"discard" => [], "flashes" => {"notice" => "フラッシュもセッション管理なんだよね?"}}}

ふむ。中身は期待通りのセッションデータの内容になってそうです。

内部実装を確認してみます。

# https://github.com/rails/rails/blob/v8.0.4/actionpack/lib/action_dispatch/middleware/session/cache_store.rb#L39
def write_session(env, sid, session, options)
  key = cache_key(sid.private_id)
  if session
    @cache.write(key, session, expires_in: options[:expire_after])
    #中略
  end
 end

どうやら sid.private_id とやらが 2::c6fb7615255ca439bcaf80754d4342449f99cb2170d3f4f19f7675bde5c13550 みたいなキャッシュ用のキー名に対応しているらしい。
……で、 sid ってなんだ 🤔

良く分からんので private_id と言う特徴的な名前で検索をかけてみる
するとテストコード内で怪しいものを発見!

# https://github.com/rails/rails/blob/v8.0.4/actionpack/test/dispatch/session/cache_store_test.rb#L153
sid = Rack::Session::SessionId.new("0xhax")
assert_nil @cache.read("_session_id:#{sid.private_id}")

private_id の形式を比較すると先ほどのCookieの値と似ている……!!

app(dev)> sid = Rack::Session::SessionId.new("0xhax")
=> "0xhax"
app(dev)> sid.private_id
=> "2::f7822a40c99cfa090d079e6a349584192b9ecbe5c0dce6e8eb32d6b6c23c67f1"
app(dev)> sid.public_id
=> "0xhax"

つまり

  • sid.private_id がRedis側に保存されたセッションの値を参照するためのキャッシュのキーになる
  • sid.public_id がクライアント側のCookieとして保存される値になる。
    Rails側はリクエストから受け取ったCookieの値で Rack::Session::SessionId.new(cookie).private_id のようにキャッシュのキーに変換すればセッションの値が参照できる

と言うことなのだろうと予測が付きました。
改めて先ほどのCookieの値からキャッシュのキーを復元、セッションの値を読み取ってみます。

app(dev)> sid = Rack::Session::SessionId.new("10de0f4e106f1696133770f00deb0679")
=> "10de0f4e106f1696133770f00deb0679"
app(dev)> sid.private_id
=> "2::15813e1aa657e23d09645008a623e47717e9127841b7a8d2671a5c284dbae26a"
app(dev)> Rails.cache.read("_session_id:#{sid.private_id}")
=> {"secret" => "内緒だよ", "flash" => {"discard" => [], "flashes" => {"notice" => "フラッシュもセッション管理なんだよね?"}}}

無事に(?)読み取れました。

Rack::Session::SessionId インスタンスを利用したセッション管理について

この Rack::Session::SessionId ってなんなんだ?と言うことで関連するrackの変更が入った経緯とかがまとめられていた記事を見つけたので紹介しておきます。

参考: rack 2.0.8でのセッションIDの破壊的変更(CVE-2019-16782) - shimojubox

どうやら以前はCookieに保存されてる値がセッションIDそのものだったようですね。
タイミング攻撃に対する脆弱性のセキュリティ対応がきっかけだったようですが、
Geminiさん的には private_idpublic_id に分かれていることでRedisへのアクセスを許してしまった時に全てのユーザのセッションが乗っ取られないようにするのも大事です。
みたいなことも語ってました。まぁそう言う側面もあるのかも?

まとめ

  • ブラウザ側に保存されるCookieの値は Rack::Session::SessionId インスタンスの public_id の値
  • キャッシュストア内では Rack::Session::SessionId インスタンスの private_id の値がキーに使われる
  • Cookieの値を元に public_id の値さえ分かれば private_id の値が算出できるため、Railsサーバから、あるいはRedisに直接アクセスすることでキャッシュとして保存されたsessionの値の読み取りができる

おわり

他にも config.session_store としては ActionDispatch::Session::MemCacheStore だったり activerecord-session_store gem のようなgemによる拡張を指定したりと言った他のパターンのsessionストアの構成があり得ますが……
ここまで書いた分でもそこそこのボリュームなので終わりにしておきます。

引っ越し後の新生活で初めて通して過ごした1年だったせいもあってか長かったようであっと言う間だったようで色々あった年でした。
個人的に1~2ヶ月前に思い描いていた年末、年越しとは全く異なる過ごし方になりそうな想定外の出来事もあり、気持ちの整理が付いてないところもありますがゆっくり休んで来年に備えようと思います。

お世話になった方もそうでもない方も今年1年間ありがとうございました!


BPSアドベントカレンダー2025


CONTACT

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