1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails: Cookieのsession情報+CSRFトークンで、authenticity_tokenの検証を手動でやってみる

Last updated at Posted at 2025-03-18

背景

Railsでブラウザにあるauthenticity_tokenはどうやって検証されているのか?を追っかけて、irb上で自分でトークンの検証ができるかを試してみたという内容。

Rails 7.0のrequest_forgery_protection.rbを解読して、irb上で実行する。

前提

この記事は特定のシーンで使われている内容に限定されているため、RailsのCSRFトークンのすべてを網羅した内容にはなっていません。

  • Rails 7.0で動作確認
  • activerecord-session_storeを利用してセッション管理をしている
  • per_form_csrf_tokensのことは考慮していない

言葉の定義

  • ここでCSRFトークンと呼ぶものは、バックエンド側に保持されている_csrf_tokenのことを指す
  • authenticity_tokenは、ブラウザ側に渡ってくるトークンのこと。

比較の流れ

まずはブラウザから送られてくるauthenticity_tokenから、突き合わせ用の文字列を得る。
authenticity_tokenのポイントは、共通鍵暗号方式のワンタイムパッド+XORで暗号化がされていて、元のCSRFトークンとは異なる値になっているということ。

この暗号化処理は、元のCSRFトークンを隠す目的ではなく、ランダムな鍵を利用して暗号化することで、結果として毎回異なるauthenticity_tokenが生成され、BREACH攻撃を防ぐ狙いがある。authenticity_tokenの前半は鍵、後半はCSRFトークン本体という構成になっている。ワンタイムパッドにおいては、鍵と平文の長さが同一でないといけないという縛りがあるため、CSRFトークンが32バイトだったら鍵も32バイトある。

# サーバーに X-CSRF-TOKEN として送られてくる情報
# browser_authenticity_tokenの例のような値が入っているはず。
browser_authenticity_token = "E6liYxuf0QgsH-4-xgpW0dv0dg0mJlGQ8QcAp4YsF6Aj_djkHuYwvu9iex4VN3t_aXx4nB4shXJLt4mnxoAnNQ"

# まずはBase64デコード
decoded_authenticity_token = Base64.urlsafe_decode64(browser_authenticity_token)
# 前半は暗号化時に使用したランダムな鍵。なのでこれをそのまま鍵として使う
one_time_pad = decoded_authenticity_token[0...32]
# 後半はワンタイムパッド+XORで暗号化されたCSRFトークン本体
encrypted_csrf_token = decoded_authenticity_token[32..-1]

# irbで実行するために関数を定義しちゃう
# 復号のためのXOR処理。
def xor_byte_strings(s1, s2)
  s2 = s2.dup
  size = s1.bytesize
  i = 0
  while i < size
    s2.setbyte(i, s1.getbyte(i) ^ s2.getbyte(i))
    i += 1
  end
  s2
end

# 復号して比較用の文字列 unmasked_token が得られる。この値はHMAC済み。
unmasked_token = xor_byte_strings(one_time_pad, encrypted_csrf_token)

続いてCookieのsession情報から、そのユーザーのそのセッション固有のCSRFトークンを取得する。
なお、sessionsテーブル内のdataは暗号化されているが、適切な鍵の情報(secret_key_base)が設定された環境でirbを実行していれば明示的に鍵の情報は引数で渡さなくても復号できる。

# Cookieに入っているセッション情報。これもまたサーバーに送られてくる
# browser_sessionの例のような値が入っているはず。
browser_session = "9dc1b1f5c299d2fffdc4013f79da6cf5"

session_record_key = Rack::Session::SessionId.new(browser_session).private_id
session_csrf_token = ActiveRecord::SessionStore::Session.find_by_session_id(session_record_key).data["_csrf_token"]
# Base64でエンコードされたCSRFトークンが得られるので、まずはデコード
# これが本当に生のCSRFトークンと言えそう
raw_csrf_token = Base64.urlsafe_decode64(session_csrf_token)

# HMACする。HMAC後の文字列で比較は行われる。HMACの時の鍵はなぜか "!real_csrf_token" で固定。
# 良いのかそれで……?
# 比較用の文字列 signed_csrf_token が得られる
signed_csrf_token = OpenSSL::HMAC.digest(
  OpenSSL::Digest::SHA256.new,
  raw_csrf_token,
  "!real_csrf_token"
)

最後にauthenticity_tokenから得られた値と、Cookieから得られた値を比較する。同じ文字列を比較したいので、==で比較しても同じ返り値が得られるが、本家と同じようにTiming Attackを防止するための比較メソッド(fixed_length_secure_compare)を使って比較する。

ActiveSupport::SecurityUtils.fixed_length_secure_compare(unmasked_token, signed_csrf_token)
# => true

trueになったら無事検証できたということでOK。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?