脆弱性から学ぶRailsの仕組み(CVE-2022-23633編)
少し前にやった脆弱性からRubyの仕組みを学ぶやつをRails
でもやってみる記事。
前回同様「脆弱性の原理を把握する」という側面から、実際に脆弱性を試しつつ「その本体の仕組み」も学んでしまおうという趣旨。
Rails
も業務で使うようになったので、これもちゃんと学んでみようと思う。
対象の読者
この記事の対象となる人はこんな感じで想定。
-
Rails
を使い始めたばかりで仕組みから学びたい人 -
Rails
はよく使ってるけど仕組みはよくわからないから知りたいという人
自分のようなRails
入門したてホヤホヤの方にも勉強になれば嬉しいので、自分の理解度整理も含めて「Action Pack
って何?」みたいな説明を軽くする箇所もあるためその辺はご了承ください。
Ruby on Rails
さっきから「Rails、れいるず」と言っているもの。
言わずとしれたRubyのWebアプリケーションフレームワークの1つ。
「Rails
?あー、ピザのことね。」という方はWikipediaを参照。
脆弱性
これはRubyの記事で記載したので割愛。
気になる方は以下の 脆弱性からRubyの仕組みを学んだ記事 を参照。
https://qiita.com/0kate/items/e0209cd83b4992d75b19
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 Rails
はMVC
パターンに準拠したフレームワークなわけだが、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/controllers
にActionController::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::ExecutionWrapper
はActionDispatch::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_run
もto_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
によってルーティングやクッキーが処理されていたりする様子がよくわかって感動。
ちょっと見やすくするために、スタックを逆さまにして上から順番に箇条書きにするとこんな感じ。
-
Puma
がバインドされているポートにリクエストが届き、Puma::Server#process_client
が呼ばれる
a.Puma::Request#handle_request
で、リクエストの処理に移る -
Puma
からRails
に処理が移る(Rackインターフェースに準拠したRails::Engine#call
が呼ばれる) -
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. などなど... -
ActionDispatch::Routing::RouteSet#call
→ActionDispatch::Jorney::Router#serve
→ActionDispatch::Routing::RouteSet::Dispatcher#serve
でコントローラが解決され処理が引き渡される - いろいろあって
ActionController::Rendering#process_action
とか経由し、ユーザー定義のコントローラまでたどり着く - この後コールスタックを戻っていく過程で
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 Complexity
がHIGH
にされているだけあって割と発生条件も限定的というのもあって、最後の方は若干力尽きてたところもあったが、Rails
の仕組みについてはかなり勉強になったので良かった。
今回はAction Pack
周りがメインだったが、Action View
/Active Record
辺りも見てみたいと思った。