背景
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。