Confirmableのメール変更フローにおける競合状態の脆弱性を修正
Deviseの Confirmable モジュールにおける「メールアドレス変更」フローに、攻撃者が自身のアカウントで他者のメールアドレスを確認できてしまう競合状態の脆弱性が発見されました。本PRは devise_unconfirmed_email_will_change! を導入し、unconfirmed_email が必ずSQLの UPDATE 文に含まれるよう強制することで、この脆弱性を修正します。
背景
Confirmable モジュールのメール変更フローにおいて、unconfirmed_email が「変更なし」とみなされた場合にSQLの UPDATE 文から除外されることを悪用した競合状態の脆弱性が存在していました。Issue #5783 で報告されたこの脆弱性は、以下の手順で悪用可能です:
- 攻撃者が
attacker1@email.comでアカウントを登録する - 攻撃者が
attacker2@email.comへのメール変更を行うが、確認はまだしない - 攻撃者が2つの並行した「メール変更」リクエストを送信する
- リクエストA:
attacker2@email.comへの変更 - リクエストB:
victim@example.comへの変更
- リクエストA:
リクエストBが先に完了すると、unconfirmed_email が被害者のアドレスに、confirmation_token が新しいトークンに更新されます。その後にリクエストAが処理されると、unconfirmed_email の値がメモリ上のモデルと同じ attacker2@email.com のため「変更なし」とみなされ、SQLは confirmation_token のみを更新します。結果として、データベースには「被害者のメールアドレス+攻撃者に送られたトークン」という状態が生まれ、攻撃者が確認リンクを踏むことで被害者のメールアドレスを自アカウントに確認済みとして設定できてしまいます。
この脆弱性はActive RecordのDirty Trackingの挙動に起因します。postpone_email_change_until_confirmation_and_regenerate_confirmation_token メソッドが self.unconfirmed_email = self.email をセットする際、ORMはメモリ上の現在値と比較して「変更なし」と判断すると、その属性をSQLの UPDATE から除外します。
技術的な変更
修正の核心は postpone_email_change_until_confirmation_and_regenerate_confirmation_token メソッドにおいて、unconfirmed_email を設定する直前に devise_unconfirmed_email_will_change! を呼び出し、ORMに「この属性は必ず更新する」と明示することです。
変更前:
def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
@reconfirmation_required = true
self.unconfirmed_email = self.email
self.email = self.devise_email_in_database
self.confirmation_token = nil
変更後:
def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
@reconfirmation_required = true
# Force unconfirmed_email to be updated, even if the value hasn't changed, to prevent a
# race condition which could allow an attacker to confirm an email they don't own. See #5783.
devise_unconfirmed_email_will_change!
self.unconfirmed_email = self.email
self.email = self.devise_email_in_database
self.confirmation_token = nil
devise_unconfirmed_email_will_change! はActive RecordとMongoidのそれぞれに対応した実装が lib/devise/orm.rb に追加されました。Active Recordの実装は既存の will_change! パターンに沿ったシンプルな委譲です。
# Active Record
def devise_unconfirmed_email_will_change!
unconfirmed_email_will_change!
end
Mongoidの実装では、will_change! だけでは未変更属性のSQL UPDATE への強制が不十分なため、changed_attributes に nil を直接書き込むことで差分を強制的に発生させています。
# Mongoid
def devise_unconfirmed_email_will_change!
# Mongoid's will_change! doesn't force unchanged attributes into updates,
# so we override changed_attributes to make it see a difference.
unconfirmed_email_will_change!
changed_attributes["unconfirmed_email"] = nil
end
テストには競合状態を再現するケースが追加されています。Admin.where(id: attacker.id).update_all(...) でデータベースを直接更新してモデルのメモリ上の状態とDBの状態を乖離させた後、同じ値でのメール変更を実行し、最終的に被害者のメールアドレスが確認されないことを検証します。
設計判断
devise_unconfirmed_email_will_change! というラッパーメソッドを新設する方式 が採用されました。confirmable.rb に直接 unconfirmed_email_will_change! を呼び出すのではなく、ORM依存の処理を抽象化するレイヤーを挟むことで、Active RecordとMongoidの挙動の差異を吸収しています。
Mongoidについては、will_change! の呼び出しだけでは不十分であるというコメントが付いており、changed_attributes["unconfirmed_email"] = nil という追加の操作が必要になっています。既存の devise_will_save_change_to_email? などのラッパーメソッドと同じ命名規則に従うことで、ORM抽象化レイヤーの設計一貫性を維持しています。
修正箇所は1行の追加という最小限の変更でありながら、問題の根本であるDirty Trackingの挙動に直接対処しています。
まとめ
この修正は、ORMのDirty Tracking機構が「最適化」として行うSQL省略が、セキュリティ上の競合状態を生み出すという微妙な脆弱性への対処です。unconfirmed_email の更新を常に強制することで、並行リクエストによるトークンと未確認メールアドレスの不一致状態が発生しなくなり、Confirmableのメール変更フローの整合性が保証されます。