🐈

脆弱性から学ぶRailsの仕組み(CVE-2022-23633編)

2022/12/21に公開

脆弱性から学ぶRailsの仕組み(CVE-2022-23633編)

少し前にやった脆弱性からRubyの仕組みを学ぶやつRailsでもやってみる記事。
前回同様「脆弱性の原理を把握する」という側面から、実際に脆弱性を試しつつ「その本体の仕組み」も学んでしまおうという趣旨。
Railsも業務で使うようになったので、これもちゃんと学んでみようと思う。

対象の読者

この記事の対象となる人はこんな感じで想定。

  • Railsを使い始めたばかりで仕組みから学びたい人
  • Railsはよく使ってるけど仕組みはよくわからないから知りたいという人

自分のようなRails入門したてホヤホヤの方にも勉強になれば嬉しいので、自分の理解度整理も含めて「Action Packって何?」みたいな説明を軽くする箇所もあるためその辺はご了承ください。

Ruby on Rails

さっきから「Rails、れいるず」と言っているもの。
言わずとしれたRubyのWebアプリケーションフレームワークの1つ。
Rails?あー、ピザのことね。」という方はWikipediaを参照。

脆弱性

これはRubyの記事で記載したので割愛。
気になる方は以下の 脆弱性からRubyの仕組みを学んだ記事 を参照。
https://zenn.dev/0kate/articles/9872322bb93a58

CVE-2022-23633

今回の調査対象となる脆弱性。
この脆弱性を選んだのは、比較的新しめの脆弱性なので身近に見かける可能性もあるのではと思ったのと、内容的にAction Packあたりを掘り下げることになりそうなので、Railsの根本的な部分を学べそうな気がしたから。

NISTの脆弱性の概要としてはこんな感じ。

Action Pack is a framework for handling and responding to web requests. Under certain circumstances response bodies will not be closed. In the event a response is not notified of a close, ActionDispatch::Executor will not know to reset thread local state for the next request. This can lead to data being leaked to subsequent requests.This has been fixed in Rails 7.0.2.1, 6.1.4.5, 6.0.4.5, and 5.2.6.1. Upgrading is highly recommended, but to work around this problem a middleware described in GHSA-wh98-p28r-vrc9 can be used.

雑に翻訳すると、 特定の条件下で、ActionDispatch::Executorによってリクエストを処理するスレッドの終了処理がうまく行われず、以降のリクエストにスレッドの内部状態が引き継がれてしまう ものらしい。

さらに、GitHubに書いてある脆弱性の概要には若干違う記載があって、こっちのほうがもっと具体的に書いてある。

Under certain circumstances response bodies will not be closed, for example a bug in a webserver or a bug in a Rack middleware. In the event a response is not notified of a close, ActionDispatch::Executor will not know to reset thread local state for the next request. This can lead to data being leaked to subsequent requests, especially when interacting with ActiveSupport::CurrentAttributes.

基本的な内容は同じだが、こちらに更に追加で書いてあるのが WebサーバーやRackミドルウェアのバグに起因する というのと ActiveSupport::CurrentAttributesを使っているときは特にそうなる ということ。

2つをまとめると、 WebサーバーやRackミドルウェアのバグによってActionDispatch::Executorのスレッド終了処理がうまく行われず、以降のリクエストにスレッドの内部状態が引き継がれてしまう らしい。

もちろん既に公開されている情報のためパッチは当たっていて、5.2.6.1/6.0.4.5/6.1.4.5/7.0.2.1以降のRailsを使っていれば大丈夫。

パッチを見てみる

まだ全然理解できていないが、とりあえず当てられているパッチを見てみる。
NISTのページなどに貼られているリンクから飛べるGitHub上のパッチはこれ。

$ git diff 761a2e25520566d932c41c740b8a5c513d839de8 f9a2ad03943d5c2ba54e1d45f155442b519c75da
diff --git a/activesupport/lib/active_support/reloader.rb b/activesupport/lib/active_support/reloader.rb
index 2f81cd4f80..e751866a27 100644
--- a/activesupport/lib/active_support/reloader.rb
+++ b/activesupport/lib/active_support/reloader.rb
@@ -58,7 +58,7 @@ def self.reload!
       prepare!
     end

-    def self.run! # :nodoc:
+    def self.run!(reset: false) # :nodoc:
       if check!
         super
       else

このパッチはActiveSupport::Reloader#run!に当てられている。
「え、これだけ?」と思うかもしれないが、実はこのコミットは副次的なもので、もう少しだけ前のコミットログを辿るとそっちに本質的な修正が加えられている。
全部ここに載せるには少しだけ長いので、重要そうな部分だけ抜き出す。

diff --git a/actionpack/lib/action_dispatch/middleware/executor.rb b/actionpack/lib/action_dispatch/middleware/executor.rb
index 85326e313b..1878d64715 100644
--- a/actionpack/lib/action_dispatch/middleware/executor.rb
+++ b/actionpack/lib/action_dispatch/middleware/executor.rb
@@ -9,7 +9,7 @@ def initialize(app, executor)
     end

     def call(env)
-      state = @executor.run!
+      state = @executor.run!(reset: true)
       begin
         response = @app.call(env)
         returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }

⬆ まずは概要分にも書かれていたActionDispatch::Executor#callの変更。
reset引数にtrueを渡している。先程のActiveSupport::Reloader#run!に加えられているreset引数と関係ありそう。

diff --git a/activesupport/lib/active_support/execution_wrapper.rb b/activesupport/lib/active_support/execution_wrapper.rb
index 87d90839ca..5a4a9b2c60 100644
--- a/activesupport/lib/active_support/execution_wrapper.rb
+++ b/activesupport/lib/active_support/execution_wrapper.rb
@@ -64,18 +64,21 @@ def self.register_hook(hook, outer: false)
     # after the work has been performed.
     #
     # Where possible, prefer +wrap+.
-    def self.run!
-      if active?
-        Null
+    def self.run!(reset: false)
+      if reset
+        lost_instance = IsolatedExecutionState.delete(active_key)
+        lost_instance&.complete!
       else
-        new.tap do |instance|
-          success = nil
-          begin
-            instance.run!
-            success = true
-          ensure
-            instance.complete! unless success
-          end
+        return Null if active?
+      end
+
+      new.tap do |instance|
+        success = nil
+        begin
+          instance.run!
+          success = true
+        ensure
+          instance.complete! unless success
         end
       end
     end
@@ -105,27 +108,20 @@ def self.perform # :nodoc:
       end
     end

-    class << self # :nodoc:
-      attr_accessor :active
-    end
-
     def self.error_reporter
       @error_reporter ||= ActiveSupport::ErrorReporter.new
     end

-    def self.inherited(other) # :nodoc:
-      super
-      other.active = Concurrent::Hash.new
+    def self.active_key # :nodoc:
+      @active_key ||= :"active_execution_wrapper_#{object_id}"
     end

-    self.active = Concurrent::Hash.new
-
     def self.active? # :nodoc:
-      @active[IsolatedExecutionState.unique_id]
+      IsolatedExecutionState.key?(active_key)
     end

     def run! # :nodoc:
-      self.class.active[IsolatedExecutionState.unique_id] = true
+      IsolatedExecutionState[self.class.active_key] = self
       run
     end

@@ -140,7 +136,7 @@ def run # :nodoc:
     def complete!
       complete
     ensure
-      self.class.active.delete(IsolatedExecutionState.unique_id)
+      IsolatedExecutionState.delete(self.class.active_key)
     end

     def complete # :nodoc:

⬆ 次はActiveSupport::ExecutionWrapperに加えられている変更。
ActiveSupport::IsolatedExecutionStateなるものが出てくる。
IsolatedExecutionStateとかいうクラスを、自分のクラス変数に保存」から「IsolatedExecutionStateのクラス変数に、自分自身のインスタンスを保存」に変わっている?ように見える。

Workaround

さらに、どうしてもパッチ適用版にアップグレードできない場合の代替策も一応用意されており、次のRackミドルウェアを適用することで一先ずなんとかなるらしい。

class GuardedExecutor < ActionDispatch::Executor
  def call(env)
    ensure_completed!
    super
  end

  private

    def ensure_completed!
      @executor.new.complete! if @executor.active?
    end
end

# Ensure the guard is inserted before ActionDispatch::Executor
Rails.application.configure do
  config.middleware.swap ActionDispatch::Executor, GuardedExecutor, executor
end

GuardedExecutor#callの中で、ActionDispatch::Executor#callの直前にcomplete!を必ず呼び出すようにしている。
どうやら、このcomplete!が呼び出されるか否かが肝らしい。

前提知識

脆弱性の概要やパッチが確認できた所で実際にもう少し深く調べていきたいが、ここまででActionDispatch::ExecutorやらActiveSupport::ExecutionWrapperやらActiveSupport::IsolatedExecutionStateやらActiveSupport::CurrentAttributesやら色々出てき過ぎていて、Rails入門したて人間には辛いものがあるため、少しだけこの辺の事前知識について調べておく。

Action Pack

まずは基本が大事ということでAction Packから。Action Dispatchもここに関係している。
📢 公式の記載を噛み砕いた内容なので、こんなもの常識だという方はガンガンスキップ推奨。

Action Pack is 何?

Ruby on RailsMVCパターンに準拠したフレームワークなわけだが、Action Packはそのコントローラレイヤーに相当するもので、HTTPリクエスト・レスポンスのハンドリングに関する責務を担っている部分。
Action Dispatch/Action Controllerという2つのモジュールから構成されている。

Action Dispatch

Action Dispatch, which parses information about the web request, handles routing as defined by the user, and does advanced processing related to HTTP such as MIME-type negotiation, decoding parameters in POST, PATCH, or PUT bodies, handling HTTP caching logic, cookies and sessions.

雑に翻訳すると、 HTTPリクエストを解釈(キャッシュやクッキー・セッションの処理なども含む)し、ユーザーが定義したコントローラに適切に受け渡す
こう書いてあるが、コントローラの処理結果を受けて戻すのもここの役割。
Rackミドルウェアとして、Railsの入口からコントローラまでの道のりを繋いでいるデコレータパターンみたいなもの。

Action Controller

Action Controller, which provides a base controller class that can be subclassed to implement filters and actions to handle requests. The result of an action is typically content generated from views.

こちらも雑に翻訳すると、 Action Dispatchから受け渡されたリクエストを実際に処理する部分(処理結果はビューによって処理されたものが一般的)
実際にRailsを使ってアプリケーションを実装していく際に、app/controllersActionController::Baseを継承して書いていくやつ。

Executor

Railsには、Executorという Railsのフレームワークを構成するコードアプリケーションのコード を分離するらしい概念が存在する。
to_run/to_completeという2種類のコールバックを受け取り、「to_runでアプリケーションのコードを実行 → 完了したらto_complete」という順番で実行する。

Rails内部でいくつか実際に使われているケースとしては以下。

  • 自動ロードやリロードのスレッドが安全な位置で稼働しているかを追跡する
  • Active Recordのクエリキャッシュを有効/無効する
  • Active Recordのコネクションをコネクションプールにリリースする
  • 内部キャッシュの制限

詳しい説明は、Rails Guideに書いてある。

ActionDispatch::Executor / ActiveSupport::ExecutionWrapper

脆弱性の概要で、「ActionDispatch::Executorがどうのこうの」と言われていたやつ。
前述のExecutorと、後続のRackミドルウェアを指定することで
実態はrails/actionpack/lib/action_dispatch/middleware/executor.rb:7にある。

ActiveSupport::ExecutionWrapperActionDispatch::Executorから呼ばれ、実際にExecutorとして振る舞うクラス。
rails/activesuport/lib/active_support/execution_wrapper.rbにある。
前述のExecutor

ActiveSupport::Reloader

名前の通りだが、リロード処理を行うクラス。
ファイルが変更されているか などリロードするべきかの判定処理はコールバックで外部から渡される。

ActiveSupport::IsolatedExecutionState

rails/activesupport/lib/active_support/isolated_execution.rbにある。
これも名前通り、「隔離された実行状態」を管理するクラス。
現在の実行単位(ThreadもしくはFiber)ごとに、キー/バリューで値を保存させられる。

ActiveSupport::CurrentAttributes

rails/activesupport/lib/active_support/current_attribute.rbにある。

Abstract super class that provides a thread-isolated attributes singleton, which resets automatically before and after each request. This allows you to keep all the per-request attributes easily available to the whole system.

リクエストを処理するスレッドごとに、状態を保存しておく用のシングルトンオブジェクトとして機能するクラス。
使い方は、ソースコードのコメントとしてわかりやすいサンプルが書かれているのでそちらを参照。
ちなみにこのActiveSupport::CurrentAttributes#current_instancesは、内部的に前述のIsolatedExecutionStateを参照している。

def current_instances
  IsolatedExecutionState[:current_attributes_instances] ||= {}
end

サンプルコードを書いて試してみる

今回もパッチ適用後のテストコードがあるので、それを見ながら変な挙動を起こしそうなサンプルコードを書いてみる。
Rails本体から切り離しても動くように分解して雑に書き換え。

require 'action_dispatch/middleware/executor'
require 'active_support/executor'
require 'active_support/isolated_execution_state'

executor = Class.new(ActiveSupport::Executor)

def middleware(inner_app)
  ActionDispatch::Executor.new(inner_app, executor)
end

total = 0
ran = 0
completed = 0

executor.to_run { total += 1; ran += 1 }
executor.to_complete { total += 1; completed += 1 }

stack = middleware(proc { [200, {}, "response"] })

requests_count = 5

requests_count.times do
  stack.call({})
end

puts "requests_count: #{requests_count}"
puts "total         : #{total}"
puts "ran           : #{ran}"
puts "completed     : #{completed}"

⬇ テストコードの通りならこんな出力が期待される。

$ bundle exec ruby ./sample.rb
requests_count: 5
total         : 9
ran           : 5
completed     : 4

⬇ ところが、パッチ適用前のRailsで実行した出力はこう。

$ bundle exec ruby ./sample.rb
requests_count: 5
total         : 1
ran           : 1
completed     : 0

5回ループしているにも関わらず1回しかto_run呼ばれてないし、to_completeに関しては1度も呼ばれてやがらない模様。

デバッガで見てみる

なんでこうなるのかデバッガで見てみる。

デバッグに当たっては、v7系のRailsならデフォルトで組み込まれているdebug gemを使っていく。
起動方法は簡単で、デバッグを開始したい任意の場所にdebuggerメソッドを埋め込むだけで、そこを通過したタイミングでデバッガが起動してくれるようになる。
あとは好きな所にブレークポイントを入れたりすればオッケー。

require 'action_dispatch/middleware/executor'
require 'active_support/executor'
require 'active_support/isolated_execution_state'
require 'debug'  # これを追加

# (変わらないので省略)

requests_count.times do
  debugger  # とりあえずこの辺に入れ込んでみる
  stack.call({})
end

puts "requests_count: #{requests_count}"
puts "total         : #{total}"
puts "ran           : #{ran}"
puts "completed     : #{completed}"

デバッガが自体の使い方は今回は割愛するので、気になる方はこれも公式のHOW TO USEを参照。
コマンドセットなどはgdbのそれを踏襲した感じなので、gdbを使える人なら感覚的にいけるはず。

ループ1周目

まずは起動一発目のループから。

$ bundle exec ruby ./sample.rb
[34, 43] in ./sample.rb
    34| stack = middleware(proc { Current.request_id += 1; [200, {}, "response"] })
    35|
    36| requests_count = 5
    37|
    38| requests_count.times do
=>  39|   debugger
    40|   stack.call({})
    41| end
    42|
    43| puts "requests_count: #{requests_count}"
=>#0	block in <main> at ./sample.rb:39
  #1	[C] Integer#times at ./sample.rb:38
  # and 1 frames (use `bt' command for all frames)
(rdbg) n    # next command
    35|
    37|
    38| requests_count.times do
    39|   debugger
=>  40|   stack.call({})
    41| end
    42|
    43| puts "requests_count: #{requests_count}"
    44| puts "total         : #{total}"
=>#0	block in <main> at ./sample.rb:40
  #1	[C] Integer#times at ./sample.rb:38
  # and 1 frames (use `bt' command for all frames)
(rdbg) s    # step command
[7, 16] in path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2/lib/action_dispatch/middleware/executor.rb
     7|     def initialize(app, executor)
     9|     end
    10|
    11|     def call(env)
=>  12|       state = @executor.run!
    13|       begin
    14|         response = @app.call(env)
    15|         returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
    16|       rescue => error
=>#0	ActionDispatch::Executor#call(env={}) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2/lib/action_dispatch/middleware/executor.rb:12
  #1	block in <main> at ./sample.rb:40
  # and 2 frames (use `bt' command for all frames)
(rdbg) s    # step command
[63, 72] in path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2/lib/active_support/execution_wrapper.rb
    63|     # Returns an instance, whose +complete!+ method *must* be invoked
    65|     #
    66|     # Where possible, prefer +wrap+.
    67|     def self.run!
=>  68|       if active?
    69|         Null
    70|       else
    71|         new.tap do |instance|
    72|           success = nil
=>#0	#<Class:ActiveSupport::ExecutionWrapper>#run! at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2/lib/active_support/execution_wrapper.rb:68
  #1	ActionDispatch::Executor#call(env={}) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2/lib/action_dispatch/middleware/executor.rb:12
  # and 3 frames (use `bt' command for all frames)
(ruby) active?
nil
(rdbg) n    # next command
    66|     # Where possible, prefer +wrap+.
    67|     def self.run!
    68|       if active?
    69|         Null
    70|       else
=>  71|         new.tap do |instance|
    72|           success = nil
    73|           begin
    74|             instance.run!
    75|             success = true
=>#0	#<Class:ActiveSupport::ExecutionWrapper>#run! at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2/lib/active_support/execution_wrapper.rb:71
  #1	ActionDispatch::Executor#call(env={}) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2/lib/action_dispatch/middleware/executor.rb:12
  # and 3 frames (use `bt' command for all frames)

...

(rdbg)

デバッガの出力を全て貼り付けると長いので多少削るが、実際に起こっていることとしてはこんな感じ。

  • ActionDispatch::Executor#callから、@executor.run!が呼ばれる
  • @executor.run!内で、初回はactive?falseになるためto_runに設定したコールバックが実行される
  • そのまま返ってきて登録したミドルウェアの処理が実行される
  • 正常にミドルウェアの処理が完了し、to_completeが呼ばれない
    (どうやらto_completeは明示的に呼ばない限り、ミドルウェアの処理が失敗したタイミングで呼ばれるみたい)

ループ2周目以降

続いて2周目も見ていく。
起こっている事象としてはこんな感じ。(デバッガの出力は特に代わり映えしないので割愛)

  • 前回のループでto_completeが呼ばれなかったため、active?trueのままとなりto_runが実行されない
  • これ以降は1周目と同じ流れで、to_runto_completeも呼ばれず5回空回るだけ

to_completeが呼ばれない

この to_completeが呼ばれない という現象が割ととんでもなく、実際のRailsのコードではto_completeでいろいろとスレッドの内部状態をリセットしていたりするもので、これが呼ばれなければ次のリクエストの処理に使い回されてしまう。
例えば、rails/activesuport/lib/active_support/railtie.rb:37とか。

module ActiveSupport
  class Railtie < Rails::Railtie # :nodoc:
    ...
    initializer "active_support.reset_execution_context" do |app|
      app.reloader.before_class_unload { ActiveSupport::ExecutionContext.clear }
      app.executor.to_run              { ActiveSupport::ExecutionContext.clear }
      # この辺とか
      app.executor.to_complete         { ActiveSupport::ExecutionContext.clear }
    end

    initializer "active_support.reset_all_current_attributes_instances" do |app|
      executor_around_test_case = app.config.active_support.executor_around_test_case

      app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all }
      app.executor.to_run              { ActiveSupport::CurrentAttributes.reset_all }
      # この辺とか
      app.executor.to_complete         { ActiveSupport::CurrentAttributes.reset_all }
      ...

試しに概要でも触れられていたActiveSupport::CurrentAttributesサンプルコードでも使ってみると、

require 'action_dispatch/middleware/executor'
require 'active_support/code_generator'
require 'active_support/current_attributes'
require 'active_support/executor'
require 'active_support/isolated_execution_state'
require 'debug'

# こんなクラスを作ってみる (適当に作っているので必要ない記載もあります)
class Current < ActiveSupport::CurrentAttributes
  attribute :account, :user
  attribute :request_id, :user_agent, :ip_address

  resets {}

  def user=(user)
    super
    self.account = user.account
    Time.zone    = user.time_zone
  end
end

...  # この辺は一緒

# Currentでシングルトンを参照してみる
$executor.to_run { Current.request_id = 1; total += 1; ran += 1 }
$executor.to_complete { ActiveSupport::CurrentAttributes.reset_all; total += 1; completed += 1 }

stack = middleware(proc { Current.request_id += 1; [200, {}, "response"] })

...  # この辺も一緒

puts "requests_count: #{requests_count}"
puts "total         : #{total}"
puts "ran           : #{ran}"
puts "completed     : #{completed}"
puts "Current.request_id: #{Current.request_id}"

本来to_completeが呼ばれてリセットされてほしいわけだが、こんな感じでリクエストごとにそのまま値が引き回されてしまう。
今回は数値を増やしているだけだが、値によってはとんでもないことになりそう。

$ bundle exec ruby ./sample.rb
requests_count: 5
total         : 1
ran           : 1
completed     : 0
Current.request_id: 6  <= これ

Railsのどこで呼ばれているのか見てみる

Railsから切り離したサンプルコードでActionDispatch::Executor#callがおかしな挙動を起こしていることが確認できたので、実際のRailsでもどこで呼ばれているのか見てみる。
あと、ちゃんとRailsの仕組みも学ばないといけないので、どういう経路で呼び出しが行われているのかも見ておく。

準備

調査用のRailsプロジェクトを用意

まずは調査用のRailsプロジェクトを用意。

# Rubyはインストール済みの前提

# パッチが当たっていない`7.0.2`の`Rails`をインストール
$ gem install rails -v 7.0.2
$ rails _7.0.2_ --version
Rails 7.0.2

# 適当にプロジェクト作成 (調査用のため色々スキップ)
$ rails _7.0.2_ new explore_cve-2022-23633 \
> --skip-git \
> --skip-keeps \
> --skip-active-record \
> --skip-bundle

# コントローラも適当に作成しておく (rootにhoge#indexを設定しておく)
$ rails g controller hoge index --no-helper

# 準備できた
$ rails s
$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 0
X-Content-Type-Options: nosniff
...

デバッガを仕込む

サンプルコード同様にデバッガを仕込む。

$ cat app/controllers/hoge_controller.rb
class HogeController < ApplicationController
  def index
    debugger  # この辺に入れ込んでおく
  end
end

# 開発サーバーを起動後、ブラウザやらなんやらでアクセスすればサーバーを起動しているコンソール上でデバッガが起動する
$ ./bin/rails s
Started GET "/" for 127.0.0.1 at 2022-mm-dd hh:mm:ss +0900
Processing by HogeController#index as HTML
[1, 5] in path/to/explore_cve-2022-23633/app/controllers/hoge_controller.rb
     1| class HogeController < ApplicationController
     2|   def index
=>   3|     debugger
     4|   end
     5| end
=>#0	HogeController#index at path/to/explore_cve-2022-23633/app/controllers/hoge_controller.rb:3
  #1	ActionController::BasicImplicitRender#send_action(method="index", args=[]) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_controller/metal/basic_implicit_render.rb:6
  # and 68 frames (use `bt' command for all frames)
(rdbg)

リクエスト受信からコントローラまで

とりあえずbt (backtrace)コマンドを使ってリクエスト受信からコントローラにたどり着くまでの呼び出し経路を見てみる。(結構長かったので一部省略)

(rdbg) bt    # backtrace command
=>#0	HogeController#index at path/to/explore_cve-2022-23633/app/controllers/hoge_controller.rb:3
  #1	ActionController::BasicImplicitRender#send_action(method="index", args=[]) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_controller/metal/basic_implicit_render.rb:6
  #2	AbstractController::Base#process_action at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/abstract_controller/base.rb:215
  #3	ActionController::Rendering#process_action at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_controller/metal/rendering.rb:53
        ...
        🔍 継承元の`ActionController`からユーザー定義のコントローラに伝播させているように見える
  #6	AbstractController::Callbacks#process_action at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/abstract_controller/callbacks.rb:233
  #7	ActionController::Rescue#process_action at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_controller/metal/rescue.rb:22
        ...
        🔍 ActionDispatchがコントローラを決定して繋いでいるように見える
  #18	ActionDispatch::Routing::RouteSet::Dispatcher#dispatch(controller=HogeController, action="index", req=#<ActionDispatch::Request GET "http://loc..., res=#<ActionDispatch::Response:0x00007f080159...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/routing/route_set.rb:49
  #19	ActionDispatch::Routing::RouteSet::Dispatcher#serve(req=#<ActionDispatch::Request GET "http://loc...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/routing/route_set.rb:32
        ...
        🔍 ActionDispatchがクッキーを処理しているように見える
  #32	ActionDispatch::Cookies#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/cookies.rb:696
        ...
        👇 ここで例の`ActionDisptch::Executor`が呼ばれている 👇
  #36	ActionDispatch::Executor#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/executor.rb:14
  #37	ActionDispatch::ActionableExceptions#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/actionable_exceptions.rb:17
  #38	ActionDispatch::DebugExceptions#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/debug_exceptions.rb:28
        ...
        🔍 Rackによるロギングが行われているように見える
  #44	Rails::Rack::Logger#call_app(request=#<ActionDispatch::Request GET "http://loc..., env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/railties-7.0.4/lib/rails/rack/logger.rb:40
        ...
        🔍 アクセス元のIPとかをなんかゴニョゴニョしているように見える
  #51	ActionDispatch::RemoteIp#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/remote_ip.rb:93
  #52	ActionDispatch::RequestId#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/request_id.rb:26
        ...
        👇 ここも`ActionDispatch::Executor`が呼ばれている 👇
  #59	ActionDispatch::Executor#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/actionpack-7.0.4/lib/action_dispatch/middleware/executor.rb:14
        ...
        🔍 Railsに処理が渡されているように見える
  #63	Rails::Engine#call(env={"rack.version"=>[1, 6], "rack.errors"=>...) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/railties-7.0.4/lib/rails/engine.rb:530
        ...
        🔍 Pumaがリクエストを受けてるように見える
  #67	Puma::Request#handle_request(client=#<Puma::Client:0x2f80 @ready=true>, lines="", requests=1) at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/puma-5.6.5/lib/puma/request.rb:76
  #68	Puma::Server#process_client(client=#<Puma::Client:0x2f80 @ready=true>, buffer="") at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/puma-5.6.5/lib/puma/server.rb:443
  #69	block {|spawned=2|} in spawn_thread at path/to/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/puma-5.6.5/lib/puma/thread_pool.rb:147

こうやって見てみると、実際にPumaから繋がれた後いろいろあってActionDispatchによってルーティングやクッキーが処理されていたりする様子がよくわかって感動。
ちょっと見やすくするために、スタックを逆さまにして上から順番に箇条書きにするとこんな感じ。

  1. Pumaがバインドされているポートにリクエストが届き、Puma::Server#process_clientが呼ばれる
    a. Puma::Request#handle_requestで、リクエストの処理に移る
  2. PumaからRailsに処理が移る(Rackインターフェースに準拠したRails::Engine#callが呼ばれる)
  3. Rails::Engine内でリクエストをActionDispatch::Requestにマッピング、Rackミドルウェアに繋ぐ (ここからコントローラまでミドルウェアの連鎖)
    a. ActionDispatch::HostAuthorization#callによるDNS-Rebinding攻撃の検査
    b. ActionDispatch::Static#callによる静的ファイルの検索
    c. ActionDispatch::ServerTiming#callによるServer-Timingヘッダーの処理
    d. ActiveSupport::Cache::Strategy::LocalCache::Middleware#callによるキャッシュの操作
    e. ActionDispatch::Executor#call 👈 ActionDispatch::Executor1箇所目
    f. ロギング
    g. ActionDispatch::RequestId#callによるRequest IDの発行
    h. ActionDispatch::RemoteIp#callによるアクセス元のIPの解決(プロキシ環境の場合など)
    i. ActionDispatch::Executor#call 👈 ActionDispatch::Executor2箇所目
    j. ActionDispatch::ContentSecurityPolicy::Middleware#callによるContent-Security-Policyの検証
    k. などなど...
  4. ActionDispatch::Routing::RouteSet#callActionDispatch::Jorney::Router#serveActionDispatch::Routing::RouteSet::Dispatcher#serveでコントローラが解決され処理が引き渡される
  5. いろいろあってActionController::Rendering#process_actionとか経由し、ユーザー定義のコントローラまでたどり着く
  6. この後コールスタックを戻っていく過程でActionViewとかでレスポンスを構築。
    最終的にRails::Engine#callの呼び出し元に[staus_code as int, headers as HashMap, responses as Array]という感じの配列が返され、Pumaがソケットにレスポンスをバイト列として書き込んで終了

力尽きた
パッチの差分やExecutorの詳しい挙動などももっと詳しく見ようかと思ったが、発生条件が思ったより複雑でこれ以上深堀りすると記事が爆発しそうな気がしたのと、単純に力尽きて別のテーマをやりたくなったのでここまでにしておく。個人的にはAction Packの細かい部分が見れたので満足。

実はPuma側にも修正が入っている

実はこの脆弱性のパッチはPumaにも関係している。
今回はPuma側まで突っ込まないが、Rackミドルウェアからおかしなレスポンスが返ってきた時、読み込みすぎているのかレスポンスが2つが返ってくるという面白いバグが起きていたらしい。
気になる方はGitHubのPRが出ているのでこちらを参照。

最後に

OpenCVEの記載にもある通り、Attack ComplexityHIGHにされているだけあって割と発生条件も限定的というのもあって、最後の方は若干力尽きてたところもあったが、Railsの仕組みについてはかなり勉強になったので良かった。
今回はAction Pack周りがメインだったが、Action View/Active Record辺りも見てみたいと思った。

Appendix

GitHubで編集を提案

Discussion