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

# 脆弱性から学ぶRailsの仕組み(CVE-2022-23633編)
少し前にやった[脆弱性からRubyの仕組みを学ぶやつ](https://qiita.com/0kate/items/e0209cd83b4992d75b19)を`Rails`でもやってみる記事。
前回同様「脆弱性の原理を把握する」という側面から、実際に脆弱性を試しつつ「その本体の仕組み」も学んでしまおうという趣旨。
`Rails`も業務で使うようになったので、これもちゃんと学んでみようと思う。

# 対象の読者
この記事の対象となる人はこんな感じで想定。
- `Rails`を使い始めたばかりで仕組みから学びたい人
- `Rails`はよく使ってるけど仕組みはよくわからないから知りたいという人

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

# Ruby on Rails
さっきから「Rails、れいるず」と言っているもの。
言わずとしれたRubyのWebアプリケーションフレームワークの1つ。
「`Rails`？あー、ピザのことね。」という方は[Wikipedia](https://ja.wikipedia.org/wiki/Ruby_on_Rails)を参照。

# 脆弱性
これはRubyの記事で記載したので割愛。
気になる方は以下の **脆弱性からRubyの仕組みを学んだ記事** を参照。
https://qiita.com/0kate/items/e0209cd83b4992d75b19

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

[NISTの脆弱性の概要](https://nvd.nist.gov/vuln/detail/CVE-2022-23633)としてはこんな感じ。
> 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に書いてある脆弱性の概要](https://github.com/rails/rails/security/advisories/GHSA-wh98-p28r-vrc9)には若干違う記載があって、こっちのほうがもっと具体的に書いてある。
> 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のページなどに貼られているリンク](https://github.com/rails/rails/commit/f9a2ad03943d5c2ba54e1d45f155442b519c75da)から飛べるGitHub上のパッチはこれ。
```diff ruby
$ 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!`に当てられている。
「え、これだけ？」と思うかもしれないが、実はこのコミットは副次的なもので、もう少しだけ前のコミットログを辿るとそっちに[本質的な修正](https://github.com/rails/rails/commit/3c0420c431954d05fef2015a27281069e68cce0c)が加えられている。
全部ここに載せるには少しだけ長いので、重要そうな部分だけ抜き出す。

```diff ruby
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 ruby
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ミドルウェアを適用することで一先ずなんとかなるらしい。
```ruby
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`](https://github.com/rails/rails/blob/main/actionpack/README.rdoc#action-pack--from-request-to-response-) is 何?
`Ruby on Rails`は`MVC`パターンに準拠したフレームワークなわけだが、`Action Pack`はその[コントローラレイヤー](https://github.com/rails/rails#controller-layer)に相当するもので、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/controllers`に`ActionController::Base`を継承して書いていくやつ。

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

`Rails`内部でいくつか実際に使われているケースとしては以下。
- 自動ロードやリロードのスレッドが安全な位置で稼働しているかを追跡する
- Active Recordのクエリキャッシュを有効/無効する
- Active Recordのコネクションをコネクションプールにリリースする
- 内部キャッシュの制限

詳しい説明は、[`Rails Guide`](https://guides.rubyonrails.org/threading_and_code_execution.html#executor)に書いてある。

### `ActionDispatch::Executor` / `ActiveSupport::ExecutionWrapper`
脆弱性の概要で、「`ActionDispatch::Executor`がどうのこうの」と言われていたやつ。
前述の`Executor`と、後続のRackミドルウェアを指定することで
実態は[`rails/actionpack/lib/action_dispatch/middleware/executor.rb:7`](https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_dispatch/middleware/executor.rb#L7)にある。

`ActiveSupport::ExecutionWrapper`は`ActionDispatch::Executor`から呼ばれ、実際に`Executor`として振る舞うクラス。
[`rails/activesuport/lib/active_support/execution_wrapper.rb`](https://github.com/rails/rails/blob/3c0420c431954d05fef2015a27281069e68cce0c/activesupport/lib/active_support/execution_wrapper.rb)にある。
前述の`Executor`

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

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

### `ActiveSupport::CurrentAttributes`
[`rails/activesupport/lib/active_support/current_attribute.rb`](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/current_attributes.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`](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/current_attributes.rb#L163)は、内部的に前述の`IsolatedExecutionState`を参照している。
```ruby
def current_instances
  IsolatedExecutionState[:current_attributes_instances] ||= {}
end
```

# サンプルコードを書いて試してみる
今回もパッチ適用後のテストコードがあるので、それを見ながら変な挙動を起こしそうなサンプルコードを書いてみる。
`Rails`本体から切り離しても動くように分解して雑に書き換え。
```ruby
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}"
```
⬇ テストコードの通りならこんな出力が期待される。
```shell
$ bundle exec ruby ./sample.rb
requests_count: 5
total         : 9
ran           : 5
completed     : 4
```

⬇ ところが、パッチ適用前の`Rails`で実行した出力はこう。
```shell
$ 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`](https://github.com/ruby/debug)を使っていく。
起動方法は簡単で、デバッグを開始したい任意の場所に`debugger`メソッドを埋め込むだけで、そこを通過したタイミングでデバッガが起動してくれるようになる。
あとは好きな所にブレークポイントを入れたりすればオッケー。
```ruby
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](https://github.com/ruby/debug#how-to-use)を参照。
コマンドセットなどは`gdb`のそれを踏襲した感じなので、`gdb`を使える人なら感覚的にいけるはず。

### ループ1周目
まずは起動一発目のループから。
```shell
$ 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_run`も`to_complete`も呼ばれず5回空回るだけ

### `to_complete`が呼ばれない
この **`to_complete`が呼ばれない** という現象が割ととんでもなく、実際の`Rails`のコードでは`to_complete`でいろいろとスレッドの内部状態をリセットしていたりするもので、これが呼ばれなければ次のリクエストの処理に使い回されてしまう。
例えば、[`rails/activesuport/lib/active_support/railtie.rb:37`](https://github.com/rails/rails/blob/3c0420c431954d05fef2015a27281069e68cce0c/activesupport/lib/active_support/railtie.rb#L37)とか。
```ruby
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`サンプルコードでも使ってみると、
```ruby
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`が呼ばれてリセットされてほしいわけだが、こんな感じでリクエストごとにそのまま値が引き回されてしまう。
今回は数値を増やしているだけだが、値によってはとんでもないことになりそう。
```shell
$ 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`プロジェクトを用意。
```shell
# 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
...
```

### デバッガを仕込む
サンプルコード同様にデバッガを仕込む。
```shell
$ 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)`コマンドを使ってリクエスト受信からコントローラにたどり着くまでの呼び出し経路を見てみる。(結構長かったので一部省略)

```shell
(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::Executor`1箇所目
   f. ロギング
   g. `ActionDispatch::RequestId#call`によるRequest IDの発行
   h. `ActionDispatch::RemoteIp#call`によるアクセス元のIPの解決(プロキシ環境の場合など)
   i. `ActionDispatch::Executor#call` 👈 `ActionDispatch::Executor`2箇所目
   j. `ActionDispatch::ContentSecurityPolicy::Middleware#call`による`Content-Security-Policy`の検証
   k. などなど...
5. `ActionDispatch::Routing::RouteSet#call`→`ActionDispatch::Jorney::Router#serve`→`ActionDispatch::Routing::RouteSet::Dispatcher#serve`でコントローラが解決され処理が引き渡される
6. いろいろあって`ActionController::Rendering#process_action`とか経由し、ユーザー定義のコントローラまでたどり着く
7. この後コールスタックを戻っていく過程で`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](https://github.com/puma/puma/pull/2812)が出ているのでこちらを参照。

# 最後に
[OpenCVEの記載](https://www.opencve.io/cve/CVE-2022-23633)にもある通り、`Attack Complexity`が`HIGH`にされているだけあって割と発生条件も限定的というのもあって、最後の方は若干力尽きてたところもあったが、`Rails`の仕組みについてはかなり勉強になったので良かった。
今回は`Action Pack`周りがメインだったが、`Action View`/`Active Record`辺りも見てみたいと思った。

# Appendix
- [NIST](https://nvd.nist.gov/vuln/detail/CVE-2022-23633)
- パッチ
  - https://github.com/rails/rails/commit/f9a2ad03943d5c2ba54e1d45f155442b519c75da
  - https://github.com/rails/rails/commit/3c0420c431954d05fef2015a27281069e68cce0c